From 6787ccd2d85c2bb763ff79a775aead45745ed5e3 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 31 Oct 2025 17:24:51 +0100 Subject: [PATCH] Add portfolio distribution chart to receipt and update ID display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/routes/checkout.js | 20 +++ server/utils/wrenClient.js | 76 +++++++- src/components/StaticPortfolioPieChart.tsx | 194 +++++++++++++++++++++ src/pages/CheckoutSuccess.tsx | 43 ++++- src/types.ts | 2 + 5 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 src/components/StaticPortfolioPieChart.tsx diff --git a/server/routes/checkout.js b/server/routes/checkout.js index d5749da..e0c94b9 100644 --- a/server/routes/checkout.js +++ b/server/routes/checkout.js @@ -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, diff --git a/server/utils/wrenClient.js b/server/utils/wrenClient.js index 35c59ca..f2891b0 100644 --- a/server/utils/wrenClient.js +++ b/server/utils/wrenClient.js @@ -131,7 +131,81 @@ export async function getWrenOffsetOrder(orderId) { } } +/** + * Get Wren portfolios + * @returns {Promise} 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 }; diff --git a/src/components/StaticPortfolioPieChart.tsx b/src/components/StaticPortfolioPieChart.tsx new file mode 100644 index 0000000..f7a3932 --- /dev/null +++ b/src/components/StaticPortfolioPieChart.tsx @@ -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 ( +
+ {/* SVG Chart */} + + {/* Donut segments */} + + {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 ( + + {/* Segment path */} + + + {/* Label line */} + + + {/* Label text */} + + {segment.project.name} + + + {segment.tons} tons + + + {segment.percentage.toFixed(1)}% + + + ); + })} + + {/* Center text showing total */} + + {totalTons} + + + tons CO₂ + + + + + {/* Legend below chart (for print clarity) */} +
+ {segments.map((segment, index) => ( +
+
+
+

+ {segment.project.name} +

+

+ {segment.tons} tons ({segment.percentage.toFixed(1)}%) +

+
+
+ ))} +
+
+ ); +} diff --git a/src/pages/CheckoutSuccess.tsx b/src/pages/CheckoutSuccess.tsx index 8cef439..2c2c4c0 100644 --- a/src/pages/CheckoutSuccess.tsx +++ b/src/pages/CheckoutSuccess.tsx @@ -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 */}
+ {/* Payment ID (Stripe) */}
- Order ID -

{order.id}

+ Payment ID +

{order.stripeSessionId}

-
+ + {/* Offsetting Order ID (Wren) - Only show if fulfilled */} + {order.wrenOrderId && ( +
+ Offsetting Order ID +

{order.wrenOrderId}

+
+ )} + +
Status

@@ -264,12 +275,14 @@ export default function CheckoutSuccess({

+ {session.customerEmail && (
Email

{session.customerEmail}

)} +
Date

@@ -285,6 +298,30 @@ export default function CheckoutSuccess({

+ {/* Portfolio Distribution Chart */} + {orderDetails.order.portfolio?.projects && orderDetails.order.portfolio.projects.length > 0 && ( + +
+

+ Your Carbon Offset Distribution +

+

+ Your {order.tons} tons of CO₂ offsets are distributed across these verified projects: +

+ +
+
+ )} + {/* Impact Comparisons */}