Transport components: - TransportTimeline: Visual timeline of transport events with status badges - JourneyMap: SVG-based map visualization of plant journey locations - CarbonFootprintCard: Carbon metrics display with comparison charts - QRCodeDisplay: QR code generation for traceability verification - TransportEventForm: Form for recording transport events Demand components: - DemandSignalCard: Regional demand signal with supply status indicators - PreferencesForm: Multi-section consumer preference input form - RecommendationList: Planting recommendations with risk assessment - SupplyGapChart: Supply vs demand visualization with gap indicators - SeasonalCalendar: Seasonal produce availability calendar view Analytics components: - EnvironmentalImpact: Comprehensive carbon and food miles analysis - FoodMilesTracker: Food miles tracking with daily charts and targets - SavingsCalculator: Environmental savings vs conventional agriculture All components follow existing patterns, use Tailwind CSS, and are fully typed.
465 lines
18 KiB
TypeScript
465 lines
18 KiB
TypeScript
import { useState } from 'react';
|
|
import { ConsumerPreference, ProduceCategory, ProducePreference } from '../../lib/demand/types';
|
|
|
|
interface PreferencesFormProps {
|
|
initialPreferences?: Partial<ConsumerPreference>;
|
|
onSubmit: (preferences: Partial<ConsumerPreference>) => void;
|
|
loading?: boolean;
|
|
}
|
|
|
|
const DIETARY_TYPES = [
|
|
{ value: 'omnivore', label: 'Omnivore' },
|
|
{ value: 'vegetarian', label: 'Vegetarian' },
|
|
{ value: 'vegan', label: 'Vegan' },
|
|
{ value: 'pescatarian', label: 'Pescatarian' },
|
|
{ value: 'flexitarian', label: 'Flexitarian' },
|
|
] as const;
|
|
|
|
const PRODUCE_CATEGORIES: { value: ProduceCategory; label: string; icon: string }[] = [
|
|
{ value: 'leafy_greens', label: 'Leafy Greens', icon: '🥬' },
|
|
{ value: 'root_vegetables', label: 'Root Vegetables', icon: '🥕' },
|
|
{ value: 'nightshades', label: 'Nightshades', icon: '🍅' },
|
|
{ value: 'brassicas', label: 'Brassicas', icon: '🥦' },
|
|
{ value: 'alliums', label: 'Alliums', icon: '🧅' },
|
|
{ value: 'legumes', label: 'Legumes', icon: '🫘' },
|
|
{ value: 'squash', label: 'Squash', icon: '🎃' },
|
|
{ value: 'herbs', label: 'Herbs', icon: '🌿' },
|
|
{ value: 'microgreens', label: 'Microgreens', icon: '🌱' },
|
|
{ value: 'sprouts', label: 'Sprouts', icon: '🌾' },
|
|
{ value: 'mushrooms', label: 'Mushrooms', icon: '🍄' },
|
|
{ value: 'fruits', label: 'Fruits', icon: '🍎' },
|
|
{ value: 'berries', label: 'Berries', icon: '🍓' },
|
|
{ value: 'citrus', label: 'Citrus', icon: '🍊' },
|
|
{ value: 'tree_fruits', label: 'Tree Fruits', icon: '🍑' },
|
|
{ value: 'melons', label: 'Melons', icon: '🍈' },
|
|
{ value: 'edible_flowers', label: 'Edible Flowers', icon: '🌸' },
|
|
];
|
|
|
|
const CERTIFICATIONS = [
|
|
{ value: 'organic', label: 'Organic' },
|
|
{ value: 'non_gmo', label: 'Non-GMO' },
|
|
{ value: 'biodynamic', label: 'Biodynamic' },
|
|
{ value: 'local', label: 'Local' },
|
|
{ value: 'heirloom', label: 'Heirloom' },
|
|
] as const;
|
|
|
|
const DELIVERY_METHODS = [
|
|
{ value: 'home_delivery', label: 'Home Delivery' },
|
|
{ value: 'pickup_point', label: 'Pickup Point' },
|
|
{ value: 'farmers_market', label: 'Farmers Market' },
|
|
{ value: 'csa', label: 'CSA Box' },
|
|
] as const;
|
|
|
|
const DELIVERY_FREQUENCIES = [
|
|
{ value: 'daily', label: 'Daily' },
|
|
{ value: 'twice_weekly', label: 'Twice Weekly' },
|
|
{ value: 'weekly', label: 'Weekly' },
|
|
{ value: 'bi_weekly', label: 'Bi-Weekly' },
|
|
{ value: 'monthly', label: 'Monthly' },
|
|
] as const;
|
|
|
|
export default function PreferencesForm({
|
|
initialPreferences,
|
|
onSubmit,
|
|
loading = false,
|
|
}: PreferencesFormProps) {
|
|
const [activeSection, setActiveSection] = useState<string>('dietary');
|
|
|
|
const [dietaryType, setDietaryType] = useState<string[]>(initialPreferences?.dietaryType || ['omnivore']);
|
|
const [allergies, setAllergies] = useState<string>(initialPreferences?.allergies?.join(', ') || '');
|
|
const [dislikes, setDislikes] = useState<string>(initialPreferences?.dislikes?.join(', ') || '');
|
|
|
|
const [preferredCategories, setPreferredCategories] = useState<ProduceCategory[]>(
|
|
initialPreferences?.preferredCategories || []
|
|
);
|
|
const [certifications, setCertifications] = useState<string[]>(
|
|
initialPreferences?.certificationPreferences || []
|
|
);
|
|
|
|
const [freshnessImportance, setFreshnessImportance] = useState<number>(
|
|
initialPreferences?.freshnessImportance || 4
|
|
);
|
|
const [priceImportance, setPriceImportance] = useState<number>(
|
|
initialPreferences?.priceImportance || 3
|
|
);
|
|
const [sustainabilityImportance, setSustainabilityImportance] = useState<number>(
|
|
initialPreferences?.sustainabilityImportance || 4
|
|
);
|
|
|
|
const [deliveryMethods, setDeliveryMethods] = useState<string[]>(
|
|
initialPreferences?.deliveryPreferences?.method || ['home_delivery']
|
|
);
|
|
const [deliveryFrequency, setDeliveryFrequency] = useState<string>(
|
|
initialPreferences?.deliveryPreferences?.frequency || 'weekly'
|
|
);
|
|
|
|
const [householdSize, setHouseholdSize] = useState<number>(
|
|
initialPreferences?.householdSize || 2
|
|
);
|
|
const [weeklyBudget, setWeeklyBudget] = useState<number | undefined>(
|
|
initialPreferences?.weeklyBudget
|
|
);
|
|
|
|
const [maxDeliveryRadius, setMaxDeliveryRadius] = useState<number>(
|
|
initialPreferences?.location?.maxDeliveryRadiusKm || 25
|
|
);
|
|
|
|
const toggleCategory = (category: ProduceCategory) => {
|
|
setPreferredCategories((prev) =>
|
|
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
|
|
);
|
|
};
|
|
|
|
const toggleCertification = (cert: string) => {
|
|
setCertifications((prev) =>
|
|
prev.includes(cert) ? prev.filter((c) => c !== cert) : [...prev, cert]
|
|
);
|
|
};
|
|
|
|
const toggleDeliveryMethod = (method: string) => {
|
|
setDeliveryMethods((prev) =>
|
|
prev.includes(method) ? prev.filter((m) => m !== method) : [...prev, method]
|
|
);
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
const preferences: Partial<ConsumerPreference> = {
|
|
dietaryType: dietaryType as ConsumerPreference['dietaryType'],
|
|
allergies: allergies.split(',').map((a) => a.trim()).filter(Boolean),
|
|
dislikes: dislikes.split(',').map((d) => d.trim()).filter(Boolean),
|
|
preferredCategories,
|
|
certificationPreferences: certifications as ConsumerPreference['certificationPreferences'],
|
|
freshnessImportance: freshnessImportance as 1 | 2 | 3 | 4 | 5,
|
|
priceImportance: priceImportance as 1 | 2 | 3 | 4 | 5,
|
|
sustainabilityImportance: sustainabilityImportance as 1 | 2 | 3 | 4 | 5,
|
|
deliveryPreferences: {
|
|
method: deliveryMethods as ConsumerPreference['deliveryPreferences']['method'],
|
|
frequency: deliveryFrequency as ConsumerPreference['deliveryPreferences']['frequency'],
|
|
preferredDays: ['saturday'], // Default
|
|
},
|
|
householdSize,
|
|
weeklyBudget,
|
|
location: {
|
|
latitude: 0,
|
|
longitude: 0,
|
|
maxDeliveryRadiusKm: maxDeliveryRadius,
|
|
},
|
|
};
|
|
|
|
onSubmit(preferences);
|
|
};
|
|
|
|
const sections = [
|
|
{ id: 'dietary', name: 'Diet', icon: '🥗' },
|
|
{ id: 'produce', name: 'Produce', icon: '🥬' },
|
|
{ id: 'quality', name: 'Quality', icon: '⭐' },
|
|
{ id: 'delivery', name: 'Delivery', icon: '📦' },
|
|
{ id: 'household', name: 'Household', icon: '🏠' },
|
|
];
|
|
|
|
const ImportanceSlider = ({
|
|
label,
|
|
value,
|
|
onChange,
|
|
helpText,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
onChange: (value: number) => void;
|
|
helpText: string;
|
|
}) => (
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<label className="text-sm font-medium text-gray-700">{label}</label>
|
|
<span className="text-sm text-gray-500">{value}/5</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="5"
|
|
value={value}
|
|
onChange={(e) => onChange(parseInt(e.target.value))}
|
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
|
|
/>
|
|
<p className="text-xs text-gray-500">{helpText}</p>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-lg">
|
|
{/* Section Tabs */}
|
|
<div className="flex overflow-x-auto border-b border-gray-200 bg-gray-50">
|
|
{sections.map((section) => (
|
|
<button
|
|
key={section.id}
|
|
type="button"
|
|
onClick={() => setActiveSection(section.id)}
|
|
className={`px-4 py-3 text-sm font-medium whitespace-nowrap transition ${
|
|
activeSection === section.id
|
|
? 'border-b-2 border-green-600 text-green-600 bg-white'
|
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
|
}`}
|
|
>
|
|
<span className="mr-2">{section.icon}</span>
|
|
{section.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
{/* Dietary Section */}
|
|
{activeSection === 'dietary' && (
|
|
<div className="space-y-6">
|
|
<h3 className="text-lg font-semibold text-gray-900">Dietary Preferences</h3>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">Dietary Type</label>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
{DIETARY_TYPES.map((type) => (
|
|
<button
|
|
key={type.value}
|
|
type="button"
|
|
onClick={() =>
|
|
setDietaryType((prev) =>
|
|
prev.includes(type.value)
|
|
? prev.filter((t) => t !== type.value)
|
|
: [...prev, type.value]
|
|
)
|
|
}
|
|
className={`p-2 rounded-lg border-2 text-sm font-medium transition ${
|
|
dietaryType.includes(type.value)
|
|
? 'border-green-500 bg-green-50 text-green-700'
|
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
|
}`}
|
|
>
|
|
{type.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Allergies (comma-separated)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={allergies}
|
|
onChange={(e) => setAllergies(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
|
placeholder="e.g., nuts, shellfish, gluten"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Dislikes (comma-separated)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={dislikes}
|
|
onChange={(e) => setDislikes(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
|
placeholder="e.g., cilantro, mushrooms"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Produce Section */}
|
|
{activeSection === 'produce' && (
|
|
<div className="space-y-6">
|
|
<h3 className="text-lg font-semibold text-gray-900">Produce Preferences</h3>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
Preferred Categories ({preferredCategories.length} selected)
|
|
</label>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
{PRODUCE_CATEGORIES.map((category) => (
|
|
<button
|
|
key={category.value}
|
|
type="button"
|
|
onClick={() => toggleCategory(category.value)}
|
|
className={`p-3 rounded-lg border-2 text-sm font-medium transition flex items-center gap-2 ${
|
|
preferredCategories.includes(category.value)
|
|
? 'border-green-500 bg-green-50 text-green-700'
|
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
|
}`}
|
|
>
|
|
<span>{category.icon}</span>
|
|
<span className="text-xs">{category.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quality Section */}
|
|
{activeSection === 'quality' && (
|
|
<div className="space-y-6">
|
|
<h3 className="text-lg font-semibold text-gray-900">Quality Preferences</h3>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
Preferred Certifications
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{CERTIFICATIONS.map((cert) => (
|
|
<button
|
|
key={cert.value}
|
|
type="button"
|
|
onClick={() => toggleCertification(cert.value)}
|
|
className={`px-4 py-2 rounded-full border-2 text-sm font-medium transition ${
|
|
certifications.includes(cert.value)
|
|
? 'border-green-500 bg-green-50 text-green-700'
|
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
|
}`}
|
|
>
|
|
{cert.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 pt-4">
|
|
<ImportanceSlider
|
|
label="Freshness Importance"
|
|
value={freshnessImportance}
|
|
onChange={setFreshnessImportance}
|
|
helpText="How important is maximum freshness?"
|
|
/>
|
|
<ImportanceSlider
|
|
label="Price Importance"
|
|
value={priceImportance}
|
|
onChange={setPriceImportance}
|
|
helpText="How price-sensitive are you?"
|
|
/>
|
|
<ImportanceSlider
|
|
label="Sustainability Importance"
|
|
value={sustainabilityImportance}
|
|
onChange={setSustainabilityImportance}
|
|
helpText="How important is environmental impact?"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delivery Section */}
|
|
{activeSection === 'delivery' && (
|
|
<div className="space-y-6">
|
|
<h3 className="text-lg font-semibold text-gray-900">Delivery Preferences</h3>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
Preferred Delivery Methods
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{DELIVERY_METHODS.map((method) => (
|
|
<button
|
|
key={method.value}
|
|
type="button"
|
|
onClick={() => toggleDeliveryMethod(method.value)}
|
|
className={`p-3 rounded-lg border-2 text-sm font-medium transition ${
|
|
deliveryMethods.includes(method.value)
|
|
? 'border-green-500 bg-green-50 text-green-700'
|
|
: 'border-gray-200 hover:border-gray-300 text-gray-600'
|
|
}`}
|
|
>
|
|
{method.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Delivery Frequency</label>
|
|
<select
|
|
value={deliveryFrequency}
|
|
onChange={(e) => setDeliveryFrequency(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
|
>
|
|
{DELIVERY_FREQUENCIES.map((freq) => (
|
|
<option key={freq.value} value={freq.value}>
|
|
{freq.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Max Delivery Radius: {maxDeliveryRadius} km
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="5"
|
|
max="100"
|
|
value={maxDeliveryRadius}
|
|
onChange={(e) => setMaxDeliveryRadius(parseInt(e.target.value))}
|
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Shorter radius = fresher produce, fewer food miles
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Household Section */}
|
|
{activeSection === 'household' && (
|
|
<div className="space-y-6">
|
|
<h3 className="text-lg font-semibold text-gray-900">Household Info</h3>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Household Size: {householdSize} {householdSize === 1 ? 'person' : 'people'}
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="10"
|
|
value={householdSize}
|
|
onChange={(e) => setHouseholdSize(parseInt(e.target.value))}
|
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-green-600"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Weekly Budget (optional)
|
|
</label>
|
|
<div className="relative">
|
|
<span className="absolute left-3 top-2 text-gray-500">$</span>
|
|
<input
|
|
type="number"
|
|
value={weeklyBudget || ''}
|
|
onChange={(e) => setWeeklyBudget(e.target.value ? parseInt(e.target.value) : undefined)}
|
|
className="w-full pl-8 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
|
|
placeholder="e.g., 100"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Helps us recommend produce within your budget
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit Button */}
|
|
<div className="mt-6 pt-4 border-t border-gray-200">
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="w-full py-3 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{loading ? 'Saving...' : 'Save Preferences'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|