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)
290 lines
9.8 KiB
TypeScript
290 lines
9.8 KiB
TypeScript
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;
|