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,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>
|
||||
|
||||
Reference in New Issue
Block a user