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