puffin-app/project/src/components/TripCalculator.tsx
Matt fe0c1c182f Restore project/ folder for comparison reference
- Restored legacy Vite application folder from git history
- Needed for comparing old vs new Next.js implementation
- Contains original component implementations and utilities
2025-11-03 14:23:42 +01:00

499 lines
19 KiB
TypeScript

import { useState, useCallback } from 'react';
import { Route } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import type { VesselData, TripEstimate, CurrencyCode } from '../types';
import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
import { currencies, formatCurrency } from '../utils/currencies';
import { CurrencySelect } from './CurrencySelect';
interface Props {
vesselData: VesselData;
onOffsetClick?: (tons: number, monetaryAmount?: number) => void;
}
export function TripCalculator({ vesselData, onOffsetClick }: Props) {
const [calculationType, setCalculationType] = useState<'fuel' | 'distance' | 'custom'>('fuel');
const [distance, setDistance] = useState<string>('');
const [speed, setSpeed] = useState<string>('12');
const [fuelRate, setFuelRate] = useState<string>('100');
const [fuelAmount, setFuelAmount] = useState<string>('');
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters');
const [tripEstimate, setTripEstimate] = useState<TripEstimate | null>(null);
const [currency, setCurrency] = useState<CurrencyCode>('USD');
const [offsetPercentage, setOffsetPercentage] = useState<number>(100);
const [customPercentage, setCustomPercentage] = useState<string>('');
const [customAmount, setCustomAmount] = useState<string>('');
const handleCalculate = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (calculationType === 'distance') {
const estimate = calculateTripCarbon(
vesselData,
Number(distance),
Number(speed),
Number(fuelRate)
);
setTripEstimate(estimate);
} else if (calculationType === 'fuel') {
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount), fuelUnit === 'gallons');
setTripEstimate({
distance: 0,
duration: 0,
fuelConsumption: Number(fuelAmount),
co2Emissions
});
}
}, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, vesselData]);
const handleCustomPercentageChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '' || (Number(value) >= 0 && Number(value) <= 100)) {
setCustomPercentage(value);
if (value !== '') {
setOffsetPercentage(Number(value));
}
}
}, []);
const handlePresetPercentage = useCallback((percentage: number) => {
setOffsetPercentage(percentage);
setCustomPercentage('');
}, []);
const calculateOffsetAmount = useCallback((emissions: number, percentage: number) => {
return (emissions * percentage) / 100;
}, []);
const handleCustomAmountChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '' || Number(value) >= 0) {
setCustomAmount(value);
}
}, []);
// Animation variants
const fadeIn = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { duration: 0.5 }
}
};
const slideIn = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1.0]
}
}
};
return (
<motion.div
className="bg-white rounded-lg shadow-xl p-6 max-w-2xl w-full mt-8"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1]
}}
>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-800">Carbon Offset Calculator</h2>
<motion.div
initial={{ rotate: -10, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Route className="text-blue-500" size={24} />
</motion.div>
</div>
<motion.div
className="mb-6"
variants={fadeIn}
initial="hidden"
animate="visible"
>
<label className="block text-sm font-medium text-gray-700 mb-2">
Calculation Method
</label>
<div className="flex flex-wrap gap-3">
<motion.button
type="button"
onClick={() => setCalculationType('fuel')}
className={`px-4 py-2 rounded-lg transition-colors ${
calculationType === 'fuel'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
Fuel Based
</motion.button>
<motion.button
type="button"
onClick={() => setCalculationType('distance')}
className={`px-4 py-2 rounded-lg transition-colors ${
calculationType === 'distance'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
Distance Based
</motion.button>
<motion.button
type="button"
onClick={() => setCalculationType('custom')}
className={`px-4 py-2 rounded-lg transition-colors ${
calculationType === 'custom'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
Custom Amount
</motion.button>
</div>
</motion.div>
<AnimatePresence mode="wait">
{calculationType === 'custom' ? (
<motion.div
key="custom"
className="space-y-4"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Currency
</label>
<div className="max-w-xs">
<CurrencySelect value={currency} onChange={setCurrency} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Enter Amount to Offset
</label>
<div className="relative rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="text-gray-500 sm:text-sm">{currencies[currency].symbol}</span>
</div>
<input
type="number"
value={customAmount}
onChange={handleCustomAmountChange}
placeholder="Enter amount"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 pl-7 pr-12 focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
required
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<span className="text-gray-500 sm:text-sm">{currency}</span>
</div>
</div>
</div>
{customAmount && Number(customAmount) > 0 && (
<motion.button
onClick={() => onOffsetClick?.(0, Number(customAmount))}
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors mt-6"
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
>
Offset Your Impact
</motion.button>
)}
</motion.div>
) : (
<motion.form
key="calculator"
onSubmit={handleCalculate}
className="space-y-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
{calculationType === 'fuel' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<label className="block text-sm font-medium text-gray-700">
Fuel Consumption
</label>
<div className="flex space-x-4">
<div className="flex-1">
<input
type="number"
min="1"
value={fuelAmount}
onChange={(e) => setFuelAmount(e.target.value)}
placeholder="Enter amount"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
</div>
<div>
<select
value={fuelUnit}
onChange={(e) => setFuelUnit(e.target.value as 'liters' | 'gallons')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
>
<option value="liters">Liters</option>
<option value="gallons">Gallons</option>
</select>
</div>
</div>
</motion.div>
)}
{calculationType === 'distance' && (
<>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<label className="block text-sm font-medium text-gray-700">
Distance (nautical miles)
</label>
<input
type="number"
min="1"
value={distance}
onChange={(e) => setDistance(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<label className="block text-sm font-medium text-gray-700">
Average Speed (knots)
</label>
<input
type="number"
min="1"
max="50"
value={speed}
onChange={(e) => setSpeed(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<label className="block text-sm font-medium text-gray-700">
Fuel Consumption Rate (liters per hour)
</label>
<input
type="number"
min="1"
step="1"
value={fuelRate}
onChange={(e) => setFuelRate(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
<p className="mt-1 text-sm text-gray-500">
Typical range: 50 - 500 liters per hour for most yachts
</p>
</motion.div>
</>
)}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<label className="block text-sm font-medium text-gray-700">
Select Currency
</label>
<div className="max-w-xs">
<CurrencySelect value={currency} onChange={setCurrency} />
</div>
</motion.div>
<motion.button
type="submit"
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: 0.5,
type: "spring",
stiffness: 400,
damping: 17
}}
>
Calculate Impact
</motion.button>
</motion.form>
)}
</AnimatePresence>
<AnimatePresence>
{tripEstimate && calculationType !== 'custom' && (
<motion.div
className="mt-6 space-y-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
<motion.div
className="grid grid-cols-2 gap-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
{calculationType === 'distance' && (
<motion.div
className="bg-gray-50 p-4 rounded-lg"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<p className="text-sm text-gray-600">Trip Duration</p>
<p className="text-xl font-bold text-gray-900">
{tripEstimate.duration.toFixed(1)} hours
</p>
</motion.div>
)}
<motion.div
className="bg-gray-50 p-4 rounded-lg"
initial={{ opacity: 0, x: calculationType === 'distance' ? 20 : 0 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<p className="text-sm text-gray-600">Fuel Consumption</p>
<p className="text-xl font-bold text-gray-900">
{tripEstimate.fuelConsumption.toLocaleString()} {fuelUnit}
</p>
</motion.div>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
>
<label className="block text-sm font-medium text-gray-700 mb-2">
Offset Percentage
</label>
<div className="flex flex-wrap gap-3 mb-3">
{[100, 75, 50, 25].map((percent, index) => (
<motion.button
key={percent}
type="button"
onClick={() => handlePresetPercentage(percent)}
className={`px-4 py-2 rounded-lg transition-colors ${
offsetPercentage === percent && customPercentage === ''
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: 0.6 + (index * 0.1),
type: "spring",
stiffness: 400,
damping: 17
}}
>
{percent}%
</motion.button>
))}
<motion.div
className="flex items-center space-x-2"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 1.0 }}
>
<input
type="number"
value={customPercentage}
onChange={handleCustomPercentageChange}
placeholder="Custom %"
min="0"
max="100"
className="w-24 px-3 py-2 border rounded-lg focus:ring-blue-500 focus:border-blue-500"
/>
<span className="text-gray-600">%</span>
</motion.div>
</div>
</motion.div>
<motion.div
className="bg-blue-50 p-4 rounded-lg"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 1.1 }}
>
<p className="text-sm text-gray-600">Selected CO Offset</p>
<p className="text-2xl font-bold text-blue-900">
{calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage).toFixed(2)} tons
</p>
<p className="text-sm text-blue-600 mt-1">
{offsetPercentage}% of {tripEstimate.co2Emissions.toFixed(2)} tons
</p>
</motion.div>
<motion.button
onClick={() => onOffsetClick?.(calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage))}
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors"
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
delay: 1.2,
type: "spring",
stiffness: 400,
damping: 17
}}
>
Offset Your Impact
</motion.button>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}