Add comprehensive plant trading marketplace with: - Prisma schema with marketplace models (Listing, Offer, SellerProfile, WishlistItem) - Service layer for listings, offers, search, and matching - API endpoints for CRUD operations, search, and recommendations - Marketplace pages: home, listing detail, create, my-listings, my-offers - Reusable UI components: ListingCard, ListingGrid, OfferForm, SearchFilters, etc. Features: - Browse and search listings by category, price, tags - Create and manage listings (draft, active, sold, cancelled) - Make and manage offers on listings - Seller and buyer views with statistics - Featured and recommended listings - In-memory store (ready for database migration via Agent 2)
365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import Link from 'next/link';
|
||
import Head from 'next/head';
|
||
import { useRouter } from 'next/router';
|
||
|
||
interface Listing {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
price: number;
|
||
currency: string;
|
||
quantity: number;
|
||
category: string;
|
||
status: string;
|
||
sellerId: string;
|
||
sellerName?: string;
|
||
location?: { city?: string; region?: string };
|
||
tags: string[];
|
||
viewCount: number;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
const categoryLabels: Record<string, string> = {
|
||
seeds: 'Seeds',
|
||
seedlings: 'Seedlings',
|
||
mature_plants: 'Mature Plants',
|
||
cuttings: 'Cuttings',
|
||
produce: 'Produce',
|
||
supplies: 'Supplies',
|
||
};
|
||
|
||
const categoryIcons: Record<string, string> = {
|
||
seeds: '🌰',
|
||
seedlings: '🌱',
|
||
mature_plants: '🪴',
|
||
cuttings: '✂️',
|
||
produce: '🥬',
|
||
supplies: '🧰',
|
||
};
|
||
|
||
const statusColors: Record<string, string> = {
|
||
draft: 'bg-gray-100 text-gray-800',
|
||
active: 'bg-green-100 text-green-800',
|
||
sold: 'bg-blue-100 text-blue-800',
|
||
expired: 'bg-yellow-100 text-yellow-800',
|
||
cancelled: 'bg-red-100 text-red-800',
|
||
};
|
||
|
||
export default function ListingDetailPage() {
|
||
const router = useRouter();
|
||
const { id } = router.query;
|
||
|
||
const [listing, setListing] = useState<Listing | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [showOfferModal, setShowOfferModal] = useState(false);
|
||
const [offerAmount, setOfferAmount] = useState('');
|
||
const [offerMessage, setOfferMessage] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (id) {
|
||
fetchListing();
|
||
}
|
||
}, [id]);
|
||
|
||
const fetchListing = async () => {
|
||
try {
|
||
const response = await fetch(`/api/marketplace/listings/${id}`);
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Failed to fetch listing');
|
||
}
|
||
|
||
setListing(data);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Something went wrong');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleMakeOffer = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setSubmitting(true);
|
||
|
||
try {
|
||
const response = await fetch(`/api/marketplace/listings/${id}/offers`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'x-user-id': 'demo-buyer',
|
||
'x-user-name': 'Demo Buyer',
|
||
},
|
||
body: JSON.stringify({
|
||
amount: parseFloat(offerAmount),
|
||
message: offerMessage,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || 'Failed to submit offer');
|
||
}
|
||
|
||
alert('Offer submitted successfully!');
|
||
setShowOfferModal(false);
|
||
setOfferAmount('');
|
||
setOfferMessage('');
|
||
} catch (err) {
|
||
alert(err instanceof Error ? err.message : 'Failed to submit offer');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto"></div>
|
||
<p className="mt-4 text-gray-600">Loading listing...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error || !listing) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Listing Not Found</h1>
|
||
<p className="text-gray-600 mb-6">{error || 'The listing you are looking for does not exist.'}</p>
|
||
<Link href="/marketplace">
|
||
<a className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
|
||
Back to Marketplace
|
||
</a>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||
<Head>
|
||
<title>{listing.title} - LocalGreenChain Marketplace</title>
|
||
</Head>
|
||
|
||
{/* Header */}
|
||
<header className="bg-white shadow-sm">
|
||
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||
<div className="flex items-center justify-between">
|
||
<Link href="/marketplace">
|
||
<a className="text-2xl font-bold text-green-800">LocalGreenChain</a>
|
||
</Link>
|
||
<nav>
|
||
<Link href="/marketplace">
|
||
<a className="text-green-600 hover:text-green-700">Back to Marketplace</a>
|
||
</Link>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Main Content */}
|
||
<main className="max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
{/* Listing Details */}
|
||
<div className="lg:col-span-2">
|
||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||
{/* Image Placeholder */}
|
||
<div className="h-80 bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
|
||
<span className="text-9xl">{categoryIcons[listing.category] || '🌿'}</span>
|
||
</div>
|
||
|
||
{/* Details */}
|
||
<div className="p-8">
|
||
<div className="flex justify-between items-start mb-4">
|
||
<div>
|
||
<span className={`inline-block px-3 py-1 rounded-full text-sm font-medium ${statusColors[listing.status]}`}>
|
||
{listing.status.charAt(0).toUpperCase() + listing.status.slice(1)}
|
||
</span>
|
||
<span className="ml-2 text-gray-500">
|
||
{categoryLabels[listing.category]}
|
||
</span>
|
||
</div>
|
||
<div className="text-sm text-gray-500">
|
||
{listing.viewCount} views
|
||
</div>
|
||
</div>
|
||
|
||
<h1 className="text-3xl font-bold text-gray-900 mb-4">{listing.title}</h1>
|
||
|
||
<div className="prose max-w-none mb-6">
|
||
<p className="text-gray-700 whitespace-pre-line">{listing.description}</p>
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
{listing.tags.length > 0 && (
|
||
<div className="mb-6">
|
||
<h3 className="text-sm font-medium text-gray-500 mb-2">Tags</h3>
|
||
<div className="flex flex-wrap gap-2">
|
||
{listing.tags.map((tag) => (
|
||
<Link key={tag} href={`/marketplace/listings?tags=${tag}`}>
|
||
<a className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm hover:bg-green-100 transition">
|
||
{tag}
|
||
</a>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Additional Info */}
|
||
<div className="border-t pt-6">
|
||
<dl className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<dt className="text-sm text-gray-500">Listed</dt>
|
||
<dd className="text-gray-900">
|
||
{new Date(listing.createdAt).toLocaleDateString()}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-sm text-gray-500">Last Updated</dt>
|
||
<dd className="text-gray-900">
|
||
{new Date(listing.updatedAt).toLocaleDateString()}
|
||
</dd>
|
||
</div>
|
||
{listing.location && (
|
||
<div className="col-span-2">
|
||
<dt className="text-sm text-gray-500">Location</dt>
|
||
<dd className="text-gray-900">
|
||
{[listing.location.city, listing.location.region].filter(Boolean).join(', ')}
|
||
</dd>
|
||
</div>
|
||
)}
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sidebar */}
|
||
<div className="lg:col-span-1">
|
||
{/* Price Card */}
|
||
<div className="bg-white rounded-lg shadow-lg p-6 sticky top-6">
|
||
<div className="text-4xl font-bold text-green-600 mb-2">
|
||
${listing.price.toFixed(2)}
|
||
<span className="text-lg font-normal text-gray-500"> {listing.currency}</span>
|
||
</div>
|
||
|
||
<div className="text-gray-600 mb-6">
|
||
{listing.quantity} available
|
||
</div>
|
||
|
||
{listing.status === 'active' && (
|
||
<>
|
||
<button
|
||
onClick={() => setShowOfferModal(true)}
|
||
className="w-full py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition mb-3"
|
||
>
|
||
Make an Offer
|
||
</button>
|
||
<button className="w-full py-3 border border-green-600 text-green-600 font-semibold rounded-lg hover:bg-green-50 transition">
|
||
Add to Wishlist
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{listing.status === 'sold' && (
|
||
<div className="text-center py-3 bg-gray-100 rounded-lg text-gray-600">
|
||
This item has been sold
|
||
</div>
|
||
)}
|
||
|
||
{/* Seller Info */}
|
||
<div className="mt-6 pt-6 border-t">
|
||
<h3 className="text-sm font-medium text-gray-500 mb-2">Seller</h3>
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||
<span className="text-2xl">🧑🌾</span>
|
||
</div>
|
||
<div>
|
||
<div className="font-semibold text-gray-900">
|
||
{listing.sellerName || 'Anonymous Seller'}
|
||
</div>
|
||
<div className="text-sm text-gray-500">
|
||
Member
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Offer Modal */}
|
||
{showOfferModal && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
||
<h2 className="text-2xl font-bold text-gray-900 mb-4">Make an Offer</h2>
|
||
<form onSubmit={handleMakeOffer}>
|
||
<div className="mb-4">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Your Offer Amount (USD)
|
||
</label>
|
||
<div className="relative">
|
||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||
<input
|
||
type="number"
|
||
value={offerAmount}
|
||
onChange={(e) => setOfferAmount(e.target.value)}
|
||
required
|
||
min="0.01"
|
||
step="0.01"
|
||
placeholder={listing.price.toFixed(2)}
|
||
className="w-full pl-8 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
<p className="mt-1 text-sm text-gray-500">
|
||
Asking price: ${listing.price.toFixed(2)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mb-6">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Message (optional)
|
||
</label>
|
||
<textarea
|
||
value={offerMessage}
|
||
onChange={(e) => setOfferMessage(e.target.value)}
|
||
rows={3}
|
||
placeholder="Add a message to the seller..."
|
||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowOfferModal(false)}
|
||
className="flex-1 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={submitting}
|
||
className="flex-1 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50"
|
||
>
|
||
{submitting ? 'Submitting...' : 'Submit Offer'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|