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>
This commit is contained in:
delta-lynx-89e8
2026-02-22 07:00:44 -08:00
commit b37b734c82
95 changed files with 10921 additions and 0 deletions

13
.claude/settings.json Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
MARKETPLACE/footer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
MARKETPLACE/hero3333.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
MARKETPLACE/log in.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
MARKETPLACE/makeoffer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
MARKETPLACE/memberchat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
MARKETPLACE/offers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
MARKETPLACE/payment.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
MARKETPLACE/sell_item.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
MARKETPLACE/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
MARKETPLACE/sign up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
MARKETPLACE/sold_items.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

73
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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();

View 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>
);
}

View 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">&copy; 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>
);
}

View 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>
);
}

View 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}</>;
}

View File

@@ -0,0 +1,4 @@
export { Header } from './Header';
export { Footer } from './Footer';
export { DashboardLayout } from './DashboardLayout';
export { RequireAuth } from './RequireAuth';

View 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>
);
}

View 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>
);
}

View 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 &gt;
</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>
);
}

View File

@@ -0,0 +1,3 @@
export { ListingCard } from './ListingCard';
export { ListingGrid } from './ListingGrid';
export { CategorySidebar } from './CategorySidebar';

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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>
);
}

View 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>
);
}

View 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';

View 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
View 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
View 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>,
);

View 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} &middot; {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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &gt;</Link>
</p>
</div>
</div>
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &gt;</Link>
</p>
</div>
</div>
</div>
);
}

View 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>
);
}

View 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
View 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
View 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;
}

View 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;

View 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',
});
}

View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
client/tsconfig.node.json Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

13
package.json Normal file
View 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
View 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
View 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])
}

View 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
View 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
View 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}`);
});

View 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();
}

View 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' });
}

View 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 },
});

View 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
View 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
View 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;

View 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;

View 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
View 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;

View 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
View 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;

View 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
View 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 };
}

View 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);
}

View 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'),
});

View 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();

View 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
View 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"]
}