Implement marketplace foundation (Agent 9)
Add comprehensive plant trading marketplace with: - Prisma schema with marketplace models (Listing, Offer, SellerProfile, WishlistItem) - Service layer for listings, offers, search, and matching - API endpoints for CRUD operations, search, and recommendations - Marketplace pages: home, listing detail, create, my-listings, my-offers - Reusable UI components: ListingCard, ListingGrid, OfferForm, SearchFilters, etc. Features: - Browse and search listings by category, price, tags - Create and manage listings (draft, active, sold, cancelled) - Make and manage offers on listings - Seller and buyer views with statistics - Featured and recommended listings - In-memory store (ready for database migration via Agent 2)
This commit is contained in:
parent
705105d9b6
commit
b3c2af51bf
31 changed files with 5678 additions and 0 deletions
149
components/marketplace/ListingCard.tsx
Normal file
149
components/marketplace/ListingCard.tsx
Normal 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;
|
||||||
290
components/marketplace/ListingForm.tsx
Normal file
290
components/marketplace/ListingForm.tsx
Normal 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;
|
||||||
64
components/marketplace/ListingGrid.tsx
Normal file
64
components/marketplace/ListingGrid.tsx
Normal 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;
|
||||||
144
components/marketplace/OfferForm.tsx
Normal file
144
components/marketplace/OfferForm.tsx
Normal 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;
|
||||||
163
components/marketplace/OfferList.tsx
Normal file
163
components/marketplace/OfferList.tsx
Normal 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;
|
||||||
96
components/marketplace/PriceDisplay.tsx
Normal file
96
components/marketplace/PriceDisplay.tsx
Normal 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;
|
||||||
219
components/marketplace/SearchFilters.tsx
Normal file
219
components/marketplace/SearchFilters.tsx
Normal 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;
|
||||||
8
components/marketplace/index.ts
Normal file
8
components/marketplace/index.ts
Normal 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
15
lib/marketplace/index.ts
Normal 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';
|
||||||
233
lib/marketplace/listingService.ts
Normal file
233
lib/marketplace/listingService.ts
Normal 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();
|
||||||
230
lib/marketplace/matchingService.ts
Normal file
230
lib/marketplace/matchingService.ts
Normal 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();
|
||||||
291
lib/marketplace/offerService.ts
Normal file
291
lib/marketplace/offerService.ts
Normal 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();
|
||||||
285
lib/marketplace/searchService.ts
Normal file
285
lib/marketplace/searchService.ts
Normal 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
273
lib/marketplace/store.ts
Normal 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
167
lib/marketplace/types.ts
Normal 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 }[];
|
||||||
|
}
|
||||||
56
pages/api/marketplace/featured.ts
Normal file
56
pages/api/marketplace/featured.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
116
pages/api/marketplace/listings/[id]/index.ts
Normal file
116
pages/api/marketplace/listings/[id]/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
pages/api/marketplace/listings/[id]/offers.ts
Normal file
95
pages/api/marketplace/listings/[id]/offers.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
pages/api/marketplace/listings/index.ts
Normal file
122
pages/api/marketplace/listings/index.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
51
pages/api/marketplace/my-listings.ts
Normal file
51
pages/api/marketplace/my-listings.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
71
pages/api/marketplace/my-offers.ts
Normal file
71
pages/api/marketplace/my-offers.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
117
pages/api/marketplace/offers/[id].ts
Normal file
117
pages/api/marketplace/offers/[id].ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
pages/api/marketplace/recommendations.ts
Normal file
89
pages/api/marketplace/recommendations.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
102
pages/api/marketplace/search.ts
Normal file
102
pages/api/marketplace/search.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
282
pages/marketplace/create.tsx
Normal file
282
pages/marketplace/create.tsx
Normal 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
305
pages/marketplace/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
365
pages/marketplace/listings/[id].tsx
Normal file
365
pages/marketplace/listings/[id].tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
372
pages/marketplace/listings/index.tsx
Normal file
372
pages/marketplace/listings/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
pages/marketplace/my-listings.tsx
Normal file
319
pages/marketplace/my-listings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
pages/marketplace/my-offers.tsx
Normal file
305
pages/marketplace/my-offers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
prisma/schema.prisma
Normal file
284
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
// Prisma Schema for LocalGreenChain
|
||||||
|
// Note: This requires Agent 2 (Database) to set up the database connection
|
||||||
|
// For now, marketplace uses in-memory storage in lib/marketplace/store.ts
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// User & Authentication Models (Agent 1)
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
USER
|
||||||
|
GROWER
|
||||||
|
SELLER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
passwordHash String?
|
||||||
|
name String?
|
||||||
|
image String?
|
||||||
|
role Role @default(USER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
plants Plant[]
|
||||||
|
farms VerticalFarm[]
|
||||||
|
listings Listing[]
|
||||||
|
offers Offer[]
|
||||||
|
wishlistItems WishlistItem[]
|
||||||
|
sellerProfile SellerProfile?
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Plant & Blockchain Models
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
model Plant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
species String
|
||||||
|
variety String?
|
||||||
|
parentId String?
|
||||||
|
generation Int @default(1)
|
||||||
|
registeredAt DateTime @default(now())
|
||||||
|
ownerId String
|
||||||
|
locationLat Float?
|
||||||
|
locationLng Float?
|
||||||
|
blockHash String?
|
||||||
|
|
||||||
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
|
parent Plant? @relation("PlantLineage", fields: [parentId], references: [id])
|
||||||
|
children Plant[] @relation("PlantLineage")
|
||||||
|
transportEvents TransportEvent[]
|
||||||
|
environmentRecords EnvironmentRecord[]
|
||||||
|
listings Listing[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model TransportEvent {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
plantId String
|
||||||
|
eventType String
|
||||||
|
fromLat Float?
|
||||||
|
fromLng Float?
|
||||||
|
toLat Float?
|
||||||
|
toLng Float?
|
||||||
|
distance Float?
|
||||||
|
carbonKg Float?
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
|
plant Plant @relation(fields: [plantId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model EnvironmentRecord {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
plantId String
|
||||||
|
temperature Float?
|
||||||
|
humidity Float?
|
||||||
|
light Float?
|
||||||
|
soilMoisture Float?
|
||||||
|
recordedAt DateTime @default(now())
|
||||||
|
|
||||||
|
plant Plant @relation(fields: [plantId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Vertical Farm Models
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
model VerticalFarm {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
ownerId String
|
||||||
|
totalArea Float
|
||||||
|
zones Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
|
farmZones FarmZone[]
|
||||||
|
batches CropBatch[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model FarmZone {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
farmId String
|
||||||
|
name String
|
||||||
|
area Float
|
||||||
|
lightType String
|
||||||
|
status String
|
||||||
|
|
||||||
|
farm VerticalFarm @relation(fields: [farmId], references: [id])
|
||||||
|
batches CropBatch[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CropBatch {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
farmId String
|
||||||
|
zoneId String
|
||||||
|
cropType String
|
||||||
|
plantedAt DateTime @default(now())
|
||||||
|
harvestedAt DateTime?
|
||||||
|
status String
|
||||||
|
|
||||||
|
farm VerticalFarm @relation(fields: [farmId], references: [id])
|
||||||
|
zone FarmZone @relation(fields: [zoneId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Marketplace Models (Agent 9)
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
enum ListingCategory {
|
||||||
|
seeds
|
||||||
|
seedlings
|
||||||
|
mature_plants
|
||||||
|
cuttings
|
||||||
|
produce
|
||||||
|
supplies
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ListingStatus {
|
||||||
|
draft
|
||||||
|
active
|
||||||
|
sold
|
||||||
|
expired
|
||||||
|
cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OfferStatus {
|
||||||
|
pending
|
||||||
|
accepted
|
||||||
|
rejected
|
||||||
|
withdrawn
|
||||||
|
expired
|
||||||
|
}
|
||||||
|
|
||||||
|
model Listing {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sellerId String
|
||||||
|
plantId String?
|
||||||
|
title String
|
||||||
|
description String @db.Text
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
currency String @default("USD")
|
||||||
|
quantity Int
|
||||||
|
category ListingCategory
|
||||||
|
status ListingStatus @default(draft)
|
||||||
|
locationLat Float?
|
||||||
|
locationLng Float?
|
||||||
|
locationCity String?
|
||||||
|
locationRegion String?
|
||||||
|
tags String[]
|
||||||
|
viewCount Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
expiresAt DateTime?
|
||||||
|
|
||||||
|
seller User @relation(fields: [sellerId], references: [id])
|
||||||
|
plant Plant? @relation(fields: [plantId], references: [id])
|
||||||
|
offers Offer[]
|
||||||
|
images ListingImage[]
|
||||||
|
wishlistItems WishlistItem[]
|
||||||
|
|
||||||
|
@@index([sellerId])
|
||||||
|
@@index([category])
|
||||||
|
@@index([status])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ListingImage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listingId String
|
||||||
|
url String
|
||||||
|
alt String?
|
||||||
|
isPrimary Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([listingId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Offer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
listingId String
|
||||||
|
buyerId String
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
message String? @db.Text
|
||||||
|
status OfferStatus @default(pending)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
expiresAt DateTime?
|
||||||
|
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id])
|
||||||
|
buyer User @relation(fields: [buyerId], references: [id])
|
||||||
|
|
||||||
|
@@index([listingId])
|
||||||
|
@@index([buyerId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SellerProfile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
displayName String
|
||||||
|
bio String? @db.Text
|
||||||
|
locationCity String?
|
||||||
|
locationRegion String?
|
||||||
|
rating Float @default(0)
|
||||||
|
reviewCount Int @default(0)
|
||||||
|
totalSales Int @default(0)
|
||||||
|
verified Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WishlistItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
listingId String
|
||||||
|
addedAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, listingId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Transparency & Audit Models
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String?
|
||||||
|
action String
|
||||||
|
entityType String
|
||||||
|
entityId String
|
||||||
|
details Json?
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@index([entityType, entityId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue