177 lines
5.0 KiB
TypeScript
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;
|