Add portfolio distribution chart to receipt and update ID display
All checks were successful
Build and Push Docker Images / docker (push) Successful in 49s
All checks were successful
Build and Push Docker Images / docker (push) Successful in 49s
Backend: - Add getWrenPortfolios() to wrenClient.js with detailed logging including certification status - Modify checkout endpoint to fetch and include portfolio data in order response - Add stripeSessionId to order response for Payment ID display Frontend: - Create new StaticPortfolioPieChart component for printable receipt - Add portfolio distribution visualization to CheckoutSuccess page - Update receipt to show Payment ID (Stripe) and Offsetting Order ID (Wren) - Implement responsive grid layout for order IDs - Add print-friendly styling with Tailwind print classes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
837411699f
commit
6787ccd2d8
@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
import { stripe } from '../config/stripe.js';
|
||||
import { Order } from '../models/Order.js';
|
||||
import { getWrenPortfolios } from '../utils/wrenClient.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@ -166,6 +167,23 @@ router.get('/session/:sessionId', async (req, res) => {
|
||||
// Get Stripe session (optional - for additional details)
|
||||
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
||||
|
||||
// Fetch portfolio data from Wren API
|
||||
let portfolio = null;
|
||||
try {
|
||||
const portfolios = await getWrenPortfolios();
|
||||
// Find the portfolio that matches the order's portfolio ID
|
||||
portfolio = portfolios.find(p => p.id === order.portfolio_id);
|
||||
|
||||
if (portfolio) {
|
||||
console.log(`✅ Portfolio data fetched for order ${order.id} - Portfolio ID: ${portfolio.id}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Portfolio ${order.portfolio_id} not found in Wren API response`);
|
||||
}
|
||||
} catch (portfolioError) {
|
||||
console.error('❌ Failed to fetch portfolio data:', portfolioError.message);
|
||||
// Continue without portfolio data (graceful degradation)
|
||||
}
|
||||
|
||||
res.json({
|
||||
order: {
|
||||
id: order.id,
|
||||
@ -177,7 +195,9 @@ router.get('/session/:sessionId', async (req, res) => {
|
||||
currency: order.currency,
|
||||
status: order.status,
|
||||
wrenOrderId: order.wren_order_id,
|
||||
stripeSessionId: order.stripe_session_id,
|
||||
createdAt: order.created_at,
|
||||
portfolio: portfolio, // Add portfolio data with projects
|
||||
},
|
||||
session: {
|
||||
paymentStatus: session.payment_status,
|
||||
|
||||
@ -131,7 +131,81 @@ export async function getWrenOffsetOrder(orderId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Wren portfolios
|
||||
* @returns {Promise<Array>} Array of portfolio objects with projects
|
||||
*/
|
||||
export async function getWrenPortfolios() {
|
||||
const startTime = Date.now();
|
||||
const apiToken = process.env.WREN_API_TOKEN;
|
||||
|
||||
console.log('🔵 [WREN API SERVER] ========================================');
|
||||
console.log('🔵 [WREN API SERVER] GET /portfolios - Request initiated');
|
||||
console.log('🔵 [WREN API SERVER] Timestamp:', new Date().toISOString());
|
||||
console.log('🔵 [WREN API SERVER] API Token:', apiToken ? `${apiToken.substring(0, 8)}...${apiToken.substring(apiToken.length - 4)}` : 'NOT SET');
|
||||
console.log('🔵 [WREN API SERVER] Request URL:', `${WREN_API_BASE_URL}/portfolios`);
|
||||
|
||||
if (!apiToken) {
|
||||
console.error('❌ [WREN API SERVER] WREN_API_TOKEN not configured');
|
||||
throw new Error('WREN_API_TOKEN environment variable is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${WREN_API_BASE_URL}/portfolios`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log('✅ [WREN API SERVER] GET /portfolios - Success');
|
||||
console.log('✅ [WREN API SERVER] Status:', response.status);
|
||||
console.log('✅ [WREN API SERVER] Duration:', duration + 'ms');
|
||||
console.log('✅ [WREN API SERVER] Portfolios count:', response.data?.portfolios?.length || 0);
|
||||
|
||||
// Log detailed portfolio and project information including certification status
|
||||
if (response.data?.portfolios?.length > 0) {
|
||||
console.log('📊 [WREN API SERVER] Portfolio Details:');
|
||||
response.data.portfolios.forEach((portfolio, idx) => {
|
||||
console.log(` Portfolio ${idx + 1}: "${portfolio.name}" (ID: ${portfolio.id})`);
|
||||
console.log(` ├─ Cost per ton: $${portfolio.cost_per_ton}`);
|
||||
console.log(` └─ Projects (${portfolio.projects?.length || 0}):`);
|
||||
|
||||
if (portfolio.projects?.length > 0) {
|
||||
portfolio.projects.forEach((project, pIdx) => {
|
||||
console.log(` ${pIdx + 1}. "${project.name}"`);
|
||||
console.log(` ├─ Certification: ${project.certification_status || 'N/A'}`);
|
||||
console.log(` ├─ Cost per ton: $${project.cost_per_ton || 'N/A'}`);
|
||||
console.log(` └─ Percentage: ${project.percentage ? (project.percentage * 100).toFixed(1) + '%' : 'N/A'}`);
|
||||
});
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('🔵 [WREN API SERVER] ========================================');
|
||||
|
||||
return response.data?.portfolios || [];
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error('❌ [WREN API SERVER] GET /portfolios - Failed');
|
||||
console.error('❌ [WREN API SERVER] Status:', error.response?.status || 'No response');
|
||||
console.error('❌ [WREN API SERVER] Duration:', duration + 'ms');
|
||||
console.error('❌ [WREN API SERVER] Error message:', error.message);
|
||||
console.error('❌ [WREN API SERVER] Error response:', JSON.stringify(error.response?.data, null, 2));
|
||||
console.log('🔵 [WREN API SERVER] ========================================');
|
||||
|
||||
// Return empty array on error (graceful degradation)
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createWrenOffsetOrder,
|
||||
getWrenOffsetOrder
|
||||
getWrenOffsetOrder,
|
||||
getWrenPortfolios
|
||||
};
|
||||
|
||||
194
src/components/StaticPortfolioPieChart.tsx
Normal file
194
src/components/StaticPortfolioPieChart.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import type { OffsetProject } from '../types';
|
||||
import { getProjectColor } from '../utils/portfolioColors';
|
||||
|
||||
interface StaticPortfolioPieChartProps {
|
||||
projects: OffsetProject[];
|
||||
totalTons: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function StaticPortfolioPieChart({
|
||||
projects,
|
||||
totalTons,
|
||||
size = 280,
|
||||
}: StaticPortfolioPieChartProps) {
|
||||
const centerX = size / 2;
|
||||
const centerY = size / 2;
|
||||
const radius = size * 0.35;
|
||||
const innerRadius = size * 0.20; // Slightly smaller for better labeling
|
||||
const labelRadius = size * 0.50; // Distance for label lines
|
||||
|
||||
// Helper function to convert polar coordinates to cartesian
|
||||
const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => {
|
||||
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
|
||||
return {
|
||||
x: centerX + radius * Math.cos(angleInRadians),
|
||||
y: centerY + radius * Math.sin(angleInRadians),
|
||||
};
|
||||
};
|
||||
|
||||
// Generate SVG path for a donut segment
|
||||
const createSegmentPath = (
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
outerR: number,
|
||||
innerR: number
|
||||
): string => {
|
||||
const start = polarToCartesian(centerX, centerY, outerR, endAngle);
|
||||
const end = polarToCartesian(centerX, centerY, outerR, startAngle);
|
||||
const innerStart = polarToCartesian(centerX, centerY, innerR, endAngle);
|
||||
const innerEnd = polarToCartesian(centerX, centerY, innerR, startAngle);
|
||||
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
|
||||
|
||||
return [
|
||||
'M', start.x, start.y,
|
||||
'A', outerR, outerR, 0, largeArcFlag, 0, end.x, end.y,
|
||||
'L', innerEnd.x, innerEnd.y,
|
||||
'A', innerR, innerR, 0, largeArcFlag, 1, innerStart.x, innerStart.y,
|
||||
'Z',
|
||||
].join(' ');
|
||||
};
|
||||
|
||||
// Calculate segments with label positions
|
||||
const segments: Array<{
|
||||
path: string;
|
||||
percentage: number;
|
||||
project: OffsetProject;
|
||||
color: string;
|
||||
midAngle: number;
|
||||
tons: number;
|
||||
}> = [];
|
||||
|
||||
let currentAngle = 0;
|
||||
|
||||
projects.forEach((project, index) => {
|
||||
if (!project.percentage) return;
|
||||
|
||||
const percentage = project.percentage * 100;
|
||||
const segmentAngle = (percentage / 100) * 360;
|
||||
const midAngle = currentAngle + segmentAngle / 2;
|
||||
const tons = parseFloat((totalTons * project.percentage).toFixed(2));
|
||||
const color = getProjectColor(index, projects.length);
|
||||
|
||||
segments.push({
|
||||
path: createSegmentPath(currentAngle, currentAngle + segmentAngle, radius, innerRadius),
|
||||
percentage,
|
||||
project,
|
||||
color,
|
||||
midAngle,
|
||||
tons,
|
||||
});
|
||||
|
||||
currentAngle += segmentAngle;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full">
|
||||
{/* SVG Chart */}
|
||||
<svg
|
||||
width={size * 2.2}
|
||||
height={size * 1.4}
|
||||
viewBox={`0 0 ${size * 2.2} ${size * 1.4}`}
|
||||
className="overflow-visible print:overflow-visible"
|
||||
>
|
||||
{/* Donut segments */}
|
||||
<g transform={`translate(${size * 1.1 - centerX}, ${size * 0.7 - centerY})`}>
|
||||
{segments.map((segment, index) => {
|
||||
const labelPoint = polarToCartesian(centerX, centerY, labelRadius, segment.midAngle);
|
||||
const textAnchor = labelPoint.x > centerX ? 'start' : 'end';
|
||||
const lineEndX = labelPoint.x > centerX ? labelPoint.x + 40 : labelPoint.x - 40;
|
||||
|
||||
return (
|
||||
<g key={index}>
|
||||
{/* Segment path */}
|
||||
<path
|
||||
d={segment.path}
|
||||
fill={segment.color}
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
className="print:stroke-gray-300"
|
||||
/>
|
||||
|
||||
{/* Label line */}
|
||||
<line
|
||||
x1={labelPoint.x}
|
||||
y1={labelPoint.y}
|
||||
x2={lineEndX}
|
||||
y2={labelPoint.y}
|
||||
stroke={segment.color}
|
||||
strokeWidth="2"
|
||||
className="print:stroke-gray-400"
|
||||
/>
|
||||
|
||||
{/* Label text */}
|
||||
<text
|
||||
x={lineEndX}
|
||||
y={labelPoint.y - 20}
|
||||
textAnchor={textAnchor}
|
||||
className="text-sm font-semibold fill-slate-700 print:text-xs"
|
||||
>
|
||||
{segment.project.name}
|
||||
</text>
|
||||
<text
|
||||
x={lineEndX}
|
||||
y={labelPoint.y - 4}
|
||||
textAnchor={textAnchor}
|
||||
className="text-xs fill-slate-600 print:text-[10px]"
|
||||
>
|
||||
{segment.tons} tons
|
||||
</text>
|
||||
<text
|
||||
x={lineEndX}
|
||||
y={labelPoint.y + 12}
|
||||
textAnchor={textAnchor}
|
||||
className="text-xs font-bold fill-slate-800 print:text-[10px]"
|
||||
>
|
||||
{segment.percentage.toFixed(1)}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Center text showing total */}
|
||||
<text
|
||||
x={centerX}
|
||||
y={centerY - 10}
|
||||
textAnchor="middle"
|
||||
className="text-2xl font-bold fill-emerald-600 print:text-xl"
|
||||
>
|
||||
{totalTons}
|
||||
</text>
|
||||
<text
|
||||
x={centerX}
|
||||
y={centerY + 8}
|
||||
textAnchor="middle"
|
||||
className="text-sm fill-slate-600 print:text-xs"
|
||||
>
|
||||
tons CO₂
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Legend below chart (for print clarity) */}
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-3 w-full max-w-2xl print:mt-4 print:gap-2">
|
||||
{segments.map((segment, index) => (
|
||||
<div key={index} className="flex items-center gap-3 print:gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm flex-shrink-0 print:w-3 print:h-3"
|
||||
style={{ backgroundColor: segment.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate print:text-xs">
|
||||
{segment.project.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600 print:text-[10px]">
|
||||
{segment.tons} tons ({segment.percentage.toFixed(1)}%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { motion } from 'framer-motion';
|
||||
import { getOrderDetails } from '../api/checkoutClient';
|
||||
import { OrderDetailsResponse } from '../types';
|
||||
import { CarbonImpactComparison } from '../components/CarbonImpactComparison';
|
||||
import { StaticPortfolioPieChart } from '../components/StaticPortfolioPieChart';
|
||||
import { useCalculatorState } from '../hooks/useCalculatorState';
|
||||
|
||||
interface CheckoutSuccessProps {
|
||||
@ -252,11 +253,21 @@ export default function CheckoutSuccess({
|
||||
{/* 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">Order ID</span>
|
||||
<p className="text-slate-800 font-mono text-sm mt-1 break-all">{order.id}</p>
|
||||
<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>
|
||||
<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}`}>
|
||||
@ -264,12 +275,14 @@ export default function CheckoutSuccess({
|
||||
</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">
|
||||
@ -285,6 +298,30 @@ export default function CheckoutSuccess({
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Portfolio Distribution Chart */}
|
||||
{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:shadow-none print:border print:border-gray-300">
|
||||
<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>
|
||||
<StaticPortfolioPieChart
|
||||
projects={orderDetails.order.portfolio.projects}
|
||||
totalTons={order.tons}
|
||||
size={280}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Impact Comparisons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
@ -133,7 +133,9 @@ export interface OrderDetailsResponse {
|
||||
currency: string;
|
||||
status: string;
|
||||
wrenOrderId: string | null;
|
||||
stripeSessionId: string;
|
||||
createdAt: string;
|
||||
portfolio?: Portfolio; // Portfolio data with projects for pie chart
|
||||
};
|
||||
session: {
|
||||
paymentStatus: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user