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.
This commit is contained in:
Claude 2025-11-22 18:34:51 +00:00
parent b8a3ebb823
commit 0cce5e2345
No known key found for this signature in database
16 changed files with 3266 additions and 0 deletions

View file

@ -0,0 +1,272 @@
import { EnvironmentalImpact as ImpactData, TransportEventType, TransportMethod } from '../../lib/transport/types';
interface EnvironmentalImpactProps {
impact: ImpactData;
title?: string;
showDetails?: boolean;
}
const METHOD_LABELS: Record<TransportMethod, string> = {
walking: 'Walking',
bicycle: 'Bicycle',
electric_vehicle: 'Electric Vehicle',
hybrid_vehicle: 'Hybrid Vehicle',
gasoline_vehicle: 'Gas Vehicle',
diesel_truck: 'Diesel Truck',
electric_truck: 'Electric Truck',
refrigerated_truck: 'Refrigerated Truck',
rail: 'Rail',
ship: 'Ship',
air: 'Air Freight',
drone: 'Drone',
local_delivery: 'Local Delivery',
customer_pickup: 'Customer Pickup',
};
const EVENT_LABELS: Record<TransportEventType, string> = {
seed_acquisition: 'Seed Acquisition',
planting: 'Planting',
growing_transport: 'Growing Transport',
harvest: 'Harvest',
processing: 'Processing',
distribution: 'Distribution',
consumer_delivery: 'Consumer Delivery',
seed_saving: 'Seed Saving',
seed_sharing: 'Seed Sharing',
};
function getCarbonRating(carbon: number): { label: string; color: string; bgColor: string } {
if (carbon < 0.5) return { label: 'Excellent', color: 'text-green-600', bgColor: 'bg-green-100' };
if (carbon < 1) return { label: 'Very Good', color: 'text-emerald-600', bgColor: 'bg-emerald-100' };
if (carbon < 2) return { label: 'Good', color: 'text-lime-600', bgColor: 'bg-lime-100' };
if (carbon < 5) return { label: 'Moderate', color: 'text-yellow-600', bgColor: 'bg-yellow-100' };
if (carbon < 10) return { label: 'High', color: 'text-orange-600', bgColor: 'bg-orange-100' };
return { label: 'Very High', color: 'text-red-600', bgColor: 'bg-red-100' };
}
export default function EnvironmentalImpact({
impact,
title = 'Environmental Impact Report',
showDetails = true,
}: EnvironmentalImpactProps) {
const rating = getCarbonRating(impact.totalCarbonKg);
// Sort methods by carbon impact
const sortedMethods = Object.entries(impact.breakdownByMethod)
.filter(([_, data]) => data.carbon > 0 || data.distance > 0)
.sort((a, b) => b[1].carbon - a[1].carbon);
// Sort events by count
const sortedEvents = Object.entries(impact.breakdownByEventType)
.filter(([_, data]) => data.count > 0)
.sort((a, b) => b[1].count - a[1].count);
const totalMethods = sortedMethods.length;
const totalEvents = sortedEvents.reduce((sum, [_, data]) => sum + data.count, 0);
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-teal-600 to-cyan-600 text-white p-6">
<h3 className="text-xl font-bold">{title}</h3>
<p className="text-teal-200 text-sm mt-1">Complete carbon and food miles analysis</p>
</div>
<div className="p-6 space-y-6">
{/* Main metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-green-50 rounded-lg">
<p className="text-3xl font-bold text-green-600">{impact.totalCarbonKg.toFixed(2)}</p>
<p className="text-sm text-gray-500">Total Carbon (kg CO2)</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-3xl font-bold text-blue-600">{impact.totalFoodMiles.toFixed(1)}</p>
<p className="text-sm text-gray-500">Total Food Miles (km)</p>
</div>
<div className="text-center p-4 bg-purple-50 rounded-lg">
<p className="text-3xl font-bold text-purple-600">{impact.carbonPerKgProduce.toFixed(3)}</p>
<p className="text-sm text-gray-500">kg CO2 / kg Produce</p>
</div>
<div className="text-center p-4 bg-orange-50 rounded-lg">
<p className="text-3xl font-bold text-orange-600">{impact.milesPerKgProduce.toFixed(1)}</p>
<p className="text-sm text-gray-500">Miles / kg Produce</p>
</div>
</div>
{/* Rating badge */}
<div className="flex justify-center">
<div className={`px-6 py-3 rounded-full ${rating.bgColor}`}>
<span className={`text-lg font-bold ${rating.color}`}>
Environmental Rating: {rating.label}
</span>
</div>
</div>
{/* Comparison with conventional */}
{impact.comparisonToConventional && (
<div className="p-6 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
<h4 className="text-lg font-bold text-gray-900 mb-4">
Comparison vs Conventional Agriculture
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="w-24 h-24 mx-auto mb-3 rounded-full bg-green-100 flex items-center justify-center">
<span className="text-3xl font-bold text-green-600">
{impact.comparisonToConventional.percentageReduction.toFixed(0)}%
</span>
</div>
<p className="text-sm text-gray-600">Carbon Reduction</p>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-green-600">
{impact.comparisonToConventional.carbonSaved.toFixed(1)}
</p>
<p className="text-sm text-gray-500">kg CO2 Saved</p>
<div className="mt-2 flex items-center justify-center gap-1 text-green-600">
<span>🌲</span>
<span className="text-xs">
= {(impact.comparisonToConventional.carbonSaved / 21).toFixed(1)} trees/year
</span>
</div>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-blue-600">
{impact.comparisonToConventional.milesSaved.toFixed(0)}
</p>
<p className="text-sm text-gray-500">km Saved</p>
<div className="mt-2 flex items-center justify-center gap-1 text-blue-600">
<span>🚗</span>
<span className="text-xs">
= {(impact.comparisonToConventional.milesSaved / 15).toFixed(0)} car trips
</span>
</div>
</div>
</div>
{/* Visual bar comparison */}
<div className="mt-6 space-y-3">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Your Carbon Footprint</span>
<span className="font-medium text-green-600">{impact.totalCarbonKg.toFixed(2)} kg</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{
width: `${100 - impact.comparisonToConventional.percentageReduction}%`
}}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Conventional Average</span>
<span className="font-medium text-gray-500">
{(impact.totalCarbonKg + impact.comparisonToConventional.carbonSaved).toFixed(2)} kg
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<div className="h-full bg-gray-400 rounded-full w-full"></div>
</div>
</div>
</div>
</div>
)}
{/* Detailed breakdowns */}
{showDetails && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* By Transport Method */}
{sortedMethods.length > 0 && (
<div className="p-4 border border-gray-200 rounded-lg">
<h4 className="font-semibold text-gray-900 mb-4">By Transport Method</h4>
<div className="space-y-3">
{sortedMethods.map(([method, data]) => {
const percentage = impact.totalCarbonKg > 0
? (data.carbon / impact.totalCarbonKg) * 100
: 0;
return (
<div key={method}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">
{METHOD_LABELS[method as TransportMethod] || method}
</span>
<span className="font-medium text-gray-900">
{data.carbon.toFixed(3)} kg ({percentage.toFixed(0)}%)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="h-full bg-teal-500 rounded-full"
style={{ width: `${percentage}%` }}
></div>
</div>
<p className="text-xs text-gray-400 mt-0.5">
{data.distance.toFixed(1)} km traveled
</p>
</div>
);
})}
</div>
</div>
)}
{/* By Event Type */}
{sortedEvents.length > 0 && (
<div className="p-4 border border-gray-200 rounded-lg">
<h4 className="font-semibold text-gray-900 mb-4">By Event Type</h4>
<div className="space-y-3">
{sortedEvents.map(([eventType, data]) => {
const percentage = totalEvents > 0
? (data.count / totalEvents) * 100
: 0;
return (
<div key={eventType}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">
{EVENT_LABELS[eventType as TransportEventType] || eventType}
</span>
<span className="font-medium text-gray-900">
{data.count} events {data.carbon.toFixed(3)} kg
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="h-full bg-cyan-500 rounded-full"
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{/* Tips Section */}
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<h4 className="font-semibold text-yellow-800 mb-2">Tips to Reduce Impact</h4>
<ul className="text-sm text-yellow-700 space-y-1">
<li> Prefer walking, cycling, or electric vehicles for short distances</li>
<li> Consolidate multiple transports into single trips</li>
<li> Source from local producers within 25km when possible</li>
<li> Use rail transport for longer distances when available</li>
<li> Avoid air freight unless absolutely necessary</li>
</ul>
</div>
{/* Summary stats */}
<div className="flex justify-between text-xs text-gray-500 pt-4 border-t border-gray-200">
<span>Transport methods used: {totalMethods}</span>
<span>Total events tracked: {totalEvents}</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,231 @@
import { useState } from 'react';
interface FoodMilesTrackerProps {
data: FoodMilesData[];
currentTotal: number;
targetMiles?: number;
}
export interface FoodMilesData {
date: string;
miles: number;
carbonKg: number;
eventType?: string;
produceType?: string;
}
type TimeRange = '7d' | '30d' | '90d' | '365d';
export default function FoodMilesTracker({
data,
currentTotal,
targetMiles = 50,
}: FoodMilesTrackerProps) {
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
// Filter data based on time range
const filterData = (range: TimeRange): FoodMilesData[] => {
const now = new Date();
const daysMap: Record<TimeRange, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
'365d': 365,
};
const cutoff = new Date(now.getTime() - daysMap[range] * 24 * 60 * 60 * 1000);
return data.filter((d) => new Date(d.date) >= cutoff);
};
const filteredData = filterData(timeRange);
// Calculate stats for filtered period
const totalMiles = filteredData.reduce((sum, d) => sum + d.miles, 0);
const totalCarbon = filteredData.reduce((sum, d) => sum + d.carbonKg, 0);
const avgMilesPerDay = filteredData.length > 0 ? totalMiles / filteredData.length : 0;
// Group data by day for chart
const dailyData: Record<string, { miles: number; carbon: number }> = {};
filteredData.forEach((d) => {
const day = d.date.split('T')[0];
if (!dailyData[day]) {
dailyData[day] = { miles: 0, carbon: 0 };
}
dailyData[day].miles += d.miles;
dailyData[day].carbon += d.carbonKg;
});
const chartData = Object.entries(dailyData).sort((a, b) => a[0].localeCompare(b[0]));
const maxMiles = Math.max(...chartData.map(([_, d]) => d.miles), 1);
// Progress towards target
const progressPercentage = Math.min((currentTotal / targetMiles) * 100, 100);
const isOverTarget = currentTotal > targetMiles;
// Recent events
const recentEvents = [...data].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
).slice(0, 5);
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white p-6">
<h3 className="text-xl font-bold">Food Miles Tracker</h3>
<p className="text-blue-200 text-sm mt-1">Monitor your produce transportation distances</p>
</div>
<div className="p-6 space-y-6">
{/* Time range selector */}
<div className="flex justify-center">
<div className="inline-flex rounded-lg overflow-hidden border border-gray-300">
{(['7d', '30d', '90d', '365d'] as TimeRange[]).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-4 py-2 text-sm font-medium ${
timeRange === range
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100'
}`}
>
{range === '7d' ? '7 Days' : range === '30d' ? '30 Days' : range === '90d' ? '90 Days' : '1 Year'}
</button>
))}
</div>
</div>
{/* Current total with target */}
<div className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-gray-600">Current Period Total</p>
<p className={`text-4xl font-bold ${isOverTarget ? 'text-orange-600' : 'text-blue-600'}`}>
{totalMiles.toFixed(1)} km
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">Target</p>
<p className="text-2xl font-bold text-gray-400">{targetMiles} km</p>
</div>
</div>
{/* Progress bar */}
<div className="relative">
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className={`h-4 rounded-full transition-all ${
isOverTarget ? 'bg-orange-500' : 'bg-blue-500'
}`}
style={{ width: `${progressPercentage}%` }}
></div>
</div>
{/* Target marker */}
<div
className="absolute top-0 w-0.5 h-6 bg-gray-800 -mt-1"
style={{ left: '100%' }}
>
<div className="absolute -top-5 -left-4 text-xs text-gray-500">Target</div>
</div>
</div>
<p className={`text-sm mt-2 ${isOverTarget ? 'text-orange-600' : 'text-green-600'}`}>
{isOverTarget
? `${(currentTotal - targetMiles).toFixed(1)} km over target`
: `${(targetMiles - currentTotal).toFixed(1)} km remaining to target`}
</p>
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-gray-900">{filteredData.length}</p>
<p className="text-xs text-gray-500">Transport Events</p>
</div>
<div className="text-center p-4 bg-green-50 rounded-lg">
<p className="text-2xl font-bold text-green-600">{totalCarbon.toFixed(2)}</p>
<p className="text-xs text-gray-500">kg CO2 Total</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{avgMilesPerDay.toFixed(1)}</p>
<p className="text-xs text-gray-500">Avg km/day</p>
</div>
</div>
{/* Simple bar chart */}
{chartData.length > 0 && (
<div className="p-4 bg-gray-50 rounded-lg">
<h4 className="text-sm font-semibold text-gray-900 mb-4">Daily Food Miles</h4>
<div className="flex items-end gap-1 h-32">
{chartData.slice(-14).map(([date, values], index) => {
const height = (values.miles / maxMiles) * 100;
return (
<div
key={date}
className="flex-1 flex flex-col items-center group"
>
<div className="w-full relative flex-1 flex items-end">
<div
className="w-full bg-blue-500 rounded-t hover:bg-blue-600 transition cursor-pointer"
style={{ height: `${Math.max(height, 2)}%` }}
title={`${date}: ${values.miles.toFixed(1)} km`}
></div>
</div>
<div className="text-xs text-gray-400 mt-1 rotate-45 origin-top-left">
{new Date(date).getDate()}
</div>
</div>
);
})}
</div>
<p className="text-xs text-gray-400 text-center mt-2">Last 14 days</p>
</div>
)}
{/* Recent events */}
{recentEvents.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-3">Recent Transport Events</h4>
<div className="space-y-2">
{recentEvents.map((event, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition"
>
<div>
<p className="font-medium text-gray-900">
{event.produceType || event.eventType || 'Transport'}
</p>
<p className="text-xs text-gray-500">
{new Date(event.date).toLocaleDateString()}
</p>
</div>
<div className="text-right">
<p className="font-bold text-blue-600">{event.miles.toFixed(1)} km</p>
<p className="text-xs text-gray-500">{event.carbonKg.toFixed(3)} kg CO2</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Comparison info */}
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 className="font-semibold text-green-800 mb-2">Did you know?</h4>
<p className="text-sm text-green-700">
The average food item in conventional supply chains travels <strong>2,400 km</strong> before reaching your plate.
LocalGreenChain helps reduce this by connecting you with local growers.
</p>
<div className="mt-3 text-sm text-green-600">
Your average: <strong>{avgMilesPerDay.toFixed(1)} km/day</strong> vs Conventional: <strong>6.6 km/day</strong>
{avgMilesPerDay < 6.6 && (
<span className="ml-2 text-green-500">
({((1 - avgMilesPerDay / 6.6) * 100).toFixed(0)}% better!)
</span>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,273 @@
import { useState, useMemo } from 'react';
interface SavingsCalculatorProps {
actualCarbon: number;
actualMiles: number;
produceWeightKg: number;
conventionalData?: ConventionalBaseline;
}
export interface ConventionalBaseline {
avgCarbonPerKg: number; // Default: 2.5 kg CO2 per kg produce
avgMilesPerKg: number; // Default: 2400 km average food miles
avgWaterLitersPerKg: number; // Default: 500 liters per kg
avgFoodWastePercent: number; // Default: 30% waste
}
const DEFAULT_CONVENTIONAL: ConventionalBaseline = {
avgCarbonPerKg: 2.5,
avgMilesPerKg: 2400,
avgWaterLitersPerKg: 500,
avgFoodWastePercent: 30,
};
const EQUIVALENTS = {
carbonPerTree: 21, // kg CO2 absorbed per tree per year
carbonPerCarMile: 0.404, // kg CO2 per mile driven
carbonPerFlight: 250, // kg CO2 per hour of flight
waterPerShower: 75, // liters per 5-min shower
carbonPerGallon: 8.89, // kg CO2 per gallon of gasoline
};
export default function SavingsCalculator({
actualCarbon,
actualMiles,
produceWeightKg,
conventionalData = DEFAULT_CONVENTIONAL,
}: SavingsCalculatorProps) {
const [showDetails, setShowDetails] = useState(false);
// Calculate conventional equivalents
const conventionalCarbon = produceWeightKg * conventionalData.avgCarbonPerKg;
const conventionalMiles = produceWeightKg * conventionalData.avgMilesPerKg;
const conventionalWater = produceWeightKg * conventionalData.avgWaterLitersPerKg;
const conventionalWaste = produceWeightKg * (conventionalData.avgFoodWastePercent / 100);
// Calculate savings
const carbonSaved = conventionalCarbon - actualCarbon;
const milesSaved = conventionalMiles - actualMiles;
const wasteSaved = conventionalWaste * 0.75; // Assume 75% less waste with local
// Calculate percentage reductions
const carbonReduction = conventionalCarbon > 0 ? (carbonSaved / conventionalCarbon) * 100 : 0;
const milesReduction = conventionalMiles > 0 ? (milesSaved / conventionalMiles) * 100 : 0;
// Convert to tangible equivalents
const equivalents = useMemo(() => ({
treesEquivalent: carbonSaved / EQUIVALENTS.carbonPerTree,
carMilesEquivalent: carbonSaved / EQUIVALENTS.carbonPerCarMile,
flightHoursEquivalent: carbonSaved / EQUIVALENTS.carbonPerFlight,
showersEquivalent: (conventionalWater - conventionalWater * 0.1) / EQUIVALENTS.waterPerShower,
gallonsGasoline: carbonSaved / EQUIVALENTS.carbonPerGallon,
}), [carbonSaved, conventionalWater]);
// Calculate annual projections (assuming monthly data)
const annualMultiplier = 12;
const annualCarbonSaved = carbonSaved * annualMultiplier;
const annualMilesSaved = milesSaved * annualMultiplier;
// Rating based on carbon reduction
const getRating = (reduction: number): { stars: number; label: string; color: string } => {
if (reduction >= 90) return { stars: 5, label: 'Outstanding', color: 'text-green-600' };
if (reduction >= 75) return { stars: 4, label: 'Excellent', color: 'text-emerald-600' };
if (reduction >= 50) return { stars: 3, label: 'Great', color: 'text-lime-600' };
if (reduction >= 25) return { stars: 2, label: 'Good', color: 'text-yellow-600' };
return { stars: 1, label: 'Getting Started', color: 'text-orange-600' };
};
const rating = getRating(carbonReduction);
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white p-6">
<h3 className="text-xl font-bold">Savings Calculator</h3>
<p className="text-green-200 text-sm mt-1">See your environmental impact vs conventional</p>
</div>
<div className="p-6 space-y-6">
{/* Rating display */}
<div className="text-center p-6 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg">
<div className="flex justify-center gap-1 mb-2">
{[1, 2, 3, 4, 5].map((star) => (
<span
key={star}
className={`text-2xl ${star <= rating.stars ? 'text-yellow-400' : 'text-gray-300'}`}
>
</span>
))}
</div>
<p className={`text-xl font-bold ${rating.color}`}>{rating.label}</p>
<p className="text-sm text-gray-600 mt-1">
You've reduced carbon by {carbonReduction.toFixed(0)}% compared to conventional!
</p>
</div>
{/* Main savings metrics */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-green-50 rounded-lg text-center">
<p className="text-3xl font-bold text-green-600">{carbonSaved.toFixed(1)}</p>
<p className="text-sm text-gray-500">kg CO2 Saved</p>
<div className="mt-2 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${Math.min(carbonReduction, 100)}%` }}
></div>
</div>
<p className="text-xs text-gray-400 mt-1">{carbonReduction.toFixed(0)}% reduction</p>
</div>
<div className="p-4 bg-blue-50 rounded-lg text-center">
<p className="text-3xl font-bold text-blue-600">{milesSaved.toFixed(0)}</p>
<p className="text-sm text-gray-500">km Saved</p>
<div className="mt-2 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${Math.min(milesReduction, 100)}%` }}
></div>
</div>
<p className="text-xs text-gray-400 mt-1">{milesReduction.toFixed(0)}% reduction</p>
</div>
</div>
{/* Comparison table */}
<div className="overflow-hidden border border-gray-200 rounded-lg">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900">Metric</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-900">Your Impact</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-gray-500">Conventional</th>
<th className="px-4 py-3 text-right text-sm font-semibold text-green-600">Saved</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="px-4 py-3 text-sm text-gray-600">Carbon Footprint</td>
<td className="px-4 py-3 text-sm text-right font-medium">{actualCarbon.toFixed(2)} kg</td>
<td className="px-4 py-3 text-sm text-right text-gray-500">{conventionalCarbon.toFixed(2)} kg</td>
<td className="px-4 py-3 text-sm text-right text-green-600 font-medium">
-{carbonSaved.toFixed(2)} kg
</td>
</tr>
<tr className="bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-600">Food Miles</td>
<td className="px-4 py-3 text-sm text-right font-medium">{actualMiles.toFixed(0)} km</td>
<td className="px-4 py-3 text-sm text-right text-gray-500">{conventionalMiles.toFixed(0)} km</td>
<td className="px-4 py-3 text-sm text-right text-green-600 font-medium">
-{milesSaved.toFixed(0)} km
</td>
</tr>
<tr>
<td className="px-4 py-3 text-sm text-gray-600">Est. Food Waste</td>
<td className="px-4 py-3 text-sm text-right font-medium">
{(conventionalWaste * 0.25).toFixed(2)} kg
</td>
<td className="px-4 py-3 text-sm text-right text-gray-500">{conventionalWaste.toFixed(2)} kg</td>
<td className="px-4 py-3 text-sm text-right text-green-600 font-medium">
-{wasteSaved.toFixed(2)} kg
</td>
</tr>
</tbody>
</table>
</div>
{/* Tangible equivalents */}
<div>
<button
onClick={() => setShowDetails(!showDetails)}
className="w-full flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition"
>
<span className="font-semibold text-gray-900">What does this mean?</span>
<span className="text-gray-500">{showDetails ? '▼' : '▶'}</span>
</button>
{showDetails && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="p-4 bg-green-50 rounded-lg text-center">
<span className="text-3xl">🌲</span>
<p className="text-xl font-bold text-green-600 mt-2">
{equivalents.treesEquivalent.toFixed(1)}
</p>
<p className="text-xs text-gray-500">Trees planted (annual equivalent)</p>
</div>
<div className="p-4 bg-blue-50 rounded-lg text-center">
<span className="text-3xl">🚗</span>
<p className="text-xl font-bold text-blue-600 mt-2">
{equivalents.carMilesEquivalent.toFixed(0)}
</p>
<p className="text-xs text-gray-500">Car miles avoided</p>
</div>
<div className="p-4 bg-purple-50 rounded-lg text-center">
<span className="text-3xl"></span>
<p className="text-xl font-bold text-purple-600 mt-2">
{equivalents.flightHoursEquivalent.toFixed(1)}
</p>
<p className="text-xs text-gray-500">Flight hours equivalent</p>
</div>
<div className="p-4 bg-cyan-50 rounded-lg text-center">
<span className="text-3xl">🚿</span>
<p className="text-xl font-bold text-cyan-600 mt-2">
{equivalents.showersEquivalent.toFixed(0)}
</p>
<p className="text-xs text-gray-500">Showers worth of water</p>
</div>
<div className="p-4 bg-orange-50 rounded-lg text-center">
<span className="text-3xl"></span>
<p className="text-xl font-bold text-orange-600 mt-2">
{equivalents.gallonsGasoline.toFixed(1)}
</p>
<p className="text-xs text-gray-500">Gallons of gas saved</p>
</div>
<div className="p-4 bg-yellow-50 rounded-lg text-center">
<span className="text-3xl">🌍</span>
<p className="text-xl font-bold text-yellow-600 mt-2">
{carbonReduction.toFixed(0)}%
</p>
<p className="text-xs text-gray-500">Better for the planet</p>
</div>
</div>
)}
</div>
{/* Annual projection */}
<div className="p-4 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-lg border border-emerald-200">
<h4 className="font-semibold text-emerald-800 mb-3">Annual Projection</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-bold text-emerald-600">
{annualCarbonSaved.toFixed(0)} kg CO2
</p>
<p className="text-sm text-gray-600">Carbon saved per year</p>
</div>
<div>
<p className="text-2xl font-bold text-teal-600">
{annualMilesSaved.toFixed(0)} km
</p>
<p className="text-sm text-gray-600">Food miles saved per year</p>
</div>
</div>
<p className="text-xs text-gray-500 mt-3">
Based on current consumption of {produceWeightKg.toFixed(1)} kg produce
</p>
</div>
{/* Call to action */}
<div className="text-center">
<p className="text-sm text-gray-600">
Keep sourcing local produce to maximize your environmental impact!
</p>
<p className="text-xs text-gray-400 mt-1">
Every kilometer saved makes a difference
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,5 @@
export { default as EnvironmentalImpact } from './EnvironmentalImpact';
export { default as FoodMilesTracker } from './FoodMilesTracker';
export { default as SavingsCalculator } from './SavingsCalculator';
export type { FoodMilesData } from './FoodMilesTracker';
export type { ConventionalBaseline } from './SavingsCalculator';

View file

@ -0,0 +1,163 @@
import { DemandSignal, DemandItem } from '../../lib/demand/types';
interface DemandSignalCardProps {
signal: DemandSignal;
onViewDetails?: () => void;
}
const STATUS_COLORS: Record<DemandSignal['supplyStatus'], { bg: string; text: string; icon: string }> = {
surplus: { bg: 'bg-green-100', text: 'text-green-800', icon: '📈' },
balanced: { bg: 'bg-blue-100', text: 'text-blue-800', icon: '⚖️' },
shortage: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: '⚠️' },
critical: { bg: 'bg-red-100', text: 'text-red-800', icon: '🚨' },
};
function formatCategory(category: string): string {
return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
export default function DemandSignalCard({ signal, onViewDetails }: DemandSignalCardProps) {
const statusStyle = STATUS_COLORS[signal.supplyStatus];
// Get top demand items
const topItems = [...signal.demandItems]
.sort((a, b) => b.weeklyDemandKg - a.weeklyDemandKg)
.slice(0, 5);
const gapPercentage = signal.totalWeeklyDemandKg > 0
? (signal.supplyGapKg / signal.totalWeeklyDemandKg) * 100
: 0;
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 text-white p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold">{signal.region.name}</h3>
<p className="text-blue-200 text-sm">
{signal.seasonalPeriod.charAt(0).toUpperCase() + signal.seasonalPeriod.slice(1)} Season
</p>
</div>
<div className={`px-3 py-1 rounded-full ${statusStyle.bg}`}>
<span className={`text-sm font-semibold ${statusStyle.text}`}>
{statusStyle.icon} {signal.supplyStatus.charAt(0).toUpperCase() + signal.supplyStatus.slice(1)}
</span>
</div>
</div>
</div>
<div className="p-6 space-y-4">
{/* Key metrics */}
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-gray-900">{signal.totalConsumers}</p>
<p className="text-xs text-gray-500">Consumers</p>
</div>
<div className="text-center p-3 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{signal.totalWeeklyDemandKg.toFixed(0)}</p>
<p className="text-xs text-gray-500">Weekly Demand (kg)</p>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<p className="text-2xl font-bold text-green-600">{signal.currentSupplyKg.toFixed(0)}</p>
<p className="text-xs text-gray-500">Current Supply (kg)</p>
</div>
</div>
{/* Supply Gap Indicator */}
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-700">Supply Coverage</span>
<span className={`text-sm font-bold ${gapPercentage > 20 ? 'text-red-600' : 'text-green-600'}`}>
{(100 - gapPercentage).toFixed(0)}% covered
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all ${
gapPercentage > 50 ? 'bg-red-500' : gapPercentage > 20 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${Math.min(100 - gapPercentage, 100)}%` }}
></div>
</div>
{signal.supplyGapKg > 0 && (
<p className="text-xs text-red-600 mt-2">
Gap: {signal.supplyGapKg.toFixed(0)} kg needed
</p>
)}
</div>
{/* Top Demand Items */}
<div>
<h4 className="text-sm font-semibold text-gray-900 mb-3">Top Demanded Items</h4>
<div className="space-y-2">
{topItems.map((item, index) => (
<DemandItemRow key={index} item={item} rank={index + 1} />
))}
</div>
</div>
{/* Confidence indicator */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Confidence:</span>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((level) => (
<div
key={level}
className={`w-2 h-4 rounded-sm ${
level <= signal.confidenceLevel / 20 ? 'bg-green-500' : 'bg-gray-200'
}`}
></div>
))}
</div>
<span className="text-sm text-gray-600">{signal.confidenceLevel}%</span>
</div>
<span className="text-xs text-gray-400">
Updated: {new Date(signal.timestamp).toLocaleDateString()}
</span>
</div>
{/* View Details Button */}
{onViewDetails && (
<button
onClick={onViewDetails}
className="w-full mt-4 py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition"
>
View Full Details
</button>
)}
</div>
</div>
);
}
function DemandItemRow({ item, rank }: { item: DemandItem; rank: number }) {
const urgencyColors: Record<DemandItem['urgency'], string> = {
immediate: 'bg-red-100 text-red-700',
this_week: 'bg-orange-100 text-orange-700',
this_month: 'bg-yellow-100 text-yellow-700',
next_season: 'bg-blue-100 text-blue-700',
};
return (
<div className="flex items-center p-2 bg-gray-50 rounded-lg hover:bg-gray-100 transition">
<span className="w-6 h-6 flex items-center justify-center bg-indigo-100 text-indigo-700 rounded-full text-xs font-bold">
{rank}
</span>
<div className="ml-3 flex-1">
<p className="text-sm font-medium text-gray-900">{item.produceType}</p>
<p className="text-xs text-gray-500">{formatCategory(item.category)}</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-gray-900">{item.weeklyDemandKg.toFixed(1)} kg</p>
<span className={`text-xs px-2 py-0.5 rounded-full ${urgencyColors[item.urgency]}`}>
{item.urgency.replace(/_/g, ' ')}
</span>
</div>
{item.inSeason && (
<span className="ml-2 text-green-500" title="In Season">🌿</span>
)}
</div>
);
}

View file

@ -0,0 +1,465 @@
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>
);
}

View file

@ -0,0 +1,206 @@
import { PlantingRecommendation, RiskFactor } from '../../lib/demand/types';
interface RecommendationListProps {
recommendations: PlantingRecommendation[];
onSelect?: (recommendation: PlantingRecommendation) => void;
loading?: boolean;
}
const RISK_COLORS: Record<RiskFactor['severity'], string> = {
low: 'bg-green-100 text-green-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-red-100 text-red-800',
};
const OVERALL_RISK_COLORS: Record<string, string> = {
low: 'text-green-600 bg-green-50 border-green-200',
medium: 'text-yellow-600 bg-yellow-50 border-yellow-200',
high: 'text-red-600 bg-red-50 border-red-200',
};
function formatCategory(category: string): string {
return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
export default function RecommendationList({
recommendations,
onSelect,
loading = false,
}: RecommendationListProps) {
if (loading) {
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Planting Recommendations</h3>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse">
<div className="h-32 bg-gray-200 rounded-lg"></div>
</div>
))}
</div>
</div>
);
}
if (recommendations.length === 0) {
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Planting Recommendations</h3>
<div className="text-center py-8">
<div className="text-4xl mb-3">🌱</div>
<p className="text-gray-500">No recommendations available yet.</p>
<p className="text-sm text-gray-400 mt-1">
Check back later or update your preferences to get personalized recommendations.
</p>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900">Planting Recommendations</h3>
<span className="text-sm text-gray-500">{recommendations.length} suggestions</span>
</div>
<div className="space-y-4">
{recommendations.map((rec) => (
<RecommendationCard key={rec.id} recommendation={rec} onSelect={onSelect} />
))}
</div>
</div>
);
}
function RecommendationCard({
recommendation: rec,
onSelect,
}: {
recommendation: PlantingRecommendation;
onSelect?: (recommendation: PlantingRecommendation) => void;
}) {
return (
<div
className={`border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition ${
onSelect ? 'cursor-pointer' : ''
}`}
onClick={() => onSelect?.(rec)}
>
{/* Header */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div>
<h4 className="text-lg font-semibold text-gray-900">{rec.produceType}</h4>
<p className="text-sm text-gray-500">
{rec.variety && <span className="mr-2">{rec.variety}</span>}
<span className="text-gray-400">|</span>
<span className="ml-2">{formatCategory(rec.category)}</span>
</p>
</div>
<div className={`px-3 py-1 rounded-full border ${OVERALL_RISK_COLORS[rec.overallRisk]}`}>
<span className="text-sm font-medium capitalize">{rec.overallRisk} Risk</span>
</div>
</div>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Key metrics */}
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-2 bg-gray-50 rounded-lg">
<p className="text-lg font-bold text-gray-900">{rec.recommendedQuantity}</p>
<p className="text-xs text-gray-500">{rec.quantityUnit}</p>
</div>
<div className="text-center p-2 bg-blue-50 rounded-lg">
<p className="text-lg font-bold text-blue-600">{rec.expectedYieldKg.toFixed(1)} kg</p>
<p className="text-xs text-gray-500">Expected Yield</p>
</div>
<div className="text-center p-2 bg-green-50 rounded-lg">
<p className="text-lg font-bold text-green-600">${rec.projectedRevenue.toFixed(0)}</p>
<p className="text-xs text-gray-500">Est. Revenue</p>
</div>
</div>
{/* Timing */}
<div className="flex items-center justify-between text-sm p-3 bg-yellow-50 rounded-lg">
<div>
<span className="text-gray-600">Plant by:</span>
<span className="font-semibold text-gray-900 ml-2">{formatDate(rec.plantByDate)}</span>
</div>
<div className="text-gray-400">|</div>
<div>
<span className="text-gray-600">Harvest:</span>
<span className="font-semibold text-gray-900 ml-2">
{formatDate(rec.expectedHarvestStart)} - {formatDate(rec.expectedHarvestEnd)}
</span>
</div>
</div>
{/* Market opportunity */}
<div className="p-3 border border-gray-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600">Market Opportunity</span>
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((level) => (
<div
key={level}
className={`w-2 h-4 rounded-sm ${
level <= rec.marketConfidence / 20 ? 'bg-green-500' : 'bg-gray-200'
}`}
></div>
))}
<span className="text-xs text-gray-500 ml-1">{rec.marketConfidence}%</span>
</div>
</div>
<div className="text-sm">
<span className="text-gray-600">Projected demand:</span>
<span className="font-semibold text-gray-900 ml-1">{rec.projectedDemandKg.toFixed(0)} kg</span>
<span className="text-gray-400 mx-2">@</span>
<span className="font-semibold text-green-600">${rec.projectedPricePerKg.toFixed(2)}/kg</span>
</div>
</div>
{/* Risk factors */}
{rec.riskFactors.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Risk Factors</p>
<div className="flex flex-wrap gap-2">
{rec.riskFactors.slice(0, 3).map((risk, index) => (
<span
key={index}
className={`text-xs px-2 py-1 rounded-full ${RISK_COLORS[risk.severity]}`}
title={risk.description}
>
{risk.type}
</span>
))}
{rec.riskFactors.length > 3 && (
<span className="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-600">
+{rec.riskFactors.length - 3} more
</span>
)}
</div>
</div>
)}
{/* Explanation */}
<p className="text-sm text-gray-600 italic">"{rec.explanation}"</p>
{/* Growing days indicator */}
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-100">
<span>Growing period: {rec.growingDays} days</span>
<span>Yield confidence: {rec.yieldConfidence}%</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,308 @@
import { useState } from 'react';
import { DemandItem, ProduceCategory } from '../../lib/demand/types';
interface SeasonalCalendarProps {
items: SeasonalItem[];
currentSeason?: 'spring' | 'summer' | 'fall' | 'winter';
}
export interface SeasonalItem {
produceType: string;
category: ProduceCategory;
seasonalAvailability: {
spring: boolean;
summer: boolean;
fall: boolean;
winter: boolean;
};
peakSeason?: 'spring' | 'summer' | 'fall' | 'winter';
}
const SEASONS = ['spring', 'summer', 'fall', 'winter'] as const;
const SEASON_COLORS: Record<string, { bg: string; border: string; text: string; icon: string }> = {
spring: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', icon: '🌸' },
summer: { bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', icon: '☀️' },
fall: { bg: 'bg-orange-100', border: 'border-orange-300', text: 'text-orange-800', icon: '🍂' },
winter: { bg: 'bg-blue-100', border: 'border-blue-300', text: 'text-blue-800', icon: '❄️' },
};
const CATEGORY_ICONS: Record<ProduceCategory, string> = {
leafy_greens: '🥬',
root_vegetables: '🥕',
nightshades: '🍅',
brassicas: '🥦',
alliums: '🧅',
legumes: '🫘',
squash: '🎃',
herbs: '🌿',
microgreens: '🌱',
sprouts: '🌾',
mushrooms: '🍄',
fruits: '🍎',
berries: '🍓',
citrus: '🍊',
tree_fruits: '🍑',
melons: '🍈',
edible_flowers: '🌸',
};
function formatCategory(category: string): string {
return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
function getCurrentSeason(): 'spring' | 'summer' | 'fall' | 'winter' {
const month = new Date().getMonth();
if (month >= 2 && month <= 4) return 'spring';
if (month >= 5 && month <= 7) return 'summer';
if (month >= 8 && month <= 10) return 'fall';
return 'winter';
}
export default function SeasonalCalendar({
items,
currentSeason = getCurrentSeason(),
}: SeasonalCalendarProps) {
const [selectedCategory, setSelectedCategory] = useState<ProduceCategory | 'all'>('all');
const [viewMode, setViewMode] = useState<'calendar' | 'list'>('calendar');
// Get unique categories
const categories = Array.from(new Set(items.map((item) => item.category)));
// Filter items by category
const filteredItems = selectedCategory === 'all'
? items
: items.filter((item) => item.category === selectedCategory);
// Group items by season for current view
const itemsBySeason: Record<string, SeasonalItem[]> = {
spring: [],
summer: [],
fall: [],
winter: [],
};
filteredItems.forEach((item) => {
SEASONS.forEach((season) => {
if (item.seasonalAvailability[season]) {
itemsBySeason[season].push(item);
}
});
});
// Get items available now
const availableNow = filteredItems.filter(
(item) => item.seasonalAvailability[currentSeason]
);
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-emerald-600 to-teal-600 text-white p-4">
<h3 className="text-xl font-bold">Seasonal Availability</h3>
<p className="text-emerald-200 text-sm">
Currently: {SEASON_COLORS[currentSeason].icon} {currentSeason.charAt(0).toUpperCase() + currentSeason.slice(1)}
</p>
</div>
{/* Controls */}
<div className="p-4 border-b border-gray-200 bg-gray-50">
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Category filter */}
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Category:</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value as ProduceCategory | 'all')}
className="px-3 py-1 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
>
<option value="all">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{CATEGORY_ICONS[cat]} {formatCategory(cat)}
</option>
))}
</select>
</div>
{/* View toggle */}
<div className="flex rounded-lg overflow-hidden border border-gray-300">
<button
onClick={() => setViewMode('calendar')}
className={`px-3 py-1 text-sm ${
viewMode === 'calendar'
? 'bg-green-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100'
}`}
>
Calendar
</button>
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1 text-sm ${
viewMode === 'list'
? 'bg-green-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100'
}`}
>
List
</button>
</div>
</div>
</div>
<div className="p-6">
{/* Available Now Section */}
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 className="font-semibold text-green-800 mb-3 flex items-center gap-2">
<span>{SEASON_COLORS[currentSeason].icon}</span>
Available Now ({availableNow.length} items)
</h4>
<div className="flex flex-wrap gap-2">
{availableNow.slice(0, 12).map((item, index) => (
<span
key={index}
className="px-3 py-1 bg-white border border-green-300 text-green-800 text-sm rounded-full"
>
{CATEGORY_ICONS[item.category]} {item.produceType}
</span>
))}
{availableNow.length > 12 && (
<span className="px-3 py-1 bg-green-200 text-green-800 text-sm rounded-full">
+{availableNow.length - 12} more
</span>
)}
</div>
</div>
{/* Calendar View */}
{viewMode === 'calendar' && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{SEASONS.map((season) => {
const seasonStyle = SEASON_COLORS[season];
const isCurrentSeason = season === currentSeason;
return (
<div
key={season}
className={`rounded-lg border-2 ${
isCurrentSeason ? seasonStyle.border : 'border-gray-200'
} overflow-hidden`}
>
<div className={`p-3 ${seasonStyle.bg} ${seasonStyle.text}`}>
<div className="flex items-center justify-between">
<span className="font-bold capitalize">
{seasonStyle.icon} {season}
</span>
<span className="text-sm">
{itemsBySeason[season].length} items
</span>
</div>
</div>
<div className="p-3 max-h-48 overflow-y-auto">
{itemsBySeason[season].length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">
No items in this season
</p>
) : (
<div className="space-y-1">
{itemsBySeason[season].map((item, index) => (
<div
key={index}
className={`text-sm p-1 rounded flex items-center gap-1 ${
item.peakSeason === season ? 'font-medium bg-gray-100' : ''
}`}
>
<span>{CATEGORY_ICONS[item.category]}</span>
<span>{item.produceType}</span>
{item.peakSeason === season && (
<span className="text-yellow-500 ml-auto" title="Peak season"></span>
)}
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* List View */}
{viewMode === 'list' && (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-3 text-sm font-semibold text-gray-900">Item</th>
<th className="text-left py-2 px-3 text-sm font-semibold text-gray-900">Category</th>
{SEASONS.map((season) => (
<th
key={season}
className={`text-center py-2 px-3 text-sm font-semibold ${
season === currentSeason ? 'bg-green-50 text-green-800' : 'text-gray-900'
}`}
>
{SEASON_COLORS[season].icon}
<br />
<span className="text-xs capitalize">{season}</span>
</th>
))}
</tr>
</thead>
<tbody>
{filteredItems.map((item, index) => (
<tr
key={index}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2 px-3 text-sm">
<span className="mr-1">{CATEGORY_ICONS[item.category]}</span>
{item.produceType}
</td>
<td className="py-2 px-3 text-sm text-gray-500">
{formatCategory(item.category)}
</td>
{SEASONS.map((season) => (
<td
key={season}
className={`text-center py-2 px-3 ${
season === currentSeason ? 'bg-green-50' : ''
}`}
>
{item.seasonalAvailability[season] ? (
<span className={item.peakSeason === season ? 'text-yellow-500' : 'text-green-500'}>
{item.peakSeason === season ? '⭐' : '✓'}
</span>
) : (
<span className="text-gray-300">-</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Legend */}
<div className="mt-6 pt-4 border-t border-gray-200 flex items-center justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span className="text-gray-600">Available</span>
</div>
<div className="flex items-center gap-2">
<span className="text-yellow-500"></span>
<span className="text-gray-600">Peak Season</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-300">-</span>
<span className="text-gray-600">Not Available</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,187 @@
import { DemandItem, DemandSignal } from '../../lib/demand/types';
interface SupplyGapChartProps {
demandSignal: DemandSignal;
showTopN?: number;
}
function formatCategory(category: string): string {
return category.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
export default function SupplyGapChart({ demandSignal, showTopN = 10 }: SupplyGapChartProps) {
// Sort items by gap (largest gaps first)
const sortedItems = [...demandSignal.demandItems]
.filter((item) => item.gapKg > 0)
.sort((a, b) => b.gapKg - a.gapKg)
.slice(0, showTopN);
// Find the max for scaling
const maxDemand = Math.max(...demandSignal.demandItems.map((item) => item.weeklyDemandKg));
// Calculate overall stats
const totalDemand = demandSignal.totalWeeklyDemandKg;
const totalSupply = demandSignal.currentSupplyKg;
const totalGap = demandSignal.supplyGapKg;
const coveragePercentage = totalDemand > 0 ? ((totalSupply / totalDemand) * 100) : 100;
if (demandSignal.demandItems.length === 0) {
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Supply vs Demand</h3>
<div className="text-center py-8">
<div className="text-4xl mb-3">📊</div>
<p className="text-gray-500">No demand data available.</p>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold text-gray-900 mb-2">Supply vs Demand</h3>
<p className="text-sm text-gray-500 mb-6">{demandSignal.region.name} - {demandSignal.seasonalPeriod}</p>
{/* Overall summary */}
<div className="mb-6 p-4 bg-gradient-to-r from-blue-50 to-green-50 rounded-lg">
<div className="grid grid-cols-4 gap-4 text-center">
<div>
<p className="text-2xl font-bold text-blue-600">{totalDemand.toFixed(0)}</p>
<p className="text-xs text-gray-500">Weekly Demand (kg)</p>
</div>
<div>
<p className="text-2xl font-bold text-green-600">{totalSupply.toFixed(0)}</p>
<p className="text-xs text-gray-500">Current Supply (kg)</p>
</div>
<div>
<p className={`text-2xl font-bold ${totalGap > 0 ? 'text-red-600' : 'text-green-600'}`}>
{totalGap.toFixed(0)}
</p>
<p className="text-xs text-gray-500">Gap (kg)</p>
</div>
<div>
<p className={`text-2xl font-bold ${
coveragePercentage >= 100 ? 'text-green-600' :
coveragePercentage >= 80 ? 'text-yellow-600' : 'text-red-600'
}`}>
{coveragePercentage.toFixed(0)}%
</p>
<p className="text-xs text-gray-500">Coverage</p>
</div>
</div>
{/* Coverage bar */}
<div className="mt-4">
<div className="w-full bg-gray-200 rounded-full h-4 overflow-hidden">
<div
className={`h-4 transition-all ${
coveragePercentage >= 100 ? 'bg-green-500' :
coveragePercentage >= 80 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${Math.min(coveragePercentage, 100)}%` }}
></div>
</div>
</div>
</div>
{/* Gap items chart */}
{sortedItems.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-900">Largest Supply Gaps</h4>
{sortedItems.map((item, index) => (
<SupplyGapBar key={index} item={item} maxDemand={maxDemand} />
))}
</div>
)}
{/* Items with surplus */}
{demandSignal.demandItems.filter(item => item.gapKg <= 0).length > 0 && (
<div className="mt-6 pt-4 border-t border-gray-200">
<h4 className="text-sm font-semibold text-gray-900 mb-3">Well Supplied Items</h4>
<div className="flex flex-wrap gap-2">
{demandSignal.demandItems
.filter(item => item.gapKg <= 0)
.slice(0, 8)
.map((item, index) => (
<span
key={index}
className="px-3 py-1 bg-green-100 text-green-800 text-sm rounded-full"
>
{item.produceType}
</span>
))}
</div>
</div>
)}
{/* Legend */}
<div className="mt-6 pt-4 border-t border-gray-200 flex items-center justify-center gap-6 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-blue-500 rounded"></div>
<span className="text-gray-600">Demand</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded"></div>
<span className="text-gray-600">Supply</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-400 rounded"></div>
<span className="text-gray-600">Gap</span>
</div>
</div>
</div>
);
}
function SupplyGapBar({ item, maxDemand }: { item: DemandItem; maxDemand: number }) {
const demandWidth = (item.weeklyDemandKg / maxDemand) * 100;
const supplyWidth = (item.matchedSupply / maxDemand) * 100;
const gapPercentage = item.weeklyDemandKg > 0
? ((item.gapKg / item.weeklyDemandKg) * 100)
: 0;
return (
<div className="p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition">
<div className="flex items-center justify-between mb-2">
<div>
<span className="font-medium text-gray-900">{item.produceType}</span>
<span className="text-xs text-gray-500 ml-2">{formatCategory(item.category)}</span>
</div>
<div className="text-right">
<span className={`text-sm font-bold ${gapPercentage > 50 ? 'text-red-600' : 'text-yellow-600'}`}>
-{item.gapKg.toFixed(1)} kg
</span>
<span className="text-xs text-gray-500 ml-1">({gapPercentage.toFixed(0)}% gap)</span>
</div>
</div>
{/* Stacked bar */}
<div className="relative h-6 bg-gray-200 rounded-full overflow-hidden">
{/* Supply (filled portion) */}
<div
className="absolute top-0 left-0 h-full bg-green-500 transition-all"
style={{ width: `${supplyWidth}%` }}
></div>
{/* Gap (unfilled portion shown as demand outline) */}
<div
className="absolute top-0 left-0 h-full border-2 border-blue-500 rounded-full pointer-events-none"
style={{ width: `${demandWidth}%` }}
></div>
{/* Gap indicator */}
<div
className="absolute top-0 h-full bg-red-300 opacity-50"
style={{
left: `${supplyWidth}%`,
width: `${demandWidth - supplyWidth}%`
}}
></div>
</div>
{/* Values */}
<div className="flex justify-between mt-1 text-xs text-gray-500">
<span>Supply: {item.matchedSupply.toFixed(0)} kg</span>
<span>Demand: {item.weeklyDemandKg.toFixed(0)} kg</span>
</div>
</div>
);
}

View file

@ -0,0 +1,6 @@
export { default as DemandSignalCard } from './DemandSignalCard';
export { default as PreferencesForm } from './PreferencesForm';
export { default as RecommendationList } from './RecommendationList';
export { default as SupplyGapChart } from './SupplyGapChart';
export { default as SeasonalCalendar } from './SeasonalCalendar';
export type { SeasonalItem } from './SeasonalCalendar';

View file

@ -0,0 +1,191 @@
import { EnvironmentalImpact, TransportMethod } from '../../lib/transport/types';
interface CarbonFootprintCardProps {
impact: EnvironmentalImpact;
showComparison?: boolean;
}
const METHOD_LABELS: Record<TransportMethod, string> = {
walking: 'Walking',
bicycle: 'Bicycle',
electric_vehicle: 'Electric Vehicle',
hybrid_vehicle: 'Hybrid Vehicle',
gasoline_vehicle: 'Gas Vehicle',
diesel_truck: 'Diesel Truck',
electric_truck: 'Electric Truck',
refrigerated_truck: 'Refrigerated Truck',
rail: 'Rail',
ship: 'Ship',
air: 'Air Freight',
drone: 'Drone',
local_delivery: 'Local Delivery',
customer_pickup: 'Customer Pickup',
};
const METHOD_COLORS: Record<string, string> = {
walking: 'bg-green-500',
bicycle: 'bg-green-400',
electric_vehicle: 'bg-emerald-400',
hybrid_vehicle: 'bg-lime-400',
electric_truck: 'bg-teal-400',
drone: 'bg-cyan-400',
rail: 'bg-blue-400',
ship: 'bg-blue-500',
local_delivery: 'bg-yellow-400',
customer_pickup: 'bg-orange-400',
gasoline_vehicle: 'bg-orange-500',
diesel_truck: 'bg-red-400',
refrigerated_truck: 'bg-red-500',
air: 'bg-red-600',
};
export default function CarbonFootprintCard({ impact, showComparison = true }: CarbonFootprintCardProps) {
// Calculate breakdown percentages
const totalCarbon = impact.totalCarbonKg;
const methodBreakdown = Object.entries(impact.breakdownByMethod)
.filter(([_, data]) => data.carbon > 0)
.sort((a, b) => b[1].carbon - a[1].carbon);
const getCarbonRating = (carbon: number): { label: string; color: string; icon: string } => {
if (carbon < 0.5) return { label: 'Excellent', color: 'text-green-600', icon: '🌟' };
if (carbon < 2) return { label: 'Good', color: 'text-lime-600', icon: '✓' };
if (carbon < 5) return { label: 'Moderate', color: 'text-yellow-600', icon: '⚡' };
if (carbon < 10) return { label: 'High', color: 'text-orange-600', icon: '⚠️' };
return { label: 'Very High', color: 'text-red-600', icon: '🚨' };
};
const rating = getCarbonRating(totalCarbon);
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-bold">Carbon Footprint</h3>
<p className="text-green-100 text-sm mt-1">Environmental impact analysis</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold">{totalCarbon.toFixed(2)}</div>
<div className="text-green-100 text-sm">kg CO2e</div>
</div>
</div>
{/* Rating badge */}
<div className="mt-4 inline-flex items-center px-3 py-1 bg-white bg-opacity-20 rounded-full">
<span className="mr-2">{rating.icon}</span>
<span className="font-semibold">{rating.label}</span>
</div>
</div>
<div className="p-6 space-y-6">
{/* Key metrics */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-gray-600">Total Food Miles</p>
<p className="text-2xl font-bold text-blue-600">{impact.totalFoodMiles.toFixed(1)}</p>
<p className="text-xs text-gray-500">kilometers</p>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<p className="text-sm text-gray-600">Carbon Intensity</p>
<p className="text-2xl font-bold text-green-600">
{impact.carbonPerKgProduce.toFixed(3)}
</p>
<p className="text-xs text-gray-500">kg CO2 / kg produce</p>
</div>
</div>
{/* Comparison with conventional */}
{showComparison && impact.comparisonToConventional && (
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200">
<h4 className="font-semibold text-gray-900 mb-3 flex items-center">
<span className="mr-2">📊</span>
vs. Conventional Agriculture
</h4>
<div className="space-y-3">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Carbon Saved</span>
<span className="font-semibold text-green-600">
{impact.comparisonToConventional.carbonSaved.toFixed(2)} kg
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full"
style={{
width: `${Math.min(impact.comparisonToConventional.percentageReduction, 100)}%`,
}}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">Miles Saved</span>
<span className="font-semibold text-blue-600">
{impact.comparisonToConventional.milesSaved.toFixed(1)} km
</span>
</div>
</div>
<div className="pt-2 border-t border-green-200 text-center">
<span className="text-2xl font-bold text-green-600">
{impact.comparisonToConventional.percentageReduction.toFixed(0)}%
</span>
<span className="text-sm text-gray-600 ml-2">reduction vs conventional</span>
</div>
</div>
</div>
)}
{/* Breakdown by transport method */}
{methodBreakdown.length > 0 && (
<div>
<h4 className="font-semibold text-gray-900 mb-3">Breakdown by Transport Method</h4>
<div className="space-y-2">
{methodBreakdown.map(([method, data]) => {
const percentage = totalCarbon > 0 ? (data.carbon / totalCarbon) * 100 : 0;
return (
<div key={method} className="flex items-center">
<div className="w-32 text-sm text-gray-600 truncate">
{METHOD_LABELS[method as TransportMethod] || method}
</div>
<div className="flex-1 mx-3">
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className={`${METHOD_COLORS[method] || 'bg-gray-400'} h-4 rounded-full transition-all`}
style={{ width: `${percentage}%` }}
></div>
</div>
</div>
<div className="w-24 text-right text-sm">
<span className="font-medium text-gray-900">{data.carbon.toFixed(3)} kg</span>
<span className="text-gray-500 ml-1">({percentage.toFixed(0)}%)</span>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Tips */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 className="font-semibold text-yellow-800 mb-2">Tips to Reduce Impact</h4>
<ul className="text-sm text-yellow-700 space-y-1">
{totalCarbon > 5 && (
<li> Consider electric or hybrid vehicles for transport</li>
)}
{impact.totalFoodMiles > 50 && (
<li> Source produce from closer locations when possible</li>
)}
<li> Consolidate shipments to reduce trips</li>
<li> Use bicycle delivery for short distances</li>
</ul>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,231 @@
import { useState } from 'react';
import { TransportEvent, TransportLocation } from '../../lib/transport/types';
interface JourneyMapProps {
plantId: string;
events: TransportEvent[];
currentLocation?: TransportLocation;
}
interface MapPoint {
lat: number;
lng: number;
label: string;
eventType: string;
timestamp: string;
}
export default function JourneyMap({ plantId, events, currentLocation }: JourneyMapProps) {
const [selectedPoint, setSelectedPoint] = useState<MapPoint | null>(null);
// Extract unique locations from events
const mapPoints: MapPoint[] = [];
const seenCoords = new Set<string>();
events.forEach((event) => {
const fromKey = `${event.fromLocation.latitude},${event.fromLocation.longitude}`;
const toKey = `${event.toLocation.latitude},${event.toLocation.longitude}`;
if (!seenCoords.has(fromKey)) {
seenCoords.add(fromKey);
mapPoints.push({
lat: event.fromLocation.latitude,
lng: event.fromLocation.longitude,
label: event.fromLocation.facilityName || event.fromLocation.city || 'Origin',
eventType: event.eventType,
timestamp: event.timestamp,
});
}
if (!seenCoords.has(toKey)) {
seenCoords.add(toKey);
mapPoints.push({
lat: event.toLocation.latitude,
lng: event.toLocation.longitude,
label: event.toLocation.facilityName || event.toLocation.city || 'Destination',
eventType: event.eventType,
timestamp: event.timestamp,
});
}
});
// Calculate map bounds
const lats = mapPoints.map((p) => p.lat);
const lngs = mapPoints.map((p) => p.lng);
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
// Calculate center and scale for SVG
const centerLat = (minLat + maxLat) / 2;
const centerLng = (minLng + maxLng) / 2;
const latRange = Math.max(maxLat - minLat, 0.01);
const lngRange = Math.max(maxLng - minLng, 0.01);
// Convert geo coords to SVG coords
const toSvgCoords = (lat: number, lng: number) => {
const x = ((lng - minLng) / lngRange) * 280 + 60;
const y = ((maxLat - lat) / latRange) * 180 + 60;
return { x, y };
};
// Calculate total distance
const totalDistance = events.reduce((sum, e) => sum + e.distanceKm, 0);
if (events.length === 0) {
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Journey Map</h3>
<div className="bg-gray-100 rounded-lg p-8 text-center">
<div className="text-4xl mb-2">🗺</div>
<p className="text-gray-500">No journey data available yet.</p>
<p className="text-sm text-gray-400 mt-1">Transport events will appear here as they're recorded.</p>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-gray-900">Journey Map</h3>
<span className="text-sm text-gray-500">{mapPoints.length} locations</span>
</div>
{/* SVG Map */}
<div className="relative bg-gradient-to-br from-blue-50 to-green-50 rounded-lg overflow-hidden">
<svg viewBox="0 0 400 300" className="w-full h-64">
{/* Grid lines */}
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#e5e7eb" strokeWidth="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Draw paths between consecutive events */}
{events.map((event, index) => {
const from = toSvgCoords(event.fromLocation.latitude, event.fromLocation.longitude);
const to = toSvgCoords(event.toLocation.latitude, event.toLocation.longitude);
return (
<g key={`path-${index}`}>
<line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke="#10b981"
strokeWidth="2"
strokeDasharray="5,5"
className="animate-pulse"
/>
{/* Arrow head */}
<circle cx={to.x} cy={to.y} r="4" fill="#10b981" />
</g>
);
})}
{/* Draw location points */}
{mapPoints.map((point, index) => {
const { x, y } = toSvgCoords(point.lat, point.lng);
const isSelected = selectedPoint?.lat === point.lat && selectedPoint?.lng === point.lng;
return (
<g
key={`point-${index}`}
onClick={() => setSelectedPoint(point)}
className="cursor-pointer"
>
<circle
cx={x}
cy={y}
r={isSelected ? 12 : 8}
fill={index === 0 ? '#10b981' : index === mapPoints.length - 1 ? '#f59e0b' : '#3b82f6'}
stroke="white"
strokeWidth="2"
className="transition-all hover:r-12"
/>
<text
x={x}
y={y - 15}
textAnchor="middle"
className="text-xs fill-gray-600 font-medium"
>
{index + 1}
</text>
</g>
);
})}
{/* Current location marker */}
{currentLocation && (
<g>
{(() => {
const { x, y } = toSvgCoords(currentLocation.latitude, currentLocation.longitude);
return (
<>
<circle cx={x} cy={y} r="15" fill="#ef4444" fillOpacity="0.3" className="animate-ping" />
<circle cx={x} cy={y} r="8" fill="#ef4444" stroke="white" strokeWidth="2" />
</>
);
})()}
</g>
)}
</svg>
{/* Legend */}
<div className="absolute bottom-2 left-2 bg-white bg-opacity-90 rounded-lg p-2 text-xs">
<div className="flex items-center gap-2 mb-1">
<span className="w-3 h-3 rounded-full bg-green-500"></span>
<span>Origin</span>
</div>
<div className="flex items-center gap-2 mb-1">
<span className="w-3 h-3 rounded-full bg-blue-500"></span>
<span>Waypoint</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-yellow-500"></span>
<span>Current</span>
</div>
</div>
</div>
{/* Selected point details */}
{selectedPoint && (
<div className="mt-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-gray-900">{selectedPoint.label}</h4>
<button onClick={() => setSelectedPoint(null)} className="text-gray-400 hover:text-gray-600">
</button>
</div>
<p className="text-sm text-gray-600 mt-1">
Event: {selectedPoint.eventType.replace(/_/g, ' ')}
</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(selectedPoint.timestamp).toLocaleString()}
</p>
<p className="text-xs text-gray-400 mt-1">
Coords: {selectedPoint.lat.toFixed(4)}, {selectedPoint.lng.toFixed(4)}
</p>
</div>
)}
{/* Summary stats */}
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
<div className="p-3 bg-blue-50 rounded-lg">
<p className="text-lg font-bold text-blue-600">{totalDistance.toFixed(1)} km</p>
<p className="text-xs text-gray-500">Total Distance</p>
</div>
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-lg font-bold text-green-600">{mapPoints.length}</p>
<p className="text-xs text-gray-500">Locations</p>
</div>
<div className="p-3 bg-purple-50 rounded-lg">
<p className="text-lg font-bold text-purple-600">{events.length}</p>
<p className="text-xs text-gray-500">Transports</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,217 @@
import { useState } from 'react';
import { TransportQRData } from '../../lib/transport/types';
interface QRCodeDisplayProps {
qrData: TransportQRData;
size?: number;
showDetails?: boolean;
}
// Simple QR code matrix generator (basic implementation)
function generateQRMatrix(data: string, size: number = 21): boolean[][] {
// This is a simplified representation - in production you'd use a library like 'qrcode'
const matrix: boolean[][] = Array(size)
.fill(null)
.map(() => Array(size).fill(false));
// Add finder patterns (corners)
const addFinderPattern = (row: number, col: number) => {
for (let r = 0; r < 7; r++) {
for (let c = 0; c < 7; c++) {
if (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4)) {
if (row + r < size && col + c < size) {
matrix[row + r][col + c] = true;
}
}
}
}
};
addFinderPattern(0, 0);
addFinderPattern(0, size - 7);
addFinderPattern(size - 7, 0);
// Add some data-based pattern
const hash = data.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
for (let i = 8; i < size - 8; i++) {
for (let j = 8; j < size - 8; j++) {
matrix[i][j] = ((i * j + hash) % 3) === 0;
}
}
return matrix;
}
export default function QRCodeDisplay({ qrData, size = 200, showDetails = true }: QRCodeDisplayProps) {
const [copied, setCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
const qrString = JSON.stringify(qrData);
const matrix = generateQRMatrix(qrString);
const cellSize = size / matrix.length;
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(qrData.quickLookupUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleDownload = () => {
setDownloading(true);
// Create SVG for download
const svgContent = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<rect width="100%" height="100%" fill="white"/>
${matrix
.map((row, i) =>
row
.map((cell, j) =>
cell
? `<rect x="${j * cellSize}" y="${i * cellSize}" width="${cellSize}" height="${cellSize}" fill="black"/>`
: ''
)
.join('')
)
.join('')}
</svg>
`;
const blob = new Blob([svgContent], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qr-${qrData.plantId || qrData.batchId || 'code'}.svg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setDownloading(false);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white p-4">
<h3 className="text-lg font-bold">Traceability QR Code</h3>
<p className="text-purple-200 text-sm">Scan to verify authenticity</p>
</div>
<div className="p-6">
{/* QR Code */}
<div className="flex justify-center mb-6">
<div className="p-4 bg-white border-2 border-gray-200 rounded-lg shadow-inner">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<rect width="100%" height="100%" fill="white" />
{matrix.map((row, i) =>
row.map((cell, j) =>
cell ? (
<rect
key={`${i}-${j}`}
x={j * cellSize}
y={i * cellSize}
width={cellSize}
height={cellSize}
fill="black"
/>
) : null
)
)}
</svg>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 mb-6">
<button
onClick={handleCopyLink}
className="flex-1 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition flex items-center justify-center gap-2"
>
{copied ? (
<>
<span className="text-green-600"></span> Copied!
</>
) : (
<>
<span>📋</span> Copy Link
</>
)}
</button>
<button
onClick={handleDownload}
disabled={downloading}
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium transition flex items-center justify-center gap-2 disabled:opacity-50"
>
{downloading ? (
<>Downloading...</>
) : (
<>
<span></span> Download
</>
)}
</button>
</div>
{/* Details */}
{showDetails && (
<div className="space-y-3 border-t border-gray-200 pt-4">
{qrData.plantId && (
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">Plant ID</span>
<span className="text-sm font-mono text-gray-900">{qrData.plantId.slice(0, 12)}...</span>
</div>
)}
{qrData.batchId && (
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">Batch ID</span>
<span className="text-sm font-mono text-gray-900">{qrData.batchId.slice(0, 12)}...</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">Last Event</span>
<span className="text-sm text-gray-900 capitalize">
{qrData.lastEventType.replace(/_/g, ' ')}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">Last Updated</span>
<span className="text-sm text-gray-900">{formatDate(qrData.lastEventTimestamp)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">Current Custodian</span>
<span className="text-sm text-gray-900">{qrData.currentCustodian}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">Verification Code</span>
<span className="text-sm font-mono text-green-600">{qrData.verificationCode}</span>
</div>
</div>
)}
{/* Blockchain info */}
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>🔗</span>
<span className="font-medium">Blockchain Verified</span>
</div>
<p className="text-xs text-gray-400 mt-1 font-mono break-all">
{qrData.blockchainAddress}
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,324 @@
import { useState } from 'react';
import { TransportEventType, TransportMethod, TransportLocation } from '../../lib/transport/types';
interface TransportEventFormProps {
onSubmit: (data: TransportEventFormData) => void;
plantId?: string;
batchId?: string;
loading?: boolean;
defaultEventType?: TransportEventType;
}
export interface TransportEventFormData {
eventType: TransportEventType;
fromLocation: Partial<TransportLocation>;
toLocation: Partial<TransportLocation>;
transportMethod: TransportMethod;
notes?: string;
plantIds?: string[];
seedBatchId?: string;
}
const EVENT_TYPES: { value: TransportEventType; label: string; icon: string }[] = [
{ value: 'seed_acquisition', label: 'Seed Acquisition', icon: '🌱' },
{ value: 'planting', label: 'Planting', icon: '🌿' },
{ value: 'growing_transport', label: 'Growing Transport', icon: '🚚' },
{ value: 'harvest', label: 'Harvest', icon: '🥬' },
{ value: 'processing', label: 'Processing', icon: '⚙️' },
{ value: 'distribution', label: 'Distribution', icon: '📦' },
{ value: 'consumer_delivery', label: 'Consumer Delivery', icon: '🏠' },
{ value: 'seed_saving', label: 'Seed Saving', icon: '💾' },
{ value: 'seed_sharing', label: 'Seed Sharing', icon: '🤝' },
];
const TRANSPORT_METHODS: { value: TransportMethod; label: string; carbonInfo: string }[] = [
{ value: 'walking', label: 'Walking', carbonInfo: '0 kg CO2/km' },
{ value: 'bicycle', label: 'Bicycle', carbonInfo: '0 kg CO2/km' },
{ value: 'electric_vehicle', label: 'Electric Vehicle', carbonInfo: '0.02 kg CO2/km' },
{ value: 'hybrid_vehicle', label: 'Hybrid Vehicle', carbonInfo: '0.08 kg CO2/km' },
{ value: 'gasoline_vehicle', label: 'Gasoline Vehicle', carbonInfo: '0.12 kg CO2/km' },
{ value: 'diesel_truck', label: 'Diesel Truck', carbonInfo: '0.15 kg CO2/km' },
{ value: 'electric_truck', label: 'Electric Truck', carbonInfo: '0.03 kg CO2/km' },
{ value: 'refrigerated_truck', label: 'Refrigerated Truck', carbonInfo: '0.25 kg CO2/km' },
{ value: 'rail', label: 'Rail', carbonInfo: '0.01 kg CO2/km' },
{ value: 'ship', label: 'Ship', carbonInfo: '0.008 kg CO2/km' },
{ value: 'air', label: 'Air Freight', carbonInfo: '0.5 kg CO2/km' },
{ value: 'drone', label: 'Drone', carbonInfo: '0.01 kg CO2/km' },
{ value: 'local_delivery', label: 'Local Delivery', carbonInfo: '0.05 kg CO2/km' },
{ value: 'customer_pickup', label: 'Customer Pickup', carbonInfo: '0.1 kg CO2/km' },
];
const LOCATION_TYPES = [
'farm',
'greenhouse',
'vertical_farm',
'warehouse',
'hub',
'market',
'consumer',
'seed_bank',
'other',
] as const;
export default function TransportEventForm({
onSubmit,
plantId,
batchId,
loading = false,
defaultEventType = 'harvest',
}: TransportEventFormProps) {
const [eventType, setEventType] = useState<TransportEventType>(defaultEventType);
const [transportMethod, setTransportMethod] = useState<TransportMethod>('electric_vehicle');
const [notes, setNotes] = useState('');
const [useCurrentLocation, setUseCurrentLocation] = useState(false);
const [gettingLocation, setGettingLocation] = useState(false);
const [fromLocation, setFromLocation] = useState<Partial<TransportLocation>>({
latitude: 0,
longitude: 0,
locationType: 'farm',
city: '',
region: '',
});
const [toLocation, setToLocation] = useState<Partial<TransportLocation>>({
latitude: 0,
longitude: 0,
locationType: 'market',
city: '',
region: '',
});
const getCurrentLocation = async (target: 'from' | 'to') => {
if (!navigator.geolocation) {
alert('Geolocation is not supported by your browser');
return;
}
setGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
const update = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
};
if (target === 'from') {
setFromLocation((prev) => ({ ...prev, ...update }));
} else {
setToLocation((prev) => ({ ...prev, ...update }));
}
setGettingLocation(false);
},
(error) => {
console.error('Error getting location:', error);
alert('Unable to get your location. Please enter coordinates manually.');
setGettingLocation(false);
}
);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
eventType,
fromLocation,
toLocation,
transportMethod,
notes: notes || undefined,
plantIds: plantId ? [plantId] : undefined,
seedBatchId: batchId,
});
};
const LocationInput = ({
label,
value,
onChange,
onGetCurrent,
}: {
label: string;
value: Partial<TransportLocation>;
onChange: (loc: Partial<TransportLocation>) => void;
onGetCurrent: () => void;
}) => (
<div className="space-y-3 p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<h4 className="font-medium text-gray-900">{label}</h4>
<button
type="button"
onClick={onGetCurrent}
disabled={gettingLocation}
className="text-sm text-blue-600 hover:text-blue-800 disabled:text-gray-400"
>
{gettingLocation ? 'Getting...' : '📍 Use Current'}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Latitude</label>
<input
type="number"
step="any"
value={value.latitude || ''}
onChange={(e) => onChange({ ...value, latitude: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
placeholder="0.0000"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Longitude</label>
<input
type="number"
step="any"
value={value.longitude || ''}
onChange={(e) => onChange({ ...value, longitude: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
placeholder="0.0000"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">City</label>
<input
type="text"
value={value.city || ''}
onChange={(e) => onChange({ ...value, city: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
placeholder="City name"
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Region</label>
<input
type="text"
value={value.region || ''}
onChange={(e) => onChange({ ...value, region: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
placeholder="State/Province"
/>
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Location Type</label>
<select
value={value.locationType || 'other'}
onChange={(e) => onChange({ ...value, locationType: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-green-500"
>
{LOCATION_TYPES.map((type) => (
<option key={type} value={type}>
{type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</option>
))}
</select>
</div>
</div>
);
return (
<form onSubmit={handleSubmit} className="bg-white rounded-lg shadow-lg p-6 space-y-6">
<h3 className="text-xl font-bold text-gray-900">Record Transport Event</h3>
{/* Event Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Event Type</label>
<div className="grid grid-cols-3 gap-2">
{EVENT_TYPES.map((type) => (
<button
key={type.value}
type="button"
onClick={() => setEventType(type.value)}
className={`p-3 rounded-lg border-2 text-sm font-medium transition ${
eventType === type.value
? 'border-green-500 bg-green-50 text-green-700'
: 'border-gray-200 hover:border-gray-300 text-gray-600'
}`}
>
<span className="text-lg mr-1">{type.icon}</span>
<span className="hidden sm:inline">{type.label}</span>
</button>
))}
</div>
</div>
{/* Locations */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<LocationInput
label="From Location"
value={fromLocation}
onChange={setFromLocation}
onGetCurrent={() => getCurrentLocation('from')}
/>
<LocationInput
label="To Location"
value={toLocation}
onChange={setToLocation}
onGetCurrent={() => getCurrentLocation('to')}
/>
</div>
{/* Transport Method */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Transport Method</label>
<select
value={transportMethod}
onChange={(e) => setTransportMethod(e.target.value as TransportMethod)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
>
{TRANSPORT_METHODS.map((method) => (
<option key={method.value} value={method.value}>
{method.label} - {method.carbonInfo}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
Carbon emissions are calculated based on distance and transport method
</p>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Notes (optional)</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500"
placeholder="Any additional details about this transport event..."
/>
</div>
{/* IDs info */}
{(plantId || batchId) && (
<div className="p-3 bg-blue-50 rounded-lg text-sm">
{plantId && (
<p className="text-blue-700">
<strong>Plant ID:</strong> {plantId}
</p>
)}
{batchId && (
<p className="text-blue-700">
<strong>Batch ID:</strong> {batchId}
</p>
)}
</div>
)}
{/* Submit */}
<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 ? 'Recording...' : 'Record Transport Event'}
</button>
</form>
);
}

View file

@ -0,0 +1,181 @@
import { TransportEvent, TransportEventType } from '../../lib/transport/types';
interface TransportTimelineProps {
plantId: string;
events: TransportEvent[];
}
const EVENT_ICONS: Record<TransportEventType, string> = {
seed_acquisition: '🌱',
planting: '🌿',
growing_transport: '🚚',
harvest: '🥬',
processing: '⚙️',
distribution: '📦',
consumer_delivery: '🏠',
seed_saving: '💾',
seed_sharing: '🤝',
};
const EVENT_COLORS: Record<TransportEventType, string> = {
seed_acquisition: 'bg-green-500',
planting: 'bg-emerald-500',
growing_transport: 'bg-blue-500',
harvest: 'bg-yellow-500',
processing: 'bg-orange-500',
distribution: 'bg-purple-500',
consumer_delivery: 'bg-pink-500',
seed_saving: 'bg-teal-500',
seed_sharing: 'bg-indigo-500',
};
function formatEventType(type: TransportEventType): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatLocation(location: { city?: string; region?: string; address?: string }): string {
if (location.city && location.region) {
return `${location.city}, ${location.region}`;
}
return location.city || location.region || location.address || 'Unknown location';
}
export default function TransportTimeline({ plantId, events }: TransportTimelineProps) {
if (events.length === 0) {
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Transport Timeline</h3>
<p className="text-gray-500 text-center py-8">No transport events recorded for this plant yet.</p>
</div>
);
}
const sortedEvents = [...events].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900">Transport Timeline</h3>
<span className="text-sm text-gray-500">Plant ID: {plantId.slice(0, 8)}...</span>
</div>
<div className="relative">
{/* Vertical line */}
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200"></div>
<div className="space-y-6">
{sortedEvents.map((event, index) => (
<div key={event.id} className="relative flex items-start">
{/* Event icon */}
<div
className={`relative z-10 flex items-center justify-center w-12 h-12 rounded-full ${EVENT_COLORS[event.eventType]} text-white text-xl shadow-lg`}
>
{EVENT_ICONS[event.eventType]}
</div>
{/* Event content */}
<div className="ml-4 flex-1 min-w-0">
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-gray-900">
{formatEventType(event.eventType)}
</h4>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
event.status === 'verified'
? 'bg-green-100 text-green-800'
: event.status === 'delivered'
? 'bg-blue-100 text-blue-800'
: event.status === 'in_transit'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{event.status}
</span>
</div>
<p className="text-sm text-gray-600 mb-3">{formatDate(event.timestamp)}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div>
<span className="text-gray-500">From:</span>
<p className="font-medium text-gray-900">
{formatLocation(event.fromLocation)}
</p>
</div>
<div>
<span className="text-gray-500">To:</span>
<p className="font-medium text-gray-900">
{formatLocation(event.toLocation)}
</p>
</div>
</div>
<div className="mt-3 pt-3 border-t border-gray-200 grid grid-cols-3 gap-2 text-sm">
<div>
<span className="text-gray-500">Distance</span>
<p className="font-medium text-gray-900">{event.distanceKm.toFixed(1)} km</p>
</div>
<div>
<span className="text-gray-500">Duration</span>
<p className="font-medium text-gray-900">{event.durationMinutes} min</p>
</div>
<div>
<span className="text-gray-500">Carbon</span>
<p className="font-medium text-gray-900">
{event.carbonFootprintKg.toFixed(3)} kg
</p>
</div>
</div>
<div className="mt-2 text-xs text-gray-500">
Transport: {event.transportMethod.replace(/_/g, ' ')}
</div>
{event.notes && (
<p className="mt-2 text-sm text-gray-600 italic">"{event.notes}"</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Summary */}
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-2xl font-bold text-gray-900">{events.length}</p>
<p className="text-sm text-gray-500">Total Events</p>
</div>
<div>
<p className="text-2xl font-bold text-blue-600">
{events.reduce((sum, e) => sum + e.distanceKm, 0).toFixed(1)} km
</p>
<p className="text-sm text-gray-500">Total Distance</p>
</div>
<div>
<p className="text-2xl font-bold text-green-600">
{events.reduce((sum, e) => sum + e.carbonFootprintKg, 0).toFixed(2)} kg
</p>
<p className="text-sm text-gray-500">Total Carbon</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,6 @@
export { default as TransportTimeline } from './TransportTimeline';
export { default as JourneyMap } from './JourneyMap';
export { default as CarbonFootprintCard } from './CarbonFootprintCard';
export { default as QRCodeDisplay } from './QRCodeDisplay';
export { default as TransportEventForm } from './TransportEventForm';
export type { TransportEventFormData } from './TransportEventForm';