diff --git a/.gitea/workflows/build-deploy.yml b/.gitea/workflows/build-deploy.yml index 6e89e6f..39cf9f1 100644 --- a/.gitea/workflows/build-deploy.yml +++ b/.gitea/workflows/build-deploy.yml @@ -30,9 +30,6 @@ jobs: file: ./Dockerfile platforms: linux/amd64 push: true - build-args: | - NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL }} - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }} tags: | ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:frontend-latest ${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:frontend-main-${{ github.sha }} diff --git a/Dockerfile b/Dockerfile index 9c92126..ba0c30b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,17 +10,8 @@ RUN npm ci # Copy the rest of the app COPY . . -# Accept build arguments for NEXT_PUBLIC_ variables -# These MUST be provided at build time -ARG NEXT_PUBLIC_API_BASE_URL -ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY - -# Set as environment variables so Next.js can bake them into the build -ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY - # Build Next.js app (standalone mode) -# NEXT_PUBLIC_ variables are now baked in at build time +# All environment variables are runtime-configurable via .env or docker-compose RUN npm run build # Production Stage - Next.js standalone server diff --git a/app/api/wren/offset-orders/route.ts b/app/api/wren/offset-orders/route.ts new file mode 100644 index 0000000..3f8435f --- /dev/null +++ b/app/api/wren/offset-orders/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * POST /api/wren/offset-orders + * Proxy endpoint to create offset orders with Wren API + * This keeps the WREN_API_TOKEN secure on the server + * + * Request body: + * { + * tons: number, + * portfolioId: number, + * dryRun?: boolean, + * source?: string, + * note?: string + * } + */ +export async function POST(request: NextRequest) { + try { + const apiToken = process.env.WREN_API_TOKEN; + + if (!apiToken) { + console.error('WREN_API_TOKEN is not configured'); + return NextResponse.json( + { error: 'Wren API is not configured' }, + { status: 500 } + ); + } + + // Parse request body + const body = await request.json(); + const { tons, portfolioId, dryRun = false, source, note } = body; + + // Validate required fields + if (!tons || !portfolioId) { + return NextResponse.json( + { error: 'Missing required fields: tons and portfolioId' }, + { status: 400 } + ); + } + + // Create offset order payload + const orderPayload: any = { + tons, + portfolio_id: portfolioId, + dry_run: dryRun, + }; + + if (source) orderPayload.source = source; + if (note) orderPayload.note = note; + + // Make request to Wren API + const response = await fetch('https://www.wren.co/api/offset_orders', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(orderPayload), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Wren API error:', response.status, errorText); + return NextResponse.json( + { error: 'Failed to create offset order with Wren' }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Error creating Wren offset order:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to create offset order' }, + { status: 500 } + ); + } +} diff --git a/app/api/wren/portfolios/route.ts b/app/api/wren/portfolios/route.ts new file mode 100644 index 0000000..b5ebdfa --- /dev/null +++ b/app/api/wren/portfolios/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server'; + +/** + * GET /api/wren/portfolios + * Proxy endpoint to fetch portfolios from Wren API + * This keeps the WREN_API_TOKEN secure on the server + */ +export async function GET() { + try { + const apiToken = process.env.WREN_API_TOKEN; + + if (!apiToken) { + console.error('WREN_API_TOKEN is not configured'); + return NextResponse.json( + { error: 'Wren API is not configured' }, + { status: 500 } + ); + } + + const response = await fetch('https://www.wren.co/api/portfolios', { + headers: { + 'Authorization': `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Wren API error:', response.status, errorText); + return NextResponse.json( + { error: 'Failed to fetch portfolios from Wren' }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Error fetching Wren portfolios:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch portfolios' }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 78ece89..79e52cf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -16,7 +16,7 @@ export const metadata: Metadata = { authors: [{ name: 'Puffin Offset' }], creator: 'Puffin Offset', publisher: 'Puffin Offset', - metadataBase: new URL(process.env.NEXT_PUBLIC_API_BASE_URL || 'https://puffinoffset.com'), + metadataBase: new URL('https://puffinoffset.com'), alternates: { canonical: '/', }, diff --git a/src/api/checkoutClient.ts b/src/api/checkoutClient.ts index da25dc3..438bfaa 100644 --- a/src/api/checkoutClient.ts +++ b/src/api/checkoutClient.ts @@ -1,18 +1,18 @@ import axios from 'axios'; import { logger } from '../utils/logger'; -// Get API base URL from runtime config (window.env) or build-time config +// Get API base URL from runtime config (window.env) // IMPORTANT: Call this function at REQUEST TIME, not at module load time, // to ensure window.env is populated by env-config.js const getApiBaseUrl = (): string => { - // Check window.env first (runtime config from env.sh) + // Check window.env first (runtime config from env.sh or docker-compose) if (typeof window !== 'undefined' && window.env?.API_BASE_URL) { return window.env.API_BASE_URL; } - // Fall back to build-time env or production default - // Next.js requires direct static reference to NEXT_PUBLIC_ variables - return process.env.NEXT_PUBLIC_API_BASE_URL || 'https://puffinoffset.com/api'; + // Fall back to production default if not configured + // All configuration is now runtime-only via environment variables + return 'https://puffinoffset.com/api'; }; export interface CreateCheckoutSessionParams { diff --git a/src/api/emailClient.ts b/src/api/emailClient.ts index 38d36ea..0b8b3b0 100644 --- a/src/api/emailClient.ts +++ b/src/api/emailClient.ts @@ -1,18 +1,18 @@ import axios from 'axios'; import { logger } from '../utils/logger'; -// Get API base URL from runtime config (window.env) or build-time config +// Get API base URL from runtime config (window.env) // IMPORTANT: Call this function at REQUEST TIME, not at module load time, // to ensure window.env is populated by env-config.js const getApiBaseUrl = (): string => { - // Check window.env first (runtime config from env.sh) + // Check window.env first (runtime config from env.sh or docker-compose) if (typeof window !== 'undefined' && window.env?.API_BASE_URL) { return window.env.API_BASE_URL; } - // Fall back to build-time env or production default - // Next.js requires direct static reference to NEXT_PUBLIC_ variables - return process.env.NEXT_PUBLIC_API_BASE_URL || 'https://puffinoffset.com/api'; + // Fall back to production default if not configured + // All configuration is now runtime-only via environment variables + return 'https://puffinoffset.com/api'; }; export interface ContactEmailData { diff --git a/src/api/wrenClient.ts b/src/api/wrenClient.ts index 1dee880..004e8fe 100644 --- a/src/api/wrenClient.ts +++ b/src/api/wrenClient.ts @@ -1,11 +1,10 @@ -import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import axios from 'axios'; import type { OffsetOrder, Portfolio } from '../types'; -import { config } from '../utils/config'; import { logger } from '../utils/logger'; // Default portfolio for fallback const DEFAULT_PORTFOLIO: Portfolio = { - id: 2, // Updated to use ID 2 as in the tutorial + id: 2, name: "Community Tree Planting", description: "A curated selection of high-impact carbon removal projects focused on carbon sequestration through tree planting.", projects: [ @@ -28,85 +27,10 @@ const DEFAULT_PORTFOLIO: Portfolio = { currency: 'USD' }; -// Create API client with error handling, timeout, and retry logic -const createApiClient = () => { - if (!config.wrenApiKey) { - console.error('Wren API token is missing! Token:', config.wrenApiKey); - console.error('Environment:', window?.env ? JSON.stringify(window.env) : 'No window.env available'); - throw new Error('Wren API token is not configured'); - } - - logger.log('[wrenClient] Creating API client with key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING'); - - const client = axios.create({ - // Updated base URL to match the tutorial exactly - baseURL: 'https://www.wren.co/api', - headers: { - 'Authorization': `Bearer ${config.wrenApiKey}`, - 'Content-Type': 'application/json' - }, - timeout: 10000, // 10 second timeout - validateStatus: (status: number) => status >= 200 && status < 500, // Handle 4xx errors gracefully - }); - - // Add request interceptor for logging - client.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { - if (!config.headers?.Authorization) { - throw new Error('API token is required'); - } - logger.log('[wrenClient] Making API request to:', config.url); - return config; - }, - (error: Error) => { - console.error('[wrenClient] Request configuration error:', error.message); - return Promise.reject(error); - } - ); - - // Add response interceptor for error handling - client.interceptors.response.use( - (response: AxiosResponse) => { - logger.log('[wrenClient] Received API response:', response.status); - return response; - }, - (error: unknown) => { - if (axios.isAxiosError(error)) { - if (error.code === 'ECONNABORTED') { - logger.warn('[wrenClient] Request timeout, using fallback data'); - return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } }); - } - if (!error.response) { - logger.warn('[wrenClient] Network error, using fallback data'); - return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } }); - } - if (error.response.status === 401) { - logger.warn('[wrenClient] Authentication failed, using fallback data'); - return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } }); - } - console.error('[wrenClient] API error:', error.response?.status, error.response?.data); - } - return Promise.reject(error); - } - ); - - return client; -}; - -// Safe error logging function that handles non-serializable objects -const logError = (error: unknown) => { - if (error instanceof Error) { - const errorInfo = { - name: error.name, - message: error.message, - stack: error.stack - }; - console.error('[wrenClient] API Error:', JSON.stringify(errorInfo, null, 2)); - } else { - console.error('[wrenClient] Unknown error:', String(error)); - } -}; - +/** + * Get portfolios from Wren API via server-side proxy + * This keeps the Wren API token secure on the server + */ export async function getPortfolios(): Promise { const startTime = Date.now(); console.log('🔵 [WREN API] ========================================'); @@ -114,19 +38,9 @@ export async function getPortfolios(): Promise { console.log('🔵 [WREN API] Timestamp:', new Date().toISOString()); try { - if (!config.wrenApiKey) { - console.warn('⚠️ [WREN API] No API token configured, using fallback portfolio'); - logger.warn('[wrenClient] No Wren API token configured, using fallback portfolio'); - return [DEFAULT_PORTFOLIO]; - } + console.log('🔵 [WREN API] Making request to proxy: /api/wren/portfolios'); - console.log('🔵 [WREN API] API Key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING'); - logger.log('[wrenClient] Getting portfolios with token:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING'); - - const api = createApiClient(); - console.log('🔵 [WREN API] Making request to: https://www.wren.co/api/portfolios'); - - const response = await api.get('/portfolios'); + const response = await axios.get('/api/wren/portfolios'); const duration = Date.now() - startTime; console.log('✅ [WREN API] GET /portfolios - Success'); @@ -147,7 +61,7 @@ export async function getPortfolios(): Promise { return response.data.portfolios.map((portfolio: any) => { let pricePerTon = 18; // Default price based on the Wren Climate Fund average - + // The API returns cost_per_ton in snake_case if (portfolio.cost_per_ton !== undefined && portfolio.cost_per_ton !== null) { pricePerTon = typeof portfolio.cost_per_ton === 'number' ? portfolio.cost_per_ton : parseFloat(portfolio.cost_per_ton) || 18; @@ -159,12 +73,10 @@ export async function getPortfolios(): Promise { // Convert from snake_case to camelCase for projects const projects = portfolio.projects?.map((project: any) => { - // Ensure cost_per_ton is properly mapped const projectPricePerTon = project.cost_per_ton !== undefined && project.cost_per_ton !== null ? (typeof project.cost_per_ton === 'number' ? project.cost_per_ton : parseFloat(project.cost_per_ton)) : pricePerTon; - - // Ensure percentage is properly captured + const projectPercentage = project.percentage !== undefined && project.percentage !== null ? (typeof project.percentage === 'number' ? project.percentage : parseFloat(project.percentage)) : undefined; @@ -174,13 +86,10 @@ export async function getPortfolios(): Promise { name: project.name, description: project.description || '', shortDescription: project.short_description || project.description || '', - imageUrl: project.image_url, // Map from snake_case API response + imageUrl: project.image_url, pricePerTon: projectPricePerTon, - percentage: projectPercentage, // Include percentage field - certificationStatus: project.certification_status, // Map certification status from API - // Remove fields that aren't in the API - // The required type fields are still in the type definition for compatibility - // but we no longer populate them with default values + percentage: projectPercentage, + certificationStatus: project.certification_status, location: '', type: '', verificationStandard: '', @@ -189,7 +98,7 @@ export async function getPortfolios(): Promise { } }; }) || []; - + return { id: portfolio.id, name: portfolio.name, @@ -206,51 +115,44 @@ export async function getPortfolios(): Promise { if (axios.isAxiosError(error)) { console.error('❌ [WREN API] Status:', error.response?.status || 'No response'); - console.error('❌ [WREN API] Status Text:', error.response?.statusText || 'N/A'); console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2)); - console.error('❌ [WREN API] Request URL:', error.config?.url); } else { console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error)); } console.log('🔵 [WREN API] ========================================'); - logError(error); logger.warn('[wrenClient] Failed to fetch portfolios from API, using fallback'); return [DEFAULT_PORTFOLIO]; } } +/** + * Create offset order with Wren API via server-side proxy + * This keeps the Wren API token secure on the server + */ export async function createOffsetOrder( portfolioId: number, tons: number, - dryRun: boolean = false + dryRun: boolean = false, + source?: string, + note?: string ): Promise { const startTime = Date.now(); console.log('🔵 [WREN API] ========================================'); console.log('🔵 [WREN API] POST /offset-orders - Request initiated'); console.log('🔵 [WREN API] Timestamp:', new Date().toISOString()); - console.log('🔵 [WREN API] Parameters:', JSON.stringify({ portfolioId, tons, dryRun }, null, 2)); + console.log('🔵 [WREN API] Parameters:', JSON.stringify({ portfolioId, tons, dryRun, source, note }, null, 2)); try { - if (!config.wrenApiKey) { - console.error('❌ [WREN API] Cannot create order - missing API token'); - console.error('[wrenClient] Cannot create order - missing API token'); - throw new Error('Carbon offset service is currently unavailable. Please contact support.'); - } + console.log('🔵 [WREN API] Making request to proxy: /api/wren/offset-orders'); + logger.log(`[wrenClient] Creating offset order via proxy: portfolio=${portfolioId}, tons=${tons}, dryRun=${dryRun}`); - console.log('🔵 [WREN API] API Key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING'); - logger.log(`[wrenClient] Creating offset order: portfolio=${portfolioId}, tons=${tons}, dryRun=${dryRun}`); - - const api = createApiClient(); - console.log('🔵 [WREN API] Making request to: https://www.wren.co/api/offset-orders'); - console.log('🔵 [WREN API] Request payload:', JSON.stringify({ portfolioId, tons, dryRun }, null, 2)); - - // Removed the /api prefix to match the working example - const response = await api.post('/offset-orders', { - // Using exactly the format shown in the API tutorial - portfolioId, // Use the provided portfolio ID instead of hardcoding + const response = await axios.post('/api/wren/offset-orders', { tons, - dryRun // Use the provided dryRun parameter + portfolioId, + dryRun, + source, + note }); const duration = Date.now() - startTime; @@ -258,127 +160,39 @@ export async function createOffsetOrder( console.log('✅ [WREN API] POST /offset-orders - Success'); console.log('✅ [WREN API] Status:', response.status); console.log('✅ [WREN API] Duration:', duration + 'ms'); + console.log('✅ [WREN API] Order ID:', response.data.id); + console.log('✅ [WREN API] Amount Charged:', response.data.amount_paid_by_customer); + console.log('🔵 [WREN API] ========================================'); - // Add detailed response logging - logger.log('[wrenClient] Offset order response:', - response.status, - response.data ? 'has data' : 'no data'); + logger.log(`[wrenClient] Order created successfully: ${response.data.id}`); - if (response.status === 400) { - console.error('❌ [WREN API] Bad request - Status 400'); - console.error('❌ [WREN API] Bad request details:', response.data); - console.error('[wrenClient] Bad request details:', response.data); - throw new Error(`Failed to create offset order: ${JSON.stringify(response.data)}`); - } - - const order = response.data; - if (!order) { - console.error('❌ [WREN API] Empty response received'); - throw new Error('Empty response received from offset order API'); - } - - console.log('✅ [WREN API] Order ID:', order.id || 'N/A'); - console.log('✅ [WREN API] Amount Charged:', order.amountCharged ? `$${order.amountCharged}` : 'N/A'); - console.log('✅ [WREN API] Tons:', order.tons || 'N/A'); - console.log('✅ [WREN API] Status:', order.status || 'N/A'); - console.log('✅ [WREN API] Dry Run:', order.dryRun !== undefined ? order.dryRun : 'N/A'); - console.log('🔵 [WREN API] ========================================') - - // Log to help diagnose issues - logger.log('[wrenClient] Order data keys:', Object.keys(order).join(', ')); - if (order.portfolio) { - logger.log('[wrenClient] Portfolio data keys:', Object.keys(order.portfolio).join(', ')); - } - - // Get price from API response which uses cost_per_ton - let pricePerTon = 18; - if (order.portfolio?.cost_per_ton !== undefined) { - pricePerTon = typeof order.portfolio.cost_per_ton === 'number' ? order.portfolio.cost_per_ton : parseFloat(order.portfolio.cost_per_ton) || 18; - } else if (order.portfolio?.costPerTon !== undefined) { - pricePerTon = typeof order.portfolio.costPerTon === 'number' ? order.portfolio.costPerTon : parseFloat(order.portfolio.costPerTon) || 18; - } else if (order.portfolio?.pricePerTon !== undefined) { - pricePerTon = typeof order.portfolio.pricePerTon === 'number' ? order.portfolio.pricePerTon : parseFloat(order.portfolio.pricePerTon) || 18; - } - - // Create a safe method to extract properties with fallbacks - const getSafeProp = (obj: any, prop: string, fallback: any) => { - if (!obj) return fallback; - return obj[prop] !== undefined ? obj[prop] : fallback; - }; - - // Use safe accessor to avoid undefined errors - const portfolio = order.portfolio || {}; - - // Adjusted to use camelCase as per API docs response format return { - id: getSafeProp(order, 'id', ''), - amountCharged: getSafeProp(order, 'amountCharged', 0), - currency: getSafeProp(order, 'currency', 'USD'), - tons: getSafeProp(order, 'tons', 0), - portfolio: { - id: getSafeProp(portfolio, 'id', 2), - name: getSafeProp(portfolio, 'name', 'Community Tree Planting'), - description: getSafeProp(portfolio, 'description', ''), - projects: getSafeProp(portfolio, 'projects', []), - pricePerTon, - currency: getSafeProp(order, 'currency', 'USD') - }, - status: getSafeProp(order, 'status', ''), - createdAt: getSafeProp(order, 'createdAt', new Date().toISOString()), - dryRun: getSafeProp(order, 'dryRun', true) + id: response.data.id, + amountCharged: response.data.amount_paid_by_customer, + currency: (response.data.currency?.toUpperCase() || 'USD') as 'USD' | 'EUR' | 'GBP' | 'CHF', + tons: response.data.tons, + portfolio: response.data.portfolio, + status: 'completed', + createdAt: new Date().toISOString(), + dryRun, + source, + note }; - } catch (error: unknown) { + } catch (error) { const duration = Date.now() - startTime; console.error('❌ [WREN API] POST /offset-orders - Failed'); console.error('❌ [WREN API] Duration:', duration + 'ms'); - logError(error); - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError; - - console.error('❌ [WREN API] Status:', axiosError.response?.status || 'No response'); - console.error('❌ [WREN API] Status Text:', axiosError.response?.statusText || 'N/A'); - console.error('❌ [WREN API] Error Data:', JSON.stringify(axiosError.response?.data, null, 2)); - console.error('❌ [WREN API] Request URL:', axiosError.config?.url); - console.error('❌ [WREN API] Request Method:', axiosError.config?.method?.toUpperCase()); - console.error('❌ [WREN API] Request Data:', JSON.stringify(axiosError.config?.data, null, 2)); - console.log('🔵 [WREN API] ========================================'); - - console.error('[wrenClient] Axios error details:', { - status: axiosError.response?.status, - statusText: axiosError.response?.statusText, - data: axiosError.response?.data, - config: { - url: axiosError.config?.url, - method: axiosError.config?.method, - headers: axiosError.config?.headers ? 'Headers present' : 'No headers', - baseURL: axiosError.config?.baseURL, - data: axiosError.config?.data - } - }); - - if (axiosError.response?.status === 400) { - // Provide more specific error for 400 Bad Request - const responseData = axiosError.response.data as any; - const errorMessage = responseData?.message || responseData?.error || 'Invalid request format'; - throw new Error(`Bad request: ${errorMessage}`); - } - - if (axiosError.code === 'ECONNABORTED') { - throw new Error('Request timed out. Please try again.'); - } - if (!axiosError.response) { - throw new Error('Network error. Please check your connection and try again.'); - } - if (axiosError.response.status === 401) { - throw new Error('Carbon offset service authentication failed. Please check your API token.'); - } + console.error('❌ [WREN API] Status:', error.response?.status || 'No response'); + console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2)); + console.error('❌ [WREN API] Request URL:', error.config?.url); } else { console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error)); - console.log('🔵 [WREN API] ========================================'); } + console.log('🔵 [WREN API] ========================================'); - throw new Error('Failed to create offset order. Please try again.'); + logger.error('[wrenClient] Failed to create offset order'); + throw new Error('Failed to create carbon offset order. Please try again or contact support.'); } }