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:
68
CLAUDE.md
Normal file
68
CLAUDE.md
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
109
client/src/components/ui/LocationInput.tsx
Normal file
109
client/src/components/ui/LocationInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export { Card } from './Card';
|
||||
export { Avatar } from './Avatar';
|
||||
export { Toggle } from './Toggle';
|
||||
export { Badge } from './Badge';
|
||||
export { LocationInput } from './LocationInput';
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
103
client/src/pages/MyListingsPage.tsx
Normal file
103
client/src/pages/MyListingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
client/src/pages/SavedItemsPage.tsx
Normal file
39
client/src/pages/SavedItemsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
313
server/prisma/migrations/20260222200755_init/migration.sql
Normal file
313
server/prisma/migrations/20260222200755_init/migration.sql
Normal 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;
|
||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -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
793
server/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
58
server/src/routes/location.ts
Normal file
58
server/src/routes/location.ts
Normal 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
41
server/src/routes/misc.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
17
server/src/utils/blocked.ts
Normal file
17
server/src/utils/blocked.ts
Normal 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);
|
||||
}
|
||||
27
server/src/validators/user.ts
Normal file
27
server/src/validators/user.ts
Normal 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'),
|
||||
});
|
||||
Reference in New Issue
Block a user