localgreenchain/pages/marketplace/listings/[id].tsx
Claude b3c2af51bf
Implement marketplace foundation (Agent 9)
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)
2025-11-23 03:58:08 +00:00

365 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}