localgreenchain/components/demand/PreferencesForm.tsx
Claude 0cce5e2345
Add UI components for transport tracking, demand visualization, and analytics
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.
2025-11-22 18:34:51 +00:00

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>
);
}