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

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

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

View File

@@ -1,28 +1,46 @@
import { NavLink, Outlet } from 'react-router-dom';
import { MessageSquare, Tag, Bell, ShoppingBag, Settings } from 'lucide-react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { MessageSquare, Tag, ShoppingBag, Settings, List, Heart, LogOut } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
const navItems = [
{ to: '/dashboard/messages', icon: MessageSquare, label: 'Messages' },
{ to: '/dashboard/offers', icon: Tag, label: 'Offers' },
{ to: '/dashboard/notifications', icon: Bell, label: 'Notifications' },
{ to: '/dashboard/sold', icon: ShoppingBag, label: 'Sold Items' },
{ to: '/dashboard/listings', icon: List, label: 'My Listings' },
{ to: '/dashboard/saved', icon: Heart, label: 'Saved Items' },
{ to: '/dashboard/messages', icon: MessageSquare, label: 'My Messages' },
{ to: '/dashboard/settings', icon: Settings, label: 'Settings' },
];
export function DashboardLayout() {
const { logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/');
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
<div className="flex gap-8">
{/* Sidebar */}
<aside className="hidden md:block w-56 flex-shrink-0">
<nav className="bg-white rounded-2xl border border-gray-100 p-3 sticky top-24">
<nav className="bg-gradient-to-b from-purple-100 via-purple-50 to-pink-50 rounded-2xl p-3 sticky top-24 shadow-sm">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink key={to} to={to}
className={({ isActive }) => `flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors ${isActive ? 'bg-primary-50 text-primary-700' : 'text-gray-600 hover:bg-gray-50'}`}>
className={({ isActive }) => `flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors ${isActive ? 'bg-white/70 text-primary-700 shadow-sm' : 'text-purple-700/70 hover:bg-white/40'}`}>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
<hr className="my-2 border-purple-200/50" />
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors text-purple-700/70 hover:bg-white/40 w-full cursor-pointer"
>
<LogOut className="w-4 h-4" />
Logout
</button>
</nav>
</aside>

View File

@@ -1,7 +1,25 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Facebook, Twitter, Instagram, Youtube, Package } from 'lucide-react';
import { api } from '../../api/client';
export function Footer() {
const [email, setEmail] = useState('');
const [subscribing, setSubscribing] = useState(false);
const [subscribed, setSubscribed] = useState(false);
const handleSubscribe = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || subscribing) return;
setSubscribing(true);
try {
await api.post('/newsletter', { email });
setSubscribed(true);
setEmail('');
} catch {}
setSubscribing(false);
};
return (
<footer className="bg-primary-900 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-12">
@@ -20,12 +38,18 @@ export function Footer() {
<div>
<p className="text-sm font-semibold mb-2">Stay Updated</p>
<p className="text-xs text-primary-300 mb-3">Subscribe to our newsletter for tips and special offers</p>
<form className="flex gap-2">
<input type="email" placeholder="Your email address" className="flex-1 px-3 py-2 rounded-lg bg-primary-800 border border-primary-700 text-sm placeholder:text-primary-400 focus:outline-none focus:border-primary-500" />
<button type="button" className="px-4 py-2 bg-gradient-to-r from-pink-500 to-primary-500 rounded-lg text-sm font-semibold hover:from-pink-600 hover:to-primary-600 transition-all cursor-pointer">
Subscribe
</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>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Search, Bell, Menu, X, User, LogOut, Package, Heart, MessageSquare, Settings } from 'lucide-react';
import { Search, Bell, Menu, X, User, LogOut, ShoppingBag, Heart, MessageSquare, Settings } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { Avatar } from '../ui/Avatar';
@@ -19,15 +19,15 @@ export function Header() {
};
return (
<header className="bg-white border-b border-gray-100 sticky top-0 z-40">
<header className="bg-gradient-to-r from-primary-700 via-primary-600 to-primary-500 sticky top-0 z-40 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center gap-2 flex-shrink-0">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-pink-500 to-primary-600 flex items-center justify-center">
<Package className="w-5 h-5 text-white" />
<div className="w-8 h-8 rounded-lg bg-white/20 backdrop-blur flex items-center justify-center">
<ShoppingBag className="w-5 h-5 text-white" />
</div>
<span className="text-lg font-bold bg-gradient-to-r from-primary-600 to-pink-500 bg-clip-text text-transparent hidden sm:block">
<span className="text-lg font-bold text-white hidden sm:block">
MARKETPLACE
</span>
</Link>
@@ -35,14 +35,14 @@ export function Header() {
{/* Search */}
<form onSubmit={handleSearch} className="hidden md:flex flex-1 max-w-lg mx-6">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
<input
type="text"
placeholder="Search for items..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-xl border border-gray-200 bg-gray-50 text-sm
focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none transition-all"
className="w-full pl-10 pr-4 py-2 rounded-xl border border-white/20 bg-white/10 text-sm text-white placeholder-white/50
focus:border-white/40 focus:ring-2 focus:ring-white/20 focus:outline-none transition-all backdrop-blur"
/>
</div>
</form>
@@ -51,15 +51,15 @@ export function Header() {
<div className="flex items-center gap-3">
{isAuthenticated ? (
<>
<Link to="/sell" className="hidden sm:inline-flex items-center px-4 py-2 text-sm font-semibold text-white rounded-xl bg-gradient-to-r from-pink-500 to-primary-600 hover:from-pink-600 hover:to-primary-700 transition-all">
<Link to="/sell" className="hidden sm:inline-flex items-center px-4 py-2 text-sm font-semibold text-white rounded-xl bg-white/20 hover:bg-white/30 backdrop-blur transition-all">
Sell Item
</Link>
<Link to="/dashboard/messages" className="p-2 rounded-lg hover:bg-gray-100 transition-colors relative">
<MessageSquare className="w-5 h-5 text-gray-600" />
<Link to="/dashboard/messages" className="p-2 rounded-lg hover:bg-white/10 transition-colors relative">
<MessageSquare className="w-5 h-5 text-white/80" />
</Link>
<Link to="/dashboard/notifications" className="p-2 rounded-lg hover:bg-gray-100 transition-colors relative">
<Bell className="w-5 h-5 text-gray-600" />
<span className="absolute top-1 right-1 w-2 h-2 bg-pink-500 rounded-full" />
<Link to="/dashboard/notifications" className="p-2 rounded-lg hover:bg-white/10 transition-colors relative">
<Bell className="w-5 h-5 text-white/80" />
<span className="absolute top-1 right-1 w-2 h-2 bg-pink-400 rounded-full" />
</Link>
<div className="relative">
<button onClick={() => setShowUserMenu(!showUserMenu)} className="flex items-center gap-2 cursor-pointer">
@@ -77,7 +77,7 @@ export function Header() {
<User className="w-4 h-4" /> Profile
</Link>
<Link to="/dashboard/offers" onClick={() => setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<Package className="w-4 h-4" /> My Offers
<ShoppingBag className="w-4 h-4" /> My Offers
</Link>
<Link to="/dashboard/sold" onClick={() => setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
<Heart className="w-4 h-4" /> Sold Items
@@ -96,28 +96,28 @@ export function Header() {
</>
) : (
<>
<Link to="/login" className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors">
Log In
<Link to="/login" className="px-4 py-2 text-sm font-medium text-white/80 hover:text-white transition-colors">
Login
</Link>
<Link to="/signup" className="px-4 py-2 text-sm font-semibold text-white rounded-xl bg-gradient-to-r from-pink-500 to-primary-600 hover:from-pink-600 hover:to-primary-700 transition-all">
<Link to="/signup" className="px-4 py-2 text-sm font-semibold text-primary-700 rounded-xl bg-white hover:bg-white/90 transition-all">
Sign Up
</Link>
</>
)}
<button onClick={() => setShowMobileMenu(!showMobileMenu)} className="md:hidden p-2 rounded-lg hover:bg-gray-100 cursor-pointer">
{showMobileMenu ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
<button onClick={() => setShowMobileMenu(!showMobileMenu)} className="md:hidden p-2 rounded-lg hover:bg-white/10 cursor-pointer">
{showMobileMenu ? <X className="w-5 h-5 text-white" /> : <Menu className="w-5 h-5 text-white" />}
</button>
</div>
</div>
{/* Mobile menu */}
{showMobileMenu && (
<div className="md:hidden pb-4 border-t border-gray-100 pt-4">
<div className="md:hidden pb-4 border-t border-white/10 pt-4">
<form onSubmit={handleSearch} className="mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
<input type="text" placeholder="Search..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-xl border border-gray-200 bg-gray-50 text-sm focus:border-primary-400 focus:outline-none" />
className="w-full pl-10 pr-4 py-2 rounded-xl border border-white/20 bg-white/10 text-sm text-white placeholder-white/50 focus:border-white/40 focus:outline-none" />
</div>
</form>
{isAuthenticated && (

View File

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

View File

@@ -2,6 +2,8 @@ import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Heart, MapPin } from 'lucide-react';
import { Badge } from '../ui/Badge';
import { api } from '../../api/client';
import { useAuth } from '../../context/AuthContext';
import type { Listing } from '../../types';
import { formatCurrency } from '../../utils/format';
@@ -10,34 +12,57 @@ interface ListingCardProps {
}
export function ListingCard({ listing }: ListingCardProps) {
const { isAuthenticated } = useAuth();
const [isFav, setIsFav] = useState(listing.isFavorited ?? false);
const [toggling, setToggling] = useState(false);
const conditionVariant = listing.condition === 'NEW' ? 'success'
: listing.condition === 'LIKE_NEW' ? 'info'
: 'default';
const categoryEmoji =
listing.category === 'FURNITURE' ? '\uD83E\uDE91' :
listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' :
listing.category === 'CLOTHING' ? '\uD83D\uDC55' :
listing.category === 'HOME_GARDEN' ? '\u2615' :
listing.category === 'SPORTS' ? '\uD83D\uDEB4' :
listing.category === 'BOOKS' ? '\uD83D\uDCDA' :
listing.category === 'GAMES' ? '\uD83C\uDFAE' : '\uD83D\uDCE6';
const handleFavorite = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isAuthenticated || toggling) return;
setToggling(true);
try {
const res = await api.post<{ isFavorited: boolean }>(`/listings/${listing.id}/favorite`);
setIsFav(res.isFavorited);
} catch {}
setToggling(false);
};
const hasImage = listing.images?.[0]?.url;
return (
<Link to={`/listings/${listing.id}`} className="group block">
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
{/* Image */}
<div className="relative aspect-square bg-gray-100">
<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 */}

View File

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

View File

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

View File

@@ -1,24 +1,60 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Avatar } from '../components/ui/Avatar';
import { mockConversations, mockMessages, mockCurrentUser } from '../utils/mockData';
import { api } from '../api/client';
import { useAuth } from '../context/AuthContext';
import { formatDate, formatCurrency } from '../utils/format';
import type { Conversation, Message } from '../types';
export function ChatPage() {
const [selectedConv, setSelectedConv] = useState(mockConversations[0]?.id);
const { user } = useAuth();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [selectedConv, setSelectedConv] = useState<string | undefined>();
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
const messages = mockMessages.filter(m => m.conversationId === selectedConv);
const activeConv = mockConversations.find(c => c.id === selectedConv);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<Conversation[]>('/chat/conversations')
.then(convs => {
setConversations(convs);
if (convs.length > 0 && !selectedConv) setSelectedConv(convs[0].id);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!selectedConv) return;
api.get<{ data: Message[] }>(`/chat/conversations/${selectedConv}/messages`)
.then(res => setMessages(res.data))
.catch(() => setMessages([]));
}, [selectedConv]);
const activeConv = conversations.find(c => c.id === selectedConv);
const otherUser = activeConv
? (activeConv.user1.id === mockCurrentUser.id ? activeConv.user2 : activeConv.user1)
? (activeConv.user1.id === user?.id ? activeConv.user2 : activeConv.user1)
: null;
const handleSend = (e: React.FormEvent) => {
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
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..."

View File

@@ -1,22 +1,44 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User, Mail, Phone, MapPin, Camera } from 'lucide-react';
import { User, Mail, Phone, Camera } from 'lucide-react';
import { Input } from '../components/ui/Input';
import { GradientButton } from '../components/ui/GradientButton';
import { Card } from '../components/ui/Card';
import { Avatar } from '../components/ui/Avatar';
import { LocationInput } from '../components/ui/LocationInput';
import { api } from '../api/client';
import { useAuth } from '../context/AuthContext';
export function CreateProfilePage() {
const navigate = useNavigate();
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const { user, updateUser } = useAuth();
const [fullName, setFullName] = useState(user?.fullName || '');
const [email, setEmail] = useState(user?.email || '');
const [phone, setPhone] = useState('');
const [location, setLocation] = useState('');
const [bio, setBio] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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>

View File

@@ -1,26 +1,33 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Search } from 'lucide-react';
import { ListingGrid } from '../components/listings/ListingGrid';
import { CategorySidebar } from '../components/listings/CategorySidebar';
import { mockListings } from '../utils/mockData';
import { api } from '../api/client';
import type { Listing, PaginatedResponse } from '../types';
const categoryTags = ['Furniture', 'Electronics', 'Clothing', 'Books', 'Games'];
export function HomePage() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | undefined>();
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(true);
const filtered = selectedCategory
? mockListings.filter(l => l.category === selectedCategory)
: mockListings;
useEffect(() => {
setLoading(true);
const params = new URLSearchParams();
params.set('pageSize', '20');
if (selectedCategory) params.set('category', selectedCategory);
if (searchQuery) params.set('search', searchQuery);
api.get<PaginatedResponse<Listing>>(`/listings?${params}`)
.then(res => setListings(res.data))
.catch(() => setListings([]))
.finally(() => setLoading(false));
}, [selectedCategory, searchQuery]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
navigate(`/?search=${encodeURIComponent(searchQuery)}`);
}
};
return (
@@ -62,34 +69,24 @@ export function HomePage() {
{/* Main content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
<div className="flex gap-8">
{/* Sidebar - desktop only */}
<aside className="hidden lg:block w-56 flex-shrink-0">
<div className="sticky top-24">
<CategorySidebar selected={selectedCategory} onSelect={setSelectedCategory} />
</div>
</aside>
{/* Listings */}
<div className="flex-1 space-y-10">
<ListingGrid listings={filtered.slice(0, 4)} title="Popular Items" showViewAll />
<ListingGrid
listings={filtered.slice(2, 6)}
title="Trending Items"
showViewAll
/>
<ListingGrid
listings={filtered.slice(4, 8)}
title="Selling Hot This Week"
showViewAll
/>
<ListingGrid
listings={filtered.slice(0, 4).reverse()}
title="Newly Listed"
showViewAll
/>
{loading ? (
<p className="text-gray-500 text-center py-12">Loading listings...</p>
) : listings.length === 0 ? (
<p className="text-gray-500 text-center py-12">No listings found.</p>
) : (
<>
<ListingGrid listings={listings.slice(0, 4)} title="Popular Items" showViewAll />
<ListingGrid listings={listings.slice(4, 8)} title="Trending Items" showViewAll />
{listings.length > 8 && <ListingGrid listings={listings.slice(8, 12)} title="Newly Listed" showViewAll />}
</>
)}
</div>
</div>
</div>

View File

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

View File

@@ -1,13 +1,35 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { GradientButton } from '../components/ui/GradientButton';
import { Button } from '../components/ui/Button';
import { Badge } from '../components/ui/Badge';
import { Avatar } from '../components/ui/Avatar';
import { mockOffers } from '../utils/mockData';
import { api } from '../api/client';
import { formatCurrency, formatDate } from '../utils/format';
import type { Offer } from '../types';
export function MyOffersPage() {
const [sortBy] = useState('newest');
const [offers, setOffers] = useState<Offer[]>([]);
const [sortBy, setSortBy] = useState('newest');
const [loading, setLoading] = useState(true);
const fetchOffers = () => {
setLoading(true);
api.get<Offer[]>(`/offers?type=received&sort=${sortBy}`)
.then(setOffers)
.catch(() => setOffers([]))
.finally(() => setLoading(false));
};
useEffect(() => { fetchOffers(); }, [sortBy]);
const handleRespond = async (offerId: string, status: string, counterAmount?: number) => {
try {
await api.patch(`/offers/${offerId}`, { status, counterAmount });
fetchOffers();
} catch {}
};
if (loading) return <div className="text-center text-gray-500 py-12">Loading offers...</div>;
return (
<div>
@@ -16,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>
);
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { Bell, Check, Heart, Star, MessageSquare, Tag } from 'lucide-react';
import { Button } from '../components/ui/Button';
import { mockNotifications } from '../utils/mockData';
import { api } from '../api/client';
import { formatDate } from '../utils/format';
import type { NotificationType } from '../types';
import type { NotificationType, Notification, PaginatedResponse } from '../types';
const iconMap: Record<NotificationType, typeof Bell> = {
NEW_OFFER: Tag,
@@ -23,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>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag, Pencil } from 'lucide-react';
import { Card } from '../components/ui/Card';
import { GradientButton } from '../components/ui/GradientButton';
import { Button } from '../components/ui/Button';
@@ -8,19 +8,124 @@ import { Badge } from '../components/ui/Badge';
import { Avatar } from '../components/ui/Avatar';
import { Modal } from '../components/ui/Modal';
import { Input } from '../components/ui/Input';
import { mockListings } from '../utils/mockData';
import { api } from '../api/client';
import { useAuth } from '../context/AuthContext';
import { formatCurrency, formatDate } from '../utils/format';
import type { Listing } from '../types';
export function ProductDetailPage() {
const { id } = useParams();
const listing = mockListings.find(l => l.id === id) || mockListings[0]!;
const [isFav, setIsFav] = useState(listing.isFavorited ?? false);
const navigate = useNavigate();
const { user, isAuthenticated } = useAuth();
const [listing, setListing] = useState<Listing | null>(null);
const [loading, setLoading] = useState(true);
const [isFav, setIsFav] = useState(false);
const [showOffer, setShowOffer] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [offerAmount, setOfferAmount] = useState('');
const [offerMessage, setOfferMessage] = useState('');
const [offerError, setOfferError] = useState('');
// Edit form state
const [editTitle, setEditTitle] = useState('');
const [editPrice, setEditPrice] = useState('');
const [editCondition, setEditCondition] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState('');
useEffect(() => {
if (!id) return;
api.get<Listing>(`/listings/${id}`)
.then(data => {
setListing(data);
setIsFav(data.isFavorited ?? false);
})
.catch(() => setListing(null))
.finally(() => setLoading(false));
}, [id]);
const handleFavorite = async () => {
if (!listing || !isAuthenticated) return;
try {
const res = await api.post<{ isFavorited: boolean }>(`/listings/${listing.id}/favorite`);
setIsFav(res.isFavorited);
} catch {}
};
const handleSendOffer = async () => {
if (!listing || !offerAmount) return;
setOfferError('');
try {
await api.post('/offers', {
amount: parseFloat(offerAmount),
message: offerMessage || undefined,
listingId: listing.id,
});
setShowOffer(false);
setOfferAmount('');
setOfferMessage('');
} catch (err: unknown) {
setOfferError(err instanceof Error ? err.message : 'Failed to send offer');
}
};
const handleOpenEdit = () => {
if (!listing) return;
setEditTitle(listing.title);
setEditPrice(String(listing.price));
setEditCondition(listing.condition);
setEditDescription(listing.description);
setEditError('');
setShowEdit(true);
};
const handleSaveEdit = async () => {
if (!listing) return;
setEditSaving(true);
setEditError('');
try {
const updated = await api.put<Listing>(`/listings/${listing.id}`, {
title: editTitle,
price: parseFloat(editPrice),
condition: editCondition,
description: editDescription,
});
setListing(updated);
setShowEdit(false);
} catch (err) {
setEditError(err instanceof Error ? err.message : 'Failed to save changes');
} finally {
setEditSaving(false);
}
};
const handleDelete = async () => {
if (!listing) return;
if (!confirm('Are you sure you want to delete this listing?')) return;
try {
await api.delete(`/listings/${listing.id}`);
navigate('/');
} catch {}
};
const handleMessage = async () => {
if (!listing || !isAuthenticated) return;
try {
const conversation = await api.post<{ id: string }>('/chat/conversations', {
recipientId: listing.seller.id,
listingId: listing.id,
});
navigate('/dashboard/messages', { state: { conversationId: conversation.id } });
} catch {}
};
if (loading) return <div className="max-w-7xl mx-auto px-4 py-12 text-center text-gray-500">Loading...</div>;
if (!listing) return <div className="max-w-7xl mx-auto px-4 py-12 text-center text-gray-500">Listing not found</div>;
const isOwner = user?.id === listing.sellerId;
const conditionVariant = listing.condition === 'NEW' ? 'success' : listing.condition === 'LIKE_NEW' ? 'info' : 'default';
const hasImages = listing.images && listing.images.length > 0;
const categoryEmoji = listing.category === 'FURNITURE' ? '\uD83E\uDE91' : listing.category === 'ELECTRONICS' ? '\uD83C\uDFA7' : listing.category === 'CLOTHING' ? '\uD83D\uDC55' : listing.category === 'HOME_GARDEN' ? '\u2615' : '\uD83D\uDCE6';
return (
@@ -28,13 +133,21 @@ export function ProductDetailPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Images */}
<div>
<div className="aspect-square bg-gradient-to-br from-primary-50 to-pink-50 rounded-2xl flex items-center justify-center mb-4">
<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>

View File

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

View File

@@ -1,15 +1,16 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Upload, X, Camera, DollarSign, MapPin } from 'lucide-react';
import { Upload, X, DollarSign } from 'lucide-react';
import { Input } from '../components/ui/Input';
import { GradientButton } from '../components/ui/GradientButton';
import { Card } from '../components/ui/Card';
import { Modal } from '../components/ui/Modal';
import { CATEGORIES, CONDITIONS, LISTING_FEE } from '../utils/constants';
import { formatCurrency } from '../utils/format';
import { LocationInput } from '../components/ui/LocationInput';
import { CATEGORIES, CONDITIONS } from '../utils/constants';
import { api } from '../api/client';
export function SellItemPage() {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('');
@@ -17,27 +18,65 @@ export function SellItemPage() {
const [condition, setCondition] = useState('');
const [location, setLocation] = useState('');
const [obo, setObo] = useState(false);
const [photos, setPhotos] = useState<string[]>([]);
const [showPayment, setShowPayment] = useState(false);
const [photos, setPhotos] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const handleAddPhoto = () => {
if (photos.length < 6) {
setPhotos([...photos, `photo-${photos.length + 1}`]);
}
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const remaining = 6 - photos.length;
const newFiles = files.slice(0, remaining);
if (newFiles.length === 0) return;
setPhotos(prev => [...prev, ...newFiles]);
setPreviews(prev => [...prev, ...newFiles.map(f => URL.createObjectURL(f))]);
e.target.value = '';
};
const handleRemovePhoto = (index: number) => {
URL.revokeObjectURL(previews[index]);
setPhotos(photos.filter((_, i) => i !== index));
setPreviews(previews.filter((_, i) => i !== index));
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setShowPayment(true);
};
setError('');
setSubmitting(true);
const handlePayment = () => {
setShowPayment(false);
navigate('/');
try {
// 1. Create the listing
const listing = await api.post<{ id: string }>('/listings', {
title,
description,
price: parseFloat(price),
category,
condition,
location,
obo,
});
// 2. Upload images if any
if (photos.length > 0) {
const formData = new FormData();
photos.forEach(file => formData.append('images', file));
await api.upload(`/listings/${listing.id}/images`, formData);
}
// 3. Activate the listing
await api.post(`/listings/${listing.id}/activate`);
navigate(`/listings/${listing.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create listing');
} finally {
setSubmitting(false);
}
};
return (
@@ -46,15 +85,20 @@ export function SellItemPage() {
<h1 className="text-2xl font-bold text-gray-900 mb-2">Sell Your Item</h1>
<p className="text-sm text-gray-500 mb-8">Help buyers find your item with a good description</p>
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-600">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-8">
{/* Photos */}
<div>
<h2 className="text-sm font-semibold text-gray-900 mb-3">Photos</h2>
<p className="text-xs text-gray-500 mb-3">Add up to 6 photos. First photo will be shown in search results.</p>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleFileChange} />
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{photos.map((_, i) => (
<div key={i} className="relative aspect-square bg-gradient-to-br from-primary-50 to-pink-50 rounded-xl flex items-center justify-center">
<Camera className="w-6 h-6 text-primary-300" />
{previews.map((src, i) => (
<div key={i} className="relative aspect-square rounded-xl overflow-hidden">
<img src={src} alt="" className="w-full h-full object-cover" />
<button type="button" onClick={() => handleRemovePhoto(i)}
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center cursor-pointer">
<X className="w-3 h-3" />
@@ -113,37 +157,14 @@ export function SellItemPage() {
{/* Location */}
<div>
<h2 className="text-sm font-semibold text-gray-900 mb-3">Location</h2>
<Input placeholder="E.g. city, state, zip code" value={location} onChange={(e) => setLocation(e.target.value)} required
icon={<MapPin className="w-4 h-4" />} />
<LocationInput placeholder="E.g. city, state, zip code" value={location} onChange={setLocation} required />
</div>
<GradientButton type="submit" className="w-full" size="lg">
Submit Listing
<GradientButton type="submit" className="w-full" size="lg" disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Listing'}
</GradientButton>
<p className="text-xs text-gray-400 text-center">
A one-time fee of {formatCurrency(LISTING_FEE)} will be charged to list your item.
</p>
</form>
</Card>
{/* Payment Modal */}
<Modal isOpen={showPayment} onClose={() => setShowPayment(false)} title="Payment" size="sm">
<div className="text-center mb-6">
<p className="text-sm text-gray-500">Small one-time fee to list your item on the marketplace</p>
<p className="text-3xl font-bold text-primary-600 mt-3">{formatCurrency(LISTING_FEE)} <span className="text-sm font-normal text-gray-400">USD</span></p>
</div>
<div className="space-y-4 mb-6">
<Input placeholder="4242 4242 4242 4242" label="Card Number" />
<div className="grid grid-cols-2 gap-3">
<Input placeholder="MM / YY" label="Expiry" />
<Input placeholder="CVC" label="CVC" />
</div>
</div>
<GradientButton className="w-full" size="lg" onClick={handlePayment}>
Pay {formatCurrency(LISTING_FEE)}
</GradientButton>
<p className="text-xs text-gray-400 text-center mt-4">Secure payment powered by Stripe</p>
</Modal>
</div>
);
}

View File

@@ -1,17 +1,56 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Shield, Lock, Eye, Trash2 } from 'lucide-react';
import { Card } from '../components/ui/Card';
import { Toggle } from '../components/ui/Toggle';
import { Button } from '../components/ui/Button';
import { GradientButton } from '../components/ui/GradientButton';
import { api } from '../api/client';
interface Settings {
showOnline: boolean;
showRating: boolean;
twoFactorEnabled: boolean;
notifEmail: boolean;
marketingEmail: boolean;
}
export function SettingsPage() {
const [showOnline, setShowOnline] = useState(true);
const [showRating, setShowRating] = useState(true);
const [twoFactor, setTwoFactor] = useState(false);
const [notifEmail, setNotifEmail] = useState(true);
const [marketingEmail, setMarketingEmail] = useState(false);
const [cookiePref, setCookiePref] = useState(true);
const [settings, setSettings] = useState<Settings>({
showOnline: true,
showRating: true,
twoFactorEnabled: false,
notifEmail: true,
marketingEmail: false,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get<Settings>('/users/settings')
.then(setSettings)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleSave = async () => {
setSaving(true);
try {
const updated = await api.put<Settings>('/users/settings', {
showOnline: settings.showOnline,
showRating: settings.showRating,
notifEmail: settings.notifEmail,
marketingEmail: settings.marketingEmail,
});
setSettings(updated);
} catch {}
setSaving(false);
};
const update = (key: keyof Settings, value: boolean) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
if (loading) return <div className="text-center text-gray-500 py-12">Loading settings...</div>;
return (
<div className="space-y-6">
@@ -20,7 +59,6 @@ export function SettingsPage() {
<p className="text-sm text-gray-500 mt-1">Manage your privacy, security and data preferences</p>
</div>
{/* Account Privacy */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center">
@@ -29,8 +67,8 @@ export function SettingsPage() {
<h2 className="font-semibold text-gray-900">Account Privacy</h2>
</div>
<div className="space-y-4">
<Toggle checked={showOnline} onChange={setShowOnline} label="Show Online Status" description="Let others see when you're online" />
<Toggle checked={showRating} onChange={setShowRating} label="Show Seller Rating" description="Display your rating on your profile" />
<Toggle checked={settings.showOnline} onChange={(v) => update('showOnline', v)} label="Show Online Status" description="Let others see when you're online" />
<Toggle checked={settings.showRating} onChange={(v) => update('showRating', v)} label="Show Seller Rating" description="Display your rating on your profile" />
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700">Blocked Users</div>
@@ -41,7 +79,6 @@ export function SettingsPage() {
</div>
</Card>
{/* Security Settings */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center">
@@ -50,7 +87,7 @@ export function SettingsPage() {
<h2 className="font-semibold text-gray-900">Security Settings</h2>
</div>
<div className="space-y-4">
<Toggle checked={twoFactor} onChange={setTwoFactor} label="Two-Factor Authentication" description="Add an extra layer of security" />
<Toggle checked={settings.twoFactorEnabled} onChange={(v) => update('twoFactorEnabled', v)} label="Two-Factor Authentication" description="Add an extra layer of security" />
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700">Password Reset</div>
@@ -68,7 +105,6 @@ export function SettingsPage() {
</div>
</Card>
{/* Data & Privacy */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center">
@@ -77,9 +113,8 @@ export function SettingsPage() {
<h2 className="font-semibold text-gray-900">Data & Privacy</h2>
</div>
<div className="space-y-4">
<Toggle checked={notifEmail} onChange={setNotifEmail} label="Notification Emails" description="Receive email notifications" />
<Toggle checked={marketingEmail} onChange={setMarketingEmail} label="Marketing Emails" description="Receive promotional emails" />
<Toggle checked={cookiePref} onChange={setCookiePref} label="Cookie Preferences" description="Manage cookie settings" />
<Toggle checked={settings.notifEmail} onChange={(v) => update('notifEmail', v)} label="Notification Emails" description="Receive email notifications" />
<Toggle checked={settings.marketingEmail} onChange={(v) => update('marketingEmail', v)} label="Marketing Emails" description="Receive promotional emails" />
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700">Privacy Policy</div>
@@ -97,12 +132,13 @@ export function SettingsPage() {
</div>
</Card>
{/* Danger Zone */}
<div className="flex items-center justify-between pt-4">
<Button variant="danger" size="md">
<Trash2 className="w-4 h-4 mr-2" /> Delete Account
</Button>
<GradientButton size="md">Save Profile</GradientButton>
<GradientButton size="md" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</GradientButton>
</div>
</div>
);

View File

@@ -1,12 +1,44 @@
import { useState, useEffect } from 'react';
import { Card } from '../components/ui/Card';
import { Avatar } from '../components/ui/Avatar';
import { mockSoldItems } from '../utils/mockData';
import { api } from '../api/client';
import { formatCurrency, formatDate } from '../utils/format';
import { DollarSign, TrendingUp } from 'lucide-react';
interface SoldItem {
id: string;
title: string;
price: number;
category: string;
images: Array<{ url: string }>;
salePrice: number;
buyer: { fullName: string; avatar?: string } | null;
soldDate: string;
}
interface SoldResponse {
data: SoldItem[];
stats: { totalSold: number; totalEarnings: number };
}
export function SoldItemsPage() {
const totalValue = mockSoldItems.reduce((sum, item) => sum + item.listing.price, 0);
const totalEarnings = mockSoldItems.reduce((sum, item) => sum + item.salePrice, 0);
const [soldItems, setSoldItems] = useState<SoldItem[]>([]);
const [stats, setStats] = useState({ totalSold: 0, totalEarnings: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<SoldResponse>('/listings/sold')
.then(res => {
setSoldItems(res.data);
setStats(res.stats);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const totalValue = soldItems.reduce((sum, item) => sum + item.price, 0);
if (loading) return <div className="text-center text-gray-500 py-12">Loading sold items...</div>;
return (
<div>
@@ -30,7 +62,7 @@ export function SoldItemsPage() {
</div>
<div>
<p className="text-sm text-gray-500">Total Earnings</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(totalEarnings)}</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(stats.totalEarnings)}</p>
</div>
</div>
</Card>
@@ -46,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>
);
}

View File

@@ -6,26 +6,35 @@ import { GradientButton } from '../components/ui/GradientButton';
import { Card } from '../components/ui/Card';
import { Avatar } from '../components/ui/Avatar';
import { Toggle } from '../components/ui/Toggle';
import { mockCurrentUser } from '../utils/mockData';
import { LocationInput } from '../components/ui/LocationInput';
import { useAuth } from '../context/AuthContext';
import { api } from '../api/client';
export function UpdateProfilePage() {
const navigate = useNavigate();
const { user } = useAuth();
const currentUser = user || mockCurrentUser;
const { user, updateUser } = useAuth();
const [fullName, setFullName] = useState(currentUser.fullName);
const [email] = useState(currentUser.email);
const [phone, setPhone] = useState(currentUser.phone || '');
const [location, setLocation] = useState(currentUser.location || '');
const [bio, setBio] = useState(currentUser.bio || '');
const [showEmail, setShowEmail] = useState(currentUser.showEmail);
const [showPhone, setShowPhone] = useState(currentUser.showPhone);
const [showLocation, setShowLocation] = useState(currentUser.showLocation);
const [fullName, setFullName] = useState(user?.fullName || '');
const [email] = useState(user?.email || '');
const [phone, setPhone] = useState(user?.phone || '');
const [location, setLocation] = useState(user?.location || '');
const [bio, setBio] = useState(user?.bio || '');
const [showEmail, setShowEmail] = useState(user?.showEmail ?? false);
const [showPhone, setShowPhone] = useState(user?.showPhone ?? true);
const [showLocation, setShowLocation] = useState(user?.showLocation ?? true);
const [saving, setSaving] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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>

View File

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

View File

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