From 5e642794d8320c3ea8ed56ed6142b9cf74c83b29 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 30 Oct 2025 13:55:51 +0100 Subject: [PATCH] Implement calculator state persistence and fix checkout navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Add useCalculatorState hook with localStorage persistence and 1-hour expiry - State persists through page reloads and Stripe checkout redirects - Automatically clears state on successful payment (paid/fulfilled status) Navigation fixes: - Fix white page issues on checkout success/cancel pages - Replace links with button handlers for proper state-based routing - Pass navigation handlers from App.tsx to checkout pages State persistence integration: - TripCalculator: Save/restore calculator inputs (fuel, distance, custom) - MobileCalculator: Full state persistence for mobile app route - OffsetOrder: Persist offset percentage and portfolio selection - MobileOffsetOrder: Persist offset percentage for mobile flow Carbon impact comparisons: - Add varied carbon impact comparisons with random selection - Display 3 comparisons in preview mode, 5 in success mode - Categories: cars, flights, trees, streaming, homes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.tsx | 16 +- src/components/CarbonImpactComparison.tsx | 176 ++++++++++++ src/components/MobileCalculator.tsx | 37 ++- src/components/MobileOffsetOrder.tsx | 24 +- src/components/OffsetOrder.tsx | 33 ++- src/components/TripCalculator.tsx | 37 ++- src/hooks/useCalculatorState.ts | 130 +++++++++ src/pages/CheckoutCancel.tsx | 22 +- src/pages/CheckoutSuccess.tsx | 52 +++- src/types/carbonEquivalencies.ts | 78 ++++++ src/utils/carbonEquivalencies.ts | 119 ++++++++ src/utils/impactSelector.ts | 318 ++++++++++++++++++++++ 12 files changed, 999 insertions(+), 43 deletions(-) create mode 100644 src/components/CarbonImpactComparison.tsx create mode 100644 src/hooks/useCalculatorState.ts create mode 100644 src/types/carbonEquivalencies.ts create mode 100644 src/utils/carbonEquivalencies.ts create mode 100644 src/utils/impactSelector.ts diff --git a/src/App.tsx b/src/App.tsx index 8dcc240..58ac35e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -113,6 +113,8 @@ function App() { setCurrentPage(page); setMobileMenuOpen(false); setIsMobileApp(false); + setIsCheckoutSuccess(false); // Clear checkout flags when navigating + setIsCheckoutCancel(false); // Clear checkout flags when navigating window.history.pushState({}, '', `/${page === 'home' ? '' : page}`); window.scrollTo({ top: 0, behavior: 'smooth' }); }; @@ -185,12 +187,22 @@ function App() { // If we're on the checkout success route, render only the success page if (isCheckoutSuccess) { - return ; + return ( + handleNavigate('home')} + onNavigateCalculator={() => handleNavigate('calculator')} + /> + ); } // If we're on the checkout cancel route, render only the cancel page if (isCheckoutCancel) { - return ; + return ( + handleNavigate('home')} + onNavigateCalculator={() => handleNavigate('calculator')} + /> + ); } return ( diff --git a/src/components/CarbonImpactComparison.tsx b/src/components/CarbonImpactComparison.tsx new file mode 100644 index 0000000..3687194 --- /dev/null +++ b/src/components/CarbonImpactComparison.tsx @@ -0,0 +1,176 @@ +/** + * CarbonImpactComparison Component + * + * Displays varied carbon offset impact comparisons with animations. + * Uses EPA/DEFRA/IMO 2024 verified conversion factors. + */ + +import { motion, useInView } from 'framer-motion'; +import { useRef } from 'react'; +import * as LucideIcons from 'lucide-react'; +import { selectComparisons } from '../utils/impactSelector'; +import type { CarbonComparison } from '../types/carbonEquivalencies'; + +interface CarbonImpactComparisonProps { + /** Metric tons of CO2e being offset */ + tons: number; + + /** Display variant */ + variant?: 'preview' | 'success'; + + /** Number of comparisons to show (default: 3) */ + count?: number; + + /** Additional CSS classes */ + className?: string; +} + +/** + * Animated counter component with count-up effect + */ +interface AnimatedCounterProps { + value: number; + duration?: number; +} + +function AnimatedCounter({ value, duration = 2 }: AnimatedCounterProps) { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, amount: 0.5 }); + + return ( + + {isInView && ( + + {value.toLocaleString()} + + )} + + ); +} + +/** + * Get Lucide icon component by name + */ +function getLucideIcon(iconName: string): React.ElementType { + const Icon = (LucideIcons as Record)[iconName]; + return Icon || LucideIcons.Leaf; // Fallback to Leaf icon +} + +/** + * Individual comparison card component + */ +interface ComparisonCardProps { + comparison: CarbonComparison; + index: number; +} + +function ComparisonCard({ comparison, index }: ComparisonCardProps) { + const Icon = getLucideIcon(comparison.icon); + + return ( + + {/* Icon */} +
+
+ +
+
+ + {/* Value */} +
+
+ +
+
{comparison.unit}
+
+ + {/* Label */} +
+

{comparison.label}

+ {comparison.description && ( +

{comparison.description}

+ )} +
+ + {/* Source */} +
+ Source: {comparison.source} +
+
+ ); +} + +/** + * Main CarbonImpactComparison component + */ +export function CarbonImpactComparison({ + tons, + variant = 'preview', + count = 3, + className = '', +}: CarbonImpactComparisonProps) { + // Get appropriate comparisons for this CO2 amount + const comparisons = selectComparisons(tons, count); + + // Variant-specific styling + const titleText = variant === 'success' ? 'Your Impact' : 'Making an Impact'; + const subtitleText = + variant === 'success' + ? "Here's what your offset is equivalent to:" + : 'Your carbon offset is equivalent to:'; + + return ( +
+ {/* Header */} + +

{titleText}

+

{subtitleText}

+
+ + {/* Comparison Cards Grid */} +
+ {comparisons.map((comparison, index) => ( + + ))} +
+ + {/* Footer Note */} + +

+ Equivalencies calculated using EPA 2024, DEFRA 2024, and IMO 2024 verified conversion + factors. +

+
+
+ ); +} + +export default CarbonImpactComparison; diff --git a/src/components/MobileCalculator.tsx b/src/components/MobileCalculator.tsx index 761acc1..c80714d 100644 --- a/src/components/MobileCalculator.tsx +++ b/src/components/MobileCalculator.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Calculator, ArrowLeft, Zap, Route, DollarSign } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import type { VesselData, TripEstimate, CurrencyCode } from '../types'; @@ -7,6 +7,7 @@ import { currencies, formatCurrency, getCurrencyByCode } from '../utils/currenci import { CurrencySelect } from './CurrencySelect'; import { PWAInstallPrompt } from './PWAInstallPrompt'; import { MobileOffsetOrder } from './MobileOffsetOrder'; +import { useCalculatorState } from '../hooks/useCalculatorState'; interface Props { vesselData: VesselData; @@ -15,11 +16,15 @@ interface Props { } export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) { - const [calculationType, setCalculationType] = useState<'fuel' | 'distance' | 'custom'>('fuel'); - const [distance, setDistance] = useState(''); - const [speed, setSpeed] = useState('12'); - const [fuelRate, setFuelRate] = useState('100'); - const [fuelAmount, setFuelAmount] = useState(''); + const { state: savedState, saveState } = useCalculatorState(); + + const [calculationType, setCalculationType] = useState<'fuel' | 'distance' | 'custom'>( + savedState?.calculationType || 'fuel' + ); + const [distance, setDistance] = useState(savedState?.distance || ''); + const [speed, setSpeed] = useState(savedState?.speed || '12'); + const [fuelRate, setFuelRate] = useState(savedState?.fuelRate || '100'); + const [fuelAmount, setFuelAmount] = useState(savedState?.fuelAmount || ''); // Format number with commas const formatNumber = (value: string): string => { @@ -65,10 +70,12 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) { setFuelRate(formatNumber(value)); } }; - const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters'); + const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>( + savedState?.fuelUnit || 'liters' + ); const [tripEstimate, setTripEstimate] = useState(null); const [currency, setCurrency] = useState('USD'); - const [customAmount, setCustomAmount] = useState(''); + const [customAmount, setCustomAmount] = useState(savedState?.customAmount || ''); const [showOffsetOrder, setShowOffsetOrder] = useState(false); const [offsetTons, setOffsetTons] = useState(0); const [monetaryAmount, setMonetaryAmount] = useState(); @@ -102,6 +109,20 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) { } }, []); + // Save state to localStorage whenever inputs change + useEffect(() => { + saveState({ + calculationType, + distance, + speed, + fuelRate, + fuelAmount, + fuelUnit, + customAmount, + offsetPercentage: 100, // Default offset percentage + }); + }, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, customAmount, saveState]); + const calculationMethods = [ { id: 'fuel', icon: Zap, label: 'Fuel Based', color: 'blue' }, { id: 'distance', icon: Route, label: 'Distance', color: 'green' }, diff --git a/src/components/MobileOffsetOrder.tsx b/src/components/MobileOffsetOrder.tsx index 55ab112..b4b0e70 100644 --- a/src/components/MobileOffsetOrder.tsx +++ b/src/components/MobileOffsetOrder.tsx @@ -10,6 +10,8 @@ import { RadialProgress } from './RadialProgress'; import { PortfolioDonutChart } from './PortfolioDonutChart'; import { getProjectColor } from '../utils/portfolioColors'; import { CertificationBadge } from './CertificationBadge'; +import { CarbonImpactComparison } from './CarbonImpactComparison'; +import { useCalculatorState } from '../hooks/useCalculatorState'; interface Props { tons: number; @@ -58,6 +60,8 @@ const ProjectTypeIcon = ({ project }: ProjectTypeIconProps) => { }; export function MobileOffsetOrder({ tons, monetaryAmount, onBack }: Props) { + const { state: savedState, saveState } = useCalculatorState(); + const [currentStep, setCurrentStep] = useState<'summary' | 'projects' | 'confirmation'>('summary'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -65,7 +69,9 @@ export function MobileOffsetOrder({ tons, monetaryAmount, onBack }: Props) { const [order, setOrder] = useState(null); const [portfolio, setPortfolio] = useState(null); const [loadingPortfolio, setLoadingPortfolio] = useState(true); - const [offsetPercentage, setOffsetPercentage] = useState(100); // Default to 100% + const [offsetPercentage, setOffsetPercentage] = useState( + savedState?.offsetPercentage ?? 100 // Default to 100% or use saved value + ); // Calculate the actual tons to offset based on percentage const actualOffsetTons = (tons * offsetPercentage) / 100; @@ -94,6 +100,17 @@ export function MobileOffsetOrder({ tons, monetaryAmount, onBack }: Props) { })); }, [offsetPercentage, actualOffsetTons, tons]); + // Save offset percentage and portfolio ID to localStorage + useEffect(() => { + if (savedState) { + saveState({ + ...savedState, + offsetPercentage, + portfolioId: portfolio?.id, + }); + } + }, [offsetPercentage, portfolio, savedState, saveState]); + useEffect(() => { if (!config.wrenApiKey) { setError('Carbon offset service is currently unavailable. Please use our contact form to request offsetting.'); @@ -409,6 +426,11 @@ export function MobileOffsetOrder({ tons, monetaryAmount, onBack }: Props) {

{portfolio.description}

+ {/* Carbon Impact Comparisons */} +
+ +
+
{/* Help Section */} diff --git a/src/pages/CheckoutSuccess.tsx b/src/pages/CheckoutSuccess.tsx index 31b869d..ab3522e 100644 --- a/src/pages/CheckoutSuccess.tsx +++ b/src/pages/CheckoutSuccess.tsx @@ -2,11 +2,29 @@ import { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; import { getOrderDetails } from '../api/checkoutClient'; import { OrderDetailsResponse } from '../types'; +import { CarbonImpactComparison } from '../components/CarbonImpactComparison'; +import { useCalculatorState } from '../hooks/useCalculatorState'; -export default function CheckoutSuccess() { +interface CheckoutSuccessProps { + onNavigateHome: () => void; + onNavigateCalculator: () => void; +} + +export default function CheckoutSuccess({ + onNavigateHome, + onNavigateCalculator +}: CheckoutSuccessProps) { const [orderDetails, setOrderDetails] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { clearState } = useCalculatorState(); + + // Clear calculator state on successful payment (per user preference) + useEffect(() => { + if (orderDetails && (orderDetails.order.status === 'paid' || orderDetails.order.status === 'fulfilled')) { + clearState(); + } + }, [orderDetails, clearState]); useEffect(() => { const fetchOrderDetails = async () => { @@ -60,12 +78,12 @@ export default function CheckoutSuccess() {
⚠️

Order Not Found

{error || 'Unable to retrieve order details'}

- Return to Home - + ); @@ -136,7 +154,7 @@ export default function CheckoutSuccess() { {/* Processing Fee */}
- Processing Fee (3%) + Processing Fee (5%) ${processingFee.toFixed(2)} @@ -182,18 +200,16 @@ export default function CheckoutSuccess() {
- {/* Impact Message */} + {/* Impact Comparisons */} -

🌍 Making an Impact

-

- You've offset {order.tons} tons of CO₂ - equivalent to planting approximately{' '} - {Math.round(order.tons * 50)} trees! -

+
+ +
{/* Action Buttons */} @@ -203,12 +219,18 @@ export default function CheckoutSuccess() { transition={{ delay: 0.5 }} className="flex flex-col sm:flex-row gap-4 justify-center" > - Return to Home - + +