From b3c2af51bfaadd31749b3b2ca4269348a40a82de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Nov 2025 03:58:08 +0000 Subject: [PATCH] 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) --- components/marketplace/ListingCard.tsx | 149 +++++++ components/marketplace/ListingForm.tsx | 290 ++++++++++++++ components/marketplace/ListingGrid.tsx | 64 +++ components/marketplace/OfferForm.tsx | 144 +++++++ components/marketplace/OfferList.tsx | 163 ++++++++ components/marketplace/PriceDisplay.tsx | 96 +++++ components/marketplace/SearchFilters.tsx | 219 +++++++++++ components/marketplace/index.ts | 8 + lib/marketplace/index.ts | 15 + lib/marketplace/listingService.ts | 233 +++++++++++ lib/marketplace/matchingService.ts | 230 +++++++++++ lib/marketplace/offerService.ts | 291 ++++++++++++++ lib/marketplace/searchService.ts | 285 ++++++++++++++ lib/marketplace/store.ts | 273 +++++++++++++ lib/marketplace/types.ts | 167 ++++++++ pages/api/marketplace/featured.ts | 56 +++ pages/api/marketplace/listings/[id]/index.ts | 116 ++++++ pages/api/marketplace/listings/[id]/offers.ts | 95 +++++ pages/api/marketplace/listings/index.ts | 122 ++++++ pages/api/marketplace/my-listings.ts | 51 +++ pages/api/marketplace/my-offers.ts | 71 ++++ pages/api/marketplace/offers/[id].ts | 117 ++++++ pages/api/marketplace/recommendations.ts | 89 +++++ pages/api/marketplace/search.ts | 102 +++++ pages/marketplace/create.tsx | 282 +++++++++++++ pages/marketplace/index.tsx | 305 ++++++++++++++ pages/marketplace/listings/[id].tsx | 365 +++++++++++++++++ pages/marketplace/listings/index.tsx | 372 ++++++++++++++++++ pages/marketplace/my-listings.tsx | 319 +++++++++++++++ pages/marketplace/my-offers.tsx | 305 ++++++++++++++ prisma/schema.prisma | 284 +++++++++++++ 31 files changed, 5678 insertions(+) create mode 100644 components/marketplace/ListingCard.tsx create mode 100644 components/marketplace/ListingForm.tsx create mode 100644 components/marketplace/ListingGrid.tsx create mode 100644 components/marketplace/OfferForm.tsx create mode 100644 components/marketplace/OfferList.tsx create mode 100644 components/marketplace/PriceDisplay.tsx create mode 100644 components/marketplace/SearchFilters.tsx create mode 100644 components/marketplace/index.ts create mode 100644 lib/marketplace/index.ts create mode 100644 lib/marketplace/listingService.ts create mode 100644 lib/marketplace/matchingService.ts create mode 100644 lib/marketplace/offerService.ts create mode 100644 lib/marketplace/searchService.ts create mode 100644 lib/marketplace/store.ts create mode 100644 lib/marketplace/types.ts create mode 100644 pages/api/marketplace/featured.ts create mode 100644 pages/api/marketplace/listings/[id]/index.ts create mode 100644 pages/api/marketplace/listings/[id]/offers.ts create mode 100644 pages/api/marketplace/listings/index.ts create mode 100644 pages/api/marketplace/my-listings.ts create mode 100644 pages/api/marketplace/my-offers.ts create mode 100644 pages/api/marketplace/offers/[id].ts create mode 100644 pages/api/marketplace/recommendations.ts create mode 100644 pages/api/marketplace/search.ts create mode 100644 pages/marketplace/create.tsx create mode 100644 pages/marketplace/index.tsx create mode 100644 pages/marketplace/listings/[id].tsx create mode 100644 pages/marketplace/listings/index.tsx create mode 100644 pages/marketplace/my-listings.tsx create mode 100644 pages/marketplace/my-offers.tsx create mode 100644 prisma/schema.prisma diff --git a/components/marketplace/ListingCard.tsx b/components/marketplace/ListingCard.tsx new file mode 100644 index 0000000..48bad87 --- /dev/null +++ b/components/marketplace/ListingCard.tsx @@ -0,0 +1,149 @@ +import Link from 'next/link'; + +interface Listing { + id: string; + title: string; + description: string; + price: number; + currency: string; + quantity: number; + category: string; + sellerName?: string; + location?: { city?: string; region?: string }; + tags: string[]; + viewCount: number; +} + +const categoryLabels: Record = { + seeds: 'Seeds', + seedlings: 'Seedlings', + mature_plants: 'Mature Plants', + cuttings: 'Cuttings', + produce: 'Produce', + supplies: 'Supplies', +}; + +const categoryIcons: Record = { + seeds: '🌰', + seedlings: '🌱', + mature_plants: '🪴', + cuttings: '✂️', + produce: '🥬', + supplies: '🧰', +}; + +interface ListingCardProps { + listing: Listing; + variant?: 'default' | 'compact' | 'featured'; +} + +export function ListingCard({ listing, variant = 'default' }: ListingCardProps) { + if (variant === 'compact') { + return ( + + +
+ {categoryIcons[listing.category] || '🌿'} +
+
+

{listing.title}

+

{categoryLabels[listing.category]}

+
+
+
${listing.price.toFixed(2)}
+
{listing.quantity} avail.
+
+
+ + ); + } + + if (variant === 'featured') { + return ( + + +
+
+ {categoryIcons[listing.category] || '🌿'} +
+
+ + Featured + +
+
+
+
+

+ {listing.title} +

+ + ${listing.price.toFixed(2)} + +
+

+ {listing.description} +

+
+
+ {categoryLabels[listing.category]} + + {listing.quantity} available +
+ + {listing.viewCount} views + +
+
+
+ + ); + } + + // Default variant + return ( + + +
+ {categoryIcons[listing.category] || '🌿'} +
+
+
+

+ {listing.title} +

+ + ${listing.price.toFixed(2)} + +
+

+ {listing.description} +

+
+ {categoryLabels[listing.category]} + {listing.quantity} available +
+ {listing.sellerName && ( +
+ by {listing.sellerName} +
+ )} + {listing.tags.length > 0 && ( +
+ {listing.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ + ); +} + +export default ListingCard; diff --git a/components/marketplace/ListingForm.tsx b/components/marketplace/ListingForm.tsx new file mode 100644 index 0000000..64c026e --- /dev/null +++ b/components/marketplace/ListingForm.tsx @@ -0,0 +1,290 @@ +import { useState } from 'react'; + +interface ListingFormData { + title: string; + description: string; + price: string; + quantity: string; + category: string; + tags: string; + city: string; + region: string; +} + +interface ListingFormProps { + initialData?: Partial; + onSubmit: (data: ListingFormData) => Promise; + submitLabel?: string; + isLoading?: boolean; +} + +const categories = [ + { value: 'seeds', label: 'Seeds', icon: '🌰', description: 'Plant seeds for growing' }, + { value: 'seedlings', label: 'Seedlings', icon: '🌱', description: 'Young plants ready for transplanting' }, + { value: 'mature_plants', label: 'Mature Plants', icon: '🪴', description: 'Fully grown plants' }, + { value: 'cuttings', label: 'Cuttings', icon: '✂️', description: 'Plant cuttings for propagation' }, + { value: 'produce', label: 'Produce', icon: '🥬', description: 'Fresh fruits and vegetables' }, + { value: 'supplies', label: 'Supplies', icon: '🧰', description: 'Gardening tools and supplies' }, +]; + +export function ListingForm({ + initialData = {}, + onSubmit, + submitLabel = 'Create Listing', + isLoading = false, +}: ListingFormProps) { + const [formData, setFormData] = useState({ + title: initialData.title || '', + description: initialData.description || '', + price: initialData.price || '', + quantity: initialData.quantity || '1', + category: initialData.category || '', + tags: initialData.tags || '', + city: initialData.city || '', + region: initialData.region || '', + }); + + const [errors, setErrors] = useState>>({}); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + + // Clear error when field is edited + if (errors[name as keyof ListingFormData]) { + setErrors((prev) => ({ ...prev, [name]: undefined })); + } + }; + + const validate = (): boolean => { + const newErrors: Partial> = {}; + + if (!formData.title.trim()) { + newErrors.title = 'Title is required'; + } else if (formData.title.length < 10) { + newErrors.title = 'Title must be at least 10 characters'; + } + + if (!formData.description.trim()) { + newErrors.description = 'Description is required'; + } else if (formData.description.length < 20) { + newErrors.description = 'Description must be at least 20 characters'; + } + + if (!formData.price) { + newErrors.price = 'Price is required'; + } else if (parseFloat(formData.price) <= 0) { + newErrors.price = 'Price must be greater than 0'; + } + + if (!formData.quantity) { + newErrors.quantity = 'Quantity is required'; + } else if (parseInt(formData.quantity, 10) < 1) { + newErrors.quantity = 'Quantity must be at least 1'; + } + + if (!formData.category) { + newErrors.category = 'Category is required'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validate()) { + return; + } + + await onSubmit(formData); + }; + + return ( +
+ {/* Title */} +
+ + + {errors.title &&

{errors.title}

} +
+ + {/* Description */} +
+ +