Matt 94f422e540
All checks were successful
Build and Push Docker Images / docker (push) Successful in 2m27s
Remove invalid size prop from RechartsPortfolioPieChart component
Fixed TypeScript error where size prop was being passed to RechartsPortfolioPieChart
but the component only accepts projects and totalTons props. The component is
responsive by default and doesn't need a size parameter.

This resolves the Docker build error:
Property 'size' does not exist on type 'IntrinsicAttributes & RechartsPortfolioPieChartProps'

Verified with tsc --noEmit that no other TypeScript errors exist.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 15:49:49 +01:00

536 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { Leaf } from 'lucide-react';
import { getOrderDetails } from '../../../src/api/checkoutClient';
import { OrderDetailsResponse } from '../../../src/types';
import { CarbonImpactComparison } from '../../../src/components/CarbonImpactComparison';
import { RechartsPortfolioPieChart } from '../../../src/components/RechartsPortfolioPieChart';
import { useCalculatorState } from '../../../src/hooks/useCalculatorState';
// Map backend status to user-friendly labels
const getStatusDisplay = (status: string): { label: string; className: string } => {
switch (status) {
case 'paid':
case 'fulfilled':
return { label: 'Confirmed', className: 'bg-green-100 text-green-700' };
case 'pending':
return { label: 'Processing', className: 'bg-yellow-100 text-yellow-700' };
default:
return { label: status.toUpperCase(), className: 'bg-slate-100 text-slate-700' };
}
};
// Format currency with commas
const formatCurrency = (amount: number): string => {
return amount.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
};
export default function CheckoutSuccessPage() {
const router = useRouter();
const [orderDetails, setOrderDetails] = useState<OrderDetailsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 () => {
// Get session ID from URL
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
if (!sessionId) {
setError('No session ID found in URL');
setLoading(false);
return;
}
try {
const details = await getOrderDetails(sessionId);
setOrderDetails(details);
} catch (err) {
setError('Failed to load order details');
console.error(err);
} finally {
setLoading(false);
}
};
fetchOrderDetails();
}, []);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center"
>
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-slate-600 font-medium">Loading your order details...</p>
</motion.div>
</div>
);
}
if (error || !orderDetails) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-md w-full bg-white rounded-2xl shadow-luxury p-8 text-center"
>
<div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-slate-800 mb-2">Order Not Found</h2>
<p className="text-slate-600 mb-6">{error || 'Unable to retrieve order details'}</p>
<button
onClick={() => router.push('/')}
className="inline-block px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Return to Home
</button>
</motion.div>
</div>
);
}
const { order, session } = orderDetails;
const totalAmount = order.totalAmount / 100; // Convert cents to dollars
const baseAmount = order.baseAmount / 100;
const processingFee = order.processingFee / 100;
// Use Stripe payment status if available (more accurate for just-completed payments)
// Otherwise fall back to order status
const effectiveStatus = session.paymentStatus === 'paid' ? 'paid' : order.status;
const statusDisplay = getStatusDisplay(effectiveStatus);
return (
<>
{/* Print-specific styles */}
<style>{`
@media print {
/* Force print backgrounds and colors */
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
/* Hide non-print elements */
.no-print {
display: none !important;
}
/* Page breaks */
.print-page-break {
page-break-after: always !important;
break-after: page !important;
}
/* Page setup */
@page {
margin: 0.3in;
size: letter;
}
/* Main container - optimize for print */
.print-receipt {
max-width: 100% !important;
margin: 0 !important;
padding: 0.15rem !important;
}
/* Aggressive spacing compression */
.p-8, .px-8, .py-8 {
padding: 0.3rem !important;
}
.p-6, .px-6, .py-6 {
padding: 0.3rem !important;
}
.p-5 {
padding: 0.3rem !important;
}
.mb-8, .mb-6 {
margin-bottom: 0.15rem !important;
}
.mt-8, .mt-6 {
margin-top: 0.15rem !important;
}
.mb-4 {
margin-bottom: 0.1rem !important;
}
.mt-4 {
margin-top: 0.1rem !important;
}
.mb-2 {
margin-bottom: 0.08rem !important;
}
/* Spacing between elements */
.space-y-1 > * + * {
margin-top: 0.08rem !important;
}
.space-y-3 > * + * {
margin-top: 0.1rem !important;
}
.space-y-4 > * + * {
margin-top: 0.15rem !important;
}
.gap-3 {
gap: 0.15rem !important;
}
.gap-5 {
gap: 0.15rem !important;
}
/* Font size optimization */
.text-4xl {
font-size: 1.1rem !important;
}
.text-3xl {
font-size: 1rem !important;
}
.text-2xl {
font-size: 0.9rem !important;
}
.text-xl {
font-size: 0.85rem !important;
}
.text-lg {
font-size: 0.8rem !important;
}
.text-base {
font-size: 0.75rem !important;
}
.text-sm {
font-size: 0.7rem !important;
}
.text-xs {
font-size: 0.65rem !important;
}
/* Line height compression */
* {
line-height: 1.2 !important;
animation: none !important;
transition: none !important;
}
h1, h2, h3 {
line-height: 1.15 !important;
}
/* Logo sizing */
.print-logo {
max-width: 80px !important;
margin-bottom: 0.15rem !important;
}
/* Compact grid layouts */
.grid {
gap: 0.15rem !important;
}
/* Metadata grid - keep 2 columns but more compact */
.md\\:grid-cols-2 {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
}
/* Make single column items span only 1 column in print */
.md\\:col-span-2 {
grid-column: span 1 !important;
}
/* Reduce rounded corners for print to save space */
.rounded-3xl, .rounded-2xl, .rounded-xl {
border-radius: 0.25rem !important;
}
/* Compact the header gradient section */
.bg-gradient-to-br {
padding-top: 0.3rem !important;
padding-bottom: 0.3rem !important;
}
/* Reduce shadow to save ink/space */
.shadow-2xl, .shadow-xl, .shadow-lg {
box-shadow: none !important;
}
}
`}</style>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 flex items-center justify-center p-4 sm:p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-full sm:max-w-4xl lg:max-w-5xl w-full print-receipt"
>
{/* Receipt Container - Page 1 */}
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden print:rounded-none print:shadow-none print-page-break">
{/* Header with Logo */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-gradient-to-br from-cyan-500 via-blue-500 to-indigo-600 p-8 text-center"
>
<img
src="/puffinOffset.webp"
alt="Puffin Offset"
className="h-24 mx-auto mb-4 print-logo"
/>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">
Order Confirmed
</h1>
<p className="text-cyan-50 text-lg">
Thank you for your carbon offset purchase
</p>
</motion.div>
{/* Success Badge */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="flex justify-center -mt-8 mb-6 no-print"
>
<div className="bg-green-500 text-white rounded-full p-6 shadow-xl border-4 border-white">
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</motion.div>
{/* Order Details Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="px-8 py-6"
>
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-3 border-b-2 border-slate-200">
Order Summary
</h2>
<div className="space-y-1 mb-6">
{/* Carbon Offset - Highlighted */}
<div className="bg-gradient-to-r from-emerald-50 to-teal-50 rounded-xl p-6 mb-4 border-l-4 border-emerald-500">
<div className="flex justify-between items-center">
<div>
<span className="text-sm text-emerald-700 font-medium uppercase tracking-wide">Carbon Offset</span>
<p className="text-3xl font-bold text-emerald-900 mt-1">{order.tons} tons CO</p>
</div>
<div className="text-emerald-600">
<Leaf className="w-16 h-16" />
</div>
</div>
</div>
{/* Pricing Breakdown */}
<div className="bg-slate-50 rounded-lg p-5 my-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-slate-700">Offset Cost</span>
<span className="text-slate-900 font-semibold">
${formatCurrency(baseAmount)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-700">Processing Fee (3%)</span>
<span className="text-slate-900 font-semibold">
${formatCurrency(processingFee)}
</span>
</div>
<div className="border-t-2 border-slate-300 pt-3 mt-3">
<div className="flex justify-between items-center">
<span className="text-slate-800 font-bold text-lg">Total Paid</span>
<span className="text-blue-600 font-bold text-3xl">
${formatCurrency(totalAmount)}
</span>
</div>
</div>
</div>
</div>
{/* Order Metadata */}
<div className="bg-gradient-to-r from-slate-50 to-blue-50 rounded-lg p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Payment ID (Stripe) */}
<div>
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Payment ID</span>
<p className="text-slate-800 font-mono text-xs mt-1 break-all">{order.stripeSessionId}</p>
</div>
{/* Offsetting Order ID (Wren) - Only show if fulfilled */}
{order.wrenOrderId && (
<div>
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Offsetting Order ID</span>
<p className="text-slate-800 font-mono text-xs mt-1 break-all">{order.wrenOrderId}</p>
</div>
)}
<div className={order.wrenOrderId ? '' : 'md:col-start-2'}>
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Status</span>
<p className="mt-2">
<span className={`inline-block px-4 py-1.5 rounded-full text-sm font-bold ${statusDisplay.className}`}>
{statusDisplay.label}
</span>
</p>
</div>
{session.customerEmail && (
<div className="md:col-span-2">
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Email</span>
<p className="text-slate-800 font-medium mt-1">{session.customerEmail}</p>
</div>
)}
<div className="md:col-span-2">
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Date</span>
<p className="text-slate-800 font-medium mt-1">
{new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
</div>
</div>
</div>
</motion.div>
</div>
{/* Portfolio Distribution Chart - Page 2 */}
{orderDetails.order.portfolio?.projects && orderDetails.order.portfolio.projects.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35 }}
className="mt-6"
>
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden p-8 print:rounded-none print:shadow-none print:border-none print-page-break">
<h2 className="text-2xl font-bold text-slate-800 mb-2 text-center print:text-xl">
Your Carbon Offset Distribution
</h2>
<p className="text-slate-600 text-center mb-8 print:text-sm print:mb-4">
Your {order.tons} tons of CO offsets are distributed across these verified projects:
</p>
<RechartsPortfolioPieChart
projects={orderDetails.order.portfolio.projects}
totalTons={order.tons}
/>
</div>
</motion.div>
)}
{/* Impact Comparisons - Page 3 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-6"
>
<div className="bg-gradient-to-br from-emerald-600 via-teal-600 to-cyan-600 rounded-3xl p-8 shadow-2xl print:rounded-none print:shadow-none print:bg-white print:border print:border-gray-300 print-page-break">
<CarbonImpactComparison tons={order.tons} variant="success" count={3} />
</div>
</motion.div>
{/* Action Buttons */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="flex flex-col sm:flex-row gap-4 justify-center mt-8 no-print"
>
<button
onClick={() => router.push('/')}
className="px-8 py-4 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-xl hover:from-blue-600 hover:to-cyan-600 transition-all hover:shadow-xl font-bold text-center transform hover:scale-105"
>
Return to Home
</button>
<button
onClick={() => router.push('/calculator')}
className="px-8 py-4 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-xl hover:from-green-600 hover:to-emerald-600 transition-all hover:shadow-xl font-bold text-center transform hover:scale-105"
>
Calculate Another Offset
</button>
<button
onClick={() => window.print()}
className="px-8 py-4 bg-white text-slate-700 rounded-xl hover:bg-slate-50 transition-all hover:shadow-xl font-bold border-2 border-slate-300 flex items-center justify-center gap-2 transform hover:scale-105"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Print Receipt
</button>
</motion.div>
{/* Confirmation Email Notice */}
{session.customerEmail && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="text-center mt-6 no-print"
>
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-lg inline-block">
<p className="text-blue-800 font-medium">
<svg className="w-5 h-5 inline mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
Confirmation email sent to {session.customerEmail}
</p>
</div>
</motion.div>
)}
{/* Footer */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
className="text-center text-slate-500 text-sm mt-8 pb-6 no-print"
>
<p>Thank you for making a positive impact on our planet</p>
<p className="mt-2">Questions? Contact us at support@puffinoffset.com</p>
</motion.div>
</motion.div>
</div>
</>
);
}