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:
@@ -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
|
||||
</button>
|
||||
</form>
|
||||
{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">
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary-50 to-pink-50">
|
||||
<span className="text-4xl">
|
||||
{listing.category === 'FURNITURE' ? '\uD83E\uDE91' :
|
||||
listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' :
|
||||
listing.category === 'CLOTHING' ? '\uD83D\uDC55' :
|
||||
listing.category === 'HOME_GARDEN' ? '\u2615' :
|
||||
listing.category === 'SPORTS' ? '\uD83D\uDEB4' :
|
||||
listing.category === 'BOOKS' ? '\uD83D\uDCDA' :
|
||||
listing.category === 'GAMES' ? '\uD83C\uDFAE' : '\uD83D\uDCE6'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); setIsFav(!isFav); }}
|
||||
className="absolute top-2 right-2 p-1.5 bg-white/80 backdrop-blur rounded-full hover:bg-white transition-colors cursor-pointer"
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${isFav ? 'fill-pink-500 text-pink-500' : 'text-gray-400'}`} />
|
||||
</button>
|
||||
{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">{categoryEmoji}</span>
|
||||
</div>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
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;
|
||||
setNewMessage('');
|
||||
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();
|
||||
navigate('/');
|
||||
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,59 +38,68 @@ 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>
|
||||
|
||||
<div className="space-y-3">
|
||||
{mockOffers.map(offer => {
|
||||
const savings = offer.listing.price - offer.amount;
|
||||
const statusVariant = offer.status === 'ACCEPTED' ? 'success' : offer.status === 'DECLINED' ? 'error' : offer.status === 'COUNTERED' ? 'warning' : 'info';
|
||||
{offers.length === 0 ? (
|
||||
<p className="text-center text-gray-400 py-12">No offers yet</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{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">
|
||||
<span className="text-2xl">
|
||||
{offer.listing.category === 'FURNITURE' ? '\uD83E\uDE91' : offer.listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' : '\uD83D\uDCE6'}
|
||||
</span>
|
||||
</div>
|
||||
return (
|
||||
<div key={offer.id} className="bg-white rounded-2xl border border-gray-100 p-4 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">
|
||||
{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">
|
||||
<Avatar name={offer.buyer.fullName} size="sm" />
|
||||
<span className="text-xs text-gray-500">{offer.buyer.fullName}</span>
|
||||
<span className="text-xs text-gray-400">{formatDate(offer.createdAt)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{offer.listing.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Avatar name={offer.buyer.fullName} size="sm" />
|
||||
<span className="text-xs text-gray-500">{offer.buyer.fullName}</span>
|
||||
<span className="text-xs text-gray-400">{formatDate(offer.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{savings > 0 && <Badge variant="error" size="sm">-{formatCurrency(savings)}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{offer.status === 'PENDING' ? (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prices */}
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-gray-400 line-through">{formatCurrency(offer.listing.price)}</p>
|
||||
<p className="text-lg font-bold text-primary-600">{formatCurrency(offer.amount)}</p>
|
||||
<Badge variant="error" size="sm">-{formatCurrency(savings)}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Status / Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{offer.status === 'PENDING' ? (
|
||||
<>
|
||||
<Button variant="secondary" size="sm">Accept</Button>
|
||||
<GradientButton size="sm">Counteroffer</GradientButton>
|
||||
</>
|
||||
) : (
|
||||
<Badge variant={statusVariant} size="md">{offer.status}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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,39 +24,62 @@ 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>
|
||||
|
||||
<div className="space-y-2">
|
||||
{mockNotifications.map(notif => {
|
||||
const Icon = iconMap[notif.type] || Bell;
|
||||
const colorClass = iconColorMap[notif.type] || 'text-gray-500 bg-gray-50';
|
||||
{notifications.length === 0 ? (
|
||||
<p className="text-center text-gray-400 py-12">No notifications</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notifications.map(notif => {
|
||||
const Icon = iconMap[notif.type] || Bell;
|
||||
const colorClass = iconColorMap[notif.type] || 'text-gray-500 bg-gray-50';
|
||||
|
||||
return (
|
||||
<div key={notif.id} className={`flex items-center gap-4 p-4 rounded-2xl border transition-colors ${notif.isRead ? 'bg-white border-gray-100' : 'bg-primary-50/50 border-primary-100'}`}>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
return (
|
||||
<div key={notif.id} className={`flex items-center gap-4 p-4 rounded-2xl border transition-colors ${notif.isRead ? 'bg-white border-gray-100' : 'bg-primary-50/50 border-primary-100'}`}>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${colorClass}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${notif.isRead ? 'text-gray-600' : 'text-gray-900 font-medium'}`}>{notif.body}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{formatDate(notif.createdAt)}</p>
|
||||
</div>
|
||||
{(notif.type === 'NEW_OFFER' || notif.type === 'OFFER_ACCEPTED') && (
|
||||
<button className="text-xs font-medium text-primary-600 hover:text-primary-700 flex-shrink-0 cursor-pointer">
|
||||
View Offer
|
||||
</button>
|
||||
)}
|
||||
{!notif.isRead && <div className="w-2 h-2 bg-primary-500 rounded-full flex-shrink-0" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm ${notif.isRead ? 'text-gray-600' : 'text-gray-900 font-medium'}`}>{notif.body}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{formatDate(notif.createdAt)}</p>
|
||||
</div>
|
||||
{(notif.type === 'NEW_OFFER' || notif.type === 'OFFER_ACCEPTED') && (
|
||||
<button className="text-xs font-medium text-primary-600 hover:text-primary-700 flex-shrink-0 cursor-pointer">
|
||||
View Offer
|
||||
</button>
|
||||
)}
|
||||
{!notif.isRead && <div className="w-2 h-2 bg-primary-500 rounded-full flex-shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<span className="text-8xl">{categoryEmoji}</span>
|
||||
<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">
|
||||
<span className="text-2xl">{categoryEmoji}</span>
|
||||
{(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,9 +166,16 @@ export function ProductDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setIsFav(!isFav)} className="p-2 rounded-xl hover:bg-gray-100 transition-colors cursor-pointer">
|
||||
<Heart className={`w-6 h-6 ${isFav ? 'fill-pink-500 text-pink-500' : 'text-gray-400'}`} />
|
||||
</button>
|
||||
<div 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)}
|
||||
@@ -63,38 +183,40 @@ export function ProductDetailPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<GradientButton className="flex-1" size="lg" onClick={() => setShowOffer(true)}>
|
||||
Make Offer
|
||||
</GradientButton>
|
||||
<Button variant="outline" size="lg" onClick={() => {}}>
|
||||
<MessageSquare className="w-4 h-4 mr-2" /> Message
|
||||
</Button>
|
||||
</div>
|
||||
{!isOwner && (
|
||||
<div className="flex gap-3">
|
||||
<GradientButton className="flex-1" size="lg" onClick={() => setShowOffer(true)}>
|
||||
Make Offer
|
||||
</GradientButton>
|
||||
<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">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-sm font-medium">{listing.seller.rating}</span>
|
||||
{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,35 +78,48 @@ export function SoldItemsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
<div className="hidden sm:grid grid-cols-12 gap-4 px-4 py-3 bg-gray-50 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
<div className="col-span-4">Item</div>
|
||||
<div className="col-span-2">Listing Price</div>
|
||||
<div className="col-span-2">Sale Price</div>
|
||||
<div className="col-span-2">Buyer</div>
|
||||
<div className="col-span-2">Date</div>
|
||||
</div>
|
||||
{mockSoldItems.map((item, i) => (
|
||||
<div key={i} className="grid grid-cols-12 gap-4 items-center px-4 py-4 border-t border-gray-50 hover:bg-gray-50 transition-colors">
|
||||
<div className="col-span-4 flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-50 to-pink-50 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xl">
|
||||
{item.listing.category === 'FURNITURE' ? '\uD83E\uDE91' : item.listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' : item.listing.category === 'CLOTHING' ? '\uD83D\uDC55' : '\u2615'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 truncate">{item.listing.title}</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-gray-500">{formatCurrency(item.listing.price)}</div>
|
||||
<div className="col-span-2 text-sm font-semibold text-green-600">{formatCurrency(item.salePrice)}</div>
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
<Avatar name={item.buyer.fullName} size="sm" />
|
||||
<span className="text-sm text-gray-600 truncate">{item.buyer.fullName}</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-gray-400">{formatDate(item.date)}</div>
|
||||
{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>
|
||||
<div className="col-span-2">Listing Price</div>
|
||||
<div className="col-span-2">Sale Price</div>
|
||||
<div className="col-span-2">Buyer</div>
|
||||
<div className="col-span-2">Date</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{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 overflow-hidden">
|
||||
{item.images?.[0] ? (
|
||||
<img src={item.images[0].url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xl">
|
||||
{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.title}</span>
|
||||
</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.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();
|
||||
navigate('/');
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user