Initial marketplace implementation
Full-stack marketplace for buying/selling second-hand items. React 19 + TypeScript + Tailwind CSS v4 frontend with 17 screens, Express + Prisma + Socket.io backend, Stripe payments, JWT auth. Deployed at https://marketplace.173.212.212.157.sslip.io/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
13
.claude/settings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"figma": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://mcp.figma.com/mcp"
|
||||||
|
},
|
||||||
|
"chrome-devtools": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics", "--isolated"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
server/.env
|
||||||
|
server/uploads/
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
server/prisma/*.db
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
BIN
MARKETPLACE/buy_item.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
MARKETPLACE/categorymenu.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
MARKETPLACE/createuserprofile.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
MARKETPLACE/edit_item_info.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
MARKETPLACE/footer.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
MARKETPLACE/hero3333.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
MARKETPLACE/log in.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
MARKETPLACE/makeoffer.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
MARKETPLACE/memberchat.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
MARKETPLACE/notifications.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
MARKETPLACE/offers.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
MARKETPLACE/payment.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
MARKETPLACE/sell_item.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
MARKETPLACE/settings.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
MARKETPLACE/sign up.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
MARKETPLACE/sold_items.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
MARKETPLACE/updateprofile.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
client/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
16
client/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Marketplace - Buy & Sell Second-Hand Items</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
client/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "marketplace-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.0",
|
||||||
|
"socket.io-client": "^4.8.0",
|
||||||
|
"@stripe/stripe-js": "^5.0.0",
|
||||||
|
"@stripe/react-stripe-js": "^3.1.0",
|
||||||
|
"lucide-react": "^0.469.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "~5.7.0",
|
||||||
|
"typescript-eslint": "^8.18.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
client/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
15
client/src/App.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Header } from './components/layout/Header';
|
||||||
|
import { Footer } from './components/layout/Footer';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-background">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
client/src/api/client.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
|
||||||
|
setToken(token: string | null) {
|
||||||
|
this.accessToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers as Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||||
|
throw new Error(error.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(path: string) { return this.request<T>(path); }
|
||||||
|
|
||||||
|
post<T>(path: string, body?: unknown) {
|
||||||
|
return this.request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
put<T>(path: string, body?: unknown) {
|
||||||
|
return this.request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
patch<T>(path: string, body?: unknown) {
|
||||||
|
return this.request<T>(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete<T>(path: string) {
|
||||||
|
return this.request<T>(path, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload<T>(path: string, formData: FormData): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (this.accessToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Upload failed' }));
|
||||||
|
throw new Error(error.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiClient();
|
||||||
36
client/src/components/layout/DashboardLayout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
import { MessageSquare, Tag, Bell, ShoppingBag, Settings } from 'lucide-react';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/dashboard/messages', icon: MessageSquare, label: 'Messages' },
|
||||||
|
{ to: '/dashboard/offers', icon: Tag, label: 'Offers' },
|
||||||
|
{ to: '/dashboard/notifications', icon: Bell, label: 'Notifications' },
|
||||||
|
{ to: '/dashboard/sold', icon: ShoppingBag, label: 'Sold Items' },
|
||||||
|
{ to: '/dashboard/settings', icon: Settings, label: 'Settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardLayout() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="hidden md:block w-56 flex-shrink-0">
|
||||||
|
<nav className="bg-white rounded-2xl border border-gray-100 p-3 sticky top-24">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink key={to} to={to}
|
||||||
|
className={({ isActive }) => `flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors ${isActive ? 'bg-primary-50 text-primary-700' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex-1 min-w-0">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
client/src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Facebook, Twitter, Instagram, Youtube, Package } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="bg-primary-900 text-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-12">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8">
|
||||||
|
{/* Brand */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-pink-500 to-primary-400 flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold">MARKETPLACE</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-primary-200 text-sm mb-6 max-w-sm">
|
||||||
|
Discover great deals on unique pre-loved items. Buy and sell second-hand items online.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold mb-2">Stay Updated</p>
|
||||||
|
<p className="text-xs text-primary-300 mb-3">Subscribe to our newsletter for tips and special offers</p>
|
||||||
|
<form className="flex gap-2">
|
||||||
|
<input type="email" placeholder="Your email address" className="flex-1 px-3 py-2 rounded-lg bg-primary-800 border border-primary-700 text-sm placeholder:text-primary-400 focus:outline-none focus:border-primary-500" />
|
||||||
|
<button type="button" className="px-4 py-2 bg-gradient-to-r from-pink-500 to-primary-500 rounded-lg text-sm font-semibold hover:from-pink-600 hover:to-primary-600 transition-all cursor-pointer">
|
||||||
|
Subscribe
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marketplace */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">Marketplace</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-primary-200">
|
||||||
|
<li><Link to="/" className="hover:text-white transition-colors">Home</Link></li>
|
||||||
|
<li><Link to="/?category=all" className="hover:text-white transition-colors">Browse</Link></li>
|
||||||
|
<li><Link to="/sell" className="hover:text-white transition-colors">Sell Your Item</Link></li>
|
||||||
|
<li><Link to="/dashboard/offers" className="hover:text-white transition-colors">My Listings</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Support */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">Support</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-primary-200">
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Help & Support</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Contact Us</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Returns & Conditions</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">About</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-primary-200">
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">About Us</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className="font-semibold mb-3 text-sm">Follow Us</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<a href="#" className="p-2 bg-primary-800 rounded-lg hover:bg-primary-700 transition-colors"><Facebook className="w-4 h-4" /></a>
|
||||||
|
<a href="#" className="p-2 bg-primary-800 rounded-lg hover:bg-primary-700 transition-colors"><Twitter className="w-4 h-4" /></a>
|
||||||
|
<a href="#" className="p-2 bg-primary-800 rounded-lg hover:bg-primary-700 transition-colors"><Instagram className="w-4 h-4" /></a>
|
||||||
|
<a href="#" className="p-2 bg-primary-800 rounded-lg hover:bg-primary-700 transition-colors"><Youtube className="w-4 h-4" /></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 pt-6 border-t border-primary-800 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-xs text-primary-400">© 2024 Marketplace. All Rights Reserved.</p>
|
||||||
|
<div className="flex gap-4 text-xs text-primary-400">
|
||||||
|
<a href="#" className="hover:text-white transition-colors">Terms of Service</a>
|
||||||
|
<a href="#" className="hover:text-white transition-colors">Privacy Policy</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
client/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Search, Bell, Menu, X, User, LogOut, Package, Heart, MessageSquare, Settings } from 'lucide-react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { user, isAuthenticated, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||||
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
navigate(`/?search=${encodeURIComponent(searchQuery.trim())}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white border-b border-gray-100 sticky top-0 z-40">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* Logo */}
|
||||||
|
<Link to="/" className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-pink-500 to-primary-600 flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold bg-gradient-to-r from-primary-600 to-pink-500 bg-clip-text text-transparent hidden sm:block">
|
||||||
|
MARKETPLACE
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<form onSubmit={handleSearch} className="hidden md:flex flex-1 max-w-lg mx-6">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for items..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 rounded-xl border border-gray-200 bg-gray-50 text-sm
|
||||||
|
focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Link to="/sell" className="hidden sm:inline-flex items-center px-4 py-2 text-sm font-semibold text-white rounded-xl bg-gradient-to-r from-pink-500 to-primary-600 hover:from-pink-600 hover:to-primary-700 transition-all">
|
||||||
|
Sell Item
|
||||||
|
</Link>
|
||||||
|
<Link to="/dashboard/messages" className="p-2 rounded-lg hover:bg-gray-100 transition-colors relative">
|
||||||
|
<MessageSquare className="w-5 h-5 text-gray-600" />
|
||||||
|
</Link>
|
||||||
|
<Link to="/dashboard/notifications" className="p-2 rounded-lg hover:bg-gray-100 transition-colors relative">
|
||||||
|
<Bell className="w-5 h-5 text-gray-600" />
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 bg-pink-500 rounded-full" />
|
||||||
|
</Link>
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={() => setShowUserMenu(!showUserMenu)} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Avatar name={user?.fullName || 'User'} src={user?.avatar} size="sm" />
|
||||||
|
</button>
|
||||||
|
{showUserMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0" onClick={() => setShowUserMenu(false)} />
|
||||||
|
<div className="absolute right-0 mt-2 w-56 bg-white rounded-xl shadow-lg border border-gray-100 py-2 z-50">
|
||||||
|
<div className="px-4 py-2 border-b border-gray-100">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{user?.fullName}</p>
|
||||||
|
<p className="text-xs text-gray-500">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/profile/edit" onClick={() => setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||||
|
<User className="w-4 h-4" /> Profile
|
||||||
|
</Link>
|
||||||
|
<Link to="/dashboard/offers" onClick={() => setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||||
|
<Package className="w-4 h-4" /> My Offers
|
||||||
|
</Link>
|
||||||
|
<Link to="/dashboard/sold" onClick={() => setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||||
|
<Heart className="w-4 h-4" /> Sold Items
|
||||||
|
</Link>
|
||||||
|
<Link to="/dashboard/settings" onClick={() => setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||||
|
<Settings className="w-4 h-4" /> Settings
|
||||||
|
</Link>
|
||||||
|
<hr className="my-1 border-gray-100" />
|
||||||
|
<button onClick={() => { logout(); setShowUserMenu(false); }} className="flex items-center gap-3 px-4 py-2 text-sm text-red-600 hover:bg-red-50 w-full cursor-pointer">
|
||||||
|
<LogOut className="w-4 h-4" /> Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link to="/login" className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors">
|
||||||
|
Log In
|
||||||
|
</Link>
|
||||||
|
<Link to="/signup" className="px-4 py-2 text-sm font-semibold text-white rounded-xl bg-gradient-to-r from-pink-500 to-primary-600 hover:from-pink-600 hover:to-primary-700 transition-all">
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setShowMobileMenu(!showMobileMenu)} className="md:hidden p-2 rounded-lg hover:bg-gray-100 cursor-pointer">
|
||||||
|
{showMobileMenu ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu */}
|
||||||
|
{showMobileMenu && (
|
||||||
|
<div className="md:hidden pb-4 border-t border-gray-100 pt-4">
|
||||||
|
<form onSubmit={handleSearch} className="mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input type="text" placeholder="Search..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 rounded-xl border border-gray-200 bg-gray-50 text-sm focus:border-primary-400 focus:outline-none" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<Link to="/sell" onClick={() => setShowMobileMenu(false)}
|
||||||
|
className="block w-full text-center px-4 py-2 text-sm font-semibold text-white rounded-xl bg-gradient-to-r from-pink-500 to-primary-600 mb-2">
|
||||||
|
Sell Item
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
client/src/components/layout/RequireAuth.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
4
client/src/components/layout/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { Header } from './Header';
|
||||||
|
export { Footer } from './Footer';
|
||||||
|
export { DashboardLayout } from './DashboardLayout';
|
||||||
|
export { RequireAuth } from './RequireAuth';
|
||||||
45
client/src/components/listings/CategorySidebar.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Smartphone, Armchair, Shirt, Home, Dumbbell, BookOpen, Gamepad2, Car, Package } from 'lucide-react';
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ value: 'ELECTRONICS', label: 'Electronics', icon: Smartphone },
|
||||||
|
{ value: 'FURNITURE', label: 'Furniture', icon: Armchair },
|
||||||
|
{ value: 'CLOTHING', label: 'Clothing & Shoes', icon: Shirt },
|
||||||
|
{ value: 'HOME_GARDEN', label: 'Home & Garden', icon: Home },
|
||||||
|
{ value: 'SPORTS', label: 'Sports & Outdoors', icon: Dumbbell },
|
||||||
|
{ value: 'BOOKS', label: 'Books', icon: BookOpen },
|
||||||
|
{ value: 'GAMES', label: 'Games', icon: Gamepad2 },
|
||||||
|
{ value: 'VEHICLES', label: 'Vehicles', icon: Car },
|
||||||
|
{ value: 'OTHER', label: 'Other', icon: Package },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CategorySidebarProps {
|
||||||
|
selected?: string;
|
||||||
|
onSelect: (category: string | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategorySidebar({ selected, onSelect }: CategorySidebarProps) {
|
||||||
|
return (
|
||||||
|
<nav className="bg-white rounded-2xl border border-gray-100 p-3">
|
||||||
|
<h3 className="px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">Categories</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect(undefined)}
|
||||||
|
className={`flex items-center gap-3 w-full px-3 py-2 rounded-xl text-sm font-medium transition-colors cursor-pointer
|
||||||
|
${!selected ? 'bg-primary-50 text-primary-700' : 'text-gray-600 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
All Categories
|
||||||
|
</button>
|
||||||
|
{categories.map(({ value, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => onSelect(value)}
|
||||||
|
className={`flex items-center gap-3 w-full px-3 py-2 rounded-xl text-sm font-medium transition-colors cursor-pointer
|
||||||
|
${selected === value ? 'bg-primary-50 text-primary-700' : 'text-gray-600 hover:bg-gray-50'}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
client/src/components/listings/ListingCard.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Heart, MapPin } from 'lucide-react';
|
||||||
|
import { Badge } from '../ui/Badge';
|
||||||
|
import type { Listing } from '../../types';
|
||||||
|
import { formatCurrency } from '../../utils/format';
|
||||||
|
|
||||||
|
interface ListingCardProps {
|
||||||
|
listing: Listing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListingCard({ listing }: ListingCardProps) {
|
||||||
|
const [isFav, setIsFav] = useState(listing.isFavorited ?? false);
|
||||||
|
|
||||||
|
const conditionVariant = listing.condition === 'NEW' ? 'success'
|
||||||
|
: listing.condition === 'LIKE_NEW' ? 'info'
|
||||||
|
: 'default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={`/listings/${listing.id}`} className="group block">
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
|
||||||
|
{/* Image */}
|
||||||
|
<div className="relative aspect-square bg-gray-100">
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary-50 to-pink-50">
|
||||||
|
<span className="text-4xl">
|
||||||
|
{listing.category === 'FURNITURE' ? '\uD83E\uDE91' :
|
||||||
|
listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' :
|
||||||
|
listing.category === 'CLOTHING' ? '\uD83D\uDC55' :
|
||||||
|
listing.category === 'HOME_GARDEN' ? '\u2615' :
|
||||||
|
listing.category === 'SPORTS' ? '\uD83D\uDEB4' :
|
||||||
|
listing.category === 'BOOKS' ? '\uD83D\uDCDA' :
|
||||||
|
listing.category === 'GAMES' ? '\uD83C\uDFAE' : '\uD83D\uDCE6'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); setIsFav(!isFav); }}
|
||||||
|
className="absolute top-2 right-2 p-1.5 bg-white/80 backdrop-blur rounded-full hover:bg-white transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Heart className={`w-4 h-4 ${isFav ? 'fill-pink-500 text-pink-500' : 'text-gray-400'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 truncate group-hover:text-primary-600 transition-colors">
|
||||||
|
{listing.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-lg font-bold text-primary-600 mt-1">
|
||||||
|
{formatCurrency(listing.price)}
|
||||||
|
{listing.obo && <span className="text-xs font-normal text-gray-400 ml-1">OBO</span>}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
<Badge variant={conditionVariant}>{listing.condition.replace('_', ' ')}</Badge>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{listing.location}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
client/src/components/listings/ListingGrid.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ListingCard } from './ListingCard';
|
||||||
|
import type { Listing } from '../../types';
|
||||||
|
|
||||||
|
interface ListingGridProps {
|
||||||
|
listings: Listing[];
|
||||||
|
title?: string;
|
||||||
|
showViewAll?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListingGrid({ listings, title, showViewAll }: ListingGridProps) {
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
||||||
|
{showViewAll && (
|
||||||
|
<button className="text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors cursor-pointer">
|
||||||
|
View All >
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{listings.map(listing => (
|
||||||
|
<ListingCard key={listing.id} listing={listing} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
client/src/components/listings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { ListingCard } from './ListingCard';
|
||||||
|
export { ListingGrid } from './ListingGrid';
|
||||||
|
export { CategorySidebar } from './CategorySidebar';
|
||||||
26
client/src/components/ui/Avatar.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
interface AvatarProps {
|
||||||
|
src?: string;
|
||||||
|
name: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ src, name, size = 'md' }: AvatarProps) {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'w-8 h-8 text-xs',
|
||||||
|
md: 'w-10 h-10 text-sm',
|
||||||
|
lg: 'w-14 h-14 text-lg',
|
||||||
|
xl: 'w-20 h-20 text-2xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials = name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
return <img src={src} alt={name} className={`${sizes[size]} rounded-full object-cover`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${sizes[size]} rounded-full bg-gradient-to-br from-primary-400 to-primary-600 text-white flex items-center justify-center font-semibold flex-shrink-0`}>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
client/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ children, variant = 'default', size = 'sm' }: BadgeProps) {
|
||||||
|
const variants = {
|
||||||
|
default: 'bg-gray-100 text-gray-600',
|
||||||
|
success: 'bg-green-100 text-green-700',
|
||||||
|
warning: 'bg-amber-100 text-amber-700',
|
||||||
|
error: 'bg-red-100 text-red-700',
|
||||||
|
info: 'bg-primary-100 text-primary-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-2 py-0.5 text-xs',
|
||||||
|
md: 'px-3 py-1 text-sm',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center font-medium rounded-full ${variants[variant]} ${sizes[size]}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
client/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
children: ReactNode;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ variant = 'primary', size = 'md', children, isLoading, className = '', disabled, ...props }: ButtonProps) {
|
||||||
|
const baseStyles = 'inline-flex items-center justify-center font-semibold rounded-xl transition-all duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800',
|
||||||
|
secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||||
|
outline: 'border-2 border-primary-300 text-primary-600 hover:bg-primary-50',
|
||||||
|
ghost: 'text-gray-600 hover:bg-gray-100',
|
||||||
|
danger: 'bg-red-500 text-white hover:bg-red-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-5 py-2.5 text-sm',
|
||||||
|
lg: 'px-6 py-3 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
client/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||||
|
hover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, padding = 'md', hover = false, className = '', ...props }: CardProps) {
|
||||||
|
const paddings = {
|
||||||
|
none: '',
|
||||||
|
sm: 'p-3',
|
||||||
|
md: 'p-5',
|
||||||
|
lg: 'p-6',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white rounded-2xl border border-gray-100 shadow-sm ${paddings[padding]}
|
||||||
|
${hover ? 'hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 cursor-pointer' : ''} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
client/src/components/ui/GradientButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface GradientButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
children: ReactNode;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GradientButton({ size = 'md', children, isLoading, className = '', disabled, ...props }: GradientButtonProps) {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-4 py-2 text-sm',
|
||||||
|
md: 'px-6 py-3 text-sm',
|
||||||
|
lg: 'px-8 py-3.5 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center justify-center font-semibold rounded-xl text-white transition-all duration-200 cursor-pointer
|
||||||
|
bg-gradient-to-r from-pink-500 to-primary-600 hover:from-pink-600 hover:to-primary-700
|
||||||
|
shadow-lg shadow-primary-500/25 hover:shadow-xl hover:shadow-primary-500/30
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed ${sizes[size]} ${className}`}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
client/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, icon, className = '', ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && <label className="block text-sm font-medium text-gray-700 mb-1.5">{label}</label>}
|
||||||
|
<div className="relative">
|
||||||
|
{icon && <div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">{icon}</div>}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={`w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900
|
||||||
|
placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none
|
||||||
|
transition-all duration-200 ${icon ? 'pl-10' : ''} ${error ? 'border-red-300 focus:border-red-400 focus:ring-red-100' : ''} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
46
client/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
<div className={`relative w-full ${sizes[size]} bg-white rounded-2xl shadow-2xl p-6 max-h-[90vh] overflow-y-auto`}>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
||||||
|
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer">
|
||||||
|
<X className="w-5 h-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
client/src/components/ui/Toggle.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
interface ToggleProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle({ checked, onChange, label, description }: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center justify-between cursor-pointer group">
|
||||||
|
{(label || description) && (
|
||||||
|
<div className="mr-3">
|
||||||
|
{label && <div className="text-sm font-medium text-gray-700">{label}</div>}
|
||||||
|
{description && <div className="text-xs text-gray-500">{description}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 cursor-pointer
|
||||||
|
${checked ? 'bg-green-500' : 'bg-gray-300'}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 shadow-sm
|
||||||
|
${checked ? 'translate-x-6' : 'translate-x-1'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
client/src/components/ui/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { Button } from './Button';
|
||||||
|
export { GradientButton } from './GradientButton';
|
||||||
|
export { Input } from './Input';
|
||||||
|
export { Modal } from './Modal';
|
||||||
|
export { Card } from './Card';
|
||||||
|
export { Avatar } from './Avatar';
|
||||||
|
export { Toggle } from './Toggle';
|
||||||
|
export { Badge } from './Badge';
|
||||||
72
client/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
|
||||||
|
import type { User } from '../types';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
signup: (data: { fullName: string; email: string; password: string }) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (data: Partial<User>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) {
|
||||||
|
api.setToken(token);
|
||||||
|
api.get<{ user: User }>('/auth/me')
|
||||||
|
.then(({ user }) => setUser(user))
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
api.setToken(null);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const { user, accessToken } = await api.post<{ user: User; accessToken: string }>('/auth/login', { email, password });
|
||||||
|
localStorage.setItem('accessToken', accessToken);
|
||||||
|
api.setToken(accessToken);
|
||||||
|
setUser(user);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signup = useCallback(async (data: { fullName: string; email: string; password: string }) => {
|
||||||
|
const { user, accessToken } = await api.post<{ user: User; accessToken: string }>('/auth/register', data);
|
||||||
|
localStorage.setItem('accessToken', accessToken);
|
||||||
|
api.setToken(accessToken);
|
||||||
|
setUser(user);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
api.setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateUser = useCallback((data: Partial<User>) => {
|
||||||
|
setUser(prev => prev ? { ...prev, ...data } : null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isLoading, login, signup, logout, updateUser }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return context;
|
||||||
|
}
|
||||||
55
client/src/index.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter', sans-serif;
|
||||||
|
|
||||||
|
--color-primary-50: #f5f3ff;
|
||||||
|
--color-primary-100: #ede9fe;
|
||||||
|
--color-primary-200: #ddd6fe;
|
||||||
|
--color-primary-300: #c4b5fd;
|
||||||
|
--color-primary-400: #a78bfa;
|
||||||
|
--color-primary-500: #8b5cf6;
|
||||||
|
--color-primary-600: #7c3aed;
|
||||||
|
--color-primary-700: #6d28d9;
|
||||||
|
--color-primary-800: #5b21b6;
|
||||||
|
--color-primary-900: #4c1d95;
|
||||||
|
|
||||||
|
--color-pink-400: #f472b6;
|
||||||
|
--color-pink-500: #ec4899;
|
||||||
|
--color-pink-600: #db2777;
|
||||||
|
|
||||||
|
--color-accent: #E994B8;
|
||||||
|
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-background: #f3f0ff;
|
||||||
|
--color-text: #1f2937;
|
||||||
|
--color-text-secondary: #6b7280;
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-error: #ef4444;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
14
client/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import { router } from './router';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
104
client/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
import { Avatar } from '../components/ui/Avatar';
|
||||||
|
import { mockConversations, mockMessages, mockCurrentUser } from '../utils/mockData';
|
||||||
|
import { formatDate, formatCurrency } from '../utils/format';
|
||||||
|
|
||||||
|
export function ChatPage() {
|
||||||
|
const [selectedConv, setSelectedConv] = useState(mockConversations[0]?.id);
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
const messages = mockMessages.filter(m => m.conversationId === selectedConv);
|
||||||
|
const activeConv = mockConversations.find(c => c.id === selectedConv);
|
||||||
|
const otherUser = activeConv
|
||||||
|
? (activeConv.user1.id === mockCurrentUser.id ? activeConv.user2 : activeConv.user1)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleSend = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newMessage.trim()) return;
|
||||||
|
setNewMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden" style={{ height: 'calc(100vh - 200px)' }}>
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Conversation List */}
|
||||||
|
<div className="w-72 border-r border-gray-100 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-gray-100">
|
||||||
|
<h2 className="font-bold text-gray-900">Messages</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{mockConversations.map(conv => {
|
||||||
|
const other = conv.user1.id === mockCurrentUser.id ? conv.user2 : conv.user1;
|
||||||
|
return (
|
||||||
|
<button key={conv.id} onClick={() => setSelectedConv(conv.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors cursor-pointer
|
||||||
|
${selectedConv === conv.id ? 'bg-primary-50' : ''}`}>
|
||||||
|
<Avatar name={other.fullName} size="md" />
|
||||||
|
<div className="flex-1 min-w-0 text-left">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-gray-900 truncate">{other.nickname || other.fullName}</span>
|
||||||
|
<span className="text-xs text-gray-400 flex-shrink-0">{conv.lastMessage ? formatDate(conv.lastMessage.createdAt) : ''}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{conv.lastMessage?.content}</p>
|
||||||
|
</div>
|
||||||
|
{conv.unreadCount > 0 && (
|
||||||
|
<span className="w-5 h-5 bg-pink-500 text-white text-xs rounded-full flex items-center justify-center flex-shrink-0">{conv.unreadCount}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat Area */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{activeConv ? (
|
||||||
|
<>
|
||||||
|
{/* Chat Header */}
|
||||||
|
<div className="p-4 border-b border-gray-100 flex items-center gap-3">
|
||||||
|
<Avatar name={otherUser?.fullName || ''} size="sm" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">{otherUser?.nickname || otherUser?.fullName}</h3>
|
||||||
|
{activeConv.listing && (
|
||||||
|
<p className="text-xs text-gray-500">{activeConv.listing.title} · {formatCurrency(activeConv.listing.price)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{messages.map(msg => {
|
||||||
|
const isMe = msg.senderId === mockCurrentUser.id;
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`max-w-xs px-4 py-2.5 rounded-2xl text-sm
|
||||||
|
${isMe ? 'bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-br-md' : 'bg-gray-100 text-gray-900 rounded-bl-md'}`}>
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
<p className={`text-xs mt-1 ${isMe ? 'text-primary-200' : 'text-gray-400'}`}>{formatDate(msg.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<form onSubmit={handleSend} className="p-4 border-t border-gray-100 flex gap-3">
|
||||||
|
<input type="text" value={newMessage} onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 text-sm focus:border-primary-400 focus:outline-none" />
|
||||||
|
<button type="submit" className="p-2.5 rounded-xl bg-gradient-to-r from-pink-500 to-primary-600 text-white hover:from-pink-600 hover:to-primary-700 transition-all cursor-pointer">
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||||
|
Select a conversation to start chatting
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
client/src/pages/CreateProfilePage.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { User, Mail, Phone, MapPin, Camera } from 'lucide-react';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { Avatar } from '../components/ui/Avatar';
|
||||||
|
|
||||||
|
export function CreateProfilePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [fullName, setFullName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [location, setLocation] = useState('');
|
||||||
|
const [bio, setBio] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-12">
|
||||||
|
<Card padding="lg">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Create User Profile</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Set up your profile to start buying and selling on the marketplace</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar name={fullName || 'U'} size="xl" />
|
||||||
|
<button type="button" className="absolute -bottom-1 -right-1 p-2 bg-gradient-to-r from-pink-500 to-primary-600 rounded-full text-white shadow-lg cursor-pointer">
|
||||||
|
<Camera className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input label="Full Name" placeholder="Enter your full name" value={fullName} onChange={(e) => setFullName(e.target.value)}
|
||||||
|
icon={<User className="w-4 h-4" />} required />
|
||||||
|
<Input label="Email Address" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
|
icon={<Mail className="w-4 h-4" />} required />
|
||||||
|
<Input label="Phone" type="tel" placeholder="(XXX) XXX-XXXX" value={phone} onChange={(e) => setPhone(e.target.value)}
|
||||||
|
icon={<Phone className="w-4 h-4" />} />
|
||||||
|
<Input label="Location" placeholder="E.g. city, state, zip code" value={location} onChange={(e) => setLocation(e.target.value)}
|
||||||
|
icon={<MapPin className="w-4 h-4" />} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Bio</label>
|
||||||
|
<textarea value={bio} onChange={(e) => setBio(e.target.value)} rows={3}
|
||||||
|
placeholder="Tell us a little about yourself"
|
||||||
|
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none" />
|
||||||
|
</div>
|
||||||
|
<GradientButton type="submit" className="w-full" size="lg">Create Profile</GradientButton>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
client/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { ListingGrid } from '../components/listings/ListingGrid';
|
||||||
|
import { CategorySidebar } from '../components/listings/CategorySidebar';
|
||||||
|
import { mockListings } from '../utils/mockData';
|
||||||
|
|
||||||
|
const categoryTags = ['Furniture', 'Electronics', 'Clothing', 'Books', 'Games'];
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const filtered = selectedCategory
|
||||||
|
? mockListings.filter(l => l.category === selectedCategory)
|
||||||
|
: mockListings;
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
navigate(`/?search=${encodeURIComponent(searchQuery)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="bg-gradient-to-br from-primary-100 via-primary-50 to-pink-50 py-12 sm:py-16">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 text-center">
|
||||||
|
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-gray-900 mb-4">
|
||||||
|
Buy & sell <span className="bg-gradient-to-r from-primary-600 to-pink-500 bg-clip-text text-transparent">second-hand</span> items online
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-lg mb-8 max-w-2xl mx-auto">
|
||||||
|
Discover great deals on unique pre-loved items near you.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSearch} className="max-w-xl mx-auto mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for items..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-4 py-3.5 rounded-2xl border border-gray-200 bg-white text-base shadow-sm
|
||||||
|
focus:border-primary-400 focus:ring-4 focus:ring-primary-100 focus:outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{categoryTags.map(tag => (
|
||||||
|
<button key={tag}
|
||||||
|
onClick={() => setSelectedCategory(tag.toUpperCase().replace(/ /g, '_') === 'CLOTHING' ? 'CLOTHING' : tag.toUpperCase())}
|
||||||
|
className="px-4 py-1.5 rounded-full bg-white/80 backdrop-blur border border-gray-200 text-sm font-medium text-gray-700 hover:bg-primary-50 hover:border-primary-200 hover:text-primary-700 transition-all cursor-pointer">
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
{/* Sidebar - desktop only */}
|
||||||
|
<aside className="hidden lg:block w-56 flex-shrink-0">
|
||||||
|
<div className="sticky top-24">
|
||||||
|
<CategorySidebar selected={selectedCategory} onSelect={setSelectedCategory} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Listings */}
|
||||||
|
<div className="flex-1 space-y-10">
|
||||||
|
<ListingGrid listings={filtered.slice(0, 4)} title="Popular Items" showViewAll />
|
||||||
|
|
||||||
|
<ListingGrid
|
||||||
|
listings={filtered.slice(2, 6)}
|
||||||
|
title="Trending Items"
|
||||||
|
showViewAll
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListingGrid
|
||||||
|
listings={filtered.slice(4, 8)}
|
||||||
|
title="Selling Hot This Week"
|
||||||
|
showViewAll
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListingGrid
|
||||||
|
listings={filtered.slice(0, 4).reverse()}
|
||||||
|
title="Newly Listed"
|
||||||
|
showViewAll
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
client/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Mail, Lock, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[80vh] flex items-center justify-center px-4 py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-3xl shadow-xl p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Log In</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Welcome back! Please sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Login */}
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<Button variant="outline" className="w-full justify-center gap-2" onClick={() => {}}>
|
||||||
|
<img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" alt="Google" className="w-5 h-5" />
|
||||||
|
Log in with Google
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-center gap-2 !border-blue-200 !text-blue-600 hover:!bg-blue-50" onClick={() => {}}>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||||
|
Log in with Facebook
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-200" /></div>
|
||||||
|
<div className="relative flex justify-center text-sm"><span className="px-3 bg-white text-gray-400">or</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-xl bg-red-50 text-red-600 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input label="Email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
|
icon={<Mail className="w-4 h-4" />} required />
|
||||||
|
<div className="relative">
|
||||||
|
<Input label="Password" type={showPassword ? 'text' : 'password'} placeholder="Enter your password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||||
|
icon={<Lock className="w-4 h-4" />} required />
|
||||||
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-[38px] text-gray-400 hover:text-gray-600 cursor-pointer">
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<a href="#" className="text-xs text-primary-600 hover:text-primary-700">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
<GradientButton type="submit" className="w-full" size="lg" isLoading={isLoading}>
|
||||||
|
Log In
|
||||||
|
</GradientButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/signup" className="text-primary-600 font-semibold hover:text-primary-700">Sign Up ></Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
client/src/pages/MyOffersPage.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Badge } from '../components/ui/Badge';
|
||||||
|
import { Avatar } from '../components/ui/Avatar';
|
||||||
|
import { mockOffers } from '../utils/mockData';
|
||||||
|
import { formatCurrency, formatDate } from '../utils/format';
|
||||||
|
|
||||||
|
export function MyOffersPage() {
|
||||||
|
const [sortBy] = useState('newest');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">My Offers</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Review and manage incoming offers on your items</p>
|
||||||
|
</div>
|
||||||
|
<select value={sortBy} className="px-3 py-2 rounded-xl border border-gray-200 text-sm bg-white focus:outline-none">
|
||||||
|
<option value="newest">Sort: Most Recent</option>
|
||||||
|
<option value="price_high">Highest Offer</option>
|
||||||
|
<option value="price_low">Lowest Offer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mockOffers.map(offer => {
|
||||||
|
const savings = offer.listing.price - offer.amount;
|
||||||
|
const statusVariant = offer.status === 'ACCEPTED' ? 'success' : offer.status === 'DECLINED' ? 'error' : offer.status === 'COUNTERED' ? 'warning' : 'info';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={offer.id} className="bg-white rounded-2xl border border-gray-100 p-4 flex items-center gap-4">
|
||||||
|
{/* Item thumbnail */}
|
||||||
|
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-primary-50 to-pink-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-2xl">
|
||||||
|
{offer.listing.category === 'FURNITURE' ? '\uD83E\uDE91' : offer.listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' : '\uD83D\uDCE6'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">{offer.listing.title}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Avatar name={offer.buyer.fullName} size="sm" />
|
||||||
|
<span className="text-xs text-gray-500">{offer.buyer.fullName}</span>
|
||||||
|
<span className="text-xs text-gray-400">{formatDate(offer.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prices */}
|
||||||
|
<div className="text-right flex-shrink-0">
|
||||||
|
<p className="text-xs text-gray-400 line-through">{formatCurrency(offer.listing.price)}</p>
|
||||||
|
<p className="text-lg font-bold text-primary-600">{formatCurrency(offer.amount)}</p>
|
||||||
|
<Badge variant="error" size="sm">-{formatCurrency(savings)}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status / Actions */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{offer.status === 'PENDING' ? (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" size="sm">Accept</Button>
|
||||||
|
<GradientButton size="sm">Counteroffer</GradientButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge variant={statusVariant} size="md">{offer.status}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
client/src/pages/NotificationsPage.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Bell, Check, Heart, Star, MessageSquare, Tag } from 'lucide-react';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { mockNotifications } from '../utils/mockData';
|
||||||
|
import { formatDate } from '../utils/format';
|
||||||
|
import type { NotificationType } from '../types';
|
||||||
|
|
||||||
|
const iconMap: Record<NotificationType, typeof Bell> = {
|
||||||
|
NEW_OFFER: Tag,
|
||||||
|
OFFER_ACCEPTED: Check,
|
||||||
|
OFFER_DECLINED: Bell,
|
||||||
|
ITEM_SOLD: Star,
|
||||||
|
NEW_MESSAGE: MessageSquare,
|
||||||
|
ITEM_FAVORITED: Heart,
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconColorMap: Record<NotificationType, string> = {
|
||||||
|
NEW_OFFER: 'text-primary-500 bg-primary-50',
|
||||||
|
OFFER_ACCEPTED: 'text-green-500 bg-green-50',
|
||||||
|
OFFER_DECLINED: 'text-red-500 bg-red-50',
|
||||||
|
ITEM_SOLD: 'text-yellow-500 bg-yellow-50',
|
||||||
|
NEW_MESSAGE: 'text-blue-500 bg-blue-50',
|
||||||
|
ITEM_FAVORITED: 'text-pink-500 bg-pink-50',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Notifications</h1>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm">Mark All As Read</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mockNotifications.map(notif => {
|
||||||
|
const Icon = iconMap[notif.type] || Bell;
|
||||||
|
const colorClass = iconColorMap[notif.type] || 'text-gray-500 bg-gray-50';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={notif.id} className={`flex items-center gap-4 p-4 rounded-2xl border transition-colors ${notif.isRead ? 'bg-white border-gray-100' : 'bg-primary-50/50 border-primary-100'}`}>
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass}`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`text-sm ${notif.isRead ? 'text-gray-600' : 'text-gray-900 font-medium'}`}>{notif.body}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{formatDate(notif.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
{(notif.type === 'NEW_OFFER' || notif.type === 'OFFER_ACCEPTED') && (
|
||||||
|
<button className="text-xs font-medium text-primary-600 hover:text-primary-700 flex-shrink-0 cursor-pointer">
|
||||||
|
View Offer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!notif.isRead && <div className="w-2 h-2 bg-primary-500 rounded-full flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
client/src/pages/ProductDetailPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag } from 'lucide-react';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { Badge } from '../components/ui/Badge';
|
||||||
|
import { Avatar } from '../components/ui/Avatar';
|
||||||
|
import { Modal } from '../components/ui/Modal';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { mockListings } from '../utils/mockData';
|
||||||
|
import { formatCurrency, formatDate } from '../utils/format';
|
||||||
|
export function ProductDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const listing = mockListings.find(l => l.id === id) || mockListings[0]!;
|
||||||
|
const [isFav, setIsFav] = useState(listing.isFavorited ?? false);
|
||||||
|
const [showOffer, setShowOffer] = useState(false);
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
const [offerAmount, setOfferAmount] = useState('');
|
||||||
|
const [offerMessage, setOfferMessage] = useState('');
|
||||||
|
|
||||||
|
const conditionVariant = listing.condition === 'NEW' ? 'success' : listing.condition === 'LIKE_NEW' ? 'info' : 'default';
|
||||||
|
|
||||||
|
const categoryEmoji = listing.category === 'FURNITURE' ? '\uD83E\uDE91' : listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' : listing.category === 'CLOTHING' ? '\uD83D\uDC55' : listing.category === 'HOME_GARDEN' ? '\u2615' : '\uD83D\uDCE6';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Images */}
|
||||||
|
<div>
|
||||||
|
<div className="aspect-square bg-gradient-to-br from-primary-50 to-pink-50 rounded-2xl flex items-center justify-center mb-4">
|
||||||
|
<span className="text-8xl">{categoryEmoji}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="aspect-square bg-gradient-to-br from-primary-50 to-pink-50 rounded-xl flex items-center justify-center cursor-pointer hover:ring-2 hover:ring-primary-400 transition-all">
|
||||||
|
<span className="text-2xl">{categoryEmoji}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{listing.title}</h1>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<Badge variant={conditionVariant} size="md">{listing.condition.replace('_', ' ')}</Badge>
|
||||||
|
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||||
|
<Eye className="w-4 h-4" /> {listing.viewCount} views
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setIsFav(!isFav)} className="p-2 rounded-xl hover:bg-gray-100 transition-colors cursor-pointer">
|
||||||
|
<Heart className={`w-6 h-6 ${isFav ? 'fill-pink-500 text-pink-500' : 'text-gray-400'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-primary-600 mt-4">
|
||||||
|
{formatCurrency(listing.price)}
|
||||||
|
{listing.obo && <span className="text-sm font-normal text-gray-400 ml-2">or best offer</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<GradientButton className="flex-1" size="lg" onClick={() => setShowOffer(true)}>
|
||||||
|
Make Offer
|
||||||
|
</GradientButton>
|
||||||
|
<Button variant="outline" size="lg" onClick={() => {}}>
|
||||||
|
<MessageSquare className="w-4 h-4 mr-2" /> Message
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seller Info */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar name={listing.seller.fullName} src={listing.seller.avatar} size="lg" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900">{listing.seller.fullName}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||||
|
<span className="text-sm font-medium">{listing.seller.rating}</span>
|
||||||
|
<span className="text-xs text-gray-400">Joined {formatDate(listing.seller.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Card>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Item Description</h3>
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed">{listing.description}</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<Card>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Location</h3>
|
||||||
|
<p className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<MapPin className="w-4 h-4 text-gray-400" /> {listing.location}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex gap-4 text-sm text-gray-400">
|
||||||
|
<button className="flex items-center gap-1 hover:text-gray-600 cursor-pointer"><Share2 className="w-4 h-4" /> Share</button>
|
||||||
|
<button className="flex items-center gap-1 hover:text-gray-600 cursor-pointer"><Flag className="w-4 h-4" /> Report</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Make Offer Modal */}
|
||||||
|
<Modal isOpen={showOffer} onClose={() => setShowOffer(false)} title="Make Offer" size="sm">
|
||||||
|
<p className="text-sm text-gray-500 mb-4">Enter your offer amount and message to the seller</p>
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<Input label="Your Offer" type="number" placeholder="100" value={offerAmount} onChange={(e) => setOfferAmount(e.target.value)} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Message</label>
|
||||||
|
<textarea value={offerMessage} onChange={(e) => setOfferMessage(e.target.value)} rows={3}
|
||||||
|
placeholder="Enter your message to the seller"
|
||||||
|
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" className="flex-1" onClick={() => setShowOffer(false)}>Back</Button>
|
||||||
|
<GradientButton className="flex-1" onClick={() => setShowOffer(false)}>Send Offer</GradientButton>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Edit Item Modal */}
|
||||||
|
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Item Info" size="md">
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<Input label="Title" defaultValue={listing.title} />
|
||||||
|
<Input label="Price" type="number" defaultValue={String(listing.price)} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Condition</label>
|
||||||
|
<select defaultValue={listing.condition} className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm focus:border-primary-400 focus:outline-none">
|
||||||
|
<option value="NEW">New</option><option value="LIKE_NEW">Like New</option><option value="GENTLY_USED">Gently Used</option><option value="USED">Used</option><option value="FAIR">Fair</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Description</label>
|
||||||
|
<textarea defaultValue={listing.description} rows={3} className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm focus:border-primary-400 focus:outline-none resize-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="danger" className="mr-auto">Delete Listing</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setShowEdit(false)}>Cancel</Button>
|
||||||
|
<GradientButton onClick={() => setShowEdit(false)}>Save Changes</GradientButton>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
client/src/pages/SellItemPage.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Upload, X, Camera, DollarSign, MapPin } from 'lucide-react';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { Modal } from '../components/ui/Modal';
|
||||||
|
import { CATEGORIES, CONDITIONS, LISTING_FEE } from '../utils/constants';
|
||||||
|
import { formatCurrency } from '../utils/format';
|
||||||
|
|
||||||
|
export function SellItemPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [price, setPrice] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [condition, setCondition] = useState('');
|
||||||
|
const [location, setLocation] = useState('');
|
||||||
|
const [obo, setObo] = useState(false);
|
||||||
|
const [photos, setPhotos] = useState<string[]>([]);
|
||||||
|
const [showPayment, setShowPayment] = useState(false);
|
||||||
|
|
||||||
|
const handleAddPhoto = () => {
|
||||||
|
if (photos.length < 6) {
|
||||||
|
setPhotos([...photos, `photo-${photos.length + 1}`]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemovePhoto = (index: number) => {
|
||||||
|
setPhotos(photos.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowPayment(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePayment = () => {
|
||||||
|
setShowPayment(false);
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
<Card padding="lg">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Sell Your Item</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-8">Help buyers find your item with a good description</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
{/* Photos */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-3">Photos</h2>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">Add up to 6 photos. First photo will be shown in search results.</p>
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||||||
|
{photos.map((_, i) => (
|
||||||
|
<div key={i} className="relative aspect-square bg-gradient-to-br from-primary-50 to-pink-50 rounded-xl flex items-center justify-center">
|
||||||
|
<Camera className="w-6 h-6 text-primary-300" />
|
||||||
|
<button type="button" onClick={() => handleRemovePhoto(i)}
|
||||||
|
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center cursor-pointer">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{photos.length < 6 && (
|
||||||
|
<button type="button" onClick={handleAddPhoto}
|
||||||
|
className="aspect-square border-2 border-dashed border-gray-200 rounded-xl flex flex-col items-center justify-center gap-1 hover:border-primary-300 hover:bg-primary-50 transition-colors cursor-pointer">
|
||||||
|
<Upload className="w-5 h-5 text-gray-400" />
|
||||||
|
<span className="text-xs text-gray-400">Add</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Details</h2>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Category</label>
|
||||||
|
<select value={category} onChange={(e) => setCategory(e.target.value)} required
|
||||||
|
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none">
|
||||||
|
<option value="">Select a category</option>
|
||||||
|
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Input label="Title" placeholder="What are you selling?" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Condition</label>
|
||||||
|
<select value={condition} onChange={(e) => setCondition(e.target.value)} required
|
||||||
|
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none">
|
||||||
|
<option value="">Select condition</option>
|
||||||
|
{CONDITIONS.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Description</label>
|
||||||
|
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} required
|
||||||
|
placeholder="Describe your item in detail..."
|
||||||
|
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Price</h2>
|
||||||
|
<Input label="Price" type="number" placeholder="0.00" value={price} onChange={(e) => setPrice(e.target.value)} required
|
||||||
|
icon={<DollarSign className="w-4 h-4" />} />
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input type="checkbox" checked={obo} onChange={(e) => setObo(e.target.checked)} className="accent-primary-600" />
|
||||||
|
Or Best Offer (OBO)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 mb-3">Location</h2>
|
||||||
|
<Input placeholder="E.g. city, state, zip code" value={location} onChange={(e) => setLocation(e.target.value)} required
|
||||||
|
icon={<MapPin className="w-4 h-4" />} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GradientButton type="submit" className="w-full" size="lg">
|
||||||
|
Submit Listing
|
||||||
|
</GradientButton>
|
||||||
|
<p className="text-xs text-gray-400 text-center">
|
||||||
|
A one-time fee of {formatCurrency(LISTING_FEE)} will be charged to list your item.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Payment Modal */}
|
||||||
|
<Modal isOpen={showPayment} onClose={() => setShowPayment(false)} title="Payment" size="sm">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<p className="text-sm text-gray-500">Small one-time fee to list your item on the marketplace</p>
|
||||||
|
<p className="text-3xl font-bold text-primary-600 mt-3">{formatCurrency(LISTING_FEE)} <span className="text-sm font-normal text-gray-400">USD</span></p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<Input placeholder="4242 4242 4242 4242" label="Card Number" />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input placeholder="MM / YY" label="Expiry" />
|
||||||
|
<Input placeholder="CVC" label="CVC" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GradientButton className="w-full" size="lg" onClick={handlePayment}>
|
||||||
|
Pay {formatCurrency(LISTING_FEE)}
|
||||||
|
</GradientButton>
|
||||||
|
<p className="text-xs text-gray-400 text-center mt-4">Secure payment powered by Stripe</p>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
client/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Shield, Lock, Eye, Trash2 } from 'lucide-react';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { Toggle } from '../components/ui/Toggle';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
|
|
||||||
|
export function SettingsPage() {
|
||||||
|
const [showOnline, setShowOnline] = useState(true);
|
||||||
|
const [showRating, setShowRating] = useState(true);
|
||||||
|
const [twoFactor, setTwoFactor] = useState(false);
|
||||||
|
const [notifEmail, setNotifEmail] = useState(true);
|
||||||
|
const [marketingEmail, setMarketingEmail] = useState(false);
|
||||||
|
const [cookiePref, setCookiePref] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Privacy & Security Settings</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Manage your privacy, security and data preferences</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Privacy */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center">
|
||||||
|
<Eye className="w-4 h-4 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-semibold text-gray-900">Account Privacy</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Toggle checked={showOnline} onChange={setShowOnline} label="Show Online Status" description="Let others see when you're online" />
|
||||||
|
<Toggle checked={showRating} onChange={setShowRating} label="Show Seller Rating" description="Display your rating on your profile" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">Blocked Users</div>
|
||||||
|
<div className="text-xs text-gray-500">Manage your blocked users list</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">Manage</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Security Settings */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center">
|
||||||
|
<Shield className="w-4 h-4 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-semibold text-gray-900">Security Settings</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Toggle checked={twoFactor} onChange={setTwoFactor} label="Two-Factor Authentication" description="Add an extra layer of security" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">Password Reset</div>
|
||||||
|
<div className="text-xs text-gray-500">Change your account password</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">Change</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">Login Activity</div>
|
||||||
|
<div className="text-xs text-gray-500">View your recent login activity</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">View</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Data & Privacy */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center">
|
||||||
|
<Lock className="w-4 h-4 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-semibold text-gray-900">Data & Privacy</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Toggle checked={notifEmail} onChange={setNotifEmail} label="Notification Emails" description="Receive email notifications" />
|
||||||
|
<Toggle checked={marketingEmail} onChange={setMarketingEmail} label="Marketing Emails" description="Receive promotional emails" />
|
||||||
|
<Toggle checked={cookiePref} onChange={setCookiePref} label="Cookie Preferences" description="Manage cookie settings" />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">Privacy Policy</div>
|
||||||
|
<div className="text-xs text-gray-500">Read our privacy policy</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm">View</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">Terms of Service</div>
|
||||||
|
<div className="text-xs text-gray-500">Read our terms of service</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm">View</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="flex items-center justify-between pt-4">
|
||||||
|
<Button variant="danger" size="md">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" /> Delete Account
|
||||||
|
</Button>
|
||||||
|
<GradientButton size="md">Save Profile</GradientButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
client/src/pages/SignUpPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Mail, Lock, Eye, EyeOff, User } from 'lucide-react';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
|
import { Button } from '../components/ui/Button';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export function SignUpPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { signup } = useAuth();
|
||||||
|
const [fullName, setFullName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await signup({ fullName, email, password });
|
||||||
|
navigate('/profile/create');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Sign up failed');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[80vh] flex items-center justify-center px-4 py-12">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-white rounded-3xl shadow-xl p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Sign Up</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Create your account to start buying and selling</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<Button variant="outline" className="w-full justify-center gap-2" onClick={() => {}}>
|
||||||
|
<img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg" alt="Google" className="w-5 h-5" />
|
||||||
|
Sign up with Google
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-center gap-2 !border-blue-200 !text-blue-600 hover:!bg-blue-50" onClick={() => {}}>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||||
|
Sign up with Facebook
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-200" /></div>
|
||||||
|
<div className="relative flex justify-center text-sm"><span className="px-3 bg-white text-gray-400">or</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="mb-4 p-3 rounded-xl bg-red-50 text-red-600 text-sm">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input label="Full Name" placeholder="Enter your full name" value={fullName} onChange={(e) => setFullName(e.target.value)}
|
||||||
|
icon={<User className="w-4 h-4" />} required />
|
||||||
|
<Input label="Email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
|
icon={<Mail className="w-4 h-4" />} required />
|
||||||
|
<div className="relative">
|
||||||
|
<Input label="Password" type={showPassword ? 'text' : 'password'} placeholder="Create a password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||||
|
icon={<Lock className="w-4 h-4" />} required />
|
||||||
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-[38px] text-gray-400 hover:text-gray-600 cursor-pointer">
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Input label="Confirm Password" type={showPassword ? 'text' : 'password'} placeholder="Confirm your password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
icon={<Lock className="w-4 h-4" />} required />
|
||||||
|
<label className="flex items-start gap-2 text-xs text-gray-500">
|
||||||
|
<input type="checkbox" required className="mt-0.5 accent-primary-600" />
|
||||||
|
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</label>
|
||||||
|
<GradientButton type="submit" className="w-full" size="lg" isLoading={isLoading}>
|
||||||
|
Sign Up
|
||||||
|
</GradientButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-6">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-primary-600 font-semibold hover:text-primary-700">Login ></Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
client/src/pages/SoldItemsPage.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { Avatar } from '../components/ui/Avatar';
|
||||||
|
import { mockSoldItems } from '../utils/mockData';
|
||||||
|
import { formatCurrency, formatDate } from '../utils/format';
|
||||||
|
import { DollarSign, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
export function SoldItemsPage() {
|
||||||
|
const totalValue = mockSoldItems.reduce((sum, item) => sum + item.listing.price, 0);
|
||||||
|
const totalEarnings = mockSoldItems.reduce((sum, item) => sum + item.salePrice, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-primary-50 flex items-center justify-center">
|
||||||
|
<DollarSign className="w-6 h-6 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Total Listing Value</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalValue)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-6 h-6 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">Total Earnings</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalEarnings)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Sold Items</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Manage your previously sold items and track your earnings</p>
|
||||||
|
</div>
|
||||||
|
<select className="px-3 py-2 rounded-xl border border-gray-200 text-sm bg-white focus:outline-none">
|
||||||
|
<option>Sort: Most Recent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||||
|
<div className="hidden sm:grid grid-cols-12 gap-4 px-4 py-3 bg-gray-50 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||||
|
<div className="col-span-4">Item</div>
|
||||||
|
<div className="col-span-2">Listing Price</div>
|
||||||
|
<div className="col-span-2">Sale Price</div>
|
||||||
|
<div className="col-span-2">Buyer</div>
|
||||||
|
<div className="col-span-2">Date</div>
|
||||||
|
</div>
|
||||||
|
{mockSoldItems.map((item, i) => (
|
||||||
|
<div key={i} className="grid grid-cols-12 gap-4 items-center px-4 py-4 border-t border-gray-50 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="col-span-4 flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-50 to-pink-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-xl">
|
||||||
|
{item.listing.category === 'FURNITURE' ? '\uD83E\uDE91' : item.listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' : item.listing.category === 'CLOTHING' ? '\uD83D\uDC55' : '\u2615'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-900 truncate">{item.listing.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm text-gray-500">{formatCurrency(item.listing.price)}</div>
|
||||||
|
<div className="col-span-2 text-sm font-semibold text-green-600">{formatCurrency(item.salePrice)}</div>
|
||||||
|
<div className="col-span-2 flex items-center gap-2">
|
||||||
|
<Avatar name={item.buyer.fullName} size="sm" />
|
||||||
|
<span className="text-sm text-gray-600 truncate">{item.buyer.fullName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 text-sm text-gray-400">{formatDate(item.date)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
client/src/pages/UpdateProfilePage.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Camera } from 'lucide-react';
|
||||||
|
import { Input } from '../components/ui/Input';
|
||||||
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
|
import { Card } from '../components/ui/Card';
|
||||||
|
import { Avatar } from '../components/ui/Avatar';
|
||||||
|
import { Toggle } from '../components/ui/Toggle';
|
||||||
|
import { mockCurrentUser } from '../utils/mockData';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
export function UpdateProfilePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const currentUser = user || mockCurrentUser;
|
||||||
|
|
||||||
|
const [fullName, setFullName] = useState(currentUser.fullName);
|
||||||
|
const [email] = useState(currentUser.email);
|
||||||
|
const [phone, setPhone] = useState(currentUser.phone || '');
|
||||||
|
const [location, setLocation] = useState(currentUser.location || '');
|
||||||
|
const [bio, setBio] = useState(currentUser.bio || '');
|
||||||
|
const [showEmail, setShowEmail] = useState(currentUser.showEmail);
|
||||||
|
const [showPhone, setShowPhone] = useState(currentUser.showPhone);
|
||||||
|
const [showLocation, setShowLocation] = useState(currentUser.showLocation);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto px-4 py-12">
|
||||||
|
<Card padding="lg">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Update Profile Information</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">Edit your profile and update information shown publicly</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar name={fullName} size="xl" />
|
||||||
|
<button type="button" className="absolute -bottom-1 -right-1 p-2 bg-gradient-to-r from-pink-500 to-primary-600 rounded-full text-white shadow-lg cursor-pointer">
|
||||||
|
<Camera className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<Input label="Full Name" value={fullName} onChange={(e) => setFullName(e.target.value)} required />
|
||||||
|
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input label="Email" value={email} disabled />
|
||||||
|
</div>
|
||||||
|
<div className="pb-0.5">
|
||||||
|
<Toggle checked={showEmail} onChange={setShowEmail} label="Public" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input label="Phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="pb-0.5">
|
||||||
|
<Toggle checked={showPhone} onChange={setShowPhone} label="Public" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input label="Location" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="pb-0.5">
|
||||||
|
<Toggle checked={showLocation} onChange={setShowLocation} label="Public" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Bio</label>
|
||||||
|
<textarea value={bio} onChange={(e) => setBio(e.target.value)} rows={3}
|
||||||
|
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GradientButton type="submit" className="w-full" size="lg">Save Changes</GradientButton>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
client/src/router.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { createBrowserRouter } from 'react-router-dom';
|
||||||
|
import { App } from './App';
|
||||||
|
import { DashboardLayout } from './components/layout/DashboardLayout';
|
||||||
|
import { RequireAuth } from './components/layout/RequireAuth';
|
||||||
|
import { HomePage } from './pages/HomePage';
|
||||||
|
import { LoginPage } from './pages/LoginPage';
|
||||||
|
import { SignUpPage } from './pages/SignUpPage';
|
||||||
|
import { SellItemPage } from './pages/SellItemPage';
|
||||||
|
import { ProductDetailPage } from './pages/ProductDetailPage';
|
||||||
|
import { CreateProfilePage } from './pages/CreateProfilePage';
|
||||||
|
import { UpdateProfilePage } from './pages/UpdateProfilePage';
|
||||||
|
import { ChatPage } from './pages/ChatPage';
|
||||||
|
import { MyOffersPage } from './pages/MyOffersPage';
|
||||||
|
import { NotificationsPage } from './pages/NotificationsPage';
|
||||||
|
import { SoldItemsPage } from './pages/SoldItemsPage';
|
||||||
|
import { SettingsPage } from './pages/SettingsPage';
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <App />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <HomePage /> },
|
||||||
|
{ path: 'login', element: <LoginPage /> },
|
||||||
|
{ path: 'signup', element: <SignUpPage /> },
|
||||||
|
{ path: 'sell', element: <RequireAuth><SellItemPage /></RequireAuth> },
|
||||||
|
{ path: 'listings/:id', element: <ProductDetailPage /> },
|
||||||
|
{ path: 'profile/create', element: <RequireAuth><CreateProfilePage /></RequireAuth> },
|
||||||
|
{ path: 'profile/edit', element: <RequireAuth><UpdateProfilePage /></RequireAuth> },
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
element: <RequireAuth><DashboardLayout /></RequireAuth>,
|
||||||
|
children: [
|
||||||
|
{ path: 'messages', element: <ChatPage /> },
|
||||||
|
{ path: 'offers', element: <MyOffersPage /> },
|
||||||
|
{ path: 'notifications', element: <NotificationsPage /> },
|
||||||
|
{ path: 'sold', element: <SoldItemsPage /> },
|
||||||
|
{ path: 'settings', element: <SettingsPage /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
118
client/src/types/index.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
export type Category =
|
||||||
|
| 'ELECTRONICS'
|
||||||
|
| 'FURNITURE'
|
||||||
|
| 'CLOTHING'
|
||||||
|
| 'HOME_GARDEN'
|
||||||
|
| 'SPORTS'
|
||||||
|
| 'BOOKS'
|
||||||
|
| 'GAMES'
|
||||||
|
| 'VEHICLES'
|
||||||
|
| 'OTHER';
|
||||||
|
|
||||||
|
export type ListingCondition = 'NEW' | 'LIKE_NEW' | 'GENTLY_USED' | 'USED' | 'FAIR';
|
||||||
|
|
||||||
|
export type ListingStatus = 'DRAFT' | 'ACTIVE' | 'SOLD' | 'DELETED';
|
||||||
|
|
||||||
|
export type OfferStatus = 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'COUNTERED';
|
||||||
|
|
||||||
|
export type NotificationType = 'NEW_OFFER' | 'OFFER_ACCEPTED' | 'OFFER_DECLINED' | 'ITEM_SOLD' | 'NEW_MESSAGE' | 'ITEM_FAVORITED';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
nickname?: string;
|
||||||
|
avatar?: string;
|
||||||
|
phone?: string;
|
||||||
|
location?: string;
|
||||||
|
bio?: string;
|
||||||
|
rating?: number;
|
||||||
|
createdAt: string;
|
||||||
|
showEmail: boolean;
|
||||||
|
showPhone: boolean;
|
||||||
|
showLocation: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListingImage {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Listing {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
obo: boolean;
|
||||||
|
category: Category;
|
||||||
|
condition: ListingCondition;
|
||||||
|
status: ListingStatus;
|
||||||
|
location: string;
|
||||||
|
viewCount: number;
|
||||||
|
images: ListingImage[];
|
||||||
|
seller: User;
|
||||||
|
sellerId: string;
|
||||||
|
isFavorited?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Offer {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
message?: string;
|
||||||
|
status: OfferStatus;
|
||||||
|
counterAmount?: number;
|
||||||
|
buyer: User;
|
||||||
|
buyerId: string;
|
||||||
|
seller: User;
|
||||||
|
sellerId: string;
|
||||||
|
listing: Listing;
|
||||||
|
listingId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
senderId: string;
|
||||||
|
conversationId: string;
|
||||||
|
isRead: boolean;
|
||||||
|
offerAmount?: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
user1: User;
|
||||||
|
user2: User;
|
||||||
|
listing?: Listing;
|
||||||
|
lastMessage?: Message;
|
||||||
|
unreadCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
isRead: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: User;
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
21
client/src/utils/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export const CATEGORIES: { value: string; label: string; icon: string }[] = [
|
||||||
|
{ value: 'ELECTRONICS', label: 'Electronics', icon: 'Smartphone' },
|
||||||
|
{ value: 'FURNITURE', label: 'Furniture', icon: 'Armchair' },
|
||||||
|
{ value: 'CLOTHING', label: 'Clothing & Shoes', icon: 'Shirt' },
|
||||||
|
{ value: 'HOME_GARDEN', label: 'Home & Garden', icon: 'Home' },
|
||||||
|
{ value: 'SPORTS', label: 'Sports & Outdoors', icon: 'Dumbbell' },
|
||||||
|
{ value: 'BOOKS', label: 'Books', icon: 'BookOpen' },
|
||||||
|
{ value: 'GAMES', label: 'Games', icon: 'Gamepad2' },
|
||||||
|
{ value: 'VEHICLES', label: 'Vehicles', icon: 'Car' },
|
||||||
|
{ value: 'OTHER', label: 'Other', icon: 'Package' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CONDITIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'NEW', label: 'New' },
|
||||||
|
{ value: 'LIKE_NEW', label: 'Like New' },
|
||||||
|
{ value: 'GENTLY_USED', label: 'Gently Used' },
|
||||||
|
{ value: 'USED', label: 'Used' },
|
||||||
|
{ value: 'FAIR', label: 'Fair' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LISTING_FEE = 5;
|
||||||
31
client/src/utils/format.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - d.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'Just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateFull(date: string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
368
client/src/utils/mockData.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import type { User, Listing, Offer, Conversation, Message, Notification } from '../types';
|
||||||
|
|
||||||
|
export const mockUsers: User[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
email: 'john@email.com',
|
||||||
|
fullName: 'John Smith',
|
||||||
|
nickname: 'JohnS',
|
||||||
|
avatar: undefined,
|
||||||
|
phone: '(555) 123-4567',
|
||||||
|
location: 'Houston, TX',
|
||||||
|
bio: 'Avid collector and seller of quality pre-owned items.',
|
||||||
|
rating: 4.8,
|
||||||
|
createdAt: '2024-01-15T10:00:00Z',
|
||||||
|
showEmail: false,
|
||||||
|
showPhone: true,
|
||||||
|
showLocation: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
email: 'mike@email.com',
|
||||||
|
fullName: 'Mike Brown',
|
||||||
|
nickname: 'MikeB',
|
||||||
|
avatar: undefined,
|
||||||
|
phone: '(555) 987-6543',
|
||||||
|
location: 'Austin, TX',
|
||||||
|
bio: 'Looking for great deals on furniture and electronics.',
|
||||||
|
rating: 4.5,
|
||||||
|
createdAt: '2024-02-20T10:00:00Z',
|
||||||
|
showEmail: true,
|
||||||
|
showPhone: true,
|
||||||
|
showLocation: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
email: 'sarah@email.com',
|
||||||
|
fullName: 'Sarah Williams',
|
||||||
|
nickname: 'SarahW',
|
||||||
|
avatar: undefined,
|
||||||
|
phone: '(555) 456-7890',
|
||||||
|
location: 'Dallas, TX',
|
||||||
|
bio: 'Fashion enthusiast selling vintage finds.',
|
||||||
|
rating: 4.9,
|
||||||
|
createdAt: '2024-03-10T10:00:00Z',
|
||||||
|
showEmail: false,
|
||||||
|
showPhone: false,
|
||||||
|
showLocation: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockCurrentUser = mockUsers[0]!;
|
||||||
|
|
||||||
|
export const mockListings: Listing[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Leather Armchair',
|
||||||
|
description: 'Comfortable leather armchair in great condition! Has minimal wear. Perfect for any living room. Rich brown color with brass studs. Selling because I\'m redecorating.',
|
||||||
|
price: 120,
|
||||||
|
obo: true,
|
||||||
|
category: 'FURNITURE',
|
||||||
|
condition: 'GENTLY_USED',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: 'Houston, TX',
|
||||||
|
viewCount: 245,
|
||||||
|
images: [
|
||||||
|
{ id: '1', url: '/placeholder-armchair.jpg', order: 0 },
|
||||||
|
{ id: '2', url: '/placeholder-armchair-2.jpg', order: 1 },
|
||||||
|
],
|
||||||
|
seller: mockUsers[0]!,
|
||||||
|
sellerId: '1',
|
||||||
|
isFavorited: false,
|
||||||
|
createdAt: '2024-06-01T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Wireless Headphones',
|
||||||
|
description: 'Premium wireless headphones with noise cancellation. Battery lasts 30+ hours. Comes with original box and charging cable.',
|
||||||
|
price: 65,
|
||||||
|
obo: true,
|
||||||
|
category: 'ELECTRONICS',
|
||||||
|
condition: 'LIKE_NEW',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: 'Austin, TX',
|
||||||
|
viewCount: 189,
|
||||||
|
images: [{ id: '3', url: '/placeholder-headphones.jpg', order: 0 }],
|
||||||
|
seller: mockUsers[1]!,
|
||||||
|
sellerId: '2',
|
||||||
|
isFavorited: true,
|
||||||
|
createdAt: '2024-06-05T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-05T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Denim Jacket',
|
||||||
|
description: 'Classic denim jacket, size M. Barely worn, like new condition. Great for layering in spring and fall.',
|
||||||
|
price: 32,
|
||||||
|
obo: false,
|
||||||
|
category: 'CLOTHING',
|
||||||
|
condition: 'LIKE_NEW',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: 'Dallas, TX',
|
||||||
|
viewCount: 78,
|
||||||
|
images: [{ id: '4', url: '/placeholder-jacket.jpg', order: 0 }],
|
||||||
|
seller: mockUsers[2]!,
|
||||||
|
sellerId: '3',
|
||||||
|
isFavorited: false,
|
||||||
|
createdAt: '2024-06-10T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-10T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'Coffee Maker',
|
||||||
|
description: 'Programmable 12-cup coffee maker. Works perfectly. Includes reusable filter and carafe.',
|
||||||
|
price: 45,
|
||||||
|
obo: true,
|
||||||
|
category: 'HOME_GARDEN',
|
||||||
|
condition: 'USED',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: 'Houston, TX',
|
||||||
|
viewCount: 134,
|
||||||
|
images: [{ id: '5', url: '/placeholder-coffee.jpg', order: 0 }],
|
||||||
|
seller: mockUsers[0]!,
|
||||||
|
sellerId: '1',
|
||||||
|
isFavorited: false,
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
title: 'Smartwatch',
|
||||||
|
description: 'Feature-rich smartwatch with heart rate monitor, GPS, and sleep tracking. Compatible with iOS and Android.',
|
||||||
|
price: 85,
|
||||||
|
obo: true,
|
||||||
|
category: 'ELECTRONICS',
|
||||||
|
condition: 'GENTLY_USED',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: 'Austin, TX',
|
||||||
|
viewCount: 312,
|
||||||
|
images: [{ id: '6', url: '/placeholder-watch.jpg', order: 0 }],
|
||||||
|
seller: mockUsers[1]!,
|
||||||
|
sellerId: '2',
|
||||||
|
isFavorited: true,
|
||||||
|
createdAt: '2024-06-18T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-18T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
title: 'Designer Handbag',
|
||||||
|
description: 'Authentic designer handbag in excellent condition. Comes with dust bag and authenticity card.',
|
||||||
|
price: 220,
|
||||||
|
obo: true,
|
||||||
|
category: 'CLOTHING',
|
||||||
|
condition: 'GENTLY_USED',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: 'Dallas, TX',
|
||||||
|
viewCount: 456,
|
||||||
|
images: [{ id: '7', url: '/placeholder-handbag.jpg', order: 0 }],
|
||||||
|
seller: mockUsers[2]!,
|
||||||
|
sellerId: '3',
|
||||||
|
isFavorited: false,
|
||||||
|
createdAt: '2024-06-20T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-20T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
title: 'Gaming Laptop',
|
||||||
|
description: 'High-performance gaming laptop with RTX 3070, 16GB RAM, 512GB SSD. Runs all modern games at high settings.',
|
||||||
|
price: 1200,
|
||||||
|
obo: true,
|
||||||
|
category: 'ELECTRONICS',
|
||||||
|
condition: 'GENTLY_USED',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: 'Houston, TX',
|
||||||
|
viewCount: 567,
|
||||||
|
images: [{ id: '8', url: '/placeholder-laptop.jpg', order: 0 }],
|
||||||
|
seller: mockUsers[0]!,
|
||||||
|
sellerId: '1',
|
||||||
|
isFavorited: false,
|
||||||
|
createdAt: '2024-06-22T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-22T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
title: 'Mountain Bike',
|
||||||
|
description: '21-speed mountain bike, aluminum frame. Recently serviced with new tires and brakes.',
|
||||||
|
price: 300,
|
||||||
|
obo: true,
|
||||||
|
category: 'SPORTS',
|
||||||
|
condition: 'USED',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
location: 'Austin, TX',
|
||||||
|
viewCount: 223,
|
||||||
|
images: [{ id: '9', url: '/placeholder-bike.jpg', order: 0 }],
|
||||||
|
seller: mockUsers[1]!,
|
||||||
|
sellerId: '2',
|
||||||
|
isFavorited: false,
|
||||||
|
createdAt: '2024-06-25T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-25T10:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockOffers: Offer[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
amount: 98,
|
||||||
|
message: 'Would you accept $98 for the armchair?',
|
||||||
|
status: 'PENDING',
|
||||||
|
buyer: mockUsers[1]!,
|
||||||
|
buyerId: '2',
|
||||||
|
seller: mockUsers[0]!,
|
||||||
|
sellerId: '1',
|
||||||
|
listing: mockListings[0]!,
|
||||||
|
listingId: '1',
|
||||||
|
createdAt: '2024-06-20T14:00:00Z',
|
||||||
|
updatedAt: '2024-06-20T14:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
amount: 50,
|
||||||
|
message: 'Interested in the headphones. Can you do $50?',
|
||||||
|
status: 'PENDING',
|
||||||
|
buyer: mockUsers[0]!,
|
||||||
|
buyerId: '1',
|
||||||
|
seller: mockUsers[1]!,
|
||||||
|
sellerId: '2',
|
||||||
|
listing: mockListings[1]!,
|
||||||
|
listingId: '2',
|
||||||
|
createdAt: '2024-06-21T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-21T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
amount: 180,
|
||||||
|
message: 'I can pick up today if you accept $180.',
|
||||||
|
status: 'ACCEPTED',
|
||||||
|
buyer: mockUsers[1]!,
|
||||||
|
buyerId: '2',
|
||||||
|
seller: mockUsers[0]!,
|
||||||
|
sellerId: '1',
|
||||||
|
listing: mockListings[6]!,
|
||||||
|
listingId: '7',
|
||||||
|
createdAt: '2024-06-22T09:00:00Z',
|
||||||
|
updatedAt: '2024-06-22T12:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
amount: 35,
|
||||||
|
message: 'Is the coffee maker still available?',
|
||||||
|
status: 'DECLINED',
|
||||||
|
buyer: mockUsers[2]!,
|
||||||
|
buyerId: '3',
|
||||||
|
seller: mockUsers[0]!,
|
||||||
|
sellerId: '1',
|
||||||
|
listing: mockListings[3]!,
|
||||||
|
listingId: '4',
|
||||||
|
createdAt: '2024-06-23T16:00:00Z',
|
||||||
|
updatedAt: '2024-06-23T18:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockConversations: Conversation[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
user1: mockUsers[0]!,
|
||||||
|
user2: mockUsers[1]!,
|
||||||
|
listing: mockListings[0],
|
||||||
|
lastMessage: {
|
||||||
|
id: 'm1',
|
||||||
|
content: 'Sounds good! I can do $110. 😊',
|
||||||
|
senderId: '1',
|
||||||
|
conversationId: '1',
|
||||||
|
isRead: true,
|
||||||
|
createdAt: '2024-06-20T15:30:00Z',
|
||||||
|
},
|
||||||
|
unreadCount: 0,
|
||||||
|
updatedAt: '2024-06-20T15:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
user1: mockUsers[0]!,
|
||||||
|
user2: mockUsers[2]!,
|
||||||
|
listing: mockListings[3],
|
||||||
|
lastMessage: {
|
||||||
|
id: 'm2',
|
||||||
|
content: 'Is the coffee maker still available?',
|
||||||
|
senderId: '3',
|
||||||
|
conversationId: '2',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: '2024-06-21T09:00:00Z',
|
||||||
|
},
|
||||||
|
unreadCount: 1,
|
||||||
|
updatedAt: '2024-06-21T09:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockMessages: Message[] = [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
content: 'Hi! Would you take $110 for the Leather Armchair? I\'m interested.',
|
||||||
|
senderId: '2',
|
||||||
|
conversationId: '1',
|
||||||
|
isRead: true,
|
||||||
|
createdAt: '2024-06-20T14:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'm2',
|
||||||
|
content: 'Sounds good! I can do $110. 😊',
|
||||||
|
senderId: '1',
|
||||||
|
conversationId: '1',
|
||||||
|
isRead: true,
|
||||||
|
createdAt: '2024-06-20T15:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'm3',
|
||||||
|
content: 'Is the coffee maker still available?',
|
||||||
|
senderId: '3',
|
||||||
|
conversationId: '2',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: '2024-06-21T09:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockNotifications: Notification[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'NEW_OFFER',
|
||||||
|
title: 'New Offer',
|
||||||
|
body: 'JakoTV123 just made an offer for your Nintendo Switch',
|
||||||
|
data: { listingId: '1', offerId: '1', amount: 180 },
|
||||||
|
isRead: false,
|
||||||
|
createdAt: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'ITEM_SOLD',
|
||||||
|
title: 'Item Sold',
|
||||||
|
body: 'You sold your Coffee Maker for $45',
|
||||||
|
data: { listingId: '4' },
|
||||||
|
isRead: false,
|
||||||
|
createdAt: new Date(Date.now() - 7200000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'ITEM_FAVORITED',
|
||||||
|
title: 'Item Favorited',
|
||||||
|
body: 'Ethan123 loved your Reebok Sneakers',
|
||||||
|
data: { listingId: '3' },
|
||||||
|
isRead: true,
|
||||||
|
createdAt: new Date(Date.now() - 10800000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
type: 'NEW_OFFER',
|
||||||
|
title: 'New Offer',
|
||||||
|
body: 'AlessaJd1 just made an offer for your Mountain Bike',
|
||||||
|
data: { listingId: '8', offerId: '5', amount: 270 },
|
||||||
|
isRead: true,
|
||||||
|
createdAt: new Date(Date.now() - 14400000).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockSoldItems = [
|
||||||
|
{ listing: mockListings[0]!, salePrice: 180, buyer: mockUsers[1]!, date: '2024-06-20T12:00:00Z' },
|
||||||
|
{ listing: mockListings[1]!, salePrice: 70, buyer: mockUsers[2]!, date: '2024-04-02T10:00:00Z' },
|
||||||
|
{ listing: mockListings[2]!, salePrice: 30, buyer: mockUsers[1]!, date: '2024-03-27T10:00:00Z' },
|
||||||
|
{ listing: mockListings[3]!, salePrice: 40, buyer: mockUsers[1]!, date: '2024-03-27T10:00:00Z' },
|
||||||
|
];
|
||||||
26
client/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"baseUrl": "."
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
client/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
30
client/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/socket.io': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
16
docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: marketplace-db
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: marketplace
|
||||||
|
POSTGRES_PASSWORD: marketplace_dev
|
||||||
|
POSTGRES_DB: marketplace
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
81
docs/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Color Wheel Palette Generator
|
||||||
|
|
||||||
|
An interactive color palette generator built with React, TypeScript, and Tailwind CSS v4. Pick a base color on a color wheel, choose a harmony type, adjust tint/shade, and export palettes in multiple formats.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Interactive Color Wheel** — Canvas-based HSL wheel with drag-to-select hue and saturation. Touch support for mobile.
|
||||||
|
- **7 Harmony Types** — Complementary, Split-Complementary, Analogous, Triadic, Tetradic, Square, Monochromatic.
|
||||||
|
- **Tint & Shade Slider** — Adjust lightness in real-time.
|
||||||
|
- **Color Input** — Enter HEX values directly, view RGB/HSL, adjust via RGB sliders.
|
||||||
|
- **Click-to-Copy** — Copy any color value (HEX, RGB, HSL) to clipboard.
|
||||||
|
- **Export** — CSS variables, JSON, Tailwind config snippet, or PNG image.
|
||||||
|
- **Dark / Light Theme** — Toggle between dark and light modes.
|
||||||
|
- **Responsive** — Mobile-first layout with horizontal scroll palette on small screens.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5173` in your browser.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # UI components
|
||||||
|
│ ├── ColorWheel/ # Canvas-based color wheel with pointer events
|
||||||
|
│ ├── PaletteDisplay/ # Generated swatch grid
|
||||||
|
│ ├── HarmonySelector/ # Dropdown for harmony type
|
||||||
|
│ ├── ColorInput/ # HEX/RGB/HSL inputs and sliders
|
||||||
|
│ ├── TintShadeSlider/ # Lightness adjustment
|
||||||
|
│ ├── ExportPanel/ # CSS/JSON/Tailwind/PNG export
|
||||||
|
│ ├── Header/ # App header with theme toggle
|
||||||
|
│ └── MobileNav/ # Bottom sheet navigation for mobile
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useColorWheel.ts # Wheel interaction (drag, position calculation)
|
||||||
|
│ └── useColorHarmony.ts # Harmony palette generation
|
||||||
|
├── utils/
|
||||||
|
│ ├── colorConversions.ts # HEX <-> RGB <-> HSL converters
|
||||||
|
│ └── harmonies.ts # Harmony algorithms
|
||||||
|
├── App.tsx # Main layout
|
||||||
|
├── main.tsx # Entry point
|
||||||
|
└── index.css # Tailwind directives + theme variables
|
||||||
|
```
|
||||||
|
|
||||||
|
## Harmony Algorithms
|
||||||
|
|
||||||
|
| Type | Colors Generated |
|
||||||
|
|---|---|
|
||||||
|
| Complementary | Base + 180° |
|
||||||
|
| Split-Complementary | Base, +150°, +210° |
|
||||||
|
| Analogous | -30°, Base, +30° |
|
||||||
|
| Triadic | Base, +120°, +240° |
|
||||||
|
| Tetradic | Base, +90°, +180°, +270° |
|
||||||
|
| Square | Base, +90°, +180°, +270° |
|
||||||
|
| Monochromatic | Same hue, varied S/L |
|
||||||
|
|
||||||
|
## MCP Integration
|
||||||
|
|
||||||
|
The project includes MCP server configuration in `.claude/settings.json`:
|
||||||
|
|
||||||
|
- **Figma MCP** — Connect to Figma to read design files and create new frames.
|
||||||
|
- **Chrome DevTools MCP** — Debug the running app, inspect elements, and validate layouts.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- React 19 + TypeScript
|
||||||
|
- Vite
|
||||||
|
- Tailwind CSS v4
|
||||||
|
- HTML5 Canvas API
|
||||||
|
- Clipboard API
|
||||||
6469
package-lock.json
generated
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "marketplace",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"workspaces": ["client", "server"],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npm run dev --workspace=client & npm run dev --workspace=server",
|
||||||
|
"dev:client": "npm run dev --workspace=client",
|
||||||
|
"dev:server": "npm run dev --workspace=server",
|
||||||
|
"build": "npm run build --workspace=client && npm run build --workspace=server",
|
||||||
|
"lint": "npm run lint --workspace=client"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
server/package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "marketplace-server",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"@prisma/client": "^6.2.0",
|
||||||
|
"socket.io": "^4.8.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"zod": "^3.24.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"stripe": "^17.5.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"cookie-parser": "^1.4.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
|
"@types/cookie-parser": "^1.4.7",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"prisma": "^6.2.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "~5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
262
server/prisma/schema.prisma
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Category {
|
||||||
|
ELECTRONICS
|
||||||
|
FURNITURE
|
||||||
|
CLOTHING
|
||||||
|
HOME_GARDEN
|
||||||
|
SPORTS
|
||||||
|
BOOKS
|
||||||
|
GAMES
|
||||||
|
VEHICLES
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ListingCondition {
|
||||||
|
NEW
|
||||||
|
LIKE_NEW
|
||||||
|
GENTLY_USED
|
||||||
|
USED
|
||||||
|
FAIR
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ListingStatus {
|
||||||
|
DRAFT
|
||||||
|
ACTIVE
|
||||||
|
SOLD
|
||||||
|
DELETED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OfferStatus {
|
||||||
|
PENDING
|
||||||
|
ACCEPTED
|
||||||
|
DECLINED
|
||||||
|
COUNTERED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationType {
|
||||||
|
NEW_OFFER
|
||||||
|
OFFER_ACCEPTED
|
||||||
|
OFFER_DECLINED
|
||||||
|
ITEM_SOLD
|
||||||
|
NEW_MESSAGE
|
||||||
|
ITEM_FAVORITED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PaymentStatus {
|
||||||
|
PENDING
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
REFUNDED
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
fullName String
|
||||||
|
nickname String?
|
||||||
|
avatar String?
|
||||||
|
phone String?
|
||||||
|
location String?
|
||||||
|
bio String?
|
||||||
|
rating Float @default(0)
|
||||||
|
ratingCount Int @default(0)
|
||||||
|
showEmail Boolean @default(false)
|
||||||
|
showPhone Boolean @default(true)
|
||||||
|
showLocation Boolean @default(true)
|
||||||
|
showOnline Boolean @default(true)
|
||||||
|
showRating Boolean @default(true)
|
||||||
|
twoFactorEnabled Boolean @default(false)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
sessions Session[]
|
||||||
|
listings Listing[]
|
||||||
|
images ListingImage[]
|
||||||
|
sentOffers Offer[] @relation("BuyerOffers")
|
||||||
|
receivedOffers Offer[] @relation("SellerOffers")
|
||||||
|
conversations1 Conversation[] @relation("User1Conversations")
|
||||||
|
conversations2 Conversation[] @relation("User2Conversations")
|
||||||
|
messages Message[]
|
||||||
|
favorites Favorite[]
|
||||||
|
notifications Notification[]
|
||||||
|
payments Payment[]
|
||||||
|
blockedUsers BlockedUser[] @relation("Blocker")
|
||||||
|
blockedBy BlockedUser[] @relation("Blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
refreshToken String @unique
|
||||||
|
userAgent String?
|
||||||
|
ipAddress String?
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Listing {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
price Float
|
||||||
|
obo Boolean @default(false)
|
||||||
|
category Category
|
||||||
|
condition ListingCondition
|
||||||
|
status ListingStatus @default(DRAFT)
|
||||||
|
location String
|
||||||
|
viewCount Int @default(0)
|
||||||
|
sellerId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
seller User @relation(fields: [sellerId], references: [id], onDelete: Cascade)
|
||||||
|
images ListingImage[]
|
||||||
|
offers Offer[]
|
||||||
|
conversations Conversation[]
|
||||||
|
favorites Favorite[]
|
||||||
|
payments Payment[]
|
||||||
|
|
||||||
|
@@index([sellerId])
|
||||||
|
@@index([category])
|
||||||
|
@@index([status])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ListingImage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
url String
|
||||||
|
order Int @default(0)
|
||||||
|
listingId String
|
||||||
|
uploadedBy String
|
||||||
|
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [uploadedBy], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([listingId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Offer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
amount Float
|
||||||
|
message String?
|
||||||
|
status OfferStatus @default(PENDING)
|
||||||
|
counterAmount Float?
|
||||||
|
buyerId String
|
||||||
|
sellerId String
|
||||||
|
listingId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
buyer User @relation("BuyerOffers", fields: [buyerId], references: [id], onDelete: Cascade)
|
||||||
|
seller User @relation("SellerOffers", fields: [sellerId], references: [id], onDelete: Cascade)
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([buyerId])
|
||||||
|
@@index([sellerId])
|
||||||
|
@@index([listingId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Conversation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
user1Id String
|
||||||
|
user2Id String
|
||||||
|
listingId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user1 User @relation("User1Conversations", fields: [user1Id], references: [id], onDelete: Cascade)
|
||||||
|
user2 User @relation("User2Conversations", fields: [user2Id], references: [id], onDelete: Cascade)
|
||||||
|
listing Listing? @relation(fields: [listingId], references: [id], onDelete: SetNull)
|
||||||
|
messages Message[]
|
||||||
|
|
||||||
|
@@unique([user1Id, user2Id, listingId])
|
||||||
|
@@index([user1Id])
|
||||||
|
@@index([user2Id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Message {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
content String
|
||||||
|
senderId String
|
||||||
|
conversationId String
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
offerAmount Float?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||||
|
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([conversationId])
|
||||||
|
@@index([senderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Favorite {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
listingId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, listingId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type NotificationType
|
||||||
|
title String
|
||||||
|
body String
|
||||||
|
data Json?
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Payment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
listingId String
|
||||||
|
stripePaymentId String? @unique
|
||||||
|
amount Float
|
||||||
|
status PaymentStatus @default(PENDING)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([listingId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model BlockedUser {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
blockerId String
|
||||||
|
blockedId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
blocker User @relation("Blocker", fields: [blockerId], references: [id], onDelete: Cascade)
|
||||||
|
blocked User @relation("Blocked", fields: [blockedId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([blockerId, blockedId])
|
||||||
|
}
|
||||||
5
server/src/config/database.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export const prisma = new PrismaClient({
|
||||||
|
log: process.env['NODE_ENV'] === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
});
|
||||||
10
server/src/config/env.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const env = {
|
||||||
|
PORT: parseInt(process.env['PORT'] || '3000', 10),
|
||||||
|
DATABASE_URL: process.env['DATABASE_URL'] || 'postgresql://marketplace:marketplace_dev@localhost:5432/marketplace',
|
||||||
|
JWT_SECRET: process.env['JWT_SECRET'] || 'dev-secret-change-in-production',
|
||||||
|
JWT_REFRESH_SECRET: process.env['JWT_REFRESH_SECRET'] || 'dev-refresh-secret-change-in-production',
|
||||||
|
STRIPE_SECRET_KEY: process.env['STRIPE_SECRET_KEY'] || '',
|
||||||
|
STRIPE_WEBHOOK_SECRET: process.env['STRIPE_WEBHOOK_SECRET'] || '',
|
||||||
|
CLIENT_URL: process.env['CLIENT_URL'] || 'http://localhost:5173',
|
||||||
|
UPLOAD_DIR: process.env['UPLOAD_DIR'] || './uploads',
|
||||||
|
};
|
||||||
65
server/src/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { env } from './config/env.js';
|
||||||
|
import { errorHandler } from './middleware/errorHandler.js';
|
||||||
|
import { setupSocket } from './socket/index.js';
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import userRoutes from './routes/user.js';
|
||||||
|
import listingRoutes from './routes/listing.js';
|
||||||
|
import offerRoutes from './routes/offer.js';
|
||||||
|
import chatRoutes from './routes/chat.js';
|
||||||
|
import notificationRoutes from './routes/notification.js';
|
||||||
|
import paymentRoutes from './routes/payment.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const httpServer = createServer(app);
|
||||||
|
|
||||||
|
// Socket.io
|
||||||
|
const io = setupSocket(httpServer);
|
||||||
|
app.set('io', io);
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(helmet({ contentSecurityPolicy: false }));
|
||||||
|
app.use(cors({ origin: env.CLIENT_URL, credentials: true }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Stripe webhook needs raw body
|
||||||
|
app.use('/api/payments/webhook', express.raw({ type: 'application/json' }));
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
|
||||||
|
app.use('/api/auth/login', authLimiter);
|
||||||
|
app.use('/api/auth/register', authLimiter);
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
app.use('/uploads', express.static(path.join(__dirname, '..', env.UPLOAD_DIR)));
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/users', userRoutes);
|
||||||
|
app.use('/api/listings', listingRoutes);
|
||||||
|
app.use('/api/offers', offerRoutes);
|
||||||
|
app.use('/api/chat', chatRoutes);
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
app.use('/api/payments', paymentRoutes);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
httpServer.listen(env.PORT, () => {
|
||||||
|
console.log(`Server running on port ${env.PORT}`);
|
||||||
|
});
|
||||||
41
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import { verifyAccessToken } from '../utils/jwt.js';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
res.status(401).json({ message: 'Authentication required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const payload = verifyAccessToken(token);
|
||||||
|
req.userId = payload.userId;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ message: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalAuth(req: Request, _res: Response, next: NextFunction): void {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
try {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const payload = verifyAccessToken(token);
|
||||||
|
req.userId = payload.userId;
|
||||||
|
} catch {
|
||||||
|
// Token invalid, continue without auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
22
server/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(
|
||||||
|
public statusCode: number,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
|
||||||
|
console.error('Error:', err);
|
||||||
|
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
res.status(err.statusCode).json({ message: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
34
server/src/middleware/upload.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const uploadDir = env.UPLOAD_DIR;
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
cb(null, uniqueSuffix + path.extname(file.originalname));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileFilter = (_req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||||
|
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||||
|
if (allowed.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files (JPEG, PNG, WebP, GIF) are allowed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024, files: 6 },
|
||||||
|
});
|
||||||
17
server/src/middleware/validate.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import type { ZodSchema } from 'zod';
|
||||||
|
|
||||||
|
export function validate(schema: ZodSchema) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const result = schema.safeParse(req.body);
|
||||||
|
if (!result.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
message: 'Validation error',
|
||||||
|
errors: result.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.body = result.data;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
148
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { hashPassword, comparePassword } from '../utils/password.js';
|
||||||
|
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../utils/jwt.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
import { authenticate } from '../middleware/auth.js';
|
||||||
|
import { registerSchema, loginSchema } from '../validators/auth.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/register', validate(registerSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { fullName, email, password } = req.body;
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existing) throw new AppError(409, 'Email already registered');
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { fullName, email, passwordHash },
|
||||||
|
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(user.id);
|
||||||
|
const refreshToken = generateRefreshToken(user.id);
|
||||||
|
|
||||||
|
await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
refreshToken,
|
||||||
|
userAgent: req.headers['user-agent'] || null,
|
||||||
|
ipAddress: req.ip || null,
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.cookie('refreshToken', refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env['NODE_ENV'] === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ user, accessToken });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', validate(loginSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!user) throw new AppError(401, 'Invalid email or password');
|
||||||
|
if (!user.isActive) throw new AppError(403, 'Account is disabled');
|
||||||
|
|
||||||
|
const valid = await comparePassword(password, user.passwordHash);
|
||||||
|
if (!valid) throw new AppError(401, 'Invalid email or password');
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(user.id);
|
||||||
|
const refreshToken = generateRefreshToken(user.id);
|
||||||
|
|
||||||
|
await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
refreshToken,
|
||||||
|
userAgent: req.headers['user-agent'] || null,
|
||||||
|
ipAddress: req.ip || null,
|
||||||
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.cookie('refreshToken', refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env['NODE_ENV'] === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { passwordHash: _, ...userData } = user;
|
||||||
|
res.json({ user: userData, accessToken });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/refresh', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const token = req.cookies?.refreshToken;
|
||||||
|
if (!token) throw new AppError(401, 'No refresh token');
|
||||||
|
|
||||||
|
const payload = verifyRefreshToken(token);
|
||||||
|
const session = await prisma.session.findUnique({ where: { refreshToken: token } });
|
||||||
|
if (!session || session.expiresAt < new Date()) {
|
||||||
|
if (session) await prisma.session.delete({ where: { id: session.id } });
|
||||||
|
throw new AppError(401, 'Invalid refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = generateAccessToken(payload.userId);
|
||||||
|
const newRefreshToken = generateRefreshToken(payload.userId);
|
||||||
|
|
||||||
|
await prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { refreshToken: newRefreshToken, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.cookie('refreshToken', newRefreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env['NODE_ENV'] === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ accessToken });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.userId },
|
||||||
|
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
|
||||||
|
});
|
||||||
|
if (!user) throw new AppError(404, 'User not found');
|
||||||
|
res.json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logout', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const token = req.cookies?.refreshToken;
|
||||||
|
if (token) {
|
||||||
|
await prisma.session.deleteMany({ where: { refreshToken: token } });
|
||||||
|
}
|
||||||
|
res.clearCookie('refreshToken');
|
||||||
|
res.json({ message: 'Logged out' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
90
server/src/routes/chat.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { authenticate } from '../middleware/auth.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/conversations', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const conversations = await prisma.conversation.findMany({
|
||||||
|
where: { OR: [{ user1Id: req.userId }, { user2Id: req.userId }] },
|
||||||
|
include: {
|
||||||
|
user1: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||||
|
user2: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||||
|
listing: { select: { id: true, title: true, price: true, images: { take: 1 } } },
|
||||||
|
messages: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await Promise.all(conversations.map(async (conv) => {
|
||||||
|
const unreadCount = await prisma.message.count({
|
||||||
|
where: { conversationId: conv.id, senderId: { not: req.userId }, isRead: false },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...conv,
|
||||||
|
lastMessage: conv.messages[0] || null,
|
||||||
|
unreadCount,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/conversations/:id/messages', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const conv = await prisma.conversation.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!conv) throw new AppError(404, 'Conversation not found');
|
||||||
|
if (conv.user1Id !== req.userId && conv.user2Id !== req.userId) throw new AppError(403, 'Not authorized');
|
||||||
|
|
||||||
|
const messages = await prisma.message.findMany({
|
||||||
|
where: { conversationId: req.params.id },
|
||||||
|
include: { sender: { select: { id: true, fullName: true, avatar: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.message.updateMany({
|
||||||
|
where: { conversationId: req.params.id, senderId: { not: req.userId }, isRead: false },
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(messages);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/conversations', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { recipientId, listingId, message } = req.body;
|
||||||
|
if (recipientId === req.userId) throw new AppError(400, 'Cannot message yourself');
|
||||||
|
|
||||||
|
const [id1, id2] = [req.userId!, recipientId].sort();
|
||||||
|
let conversation = await prisma.conversation.findFirst({
|
||||||
|
where: { user1Id: id1, user2Id: id2, listingId: listingId || null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
conversation = await prisma.conversation.create({
|
||||||
|
data: { user1Id: id1, user2Id: id2, listingId: listingId || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
await prisma.message.create({
|
||||||
|
data: { content: message, senderId: req.userId!, conversationId: conversation.id },
|
||||||
|
});
|
||||||
|
await prisma.conversation.update({ where: { id: conversation.id }, data: { updatedAt: new Date() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(conversation);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
186
server/src/routes/listing.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { authenticate, optionalAuth } from '../middleware/auth.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
import { upload } from '../middleware/upload.js';
|
||||||
|
import { createListingSchema, updateListingSchema } from '../validators/listing.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const listingSelect = {
|
||||||
|
id: true, title: true, description: true, price: true, obo: true,
|
||||||
|
category: true, condition: true, status: true, location: true, viewCount: true,
|
||||||
|
createdAt: true, updatedAt: true, sellerId: true,
|
||||||
|
seller: { select: { id: true, fullName: true, nickname: true, avatar: true, rating: true, location: true, createdAt: true, showEmail: true, showPhone: true, showLocation: true } },
|
||||||
|
images: { orderBy: { order: 'asc' as const } },
|
||||||
|
_count: { select: { favorites: true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get('/', optionalAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = '1', pageSize = '20', category, search, sort = 'newest', condition } = req.query;
|
||||||
|
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
|
||||||
|
const take = parseInt(pageSize as string);
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { status: 'ACTIVE' };
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (condition) where.condition = condition;
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: search as string, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBy = sort === 'price_asc' ? { price: 'asc' as const }
|
||||||
|
: sort === 'price_desc' ? { price: 'desc' as const }
|
||||||
|
: sort === 'popular' ? { viewCount: 'desc' as const }
|
||||||
|
: { createdAt: 'desc' as const };
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.listing.findMany({ where, select: listingSelect, skip, take, orderBy }),
|
||||||
|
prisma.listing.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let favorites: Set<string> = new Set();
|
||||||
|
if (req.userId) {
|
||||||
|
const favs = await prisma.favorite.findMany({
|
||||||
|
where: { userId: req.userId, listingId: { in: data.map(l => l.id) } },
|
||||||
|
select: { listingId: true },
|
||||||
|
});
|
||||||
|
favorites = new Set(favs.map(f => f.listingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const listings = data.map(l => ({ ...l, isFavorited: favorites.has(l.id) }));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: listings,
|
||||||
|
total,
|
||||||
|
page: parseInt(page as string),
|
||||||
|
pageSize: take,
|
||||||
|
totalPages: Math.ceil(total / take),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', optionalAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const listing = await prisma.listing.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
select: { ...listingSelect, seller: { select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true } } },
|
||||||
|
});
|
||||||
|
if (!listing || listing.status === 'DELETED') throw new AppError(404, 'Listing not found');
|
||||||
|
|
||||||
|
await prisma.listing.update({ where: { id: req.params.id }, data: { viewCount: { increment: 1 } } });
|
||||||
|
|
||||||
|
let isFavorited = false;
|
||||||
|
if (req.userId) {
|
||||||
|
const fav = await prisma.favorite.findUnique({
|
||||||
|
where: { userId_listingId: { userId: req.userId, listingId: listing.id } },
|
||||||
|
});
|
||||||
|
isFavorited = !!fav;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ...listing, isFavorited });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authenticate, validate(createListingSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const listing = await prisma.listing.create({
|
||||||
|
data: { ...req.body, sellerId: req.userId!, status: 'DRAFT' },
|
||||||
|
select: listingSelect,
|
||||||
|
});
|
||||||
|
res.status(201).json(listing);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', authenticate, validate(updateListingSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) throw new AppError(404, 'Listing not found');
|
||||||
|
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||||
|
|
||||||
|
const listing = await prisma.listing.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: req.body,
|
||||||
|
select: listingSelect,
|
||||||
|
});
|
||||||
|
res.json(listing);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) throw new AppError(404, 'Listing not found');
|
||||||
|
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||||
|
|
||||||
|
await prisma.listing.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
res.json({ message: 'Listing deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/images', authenticate, upload.array('images', 6), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
|
||||||
|
if (!existing) throw new AppError(404, 'Listing not found');
|
||||||
|
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||||
|
|
||||||
|
const files = req.files as Express.Multer.File[];
|
||||||
|
if (!files?.length) throw new AppError(400, 'No files uploaded');
|
||||||
|
|
||||||
|
const existingImages = await prisma.listingImage.count({ where: { listingId: req.params.id } });
|
||||||
|
|
||||||
|
const images = await Promise.all(
|
||||||
|
files.map((file, i) =>
|
||||||
|
prisma.listingImage.create({
|
||||||
|
data: {
|
||||||
|
url: `/uploads/${file.filename}`,
|
||||||
|
order: existingImages + i,
|
||||||
|
listingId: req.params.id!,
|
||||||
|
uploadedBy: req.userId!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json(images);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/favorite', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.favorite.findUnique({
|
||||||
|
where: { userId_listingId: { userId: req.userId!, listingId: req.params.id! } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.favorite.delete({ where: { id: existing.id } });
|
||||||
|
res.json({ isFavorited: false });
|
||||||
|
} else {
|
||||||
|
await prisma.favorite.create({ data: { userId: req.userId!, listingId: req.params.id! } });
|
||||||
|
res.json({ isFavorited: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
44
server/src/routes/notification.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { authenticate } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const notifications = await prisma.notification.findMany({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
res.json(notifications);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/read-all', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { userId: req.userId, isRead: false },
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
res.json({ message: 'All notifications marked as read' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/:id/read', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await prisma.notification.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
res.json({ message: 'Notification marked as read' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
110
server/src/routes/offer.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { authenticate } from '../middleware/auth.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
import { createOfferSchema, respondOfferSchema } from '../validators/offer.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { type = 'received' } = req.query;
|
||||||
|
const where = type === 'sent'
|
||||||
|
? { buyerId: req.userId }
|
||||||
|
: { sellerId: req.userId };
|
||||||
|
|
||||||
|
const offers = await prisma.offer.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
listing: { include: { images: { take: 1, orderBy: { order: 'asc' } } } },
|
||||||
|
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||||
|
seller: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
res.json(offers);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authenticate, validate(createOfferSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { amount, message, listingId } = req.body;
|
||||||
|
|
||||||
|
const listing = await prisma.listing.findUnique({ where: { id: listingId } });
|
||||||
|
if (!listing) throw new AppError(404, 'Listing not found');
|
||||||
|
if (listing.status !== 'ACTIVE') throw new AppError(400, 'Listing is not active');
|
||||||
|
if (listing.sellerId === req.userId) throw new AppError(400, 'Cannot make offer on your own listing');
|
||||||
|
|
||||||
|
const offer = await prisma.offer.create({
|
||||||
|
data: { amount, message, listingId, buyerId: req.userId!, sellerId: listing.sellerId },
|
||||||
|
include: {
|
||||||
|
listing: { include: { images: { take: 1 } } },
|
||||||
|
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||||
|
seller: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: listing.sellerId,
|
||||||
|
type: 'NEW_OFFER',
|
||||||
|
title: 'New Offer',
|
||||||
|
body: `${offer.buyer.fullName} made an offer of $${amount} for ${listing.title}`,
|
||||||
|
data: { listingId, offerId: offer.id, amount },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(offer);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/:id', authenticate, validate(respondOfferSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await prisma.offer.findUnique({ where: { id: req.params.id }, include: { listing: true } });
|
||||||
|
if (!existing) throw new AppError(404, 'Offer not found');
|
||||||
|
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||||
|
if (existing.status !== 'PENDING') throw new AppError(400, 'Offer already responded to');
|
||||||
|
|
||||||
|
const { status, counterAmount } = req.body;
|
||||||
|
|
||||||
|
const offer = await prisma.offer.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { status, counterAmount },
|
||||||
|
include: {
|
||||||
|
listing: { include: { images: { take: 1 } } },
|
||||||
|
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||||
|
seller: { select: { id: true, fullName: true, nickname: true, avatar: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status === 'ACCEPTED') {
|
||||||
|
await prisma.listing.update({ where: { id: existing.listingId }, data: { status: 'SOLD' } });
|
||||||
|
await prisma.offer.updateMany({
|
||||||
|
where: { listingId: existing.listingId, id: { not: existing.id }, status: 'PENDING' },
|
||||||
|
data: { status: 'DECLINED' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationType = status === 'ACCEPTED' ? 'OFFER_ACCEPTED' : status === 'DECLINED' ? 'OFFER_DECLINED' : 'NEW_OFFER';
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
userId: existing.buyerId,
|
||||||
|
type: notificationType,
|
||||||
|
title: status === 'ACCEPTED' ? 'Offer Accepted' : status === 'DECLINED' ? 'Offer Declined' : 'Counter Offer',
|
||||||
|
body: `Your offer for ${existing.listing.title} was ${status.toLowerCase()}`,
|
||||||
|
data: { offerId: existing.id, listingId: existing.listingId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(offer);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
78
server/src/routes/payment.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { authenticate } from '../middleware/auth.js';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
|
||||||
|
|
||||||
|
router.post('/create-intent', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { listingId } = req.body;
|
||||||
|
if (!stripe) throw new AppError(500, 'Stripe not configured');
|
||||||
|
|
||||||
|
const listing = await prisma.listing.findUnique({ where: { id: listingId } });
|
||||||
|
if (!listing) throw new AppError(404, 'Listing not found');
|
||||||
|
if (listing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
|
||||||
|
|
||||||
|
const existingPayment = await prisma.payment.findFirst({
|
||||||
|
where: { listingId, status: 'COMPLETED' },
|
||||||
|
});
|
||||||
|
if (existingPayment) throw new AppError(400, 'Listing already paid for');
|
||||||
|
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
|
amount: 500,
|
||||||
|
currency: 'usd',
|
||||||
|
metadata: { listingId, userId: req.userId! },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
userId: req.userId!,
|
||||||
|
listingId,
|
||||||
|
stripePaymentId: paymentIntent.id,
|
||||||
|
amount: 5,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ clientSecret: paymentIntent.client_secret });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/webhook', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!stripe) throw new AppError(500, 'Stripe not configured');
|
||||||
|
|
||||||
|
const sig = req.headers['stripe-signature'] as string;
|
||||||
|
const event = stripe.webhooks.constructEvent(req.body, sig, env.STRIPE_WEBHOOK_SECRET);
|
||||||
|
|
||||||
|
if (event.type === 'payment_intent.succeeded') {
|
||||||
|
const paymentIntent = event.data.object;
|
||||||
|
const { listingId } = paymentIntent.metadata;
|
||||||
|
|
||||||
|
await prisma.payment.updateMany({
|
||||||
|
where: { stripePaymentId: paymentIntent.id },
|
||||||
|
data: { status: 'COMPLETED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (listingId) {
|
||||||
|
await prisma.listing.update({
|
||||||
|
where: { id: listingId },
|
||||||
|
data: { status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ received: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
68
server/src/routes/user.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { authenticate } from '../middleware/auth.js';
|
||||||
|
import { hashPassword, comparePassword } from '../utils/password.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const userSelect = {
|
||||||
|
id: true, email: true, fullName: true, nickname: true, avatar: true,
|
||||||
|
phone: true, location: true, bio: true, rating: true,
|
||||||
|
showEmail: true, showPhone: true, showLocation: true,
|
||||||
|
createdAt: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get('/profile', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.userId }, select: userSelect });
|
||||||
|
if (!user) throw new AppError(404, 'User not found');
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/profile', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { fullName, nickname, phone, location, bio, showEmail, showPhone, showLocation } = req.body;
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: req.userId },
|
||||||
|
data: { fullName, nickname, phone, location, bio, showEmail, showPhone, showLocation },
|
||||||
|
select: userSelect,
|
||||||
|
});
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/password', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.userId } });
|
||||||
|
if (!user) throw new AppError(404, 'User not found');
|
||||||
|
|
||||||
|
const valid = await comparePassword(currentPassword, user.passwordHash);
|
||||||
|
if (!valid) throw new AppError(400, 'Current password is incorrect');
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
await prisma.user.update({ where: { id: req.userId }, data: { passwordHash } });
|
||||||
|
|
||||||
|
res.json({ message: 'Password updated' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.params.id }, select: userSelect });
|
||||||
|
if (!user) throw new AppError(404, 'User not found');
|
||||||
|
res.json(user);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
88
server/src/socket/index.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Server as HTTPServer } from 'http';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import { verifyAccessToken } from '../utils/jwt.js';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
|
export function setupSocket(httpServer: HTTPServer) {
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: process.env['CLIENT_URL'] || 'http://localhost:5173',
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
io.use((socket, next) => {
|
||||||
|
const token = socket.handshake.auth.token;
|
||||||
|
if (!token) return next(new Error('Authentication required'));
|
||||||
|
try {
|
||||||
|
const payload = verifyAccessToken(token);
|
||||||
|
socket.data.userId = payload.userId;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
next(new Error('Invalid token'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
const userId = socket.data.userId;
|
||||||
|
socket.join(`user:${userId}`);
|
||||||
|
|
||||||
|
socket.on('join_conversation', (conversationId: string) => {
|
||||||
|
socket.join(`conversation:${conversationId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('leave_conversation', (conversationId: string) => {
|
||||||
|
socket.leave(`conversation:${conversationId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('send_message', async (data: { conversationId: string; content: string; offerAmount?: number }) => {
|
||||||
|
try {
|
||||||
|
const message = await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
content: data.content,
|
||||||
|
senderId: userId,
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
offerAmount: data.offerAmount,
|
||||||
|
},
|
||||||
|
include: { sender: { select: { id: true, fullName: true, avatar: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.conversation.update({
|
||||||
|
where: { id: data.conversationId },
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
io.to(`conversation:${data.conversationId}`).emit('new_message', message);
|
||||||
|
|
||||||
|
const conversation = await prisma.conversation.findUnique({ where: { id: data.conversationId } });
|
||||||
|
if (conversation) {
|
||||||
|
const recipientId = conversation.user1Id === userId ? conversation.user2Id : conversation.user1Id;
|
||||||
|
io.to(`user:${recipientId}`).emit('message_notification', { conversationId: data.conversationId, message });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
socket.emit('error', { message: 'Failed to send message' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('typing', (conversationId: string) => {
|
||||||
|
socket.to(`conversation:${conversationId}`).emit('user_typing', { userId, conversationId });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('stop_typing', (conversationId: string) => {
|
||||||
|
socket.to(`conversation:${conversationId}`).emit('user_stop_typing', { userId, conversationId });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('mark_read', async (conversationId: string) => {
|
||||||
|
await prisma.message.updateMany({
|
||||||
|
where: { conversationId, senderId: { not: userId }, isRead: false },
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
// Cleanup handled by Socket.io
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return io;
|
||||||
|
}
|
||||||
18
server/src/utils/jwt.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
export function generateAccessToken(userId: string): string {
|
||||||
|
return jwt.sign({ userId }, env.JWT_SECRET, { expiresIn: '15m' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRefreshToken(userId: string): string {
|
||||||
|
return jwt.sign({ userId }, env.JWT_REFRESH_SECRET, { expiresIn: '7d' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAccessToken(token: string): { userId: string } {
|
||||||
|
return jwt.verify(token, env.JWT_SECRET) as { userId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyRefreshToken(token: string): { userId: string } {
|
||||||
|
return jwt.verify(token, env.JWT_REFRESH_SECRET) as { userId: string };
|
||||||
|
}
|
||||||
11
server/src/utils/password.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
12
server/src/validators/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const registerSchema = z.object({
|
||||||
|
fullName: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
});
|
||||||
13
server/src/validators/listing.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const createListingSchema = z.object({
|
||||||
|
title: z.string().min(3, 'Title must be at least 3 characters').max(100),
|
||||||
|
description: z.string().min(10, 'Description must be at least 10 characters').max(2000),
|
||||||
|
price: z.number().positive('Price must be positive'),
|
||||||
|
obo: z.boolean().optional().default(false),
|
||||||
|
category: z.enum(['ELECTRONICS', 'FURNITURE', 'CLOTHING', 'HOME_GARDEN', 'SPORTS', 'BOOKS', 'GAMES', 'VEHICLES', 'OTHER']),
|
||||||
|
condition: z.enum(['NEW', 'LIKE_NEW', 'GENTLY_USED', 'USED', 'FAIR']),
|
||||||
|
location: z.string().min(2, 'Location is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateListingSchema = createListingSchema.partial();
|
||||||
12
server/src/validators/offer.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const createOfferSchema = z.object({
|
||||||
|
amount: z.number().positive('Offer amount must be positive'),
|
||||||
|
message: z.string().max(500).optional(),
|
||||||
|
listingId: z.string().min(1, 'Listing ID is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const respondOfferSchema = z.object({
|
||||||
|
status: z.enum(['ACCEPTED', 'DECLINED', 'COUNTERED']),
|
||||||
|
counterAmount: z.number().positive().optional(),
|
||||||
|
});
|
||||||
21
server/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||