puffin-app/src/components/CarbonImpactComparison.tsx
Matt 5e642794d8
All checks were successful
Build and Push Docker Images / docker (push) Successful in 49s
Implement calculator state persistence and fix checkout navigation
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 <a> 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 <noreply@anthropic.com>
2025-10-30 13:55:51 +01:00

177 lines
5.0 KiB
TypeScript

/**
* 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 (
<motion.span
ref={ref}
initial={{ opacity: 0 }}
animate={isInView ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: 0.3 }}
>
{isInView && (
<motion.span
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 100, damping: 10 }}
>
{value.toLocaleString()}
</motion.span>
)}
</motion.span>
);
}
/**
* Get Lucide icon component by name
*/
function getLucideIcon(iconName: string): React.ElementType {
const Icon = (LucideIcons as Record<string, React.ElementType>)[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 (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1, duration: 0.5 }}
className="bg-white/10 backdrop-blur-md rounded-xl p-6 border border-white/20 hover:border-emerald-400/50 transition-all duration-300 hover:shadow-lg hover:shadow-emerald-500/20"
>
{/* Icon */}
<div className="mb-4 flex items-center justify-center">
<div className="bg-emerald-500/20 p-3 rounded-full">
<Icon className="w-8 h-8 text-emerald-400" />
</div>
</div>
{/* Value */}
<div className="text-center mb-2">
<div className="text-3xl font-bold text-white">
<AnimatedCounter value={comparison.value} />
</div>
<div className="text-sm text-emerald-300 mt-1">{comparison.unit}</div>
</div>
{/* Label */}
<div className="text-center">
<p className="text-sm text-white/90 font-medium">{comparison.label}</p>
{comparison.description && (
<p className="text-xs text-white/60 mt-2">{comparison.description}</p>
)}
</div>
{/* Source */}
<div className="text-center mt-4">
<span className="text-xs text-white/50 italic">Source: {comparison.source}</span>
</div>
</motion.div>
);
}
/**
* 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 (
<div className={`w-full ${className}`}>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center mb-8"
>
<h3 className="text-2xl md:text-3xl font-bold text-white mb-2">{titleText}</h3>
<p className="text-white/80 text-sm md:text-base">{subtitleText}</p>
</motion.div>
{/* Comparison Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{comparisons.map((comparison, index) => (
<ComparisonCard
key={`${comparison.category}-${comparison.label}`}
comparison={comparison}
index={index}
/>
))}
</div>
{/* Footer Note */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5, duration: 0.5 }}
className="text-center mt-6"
>
<p className="text-xs text-white/50">
Equivalencies calculated using EPA 2024, DEFRA 2024, and IMO 2024 verified conversion
factors.
</p>
</motion.div>
</div>
);
}
export default CarbonImpactComparison;