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)
372 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|