Merge: Marketplace foundation (Agent 9) - kept comprehensive DB schema

This commit is contained in:
Vinnie Esposito 2025-11-23 11:00:57 -06:00
commit dae86c93ad
30 changed files with 5394 additions and 0 deletions

View file

@ -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<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: '🧰',
};
interface ListingCardProps {
listing: Listing;
variant?: 'default' | 'compact' | 'featured';
}
export function ListingCard({ listing, variant = 'default' }: ListingCardProps) {
if (variant === 'compact') {
return (
<Link href={`/marketplace/listings/${listing.id}`}>
<a className="flex items-center gap-4 p-4 bg-white rounded-lg shadow hover:shadow-md transition border border-gray-200">
<div className="w-16 h-16 bg-gradient-to-br from-green-100 to-emerald-100 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-2xl">{categoryIcons[listing.category] || '🌿'}</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{listing.title}</h3>
<p className="text-sm text-gray-500">{categoryLabels[listing.category]}</p>
</div>
<div className="text-right">
<div className="font-bold text-green-600">${listing.price.toFixed(2)}</div>
<div className="text-xs text-gray-500">{listing.quantity} avail.</div>
</div>
</a>
</Link>
);
}
if (variant === 'featured') {
return (
<Link href={`/marketplace/listings/${listing.id}`}>
<a className="block bg-white rounded-xl shadow-lg hover:shadow-xl transition overflow-hidden border-2 border-green-200">
<div className="relative">
<div className="h-56 bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
<span className="text-7xl">{categoryIcons[listing.category] || '🌿'}</span>
</div>
<div className="absolute top-4 left-4">
<span className="px-3 py-1 bg-green-600 text-white text-sm font-medium rounded-full">
Featured
</span>
</div>
</div>
<div className="p-6">
<div className="flex justify-between items-start mb-3">
<h3 className="text-xl font-bold text-gray-900 line-clamp-1">
{listing.title}
</h3>
<span className="text-2xl font-bold text-green-600">
${listing.price.toFixed(2)}
</span>
</div>
<p className="text-gray-600 line-clamp-2 mb-4">
{listing.description}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{categoryLabels[listing.category]}</span>
<span></span>
<span>{listing.quantity} available</span>
</div>
<span className="text-sm text-gray-400">
{listing.viewCount} views
</span>
</div>
</div>
</a>
</Link>
);
}
// Default variant
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-48 bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
<span className="text-6xl">{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.sellerName && (
<div className="mt-2 text-sm text-gray-500">
by {listing.sellerName}
</div>
)}
{listing.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{listing.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</div>
</a>
</Link>
);
}
export default ListingCard;

View file

@ -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<ListingFormData>;
onSubmit: (data: ListingFormData) => Promise<void>;
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<ListingFormData>({
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<Partial<Record<keyof ListingFormData, string>>>({});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
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<Record<keyof ListingFormData, string>> = {};
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 (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Title *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="e.g., Organic Tomato Seedlings - Cherokee Purple"
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
errors.title ? 'border-red-300' : 'border-gray-300'
}`}
/>
{errors.title && <p className="mt-1 text-sm text-red-600">{errors.title}</p>}
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows={5}
placeholder="Describe your item in detail. Include information about variety, growing conditions, care instructions, etc."
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
errors.description ? 'border-red-300' : 'border-gray-300'
}`}
/>
{errors.description && <p className="mt-1 text-sm text-red-600">{errors.description}</p>}
<p className="mt-1 text-sm text-gray-500">
{formData.description.length}/500 characters
</p>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Category *
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{categories.map((cat) => (
<button
key={cat.value}
type="button"
onClick={() => setFormData((prev) => ({ ...prev, category: cat.value }))}
className={`p-4 rounded-lg border-2 text-left transition ${
formData.category === cat.value
? 'border-green-500 bg-green-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-2xl mb-1">{cat.icon}</div>
<div className="font-medium text-gray-900">{cat.label}</div>
<div className="text-xs text-gray-500">{cat.description}</div>
</button>
))}
</div>
{errors.category && <p className="mt-2 text-sm text-red-600">{errors.category}</p>}
</div>
{/* Price and Quantity */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
Price (USD) *
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
id="price"
name="price"
value={formData.price}
onChange={handleChange}
min="0.01"
step="0.01"
placeholder="0.00"
className={`w-full pl-8 pr-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
errors.price ? 'border-red-300' : 'border-gray-300'
}`}
/>
</div>
{errors.price && <p className="mt-1 text-sm text-red-600">{errors.price}</p>}
</div>
<div>
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
Quantity *
</label>
<input
type="number"
id="quantity"
name="quantity"
value={formData.quantity}
onChange={handleChange}
min="1"
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
errors.quantity ? 'border-red-300' : 'border-gray-300'
}`}
/>
{errors.quantity && <p className="mt-1 text-sm text-red-600">{errors.quantity}</p>}
</div>
</div>
{/* Location */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
City (optional)
</label>
<input
type="text"
id="city"
name="city"
value={formData.city}
onChange={handleChange}
placeholder="e.g., Portland"
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>
<label htmlFor="region" className="block text-sm font-medium text-gray-700 mb-1">
State/Region (optional)
</label>
<input
type="text"
id="region"
name="region"
value={formData.region}
onChange={handleChange}
placeholder="e.g., OR"
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>
{/* Tags */}
<div>
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-1">
Tags (optional)
</label>
<input
type="text"
id="tags"
name="tags"
value={formData.tags}
onChange={handleChange}
placeholder="organic, heirloom, non-gmo (comma separated)"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
<p className="mt-1 text-sm text-gray-500">
Add tags to help buyers find your listing
</p>
</div>
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={isLoading}
className="w-full py-4 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<span className="animate-spin"></span>
Processing...
</span>
) : (
submitLabel
)}
</button>
</div>
</form>
);
}
export default ListingForm;

View file

@ -0,0 +1,64 @@
import { ListingCard } from './ListingCard';
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;
}
interface ListingGridProps {
listings: Listing[];
columns?: 2 | 3 | 4;
variant?: 'default' | 'compact' | 'featured';
emptyMessage?: string;
}
export function ListingGrid({
listings,
columns = 3,
variant = 'default',
emptyMessage = 'No listings found',
}: ListingGridProps) {
if (listings.length === 0) {
return (
<div className="text-center py-12 bg-white rounded-lg shadow">
<div className="text-4xl mb-4">🌿</div>
<p className="text-gray-500">{emptyMessage}</p>
</div>
);
}
const gridCols = {
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
if (variant === 'compact') {
return (
<div className="space-y-3">
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} variant="compact" />
))}
</div>
);
}
return (
<div className={`grid ${gridCols[columns]} gap-6`}>
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} variant={variant} />
))}
</div>
);
}
export default ListingGrid;

View file

@ -0,0 +1,144 @@
import { useState } from 'react';
interface OfferFormProps {
listingId: string;
askingPrice: number;
currency?: string;
onSubmit: (amount: number, message: string) => Promise<void>;
onCancel?: () => void;
}
export function OfferForm({
listingId,
askingPrice,
currency = 'USD',
onSubmit,
onCancel,
}: OfferFormProps) {
const [amount, setAmount] = useState(askingPrice.toString());
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const offerAmount = parseFloat(amount);
if (isNaN(offerAmount) || offerAmount <= 0) {
setError('Please enter a valid offer amount');
return;
}
setSubmitting(true);
try {
await onSubmit(offerAmount, message);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit offer');
} finally {
setSubmitting(false);
}
};
const suggestedOffers = [
{ label: 'Full Price', value: askingPrice },
{ label: '10% Off', value: Math.round(askingPrice * 0.9 * 100) / 100 },
{ label: '15% Off', value: Math.round(askingPrice * 0.85 * 100) / 100 },
];
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{/* Suggested Offers */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Quick Select
</label>
<div className="flex gap-2">
{suggestedOffers.map((suggestion) => (
<button
key={suggestion.label}
type="button"
onClick={() => setAmount(suggestion.value.toString())}
className={`px-3 py-2 rounded-lg text-sm transition ${
parseFloat(amount) === suggestion.value
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{suggestion.label}
<br />
<span className="font-semibold">${suggestion.value.toFixed(2)}</span>
</button>
))}
</div>
</div>
{/* Custom Amount */}
<div>
<label htmlFor="offerAmount" className="block text-sm font-medium text-gray-700 mb-1">
Your Offer ({currency})
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
id="offerAmount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
min="0.01"
step="0.01"
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: ${askingPrice.toFixed(2)}
</p>
</div>
{/* Message */}
<div>
<label htmlFor="offerMessage" className="block text-sm font-medium text-gray-700 mb-1">
Message to Seller (optional)
</label>
<textarea
id="offerMessage"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
placeholder="Introduce yourself or ask a question..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
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>
);
}
export default OfferForm;

View file

@ -0,0 +1,163 @@
interface Offer {
id: string;
buyerId: string;
buyerName?: string;
amount: number;
message?: string;
status: string;
createdAt: string;
}
interface OfferListProps {
offers: Offer[];
isSellerView?: boolean;
onAccept?: (offerId: string) => void;
onReject?: (offerId: string) => void;
onWithdraw?: (offerId: string) => void;
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
withdrawn: 'bg-gray-100 text-gray-800',
expired: 'bg-gray-100 text-gray-800',
};
const statusLabels: Record<string, string> = {
pending: 'Pending',
accepted: 'Accepted',
rejected: 'Rejected',
withdrawn: 'Withdrawn',
expired: 'Expired',
};
export function OfferList({
offers,
isSellerView = false,
onAccept,
onReject,
onWithdraw,
}: OfferListProps) {
if (offers.length === 0) {
return (
<div className="text-center py-8 bg-gray-50 rounded-lg">
<div className="text-3xl mb-2">📭</div>
<p className="text-gray-500">No offers yet</p>
</div>
);
}
return (
<div className="space-y-4">
{offers.map((offer) => (
<OfferItem
key={offer.id}
offer={offer}
isSellerView={isSellerView}
onAccept={onAccept}
onReject={onReject}
onWithdraw={onWithdraw}
/>
))}
</div>
);
}
interface OfferItemProps {
offer: Offer;
isSellerView: boolean;
onAccept?: (offerId: string) => void;
onReject?: (offerId: string) => void;
onWithdraw?: (offerId: string) => void;
}
function OfferItem({
offer,
isSellerView,
onAccept,
onReject,
onWithdraw,
}: OfferItemProps) {
const isPending = offer.status === 'pending';
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-start">
<div>
{isSellerView && (
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<span>👤</span>
</div>
<span className="font-medium text-gray-900">
{offer.buyerName || 'Anonymous'}
</span>
</div>
)}
<div className="text-2xl font-bold text-green-600">
${offer.amount.toFixed(2)}
</div>
<div className="text-sm text-gray-500 mt-1">
{new Date(offer.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})}
</div>
</div>
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${
statusColors[offer.status]
}`}
>
{statusLabels[offer.status]}
</span>
</div>
{offer.message && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-gray-700 text-sm">
"{offer.message}"
</div>
)}
{isPending && (
<div className="flex gap-2 mt-4 pt-4 border-t">
{isSellerView ? (
<>
{onAccept && (
<button
onClick={() => onAccept(offer.id)}
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition font-medium"
>
Accept
</button>
)}
{onReject && (
<button
onClick={() => onReject(offer.id)}
className="flex-1 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition font-medium"
>
Reject
</button>
)}
</>
) : (
onWithdraw && (
<button
onClick={() => onWithdraw(offer.id)}
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition"
>
Withdraw Offer
</button>
)
)}
</div>
)}
</div>
);
}
export default OfferList;

View file

@ -0,0 +1,96 @@
interface PriceDisplayProps {
price: number;
currency?: string;
originalPrice?: number;
size?: 'sm' | 'md' | 'lg' | 'xl';
showCurrency?: boolean;
}
const sizeClasses = {
sm: 'text-sm',
md: 'text-lg',
lg: 'text-2xl',
xl: 'text-4xl',
};
export function PriceDisplay({
price,
currency = 'USD',
originalPrice,
size = 'md',
showCurrency = false,
}: PriceDisplayProps) {
const hasDiscount = originalPrice && originalPrice > price;
const discountPercentage = hasDiscount
? Math.round((1 - price / originalPrice) * 100)
: 0;
const formatPrice = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
return (
<div className="flex items-baseline gap-2">
<span className={`font-bold text-green-600 ${sizeClasses[size]}`}>
{formatPrice(price)}
</span>
{showCurrency && (
<span className="text-gray-500 text-sm">{currency}</span>
)}
{hasDiscount && (
<>
<span className="text-gray-400 line-through text-sm">
{formatPrice(originalPrice)}
</span>
<span className="px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded-full font-medium">
{discountPercentage}% OFF
</span>
</>
)}
</div>
);
}
interface PriceRangeDisplayProps {
minPrice: number;
maxPrice: number;
currency?: string;
}
export function PriceRangeDisplay({
minPrice,
maxPrice,
currency = 'USD',
}: PriceRangeDisplayProps) {
const formatPrice = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
if (minPrice === maxPrice) {
return (
<span className="font-semibold text-green-600">
{formatPrice(minPrice)}
</span>
);
}
return (
<span className="font-semibold text-green-600">
{formatPrice(minPrice)} - {formatPrice(maxPrice)}
</span>
);
}
export default PriceDisplay;

View file

@ -0,0 +1,219 @@
import { useState } from 'react';
interface SearchFiltersProps {
initialValues?: {
query?: string;
category?: string;
minPrice?: string;
maxPrice?: string;
sortBy?: string;
};
onApply: (filters: {
query?: string;
category?: string;
minPrice?: string;
maxPrice?: string;
sortBy?: string;
}) => void;
onClear: () => void;
}
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 function SearchFilters({
initialValues = {},
onApply,
onClear,
}: SearchFiltersProps) {
const [query, setQuery] = useState(initialValues.query || '');
const [category, setCategory] = useState(initialValues.category || '');
const [minPrice, setMinPrice] = useState(initialValues.minPrice || '');
const [maxPrice, setMaxPrice] = useState(initialValues.maxPrice || '');
const [sortBy, setSortBy] = useState(initialValues.sortBy || 'date_desc');
const [isExpanded, setIsExpanded] = useState(false);
const handleApply = () => {
onApply({
query: query || undefined,
category: category || undefined,
minPrice: minPrice || undefined,
maxPrice: maxPrice || undefined,
sortBy: sortBy || undefined,
});
};
const handleClear = () => {
setQuery('');
setCategory('');
setMinPrice('');
setMaxPrice('');
setSortBy('date_desc');
onClear();
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
handleApply();
};
return (
<div className="bg-white rounded-lg shadow p-4">
{/* Search Bar */}
<form onSubmit={handleSearch} className="mb-4">
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search listings..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
<button
type="submit"
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Search
</button>
</div>
</form>
{/* Toggle Filters */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-800 mb-2"
>
<span>{isExpanded ? '▼' : '▶'}</span>
<span>Advanced Filters</span>
</button>
{/* Expanded Filters */}
{isExpanded && (
<div className="space-y-4 pt-4 border-t">
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Category
</label>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setCategory('')}
className={`px-3 py-1 rounded-full text-sm transition ${
!category
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
All
</button>
{Object.entries(categoryLabels).map(([value, label]) => (
<button
key={value}
onClick={() => setCategory(value)}
className={`px-3 py-1 rounded-full text-sm transition ${
category === value
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{categoryIcons[value]} {label}
</button>
))}
</div>
</div>
{/* Price Range */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Price Range
</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
<input
type="number"
value={minPrice}
onChange={(e) => setMinPrice(e.target.value)}
placeholder="Min"
min="0"
className="w-full pl-7 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
<span className="text-gray-400">-</span>
<div className="relative flex-1">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
<input
type="number"
value={maxPrice}
onChange={(e) => setMaxPrice(e.target.value)}
placeholder="Max"
min="0"
className="w-full pl-7 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
</div>
</div>
{/* Sort */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort By
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(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>
{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<button
onClick={handleClear}
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
>
Clear All
</button>
<button
onClick={handleApply}
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Apply Filters
</button>
</div>
</div>
)}
</div>
);
}
export default SearchFilters;

View file

@ -0,0 +1,8 @@
// Marketplace Components Index
export { ListingCard } from './ListingCard';
export { ListingGrid } from './ListingGrid';
export { ListingForm } from './ListingForm';
export { OfferForm } from './OfferForm';
export { OfferList } from './OfferList';
export { SearchFilters } from './SearchFilters';
export { PriceDisplay, PriceRangeDisplay } from './PriceDisplay';

15
lib/marketplace/index.ts Normal file
View file

@ -0,0 +1,15 @@
// Marketplace Module Index
// Re-exports all marketplace services and types
export * from './types';
export { listingService } from './listingService';
export { offerService } from './offerService';
export { searchService } from './searchService';
export { matchingService } from './matchingService';
export {
listingStore,
offerStore,
sellerProfileStore,
wishlistStore,
generateId,
} from './store';

View file

@ -0,0 +1,233 @@
// Listing Service for Marketplace
// Handles CRUD operations for marketplace listings
import {
Listing,
ListingStatus,
CreateListingInput,
UpdateListingInput,
} from './types';
import { listingStore, generateId } from './store';
export class ListingService {
/**
* Create a new listing
*/
async createListing(
sellerId: string,
sellerName: string,
input: CreateListingInput
): Promise<Listing> {
const listing: Listing = {
id: generateId(),
sellerId,
sellerName,
plantId: input.plantId,
title: input.title,
description: input.description,
price: input.price,
currency: input.currency || 'USD',
quantity: input.quantity,
category: input.category,
status: ListingStatus.DRAFT,
location: input.location,
tags: input.tags || [],
images: [],
viewCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: input.expiresAt,
};
return listingStore.create(listing);
}
/**
* Get a listing by ID
*/
async getListingById(id: string): Promise<Listing | null> {
const listing = listingStore.getById(id);
return listing || null;
}
/**
* Get a listing and increment view count
*/
async viewListing(id: string): Promise<Listing | null> {
listingStore.incrementViewCount(id);
return this.getListingById(id);
}
/**
* Get all listings by seller
*/
async getListingsBySeller(sellerId: string): Promise<Listing[]> {
return listingStore.getBySellerId(sellerId);
}
/**
* Get all active listings
*/
async getActiveListings(): Promise<Listing[]> {
return listingStore.getAll().filter(l => l.status === ListingStatus.ACTIVE);
}
/**
* Update a listing
*/
async updateListing(
id: string,
sellerId: string,
updates: UpdateListingInput
): Promise<Listing | null> {
const listing = listingStore.getById(id);
if (!listing) {
return null;
}
// Verify ownership
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
const updated = listingStore.update(id, updates);
return updated || null;
}
/**
* Publish a draft listing (make it active)
*/
async publishListing(id: string, sellerId: string): Promise<Listing | null> {
const listing = listingStore.getById(id);
if (!listing) {
return null;
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (listing.status !== ListingStatus.DRAFT) {
throw new Error('Only draft listings can be published');
}
return listingStore.update(id, { status: ListingStatus.ACTIVE }) || null;
}
/**
* Cancel a listing
*/
async cancelListing(id: string, sellerId: string): Promise<Listing | null> {
const listing = listingStore.getById(id);
if (!listing) {
return null;
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (listing.status === ListingStatus.SOLD) {
throw new Error('Cannot cancel a sold listing');
}
return listingStore.update(id, { status: ListingStatus.CANCELLED }) || null;
}
/**
* Mark listing as sold
*/
async markAsSold(id: string, sellerId: string): Promise<Listing | null> {
const listing = listingStore.getById(id);
if (!listing) {
return null;
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
return listingStore.update(id, {
status: ListingStatus.SOLD,
quantity: 0
}) || null;
}
/**
* Delete a listing (only drafts or cancelled)
*/
async deleteListing(id: string, sellerId: string): Promise<boolean> {
const listing = listingStore.getById(id);
if (!listing) {
return false;
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (listing.status === ListingStatus.ACTIVE || listing.status === ListingStatus.SOLD) {
throw new Error('Cannot delete active or sold listings. Cancel first.');
}
return listingStore.delete(id);
}
/**
* Get listing statistics for a seller
*/
async getSellerStats(sellerId: string): Promise<{
totalListings: number;
activeListings: number;
soldListings: number;
totalViews: number;
averagePrice: number;
}> {
const listings = listingStore.getBySellerId(sellerId);
const activeListings = listings.filter(l => l.status === ListingStatus.ACTIVE);
const soldListings = listings.filter(l => l.status === ListingStatus.SOLD);
const totalViews = listings.reduce((sum, l) => sum + l.viewCount, 0);
const averagePrice = listings.length > 0
? listings.reduce((sum, l) => sum + l.price, 0) / listings.length
: 0;
return {
totalListings: listings.length,
activeListings: activeListings.length,
soldListings: soldListings.length,
totalViews,
averagePrice: Math.round(averagePrice * 100) / 100,
};
}
/**
* Check and expire old listings
*/
async expireOldListings(): Promise<number> {
const now = new Date();
let expiredCount = 0;
const activeListings = listingStore.getAll().filter(
l => l.status === ListingStatus.ACTIVE && l.expiresAt
);
for (const listing of activeListings) {
if (listing.expiresAt && listing.expiresAt < now) {
listingStore.update(listing.id, { status: ListingStatus.EXPIRED });
expiredCount++;
}
}
return expiredCount;
}
}
// Export singleton instance
export const listingService = new ListingService();

View file

@ -0,0 +1,230 @@
// Matching Service for Marketplace
// Matches buyers with sellers based on preferences and listings
import { Listing, ListingCategory, ListingStatus } from './types';
import { listingStore, sellerProfileStore } from './store';
export interface BuyerPreferences {
categories: ListingCategory[];
maxPrice?: number;
preferredLocation?: {
lat: number;
lng: number;
maxDistanceKm: number;
};
preferredTags?: string[];
preferVerifiedSellers?: boolean;
}
export interface MatchResult {
listing: Listing;
score: number;
matchReasons: string[];
}
export class MatchingService {
/**
* Find listings that match buyer preferences
*/
async findMatchesForBuyer(
buyerId: string,
preferences: BuyerPreferences,
limit: number = 10
): Promise<MatchResult[]> {
const activeListings = listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.sellerId !== buyerId);
const matches: MatchResult[] = [];
for (const listing of activeListings) {
const { score, reasons } = this.calculateMatchScore(listing, preferences);
if (score > 0) {
matches.push({
listing,
score,
matchReasons: reasons,
});
}
}
// Sort by score descending
matches.sort((a, b) => b.score - a.score);
return matches.slice(0, limit);
}
/**
* Find buyers who might be interested in a listing
* (In a real system, this would query user preferences)
*/
async findPotentialBuyers(listingId: string): Promise<string[]> {
// Placeholder - would match against stored buyer preferences
// For now, return empty array as we don't have buyer preference storage
return [];
}
/**
* Get recommended listings for a user based on their history
*/
async getRecommendedListings(
userId: string,
limit: number = 8
): Promise<Listing[]> {
// Get user's purchase history and viewed listings
// For now, return featured listings as recommendations
const activeListings = listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.sellerId !== userId);
// Sort by a combination of view count and recency
return activeListings
.sort((a, b) => {
const aScore = a.viewCount * 0.7 + this.recencyScore(a.createdAt) * 0.3;
const bScore = b.viewCount * 0.7 + this.recencyScore(b.createdAt) * 0.3;
return bScore - aScore;
})
.slice(0, limit);
}
/**
* Find sellers with good ratings in a category
*/
async findTopSellersInCategory(
category: ListingCategory,
limit: number = 5
): Promise<{ userId: string; displayName: string; rating: number; listingCount: number }[]> {
// Get all active listings in category
const categoryListings = listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.category === category);
// Group by seller
const sellerStats: Record<string, { count: number; sellerId: string }> = {};
for (const listing of categoryListings) {
if (!sellerStats[listing.sellerId]) {
sellerStats[listing.sellerId] = { count: 0, sellerId: listing.sellerId };
}
sellerStats[listing.sellerId].count++;
}
// Get seller profiles and sort by rating
const topSellers = Object.values(sellerStats)
.map(stat => {
const profile = sellerProfileStore.getByUserId(stat.sellerId);
return {
userId: stat.sellerId,
displayName: profile?.displayName || 'Unknown Seller',
rating: profile?.rating || 0,
listingCount: stat.count,
};
})
.sort((a, b) => b.rating - a.rating)
.slice(0, limit);
return topSellers;
}
/**
* Calculate match score between a listing and buyer preferences
*/
private calculateMatchScore(
listing: Listing,
preferences: BuyerPreferences
): { score: number; reasons: string[] } {
let score = 0;
const reasons: string[] = [];
// Category match (highest weight)
if (preferences.categories.includes(listing.category)) {
score += 40;
reasons.push(`Matches preferred category: ${listing.category}`);
}
// Price within budget
if (preferences.maxPrice && listing.price <= preferences.maxPrice) {
score += 20;
reasons.push('Within budget');
}
// Location match
if (preferences.preferredLocation && listing.location) {
const distance = this.calculateDistance(
preferences.preferredLocation.lat,
preferences.preferredLocation.lng,
listing.location.lat,
listing.location.lng
);
if (distance <= preferences.preferredLocation.maxDistanceKm) {
score += 25;
reasons.push(`Nearby (${Math.round(distance)}km away)`);
}
}
// Tag matches
if (preferences.preferredTags && preferences.preferredTags.length > 0) {
const matchingTags = listing.tags.filter(tag =>
preferences.preferredTags!.includes(tag.toLowerCase())
);
if (matchingTags.length > 0) {
score += matchingTags.length * 5;
reasons.push(`Matching tags: ${matchingTags.join(', ')}`);
}
}
// Verified seller preference
if (preferences.preferVerifiedSellers) {
const profile = sellerProfileStore.getByUserId(listing.sellerId);
if (profile?.verified) {
score += 10;
reasons.push('Verified seller');
}
}
return { score, reasons };
}
/**
* Calculate recency score (0-100, higher for more recent)
*/
private recencyScore(date: Date): number {
const now = Date.now();
const created = date.getTime();
const daysOld = (now - created) / (1000 * 60 * 60 * 24);
// Exponential decay: 100 for today, ~37 for 7 days, ~14 for 14 days
return 100 * Math.exp(-daysOld / 10);
}
/**
* Calculate distance between two points (Haversine formula)
*/
private calculateDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number {
const R = 6371; // Earth's radius in km
const dLat = this.toRad(lat2 - lat1);
const dLng = this.toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(lat1)) *
Math.cos(this.toRad(lat2)) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRad(deg: number): number {
return deg * (Math.PI / 180);
}
}
// Export singleton instance
export const matchingService = new MatchingService();

View file

@ -0,0 +1,291 @@
// Offer Service for Marketplace
// Handles offer management for marketplace listings
import {
Offer,
OfferStatus,
Listing,
ListingStatus,
CreateOfferInput,
} from './types';
import { offerStore, listingStore, generateId } from './store';
export class OfferService {
/**
* Create a new offer on a listing
*/
async createOffer(
buyerId: string,
buyerName: string,
input: CreateOfferInput
): Promise<Offer> {
const listing = listingStore.getById(input.listingId);
if (!listing) {
throw new Error('Listing not found');
}
if (listing.status !== ListingStatus.ACTIVE) {
throw new Error('Cannot make offers on inactive listings');
}
if (listing.sellerId === buyerId) {
throw new Error('Cannot make an offer on your own listing');
}
// Check for existing pending offer from this buyer
const existingOffer = offerStore.getByListingId(input.listingId)
.find(o => o.buyerId === buyerId && o.status === OfferStatus.PENDING);
if (existingOffer) {
throw new Error('You already have a pending offer on this listing');
}
const offer: Offer = {
id: generateId(),
listingId: input.listingId,
buyerId,
buyerName,
amount: input.amount,
message: input.message,
status: OfferStatus.PENDING,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
};
return offerStore.create(offer);
}
/**
* Get an offer by ID
*/
async getOfferById(id: string): Promise<Offer | null> {
return offerStore.getById(id) || null;
}
/**
* Get all offers for a listing
*/
async getOffersForListing(listingId: string): Promise<Offer[]> {
return offerStore.getByListingId(listingId);
}
/**
* Get all offers made by a buyer
*/
async getOffersByBuyer(buyerId: string): Promise<Offer[]> {
return offerStore.getByBuyerId(buyerId);
}
/**
* Get all offers for a seller's listings
*/
async getOffersForSeller(sellerId: string): Promise<(Offer & { listing?: Listing })[]> {
const sellerListings = listingStore.getBySellerId(sellerId);
const listingIds = new Set(sellerListings.map(l => l.id));
const offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
return offers.map(offer => ({
...offer,
listing: listingStore.getById(offer.listingId),
}));
}
/**
* Accept an offer
*/
async acceptOffer(offerId: string, sellerId: string): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const listing = listingStore.getById(offer.listingId);
if (!listing) {
throw new Error('Listing not found');
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only accept pending offers');
}
// Accept this offer
const acceptedOffer = offerStore.update(offerId, {
status: OfferStatus.ACCEPTED
});
// Reject all other pending offers for this listing
const otherOffers = offerStore.getByListingId(offer.listingId)
.filter(o => o.id !== offerId && o.status === OfferStatus.PENDING);
for (const otherOffer of otherOffers) {
offerStore.update(otherOffer.id, { status: OfferStatus.REJECTED });
}
// Mark listing as sold
listingStore.update(offer.listingId, {
status: ListingStatus.SOLD,
quantity: 0
});
if (!acceptedOffer) {
throw new Error('Failed to accept offer');
}
return acceptedOffer;
}
/**
* Reject an offer
*/
async rejectOffer(offerId: string, sellerId: string): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const listing = listingStore.getById(offer.listingId);
if (!listing) {
throw new Error('Listing not found');
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only reject pending offers');
}
const rejectedOffer = offerStore.update(offerId, {
status: OfferStatus.REJECTED
});
if (!rejectedOffer) {
throw new Error('Failed to reject offer');
}
return rejectedOffer;
}
/**
* Withdraw an offer (buyer action)
*/
async withdrawOffer(offerId: string, buyerId: string): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.buyerId !== buyerId) {
throw new Error('Unauthorized: This is not your offer');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only withdraw pending offers');
}
const withdrawnOffer = offerStore.update(offerId, {
status: OfferStatus.WITHDRAWN
});
if (!withdrawnOffer) {
throw new Error('Failed to withdraw offer');
}
return withdrawnOffer;
}
/**
* Counter offer (update amount)
*/
async updateOfferAmount(
offerId: string,
buyerId: string,
newAmount: number
): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.buyerId !== buyerId) {
throw new Error('Unauthorized: This is not your offer');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only update pending offers');
}
const updatedOffer = offerStore.update(offerId, { amount: newAmount });
if (!updatedOffer) {
throw new Error('Failed to update offer');
}
return updatedOffer;
}
/**
* Get offer statistics
*/
async getOfferStats(userId: string, role: 'buyer' | 'seller'): Promise<{
totalOffers: number;
pendingOffers: number;
acceptedOffers: number;
rejectedOffers: number;
}> {
let offers: Offer[];
if (role === 'buyer') {
offers = offerStore.getByBuyerId(userId);
} else {
const sellerListings = listingStore.getBySellerId(userId);
const listingIds = new Set(sellerListings.map(l => l.id));
offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
}
return {
totalOffers: offers.length,
pendingOffers: offers.filter(o => o.status === OfferStatus.PENDING).length,
acceptedOffers: offers.filter(o => o.status === OfferStatus.ACCEPTED).length,
rejectedOffers: offers.filter(o => o.status === OfferStatus.REJECTED).length,
};
}
/**
* Expire old pending offers
*/
async expireOldOffers(): Promise<number> {
const now = new Date();
let expiredCount = 0;
const pendingOffers = offerStore.getAll().filter(
o => o.status === OfferStatus.PENDING && o.expiresAt
);
for (const offer of pendingOffers) {
if (offer.expiresAt && offer.expiresAt < now) {
offerStore.update(offer.id, { status: OfferStatus.EXPIRED });
expiredCount++;
}
}
return expiredCount;
}
}
// Export singleton instance
export const offerService = new OfferService();

View file

@ -0,0 +1,285 @@
// Search Service for Marketplace
// Handles listing search and filtering
import {
Listing,
ListingStatus,
SearchFilters,
SearchResult,
ListingCategory,
MarketplaceStats,
} from './types';
import { listingStore } from './store';
export class SearchService {
/**
* Search listings with filters
*/
async searchListings(filters: SearchFilters): Promise<SearchResult> {
let listings = listingStore.getAll();
// Only show active listings unless specific status requested
if (filters.status) {
listings = listings.filter(l => l.status === filters.status);
} else {
listings = listings.filter(l => l.status === ListingStatus.ACTIVE);
}
// Text search in title and description
if (filters.query) {
const query = filters.query.toLowerCase();
listings = listings.filter(l =>
l.title.toLowerCase().includes(query) ||
l.description.toLowerCase().includes(query) ||
l.tags.some(tag => tag.toLowerCase().includes(query))
);
}
// Category filter
if (filters.category) {
listings = listings.filter(l => l.category === filters.category);
}
// Price range filter
if (filters.minPrice !== undefined) {
listings = listings.filter(l => l.price >= filters.minPrice!);
}
if (filters.maxPrice !== undefined) {
listings = listings.filter(l => l.price <= filters.maxPrice!);
}
// Seller filter
if (filters.sellerId) {
listings = listings.filter(l => l.sellerId === filters.sellerId);
}
// Tags filter
if (filters.tags && filters.tags.length > 0) {
const filterTags = filters.tags.map(t => t.toLowerCase());
listings = listings.filter(l =>
filterTags.some(tag => l.tags.map(t => t.toLowerCase()).includes(tag))
);
}
// Location filter (approximate distance)
if (filters.location) {
listings = listings.filter(l => {
if (!l.location) return false;
const distance = this.calculateDistance(
filters.location!.lat,
filters.location!.lng,
l.location.lat,
l.location.lng
);
return distance <= filters.location!.radiusKm;
});
}
// Sort listings
listings = this.sortListings(listings, filters.sortBy || 'date_desc', filters.query);
// Pagination
const page = filters.page || 1;
const limit = filters.limit || 20;
const total = listings.length;
const startIndex = (page - 1) * limit;
const paginatedListings = listings.slice(startIndex, startIndex + limit);
return {
listings: paginatedListings,
total,
page,
limit,
hasMore: startIndex + limit < total,
};
}
/**
* Get featured listings (most viewed active listings)
*/
async getFeaturedListings(limit: number = 6): Promise<Listing[]> {
return listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE)
.sort((a, b) => b.viewCount - a.viewCount)
.slice(0, limit);
}
/**
* Get recent listings
*/
async getRecentListings(limit: number = 10): Promise<Listing[]> {
return listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
}
/**
* Get listings by category
*/
async getListingsByCategory(
category: ListingCategory,
limit: number = 20
): Promise<Listing[]> {
return listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE && l.category === category)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
}
/**
* Get similar listings based on category and tags
*/
async getSimilarListings(listingId: string, limit: number = 4): Promise<Listing[]> {
const listing = listingStore.getById(listingId);
if (!listing) return [];
return listingStore
.getAll()
.filter(l =>
l.id !== listingId &&
l.status === ListingStatus.ACTIVE &&
(l.category === listing.category ||
l.tags.some(tag => listing.tags.includes(tag)))
)
.sort((a, b) => {
// Score based on matching tags
const aScore = a.tags.filter(tag => listing.tags.includes(tag)).length;
const bScore = b.tags.filter(tag => listing.tags.includes(tag)).length;
return bScore - aScore;
})
.slice(0, limit);
}
/**
* Get marketplace statistics
*/
async getMarketplaceStats(): Promise<MarketplaceStats> {
const allListings = listingStore.getAll();
const activeListings = allListings.filter(l => l.status === ListingStatus.ACTIVE);
const soldListings = allListings.filter(l => l.status === ListingStatus.SOLD);
// Category counts
const categoryCounts: Record<ListingCategory, number> = {
[ListingCategory.SEEDS]: 0,
[ListingCategory.SEEDLINGS]: 0,
[ListingCategory.MATURE_PLANTS]: 0,
[ListingCategory.CUTTINGS]: 0,
[ListingCategory.PRODUCE]: 0,
[ListingCategory.SUPPLIES]: 0,
};
activeListings.forEach(l => {
categoryCounts[l.category]++;
});
// Top categories
const topCategories = Object.entries(categoryCounts)
.map(([category, count]) => ({
category: category as ListingCategory,
count
}))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
// Average price
const averagePrice = activeListings.length > 0
? activeListings.reduce((sum, l) => sum + l.price, 0) / activeListings.length
: 0;
return {
totalListings: allListings.length,
activeListings: activeListings.length,
totalSales: soldListings.length,
totalOffers: 0, // Would be calculated from offer store
categoryCounts,
averagePrice: Math.round(averagePrice * 100) / 100,
topCategories,
};
}
/**
* Get popular tags
*/
async getPopularTags(limit: number = 20): Promise<{ tag: string; count: number }[]> {
const tagCounts: Record<string, number> = {};
listingStore
.getAll()
.filter(l => l.status === ListingStatus.ACTIVE)
.forEach(l => {
l.tags.forEach(tag => {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
});
});
return Object.entries(tagCounts)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
}
/**
* Sort listings based on sort option
*/
private sortListings(
listings: Listing[],
sortBy: string,
query?: string
): Listing[] {
switch (sortBy) {
case 'price_asc':
return listings.sort((a, b) => a.price - b.price);
case 'price_desc':
return listings.sort((a, b) => b.price - a.price);
case 'date_asc':
return listings.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
case 'date_desc':
return listings.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
case 'relevance':
if (!query) {
return listings.sort((a, b) => b.viewCount - a.viewCount);
}
// Simple relevance scoring based on title match
return listings.sort((a, b) => {
const aTitle = a.title.toLowerCase().includes(query.toLowerCase()) ? 1 : 0;
const bTitle = b.title.toLowerCase().includes(query.toLowerCase()) ? 1 : 0;
return bTitle - aTitle;
});
default:
return listings;
}
}
/**
* Calculate distance between two points (Haversine formula)
*/
private calculateDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number
): number {
const R = 6371; // Earth's radius in km
const dLat = this.toRad(lat2 - lat1);
const dLng = this.toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(lat1)) *
Math.cos(this.toRad(lat2)) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRad(deg: number): number {
return deg * (Math.PI / 180);
}
}
// Export singleton instance
export const searchService = new SearchService();

273
lib/marketplace/store.ts Normal file
View file

@ -0,0 +1,273 @@
// In-Memory Store for Marketplace
// This will be replaced with Prisma database calls once Agent 2 completes database setup
import {
Listing,
Offer,
SellerProfile,
WishlistItem,
ListingCategory,
ListingStatus,
OfferStatus,
} from './types';
// In-memory storage
const listings: Map<string, Listing> = new Map();
const offers: Map<string, Offer> = new Map();
const sellerProfiles: Map<string, SellerProfile> = new Map();
const wishlistItems: Map<string, WishlistItem> = new Map();
// Helper to generate IDs
export const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
// Seed with sample data for development
const seedSampleData = () => {
const sampleListings: Listing[] = [
{
id: 'listing-1',
sellerId: 'user-1',
sellerName: 'Green Thumb Gardens',
title: 'Heirloom Tomato Seedlings - Cherokee Purple',
description: 'Beautiful heirloom Cherokee Purple tomato seedlings, organically grown. These produce large, deep purple-red fruits with rich, complex flavor. Perfect for home gardens.',
price: 4.99,
currency: 'USD',
quantity: 24,
category: ListingCategory.SEEDLINGS,
status: ListingStatus.ACTIVE,
location: { lat: 40.7128, lng: -74.006, city: 'New York', region: 'NY' },
tags: ['organic', 'heirloom', 'tomato', 'vegetable'],
images: [
{ id: 'img-1', listingId: 'listing-1', url: '/images/tomato-seedling.jpg', alt: 'Cherokee Purple Tomato Seedling', isPrimary: true, createdAt: new Date() }
],
viewCount: 142,
createdAt: new Date('2024-03-01'),
updatedAt: new Date('2024-03-15'),
},
{
id: 'listing-2',
sellerId: 'user-2',
sellerName: 'Urban Herb Farm',
title: 'Fresh Basil Plants - Genovese',
description: 'Ready-to-harvest Genovese basil plants grown in our vertical farm. Perfect for pesto, salads, and Italian cuisine. Each plant is 6-8 inches tall.',
price: 6.50,
currency: 'USD',
quantity: 50,
category: ListingCategory.MATURE_PLANTS,
status: ListingStatus.ACTIVE,
location: { lat: 34.0522, lng: -118.2437, city: 'Los Angeles', region: 'CA' },
tags: ['herbs', 'basil', 'culinary', 'fresh'],
images: [],
viewCount: 89,
createdAt: new Date('2024-03-10'),
updatedAt: new Date('2024-03-10'),
},
{
id: 'listing-3',
sellerId: 'user-1',
sellerName: 'Green Thumb Gardens',
title: 'Organic Lettuce Mix Seeds',
description: 'Premium mix of organic lettuce seeds including romaine, butterhead, and red leaf varieties. Perfect for succession planting.',
price: 3.99,
currency: 'USD',
quantity: 100,
category: ListingCategory.SEEDS,
status: ListingStatus.ACTIVE,
location: { lat: 40.7128, lng: -74.006, city: 'New York', region: 'NY' },
tags: ['organic', 'seeds', 'lettuce', 'salad'],
images: [],
viewCount: 256,
createdAt: new Date('2024-02-15'),
updatedAt: new Date('2024-03-01'),
},
{
id: 'listing-4',
sellerId: 'user-3',
sellerName: 'Succulent Paradise',
title: 'Assorted Succulent Cuttings - 10 Pack',
description: 'Beautiful assortment of succulent cuttings ready for propagation. Includes echeveria, sedum, and crassula varieties.',
price: 15.00,
currency: 'USD',
quantity: 30,
category: ListingCategory.CUTTINGS,
status: ListingStatus.ACTIVE,
location: { lat: 33.4484, lng: -112.074, city: 'Phoenix', region: 'AZ' },
tags: ['succulents', 'cuttings', 'propagation', 'drought-tolerant'],
images: [],
viewCount: 178,
createdAt: new Date('2024-03-05'),
updatedAt: new Date('2024-03-12'),
},
{
id: 'listing-5',
sellerId: 'user-2',
sellerName: 'Urban Herb Farm',
title: 'Fresh Microgreens - Chef\'s Mix',
description: 'Freshly harvested microgreens mix including sunflower, radish, and pea shoots. Harvested same day as shipping for maximum freshness.',
price: 8.99,
currency: 'USD',
quantity: 40,
category: ListingCategory.PRODUCE,
status: ListingStatus.ACTIVE,
location: { lat: 34.0522, lng: -118.2437, city: 'Los Angeles', region: 'CA' },
tags: ['microgreens', 'fresh', 'produce', 'chef'],
images: [],
viewCount: 312,
createdAt: new Date('2024-03-18'),
updatedAt: new Date('2024-03-18'),
},
];
const sampleProfiles: SellerProfile[] = [
{
userId: 'user-1',
displayName: 'Green Thumb Gardens',
bio: 'Family-owned nursery specializing in heirloom vegetables and native plants.',
location: { city: 'New York', region: 'NY' },
rating: 4.8,
reviewCount: 127,
totalSales: 523,
memberSince: new Date('2023-01-15'),
verified: true,
},
{
userId: 'user-2',
displayName: 'Urban Herb Farm',
bio: 'Vertical farm growing fresh herbs and microgreens in the heart of LA.',
location: { city: 'Los Angeles', region: 'CA' },
rating: 4.9,
reviewCount: 89,
totalSales: 412,
memberSince: new Date('2023-03-20'),
verified: true,
},
{
userId: 'user-3',
displayName: 'Succulent Paradise',
bio: 'Desert plant enthusiast sharing the beauty of succulents.',
location: { city: 'Phoenix', region: 'AZ' },
rating: 4.7,
reviewCount: 56,
totalSales: 198,
memberSince: new Date('2023-06-01'),
verified: false,
},
];
// Seed listings
sampleListings.forEach(listing => {
listings.set(listing.id, listing);
});
// Seed profiles
sampleProfiles.forEach(profile => {
sellerProfiles.set(profile.userId, profile);
});
};
// Initialize with sample data
seedSampleData();
// Export store operations
export const listingStore = {
getAll: (): Listing[] => Array.from(listings.values()),
getById: (id: string): Listing | undefined => listings.get(id),
getBySellerId: (sellerId: string): Listing[] =>
Array.from(listings.values()).filter(l => l.sellerId === sellerId),
create: (listing: Listing): Listing => {
listings.set(listing.id, listing);
return listing;
},
update: (id: string, updates: Partial<Listing>): Listing | undefined => {
const existing = listings.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...updates, updatedAt: new Date() };
listings.set(id, updated);
return updated;
},
delete: (id: string): boolean => listings.delete(id),
incrementViewCount: (id: string): void => {
const listing = listings.get(id);
if (listing) {
listing.viewCount++;
listings.set(id, listing);
}
},
};
export const offerStore = {
getAll: (): Offer[] => Array.from(offers.values()),
getById: (id: string): Offer | undefined => offers.get(id),
getByListingId: (listingId: string): Offer[] =>
Array.from(offers.values()).filter(o => o.listingId === listingId),
getByBuyerId: (buyerId: string): Offer[] =>
Array.from(offers.values()).filter(o => o.buyerId === buyerId),
create: (offer: Offer): Offer => {
offers.set(offer.id, offer);
return offer;
},
update: (id: string, updates: Partial<Offer>): Offer | undefined => {
const existing = offers.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...updates, updatedAt: new Date() };
offers.set(id, updated);
return updated;
},
delete: (id: string): boolean => offers.delete(id),
};
export const sellerProfileStore = {
getByUserId: (userId: string): SellerProfile | undefined =>
sellerProfiles.get(userId),
create: (profile: SellerProfile): SellerProfile => {
sellerProfiles.set(profile.userId, profile);
return profile;
},
update: (userId: string, updates: Partial<SellerProfile>): SellerProfile | undefined => {
const existing = sellerProfiles.get(userId);
if (!existing) return undefined;
const updated = { ...existing, ...updates };
sellerProfiles.set(userId, updated);
return updated;
},
};
export const wishlistStore = {
getByUserId: (userId: string): WishlistItem[] =>
Array.from(wishlistItems.values()).filter(w => w.userId === userId),
add: (item: WishlistItem): WishlistItem => {
wishlistItems.set(item.id, item);
return item;
},
remove: (userId: string, listingId: string): boolean => {
const item = Array.from(wishlistItems.values()).find(
w => w.userId === userId && w.listingId === listingId
);
if (item) {
return wishlistItems.delete(item.id);
}
return false;
},
exists: (userId: string, listingId: string): boolean =>
Array.from(wishlistItems.values()).some(
w => w.userId === userId && w.listingId === listingId
),
};

167
lib/marketplace/types.ts Normal file
View file

@ -0,0 +1,167 @@
// Marketplace Types for LocalGreenChain
// These types define the marketplace foundation for plant trading
export enum ListingCategory {
SEEDS = 'seeds',
SEEDLINGS = 'seedlings',
MATURE_PLANTS = 'mature_plants',
CUTTINGS = 'cuttings',
PRODUCE = 'produce',
SUPPLIES = 'supplies',
}
export enum ListingStatus {
DRAFT = 'draft',
ACTIVE = 'active',
SOLD = 'sold',
EXPIRED = 'expired',
CANCELLED = 'cancelled',
}
export enum OfferStatus {
PENDING = 'pending',
ACCEPTED = 'accepted',
REJECTED = 'rejected',
WITHDRAWN = 'withdrawn',
EXPIRED = 'expired',
}
export interface ListingImage {
id: string;
listingId: string;
url: string;
alt: string;
isPrimary: boolean;
createdAt: Date;
}
export interface Listing {
id: string;
sellerId: string;
sellerName?: string;
plantId?: string;
title: string;
description: string;
price: number;
currency: string;
quantity: number;
category: ListingCategory;
status: ListingStatus;
location?: {
lat: number;
lng: number;
city?: string;
region?: string;
};
tags: string[];
images: ListingImage[];
viewCount: number;
createdAt: Date;
updatedAt: Date;
expiresAt?: Date;
}
export interface Offer {
id: string;
listingId: string;
buyerId: string;
buyerName?: string;
amount: number;
message?: string;
status: OfferStatus;
createdAt: Date;
updatedAt: Date;
expiresAt?: Date;
}
export interface SellerProfile {
userId: string;
displayName: string;
bio?: string;
location?: {
city?: string;
region?: string;
};
rating: number;
reviewCount: number;
totalSales: number;
memberSince: Date;
verified: boolean;
}
export interface WishlistItem {
id: string;
userId: string;
listingId: string;
addedAt: Date;
}
export interface SearchFilters {
query?: string;
category?: ListingCategory;
minPrice?: number;
maxPrice?: number;
location?: {
lat: number;
lng: number;
radiusKm: number;
};
sellerId?: string;
status?: ListingStatus;
tags?: string[];
sortBy?: 'price_asc' | 'price_desc' | 'date_desc' | 'date_asc' | 'relevance';
page?: number;
limit?: number;
}
export interface SearchResult {
listings: Listing[];
total: number;
page: number;
limit: number;
hasMore: boolean;
}
export interface CreateListingInput {
title: string;
description: string;
price: number;
currency?: string;
quantity: number;
category: ListingCategory;
plantId?: string;
location?: {
lat: number;
lng: number;
city?: string;
region?: string;
};
tags?: string[];
expiresAt?: Date;
}
export interface UpdateListingInput {
title?: string;
description?: string;
price?: number;
quantity?: number;
status?: ListingStatus;
tags?: string[];
expiresAt?: Date;
}
export interface CreateOfferInput {
listingId: string;
amount: number;
message?: string;
}
export interface MarketplaceStats {
totalListings: number;
activeListings: number;
totalSales: number;
totalOffers: number;
categoryCounts: Record<ListingCategory, number>;
averagePrice: number;
topCategories: { category: ListingCategory; count: number }[];
}

View file

@ -0,0 +1,56 @@
// API: Featured Listings
// GET /api/marketplace/featured - Get featured and recent listings
import type { NextApiRequest, NextApiResponse } from 'next';
import { searchService } from '@/lib/marketplace';
import type { ListingCategory } from '@/lib/marketplace/types';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
try {
const { category, limit: limitParam } = req.query;
const limit = limitParam ? parseInt(limitParam as string, 10) : 6;
// Get featured listings
const featured = await searchService.getFeaturedListings(limit);
// Get recent listings
const recent = await searchService.getRecentListings(limit);
// Get category-specific listings if requested
let categoryListings = null;
if (category && typeof category === 'string') {
categoryListings = await searchService.getListingsByCategory(
category as ListingCategory,
limit
);
}
// Get marketplace stats
const stats = await searchService.getMarketplaceStats();
// Get popular tags
const popularTags = await searchService.getPopularTags(15);
return res.status(200).json({
featured,
recent,
categoryListings,
stats,
popularTags,
});
} catch (error) {
console.error('Featured listings API error:', error);
return res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}

View file

@ -0,0 +1,116 @@
// API: Single Marketplace Listing
// GET /api/marketplace/listings/[id] - Get a listing
// PUT /api/marketplace/listings/[id] - Update a listing
// DELETE /api/marketplace/listings/[id] - Delete a listing
import type { NextApiRequest, NextApiResponse } from 'next';
import { listingService } from '@/lib/marketplace';
import type { UpdateListingInput } from '@/lib/marketplace/types';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id } = req.query;
if (!id || typeof id !== 'string') {
return res.status(400).json({ error: 'Listing ID is required' });
}
try {
switch (req.method) {
case 'GET':
return handleGet(id, res);
case 'PUT':
return handlePut(id, req, res);
case 'DELETE':
return handleDelete(id, req, res);
default:
res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
} catch (error) {
console.error('Marketplace listing API error:', error);
return res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
async function handleGet(id: string, res: NextApiResponse) {
const listing = await listingService.viewListing(id);
if (!listing) {
return res.status(404).json({ error: 'Listing not found' });
}
return res.status(200).json(listing);
}
async function handlePut(id: string, req: NextApiRequest, res: NextApiResponse) {
const sellerId = req.headers['x-user-id'] as string || req.body.sellerId;
if (!sellerId) {
return res.status(401).json({ error: 'Authentication required' });
}
const updates: UpdateListingInput = {};
if (req.body.title) updates.title = req.body.title;
if (req.body.description) updates.description = req.body.description;
if (req.body.price) updates.price = parseFloat(req.body.price);
if (req.body.quantity) updates.quantity = parseInt(req.body.quantity, 10);
if (req.body.status) updates.status = req.body.status;
if (req.body.tags) updates.tags = req.body.tags;
if (req.body.expiresAt) updates.expiresAt = new Date(req.body.expiresAt);
try {
const listing = await listingService.updateListing(id, sellerId, updates);
if (!listing) {
return res.status(404).json({ error: 'Listing not found' });
}
return res.status(200).json({
success: true,
listing,
});
} catch (error) {
if (error instanceof Error && error.message.includes('Unauthorized')) {
return res.status(403).json({ error: error.message });
}
throw error;
}
}
async function handleDelete(id: string, req: NextApiRequest, res: NextApiResponse) {
const sellerId = req.headers['x-user-id'] as string || req.body.sellerId;
if (!sellerId) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const deleted = await listingService.deleteListing(id, sellerId);
if (!deleted) {
return res.status(404).json({ error: 'Listing not found' });
}
return res.status(200).json({
success: true,
message: 'Listing deleted successfully',
});
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Unauthorized')) {
return res.status(403).json({ error: error.message });
}
if (error.message.includes('Cannot delete')) {
return res.status(400).json({ error: error.message });
}
}
throw error;
}
}

View file

@ -0,0 +1,95 @@
// API: Offers on a Listing
// GET /api/marketplace/listings/[id]/offers - Get all offers for a listing
// POST /api/marketplace/listings/[id]/offers - Create a new offer
import type { NextApiRequest, NextApiResponse } from 'next';
import { offerService, listingService } from '@/lib/marketplace';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id: listingId } = req.query;
if (!listingId || typeof listingId !== 'string') {
return res.status(400).json({ error: 'Listing ID is required' });
}
try {
switch (req.method) {
case 'GET':
return handleGet(listingId, req, res);
case 'POST':
return handlePost(listingId, req, res);
default:
res.setHeader('Allow', ['GET', 'POST']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
} catch (error) {
console.error('Marketplace offers API error:', error);
return res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
async function handleGet(listingId: string, req: NextApiRequest, res: NextApiResponse) {
const userId = req.headers['x-user-id'] as string;
// Check if listing exists
const listing = await listingService.getListingById(listingId);
if (!listing) {
return res.status(404).json({ error: 'Listing not found' });
}
// Only the seller can see all offers
if (userId !== listing.sellerId) {
return res.status(403).json({ error: 'Only the seller can view offers' });
}
const offers = await offerService.getOffersForListing(listingId);
return res.status(200).json({
listingId,
offers,
total: offers.length,
});
}
async function handlePost(listingId: string, req: NextApiRequest, res: NextApiResponse) {
const buyerId = req.headers['x-user-id'] as string || req.body.buyerId;
const buyerName = req.headers['x-user-name'] as string || req.body.buyerName || 'Anonymous Buyer';
if (!buyerId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { amount, message } = req.body;
if (!amount || typeof amount !== 'number') {
return res.status(400).json({ error: 'Offer amount is required' });
}
if (amount <= 0) {
return res.status(400).json({ error: 'Offer amount must be greater than zero' });
}
try {
const offer = await offerService.createOffer(buyerId, buyerName, {
listingId,
amount,
message,
});
return res.status(201).json({
success: true,
offer,
});
} catch (error) {
if (error instanceof Error) {
return res.status(400).json({ error: error.message });
}
throw error;
}
}

View file

@ -0,0 +1,122 @@
// API: Marketplace Listings
// GET /api/marketplace/listings - List all active listings
// POST /api/marketplace/listings - Create a new listing
import type { NextApiRequest, NextApiResponse } from 'next';
import { listingService, searchService } from '@/lib/marketplace';
import type { CreateListingInput, SearchFilters, ListingCategory } from '@/lib/marketplace/types';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
switch (req.method) {
case 'GET':
return handleGet(req, res);
case 'POST':
return handlePost(req, res);
default:
res.setHeader('Allow', ['GET', 'POST']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
} catch (error) {
console.error('Marketplace listings API error:', error);
return res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
async function handleGet(req: NextApiRequest, res: NextApiResponse) {
const {
query,
category,
minPrice,
maxPrice,
sellerId,
tags,
sortBy,
page,
limit,
} = req.query;
const filters: SearchFilters = {};
if (query && typeof query === 'string') {
filters.query = query;
}
if (category && typeof category === 'string') {
filters.category = category as ListingCategory;
}
if (minPrice && typeof minPrice === 'string') {
filters.minPrice = parseFloat(minPrice);
}
if (maxPrice && typeof maxPrice === 'string') {
filters.maxPrice = parseFloat(maxPrice);
}
if (sellerId && typeof sellerId === 'string') {
filters.sellerId = sellerId;
}
if (tags) {
const tagArray = Array.isArray(tags) ? tags : [tags];
filters.tags = tagArray.filter((t): t is string => typeof t === 'string');
}
if (sortBy && typeof sortBy === 'string') {
filters.sortBy = sortBy as SearchFilters['sortBy'];
}
if (page && typeof page === 'string') {
filters.page = parseInt(page, 10);
}
if (limit && typeof limit === 'string') {
filters.limit = parseInt(limit, 10);
}
const results = await searchService.searchListings(filters);
return res.status(200).json(results);
}
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
// In production, get sellerId from authenticated session
// For now, use a header or body field
const sellerId = req.headers['x-user-id'] as string || req.body.sellerId || 'anonymous';
const sellerName = req.headers['x-user-name'] as string || req.body.sellerName || 'Anonymous Seller';
const input: CreateListingInput = {
title: req.body.title,
description: req.body.description,
price: parseFloat(req.body.price),
currency: req.body.currency || 'USD',
quantity: parseInt(req.body.quantity, 10),
category: req.body.category,
plantId: req.body.plantId,
location: req.body.location,
tags: req.body.tags || [],
expiresAt: req.body.expiresAt ? new Date(req.body.expiresAt) : undefined,
};
// Validate required fields
if (!input.title || !input.description || !input.price || !input.quantity || !input.category) {
return res.status(400).json({
error: 'Missing required fields',
required: ['title', 'description', 'price', 'quantity', 'category'],
});
}
const listing = await listingService.createListing(sellerId, sellerName, input);
return res.status(201).json({
success: true,
listing,
});
}

View file

@ -0,0 +1,51 @@
// API: My Listings
// GET /api/marketplace/my-listings - Get current user's listings
import type { NextApiRequest, NextApiResponse } from 'next';
import { listingService } from '@/lib/marketplace';
import { ListingStatus } from '@/lib/marketplace/types';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
try {
const sellerId = req.headers['x-user-id'] as string;
if (!sellerId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { status } = req.query;
let listings = await listingService.getListingsBySeller(sellerId);
// Filter by status if provided
if (status && typeof status === 'string') {
listings = listings.filter(l => l.status === status as ListingStatus);
}
// Get statistics
const stats = await listingService.getSellerStats(sellerId);
// Sort by most recent first
listings.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return res.status(200).json({
listings,
total: listings.length,
stats,
});
} catch (error) {
console.error('My listings API error:', error);
return res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}

View file

@ -0,0 +1,71 @@
// API: My Offers
// GET /api/marketplace/my-offers - Get current user's offers (as buyer or seller)
import type { NextApiRequest, NextApiResponse } from 'next';
import { offerService, listingService } from '@/lib/marketplace';
import { OfferStatus } from '@/lib/marketplace/types';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
try {
const userId = req.headers['x-user-id'] as string;
if (!userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { role, status } = req.query;
// Default to buyer role
const userRole = role === 'seller' ? 'seller' : 'buyer';
let offers;
if (userRole === 'buyer') {
// Get offers made by this user
offers = await offerService.getOffersByBuyer(userId);
// Attach listing info
offers = await Promise.all(
offers.map(async (offer) => {
const listing = await listingService.getListingById(offer.listingId);
return { ...offer, listing };
})
);
} else {
// Get offers on this user's listings
offers = await offerService.getOffersForSeller(userId);
}
// Filter by status if provided
if (status && typeof status === 'string') {
offers = offers.filter(o => o.status === status as OfferStatus);
}
// Sort by most recent first
offers.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
// Get statistics
const stats = await offerService.getOfferStats(userId, userRole);
return res.status(200).json({
offers,
total: offers.length,
role: userRole,
stats,
});
} catch (error) {
console.error('My offers API error:', error);
return res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}

View file

@ -0,0 +1,117 @@
// API: Single Offer Management
// GET /api/marketplace/offers/[id] - Get an offer
// PUT /api/marketplace/offers/[id] - Update offer (accept/reject/withdraw)
import type { NextApiRequest, NextApiResponse } from 'next';
import { offerService, listingService } from '@/lib/marketplace';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { id: offerId } = req.query;
if (!offerId || typeof offerId !== 'string') {
return res.status(400).json({ error: 'Offer ID is required' });
}
try {
switch (req.method) {
case 'GET':
return handleGet(offerId, req, res);
case 'PUT':
return handlePut(offerId, req, res);
default:
res.setHeader('Allow', ['GET', 'PUT']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
} catch (error) {
console.error('Offer API error:', error);
return res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
async function handleGet(offerId: string, req: NextApiRequest, res: NextApiResponse) {
const userId = req.headers['x-user-id'] as string;
const offer = await offerService.getOfferById(offerId);
if (!offer) {
return res.status(404).json({ error: 'Offer not found' });
}
// Get listing to check authorization
const listing = await listingService.getListingById(offer.listingId);
// Only buyer or seller can view the offer
if (userId !== offer.buyerId && userId !== listing?.sellerId) {
return res.status(403).json({ error: 'Not authorized to view this offer' });
}
return res.status(200).json({
...offer,
listing,
});
}
async function handlePut(offerId: string, req: NextApiRequest, res: NextApiResponse) {
const userId = req.headers['x-user-id'] as string;
if (!userId) {
return res.status(401).json({ error: 'Authentication required' });
}
const { action, amount } = req.body;
if (!action) {
return res.status(400).json({ error: 'Action is required (accept, reject, withdraw, update)' });
}
try {
let result;
switch (action) {
case 'accept':
result = await offerService.acceptOffer(offerId, userId);
break;
case 'reject':
result = await offerService.rejectOffer(offerId, userId);
break;
case 'withdraw':
result = await offerService.withdrawOffer(offerId, userId);
break;
case 'update':
if (!amount || typeof amount !== 'number') {
return res.status(400).json({ error: 'New amount is required for update action' });
}
result = await offerService.updateOfferAmount(offerId, userId, amount);
break;
default:
return res.status(400).json({
error: 'Invalid action',
validActions: ['accept', 'reject', 'withdraw', 'update'],
});
}
return res.status(200).json({
success: true,
action,
offer: result,
});
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('Unauthorized') || error.message.includes('not your')) {
return res.status(403).json({ error: error.message });
}
return res.status(400).json({ error: error.message });
}
throw error;
}
}

View file

@ -0,0 +1,89 @@
// API: Marketplace Recommendations
// GET /api/marketplace/recommendations - Get personalized recommendations
import type { NextApiRequest, NextApiResponse } from 'next';
import { matchingService, searchService } from '@/lib/marketplace';
import type { ListingCategory } from '@/lib/marketplace/types';
import type { BuyerPreferences } from '@/lib/marketplace/matchingService';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
try {
const userId = req.headers['x-user-id'] as string || 'anonymous';
const { categories, maxPrice, lat, lng, radius, tags, limit: limitParam } = req.query;
const limit = limitParam ? parseInt(limitParam as string, 10) : 10;
// Build preferences from query params
const preferences: BuyerPreferences = {
categories: [],
};
if (categories) {
const categoryArray = Array.isArray(categories)
? categories
: (categories as string).split(',');
preferences.categories = categoryArray as ListingCategory[];
} else {
// Default to all categories if none specified
preferences.categories = Object.values(ListingCategory) as ListingCategory[];
}
if (maxPrice && typeof maxPrice === 'string') {
preferences.maxPrice = parseFloat(maxPrice);
}
if (lat && lng && radius) {
preferences.preferredLocation = {
lat: parseFloat(lat as string),
lng: parseFloat(lng as string),
maxDistanceKm: parseFloat(radius as string),
};
}
if (tags) {
const tagArray = Array.isArray(tags) ? tags : (tags as string).split(',');
preferences.preferredTags = tagArray.filter((t): t is string => typeof t === 'string');
}
// Get personalized matches
const matches = await matchingService.findMatchesForBuyer(userId, preferences, limit);
// Get general recommendations as fallback
const recommended = await matchingService.getRecommendedListings(userId, limit);
// Get similar items if we have matches
let similar = [];
if (matches.length > 0) {
similar = await searchService.getSimilarListings(matches[0].listing.id, 4);
}
return res.status(200).json({
matches: matches.map(m => ({
listing: m.listing,
score: m.score,
reasons: m.matchReasons,
})),
recommended,
similar,
preferences: {
categories: preferences.categories,
hasLocation: !!preferences.preferredLocation,
hasPriceLimit: !!preferences.maxPrice,
},
});
} catch (error) {
console.error('Recommendations API error:', error);
return res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}

View file

@ -0,0 +1,102 @@
// API: Marketplace Search
// GET /api/marketplace/search - Search listings with filters
import type { NextApiRequest, NextApiResponse } from 'next';
import { searchService } from '@/lib/marketplace';
import type { SearchFilters, ListingCategory } from '@/lib/marketplace/types';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET']);
return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
}
try {
const {
q,
category,
minPrice,
maxPrice,
lat,
lng,
radius,
tags,
sort,
page,
limit,
} = req.query;
const filters: SearchFilters = {};
// Text search
if (q && typeof q === 'string') {
filters.query = q;
}
// Category
if (category && typeof category === 'string') {
filters.category = category as ListingCategory;
}
// Price range
if (minPrice && typeof minPrice === 'string') {
filters.minPrice = parseFloat(minPrice);
}
if (maxPrice && typeof maxPrice === 'string') {
filters.maxPrice = parseFloat(maxPrice);
}
// Location
if (lat && lng && radius) {
filters.location = {
lat: parseFloat(lat as string),
lng: parseFloat(lng as string),
radiusKm: parseFloat(radius as string),
};
}
// Tags
if (tags) {
const tagArray = Array.isArray(tags) ? tags : (tags as string).split(',');
filters.tags = tagArray.filter((t): t is string => typeof t === 'string');
}
// Sorting
if (sort && typeof sort === 'string') {
filters.sortBy = sort as SearchFilters['sortBy'];
}
// Pagination
if (page && typeof page === 'string') {
filters.page = parseInt(page, 10);
}
if (limit && typeof limit === 'string') {
filters.limit = Math.min(parseInt(limit, 10), 100); // Max 100 per page
}
const results = await searchService.searchListings(filters);
// Get additional data for the response
const [popularTags, stats] = await Promise.all([
searchService.getPopularTags(10),
searchService.getMarketplaceStats(),
]);
return res.status(200).json({
...results,
meta: {
popularTags,
totalActive: stats.activeListings,
},
});
} catch (error) {
console.error('Marketplace search API error:', error);
return res.status(500).json({
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}

View file

@ -0,0 +1,282 @@
import { useState } from 'react';
import Link from 'next/link';
import Head from 'next/head';
import { useRouter } from 'next/router';
const categories = [
{ value: 'seeds', label: 'Seeds', icon: '🌰' },
{ value: 'seedlings', label: 'Seedlings', icon: '🌱' },
{ value: 'mature_plants', label: 'Mature Plants', icon: '🪴' },
{ value: 'cuttings', label: 'Cuttings', icon: '✂️' },
{ value: 'produce', label: 'Produce', icon: '🥬' },
{ value: 'supplies', label: 'Supplies', icon: '🧰' },
];
export default function CreateListingPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
title: '',
description: '',
price: '',
quantity: '1',
category: '',
tags: '',
city: '',
region: '',
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await fetch('/api/marketplace/listings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-user-id': 'demo-user',
'x-user-name': 'Demo User',
},
body: JSON.stringify({
title: formData.title,
description: formData.description,
price: parseFloat(formData.price),
quantity: parseInt(formData.quantity, 10),
category: formData.category,
tags: formData.tags.split(',').map((t) => t.trim()).filter(Boolean),
location: formData.city || formData.region ? {
city: formData.city,
region: formData.region,
} : undefined,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to create listing');
}
// Redirect to the listing page
router.push(`/marketplace/listings/${data.listing.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
<Head>
<title>Create Listing - 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-3xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Create New Listing</h1>
<p className="text-gray-600 mb-8">
Fill in the details below to list your item on the marketplace.
</p>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Title *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
required
placeholder="e.g., Organic Tomato Seedlings"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
required
rows={4}
placeholder="Describe your item in detail..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
</div>
{/* Category */}
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-1">
Category *
</label>
<select
id="category"
name="category"
value={formData.category}
onChange={handleChange}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
>
<option value="">Select a category</option>
{categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.icon} {cat.label}
</option>
))}
</select>
</div>
{/* Price and Quantity */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
Price (USD) *
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
id="price"
name="price"
value={formData.price}
onChange={handleChange}
required
min="0.01"
step="0.01"
placeholder="0.00"
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>
</div>
<div>
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
Quantity *
</label>
<input
type="number"
id="quantity"
name="quantity"
value={formData.quantity}
onChange={handleChange}
required
min="1"
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>
{/* Location */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
City
</label>
<input
type="text"
id="city"
name="city"
value={formData.city}
onChange={handleChange}
placeholder="e.g., New York"
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>
<label htmlFor="region" className="block text-sm font-medium text-gray-700 mb-1">
State/Region
</label>
<input
type="text"
id="region"
name="region"
value={formData.region}
onChange={handleChange}
placeholder="e.g., NY"
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>
{/* Tags */}
<div>
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-1">
Tags
</label>
<input
type="text"
id="tags"
name="tags"
value={formData.tags}
onChange={handleChange}
placeholder="e.g., organic, heirloom, vegetable (comma separated)"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
/>
<p className="mt-1 text-sm text-gray-500">
Add tags to help buyers find your listing. Separate with commas.
</p>
</div>
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={loading}
className="w-full py-4 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50"
>
{loading ? 'Creating Listing...' : 'Create Listing'}
</button>
</div>
</form>
<p className="mt-6 text-sm text-gray-500 text-center">
Your listing will be saved as a draft. You can publish it after reviewing.
</p>
</div>
</main>
</div>
);
}

305
pages/marketplace/index.tsx Normal file
View file

@ -0,0 +1,305 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Head from 'next/head';
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 MarketplaceStats {
totalListings: number;
activeListings: number;
totalSales: number;
topCategories: { category: string; count: number }[];
}
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: '🧰',
};
export default function MarketplacePage() {
const [featured, setFeatured] = useState<Listing[]>([]);
const [recent, setRecent] = useState<Listing[]>([]);
const [stats, setStats] = useState<MarketplaceStats | null>(null);
const [popularTags, setPopularTags] = useState<{ tag: string; count: number }[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
fetchMarketplaceData();
}, []);
const fetchMarketplaceData = async () => {
try {
const response = await fetch('/api/marketplace/featured');
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch marketplace data');
}
setFeatured(data.featured || []);
setRecent(data.recent || []);
setStats(data.stats || null);
setPopularTags(data.popularTags || []);
} catch (error) {
console.error('Error fetching marketplace data:', error);
} finally {
setLoading(false);
}
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
window.location.href = `/marketplace/listings?q=${encodeURIComponent(searchQuery)}`;
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
<Head>
<title>Plant Marketplace - LocalGreenChain</title>
<meta name="description" content="Buy and sell plants, seeds, and produce locally" />
</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="/">
<a className="text-2xl font-bold text-green-800">LocalGreenChain</a>
</Link>
<nav className="flex gap-4">
<Link href="/marketplace/my-listings">
<a className="px-4 py-2 text-green-700 hover:text-green-800 transition">
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>
{/* Hero Section */}
<section className="bg-gradient-to-r from-green-600 to-emerald-600 text-white py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Plant Marketplace
</h1>
<p className="text-xl text-green-100 mb-8">
Buy and sell plants, seeds, and produce from local growers
</p>
{/* Search Bar */}
<form onSubmit={handleSearch} className="max-w-2xl mx-auto">
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search for plants, seeds, produce..."
className="flex-1 px-6 py-4 rounded-lg text-gray-900 text-lg focus:ring-2 focus:ring-green-300 focus:outline-none"
/>
<button
type="submit"
className="px-8 py-4 bg-green-800 text-white font-semibold rounded-lg hover:bg-green-900 transition"
>
Search
</button>
</div>
</form>
{/* Stats */}
{stats && (
<div className="flex justify-center gap-8 mt-8">
<div>
<div className="text-3xl font-bold">{stats.activeListings}</div>
<div className="text-green-200">Active Listings</div>
</div>
<div>
<div className="text-3xl font-bold">{stats.totalSales}</div>
<div className="text-green-200">Completed Sales</div>
</div>
</div>
)}
</div>
</section>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
{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 marketplace...</p>
</div>
) : (
<>
{/* Categories */}
<section className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Browse Categories</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{Object.entries(categoryLabels).map(([key, label]) => (
<Link key={key} href={`/marketplace/listings?category=${key}`}>
<a className="bg-white rounded-lg p-6 text-center hover:shadow-lg transition border border-gray-200">
<div className="text-4xl mb-2">{categoryIcons[key]}</div>
<div className="font-semibold text-gray-800">{label}</div>
{stats?.topCategories && (
<div className="text-sm text-gray-500">
{stats.topCategories.find(c => c.category === key)?.count || 0} listings
</div>
)}
</a>
</Link>
))}
</div>
</section>
{/* Featured Listings */}
{featured.length > 0 && (
<section className="mb-12">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Featured Listings</h2>
<Link href="/marketplace/listings?sort=relevance">
<a className="text-green-600 hover:text-green-700">View all</a>
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{featured.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
</section>
)}
{/* Recent Listings */}
{recent.length > 0 && (
<section className="mb-12">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Recently Added</h2>
<Link href="/marketplace/listings?sort=date_desc">
<a className="text-green-600 hover:text-green-700">View all</a>
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{recent.slice(0, 6).map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
</section>
)}
{/* Popular Tags */}
{popularTags.length > 0 && (
<section className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Popular Tags</h2>
<div className="flex flex-wrap gap-2">
{popularTags.map(({ tag, count }) => (
<Link key={tag} href={`/marketplace/listings?tags=${tag}`}>
<a className="px-4 py-2 bg-white rounded-full border border-gray-200 hover:border-green-500 hover:bg-green-50 transition">
<span className="text-gray-700">{tag}</span>
<span className="ml-2 text-gray-400 text-sm">({count})</span>
</a>
</Link>
))}
</div>
</section>
)}
{/* CTA Section */}
<section className="bg-white rounded-lg shadow-lg p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Ready to Start Selling?
</h2>
<p className="text-gray-600 mb-6">
List your plants, seeds, or produce and connect with local buyers.
</p>
<Link href="/marketplace/create">
<a className="inline-block px-8 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition">
Create Your First Listing
</a>
</Link>
</section>
</>
)}
</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-48 bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
<span className="text-6xl">{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.sellerName && (
<div className="mt-2 text-sm text-gray-500">
by {listing.sellerName}
</div>
)}
{listing.tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{listing.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
</div>
</a>
</Link>
);
}

View file

@ -0,0 +1,365 @@
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>
);
}

View file

@ -0,0 +1,372 @@
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>
);
}

View file

@ -0,0 +1,319 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Head from 'next/head';
interface Listing {
id: string;
title: string;
description: string;
price: number;
currency: string;
quantity: number;
category: string;
status: string;
viewCount: number;
createdAt: string;
}
interface SellerStats {
totalListings: number;
activeListings: number;
soldListings: number;
totalViews: number;
averagePrice: number;
}
const categoryLabels: Record<string, string> = {
seeds: 'Seeds',
seedlings: 'Seedlings',
mature_plants: 'Mature Plants',
cuttings: 'Cuttings',
produce: 'Produce',
supplies: '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 MyListingsPage() {
const [listings, setListings] = useState<Listing[]>([]);
const [stats, setStats] = useState<SellerStats | null>(null);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<string>('');
useEffect(() => {
fetchMyListings();
}, [statusFilter]);
const fetchMyListings = async () => {
setLoading(true);
try {
const url = statusFilter
? `/api/marketplace/my-listings?status=${statusFilter}`
: '/api/marketplace/my-listings';
const response = await fetch(url, {
headers: {
'x-user-id': 'demo-user',
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch listings');
}
setListings(data.listings || []);
setStats(data.stats || null);
} catch (error) {
console.error('Error fetching listings:', error);
} finally {
setLoading(false);
}
};
const handlePublish = async (listingId: string) => {
try {
const response = await fetch(`/api/marketplace/listings/${listingId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-user-id': 'demo-user',
},
body: JSON.stringify({ status: 'active' }),
});
if (response.ok) {
fetchMyListings();
}
} catch (error) {
console.error('Error publishing listing:', error);
}
};
const handleCancel = async (listingId: string) => {
if (!confirm('Are you sure you want to cancel this listing?')) return;
try {
const response = await fetch(`/api/marketplace/listings/${listingId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-user-id': 'demo-user',
},
body: JSON.stringify({ status: 'cancelled' }),
});
if (response.ok) {
fetchMyListings();
}
} catch (error) {
console.error('Error cancelling listing:', error);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
<Head>
<title>My 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-offers">
<a className="text-green-600 hover:text-green-700">My Offers</a>
</Link>
<Link href="/marketplace/create">
<a className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
New Listing
</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">
<h1 className="text-3xl font-bold text-gray-900 mb-8">My Listings</h1>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-gray-900">{stats.totalListings}</div>
<div className="text-sm text-gray-500">Total Listings</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-green-600">{stats.activeListings}</div>
<div className="text-sm text-gray-500">Active</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-blue-600">{stats.soldListings}</div>
<div className="text-sm text-gray-500">Sold</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-gray-900">{stats.totalViews}</div>
<div className="text-sm text-gray-500">Total Views</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-gray-900">${stats.averagePrice}</div>
<div className="text-sm text-gray-500">Avg. Price</div>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex gap-2">
<button
onClick={() => setStatusFilter('')}
className={`px-4 py-2 rounded-lg ${
!statusFilter ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
All
</button>
<button
onClick={() => setStatusFilter('draft')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'draft' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Drafts
</button>
<button
onClick={() => setStatusFilter('active')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'active' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Active
</button>
<button
onClick={() => setStatusFilter('sold')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'sold' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Sold
</button>
</div>
</div>
{/* Listings */}
{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 your listings...</p>
</div>
) : 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 Yet</h2>
<p className="text-gray-600 mb-6">
Create your first listing to start selling on the marketplace.
</p>
<Link href="/marketplace/create">
<a className="inline-block px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition">
Create Listing
</a>
</Link>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Listing
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Views
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{listings.map((listing) => (
<tr key={listing.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<Link href={`/marketplace/listings/${listing.id}`}>
<a className="text-gray-900 font-medium hover:text-green-600">
{listing.title}
</a>
</Link>
<div className="text-sm text-gray-500">
{new Date(listing.createdAt).toLocaleDateString()}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{categoryLabels[listing.category]}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
${listing.price.toFixed(2)}
<div className="text-xs text-gray-500">Qty: {listing.quantity}</div>
</td>
<td className="px-6 py-4">
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${statusColors[listing.status]}`}>
{listing.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{listing.viewCount}
</td>
<td className="px-6 py-4 text-right text-sm">
<div className="flex gap-2 justify-end">
{listing.status === 'draft' && (
<button
onClick={() => handlePublish(listing.id)}
className="text-green-600 hover:text-green-700"
>
Publish
</button>
)}
{listing.status === 'active' && (
<button
onClick={() => handleCancel(listing.id)}
className="text-red-600 hover:text-red-700"
>
Cancel
</button>
)}
<Link href={`/marketplace/listings/${listing.id}`}>
<a className="text-gray-600 hover:text-gray-700">View</a>
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,305 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Head from 'next/head';
interface Offer {
id: string;
listingId: string;
amount: number;
message?: string;
status: string;
createdAt: string;
listing?: {
id: string;
title: string;
price: number;
sellerId: string;
sellerName?: string;
};
}
interface OfferStats {
totalOffers: number;
pendingOffers: number;
acceptedOffers: number;
rejectedOffers: number;
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
withdrawn: 'bg-gray-100 text-gray-800',
expired: 'bg-gray-100 text-gray-800',
};
export default function MyOffersPage() {
const [offers, setOffers] = useState<Offer[]>([]);
const [stats, setStats] = useState<OfferStats | null>(null);
const [loading, setLoading] = useState(true);
const [role, setRole] = useState<'buyer' | 'seller'>('buyer');
const [statusFilter, setStatusFilter] = useState<string>('');
useEffect(() => {
fetchOffers();
}, [role, statusFilter]);
const fetchOffers = async () => {
setLoading(true);
try {
let url = `/api/marketplace/my-offers?role=${role}`;
if (statusFilter) {
url += `&status=${statusFilter}`;
}
const response = await fetch(url, {
headers: {
'x-user-id': role === 'buyer' ? 'demo-buyer' : 'demo-user',
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch offers');
}
setOffers(data.offers || []);
setStats(data.stats || null);
} catch (error) {
console.error('Error fetching offers:', error);
} finally {
setLoading(false);
}
};
const handleOfferAction = async (offerId: string, action: string) => {
if (action === 'withdraw' && !confirm('Are you sure you want to withdraw this offer?')) return;
try {
const response = await fetch(`/api/marketplace/offers/${offerId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-user-id': role === 'buyer' ? 'demo-buyer' : 'demo-user',
},
body: JSON.stringify({ action }),
});
if (response.ok) {
fetchOffers();
} else {
const data = await response.json();
alert(data.error || 'Failed to update offer');
}
} catch (error) {
console.error('Error updating offer:', error);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
<Head>
<title>My Offers - 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">
<a className="text-green-600 hover:text-green-700">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">
<h1 className="text-3xl font-bold text-gray-900 mb-8">My Offers</h1>
{/* Role Toggle */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex gap-4 items-center">
<span className="text-gray-600">View as:</span>
<button
onClick={() => setRole('buyer')}
className={`px-4 py-2 rounded-lg ${
role === 'buyer' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Buyer (Offers I Made)
</button>
<button
onClick={() => setRole('seller')}
className={`px-4 py-2 rounded-lg ${
role === 'seller' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Seller (Offers Received)
</button>
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-gray-900">{stats.totalOffers}</div>
<div className="text-sm text-gray-500">Total Offers</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-yellow-600">{stats.pendingOffers}</div>
<div className="text-sm text-gray-500">Pending</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-green-600">{stats.acceptedOffers}</div>
<div className="text-sm text-gray-500">Accepted</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-red-600">{stats.rejectedOffers}</div>
<div className="text-sm text-gray-500">Rejected</div>
</div>
</div>
)}
{/* Status Filter */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex gap-2">
<button
onClick={() => setStatusFilter('')}
className={`px-4 py-2 rounded-lg ${
!statusFilter ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
All
</button>
<button
onClick={() => setStatusFilter('pending')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'pending' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Pending
</button>
<button
onClick={() => setStatusFilter('accepted')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'accepted' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Accepted
</button>
<button
onClick={() => setStatusFilter('rejected')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'rejected' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Rejected
</button>
</div>
</div>
{/* Offers List */}
{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 offers...</p>
</div>
) : offers.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 Offers Yet</h2>
<p className="text-gray-600 mb-6">
{role === 'buyer'
? 'Browse the marketplace and make offers on items you like.'
: 'When buyers make offers on your listings, they will appear here.'}
</p>
<Link href="/marketplace">
<a className="inline-block px-6 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition">
Browse Marketplace
</a>
</Link>
</div>
) : (
<div className="space-y-4">
{offers.map((offer) => (
<div key={offer.id} className="bg-white rounded-lg shadow p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
{offer.listing && (
<Link href={`/marketplace/listings/${offer.listing.id}`}>
<a className="text-lg font-semibold text-gray-900 hover:text-green-600">
{offer.listing.title}
</a>
</Link>
)}
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
<span>
Listing price: ${offer.listing?.price.toFixed(2)}
</span>
<span>|</span>
<span>
{new Date(offer.createdAt).toLocaleDateString()}
</span>
</div>
{offer.message && (
<p className="mt-3 text-gray-600 bg-gray-50 p-3 rounded">
"{offer.message}"
</p>
)}
</div>
<div className="text-right ml-6">
<div className="text-2xl font-bold text-green-600">
${offer.amount.toFixed(2)}
</div>
<span className={`inline-block px-3 py-1 rounded-full text-sm font-medium mt-2 ${statusColors[offer.status]}`}>
{offer.status}
</span>
</div>
</div>
{/* Actions */}
{offer.status === 'pending' && (
<div className="flex gap-3 mt-4 pt-4 border-t">
{role === 'buyer' ? (
<button
onClick={() => handleOfferAction(offer.id, 'withdraw')}
className="px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition"
>
Withdraw Offer
</button>
) : (
<>
<button
onClick={() => handleOfferAction(offer.id, 'accept')}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Accept Offer
</button>
<button
onClick={() => handleOfferAction(offer.id, 'reject')}
className="px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition"
>
Reject
</button>
</>
)}
</div>
)}
</div>
))}
</div>
)}
</main>
</div>
);
}