Full-stack marketplace for buying/selling second-hand items. React 19 + TypeScript + Tailwind CSS v4 frontend with 17 screens, Express + Prisma + Socket.io backend, Stripe payments, JWT auth. Deployed at https://marketplace.173.212.212.157.sslip.io/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
155 lines
8.1 KiB
TypeScript
155 lines
8.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag } from 'lucide-react';
|
|
import { Card } from '../components/ui/Card';
|
|
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 { Modal } from '../components/ui/Modal';
|
|
import { Input } from '../components/ui/Input';
|
|
import { mockListings } from '../utils/mockData';
|
|
import { formatCurrency, formatDate } from '../utils/format';
|
|
export function ProductDetailPage() {
|
|
const { id } = useParams();
|
|
const listing = mockListings.find(l => l.id === id) || mockListings[0]!;
|
|
const [isFav, setIsFav] = useState(listing.isFavorited ?? false);
|
|
const [showOffer, setShowOffer] = useState(false);
|
|
const [showEdit, setShowEdit] = useState(false);
|
|
const [offerAmount, setOfferAmount] = useState('');
|
|
const [offerMessage, setOfferMessage] = useState('');
|
|
|
|
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' : '\uD83D\uDCE6';
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
|
<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>
|
|
<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>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<div className="space-y-6">
|
|
<div>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">{listing.title}</h1>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<Badge variant={conditionVariant} size="md">{listing.condition.replace('_', ' ')}</Badge>
|
|
<span className="flex items-center gap-1 text-sm text-gray-400">
|
|
<Eye className="w-4 h-4" /> {listing.viewCount} views
|
|
</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>
|
|
<p className="text-3xl font-bold text-primary-600 mt-4">
|
|
{formatCurrency(listing.price)}
|
|
{listing.obo && <span className="text-sm font-normal text-gray-400 ml-2">or best offer</span>}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<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>
|
|
|
|
{/* 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>
|
|
<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">
|
|
<MapPin className="w-4 h-4 text-gray-400" /> {listing.location}
|
|
</p>
|
|
</Card>
|
|
|
|
<div className="flex gap-4 text-sm text-gray-400">
|
|
<button className="flex items-center gap-1 hover:text-gray-600 cursor-pointer"><Share2 className="w-4 h-4" /> Share</button>
|
|
<button className="flex items-center gap-1 hover:text-gray-600 cursor-pointer"><Flag className="w-4 h-4" /> Report</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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>
|
|
<div className="space-y-4 mb-6">
|
|
<Input label="Your Offer" type="number" placeholder="100" value={offerAmount} onChange={(e) => setOfferAmount(e.target.value)} />
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1.5">Message</label>
|
|
<textarea value={offerMessage} onChange={(e) => setOfferMessage(e.target.value)} rows={3}
|
|
placeholder="Enter your message to the seller"
|
|
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>
|
|
</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>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Edit Item Modal */}
|
|
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Item Info" size="md">
|
|
<div className="space-y-4 mb-6">
|
|
<Input label="Title" defaultValue={listing.title} />
|
|
<Input label="Price" type="number" defaultValue={String(listing.price)} />
|
|
<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">
|
|
<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" />
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<Button variant="danger" className="mr-auto">Delete Listing</Button>
|
|
<Button variant="secondary" onClick={() => setShowEdit(false)}>Cancel</Button>
|
|
<GradientButton onClick={() => setShowEdit(false)}>Save Changes</GradientButton>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|