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)
144 lines
4.3 KiB
TypeScript
144 lines
4.3 KiB
TypeScript
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;
|