localgreenchain/components/marketplace/ListingForm.tsx
Claude b3c2af51bf
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)
2025-11-23 03:58:08 +00:00

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;