localgreenchain/pages/marketplace/listings/index.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

372 lines
14 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;
sellerName?: string;
location?: { city?: string; region?: string };
tags: string[];
viewCount: number;
createdAt: string;
}
interface SearchResult {
listings: Listing[];
total: number;
page: number;
limit: number;
hasMore: boolean;
}
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 sortOptions = [
{ value: 'date_desc', label: 'Newest First' },
{ value: 'date_asc', label: 'Oldest First' },
{ value: 'price_asc', label: 'Price: Low to High' },
{ value: 'price_desc', label: 'Price: High to Low' },
{ value: 'relevance', label: 'Most Popular' },
];
export default function ListingsSearchPage() {
const router = useRouter();
const { q, category, tags, sort, minPrice, maxPrice, page: pageParam } = router.query;
const [results, setResults] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState((q as string) || '');
const [selectedCategory, setSelectedCategory] = useState((category as string) || '');
const [selectedSort, setSelectedSort] = useState((sort as string) || 'date_desc');
const [priceMin, setPriceMin] = useState((minPrice as string) || '');
const [priceMax, setPriceMax] = useState((maxPrice as string) || '');
useEffect(() => {
if (router.isReady) {
fetchListings();
}
}, [router.isReady, router.query]);
const fetchListings = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (q) params.set('query', q as string);
if (category) params.set('category', category as string);
if (tags) params.set('tags', tags as string);
if (sort) params.set('sortBy', sort as string);
if (minPrice) params.set('minPrice', minPrice as string);
if (maxPrice) params.set('maxPrice', maxPrice as string);
if (pageParam) params.set('page', pageParam as string);
const response = await fetch(`/api/marketplace/listings?${params.toString()}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch listings');
}
setResults(data);
} catch (error) {
console.error('Error fetching listings:', error);
} finally {
setLoading(false);
}
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
updateFilters({ q: searchQuery || undefined });
};
const updateFilters = (updates: Record<string, string | undefined>) => {
const currentQuery = { ...router.query, ...updates };
// Remove undefined/empty values
Object.keys(currentQuery).forEach(key => {
if (!currentQuery[key]) delete currentQuery[key];
});
router.push({
pathname: '/marketplace/listings',
query: currentQuery,
});
};
const handleApplyFilters = () => {
updateFilters({
category: selectedCategory || undefined,
sortBy: selectedSort || undefined,
minPrice: priceMin || undefined,
maxPrice: priceMax || undefined,
});
};
const clearFilters = () => {
setSearchQuery('');
setSelectedCategory('');
setSelectedSort('date_desc');
setPriceMin('');
setPriceMax('');
router.push('/marketplace/listings');
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
<Head>
<title>Browse Listings - 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 className="flex gap-4">
<Link href="/marketplace/my-listings">
<a className="text-green-600 hover:text-green-700">My Listings</a>
</Link>
<Link href="/marketplace/create">
<a className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
Sell Now
</a>
</Link>
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Sidebar Filters */}
<aside className="lg:w-64 flex-shrink-0">
<div className="bg-white rounded-lg shadow p-6 sticky top-6">
<h2 className="text-lg font-bold text-gray-900 mb-4">Filters</h2>
{/* Search */}
<form onSubmit={handleSearch} className="mb-6">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search listings..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</form>
{/* Category */}
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-2">Category</h3>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option value="">All Categories</option>
{Object.entries(categoryLabels).map(([value, label]) => (
<option key={value} value={value}>
{categoryIcons[value]} {label}
</option>
))}
</select>
</div>
{/* Price Range */}
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-2">Price Range</h3>
<div className="flex gap-2">
<input
type="number"
value={priceMin}
onChange={(e) => setPriceMin(e.target.value)}
placeholder="Min"
min="0"
className="w-1/2 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
<input
type="number"
value={priceMax}
onChange={(e) => setPriceMax(e.target.value)}
placeholder="Max"
min="0"
className="w-1/2 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
{/* Sort */}
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-2">Sort By</h3>
<select
value={selectedSort}
onChange={(e) => setSelectedSort(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Apply / Clear */}
<div className="space-y-2">
<button
onClick={handleApplyFilters}
className="w-full py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Apply Filters
</button>
<button
onClick={clearFilters}
className="w-full py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
>
Clear All
</button>
</div>
</div>
</aside>
{/* Results */}
<div className="flex-1">
{/* Active Filters */}
{(q || category || tags) && (
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex flex-wrap gap-2 items-center">
<span className="text-gray-600">Active filters:</span>
{q && (
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
Search: "{q}"
</span>
)}
{category && (
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
{categoryLabels[category as string]}
</span>
)}
{tags && (
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
Tag: {tags}
</span>
)}
</div>
</div>
)}
{/* Results Header */}
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">
{q ? `Results for "${q}"` : category ? categoryLabels[category as string] : 'All Listings'}
{results && <span className="text-gray-500 text-lg ml-2">({results.total} found)</span>}
</h1>
</div>
{/* Listings Grid */}
{loading ? (
<div className="text-center py-12">
<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 listings...</p>
</div>
) : !results || results.listings.length === 0 ? (
<div className="bg-white rounded-lg shadow-lg p-12 text-center">
<div className="text-6xl mb-4">🔍</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">No Listings Found</h2>
<p className="text-gray-600 mb-6">
Try adjusting your search or filters to find what you're looking for.
</p>
<button
onClick={clearFilters}
className="px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition"
>
Clear Filters
</button>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{results.listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
{/* Pagination */}
{results.hasMore && (
<div className="mt-8 text-center">
<button
onClick={() => updateFilters({ page: String((results.page || 1) + 1) })}
className="px-8 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
>
Load More
</button>
</div>
)}
</>
)}
</div>
</div>
</main>
</div>
);
}
function ListingCard({ listing }: { listing: Listing }) {
return (
<Link href={`/marketplace/listings/${listing.id}`}>
<a className="block bg-white rounded-lg shadow hover:shadow-lg transition overflow-hidden border border-gray-200">
<div className="h-40 bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
<span className="text-5xl">{categoryIcons[listing.category] || '🌿'}</span>
</div>
<div className="p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">
{listing.title}
</h3>
<span className="text-lg font-bold text-green-600">
${listing.price.toFixed(2)}
</span>
</div>
<p className="text-gray-600 text-sm line-clamp-2 mb-3">
{listing.description}
</p>
<div className="flex justify-between items-center text-sm text-gray-500">
<span>{categoryLabels[listing.category]}</span>
<span>{listing.quantity} available</span>
</div>
{listing.location && (
<div className="mt-2 text-sm text-gray-500">
📍 {[listing.location.city, listing.location.region].filter(Boolean).join(', ')}
</div>
)}
</div>
</a>
</Link>
);
}