diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..07a692e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Core Commands + +### Development +```bash +npm run dev # Start Vite dev server at localhost:5173 +npm run build # Build production bundle to dist/ +npm run preview # Preview production build locally +``` + +### Testing & Quality +```bash +npm test # Run tests with Vitest +npm run lint # Run ESLint +``` + +### Docker Operations +```bash +docker compose up -d # Start containerized app on port 3800 +docker compose down # Stop containers +docker compose build # Build Docker images +``` + +## Architecture Overview + +### Application Type +React + TypeScript SPA for carbon offsetting calculations for yachts, built with Vite. Features both desktop and mobile-specific routes with Progressive Web App (PWA) support. + +### Key Architectural Patterns + +**Component Structure**: The app uses a single-page architecture with client-side routing managed through state in `App.tsx`. Navigation changes `currentPage` state rather than using React Router. + +**Mobile App Route**: Special `/mobile-app` route renders `MobileCalculator` component exclusively, bypassing the standard layout. This is detected via `window.location.pathname` and managed through `isMobileApp` state. + +**Data Flow**: +- Vessel data flows from `App.tsx` → Calculator components → `OffsetOrder` +- Carbon calculations happen in `utils/carbonCalculator.ts` +- API calls go through dedicated clients in `src/api/` + +### API Integration + +**Wren Climate API** (`src/api/wrenClient.ts`): +- Base URL: `https://www.wren.co/api` +- Requires Bearer token authentication via `VITE_WREN_API_TOKEN` +- Handles portfolio fetching and offset order creation +- Implements retry logic and fallback to default portfolio + +**AIS Client** (`src/api/aisClient.ts`): +- Fetches vessel data by IMO number +- Currently uses mock data in development + +**Formspree Integration**: +- Contact forms use `VITE_FORMSPREE_CONTACT_ID` and `VITE_FORMSPREE_OFFSET_ID` +- Handled in `Contact.tsx` and offset order components + +### State Management +- Local component state via React hooks +- No global state management library +- Form state managed locally within components +- Calculator results passed via props to OffsetOrder components + +### Styling Approach +- Tailwind CSS for utility-first styling +- Custom glass morphism effects via `index.css` +- Framer Motion for animations +- Responsive design with mobile-first approach + +### Component Responsibilities + +**Core Components**: +- `TripCalculator.tsx`: Desktop carbon calculator with trip details form +- `MobileCalculator.tsx`: Mobile-optimized calculator with step-by-step flow +- `OffsetOrder.tsx` / `MobileOffsetOrder.tsx`: Handles offset purchase flow +- `Home.tsx`: Landing page with hero section and navigation +- `Contact.tsx`: Contact form integration + +**Calculation Logic** (`utils/carbonCalculator.ts`): +- Implements DEFRA emission factors +- Calculates based on distance, speed, engine power +- Returns tons of CO2 and monetary amounts + +### Environment Configuration +Required environment variables in `.env`: +``` +VITE_WREN_API_TOKEN= # Wren API authentication +VITE_FORMSPREE_CONTACT_ID= # Contact form endpoint +VITE_FORMSPREE_OFFSET_ID= # Offset order form endpoint +``` + +### Testing Strategy +- Unit tests using Vitest and React Testing Library +- Test files in `__tests__` directories +- Run with `npm test` + +### Build & Deployment +- Production builds output to `dist/` +- Docker deployment uses Nginx to serve static files on port 3800 +- Host Nginx reverse proxy configuration available in `nginx-host.conf` +- PWA manifest and service worker for mobile app installation \ No newline at end of file diff --git a/src/components/MobileCalculator.tsx b/src/components/MobileCalculator.tsx index 51f5e5c..745f855 100644 --- a/src/components/MobileCalculator.tsx +++ b/src/components/MobileCalculator.tsx @@ -20,11 +20,54 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) { const [speed, setSpeed] = useState('12'); const [fuelRate, setFuelRate] = useState('100'); const [fuelAmount, setFuelAmount] = useState(''); + + // Format number with commas + const formatNumber = (value: string): string => { + const num = value.replace(/,/g, ''); + if (num === '') return ''; + const parts = num.split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return parts.join('.'); + }; + + // Format tons for display + const formatTons = (tons: number): string => { + const fixed = tons.toFixed(2); + const parts = fixed.split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return parts.join('.'); + }; + + const handleFuelAmountChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/,/g, ''); + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setFuelAmount(formatNumber(value)); + } + }; + + const handleDistanceChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/,/g, ''); + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setDistance(formatNumber(value)); + } + }; + + const handleSpeedChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/,/g, ''); + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setSpeed(formatNumber(value)); + } + }; + + const handleFuelRateChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/,/g, ''); + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setFuelRate(formatNumber(value)); + } + }; const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters'); const [tripEstimate, setTripEstimate] = useState(null); const [currency, setCurrency] = useState('USD'); - const [offsetPercentage, setOffsetPercentage] = useState(100); - const [customPercentage, setCustomPercentage] = useState(''); const [customAmount, setCustomAmount] = useState(''); const [showOffsetOrder, setShowOffsetOrder] = useState(false); const [offsetTons, setOffsetTons] = useState(0); @@ -35,40 +78,22 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) { if (calculationType === 'distance') { const estimate = calculateTripCarbon( vesselData, - Number(distance), - Number(speed), - Number(fuelRate) + Number(distance.replace(/,/g, '')), + Number(speed.replace(/,/g, '')), + Number(fuelRate.replace(/,/g, '')) ); setTripEstimate(estimate); } else if (calculationType === 'fuel') { - const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount), fuelUnit === 'gallons'); + const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount.replace(/,/g, '')), fuelUnit === 'gallons'); setTripEstimate({ distance: 0, duration: 0, - fuelConsumption: Number(fuelAmount), + fuelConsumption: Number(fuelAmount.replace(/,/g, '')), co2Emissions }); } }, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, vesselData]); - const handleCustomPercentageChange = useCallback((e: React.ChangeEvent) => { - 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) => { const value = e.target.value; @@ -263,10 +288,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) { Fuel Amount setFuelAmount(e.target.value)} + onChange={handleFuelAmountChange} placeholder="Enter fuel amount" className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent" required @@ -303,10 +327,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) { Distance (nautical miles) setDistance(e.target.value)} + onChange={handleDistanceChange} placeholder="Enter distance" className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent" required @@ -318,11 +341,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) { Average Speed (knots) setSpeed(e.target.value)} + onChange={handleSpeedChange} placeholder="Average speed" className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent" required @@ -334,11 +355,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) { Fuel Rate (liters/hour) setFuelRate(e.target.value)} + onChange={handleFuelRateChange} placeholder="Fuel consumption rate" className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent" required @@ -402,66 +421,23 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
- {tripEstimate.co2Emissions.toFixed(2)} tons + {formatTons(tripEstimate.co2Emissions)} tons
CO₂ Emissions
- {/* Offset Options */} + {/* Action Button */}
-

Offset Options

- -
- {[100, 75, 50, 25].map((percent) => ( - - ))} -
- -
- - % -
- -
-
-
- {calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage).toFixed(2)} tons -
-
- {offsetPercentage}% of total emissions -
-
-
- -
diff --git a/src/components/MobileOffsetOrder.tsx b/src/components/MobileOffsetOrder.tsx index a58f7d1..8a74b57 100644 --- a/src/components/MobileOffsetOrder.tsx +++ b/src/components/MobileOffsetOrder.tsx @@ -48,14 +48,35 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr const [portfolio, setPortfolio] = useState(null); const [loadingPortfolio, setLoadingPortfolio] = useState(true); const [selectedProject, setSelectedProject] = useState(null); + const [offsetPercentage, setOffsetPercentage] = useState(100); // Default to 100% + + // Calculate the actual tons to offset based on percentage + const actualOffsetTons = (tons * offsetPercentage) / 100; + + // Format tons for display + const formatTons = (tons: number): string => { + const fixed = tons.toFixed(2); + const parts = fixed.split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return parts.join('.'); + }; + const [formData, setFormData] = useState({ name: '', email: '', phone: '', company: '', - message: `I would like to offset ${tons.toFixed(2)} tons of CO2 from my yacht's emissions.` + message: `I would like to offset ${formatTons(actualOffsetTons)} tons of CO2 from my yacht's emissions.` }); + // Update form message when percentage changes + useEffect(() => { + setFormData(prev => ({ + ...prev, + message: `I would like to offset ${formatTons(actualOffsetTons)} tons of CO2 (${offsetPercentage}% of ${formatTons(tons)} tons) from my yacht's emissions.` + })); + }, [offsetPercentage, actualOffsetTons, tons]); + useEffect(() => { if (!config.wrenApiKey) { setError('Carbon offset service is currently unavailable. Please use our contact form to request offsetting.'); @@ -101,7 +122,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr try { if (config.wrenApiKey) { // Try to create real order if API key is available - const newOrder = await createOffsetOrder(portfolio.id, tons, true); // Using dryRun for demo + const newOrder = await createOffsetOrder(portfolio.id, actualOffsetTons, true); // Using dryRun for demo setOrder(newOrder); } else { // Create a mock order for demo purposes @@ -109,7 +130,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr id: 'DEMO-' + Date.now().toString().slice(-8), amountCharged: Math.round(offsetCost * 100), // Convert to cents currency: currency, - tons: tons, + tons: actualOffsetTons, portfolio: portfolio, status: 'completed', createdAt: new Date().toISOString(), @@ -126,7 +147,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr id: 'DEMO-' + Date.now().toString().slice(-8), amountCharged: Math.round(offsetCost * 100), currency: currency, - tons: tons, + tons: actualOffsetTons, portfolio: portfolio || { id: 0, name: 'Carbon Offset Portfolio', @@ -158,7 +179,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr } }; - const offsetCost = monetaryAmount || (portfolio ? tons * (portfolio.pricePerTon || 18) : 0); + const offsetCost = monetaryAmount || (portfolio ? actualOffsetTons * (portfolio.pricePerTon || 18) : 0); const targetCurrency = getCurrencyByCode(currency); const handleInputChange = (field: keyof typeof formData, value: string) => { @@ -283,9 +304,66 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr ) : portfolio ? ( <> + {/* Offset Percentage Slider */} +
+

Choose Your Offset Amount

+
+
+
+ Offset Percentage: + {offsetPercentage}% +
+
+ {/* Tick marks container - positioned below the slider */} +
+ {[0, 25, 50, 75, 100].map((tick) => ( +
+ ))} +
+ {/* Slider - positioned above tick marks */} + setOffsetPercentage(Number(e.target.value))} + className="relative z-10 w-full h-3 bg-gray-200 rounded-lg appearance-none cursor-pointer slider" + style={{ + background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${offsetPercentage}%, #e5e7eb ${offsetPercentage}%, #e5e7eb 100%)` + }} + /> +
+
+ 0% + 25% + 50% + 75% + 100% +
+
+
+
+ CO₂ to Offset: + + {formatTons(actualOffsetTons)} tons + +
+
+ {offsetPercentage}% of {formatTons(tons)} tons total emissions +
+
+
+
+

Offset Summary

- +
@@ -293,7 +371,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
From your yacht emissions
- {tons.toFixed(2)} tons + {formatTons(actualOffsetTons)} tons
@@ -412,7 +490,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
Total CO₂ Offset - {tons.toFixed(2)} tons + {formatTons(tons)} tons
Portfolio Price diff --git a/src/components/OffsetOrder.tsx b/src/components/OffsetOrder.tsx index 22f2747..1beab9f 100644 --- a/src/components/OffsetOrder.tsx +++ b/src/components/OffsetOrder.tsx @@ -49,14 +49,35 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr const [portfolio, setPortfolio] = useState(null); const [loadingPortfolio, setLoadingPortfolio] = useState(true); const [selectedProject, setSelectedProject] = useState(null); + const [offsetPercentage, setOffsetPercentage] = useState(100); // Default to 100% + + // Calculate the actual tons to offset based on percentage + const actualOffsetTons = (tons * offsetPercentage) / 100; + + // Format tons for display + const formatTons = (tons: number): string => { + const fixed = tons.toFixed(2); + const parts = fixed.split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return parts.join('.'); + }; + const [formData, setFormData] = useState({ name: '', email: '', phone: '', company: '', - message: `I would like to offset ${tons.toFixed(2)} tons of CO2 from my yacht's ${calculatorType} emissions.` + message: `I would like to offset ${formatTons(actualOffsetTons)} tons of CO2 from my yacht's ${calculatorType} emissions.` }); + // Update form message when percentage changes + useEffect(() => { + setFormData(prev => ({ + ...prev, + message: `I would like to offset ${formatTons(actualOffsetTons)} tons of CO2 (${offsetPercentage}% of ${formatTons(tons)} tons) from my yacht's ${calculatorType} emissions.` + })); + }, [offsetPercentage, actualOffsetTons, tons, calculatorType]); + useEffect(() => { if (!config.wrenApiKey) { setError('Carbon offset service is currently unavailable. Please use our contact form to request offsetting.'); @@ -98,12 +119,12 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr const handleOffsetOrder = async () => { if (!portfolio) return; - + setLoading(true); setError(null); - + try { - const newOrder = await createOffsetOrder(portfolio.id, tons); + const newOrder = await createOffsetOrder(portfolio.id, actualOffsetTons); setOrder(newOrder); setSuccess(true); } catch (err) { @@ -126,7 +147,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr }; // Calculate offset cost using the portfolio price - const offsetCost = monetaryAmount || (portfolio ? tons * (portfolio.pricePerTon || 18) : 0); + const offsetCost = monetaryAmount || (portfolio ? actualOffsetTons * (portfolio.pricePerTon || 18) : 0); // Robust project click handler with multiple fallbacks const handleProjectClick = useCallback((project: OffsetProject, e?: React.MouseEvent) => { @@ -180,7 +201,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr Offset Your Impact

- You're about to offset {tons.toFixed(2)} tons of CO₂ + You're about to offset {formatTons(tons)} tons of CO₂

@@ -354,20 +375,22 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr {portfolio.projects.map((project, index) => ( - {/* Header with title and percentage */} -
-
- -

{project.name}

+ {/* Header with title and percentage - Fixed height for alignment */} +
+
+
+ +
+

{project.name}

{project.percentage && ( - + {(project.percentage * 100).toFixed(1)}% )} @@ -385,35 +408,38 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
)} - {/* Description */} -

+ {/* Description - This will grow to push price and button to bottom */} +

{project.shortDescription || project.description}

- {/* Price info */} -
-
- Price per ton: - - ${project.pricePerTon.toFixed(2)} - + {/* Bottom section - Always aligned at the bottom */} +
+ {/* Price info */} +
+
+ Price per ton: + + ${project.pricePerTon.toFixed(2)} + +
-
- {/* Click button - Primary call to action */} - + {/* Click button - Primary call to action */} + +
))} @@ -432,7 +458,70 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
- +

Choose Your Offset Amount

+
+
+
+ Offset Percentage: + {offsetPercentage}% +
+
+ {/* Tick marks - visible notches */} +
+ {[0, 25, 50, 75, 100].map((tick) => ( +
+ ))} +
+ {/* Slider */} + setOffsetPercentage(Number(e.target.value))} + className="relative z-10 w-full h-3 bg-gray-200 rounded-lg appearance-none cursor-pointer slider" + style={{ + background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${offsetPercentage}%, #e5e7eb ${offsetPercentage}%, #e5e7eb 100%)` + }} + /> +
+ {/* Percentage labels aligned with tick marks */} +
+ 0% + 25% + 50% + 75% + 100% +
+
+
+
+ CO₂ to Offset: + + {formatTons(actualOffsetTons)} tons + +
+
+ {offsetPercentage}% of {formatTons(tons)} tons total emissions +
+
+
+
+ +
Amount to Offset: - {tons.toFixed(2)} tons CO₂ + {formatTons(actualOffsetTons)} tons CO₂
Portfolio Distribution: diff --git a/src/components/TripCalculator.tsx b/src/components/TripCalculator.tsx index e3b896c..e20d23a 100644 --- a/src/components/TripCalculator.tsx +++ b/src/components/TripCalculator.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { Route } from 'lucide-react'; +import { Leaf } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import type { VesselData, TripEstimate, CurrencyCode } from '../types'; import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator'; @@ -17,11 +17,46 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) { const [speed, setSpeed] = useState('12'); const [fuelRate, setFuelRate] = useState('100'); const [fuelAmount, setFuelAmount] = useState(''); + + // Format number with commas + const formatNumber = (value: string): string => { + const num = value.replace(/,/g, ''); + if (num === '') return ''; + const parts = num.split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + return parts.join('.'); + }; + + const handleFuelAmountChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/,/g, ''); + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setFuelAmount(formatNumber(value)); + } + }; + + const handleDistanceChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/,/g, ''); + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setDistance(formatNumber(value)); + } + }; + + const handleSpeedChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/,/g, ''); + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setSpeed(formatNumber(value)); + } + }; + + const handleFuelRateChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/,/g, ''); + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setFuelRate(formatNumber(value)); + } + }; const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters'); const [tripEstimate, setTripEstimate] = useState(null); const [currency, setCurrency] = useState('USD'); - const [offsetPercentage, setOffsetPercentage] = useState(100); - const [customPercentage, setCustomPercentage] = useState(''); const [customAmount, setCustomAmount] = useState(''); const handleCalculate = useCallback((e: React.FormEvent) => { @@ -29,40 +64,19 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) { if (calculationType === 'distance') { const estimate = calculateTripCarbon( vesselData, - Number(distance), - Number(speed), - Number(fuelRate) + Number(distance.replace(/,/g, '')), + Number(speed.replace(/,/g, '')), + Number(fuelRate.replace(/,/g, '')) ); - setTripEstimate(estimate); + // Immediately navigate to projects page without showing results + onOffsetClick?.(estimate.co2Emissions); } else if (calculationType === 'fuel') { - const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount), fuelUnit === 'gallons'); - setTripEstimate({ - distance: 0, - duration: 0, - fuelConsumption: Number(fuelAmount), - co2Emissions - }); + const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount.replace(/,/g, '')), fuelUnit === 'gallons'); + // Immediately navigate to projects page without showing results + onOffsetClick?.(co2Emissions); } - }, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, vesselData]); + }, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, vesselData, onOffsetClick]); - const handleCustomPercentageChange = useCallback((e: React.ChangeEvent) => { - 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) => { const value = e.target.value; @@ -112,12 +126,12 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) { Carbon Calculator - +
@@ -225,7 +239,7 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) { whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} > - Offset Your Impact + Calculate your Impact )}
@@ -251,10 +265,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
setFuelAmount(e.target.value)} + onChange={handleFuelAmountChange} 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 @@ -285,10 +298,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) { Distance (nautical miles) setDistance(e.target.value)} + onChange={handleDistanceChange} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200" required /> @@ -303,11 +315,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) { Average Speed (knots) setSpeed(e.target.value)} + onChange={handleSpeedChange} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200" required /> @@ -322,11 +332,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) { Fuel Consumption Rate (liters per hour) setFuelRate(e.target.value)} + onChange={handleFuelRateChange} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200" required /> @@ -411,93 +419,6 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {

- - - -
- {[100, 75, 50, 25].map((percent, index) => ( - 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}% - - ))} - - - % - -
-
- - -

Selected CO₂ Offset

-

- {calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage).toFixed(2)} tons -

-

- {offsetPercentage}% of {tripEstimate.co2Emissions.toFixed(2)} tons -

-
- - 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 - )} diff --git a/src/index.css b/src/index.css index 6f25700..4cd40ba 100644 --- a/src/index.css +++ b/src/index.css @@ -227,8 +227,53 @@ body { /* Enhanced gradient overlays */ .gradient-luxury { - background: linear-gradient(135deg, - rgba(15, 23, 42, 0.95) 0%, - rgba(30, 64, 175, 0.85) 50%, + background: linear-gradient(135deg, + rgba(15, 23, 42, 0.95) 0%, + rgba(30, 64, 175, 0.85) 50%, rgba(30, 58, 138, 0.9) 100%); } + +/* Custom Range Slider Styles */ +input[type="range"].slider { + -webkit-appearance: none; + width: 100%; + height: 12px; + border-radius: 6px; + background: #e5e7eb; + outline: none; + opacity: 1; + transition: opacity 0.2s; +} + +input[type="range"].slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 24px; + height: 24px; + border-radius: 50%; + background: #3b82f6; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + transition: transform 0.2s, box-shadow 0.2s; +} + +input[type="range"].slider::-moz-range-thumb { + width: 24px; + height: 24px; + border-radius: 50%; + background: #3b82f6; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + transition: transform 0.2s, box-shadow 0.2s; + border: none; +} + +input[type="range"].slider:hover::-webkit-slider-thumb { + transform: scale(1.1); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2); +} + +input[type="range"].slider:hover::-moz-range-thumb { + transform: scale(1.1); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2); +} diff --git a/src/utils/carbonCalculator.ts b/src/utils/carbonCalculator.ts index 28a87ca..a282942 100644 --- a/src/utils/carbonCalculator.ts +++ b/src/utils/carbonCalculator.ts @@ -1,11 +1,15 @@ import type { VesselData, CarbonEstimate, TripEstimate } from '../types'; -// Constants for carbon calculations -const EMISSION_FACTOR = 3.206; // tons of CO₂ per ton of fuel -const FUEL_DENSITY = 0.85; // tons per m³ (or metric tons per kiloliter) +// Constants for carbon calculations (MGO/MDO - Marine Gas Oil / Marine Diesel Oil) +const EMISSION_FACTOR = 3.206; // tons of CO₂ per ton of fuel (MGO/MDO standard) +const FUEL_DENSITY = 0.84; // kg per liter (typical MGO density, range 0.82-0.86 kg/L) const GALLONS_TO_LITERS = 3.78541; // 1 US gallon = 3.78541 liters const LITERS_TO_CUBIC_METERS = 0.001; // 1 liter = 0.001 m³ +// Shortcut constant: kg CO₂ per liter = density × EF +// 0.84 kg/L × 3.206 = 2.693 kg CO₂/L = 0.002693 tons CO₂/L +const CO2_PER_LITER = 0.002693; // tons of CO₂ per liter of MGO/MDO + export function calculateTripCarbon( vesselData: VesselData, distance: number, // nautical miles @@ -13,15 +17,16 @@ export function calculateTripCarbon( fuelRateLitersPerHour: number // liters per hour ): TripEstimate { const tripHours = distance / speed; - + // Calculate total fuel consumption in liters const fuelConsumptionLiters = fuelRateLitersPerHour * tripHours; - + // Convert liters to tons for CO₂ calculation const fuelConsumptionTons = (fuelConsumptionLiters * LITERS_TO_CUBIC_METERS) * FUEL_DENSITY; - - // Calculate CO₂ emissions using the provided formula - // ENM = F(V) * EF / V + + // Calculate CO₂ emissions using per-nautical-mile formula + // Guideline formula: tCO₂ per nm ≈ (LPH × 0.002693) / V + // Implementation: tCO₂ per nm = (LPH × density × EF / 1000) / speed const fuelRateTonsPerHour = (fuelRateLitersPerHour * LITERS_TO_CUBIC_METERS) * FUEL_DENSITY; const emissionsPerNM = (fuelRateTonsPerHour * EMISSION_FACTOR) / speed; const totalEmissions = emissionsPerNM * distance; @@ -37,20 +42,16 @@ export function calculateTripCarbon( export function calculateCarbonFromFuel(fuelAmount: number, isGallons: boolean = false): number { // Convert to liters if input is in gallons const liters = isGallons ? fuelAmount * GALLONS_TO_LITERS : fuelAmount; - + // Convert liters to cubic meters (m³) const cubicMeters = liters * LITERS_TO_CUBIC_METERS; - + // Convert volume to mass (tons) const fuelTons = cubicMeters * FUEL_DENSITY; - - // Calculate CO₂ emissions - const co2Emissions = fuelTons * EMISSION_FACTOR; - - return Number(co2Emissions.toFixed(2)); -} -export function calculateCarbonFromDistance(distance: number): number { - // This is a simplified calculation, consider removing or updating based on the new formula - return calculateCarbonFromFuel(distance * 25); // 25 liters per nautical mile is a rough estimate + // Calculate CO₂ emissions: tCO₂ = t_fuel × EF + // This implements the guideline formula: tCO₂ ≈ liters × 0.002693 + const co2Emissions = fuelTons * EMISSION_FACTOR; + + return Number(co2Emissions.toFixed(2)); } \ No newline at end of file