QA fixes: real listing creation, profile save, favorites, missing pages

- SellItemPage: real file upload + API listing creation + activate
- CreateProfilePage: save profile via PUT /users/profile
- ProductDetailPage: wire edit/delete/message buttons, show edit for owner
- ListingCard: persist favorites via API, show real images
- Footer: connect newsletter subscribe to API
- Router: add /dashboard/listings and /dashboard/saved routes
- Backend: add GET /listings/favorites endpoint
- New pages: MyListingsPage, SavedItemsPage
- Fix unused imports causing build failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
delta-lynx-89e8
2026-02-22 12:30:03 -08:00
parent 6722d1d4a1
commit d09c998d51
41 changed files with 3152 additions and 383 deletions

68
CLAUDE.md Normal file
View File

@@ -0,0 +1,68 @@
# Marketplace Project Notes
## Database: PostgreSQL via Docker
Container name: `marketplace-postgres`
Image: `postgres:17-alpine`
Volume: `marketplace-pgdata` (persistent local data)
### Start database
```bash
docker start marketplace-postgres
```
### Stop database (frees memory)
```bash
docker stop marketplace-postgres
```
### Check status
```bash
docker ps -f name=marketplace-postgres
```
### Connection string
```
postgresql://marketplace:marketplace_dev@localhost:5432/marketplace
```
### If container was deleted, recreate:
```bash
docker run -d \
--name marketplace-postgres \
-e POSTGRES_USER=marketplace \
-e POSTGRES_PASSWORD=marketplace_dev \
-e POSTGRES_DB=marketplace \
-p 5432:5432 \
-v marketplace-pgdata:/var/lib/postgresql/data \
--restart unless-stopped \
postgres:17-alpine
```
Data persists in the `marketplace-pgdata` Docker volume even if the container is removed.
## Running the app
1. Start database: `docker start marketplace-postgres`
2. Server: `npm run dev:server` (port 3000)
3. Client: `npm run dev:client` (port 5173)
4. Or both: `npm run dev`
## Seed data
```bash
cd server && npx tsx prisma/seed.ts
```
Test users (all password: `password123`):
- alice.chen@example.com
- bob.smith@example.com
- carol.jones@example.com
- david.wilson@example.com
- eva.martinez@example.com
## Key env vars (server/.env)
- `DATABASE_URL` — PostgreSQL connection
- `GOOGLE_MAPS_API_KEY` — Location autocomplete
- `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` — Payments (optional for dev)

View File

@@ -1,28 +1,46 @@
import { NavLink, Outlet } from 'react-router-dom';
import { MessageSquare, Tag, Bell, ShoppingBag, Settings } from 'lucide-react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { MessageSquare, Tag, ShoppingBag, Settings, List, Heart, LogOut } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
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/listings', icon: List, label: 'My Listings' },
{ to: '/dashboard/saved', icon: Heart, label: 'Saved Items' },
{ to: '/dashboard/messages', icon: MessageSquare, label: 'My Messages' },
{ to: '/dashboard/settings', icon: Settings, label: 'Settings' },
];
export function DashboardLayout() {
const { logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/');
};
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">
<nav className="bg-gradient-to-b from-purple-100 via-purple-50 to-pink-50 rounded-2xl p-3 sticky top-24 shadow-sm">
{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'}`}>
className={({ isActive }) => `flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors ${isActive ? 'bg-white/70 text-primary-700 shadow-sm' : 'text-purple-700/70 hover:bg-white/40'}`}>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
<hr className="my-2 border-purple-200/50" />
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors text-purple-700/70 hover:bg-white/40 w-full cursor-pointer"
>
<LogOut className="w-4 h-4" />
Logout
</button>
</nav>
</aside>

View File

@@ -1,7 +1,25 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Facebook, Twitter, Instagram, Youtube, Package } from 'lucide-react';
import { api } from '../../api/client';
export function Footer() {
const [email, setEmail] = useState('');
const [subscribing, setSubscribing] = useState(false);
const [subscribed, setSubscribed] = useState(false);
const handleSubscribe = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || subscribing) return;
setSubscribing(true);
try {
await api.post('/newsletter', { email });
setSubscribed(true);
setEmail('');
} catch {}
setSubscribing(false);
};
return (
<footer className="bg-primary-900 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-12">
@@ -20,12 +38,18 @@ export function Footer() {
<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
{subscribed ? (
<p className="text-sm text-green-400">Thanks for subscribing!</p>
) : (
<form className="flex gap-2" onSubmit={handleSubscribe}>
<input type="email" placeholder="Your email address" value={email} onChange={(e) => setEmail(e.target.value)} required
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="submit" disabled={subscribing}
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 disabled:opacity-50">
{subscribing ? '...' : 'Subscribe'}
</button>
</form>
)}
</div>
</div>
@@ -36,7 +60,7 @@ export function Footer() {
<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>
<li><Link to="/dashboard/listings" className="hover:text-white transition-colors">My Listings</Link></li>
</ul>
</div>

View File

@@ -1,6 +1,6 @@
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 { Search, Bell, Menu, X, User, LogOut, ShoppingBag, Heart, MessageSquare, Settings } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { Avatar } from '../ui/Avatar';
@@ -19,15 +19,15 @@ export function Header() {
};
return (
<header className="bg-white border-b border-gray-100 sticky top-0 z-40">
<header className="bg-gradient-to-r from-primary-700 via-primary-600 to-primary-500 sticky top-0 z-40 shadow-lg">
<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 className="w-8 h-8 rounded-lg bg-white/20 backdrop-blur flex items-center justify-center">
<ShoppingBag 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">
<span className="text-lg font-bold text-white hidden sm:block">
MARKETPLACE
</span>
</Link>
@@ -35,14 +35,14 @@ export function Header() {
{/* 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" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
<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"
className="w-full pl-10 pr-4 py-2 rounded-xl border border-white/20 bg-white/10 text-sm text-white placeholder-white/50
focus:border-white/40 focus:ring-2 focus:ring-white/20 focus:outline-none transition-all backdrop-blur"
/>
</div>
</form>
@@ -51,15 +51,15 @@ export function Header() {
<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">
<Link to="/sell" className="hidden sm:inline-flex items-center px-4 py-2 text-sm font-semibold text-white rounded-xl bg-white/20 hover:bg-white/30 backdrop-blur 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 to="/dashboard/messages" className="p-2 rounded-lg hover:bg-white/10 transition-colors relative">
<MessageSquare className="w-5 h-5 text-white/80" />
</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 to="/dashboard/notifications" className="p-2 rounded-lg hover:bg-white/10 transition-colors relative">
<Bell className="w-5 h-5 text-white/80" />
<span className="absolute top-1 right-1 w-2 h-2 bg-pink-400 rounded-full" />
</Link>
<div className="relative">
<button onClick={() => setShowUserMenu(!showUserMenu)} className="flex items-center gap-2 cursor-pointer">
@@ -77,7 +77,7 @@ export function Header() {
<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
<ShoppingBag 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
@@ -96,28 +96,28 @@ export function Header() {
</>
) : (
<>
<Link to="/login" className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors">
Log In
<Link to="/login" className="px-4 py-2 text-sm font-medium text-white/80 hover:text-white transition-colors">
Login
</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">
<Link to="/signup" className="px-4 py-2 text-sm font-semibold text-primary-700 rounded-xl bg-white hover:bg-white/90 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 onClick={() => setShowMobileMenu(!showMobileMenu)} className="md:hidden p-2 rounded-lg hover:bg-white/10 cursor-pointer">
{showMobileMenu ? <X className="w-5 h-5 text-white" /> : <Menu className="w-5 h-5 text-white" />}
</button>
</div>
</div>
{/* Mobile menu */}
{showMobileMenu && (
<div className="md:hidden pb-4 border-t border-gray-100 pt-4">
<div className="md:hidden pb-4 border-t border-white/10 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" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
<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" />
className="w-full pl-10 pr-4 py-2 rounded-xl border border-white/20 bg-white/10 text-sm text-white placeholder-white/50 focus:border-white/40 focus:outline-none" />
</div>
</form>
{isAuthenticated && (

View File

@@ -19,12 +19,11 @@ interface CategorySidebarProps {
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>
<nav className="bg-gradient-to-b from-purple-50 to-pink-50/50 rounded-2xl p-3">
<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'}`}
className={`flex items-center gap-3 w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer
${!selected ? 'bg-white/70 text-primary-700 shadow-sm' : 'text-purple-700/70 hover:bg-white/40'}`}
>
<Package className="w-4 h-4" />
All Categories
@@ -33,13 +32,21 @@ export function CategorySidebar({ selected, onSelect }: CategorySidebarProps) {
<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'}`}
className={`flex items-center gap-3 w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer
${selected === value ? 'bg-white/70 text-primary-700 shadow-sm' : 'text-purple-700/70 hover:bg-white/40'}`}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
<hr className="my-2 border-purple-200/30" />
<a
href="/sell"
className="flex items-center justify-center gap-2 w-full px-3 py-2.5 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-pink-500 to-primary-600 hover:from-pink-600 hover:to-primary-700 transition-all cursor-pointer shadow-sm"
>
<Package className="w-4 h-4" />
Sell Item
</a>
</nav>
);
}

View File

@@ -2,6 +2,8 @@ import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Heart, MapPin } from 'lucide-react';
import { Badge } from '../ui/Badge';
import { api } from '../../api/client';
import { useAuth } from '../../context/AuthContext';
import type { Listing } from '../../types';
import { formatCurrency } from '../../utils/format';
@@ -10,34 +12,57 @@ interface ListingCardProps {
}
export function ListingCard({ listing }: ListingCardProps) {
const { isAuthenticated } = useAuth();
const [isFav, setIsFav] = useState(listing.isFavorited ?? false);
const [toggling, setToggling] = useState(false);
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' :
listing.category === 'SPORTS' ? '\uD83D\uDEB4' :
listing.category === 'BOOKS' ? '\uD83D\uDCDA' :
listing.category === 'GAMES' ? '\uD83C\uDFAE' : '\uD83D\uDCE6';
const handleFavorite = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isAuthenticated || toggling) return;
setToggling(true);
try {
const res = await api.post<{ isFavorited: boolean }>(`/listings/${listing.id}/favorite`);
setIsFav(res.isFavorited);
} catch {}
setToggling(false);
};
const hasImage = listing.images?.[0]?.url;
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">
{hasImage ? (
<img src={listing.images[0].url} alt={listing.title} className="w-full h-full object-cover" />
) : (
<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>
<span className="text-4xl">{categoryEmoji}</span>
</div>
)}
{isAuthenticated && (
<button
onClick={(e) => { e.preventDefault(); setIsFav(!isFav); }}
onClick={handleFavorite}
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 */}

View File

@@ -0,0 +1,109 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { MapPin } from 'lucide-react';
import { api } from '../../api/client';
interface Prediction {
description: string;
placeId: string;
}
interface LocationInputProps {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
error?: string;
required?: boolean;
}
export function LocationInput({ value, onChange, label, placeholder = 'Enter a location...', error, required }: LocationInputProps) {
const [predictions, setPredictions] = useState<Prediction[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState(value);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setInputValue(value);
}, [value]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const fetchPredictions = useCallback(async (input: string) => {
if (input.length < 2) {
setPredictions([]);
setIsOpen(false);
return;
}
try {
const data = await api.get<{ predictions: Prediction[] }>(`/location/autocomplete?input=${encodeURIComponent(input)}`);
setPredictions(data.predictions);
setIsOpen(data.predictions.length > 0);
} catch {
setPredictions([]);
}
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setInputValue(val);
onChange(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => fetchPredictions(val), 300);
};
const handleSelect = async (prediction: Prediction) => {
setInputValue(prediction.description);
onChange(prediction.description);
setIsOpen(false);
setPredictions([]);
};
return (
<div ref={containerRef} className="relative w-full">
{label && <label className="block text-sm font-medium text-gray-700 mb-1.5">{label}</label>}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<MapPin className="w-4 h-4" />
</div>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onFocus={() => predictions.length > 0 && setIsOpen(true)}
placeholder={placeholder}
required={required}
className={`w-full rounded-xl border border-gray-200 bg-white pl-10 pr-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 ${error ? 'border-red-300 focus:border-red-400 focus:ring-red-100' : ''}`}
/>
</div>
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
{isOpen && predictions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
{predictions.map((prediction) => (
<button
key={prediction.placeId}
type="button"
onClick={() => handleSelect(prediction)}
className="w-full px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-primary-50 transition-colors flex items-center gap-2 cursor-pointer"
>
<MapPin className="w-3.5 h-3.5 text-gray-400 shrink-0" />
{prediction.description}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -6,3 +6,4 @@ export { Card } from './Card';
export { Avatar } from './Avatar';
export { Toggle } from './Toggle';
export { Badge } from './Badge';
export { LocationInput } from './LocationInput';

View File

@@ -1,24 +1,60 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Avatar } from '../components/ui/Avatar';
import { mockConversations, mockMessages, mockCurrentUser } from '../utils/mockData';
import { api } from '../api/client';
import { useAuth } from '../context/AuthContext';
import { formatDate, formatCurrency } from '../utils/format';
import type { Conversation, Message } from '../types';
export function ChatPage() {
const [selectedConv, setSelectedConv] = useState(mockConversations[0]?.id);
const { user } = useAuth();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConv, setSelectedConv] = useState<string | undefined>();
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
const messages = mockMessages.filter(m => m.conversationId === selectedConv);
const activeConv = mockConversations.find(c => c.id === selectedConv);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<Conversation[]>('/chat/conversations')
.then(convs => {
setConversations(convs);
if (convs.length > 0 && !selectedConv) setSelectedConv(convs[0].id);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!selectedConv) return;
api.get<{ data: Message[] }>(`/chat/conversations/${selectedConv}/messages`)
.then(res => setMessages(res.data))
.catch(() => setMessages([]));
}, [selectedConv]);
const activeConv = conversations.find(c => c.id === selectedConv);
const otherUser = activeConv
? (activeConv.user1.id === mockCurrentUser.id ? activeConv.user2 : activeConv.user1)
? (activeConv.user1.id === user?.id ? activeConv.user2 : activeConv.user1)
: null;
const handleSend = (e: React.FormEvent) => {
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
if (!newMessage.trim() || !selectedConv || !activeConv) return;
const recipientId = activeConv.user1.id === user?.id ? activeConv.user2.id : activeConv.user1.id;
try {
await api.post('/chat/conversations', {
recipientId,
listingId: activeConv.listing?.id,
message: newMessage,
});
setNewMessage('');
// Refresh messages
const res = await api.get<{ data: Message[] }>(`/chat/conversations/${selectedConv}/messages`);
setMessages(res.data);
} catch {}
};
if (loading) return <div className="text-center text-gray-500 py-12">Loading conversations...</div>;
return (
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden" style={{ height: 'calc(100vh - 200px)' }}>
<div className="flex h-full">
@@ -28,8 +64,10 @@ export function ChatPage() {
<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;
{conversations.length === 0 ? (
<p className="p-4 text-sm text-gray-400">No conversations yet</p>
) : conversations.map(conv => {
const other = conv.user1.id === user?.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
@@ -55,7 +93,6 @@ export function ChatPage() {
<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>
@@ -66,10 +103,9 @@ export function ChatPage() {
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map(msg => {
const isMe = msg.senderId === mockCurrentUser.id;
const isMe = msg.senderId === user?.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
@@ -82,7 +118,6 @@ export function ChatPage() {
})}
</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..."

View File

@@ -1,22 +1,44 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User, Mail, Phone, MapPin, Camera } from 'lucide-react';
import { User, Mail, Phone, 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 { LocationInput } from '../components/ui/LocationInput';
import { api } from '../api/client';
import { useAuth } from '../context/AuthContext';
export function CreateProfilePage() {
const navigate = useNavigate();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const { user, updateUser } = useAuth();
const [fullName, setFullName] = useState(user?.fullName || '');
const [email, setEmail] = useState(user?.email || '');
const [phone, setPhone] = useState('');
const [location, setLocation] = useState('');
const [bio, setBio] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError('');
try {
const result = await api.put<{ id: string; fullName: string; phone?: string; location?: string; bio?: string }>('/users/profile', {
fullName,
phone: phone || undefined,
location: location || undefined,
bio: bio || undefined,
});
updateUser(result);
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save profile');
} finally {
setSaving(false);
}
};
return (
@@ -36,22 +58,27 @@ export function CreateProfilePage() {
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-600">{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 Address" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)}
icon={<Mail className="w-4 h-4" />} required />
icon={<Mail className="w-4 h-4" />} required disabled />
<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" />} />
<LocationInput label="Location" placeholder="E.g. city, state, zip code" value={location} onChange={setLocation} />
<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>
<GradientButton type="submit" className="w-full" size="lg" disabled={saving}>
{saving ? 'Saving...' : 'Create Profile'}
</GradientButton>
</form>
</Card>
</div>

View File

@@ -1,26 +1,33 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Search } from 'lucide-react';
import { ListingGrid } from '../components/listings/ListingGrid';
import { CategorySidebar } from '../components/listings/CategorySidebar';
import { mockListings } from '../utils/mockData';
import { api } from '../api/client';
import type { Listing, PaginatedResponse } from '../types';
const categoryTags = ['Furniture', 'Electronics', 'Clothing', 'Books', 'Games'];
export function HomePage() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | undefined>();
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
const filtered = selectedCategory
? mockListings.filter(l => l.category === selectedCategory)
: mockListings;
useEffect(() => {
setLoading(true);
const params = new URLSearchParams();
params.set('pageSize', '20');
if (selectedCategory) params.set('category', selectedCategory);
if (searchQuery) params.set('search', searchQuery);
api.get<PaginatedResponse<Listing>>(`/listings?${params}`)
.then(res => setListings(res.data))
.catch(() => setListings([]))
.finally(() => setLoading(false));
}, [selectedCategory, searchQuery]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
navigate(`/?search=${encodeURIComponent(searchQuery)}`);
}
};
return (
@@ -62,34 +69,24 @@ export function HomePage() {
{/* 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
/>
{loading ? (
<p className="text-gray-500 text-center py-12">Loading listings...</p>
) : listings.length === 0 ? (
<p className="text-gray-500 text-center py-12">No listings found.</p>
) : (
<>
<ListingGrid listings={listings.slice(0, 4)} title="Popular Items" showViewAll />
<ListingGrid listings={listings.slice(4, 8)} title="Trending Items" showViewAll />
{listings.length > 8 && <ListingGrid listings={listings.slice(8, 12)} title="Newly Listed" showViewAll />}
</>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Plus, Edit2, Trash2, Eye } from 'lucide-react';
import { Card } from '../components/ui/Card';
import { Badge } from '../components/ui/Badge';
import { GradientButton } from '../components/ui/GradientButton';
import { api } from '../api/client';
import { formatCurrency, formatDate } from '../utils/format';
import type { Listing } from '../types';
export function MyListingsPage() {
const navigate = useNavigate();
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<Listing[]>('/listings/mine')
.then(setListings)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this listing?')) return;
try {
await api.delete(`/listings/${id}`);
setListings(prev => prev.filter(l => l.id !== id));
} catch {}
};
const statusVariant = (status: string) => {
switch (status) {
case 'ACTIVE': return 'success';
case 'DRAFT': return 'default';
case 'SOLD': return 'info';
default: return 'default';
}
};
if (loading) return <div className="text-center py-12 text-gray-500">Loading...</div>;
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">My Listings</h1>
<GradientButton size="sm" onClick={() => navigate('/sell')}>
<Plus className="w-4 h-4 mr-1" /> New Listing
</GradientButton>
</div>
{listings.length === 0 ? (
<Card>
<div className="text-center py-8">
<p className="text-gray-500 mb-4">You haven't listed any items yet.</p>
<GradientButton onClick={() => navigate('/sell')}>Sell Your First Item</GradientButton>
</div>
</Card>
) : (
<div className="space-y-3">
{listings.map(listing => (
<Card key={listing.id}>
<div className="flex items-center gap-4">
<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 overflow-hidden">
{listing.images?.[0]?.url ? (
<img src={listing.images[0].url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-2xl">
{listing.category === 'FURNITURE' ? '\uD83E\uDE91' :
listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' :
listing.category === 'CLOTHING' ? '\uD83D\uDC55' : '\uD83D\uDCE6'}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 truncate">{listing.title}</h3>
<Badge variant={statusVariant(listing.status)} size="sm">{listing.status}</Badge>
</div>
<p className="text-sm text-primary-600 font-bold">{formatCurrency(listing.price)}</p>
<p className="text-xs text-gray-400">{formatDate(listing.createdAt)}</p>
</div>
<div className="flex items-center gap-1">
<Link to={`/listings/${listing.id}`}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
<Eye className="w-4 h-4" />
</Link>
<Link to={`/listings/${listing.id}`}
className="p-2 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors">
<Edit2 className="w-4 h-4" />
</Link>
<button onClick={() => handleDelete(listing.id)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -1,13 +1,35 @@
import { useState } from 'react';
import { useState, useEffect } 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 { api } from '../api/client';
import { formatCurrency, formatDate } from '../utils/format';
import type { Offer } from '../types';
export function MyOffersPage() {
const [sortBy] = useState('newest');
const [offers, setOffers] = useState<Offer[]>([]);
const [sortBy, setSortBy] = useState('newest');
const [loading, setLoading] = useState(true);
const fetchOffers = () => {
setLoading(true);
api.get<Offer[]>(`/offers?type=received&sort=${sortBy}`)
.then(setOffers)
.catch(() => setOffers([]))
.finally(() => setLoading(false));
};
useEffect(() => { fetchOffers(); }, [sortBy]);
const handleRespond = async (offerId: string, status: string, counterAmount?: number) => {
try {
await api.patch(`/offers/${offerId}`, { status, counterAmount });
fetchOffers();
} catch {}
};
if (loading) return <div className="text-center text-gray-500 py-12">Loading offers...</div>;
return (
<div>
@@ -16,28 +38,34 @@ export function MyOffersPage() {
<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">
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}
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>
{offers.length === 0 ? (
<p className="text-center text-gray-400 py-12">No offers yet</p>
) : (
<div className="space-y-3">
{mockOffers.map(offer => {
{offers.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">
<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 overflow-hidden">
{offer.listing.images?.[0] ? (
<img src={offer.listing.images[0].url} alt="" className="w-full h-full object-cover" />
) : (
<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">
@@ -47,19 +75,21 @@ export function MyOffersPage() {
</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>
{savings > 0 && <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>
<Button variant="secondary" size="sm" onClick={() => handleRespond(offer.id, 'ACCEPTED')}>Accept</Button>
<Button variant="secondary" size="sm" onClick={() => handleRespond(offer.id, 'DECLINED')}>Decline</Button>
<GradientButton size="sm" onClick={() => {
const amount = prompt('Enter counter amount:');
if (amount) handleRespond(offer.id, 'COUNTERED', parseFloat(amount));
}}>Counter</GradientButton>
</>
) : (
<Badge variant={statusVariant} size="md">{offer.status}</Badge>
@@ -69,6 +99,7 @@ export function MyOffersPage() {
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { Bell, Check, Heart, Star, MessageSquare, Tag } from 'lucide-react';
import { Button } from '../components/ui/Button';
import { mockNotifications } from '../utils/mockData';
import { api } from '../api/client';
import { formatDate } from '../utils/format';
import type { NotificationType } from '../types';
import type { NotificationType, Notification, PaginatedResponse } from '../types';
const iconMap: Record<NotificationType, typeof Bell> = {
NEW_OFFER: Tag,
@@ -23,17 +24,39 @@ const iconColorMap: Record<NotificationType, string> = {
};
export function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(true);
const fetchNotifications = () => {
api.get<PaginatedResponse<Notification>>('/notifications')
.then(res => setNotifications(res.data))
.catch(() => setNotifications([]))
.finally(() => setLoading(false));
};
useEffect(() => { fetchNotifications(); }, []);
const handleMarkAllRead = async () => {
await api.patch('/notifications/read-all');
fetchNotifications();
};
if (loading) return <div className="text-center text-gray-500 py-12">Loading notifications...</div>;
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>
<Button variant="ghost" size="sm" onClick={handleMarkAllRead}>Mark All As Read</Button>
</div>
{notifications.length === 0 ? (
<p className="text-center text-gray-400 py-12">No notifications</p>
) : (
<div className="space-y-2">
{mockNotifications.map(notif => {
{notifications.map(notif => {
const Icon = iconMap[notif.type] || Bell;
const colorClass = iconColorMap[notif.type] || 'text-gray-500 bg-gray-50';
@@ -56,6 +79,7 @@ export function NotificationsPage() {
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag, Pencil } from 'lucide-react';
import { Card } from '../components/ui/Card';
import { GradientButton } from '../components/ui/GradientButton';
import { Button } from '../components/ui/Button';
@@ -8,19 +8,124 @@ 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 { api } from '../api/client';
import { useAuth } from '../context/AuthContext';
import { formatCurrency, formatDate } from '../utils/format';
import type { Listing } from '../types';
export function ProductDetailPage() {
const { id } = useParams();
const listing = mockListings.find(l => l.id === id) || mockListings[0]!;
const [isFav, setIsFav] = useState(listing.isFavorited ?? false);
const navigate = useNavigate();
const { user, isAuthenticated } = useAuth();
const [listing, setListing] = useState<Listing | null>(null);
const [loading, setLoading] = useState(true);
const [isFav, setIsFav] = useState(false);
const [showOffer, setShowOffer] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [offerAmount, setOfferAmount] = useState('');
const [offerMessage, setOfferMessage] = useState('');
const [offerError, setOfferError] = useState('');
// Edit form state
const [editTitle, setEditTitle] = useState('');
const [editPrice, setEditPrice] = useState('');
const [editCondition, setEditCondition] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState('');
useEffect(() => {
if (!id) return;
api.get<Listing>(`/listings/${id}`)
.then(data => {
setListing(data);
setIsFav(data.isFavorited ?? false);
})
.catch(() => setListing(null))
.finally(() => setLoading(false));
}, [id]);
const handleFavorite = async () => {
if (!listing || !isAuthenticated) return;
try {
const res = await api.post<{ isFavorited: boolean }>(`/listings/${listing.id}/favorite`);
setIsFav(res.isFavorited);
} catch {}
};
const handleSendOffer = async () => {
if (!listing || !offerAmount) return;
setOfferError('');
try {
await api.post('/offers', {
amount: parseFloat(offerAmount),
message: offerMessage || undefined,
listingId: listing.id,
});
setShowOffer(false);
setOfferAmount('');
setOfferMessage('');
} catch (err: unknown) {
setOfferError(err instanceof Error ? err.message : 'Failed to send offer');
}
};
const handleOpenEdit = () => {
if (!listing) return;
setEditTitle(listing.title);
setEditPrice(String(listing.price));
setEditCondition(listing.condition);
setEditDescription(listing.description);
setEditError('');
setShowEdit(true);
};
const handleSaveEdit = async () => {
if (!listing) return;
setEditSaving(true);
setEditError('');
try {
const updated = await api.put<Listing>(`/listings/${listing.id}`, {
title: editTitle,
price: parseFloat(editPrice),
condition: editCondition,
description: editDescription,
});
setListing(updated);
setShowEdit(false);
} catch (err) {
setEditError(err instanceof Error ? err.message : 'Failed to save changes');
} finally {
setEditSaving(false);
}
};
const handleDelete = async () => {
if (!listing) return;
if (!confirm('Are you sure you want to delete this listing?')) return;
try {
await api.delete(`/listings/${listing.id}`);
navigate('/');
} catch {}
};
const handleMessage = async () => {
if (!listing || !isAuthenticated) return;
try {
const conversation = await api.post<{ id: string }>('/chat/conversations', {
recipientId: listing.seller.id,
listingId: listing.id,
});
navigate('/dashboard/messages', { state: { conversationId: conversation.id } });
} catch {}
};
if (loading) return <div className="max-w-7xl mx-auto px-4 py-12 text-center text-gray-500">Loading...</div>;
if (!listing) return <div className="max-w-7xl mx-auto px-4 py-12 text-center text-gray-500">Listing not found</div>;
const isOwner = user?.id === listing.sellerId;
const conditionVariant = listing.condition === 'NEW' ? 'success' : listing.condition === 'LIKE_NEW' ? 'info' : 'default';
const hasImages = listing.images && listing.images.length > 0;
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 (
@@ -28,13 +133,21 @@ export function ProductDetailPage() {
<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">
<div className="aspect-square bg-gradient-to-br from-primary-50 to-pink-50 rounded-2xl flex items-center justify-center mb-4 overflow-hidden">
{hasImages ? (
<img src={listing.images[0].url} alt={listing.title} className="w-full h-full object-cover" />
) : (
<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">
{(hasImages ? listing.images.slice(0, 4) : [0, 1, 2, 3]).map((img, 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 overflow-hidden">
{typeof img === 'object' && 'url' in img ? (
<img src={img.url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-2xl">{categoryEmoji}</span>
)}
</div>
))}
</div>
@@ -53,48 +166,57 @@ export function ProductDetailPage() {
</span>
</div>
</div>
<button onClick={() => setIsFav(!isFav)} className="p-2 rounded-xl hover:bg-gray-100 transition-colors cursor-pointer">
<div className="flex items-center gap-1">
{isOwner && (
<button onClick={handleOpenEdit} className="p-2 rounded-xl hover:bg-gray-100 transition-colors cursor-pointer" title="Edit listing">
<Pencil className="w-5 h-5 text-gray-400" />
</button>
)}
<button onClick={handleFavorite} 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>
</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 */}
{!isOwner && (
<div className="flex gap-3">
<GradientButton className="flex-1" size="lg" onClick={() => setShowOffer(true)}>
Make Offer
</GradientButton>
<Button variant="outline" size="lg" onClick={() => {}}>
<Button variant="outline" size="lg" onClick={handleMessage}>
<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">
{listing.seller.rating !== undefined && (
<>
<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">
@@ -112,6 +234,7 @@ export function ProductDetailPage() {
{/* 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>
{offerError && <p className="text-sm text-red-500 mb-3">{offerError}</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>
@@ -123,30 +246,35 @@ export function ProductDetailPage() {
</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>
<GradientButton className="flex-1" onClick={handleSendOffer}>Send Offer</GradientButton>
</div>
</Modal>
{/* Edit Item Modal */}
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Item Info" size="md">
{editError && <p className="text-sm text-red-500 mb-3">{editError}</p>}
<div className="space-y-4 mb-6">
<Input label="Title" defaultValue={listing.title} />
<Input label="Price" type="number" defaultValue={String(listing.price)} />
<Input label="Title" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} />
<Input label="Price" type="number" value={editPrice} onChange={(e) => setEditPrice(e.target.value)} />
<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">
<select value={editCondition} onChange={(e) => setEditCondition(e.target.value)}
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" />
<textarea value={editDescription} onChange={(e) => setEditDescription(e.target.value)} 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="danger" className="mr-auto" onClick={handleDelete}>Delete Listing</Button>
<Button variant="secondary" onClick={() => setShowEdit(false)}>Cancel</Button>
<GradientButton onClick={() => setShowEdit(false)}>Save Changes</GradientButton>
<GradientButton onClick={handleSaveEdit} disabled={editSaving}>
{editSaving ? 'Saving...' : 'Save Changes'}
</GradientButton>
</div>
</Modal>
</div>

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from 'react';
import { Card } from '../components/ui/Card';
import { ListingCard } from '../components/listings/ListingCard';
import { api } from '../api/client';
import type { Listing, PaginatedResponse } from '../types';
export function SavedItemsPage() {
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<PaginatedResponse<Listing>>('/listings/favorites')
.then(res => setListings(res.data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="text-center py-12 text-gray-500">Loading...</div>;
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Saved Items</h1>
{listings.length === 0 ? (
<Card>
<div className="text-center py-8">
<p className="text-gray-500">No saved items yet. Browse listings and tap the heart to save items you like.</p>
</div>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{listings.map(listing => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
)}
</div>
);
}

View File

@@ -1,15 +1,16 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Upload, X, Camera, DollarSign, MapPin } from 'lucide-react';
import { Upload, X, DollarSign } 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';
import { LocationInput } from '../components/ui/LocationInput';
import { CATEGORIES, CONDITIONS } from '../utils/constants';
import { api } from '../api/client';
export function SellItemPage() {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('');
@@ -17,27 +18,65 @@ export function SellItemPage() {
const [condition, setCondition] = useState('');
const [location, setLocation] = useState('');
const [obo, setObo] = useState(false);
const [photos, setPhotos] = useState<string[]>([]);
const [showPayment, setShowPayment] = useState(false);
const [photos, setPhotos] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const handleAddPhoto = () => {
if (photos.length < 6) {
setPhotos([...photos, `photo-${photos.length + 1}`]);
}
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const remaining = 6 - photos.length;
const newFiles = files.slice(0, remaining);
if (newFiles.length === 0) return;
setPhotos(prev => [...prev, ...newFiles]);
setPreviews(prev => [...prev, ...newFiles.map(f => URL.createObjectURL(f))]);
e.target.value = '';
};
const handleRemovePhoto = (index: number) => {
URL.revokeObjectURL(previews[index]);
setPhotos(photos.filter((_, i) => i !== index));
setPreviews(previews.filter((_, i) => i !== index));
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setShowPayment(true);
};
setError('');
setSubmitting(true);
const handlePayment = () => {
setShowPayment(false);
navigate('/');
try {
// 1. Create the listing
const listing = await api.post<{ id: string }>('/listings', {
title,
description,
price: parseFloat(price),
category,
condition,
location,
obo,
});
// 2. Upload images if any
if (photos.length > 0) {
const formData = new FormData();
photos.forEach(file => formData.append('images', file));
await api.upload(`/listings/${listing.id}/images`, formData);
}
// 3. Activate the listing
await api.post(`/listings/${listing.id}/activate`);
navigate(`/listings/${listing.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create listing');
} finally {
setSubmitting(false);
}
};
return (
@@ -46,15 +85,20 @@ export function SellItemPage() {
<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>
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-600">{error}</div>
)}
<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>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleFileChange} />
<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" />
{previews.map((src, i) => (
<div key={i} className="relative aspect-square rounded-xl overflow-hidden">
<img src={src} alt="" className="w-full h-full object-cover" />
<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" />
@@ -113,37 +157,14 @@ export function SellItemPage() {
{/* 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" />} />
<LocationInput placeholder="E.g. city, state, zip code" value={location} onChange={setLocation} required />
</div>
<GradientButton type="submit" className="w-full" size="lg">
Submit Listing
<GradientButton type="submit" className="w-full" size="lg" disabled={submitting}>
{submitting ? 'Submitting...' : '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

@@ -1,17 +1,56 @@
import { useState } from 'react';
import { useState, useEffect } 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';
import { api } from '../api/client';
interface Settings {
showOnline: boolean;
showRating: boolean;
twoFactorEnabled: boolean;
notifEmail: boolean;
marketingEmail: boolean;
}
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);
const [settings, setSettings] = useState<Settings>({
showOnline: true,
showRating: true,
twoFactorEnabled: false,
notifEmail: true,
marketingEmail: false,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get<Settings>('/users/settings')
.then(setSettings)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
try {
const updated = await api.put<Settings>('/users/settings', {
showOnline: settings.showOnline,
showRating: settings.showRating,
notifEmail: settings.notifEmail,
marketingEmail: settings.marketingEmail,
});
setSettings(updated);
} catch {}
setSaving(false);
};
const update = (key: keyof Settings, value: boolean) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
if (loading) return <div className="text-center text-gray-500 py-12">Loading settings...</div>;
return (
<div className="space-y-6">
@@ -20,7 +59,6 @@ export function SettingsPage() {
<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">
@@ -29,8 +67,8 @@ export function SettingsPage() {
<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" />
<Toggle checked={settings.showOnline} onChange={(v) => update('showOnline', v)} label="Show Online Status" description="Let others see when you're online" />
<Toggle checked={settings.showRating} onChange={(v) => update('showRating', v)} 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>
@@ -41,7 +79,6 @@ export function SettingsPage() {
</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">
@@ -50,7 +87,7 @@ export function SettingsPage() {
<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" />
<Toggle checked={settings.twoFactorEnabled} onChange={(v) => update('twoFactorEnabled', v)} 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>
@@ -68,7 +105,6 @@ export function SettingsPage() {
</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">
@@ -77,9 +113,8 @@ export function SettingsPage() {
<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" />
<Toggle checked={settings.notifEmail} onChange={(v) => update('notifEmail', v)} label="Notification Emails" description="Receive email notifications" />
<Toggle checked={settings.marketingEmail} onChange={(v) => update('marketingEmail', v)} label="Marketing Emails" description="Receive promotional emails" />
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700">Privacy Policy</div>
@@ -97,12 +132,13 @@ export function SettingsPage() {
</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>
<GradientButton size="md" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</GradientButton>
</div>
</div>
);

View File

@@ -1,12 +1,44 @@
import { useState, useEffect } from 'react';
import { Card } from '../components/ui/Card';
import { Avatar } from '../components/ui/Avatar';
import { mockSoldItems } from '../utils/mockData';
import { api } from '../api/client';
import { formatCurrency, formatDate } from '../utils/format';
import { DollarSign, TrendingUp } from 'lucide-react';
interface SoldItem {
id: string;
title: string;
price: number;
category: string;
images: Array<{ url: string }>;
salePrice: number;
buyer: { fullName: string; avatar?: string } | null;
soldDate: string;
}
interface SoldResponse {
data: SoldItem[];
stats: { totalSold: number; totalEarnings: number };
}
export function SoldItemsPage() {
const totalValue = mockSoldItems.reduce((sum, item) => sum + item.listing.price, 0);
const totalEarnings = mockSoldItems.reduce((sum, item) => sum + item.salePrice, 0);
const [soldItems, setSoldItems] = useState<SoldItem[]>([]);
const [stats, setStats] = useState({ totalSold: 0, totalEarnings: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<SoldResponse>('/listings/sold')
.then(res => {
setSoldItems(res.data);
setStats(res.stats);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const totalValue = soldItems.reduce((sum, item) => sum + item.price, 0);
if (loading) return <div className="text-center text-gray-500 py-12">Loading sold items...</div>;
return (
<div>
@@ -30,7 +62,7 @@ export function SoldItemsPage() {
</div>
<div>
<p className="text-sm text-gray-500">Total Earnings</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalEarnings)}</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(stats.totalEarnings)}</p>
</div>
</div>
</Card>
@@ -46,7 +78,9 @@ export function SoldItemsPage() {
</select>
</div>
{/* Items */}
{soldItems.length === 0 ? (
<p className="text-center text-gray-400 py-12">No sold items yet</p>
) : (
<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>
@@ -55,26 +89,37 @@ export function SoldItemsPage() {
<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">
{soldItems.map(item => (
<div key={item.id} 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">
<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 overflow-hidden">
{item.images?.[0] ? (
<img src={item.images[0].url} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-xl">
{item.listing.category === 'FURNITURE' ? '\uD83E\uDE91' : item.listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' : item.listing.category === 'CLOTHING' ? '\uD83D\uDC55' : '\u2615'}
{item.category === 'FURNITURE' ? '\uD83E\uDE91' : item.category === 'ELECTRONICS' ? '\uD83C\uDFA7' : item.category === 'CLOTHING' ? '\uD83D\uDC55' : '\u2615'}
</span>
)}
</div>
<span className="text-sm font-medium text-gray-900 truncate">{item.listing.title}</span>
<span className="text-sm font-medium text-gray-900 truncate">{item.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 text-gray-500">{formatCurrency(item.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">
{item.buyer ? (
<>
<Avatar name={item.buyer.fullName} size="sm" />
<span className="text-sm text-gray-600 truncate">{item.buyer.fullName}</span>
</>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</div>
<div className="col-span-2 text-sm text-gray-400">{formatDate(item.date)}</div>
<div className="col-span-2 text-sm text-gray-400">{formatDate(item.soldDate)}</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -6,26 +6,35 @@ 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 { LocationInput } from '../components/ui/LocationInput';
import { useAuth } from '../context/AuthContext';
import { api } from '../api/client';
export function UpdateProfilePage() {
const navigate = useNavigate();
const { user } = useAuth();
const currentUser = user || mockCurrentUser;
const { user, updateUser } = useAuth();
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 [fullName, setFullName] = useState(user?.fullName || '');
const [email] = useState(user?.email || '');
const [phone, setPhone] = useState(user?.phone || '');
const [location, setLocation] = useState(user?.location || '');
const [bio, setBio] = useState(user?.bio || '');
const [showEmail, setShowEmail] = useState(user?.showEmail ?? false);
const [showPhone, setShowPhone] = useState(user?.showPhone ?? true);
const [showLocation, setShowLocation] = useState(user?.showLocation ?? true);
const [saving, setSaving] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const updated = await api.put('/users/profile', {
fullName, phone, location, bio, showEmail, showPhone, showLocation,
});
updateUser(updated as Record<string, unknown>);
navigate('/');
} catch {}
setSaving(false);
};
return (
@@ -68,7 +77,7 @@ export function UpdateProfilePage() {
<div className="flex items-end gap-3">
<div className="flex-1">
<Input label="Location" value={location} onChange={(e) => setLocation(e.target.value)} />
<LocationInput label="Location" value={location} onChange={setLocation} />
</div>
<div className="pb-0.5">
<Toggle checked={showLocation} onChange={setShowLocation} label="Public" />
@@ -81,7 +90,9 @@ export function UpdateProfilePage() {
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>
<GradientButton type="submit" className="w-full" size="lg" disabled={saving}>
{saving ? 'Saving...' : 'Save Changes'}
</GradientButton>
</form>
</Card>
</div>

View File

@@ -14,6 +14,8 @@ import { MyOffersPage } from './pages/MyOffersPage';
import { NotificationsPage } from './pages/NotificationsPage';
import { SoldItemsPage } from './pages/SoldItemsPage';
import { SettingsPage } from './pages/SettingsPage';
import { MyListingsPage } from './pages/MyListingsPage';
import { SavedItemsPage } from './pages/SavedItemsPage';
export const router = createBrowserRouter([
{
@@ -36,6 +38,8 @@ export const router = createBrowserRouter([
{ path: 'notifications', element: <NotificationsPage /> },
{ path: 'sold', element: <SoldItemsPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'listings', element: <MyListingsPage /> },
{ path: 'saved', element: <SavedItemsPage /> },
],
},
],

View File

@@ -13,7 +13,7 @@ export type ListingCondition = 'NEW' | 'LIKE_NEW' | 'GENTLY_USED' | 'USED' | 'FA
export type ListingStatus = 'DRAFT' | 'ACTIVE' | 'SOLD' | 'DELETED';
export type OfferStatus = 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'COUNTERED';
export type OfferStatus = 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'COUNTERED' | 'CANCELLED' | 'EXPIRED';
export type NotificationType = 'NEW_OFFER' | 'OFFER_ACCEPTED' | 'OFFER_DECLINED' | 'ITEM_SOLD' | 'NEW_MESSAGE' | 'ITEM_FAVORITED';
@@ -27,6 +27,7 @@ export interface User {
location?: string;
bio?: string;
rating?: number;
ratingCount?: number;
createdAt: string;
showEmail: boolean;
showPhone: boolean;
@@ -64,6 +65,7 @@ export interface Offer {
message?: string;
status: OfferStatus;
counterAmount?: number;
expiresAt?: string;
buyer: User;
buyerId: string;
seller: User;

2
package-lock.json generated
View File

@@ -2952,7 +2952,6 @@
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -6443,6 +6442,7 @@
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.0.0",

View File

@@ -0,0 +1,313 @@
-- CreateEnum
CREATE TYPE "Category" AS ENUM ('ELECTRONICS', 'FURNITURE', 'CLOTHING', 'HOME_GARDEN', 'SPORTS', 'BOOKS', 'GAMES', 'VEHICLES', 'OTHER');
-- CreateEnum
CREATE TYPE "ListingCondition" AS ENUM ('NEW', 'LIKE_NEW', 'GENTLY_USED', 'USED', 'FAIR');
-- CreateEnum
CREATE TYPE "ListingStatus" AS ENUM ('DRAFT', 'ACTIVE', 'SOLD', 'DELETED');
-- CreateEnum
CREATE TYPE "OfferStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DECLINED', 'COUNTERED', 'CANCELLED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('NEW_OFFER', 'OFFER_ACCEPTED', 'OFFER_DECLINED', 'ITEM_SOLD', 'NEW_MESSAGE', 'ITEM_FAVORITED');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'COMPLETED', 'FAILED', 'REFUNDED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"nickname" TEXT,
"avatar" TEXT,
"phone" TEXT,
"location" TEXT,
"bio" TEXT,
"rating" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"showEmail" BOOLEAN NOT NULL DEFAULT false,
"showPhone" BOOLEAN NOT NULL DEFAULT true,
"showLocation" BOOLEAN NOT NULL DEFAULT true,
"showOnline" BOOLEAN NOT NULL DEFAULT true,
"showRating" BOOLEAN NOT NULL DEFAULT true,
"twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"notifNewOffer" BOOLEAN NOT NULL DEFAULT true,
"notifMessages" BOOLEAN NOT NULL DEFAULT true,
"notifItemSold" BOOLEAN NOT NULL DEFAULT true,
"notifFavorites" BOOLEAN NOT NULL DEFAULT true,
"notifEmail" BOOLEAN NOT NULL DEFAULT true,
"marketingEmail" BOOLEAN NOT NULL DEFAULT false,
"resetToken" TEXT,
"resetTokenExpiry" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"userAgent" TEXT,
"ipAddress" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Listing" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"price" DOUBLE PRECISION NOT NULL,
"obo" BOOLEAN NOT NULL DEFAULT false,
"category" "Category" NOT NULL,
"condition" "ListingCondition" NOT NULL,
"status" "ListingStatus" NOT NULL DEFAULT 'DRAFT',
"location" TEXT NOT NULL,
"viewCount" INTEGER NOT NULL DEFAULT 0,
"sellerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Listing_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ListingImage" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"listingId" TEXT NOT NULL,
"uploadedBy" TEXT NOT NULL,
CONSTRAINT "ListingImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Offer" (
"id" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"message" TEXT,
"status" "OfferStatus" NOT NULL DEFAULT 'PENDING',
"counterAmount" DOUBLE PRECISION,
"expiresAt" TIMESTAMP(3),
"buyerId" TEXT NOT NULL,
"sellerId" TEXT NOT NULL,
"listingId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Offer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Conversation" (
"id" TEXT NOT NULL,
"user1Id" TEXT NOT NULL,
"user2Id" TEXT NOT NULL,
"listingId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Conversation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Message" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"conversationId" TEXT NOT NULL,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"offerAmount" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Favorite" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"listingId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Favorite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"data" JSONB,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"listingId" TEXT NOT NULL,
"stripePaymentId" TEXT,
"amount" DOUBLE PRECISION NOT NULL,
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BlockedUser" (
"id" TEXT NOT NULL,
"blockerId" TEXT NOT NULL,
"blockedId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BlockedUser_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_resetToken_key" ON "User"("resetToken");
-- CreateIndex
CREATE UNIQUE INDEX "Session_refreshToken_key" ON "Session"("refreshToken");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Listing_sellerId_idx" ON "Listing"("sellerId");
-- CreateIndex
CREATE INDEX "Listing_category_idx" ON "Listing"("category");
-- CreateIndex
CREATE INDEX "Listing_status_idx" ON "Listing"("status");
-- CreateIndex
CREATE INDEX "Listing_createdAt_idx" ON "Listing"("createdAt");
-- CreateIndex
CREATE INDEX "ListingImage_listingId_idx" ON "ListingImage"("listingId");
-- CreateIndex
CREATE INDEX "Offer_buyerId_idx" ON "Offer"("buyerId");
-- CreateIndex
CREATE INDEX "Offer_sellerId_idx" ON "Offer"("sellerId");
-- CreateIndex
CREATE INDEX "Offer_listingId_idx" ON "Offer"("listingId");
-- CreateIndex
CREATE INDEX "Conversation_user1Id_idx" ON "Conversation"("user1Id");
-- CreateIndex
CREATE INDEX "Conversation_user2Id_idx" ON "Conversation"("user2Id");
-- CreateIndex
CREATE UNIQUE INDEX "Conversation_user1Id_user2Id_listingId_key" ON "Conversation"("user1Id", "user2Id", "listingId");
-- CreateIndex
CREATE INDEX "Message_conversationId_idx" ON "Message"("conversationId");
-- CreateIndex
CREATE INDEX "Message_senderId_idx" ON "Message"("senderId");
-- CreateIndex
CREATE UNIQUE INDEX "Favorite_userId_listingId_key" ON "Favorite"("userId", "listingId");
-- CreateIndex
CREATE INDEX "Notification_userId_idx" ON "Notification"("userId");
-- CreateIndex
CREATE INDEX "Notification_createdAt_idx" ON "Notification"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "Payment_stripePaymentId_key" ON "Payment"("stripePaymentId");
-- CreateIndex
CREATE INDEX "Payment_userId_idx" ON "Payment"("userId");
-- CreateIndex
CREATE INDEX "Payment_listingId_idx" ON "Payment"("listingId");
-- CreateIndex
CREATE UNIQUE INDEX "BlockedUser_blockerId_blockedId_key" ON "BlockedUser"("blockerId", "blockedId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Listing" ADD CONSTRAINT "Listing_sellerId_fkey" FOREIGN KEY ("sellerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ListingImage" ADD CONSTRAINT "ListingImage_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ListingImage" ADD CONSTRAINT "ListingImage_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Offer" ADD CONSTRAINT "Offer_buyerId_fkey" FOREIGN KEY ("buyerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Offer" ADD CONSTRAINT "Offer_sellerId_fkey" FOREIGN KEY ("sellerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Offer" ADD CONSTRAINT "Offer_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_user1Id_fkey" FOREIGN KEY ("user1Id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_user2Id_fkey" FOREIGN KEY ("user2Id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Favorite" ADD CONSTRAINT "Favorite_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BlockedUser" ADD CONSTRAINT "BlockedUser_blockerId_fkey" FOREIGN KEY ("blockerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BlockedUser" ADD CONSTRAINT "BlockedUser_blockedId_fkey" FOREIGN KEY ("blockedId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -39,6 +39,8 @@ enum OfferStatus {
ACCEPTED
DECLINED
COUNTERED
CANCELLED
EXPIRED
}
enum NotificationType {
@@ -76,6 +78,14 @@ model User {
showRating Boolean @default(true)
twoFactorEnabled Boolean @default(false)
isActive Boolean @default(true)
notifNewOffer Boolean @default(true)
notifMessages Boolean @default(true)
notifItemSold Boolean @default(true)
notifFavorites Boolean @default(true)
notifEmail Boolean @default(true)
marketingEmail Boolean @default(false)
resetToken String? @unique
resetTokenExpiry DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -155,6 +165,7 @@ model Offer {
message String?
status OfferStatus @default(PENDING)
counterAmount Float?
expiresAt DateTime?
buyerId String
sellerId String
listingId String

793
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,793 @@
import { PrismaClient } from '@prisma/client';
import bcryptjs from 'bcryptjs';
const { hashSync } = bcryptjs;
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
const passwordHash = hashSync('password123', 10);
// ── Clear existing data (reverse dependency order) ──────────────────
await prisma.$transaction([
prisma.message.deleteMany(),
prisma.conversation.deleteMany(),
prisma.notification.deleteMany(),
prisma.payment.deleteMany(),
prisma.favorite.deleteMany(),
prisma.offer.deleteMany(),
prisma.listingImage.deleteMany(),
prisma.listing.deleteMany(),
prisma.blockedUser.deleteMany(),
prisma.session.deleteMany(),
prisma.user.deleteMany(),
]);
console.log('Cleared existing data.');
// ── Users ───────────────────────────────────────────────────────────
const users = await prisma.$transaction([
prisma.user.create({
data: {
id: 'user-alice',
email: 'alice.chen@example.com',
passwordHash,
fullName: 'Alice Chen',
nickname: 'alice',
avatar: '/uploads/avatars/alice.jpg',
phone: '(415) 555-0101',
location: 'San Francisco, CA',
bio: 'Tech enthusiast and avid reader. Always looking for great deals on gadgets and books.',
rating: 4.8,
ratingCount: 24,
showEmail: false,
showPhone: true,
showLocation: true,
showOnline: true,
showRating: true,
isActive: true,
marketingEmail: true,
},
}),
prisma.user.create({
data: {
id: 'user-bob',
email: 'bob.martinez@example.com',
passwordHash,
fullName: 'Bob Martinez',
nickname: 'bobby_m',
avatar: '/uploads/avatars/bob.jpg',
phone: '(213) 555-0202',
location: 'Los Angeles, CA',
bio: 'Sports memorabilia collector and weekend warrior. Selling gear I no longer use.',
rating: 4.5,
ratingCount: 18,
showEmail: true,
showPhone: true,
showLocation: true,
showOnline: true,
showRating: true,
isActive: true,
},
}),
prisma.user.create({
data: {
id: 'user-carol',
email: 'carol.nguyen@example.com',
passwordHash,
fullName: 'Carol Nguyen',
nickname: 'carol_n',
avatar: '/uploads/avatars/carol.jpg',
phone: '(503) 555-0303',
location: 'Portland, OR',
bio: 'Interior designer with an eye for vintage furniture. Downsizing my collection.',
rating: 4.9,
ratingCount: 32,
showEmail: false,
showPhone: false,
showLocation: true,
showOnline: false,
showRating: true,
isActive: true,
twoFactorEnabled: true,
},
}),
prisma.user.create({
data: {
id: 'user-david',
email: 'david.kim@example.com',
passwordHash,
fullName: 'David Kim',
nickname: 'dave_k',
avatar: '/uploads/avatars/david.jpg',
phone: '(312) 555-0404',
location: 'Chicago, IL',
bio: 'Gamer and music lover. Clearing out my shelves to make room for new hobbies.',
rating: 4.2,
ratingCount: 11,
showEmail: false,
showPhone: true,
showLocation: true,
showOnline: true,
showRating: true,
isActive: true,
},
}),
prisma.user.create({
data: {
id: 'user-eva',
email: 'eva.johnson@example.com',
passwordHash,
fullName: 'Eva Johnson',
nickname: 'eva_j',
avatar: '/uploads/avatars/eva.jpg',
phone: '(206) 555-0505',
location: 'Seattle, WA',
bio: 'Outdoor enthusiast and plant mom. Selling items that need a new home.',
rating: 4.7,
ratingCount: 15,
showEmail: true,
showPhone: true,
showLocation: true,
showOnline: true,
showRating: true,
isActive: true,
notifFavorites: false,
},
}),
]);
console.log(`Created ${users.length} users.`);
// ── Listings ────────────────────────────────────────────────────────
const listingsData = [
// ACTIVE listings (10)
{
id: 'listing-01',
title: 'MacBook Pro 14" M3 Pro — 18GB RAM',
description: 'Barely used MacBook Pro 14-inch with M3 Pro chip, 18GB unified memory, 512GB SSD. Includes original charger and box. AppleCare+ until March 2027. No scratches or dents, always used with a case.',
price: 1650.00,
obo: true,
category: 'ELECTRONICS' as const,
condition: 'LIKE_NEW' as const,
status: 'ACTIVE' as const,
location: 'San Francisco, CA',
viewCount: 142,
sellerId: 'user-alice',
},
{
id: 'listing-02',
title: 'Mid-Century Modern Walnut Coffee Table',
description: 'Beautiful solid walnut coffee table with tapered legs. Dimensions: 48"L x 24"W x 16"H. Minor surface wear consistent with age. A real statement piece for any living room.',
price: 320.00,
obo: true,
category: 'FURNITURE' as const,
condition: 'GENTLY_USED' as const,
status: 'ACTIVE' as const,
location: 'Portland, OR',
viewCount: 87,
sellerId: 'user-carol',
},
{
id: 'listing-03',
title: 'Patagonia Better Sweater Jacket — Men\'s Large',
description: 'Classic Patagonia fleece jacket in "New Navy" colorway, size Large. Worn a handful of times, no pilling or stains. Great for layering or casual wear.',
price: 75.00,
obo: false,
category: 'CLOTHING' as const,
condition: 'LIKE_NEW' as const,
status: 'ACTIVE' as const,
location: 'Seattle, WA',
viewCount: 63,
sellerId: 'user-eva',
},
{
id: 'listing-04',
title: 'Weber Spirit II E-310 Gas Grill',
description: 'Three-burner propane grill with side tables. Used for two summers. GS4 grilling system, porcelain-enameled cast-iron grates. Includes cover and propane tank. Pickup only.',
price: 280.00,
obo: true,
category: 'HOME_GARDEN' as const,
condition: 'USED' as const,
status: 'ACTIVE' as const,
location: 'Chicago, IL',
viewCount: 54,
sellerId: 'user-david',
},
{
id: 'listing-05',
title: 'Trek Marlin 7 Mountain Bike — Size M',
description: '2024 Trek Marlin 7 in Matte Nautical Navy. Size Medium (fits 5\'5"5\'9"). Shimano Deore 1x10 drivetrain, RockShox Judy fork. About 300 miles on it. Ready to ride.',
price: 620.00,
obo: true,
category: 'SPORTS' as const,
condition: 'GENTLY_USED' as const,
status: 'ACTIVE' as const,
location: 'Portland, OR',
viewCount: 109,
sellerId: 'user-carol',
},
{
id: 'listing-06',
title: 'Complete Dune Series (6 Books, Hardcover)',
description: 'The original six Dune novels by Frank Herbert in hardcover. Ace/Putnam editions with dust jackets. All in very good condition — no writing, highlighting, or torn pages.',
price: 95.00,
obo: false,
category: 'BOOKS' as const,
condition: 'GENTLY_USED' as const,
status: 'ACTIVE' as const,
location: 'San Francisco, CA',
viewCount: 76,
sellerId: 'user-alice',
},
{
id: 'listing-07',
title: 'PlayStation 5 Disc Edition + 4 Games',
description: 'PS5 Disc Edition console with two DualSense controllers, charging dock, and four games: Spider-Man 2, FF7 Rebirth, Elden Ring, and Baldur\'s Gate 3. Everything works perfectly.',
price: 420.00,
obo: true,
category: 'GAMES' as const,
condition: 'GENTLY_USED' as const,
status: 'ACTIVE' as const,
location: 'Chicago, IL',
viewCount: 203,
sellerId: 'user-david',
},
{
id: 'listing-08',
title: 'Sony WH-1000XM5 Wireless Headphones',
description: 'Industry-leading noise cancelling headphones in black. Includes carrying case, USB-C cable, and audio cable. Battery still holds 30+ hours. Ear pads in excellent shape.',
price: 199.00,
obo: false,
category: 'ELECTRONICS' as const,
condition: 'LIKE_NEW' as const,
status: 'ACTIVE' as const,
location: 'Los Angeles, CA',
viewCount: 91,
sellerId: 'user-bob',
},
{
id: 'listing-09',
title: 'Dyson V12 Detect Slim Cordless Vacuum',
description: 'Powerful cordless stick vacuum with laser dust detection. Comes with all original attachments and wall-mount dock. About 1 year old, works flawlessly.',
price: 340.00,
obo: true,
category: 'HOME_GARDEN' as const,
condition: 'GENTLY_USED' as const,
status: 'ACTIVE' as const,
location: 'Seattle, WA',
viewCount: 68,
sellerId: 'user-eva',
},
{
id: 'listing-10',
title: 'Vintage Schwinn Varsity Road Bike',
description: '1975 Schwinn Varsity 10-speed in original Kool Lemon color. All-original components, rides well. Some cosmetic patina adds character. A classic commuter or collector piece.',
price: 250.00,
obo: true,
category: 'VEHICLES' as const,
condition: 'FAIR' as const,
status: 'ACTIVE' as const,
location: 'Los Angeles, CA',
viewCount: 45,
sellerId: 'user-bob',
},
// SOLD listings (3)
{
id: 'listing-11',
title: 'Nintendo Switch OLED + Pro Controller',
description: 'White Nintendo Switch OLED model with Pro Controller, dock, and carrying case. Screen is pristine. Includes 3 physical game cartridges.',
price: 300.00,
obo: false,
category: 'GAMES' as const,
condition: 'GENTLY_USED' as const,
status: 'SOLD' as const,
location: 'San Francisco, CA',
viewCount: 178,
sellerId: 'user-alice',
},
{
id: 'listing-12',
title: 'Herman Miller Aeron Chair — Size B',
description: 'Fully loaded Aeron chair in graphite. Size B (medium). PostureFit SL, adjustable arms, tilt limiter. Purchased from authorized dealer in 2022. 12-year warranty still active.',
price: 750.00,
obo: true,
category: 'FURNITURE' as const,
condition: 'LIKE_NEW' as const,
status: 'SOLD' as const,
location: 'Portland, OR',
viewCount: 256,
sellerId: 'user-carol',
},
{
id: 'listing-13',
title: 'Canon EOS R6 Mark II Body Only',
description: 'Professional mirrorless camera body. 24.2MP full-frame sensor, 4K 60fps video, IBIS. Shutter count under 5,000. Includes battery, charger, and strap. No box.',
price: 1800.00,
obo: true,
category: 'ELECTRONICS' as const,
condition: 'LIKE_NEW' as const,
status: 'SOLD' as const,
location: 'Chicago, IL',
viewCount: 312,
sellerId: 'user-david',
},
// DRAFT listings (2)
{
id: 'listing-14',
title: 'Garmin Fenix 7X Solar Smartwatch',
description: 'Multi-sport GPS watch with solar charging. Titanium bezel, sapphire lens. Includes QuickFit bands. Still drafting — need to take photos.',
price: 450.00,
obo: true,
category: 'ELECTRONICS' as const,
condition: 'GENTLY_USED' as const,
status: 'DRAFT' as const,
location: 'Seattle, WA',
viewCount: 0,
sellerId: 'user-eva',
},
{
id: 'listing-15',
title: 'Restoration Hardware Cloud Sofa — 2 Seat',
description: 'RH Cloud sofa in white perennials fabric. Extremely comfortable. Moving and cannot bring it. Will update with measurements and photos shortly.',
price: 2200.00,
obo: true,
category: 'FURNITURE' as const,
condition: 'USED' as const,
status: 'DRAFT' as const,
location: 'Los Angeles, CA',
viewCount: 0,
sellerId: 'user-bob',
},
];
const listings = await prisma.$transaction(
listingsData.map((l) => prisma.listing.create({ data: l }))
);
console.log(`Created ${listings.length} listings.`);
// ── Listing Images ──────────────────────────────────────────────────
const imageRecords = [
{ listingId: 'listing-01', uploadedBy: 'user-alice', url: '/uploads/placeholder-1.jpg', order: 0 },
{ listingId: 'listing-01', uploadedBy: 'user-alice', url: '/uploads/placeholder-2.jpg', order: 1 },
{ listingId: 'listing-02', uploadedBy: 'user-carol', url: '/uploads/placeholder-3.jpg', order: 0 },
{ listingId: 'listing-02', uploadedBy: 'user-carol', url: '/uploads/placeholder-4.jpg', order: 1 },
{ listingId: 'listing-03', uploadedBy: 'user-eva', url: '/uploads/placeholder-5.jpg', order: 0 },
{ listingId: 'listing-04', uploadedBy: 'user-david', url: '/uploads/placeholder-6.jpg', order: 0 },
{ listingId: 'listing-04', uploadedBy: 'user-david', url: '/uploads/placeholder-7.jpg', order: 1 },
{ listingId: 'listing-05', uploadedBy: 'user-carol', url: '/uploads/placeholder-8.jpg', order: 0 },
{ listingId: 'listing-05', uploadedBy: 'user-carol', url: '/uploads/placeholder-9.jpg', order: 1 },
{ listingId: 'listing-06', uploadedBy: 'user-alice', url: '/uploads/placeholder-10.jpg', order: 0 },
{ listingId: 'listing-07', uploadedBy: 'user-david', url: '/uploads/placeholder-11.jpg', order: 0 },
{ listingId: 'listing-07', uploadedBy: 'user-david', url: '/uploads/placeholder-12.jpg', order: 1 },
{ listingId: 'listing-08', uploadedBy: 'user-bob', url: '/uploads/placeholder-13.jpg', order: 0 },
{ listingId: 'listing-09', uploadedBy: 'user-eva', url: '/uploads/placeholder-14.jpg', order: 0 },
{ listingId: 'listing-10', uploadedBy: 'user-bob', url: '/uploads/placeholder-15.jpg', order: 0 },
{ listingId: 'listing-10', uploadedBy: 'user-bob', url: '/uploads/placeholder-16.jpg', order: 1 },
{ listingId: 'listing-11', uploadedBy: 'user-alice', url: '/uploads/placeholder-17.jpg', order: 0 },
{ listingId: 'listing-12', uploadedBy: 'user-carol', url: '/uploads/placeholder-18.jpg', order: 0 },
{ listingId: 'listing-12', uploadedBy: 'user-carol', url: '/uploads/placeholder-19.jpg', order: 1 },
{ listingId: 'listing-13', uploadedBy: 'user-david', url: '/uploads/placeholder-20.jpg', order: 0 },
{ listingId: 'listing-13', uploadedBy: 'user-david', url: '/uploads/placeholder-21.jpg', order: 1 },
];
const images = await prisma.$transaction(
imageRecords.map((img) => prisma.listingImage.create({ data: img }))
);
console.log(`Created ${images.length} listing images.`);
// ── Offers ──────────────────────────────────────────────────────────
const now = new Date();
const inOneWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const daysAgo = (d: number) => new Date(now.getTime() - d * 24 * 60 * 60 * 1000);
const offersData = [
// ACCEPTED offers on SOLD listings
{
id: 'offer-01',
amount: 275.00,
message: 'Would you take $275? I can pick it up today.',
status: 'ACCEPTED' as const,
buyerId: 'user-bob',
sellerId: 'user-alice',
listingId: 'listing-11',
expiresAt: daysAgo(-2),
createdAt: daysAgo(10),
},
{
id: 'offer-02',
amount: 680.00,
message: 'Beautiful chair! I have been looking for one of these. Would $680 work?',
status: 'ACCEPTED' as const,
counterAmount: 720.00,
buyerId: 'user-eva',
sellerId: 'user-carol',
listingId: 'listing-12',
expiresAt: daysAgo(-1),
createdAt: daysAgo(14),
},
{
id: 'offer-03',
amount: 1700.00,
message: 'Interested in the R6 II. Offering $1700 — can meet anywhere in Chicago.',
status: 'ACCEPTED' as const,
buyerId: 'user-alice',
sellerId: 'user-david',
listingId: 'listing-13',
expiresAt: daysAgo(-3),
createdAt: daysAgo(7),
},
// PENDING offers
{
id: 'offer-04',
amount: 1500.00,
message: 'Is there any flexibility on price? I see similar configs going for around $1500.',
status: 'PENDING' as const,
buyerId: 'user-david',
sellerId: 'user-alice',
listingId: 'listing-01',
expiresAt: inOneWeek,
createdAt: daysAgo(1),
},
{
id: 'offer-05',
amount: 380.00,
message: 'Great deal on the PS5 bundle. Would you accept $380?',
status: 'PENDING' as const,
buyerId: 'user-carol',
sellerId: 'user-david',
listingId: 'listing-07',
expiresAt: inOneWeek,
createdAt: daysAgo(2),
},
{
id: 'offer-06',
amount: 550.00,
message: 'Love this bike! Would you consider $550?',
status: 'PENDING' as const,
buyerId: 'user-alice',
sellerId: 'user-carol',
listingId: 'listing-05',
expiresAt: inOneWeek,
createdAt: daysAgo(1),
},
// DECLINED offers
{
id: 'offer-07',
amount: 200.00,
message: 'I can offer $200 for the coffee table.',
status: 'DECLINED' as const,
buyerId: 'user-david',
sellerId: 'user-carol',
listingId: 'listing-02',
expiresAt: daysAgo(1),
createdAt: daysAgo(5),
},
{
id: 'offer-08',
amount: 150.00,
message: 'Budget is tight but really want those headphones. $150?',
status: 'DECLINED' as const,
buyerId: 'user-eva',
sellerId: 'user-bob',
listingId: 'listing-08',
expiresAt: daysAgo(2),
createdAt: daysAgo(8),
},
// COUNTERED offers
{
id: 'offer-09',
amount: 250.00,
message: 'Interested in the grill. How about $250?',
status: 'COUNTERED' as const,
counterAmount: 270.00,
buyerId: 'user-alice',
sellerId: 'user-david',
listingId: 'listing-04',
expiresAt: inOneWeek,
createdAt: daysAgo(3),
},
{
id: 'offer-10',
amount: 290.00,
message: 'Would $290 work for the vacuum? I am in the Ballard area and can pick up.',
status: 'COUNTERED' as const,
counterAmount: 310.00,
buyerId: 'user-bob',
sellerId: 'user-eva',
listingId: 'listing-09',
expiresAt: inOneWeek,
createdAt: daysAgo(2),
},
];
const offers = await prisma.$transaction(
offersData.map((o) => prisma.offer.create({ data: o }))
);
console.log(`Created ${offers.length} offers.`);
// ── Conversations & Messages ────────────────────────────────────────
const conv1 = await prisma.conversation.create({
data: {
id: 'conv-01',
user1Id: 'user-bob',
user2Id: 'user-alice',
listingId: 'listing-11',
},
});
const conv2 = await prisma.conversation.create({
data: {
id: 'conv-02',
user1Id: 'user-eva',
user2Id: 'user-carol',
listingId: 'listing-12',
},
});
const conv3 = await prisma.conversation.create({
data: {
id: 'conv-03',
user1Id: 'user-david',
user2Id: 'user-alice',
listingId: 'listing-01',
},
});
const conv4 = await prisma.conversation.create({
data: {
id: 'conv-04',
user1Id: 'user-alice',
user2Id: 'user-david',
listingId: 'listing-04',
},
});
console.log('Created 4 conversations.');
const messagesData = [
// Conv 1: Bob buying Switch from Alice (completed sale)
{ content: 'Hi! Is the Switch still available?', senderId: 'user-bob', conversationId: 'conv-01', isRead: true, createdAt: daysAgo(11) },
{ content: 'Yes it is! Everything works great.', senderId: 'user-alice', conversationId: 'conv-01', isRead: true, createdAt: daysAgo(11) },
{ content: 'Awesome. Would you take $275 for it?', senderId: 'user-bob', conversationId: 'conv-01', isRead: true, offerAmount: 275.00, createdAt: daysAgo(10) },
{ content: 'That works for me. When can you pick up?', senderId: 'user-alice', conversationId: 'conv-01', isRead: true, createdAt: daysAgo(10) },
{ content: 'How about Saturday around noon? I can come to the Mission district.', senderId: 'user-bob', conversationId: 'conv-01', isRead: true, createdAt: daysAgo(9) },
{ content: 'Perfect. I will DM you the exact address on Saturday morning.', senderId: 'user-alice', conversationId: 'conv-01', isRead: true, createdAt: daysAgo(9) },
{ content: 'Picked it up — everything looks great. Thanks Alice!', senderId: 'user-bob', conversationId: 'conv-01', isRead: true, createdAt: daysAgo(7) },
// Conv 2: Eva buying Aeron from Carol (completed sale)
{ content: 'Hi Carol, love the Aeron listing! Is the warranty transferable?', senderId: 'user-eva', conversationId: 'conv-02', isRead: true, createdAt: daysAgo(15) },
{ content: 'Hi Eva! Yes, Herman Miller warranties follow the chair, not the owner.', senderId: 'user-carol', conversationId: 'conv-02', isRead: true, createdAt: daysAgo(15) },
{ content: 'Great to know. Would $680 be fair? I see refurbs going for about that.', senderId: 'user-eva', conversationId: 'conv-02', isRead: true, offerAmount: 680.00, createdAt: daysAgo(14) },
{ content: 'This is from an authorized dealer though. How about we meet at $720?', senderId: 'user-carol', conversationId: 'conv-02', isRead: true, offerAmount: 720.00, createdAt: daysAgo(14) },
{ content: '$720 is fair. Deal! Can you ship to Seattle or is it pickup only?', senderId: 'user-eva', conversationId: 'conv-02', isRead: true, createdAt: daysAgo(13) },
{ content: 'I can ship via UPS freight. I will factor that into the price — still $720 total.', senderId: 'user-carol', conversationId: 'conv-02', isRead: true, createdAt: daysAgo(13) },
{ content: 'That is incredibly generous. Sending payment now.', senderId: 'user-eva', conversationId: 'conv-02', isRead: true, createdAt: daysAgo(12) },
{ content: 'Payment received! Shipping Monday. Will send tracking.', senderId: 'user-carol', conversationId: 'conv-02', isRead: true, createdAt: daysAgo(12) },
// Conv 3: David interested in MacBook from Alice (ongoing)
{ content: 'Hey, quick question about the MacBook — what is the battery cycle count?', senderId: 'user-david', conversationId: 'conv-03', isRead: true, createdAt: daysAgo(3) },
{ content: 'Let me check... 47 cycles. Basically brand new battery.', senderId: 'user-alice', conversationId: 'conv-03', isRead: true, createdAt: daysAgo(3) },
{ content: 'Nice. Any issues with the keyboard or trackpad?', senderId: 'user-david', conversationId: 'conv-03', isRead: true, createdAt: daysAgo(2) },
{ content: 'Nope, everything is perfect. I mostly used it with an external display and keyboard.', senderId: 'user-alice', conversationId: 'conv-03', isRead: true, createdAt: daysAgo(2) },
{ content: 'I submitted an offer for $1500 — let me know what you think.', senderId: 'user-david', conversationId: 'conv-03', isRead: true, offerAmount: 1500.00, createdAt: daysAgo(1) },
{ content: 'I will take a look. That is a bit lower than I was hoping for but let me think about it.', senderId: 'user-alice', conversationId: 'conv-03', isRead: false, createdAt: daysAgo(0) },
// Conv 4: Alice interested in grill from David (ongoing)
{ content: 'Hi David! Does the grill come with the cover shown in the photo?', senderId: 'user-alice', conversationId: 'conv-04', isRead: true, createdAt: daysAgo(4) },
{ content: 'Yes, the cover and propane tank are both included.', senderId: 'user-david', conversationId: 'conv-04', isRead: true, createdAt: daysAgo(4) },
{ content: 'I put in an offer for $250. Let me know if that works.', senderId: 'user-alice', conversationId: 'conv-04', isRead: true, offerAmount: 250.00, createdAt: daysAgo(3) },
{ content: 'Appreciate the offer! Could you do $270? I paid $500 new last year.', senderId: 'user-david', conversationId: 'conv-04', isRead: true, offerAmount: 270.00, createdAt: daysAgo(3) },
{ content: 'Let me think on it and get back to you.', senderId: 'user-alice', conversationId: 'conv-04', isRead: false, createdAt: daysAgo(2) },
];
const messages = await prisma.$transaction(
messagesData.map((m) => prisma.message.create({ data: m }))
);
console.log(`Created ${messages.length} messages.`);
// ── Favorites ───────────────────────────────────────────────────────
const favoritesData = [
{ userId: 'user-bob', listingId: 'listing-01' },
{ userId: 'user-bob', listingId: 'listing-06' },
{ userId: 'user-carol', listingId: 'listing-07' },
{ userId: 'user-carol', listingId: 'listing-08' },
{ userId: 'user-david', listingId: 'listing-02' },
{ userId: 'user-david', listingId: 'listing-05' },
{ userId: 'user-eva', listingId: 'listing-01' },
{ userId: 'user-eva', listingId: 'listing-07' },
{ userId: 'user-alice', listingId: 'listing-04' },
{ userId: 'user-alice', listingId: 'listing-05' },
{ userId: 'user-alice', listingId: 'listing-08' },
];
const favorites = await prisma.$transaction(
favoritesData.map((f) => prisma.favorite.create({ data: f }))
);
console.log(`Created ${favorites.length} favorites.`);
// ── Notifications ───────────────────────────────────────────────────
const notificationsData = [
// NEW_OFFER
{
userId: 'user-alice',
type: 'NEW_OFFER' as const,
title: 'New Offer Received',
body: 'David Kim offered $1,500 for your MacBook Pro 14" M3 Pro.',
data: { offerId: 'offer-04', listingId: 'listing-01' },
isRead: false,
createdAt: daysAgo(1),
},
{
userId: 'user-david',
type: 'NEW_OFFER' as const,
title: 'New Offer Received',
body: 'Carol Nguyen offered $380 for your PlayStation 5 Disc Edition.',
data: { offerId: 'offer-05', listingId: 'listing-07' },
isRead: true,
createdAt: daysAgo(2),
},
// OFFER_ACCEPTED
{
userId: 'user-bob',
type: 'OFFER_ACCEPTED' as const,
title: 'Offer Accepted!',
body: 'Alice Chen accepted your offer of $275 for the Nintendo Switch OLED.',
data: { offerId: 'offer-01', listingId: 'listing-11' },
isRead: true,
createdAt: daysAgo(10),
},
{
userId: 'user-alice',
type: 'OFFER_ACCEPTED' as const,
title: 'Offer Accepted!',
body: 'David Kim accepted your offer of $1,700 for the Canon EOS R6 Mark II.',
data: { offerId: 'offer-03', listingId: 'listing-13' },
isRead: true,
createdAt: daysAgo(7),
},
// OFFER_DECLINED
{
userId: 'user-david',
type: 'OFFER_DECLINED' as const,
title: 'Offer Declined',
body: 'Carol Nguyen declined your offer of $200 for the Mid-Century Coffee Table.',
data: { offerId: 'offer-07', listingId: 'listing-02' },
isRead: true,
createdAt: daysAgo(5),
},
{
userId: 'user-eva',
type: 'OFFER_DECLINED' as const,
title: 'Offer Declined',
body: 'Bob Martinez declined your offer of $150 for the Sony WH-1000XM5.',
data: { offerId: 'offer-08', listingId: 'listing-08' },
isRead: false,
createdAt: daysAgo(8),
},
// ITEM_SOLD
{
userId: 'user-alice',
type: 'ITEM_SOLD' as const,
title: 'Item Sold!',
body: 'Your Nintendo Switch OLED has been sold for $275.',
data: { listingId: 'listing-11', amount: 275 },
isRead: true,
createdAt: daysAgo(9),
},
{
userId: 'user-carol',
type: 'ITEM_SOLD' as const,
title: 'Item Sold!',
body: 'Your Herman Miller Aeron Chair has been sold for $720.',
data: { listingId: 'listing-12', amount: 720 },
isRead: true,
createdAt: daysAgo(12),
},
// NEW_MESSAGE
{
userId: 'user-alice',
type: 'NEW_MESSAGE' as const,
title: 'New Message',
body: 'David Kim sent you a message about MacBook Pro 14" M3 Pro.',
data: { conversationId: 'conv-03', listingId: 'listing-01' },
isRead: true,
createdAt: daysAgo(3),
},
{
userId: 'user-david',
type: 'NEW_MESSAGE' as const,
title: 'New Message',
body: 'Alice Chen sent you a message about Weber Spirit II Gas Grill.',
data: { conversationId: 'conv-04', listingId: 'listing-04' },
isRead: false,
createdAt: daysAgo(2),
},
// ITEM_FAVORITED
{
userId: 'user-alice',
type: 'ITEM_FAVORITED' as const,
title: 'Item Favorited',
body: 'Someone favorited your MacBook Pro 14" M3 Pro listing.',
data: { listingId: 'listing-01' },
isRead: false,
createdAt: daysAgo(1),
},
{
userId: 'user-carol',
type: 'ITEM_FAVORITED' as const,
title: 'Item Favorited',
body: 'Someone favorited your Trek Marlin 7 Mountain Bike listing.',
data: { listingId: 'listing-05' },
isRead: true,
createdAt: daysAgo(4),
},
];
const notifications = await prisma.$transaction(
notificationsData.map((n) => prisma.notification.create({ data: n }))
);
console.log(`Created ${notifications.length} notifications.`);
// ── Payments (3 completed for sold items) ───────────────────────────
const paymentsData = [
{
userId: 'user-bob',
listingId: 'listing-11',
stripePaymentId: 'pi_3PxSwitch001',
amount: 275.00,
status: 'COMPLETED' as const,
createdAt: daysAgo(9),
},
{
userId: 'user-eva',
listingId: 'listing-12',
stripePaymentId: 'pi_3PxAeron002',
amount: 720.00,
status: 'COMPLETED' as const,
createdAt: daysAgo(12),
},
{
userId: 'user-alice',
listingId: 'listing-13',
stripePaymentId: 'pi_3PxCanon003',
amount: 1700.00,
status: 'COMPLETED' as const,
createdAt: daysAgo(6),
},
];
const payments = await prisma.$transaction(
paymentsData.map((p) => prisma.payment.create({ data: p }))
);
console.log(`Created ${payments.length} payments.`);
// ── Blocked Users ───────────────────────────────────────────────────
await prisma.blockedUser.create({
data: {
blockerId: 'user-carol',
blockedId: 'user-david',
},
});
console.log('Created 1 blocked user entry.');
console.log('\nSeed completed successfully!');
}
main()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -6,7 +6,9 @@ export const env = {
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_PUBLISHABLE_KEY: process.env['STRIPE_PUBLISHABLE_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',
GOOGLE_MAPS_API_KEY: process.env['GOOGLE_MAPS_API_KEY'] || 'AIzaSyDW0G7wkKlYbsrzMZRrz0UI4gk-L8WCMh0',
};

View File

@@ -16,6 +16,8 @@ import offerRoutes from './routes/offer.js';
import chatRoutes from './routes/chat.js';
import notificationRoutes from './routes/notification.js';
import paymentRoutes from './routes/payment.js';
import locationRoutes from './routes/location.js';
import miscRoutes from './routes/misc.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -51,6 +53,8 @@ app.use('/api/offers', offerRoutes);
app.use('/api/chat', chatRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/location', locationRoutes);
app.use('/api', miscRoutes);
// Health check
app.get('/api/health', (_req, res) => {

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import crypto from 'crypto';
import { prisma } from '../config/database.js';
import { hashPassword, comparePassword } from '../utils/password.js';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../utils/jwt.js';
@@ -145,4 +146,73 @@ router.post('/logout', authenticate, async (req, res, next) => {
}
});
// --- Logout all sessions ---
router.post('/logout-all', authenticate, async (req, res, next) => {
try {
await prisma.session.deleteMany({ where: { userId: req.userId } });
res.clearCookie('refreshToken');
res.json({ message: 'All sessions logged out' });
} catch (error) {
next(error);
}
});
// --- Forgot password ---
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = req.body;
if (!email) throw new AppError(400, 'Email is required');
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
// Don't reveal if user exists
return res.json({ message: 'If an account exists with this email, a reset link has been sent' });
}
const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await prisma.user.update({
where: { id: user.id },
data: { resetToken, resetTokenExpiry },
});
// In production, send email with reset link
// In dev, return the token directly
res.json({
message: 'If an account exists with this email, a reset link has been sent',
...(process.env['NODE_ENV'] !== 'production' ? { resetToken } : {}),
});
} catch (error) {
next(error);
}
});
// --- Reset password ---
router.post('/reset-password', async (req, res, next) => {
try {
const { token, password } = req.body;
if (!token || !password) throw new AppError(400, 'Token and password are required');
if (password.length < 8) throw new AppError(400, 'Password must be at least 8 characters');
const user = await prisma.user.findUnique({ where: { resetToken: token } });
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
throw new AppError(400, 'Invalid or expired reset token');
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash, resetToken: null, resetTokenExpiry: null },
});
// Clear all sessions to force re-login
await prisma.session.deleteMany({ where: { userId: user.id } });
res.json({ message: 'Password reset successfully' });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -2,13 +2,25 @@ import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { AppError } from '../middleware/errorHandler.js';
import { getBlockedUserIds } from '../utils/blocked.js';
const router = Router();
router.get('/conversations', authenticate, async (req, res, next) => {
try {
// Exclude blocked users
const blockedIds = await getBlockedUserIds(req.userId!);
const conversations = await prisma.conversation.findMany({
where: { OR: [{ user1Id: req.userId }, { user2Id: req.userId }] },
where: {
OR: [{ user1Id: req.userId }, { user2Id: req.userId }],
...(blockedIds.length > 0 ? {
AND: [
{ user1Id: { notIn: blockedIds } },
{ user2Id: { notIn: blockedIds } },
],
} : {}),
},
include: {
user1: { select: { id: true, fullName: true, nickname: true, avatar: true } },
user2: { select: { id: true, fullName: true, nickname: true, avatar: true } },
@@ -41,18 +53,33 @@ router.get('/conversations/:id/messages', authenticate, async (req, res, next) =
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({
const { page = '1', pageSize = '50' } = req.query;
const take = parseInt(pageSize as string);
const skip = (parseInt(page as string) - 1) * take;
const [messages, total] = await Promise.all([
prisma.message.findMany({
where: { conversationId: req.params.id },
include: { sender: { select: { id: true, fullName: true, avatar: true } } },
orderBy: { createdAt: 'asc' },
});
skip,
take,
}),
prisma.message.count({ where: { conversationId: req.params.id } }),
]);
await prisma.message.updateMany({
where: { conversationId: req.params.id, senderId: { not: req.userId }, isRead: false },
data: { isRead: true },
});
res.json(messages);
res.json({
data: messages,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
@@ -64,6 +91,12 @@ router.post('/conversations', authenticate, async (req, res, next) => {
if (!recipientId) throw new AppError(400, 'Recipient ID is required');
if (recipientId === req.userId) throw new AppError(400, 'Cannot message yourself');
// Check blocked users
const blockedIds = await getBlockedUserIds(req.userId!);
if (blockedIds.includes(recipientId)) {
throw new AppError(403, 'Cannot message this user');
}
const [id1, id2] = [req.userId!, recipientId].sort();
const listingIdValue = listingId || undefined;
@@ -86,10 +119,27 @@ router.post('/conversations', authenticate, async (req, res, next) => {
}
if (message) {
await prisma.message.create({
const msg = 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() } });
// Send notification
const sender = await prisma.user.findUnique({ where: { id: req.userId }, select: { fullName: true } });
const notification = await prisma.notification.create({
data: {
userId: recipientId,
type: 'NEW_MESSAGE',
title: 'New Message',
body: `${sender?.fullName || 'Someone'} sent you a message`,
data: { conversationId: conversation.id },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${recipientId}`).emit('new_notification', notification);
}
}
res.json(conversation);
@@ -98,4 +148,19 @@ router.post('/conversations', authenticate, async (req, res, next) => {
}
});
router.delete('/conversations/:id', 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');
await prisma.message.deleteMany({ where: { conversationId: req.params.id } });
await prisma.conversation.delete({ where: { id: req.params.id } });
res.json({ message: 'Conversation deleted' });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -5,6 +5,7 @@ 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';
import { getBlockedUserIds } from '../utils/blocked.js';
const router = Router();
@@ -17,6 +18,111 @@ const listingSelect = {
_count: { select: { favorites: true } },
};
// --- Sold items (must be before /:id) ---
router.get('/sold', authenticate, async (req, res, next) => {
try {
const listings = await prisma.listing.findMany({
where: { sellerId: req.userId, status: 'SOLD' },
select: {
...listingSelect,
offers: {
where: { status: 'ACCEPTED' },
take: 1,
include: {
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
},
},
},
orderBy: { updatedAt: 'desc' },
});
const soldItems = listings.map(listing => {
const acceptedOffer = listing.offers[0];
return {
...listing,
offers: undefined,
salePrice: acceptedOffer?.amount ?? listing.price,
buyer: acceptedOffer?.buyer ?? null,
soldDate: acceptedOffer?.updatedAt ?? listing.updatedAt,
};
});
// Earnings stats
const totalEarnings = soldItems.reduce((sum, item) => sum + (item.salePrice || 0), 0);
res.json({
data: soldItems,
stats: {
totalSold: soldItems.length,
totalEarnings,
},
});
} catch (error) {
next(error);
}
});
// --- My listings (must be before /:id) ---
router.get('/mine', authenticate, async (req, res, next) => {
try {
const { status } = req.query;
const where: Record<string, unknown> = { sellerId: req.userId };
if (status && typeof status === 'string') {
where.status = status;
} else {
where.status = { not: 'DELETED' };
}
const listings = await prisma.listing.findMany({
where,
select: listingSelect,
orderBy: { createdAt: 'desc' },
});
res.json(listings);
} catch (error) {
next(error);
}
});
// --- Favorites list (must be before /:id) ---
router.get('/favorites', authenticate, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [favorites, total] = await Promise.all([
prisma.favorite.findMany({
where: { userId: req.userId! },
include: {
listing: {
select: listingSelect,
},
},
orderBy: { createdAt: 'desc' },
skip,
take,
}),
prisma.favorite.count({ where: { userId: req.userId! } }),
]);
const data = favorites
.filter(f => f.listing.status === 'ACTIVE')
.map(f => ({ ...f.listing, isFavorited: true }));
res.json({
data,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// --- List active listings ---
router.get('/', optionalAuth, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', category, search, sort = 'newest', condition } = req.query;
@@ -33,6 +139,14 @@ router.get('/', optionalAuth, async (req, res, next) => {
];
}
// Exclude blocked users' listings
if (req.userId) {
const blockedIds = await getBlockedUserIds(req.userId);
if (blockedIds.length > 0) {
where.sellerId = { notIn: blockedIds };
}
}
const orderBy = sort === 'price_asc' ? { price: 'asc' as const }
: sort === 'price_desc' ? { price: 'desc' as const }
: sort === 'popular' ? { viewCount: 'desc' as const }
@@ -66,6 +180,7 @@ router.get('/', optionalAuth, async (req, res, next) => {
}
});
// --- Get single listing ---
router.get('/:id', optionalAuth, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({
@@ -84,12 +199,19 @@ router.get('/:id', optionalAuth, async (req, res, next) => {
isFavorited = !!fav;
}
res.json({ ...listing, isFavorited });
// Enforce seller privacy flags
const seller: Record<string, unknown> = { ...listing.seller };
if (!listing.seller.showEmail) delete seller.email;
if (!listing.seller.showPhone) delete seller.phone;
if (!listing.seller.showLocation) delete seller.location;
res.json({ ...listing, seller, isFavorited });
} catch (error) {
next(error);
}
});
// --- Create listing ---
router.post('/', authenticate, validate(createListingSchema), async (req, res, next) => {
try {
const listing = await prisma.listing.create({
@@ -102,11 +224,14 @@ router.post('/', authenticate, validate(createListingSchema), async (req, res, n
}
});
// --- Update listing ---
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');
if (existing.status === 'SOLD') throw new AppError(400, 'Cannot edit a sold listing');
if (existing.status === 'DELETED') throw new AppError(400, 'Cannot edit a deleted listing');
const listing = await prisma.listing.update({
where: { id: req.params.id },
@@ -119,7 +244,7 @@ router.put('/:id', authenticate, validate(updateListingSchema), async (req, res,
}
});
// Activate listing (bypasses Stripe in dev, requires payment in prod)
// --- Activate listing ---
router.post('/:id/activate', authenticate, async (req, res, next) => {
try {
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
@@ -138,6 +263,7 @@ router.post('/:id/activate', authenticate, async (req, res, next) => {
}
});
// --- Delete listing ---
router.delete('/:id', authenticate, async (req, res, next) => {
try {
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
@@ -154,6 +280,7 @@ router.delete('/:id', authenticate, async (req, res, next) => {
}
});
// --- Upload images ---
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 } });
@@ -184,8 +311,58 @@ router.post('/:id/images', authenticate, upload.array('images', 6), async (req,
}
});
// --- Delete image ---
router.delete('/:id/images/:imageId', 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');
const image = await prisma.listingImage.findUnique({ where: { id: req.params.imageId } });
if (!image || image.listingId !== req.params.id) throw new AppError(404, 'Image not found');
await prisma.listingImage.delete({ where: { id: req.params.imageId } });
res.json({ message: 'Image deleted' });
} catch (error) {
next(error);
}
});
// --- Reorder images ---
router.put('/:id/images/reorder', 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');
const { imageIds } = req.body;
if (!Array.isArray(imageIds)) throw new AppError(400, 'imageIds must be an array');
await Promise.all(
imageIds.map((imageId: string, index: number) =>
prisma.listingImage.update({
where: { id: imageId },
data: { order: index },
})
)
);
const images = await prisma.listingImage.findMany({
where: { listingId: req.params.id },
orderBy: { order: 'asc' },
});
res.json(images);
} catch (error) {
next(error);
}
});
// --- Toggle favorite ---
router.post('/:id/favorite', authenticate, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
const existing = await prisma.favorite.findUnique({
where: { userId_listingId: { userId: req.userId!, listingId: req.params.id! } },
});
@@ -195,6 +372,26 @@ router.post('/:id/favorite', authenticate, async (req, res, next) => {
res.json({ isFavorited: false });
} else {
await prisma.favorite.create({ data: { userId: req.userId!, listingId: req.params.id! } });
// Notify seller (if not self)
if (listing.sellerId !== req.userId) {
const user = await prisma.user.findUnique({ where: { id: req.userId }, select: { fullName: true } });
const notification = await prisma.notification.create({
data: {
userId: listing.sellerId,
type: 'ITEM_FAVORITED',
title: 'Item Favorited',
body: `${user?.fullName || 'Someone'} favorited your listing "${listing.title}"`,
data: { listingId: listing.id },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${listing.sellerId}`).emit('new_notification', notification);
}
}
res.json({ isFavorited: true });
}
} catch (error) {

View File

@@ -0,0 +1,58 @@
import { Router } from 'express';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
router.get('/autocomplete', async (req, res, next) => {
try {
const { input } = req.query;
if (!input || typeof input !== 'string' || input.length < 2) {
return res.json({ predictions: [] });
}
const apiKey = env.GOOGLE_MAPS_API_KEY;
if (!apiKey) throw new AppError(500, 'Google Maps API key not configured');
const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?input=${encodeURIComponent(input)}&types=(cities)&key=${apiKey}`;
const response = await fetch(url);
const data = await response.json() as { predictions: Array<{ description: string; place_id: string }> };
const predictions = (data.predictions || []).map((p: { description: string; place_id: string }) => ({
description: p.description,
placeId: p.place_id,
}));
res.json({ predictions });
} catch (error) {
next(error);
}
});
router.get('/details', async (req, res, next) => {
try {
const { placeId } = req.query;
if (!placeId || typeof placeId !== 'string') {
throw new AppError(400, 'Place ID is required');
}
const apiKey = env.GOOGLE_MAPS_API_KEY;
if (!apiKey) throw new AppError(500, 'Google Maps API key not configured');
const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${encodeURIComponent(placeId)}&fields=formatted_address,geometry&key=${apiKey}`;
const response = await fetch(url);
const data = await response.json() as { result: { formatted_address: string; geometry: { location: { lat: number; lng: number } } } };
if (!data.result) throw new AppError(404, 'Place not found');
res.json({
address: data.result.formatted_address,
lat: data.result.geometry.location.lat,
lng: data.result.geometry.location.lng,
});
} catch (error) {
next(error);
}
});
export default router;

41
server/src/routes/misc.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Router } from 'express';
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
const router = Router();
const newsletterSchema = z.object({
email: z.string().email('Valid email is required'),
});
const contactSchema = z.object({
name: z.string().min(2, 'Name is required'),
email: z.string().email('Valid email is required'),
subject: z.string().min(2, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
router.post('/newsletter', validate(newsletterSchema), async (req, res, next) => {
try {
const { email } = req.body;
// In production, store in a newsletter subscribers table or send to email service
// For now, log and return success
console.log(`Newsletter subscription: ${email}`);
res.json({ message: 'Successfully subscribed to newsletter' });
} catch (error) {
next(error);
}
});
router.post('/contact', validate(contactSchema), async (req, res, next) => {
try {
const { name, email, subject, message } = req.body;
// In production, store in a contact submissions table or send email
console.log(`Contact form: ${name} <${email}> - ${subject}: ${message}`);
res.json({ message: 'Contact form submitted successfully' });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -1,22 +1,52 @@
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('/', authenticate, async (req, res, next) => {
// --- Unread count (must be before /:id) ---
router.get('/unread-count', authenticate, async (req, res, next) => {
try {
const notifications = await prisma.notification.findMany({
where: { userId: req.userId },
orderBy: { createdAt: 'desc' },
take: 50,
const count = await prisma.notification.count({
where: { userId: req.userId, isRead: false },
});
res.json(notifications);
res.json({ count });
} catch (error) {
next(error);
}
});
// --- List notifications with pagination ---
router.get('/', authenticate, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const take = parseInt(pageSize as string);
const skip = (parseInt(page as string) - 1) * take;
const [notifications, total] = await Promise.all([
prisma.notification.findMany({
where: { userId: req.userId },
orderBy: { createdAt: 'desc' },
skip,
take,
}),
prisma.notification.count({ where: { userId: req.userId } }),
]);
res.json({
data: notifications,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// --- Mark all as read ---
router.patch('/read-all', authenticate, async (req, res, next) => {
try {
await prisma.notification.updateMany({
@@ -29,8 +59,13 @@ router.patch('/read-all', authenticate, async (req, res, next) => {
}
});
// --- Mark single as read ---
router.patch('/:id/read', authenticate, async (req, res, next) => {
try {
const notification = await prisma.notification.findUnique({ where: { id: req.params.id } });
if (!notification) throw new AppError(404, 'Notification not found');
if (notification.userId !== req.userId) throw new AppError(403, 'Not authorized');
await prisma.notification.update({
where: { id: req.params.id },
data: { isRead: true },
@@ -41,4 +76,18 @@ router.patch('/:id/read', authenticate, async (req, res, next) => {
}
});
// --- Delete notification ---
router.delete('/:id', authenticate, async (req, res, next) => {
try {
const notification = await prisma.notification.findUnique({ where: { id: req.params.id } });
if (!notification) throw new AppError(404, 'Notification not found');
if (notification.userId !== req.userId) throw new AppError(403, 'Not authorized');
await prisma.notification.delete({ where: { id: req.params.id } });
res.json({ message: 'Notification deleted' });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -4,31 +4,83 @@ 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';
import { getBlockedUserIds } from '../utils/blocked.js';
const router = Router();
const offerInclude = {
listing: { include: { images: { take: 1, orderBy: { order: 'asc' as const } } } },
buyer: { select: { id: true, fullName: true, nickname: true, avatar: true } },
seller: { select: { id: true, fullName: true, nickname: true, avatar: true } },
};
// --- List offers ---
router.get('/', authenticate, async (req, res, next) => {
try {
const { type = 'received' } = req.query;
const { type = 'received', sort = 'newest' } = req.query;
const where = type === 'sent'
? { buyerId: req.userId }
: { sellerId: req.userId };
const orderBy = sort === 'price_high' ? { amount: 'desc' as const }
: sort === 'price_low' ? { amount: 'asc' as const }
: { createdAt: 'desc' as const };
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' },
include: offerInclude,
orderBy,
});
res.json(offers);
// Check for expired offers and mark them
const now = new Date();
const updatedOffers = await Promise.all(offers.map(async (offer) => {
if (offer.status === 'PENDING' && offer.expiresAt && offer.expiresAt < now) {
const updated = await prisma.offer.update({
where: { id: offer.id },
data: { status: 'EXPIRED' },
include: offerInclude,
});
return updated;
}
return offer;
}));
res.json(updatedOffers);
} catch (error) {
next(error);
}
});
// --- Get single offer ---
router.get('/:id', authenticate, async (req, res, next) => {
try {
const offer = await prisma.offer.findUnique({
where: { id: req.params.id },
include: offerInclude,
});
if (!offer) throw new AppError(404, 'Offer not found');
if (offer.buyerId !== req.userId && offer.sellerId !== req.userId) {
throw new AppError(403, 'Not authorized');
}
// Check expiry
if (offer.status === 'PENDING' && offer.expiresAt && offer.expiresAt < new Date()) {
const updated = await prisma.offer.update({
where: { id: offer.id },
data: { status: 'EXPIRED' },
include: offerInclude,
});
return res.json(updated);
}
res.json(offer);
} catch (error) {
next(error);
}
});
// --- Create offer ---
router.post('/', authenticate, validate(createOfferSchema), async (req, res, next) => {
try {
const { amount, message, listingId } = req.body;
@@ -38,16 +90,31 @@ router.post('/', authenticate, validate(createOfferSchema), async (req, res, nex
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');
// Check blocked users
const blockedIds = await getBlockedUserIds(req.userId!);
if (blockedIds.includes(listing.sellerId)) {
throw new AppError(403, 'Cannot make offer to this user');
}
// Prevent duplicate pending offers
const existingOffer = await prisma.offer.findFirst({
where: { buyerId: req.userId, listingId, status: 'PENDING' },
});
if (existingOffer) throw new AppError(409, 'You already have a pending offer on this 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 } },
data: {
amount,
message,
listingId,
buyerId: req.userId!,
sellerId: listing.sellerId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
include: offerInclude,
});
await prisma.notification.create({
const notification = await prisma.notification.create({
data: {
userId: listing.sellerId,
type: 'NEW_OFFER',
@@ -57,29 +124,72 @@ router.post('/', authenticate, validate(createOfferSchema), async (req, res, nex
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${listing.sellerId}`).emit('new_notification', notification);
io.to(`user:${listing.sellerId}`).emit('offer_update', offer);
}
res.status(201).json(offer);
} catch (error) {
next(error);
}
});
// --- Cancel offer (buyer only) ---
router.delete('/:id', authenticate, async (req, res, next) => {
try {
const offer = await prisma.offer.findUnique({ where: { id: req.params.id } });
if (!offer) throw new AppError(404, 'Offer not found');
if (offer.buyerId !== req.userId) throw new AppError(403, 'Not authorized');
if (offer.status !== 'PENDING') throw new AppError(400, 'Can only cancel pending offers');
const updated = await prisma.offer.update({
where: { id: req.params.id },
data: { status: 'CANCELLED' },
include: offerInclude,
});
const io = req.app.get('io');
if (io) {
io.to(`user:${offer.sellerId}`).emit('offer_update', updated);
}
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Respond to offer (seller or buyer responding to counter) ---
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;
// Buyer can respond to COUNTERED offers (accept or decline)
if (existing.status === 'COUNTERED' && existing.buyerId === req.userId) {
if (status !== 'ACCEPTED' && status !== 'DECLINED') {
throw new AppError(400, 'Can only accept or decline a counter offer');
}
} else if (existing.sellerId === req.userId) {
// Seller responding to PENDING offer
if (existing.status !== 'PENDING') throw new AppError(400, 'Offer already responded to');
} else {
throw new AppError(403, 'Not authorized');
}
// Validate counter amount
if (status === 'COUNTERED' && !counterAmount) {
throw new AppError(400, 'Counter amount is required when countering an offer');
}
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 } },
},
include: offerInclude,
});
if (status === 'ACCEPTED') {
@@ -90,17 +200,30 @@ router.patch('/:id', authenticate, validate(respondOfferSchema), async (req, res
});
}
const notificationType = status === 'ACCEPTED' ? 'OFFER_ACCEPTED' : status === 'DECLINED' ? 'OFFER_DECLINED' : 'NEW_OFFER';
await prisma.notification.create({
const recipientId = existing.buyerId === req.userId ? existing.sellerId : existing.buyerId;
const notificationType = status === 'ACCEPTED' ? 'OFFER_ACCEPTED' as const
: status === 'DECLINED' ? 'OFFER_DECLINED' as const
: 'NEW_OFFER' as const;
const notification = await prisma.notification.create({
data: {
userId: existing.buyerId,
userId: recipientId,
type: notificationType,
title: status === 'ACCEPTED' ? 'Offer Accepted' : status === 'DECLINED' ? 'Offer Declined' : 'Counter Offer',
body: `Your offer for ${existing.listing.title} was ${status.toLowerCase()}`,
body: status === 'COUNTERED'
? `Counter offer of $${counterAmount} for ${existing.listing.title}`
: `Your offer for ${existing.listing.title} was ${status.toLowerCase()}`,
data: { offerId: existing.id, listingId: existing.listingId },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${recipientId}`).emit('new_notification', notification);
io.to(`user:${existing.buyerId}`).emit('offer_update', offer);
io.to(`user:${existing.sellerId}`).emit('offer_update', offer);
}
res.json(offer);
} catch (error) {
next(error);

View File

@@ -9,6 +9,46 @@ const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- Stripe config (no auth required) ---
router.get('/config', (_req, res) => {
res.json({ publishableKey: env.STRIPE_PUBLISHABLE_KEY || null });
});
// --- Payment history ---
router.get('/history', authenticate, async (req, res, next) => {
try {
const payments = await prisma.payment.findMany({
where: { userId: req.userId },
include: {
listing: { select: { id: true, title: true, images: { take: 1, orderBy: { order: 'asc' } } } },
},
orderBy: { createdAt: 'desc' },
});
res.json(payments);
} catch (error) {
next(error);
}
});
// --- Payment status ---
router.get('/:id/status', authenticate, async (req, res, next) => {
try {
const payment = await prisma.payment.findUnique({
where: { id: req.params.id },
include: {
listing: { select: { id: true, title: true } },
},
});
if (!payment) throw new AppError(404, 'Payment not found');
if (payment.userId !== req.userId) throw new AppError(403, 'Not authorized');
res.json(payment);
} catch (error) {
next(error);
}
});
// --- Create payment intent ---
router.post('/create-intent', authenticate, async (req, res, next) => {
try {
const { listingId } = req.body;
@@ -45,6 +85,7 @@ router.post('/create-intent', authenticate, async (req, res, next) => {
}
});
// --- Stripe webhook ---
router.post('/webhook', async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
@@ -67,6 +108,13 @@ router.post('/webhook', async (req, res, next) => {
data: { status: 'ACTIVE' },
});
}
} else if (event.type === 'payment_intent.payment_failed') {
const paymentIntent = event.data.object;
await prisma.payment.updateMany({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'FAILED' },
});
}
res.json({ received: true });

View File

@@ -1,18 +1,115 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { upload } from '../middleware/upload.js';
import { validate } from '../middleware/validate.js';
import { hashPassword, comparePassword } from '../utils/password.js';
import { AppError } from '../middleware/errorHandler.js';
import { updateProfileSchema, updateSettingsSchema, deleteAccountSchema } from '../validators/user.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,
phone: true, location: true, bio: true, rating: true, ratingCount: true,
showEmail: true, showPhone: true, showLocation: true, showOnline: true, showRating: true,
createdAt: true,
};
// --- Avatar upload ---
router.post('/avatar', authenticate, upload.single('avatar'), async (req, res, next) => {
try {
const file = req.file;
if (!file) throw new AppError(400, 'No file uploaded');
const user = await prisma.user.update({
where: { id: req.userId },
data: { avatar: `/uploads/${file.filename}` },
select: userSelect,
});
res.json(user);
} catch (error) {
next(error);
}
});
// --- Settings ---
router.get('/settings', authenticate, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: {
showEmail: true, showPhone: true, showLocation: true,
showOnline: true, showRating: true,
notifNewOffer: true, notifMessages: true, notifItemSold: true,
notifFavorites: true, notifEmail: true, marketingEmail: true,
twoFactorEnabled: true,
},
});
if (!user) throw new AppError(404, 'User not found');
res.json(user);
} catch (error) {
next(error);
}
});
router.put('/settings', authenticate, validate(updateSettingsSchema), async (req, res, next) => {
try {
const user = await prisma.user.update({
where: { id: req.userId },
data: req.body,
select: {
showEmail: true, showPhone: true, showLocation: true,
showOnline: true, showRating: true,
notifNewOffer: true, notifMessages: true, notifItemSold: true,
notifFavorites: true, notifEmail: true, marketingEmail: true,
twoFactorEnabled: true,
},
});
res.json(user);
} catch (error) {
next(error);
}
});
// --- Sessions ---
router.get('/sessions', authenticate, async (req, res, next) => {
try {
const sessions = await prisma.session.findMany({
where: { userId: req.userId },
select: { id: true, userAgent: true, ipAddress: true, createdAt: true, expiresAt: true },
orderBy: { createdAt: 'desc' },
});
res.json(sessions);
} catch (error) {
next(error);
}
});
// --- Account deletion ---
router.delete('/account', authenticate, validate(deleteAccountSchema), async (req, res, next) => {
try {
const { password } = 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(password, user.passwordHash);
if (!valid) throw new AppError(400, 'Password is incorrect');
await prisma.session.deleteMany({ where: { userId: req.userId } });
await prisma.user.update({
where: { id: req.userId },
data: { isActive: false },
});
res.clearCookie('refreshToken');
res.json({ message: 'Account deactivated' });
} catch (error) {
next(error);
}
});
// --- Profile ---
router.get('/profile', authenticate, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({ where: { id: req.userId }, select: userSelect });
@@ -23,12 +120,11 @@ router.get('/profile', authenticate, async (req, res, next) => {
}
});
router.put('/profile', authenticate, async (req, res, next) => {
router.put('/profile', authenticate, validate(updateProfileSchema), 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 },
data: req.body,
select: userSelect,
});
res.json(user);
@@ -37,9 +133,13 @@ router.put('/profile', authenticate, async (req, res, next) => {
}
});
// --- Password ---
router.put('/password', authenticate, async (req, res, next) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) throw new AppError(400, 'Both current and new passwords are required');
if (newPassword.length < 8) throw new AppError(400, 'New password must be at least 8 characters');
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (!user) throw new AppError(404, 'User not found');
@@ -55,11 +155,67 @@ router.put('/password', authenticate, async (req, res, next) => {
}
});
// --- Block/Unblock ---
router.post('/:id/block', authenticate, async (req, res, next) => {
try {
if (req.params.id === req.userId) throw new AppError(400, 'Cannot block yourself');
const target = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!target) throw new AppError(404, 'User not found');
const existing = await prisma.blockedUser.findUnique({
where: { blockerId_blockedId: { blockerId: req.userId!, blockedId: req.params.id } },
});
if (existing) throw new AppError(409, 'User already blocked');
await prisma.blockedUser.create({
data: { blockerId: req.userId!, blockedId: req.params.id },
});
res.json({ message: 'User blocked' });
} catch (error) {
next(error);
}
});
router.delete('/:id/block', authenticate, async (req, res, next) => {
try {
const existing = await prisma.blockedUser.findUnique({
where: { blockerId_blockedId: { blockerId: req.userId!, blockedId: req.params.id } },
});
if (!existing) throw new AppError(404, 'Block not found');
await prisma.blockedUser.delete({ where: { id: existing.id } });
res.json({ message: 'User unblocked' });
} catch (error) {
next(error);
}
});
// --- Public profile (must be LAST due to /:id param) ---
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);
const user = await prisma.user.findUnique({
where: { id: req.params.id },
select: {
...userSelect,
_count: { select: { listings: { where: { status: 'ACTIVE' } } } },
},
});
if (!user || !(await prisma.user.findUnique({ where: { id: req.params.id, isActive: true } }))) {
throw new AppError(404, 'User not found');
}
// Enforce privacy flags
const publicUser: Record<string, unknown> = { ...user };
if (!user.showEmail) delete publicUser.email;
if (!user.showPhone) delete publicUser.phone;
if (!user.showLocation) delete publicUser.location;
if (!user.showRating) {
delete publicUser.rating;
delete publicUser.ratingCount;
}
res.json(publicUser);
} catch (error) {
next(error);
}

View File

@@ -3,6 +3,9 @@ import { Server } from 'socket.io';
import { verifyAccessToken } from '../utils/jwt.js';
import { prisma } from '../config/database.js';
// Online status tracking
const onlineUsers = new Map<string, Set<string>>();
export function setupSocket(httpServer: HTTPServer) {
const io = new Server(httpServer, {
cors: {
@@ -27,6 +30,15 @@ export function setupSocket(httpServer: HTTPServer) {
const userId = socket.data.userId;
socket.join(`user:${userId}`);
// Track online status
if (!onlineUsers.has(userId)) {
onlineUsers.set(userId, new Set());
}
onlineUsers.get(userId)!.add(socket.id);
// Broadcast online status
socket.broadcast.emit('user_online', { userId });
socket.on('join_conversation', (conversationId: string) => {
socket.join(`conversation:${conversationId}`);
});
@@ -58,6 +70,19 @@ export function setupSocket(httpServer: HTTPServer) {
if (conversation) {
const recipientId = conversation.user1Id === userId ? conversation.user2Id : conversation.user1Id;
io.to(`user:${recipientId}`).emit('message_notification', { conversationId: data.conversationId, message });
// Create notification
const sender = await prisma.user.findUnique({ where: { id: userId }, select: { fullName: true } });
const notification = await prisma.notification.create({
data: {
userId: recipientId,
type: 'NEW_MESSAGE',
title: 'New Message',
body: `${sender?.fullName || 'Someone'} sent you a message`,
data: { conversationId: data.conversationId },
},
});
io.to(`user:${recipientId}`).emit('new_notification', notification);
}
} catch (error) {
socket.emit('error', { message: 'Failed to send message' });
@@ -79,8 +104,20 @@ export function setupSocket(httpServer: HTTPServer) {
});
});
socket.on('get_online_users', () => {
const users = Array.from(onlineUsers.keys());
socket.emit('online_users', users);
});
socket.on('disconnect', () => {
// Cleanup handled by Socket.io
const userSockets = onlineUsers.get(userId);
if (userSockets) {
userSockets.delete(socket.id);
if (userSockets.size === 0) {
onlineUsers.delete(userId);
socket.broadcast.emit('user_offline', { userId });
}
}
});
});

View File

@@ -0,0 +1,17 @@
import { prisma } from '../config/database.js';
export async function getBlockedUserIds(userId: string): Promise<string[]> {
const blocks = await prisma.blockedUser.findMany({
where: {
OR: [{ blockerId: userId }, { blockedId: userId }],
},
select: { blockerId: true, blockedId: true },
});
const ids = new Set<string>();
for (const block of blocks) {
if (block.blockerId === userId) ids.add(block.blockedId);
else ids.add(block.blockerId);
}
return Array.from(ids);
}

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
export const updateProfileSchema = z.object({
fullName: z.string().min(2).max(100).optional(),
nickname: z.string().min(2).max(50).optional().nullable(),
phone: z.string().max(20).optional().nullable(),
location: z.string().max(200).optional().nullable(),
bio: z.string().max(500).optional().nullable(),
showEmail: z.boolean().optional(),
showPhone: z.boolean().optional(),
showLocation: z.boolean().optional(),
});
export const updateSettingsSchema = z.object({
showOnline: z.boolean().optional(),
showRating: z.boolean().optional(),
notifNewOffer: z.boolean().optional(),
notifMessages: z.boolean().optional(),
notifItemSold: z.boolean().optional(),
notifFavorites: z.boolean().optional(),
notifEmail: z.boolean().optional(),
marketingEmail: z.boolean().optional(),
});
export const deleteAccountSchema = z.object({
password: z.string().min(1, 'Password is required'),
});