diff --git a/backend/src/index.ts b/backend/src/index.ts index aa72b7ab..62501349 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -66,6 +66,7 @@ import { escrowRouter } from './routes/escrow.js'; import { multisigRouter } from './routes/multisig.js'; import { fiatPaymentsRouter } from './routes/fiat-payments.js'; import { paymentLinksRouter } from './routes/payment-links.js'; +import { checkoutRouter } from './routes/checkout.js'; import { taxRouter } from './routes/tax.js'; import { projectsRouter } from './routes/projects.js'; import { graphQLRouter, graphQLWsRouter } from './graphql/gateway.js'; @@ -292,6 +293,9 @@ app.use('/api/v1/fiat-payments', fiatPaymentsRouter); // Merchant dynamic payment links app.use('/api/v1/payment-links', paymentLinksRouter); +// Hosted checkout pages for direct payments +app.use('/api/v1/checkout', checkoutRouter); + // Merchant tax report generation (summary, 1099-K, VAT, nexus, CSV export) app.use('/api/v1/tax', taxRouter); diff --git a/backend/src/routes/checkout.ts b/backend/src/routes/checkout.ts new file mode 100644 index 00000000..e54a92f0 --- /dev/null +++ b/backend/src/routes/checkout.ts @@ -0,0 +1,85 @@ +import { Router } from 'express'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { checkoutService } from '../services/checkout.js'; +import { + createCheckoutSessionSchema, + updatePaymentMethodSchema, + processPaymentSchema, +} from '../schemas/checkout.js'; + +export const checkoutRouter = Router(); + +// Create new checkout session (merchant auth / standard request) +checkoutRouter.post( + '/sessions', + validate(createCheckoutSessionSchema), + asyncHandler(async (req, res) => { + const session = checkoutService.create(req.body); + res.status(201).json({ + data: session, + checkoutUrl: `https://pay.agenticpay.com/checkout/${session.id}`, + }); + }) +); + +// Get session details (public endpoint used by checkout client) +checkoutRouter.get( + '/sessions/:id', + asyncHandler(async (req, res) => { + const session = checkoutService.getById(req.params.id); + if (!session) { + throw new AppError(404, 'Checkout session not found', 'NOT_FOUND'); + } + res.json({ data: session }); + }) +); + +// Select payment method for a session +checkoutRouter.post( + '/sessions/:id/payment-method', + validate(updatePaymentMethodSchema), + asyncHandler(async (req, res) => { + const session = checkoutService.updatePaymentMethod(req.params.id, req.body.method); + res.json({ data: session }); + }) +); + +// Lock exchange rate for crypto method +checkoutRouter.post( + '/sessions/:id/lock-rate', + asyncHandler(async (req, res) => { + const session = checkoutService.lockExchangeRate(req.params.id); + res.json({ data: session }); + }) +); + +// Process / execute payment +checkoutRouter.post( + '/sessions/:id/pay', + validate(processPaymentSchema), + asyncHandler(async (req, res) => { + const session = await checkoutService.processPayment(req.params.id, req.body); + res.json({ data: session }); + }) +); + +// Download receipt for a completed session +checkoutRouter.get( + '/sessions/:id/receipt', + asyncHandler(async (req, res) => { + const receiptHtml = checkoutService.generateReceipt(req.params.id); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="receipt_${req.params.id}.html"`); + res.send(receiptHtml); + }) +); + +// Fetch active exchange rates +checkoutRouter.get( + '/exchange-rates', + asyncHandler(async (req, res) => { + const rates = checkoutService.getExchangeRates(); + res.json({ data: rates }); + }) +); diff --git a/backend/src/routes/payment-links.test.ts b/backend/src/routes/payment-links.test.ts new file mode 100644 index 00000000..904c4f61 --- /dev/null +++ b/backend/src/routes/payment-links.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { renderHostedCheckoutPage } from './payment-links.js'; +import type { PaymentLinkRecord } from '../services/payment-links.js'; + +function makeLink(overrides: Partial = {}): PaymentLinkRecord { + return { + id: 'link_1', + merchantId: 'merchant_1', + slug: 'safeSlug12345678', + amount: 49.99, + currency: 'USD', + description: 'Secure checkout link', + expiresAt: '2030-01-01T00:00:00.000Z', + recurrence: 'one_time', + tags: [], + requiresPassword: false, + maxUses: null, + isActive: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + analytics: { + views: 0, + completions: 0, + bySource: {}, + lastViewedAt: null, + lastCompletedAt: null, + }, + ...overrides, + }; +} + +describe('renderHostedCheckoutPage', () => { + it('escapes merchant-controlled text in the hosted checkout', () => { + const html = renderHostedCheckoutPage( + makeLink({ + description: '', + brand: { + brandName: '', + accentColor: '#0052FF', + redirectUrl: 'https://merchant.example/thanks', + }, + }) + ); + + expect(html).not.toContain(''); + expect(html).not.toContain(''); + expect(html).toContain('<script>alert("owned")</script>'); + expect(html).toContain('<img src=x onerror=alert(1)>'); + }); + + it('renders a password unlock form before showing the completion action', () => { + const lockedHtml = renderHostedCheckoutPage( + makeLink({ + requiresPassword: true, + }), + { source: 'qr' } + ); + const unlockedHtml = renderHostedCheckoutPage( + makeLink({ + requiresPassword: true, + }), + { source: 'qr', password: 'open-sesame' } + ); + + expect(lockedHtml).toContain('Payment password'); + expect(lockedHtml).toContain('Unlock checkout'); + expect(lockedHtml).not.toContain('Complete payment'); + expect(unlockedHtml).toContain('Complete payment'); + }); + + it('keeps the completion action hidden after a bad password attempt', () => { + const html = renderHostedCheckoutPage( + makeLink({ + requiresPassword: true, + }), + { + source: 'qr', + password: 'wrong-password', + passwordError: 'That password did not match this payment link.', + } + ); + + expect(html).toContain('That password did not match this payment link.'); + expect(html).not.toContain('Complete payment'); + }); +}); diff --git a/backend/src/routes/payment-links.ts b/backend/src/routes/payment-links.ts index c955aeb3..d9f1f514 100644 --- a/backend/src/routes/payment-links.ts +++ b/backend/src/routes/payment-links.ts @@ -1,4 +1,5 @@ import { Router, Request, Response, NextFunction } from 'express'; +import escapeHtml from 'escape-html'; import { AppError, asyncHandler } from '../middleware/errorHandler.js'; import { validate } from '../middleware/validate.js'; import { @@ -7,7 +8,7 @@ import { paymentLinkCompletionSchema, updatePaymentLinkSchema, } from '../schemas/payment-links.js'; -import { paymentLinksService } from '../services/payment-links.js'; +import { paymentLinksService, type PaymentLinkRecord } from '../services/payment-links.js'; export const paymentLinksRouter = Router(); @@ -164,6 +165,163 @@ function enforcePassword(slug: string, link: { requiresPassword: boolean }, pass throw new AppError(401, 'A valid password is required for this link', 'PAYMENT_LINK_PASSWORD_REQUIRED'); } +function safeUrl(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + try { + const url = new URL(value); + return url.protocol === 'https:' ? url.toString() : undefined; + } catch { + return undefined; + } +} + +function safeColor(value: string | undefined): string { + return value && /^#[A-Fa-f0-9]{6}$/.test(value) ? value : '#0052FF'; +} + +function money(amount: number, currency: string): string { + try { + return new Intl.NumberFormat('en', { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `${amount.toFixed(2)} ${currency}`; + } +} + +export function renderHostedCheckoutPage( + link: PaymentLinkRecord, + options: { source: string; password?: string; passwordError?: string } = { source: 'direct' } +): string { + const accentColor = safeColor(link.brand?.accentColor); + const brandName = escapeHtml(link.brand?.brandName || 'AgenticPay'); + const logoUrl = safeUrl(link.brand?.logoUrl); + const redirectUrl = safeUrl(link.brand?.redirectUrl); + const description = escapeHtml(link.description || 'Secure checkout link'); + const formattedAmount = escapeHtml(money(link.amount, link.currency)); + const expiresAt = escapeHtml(new Date(link.expiresAt).toUTCString()); + const source = escapeHtml(options.source || 'direct'); + const password = escapeHtml(options.password || ''); + const passwordError = options.passwordError ? escapeHtml(options.passwordError) : ''; + const isUnlocked = !link.requiresPassword || Boolean(options.password && !options.passwordError); + const completionPayload = JSON.stringify({ + amountPaid: link.amount, + source: options.source || 'direct', + password: options.password || undefined, + }).replace(/ + + + + + ${brandName} checkout + + + +
+
+
+ ${logoUrl ? `` : `
${brandName.charAt(0)}
`} +
+

${brandName}

+

Secure payment request

+
+
+
+

Review payment

+

${description}

+

${formattedAmount}

+
+ Currency + ${escapeHtml(link.currency)} +
+
+ Expires + ${expiresAt} +
+ ${ + link.requiresPassword + ? `
+ + + + ${passwordError ? `

${passwordError}

` : ''} +
+
` + : '' + } + ${ + isUnlocked + ? `
+ + ${redirectUrl ? `Return to merchant` : ''} +
+

` + : '' + } +
+
+
+ + +`; +} + paymentLinksRouter.get( '/r/:slug', redirectRateLimiter, @@ -181,43 +339,43 @@ paymentLinksRouter.get( // Gate protected links before counting the view, so brute-force probes // can't inflate analytics. - enforcePassword(slug, existing, req.query.password); + const password = typeof req.query.password === 'string' ? req.query.password : ''; + if (existing.requiresPassword) { + if (!password) { + res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send(renderHostedCheckoutPage(existing, { source })); + return; + } + + const result = paymentLinksService.verifyPassword(slug, password); + if (!result.ok) { + if (result.reason === 'locked') { + throw new AppError( + 429, + 'Too many incorrect password attempts. Try again later.', + 'PAYMENT_LINK_LOCKED' + ); + } + + res.status(401).setHeader('Content-Type', 'text/html; charset=utf-8'); + res.send( + renderHostedCheckoutPage(existing, { + source, + password, + passwordError: 'That password did not match this payment link.', + }) + ); + return; + } + } const link = paymentLinksService.trackView(slug, source); if (!link) { throw new AppError(404, 'Payment link not found', 'NOT_FOUND'); } - const accentColor = link.brand?.accentColor || '#0B3A80'; - const brandName = link.brand?.brandName || 'AgenticPay'; - const html = ` - - - - - ${brandName} Payment Link - - - -
- ${brandName} -

Payment Request

-

${link.description || 'Secure checkout link'}

-

${link.amount.toFixed(2)} ${link.currency}

-

Expires ${new Date(link.expiresAt).toUTCString()}

- Continue to Pay -
- -`; - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.send(html); + res.send(renderHostedCheckoutPage(link, { source, password })); }) ); @@ -245,4 +403,4 @@ paymentLinksRouter.post( res.json({ data: completed.analytics }); }) -); \ No newline at end of file +); diff --git a/backend/src/schemas/checkout.ts b/backend/src/schemas/checkout.ts new file mode 100644 index 00000000..6b067659 --- /dev/null +++ b/backend/src/schemas/checkout.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const brandSchema = z.object({ + brandName: z.string().min(1).max(80), + accentColor: z.string().regex(/^#([A-Fa-f0-9]{6})$/).optional(), + logoUrl: z.string().url().optional(), + redirectUrl: z.string().url().optional(), +}); + +export const createCheckoutSessionSchema = z.object({ + merchantId: z.string().min(1), + amount: z.number().positive(), + currency: z.string().length(3).default('USD'), + description: z.string().max(280).optional(), + allowedMethods: z.array(z.enum(['crypto', 'card', 'wallet'])).min(1).default(['crypto', 'card', 'wallet']), + customerEmail: z.string().email().optional(), + brand: brandSchema.optional(), + expiresInMinutes: z.number().int().positive().max(1440).default(30), +}); + +export const updatePaymentMethodSchema = z.object({ + method: z.enum(['crypto', 'card', 'wallet']), +}); + +export const processPaymentSchema = z.object({ + cardToken: z.string().optional(), + walletAddress: z.string().optional(), +}); diff --git a/backend/src/services/checkout.ts b/backend/src/services/checkout.ts new file mode 100644 index 00000000..7fa72105 --- /dev/null +++ b/backend/src/services/checkout.ts @@ -0,0 +1,394 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { BaseService } from './BaseService.js'; +import { EmailTemplateEngine } from './email-template-engine.js'; +import { EmailDeliveryService } from './email-delivery.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export type CheckoutSessionStatus = + | 'created' + | 'payment_pending' + | 'processing' + | 'completed' + | 'expired' + | 'abandoned'; + +export type CheckoutPaymentMethod = 'crypto' | 'card' | 'wallet'; + +export interface CheckoutSession { + id: string; + merchantId: string; + merchantName: string; + amount: number; + currency: string; + description?: string; + allowedMethods: CheckoutPaymentMethod[]; + selectedMethod?: CheckoutPaymentMethod; + status: CheckoutSessionStatus; + customerEmail?: string; + expiresAt: string; + createdAt: string; + updatedAt: string; + lockedRate?: { + rate: number; + lockedAt: string; + expiresAt: string; + pair: string; + }; + brand?: { + brandName: string; + accentColor?: string; + logoUrl?: string; + redirectUrl?: string; + }; + transactionId?: string; +} + +export type CreateCheckoutSessionInput = { + merchantId: string; + amount: number; + currency: string; + description?: string; + allowedMethods?: CheckoutPaymentMethod[]; + customerEmail?: string; + brand?: { + brandName: string; + accentColor?: string; + logoUrl?: string; + redirectUrl?: string; + }; + expiresInMinutes?: number; +}; + +export class CheckoutService extends BaseService { + private sessions = new Map(); + private emailTemplateEngine = new EmailTemplateEngine(); + private emailDeliveryService = new EmailDeliveryService(); + private timers = new Map(); + + private nowIso(): string { + return new Date().toISOString(); + } + + create(input: CreateCheckoutSessionInput): CheckoutSession { + this.validate(input.amount > 0, 'Amount must be greater than 0'); + this.validate(!!input.merchantId, 'Merchant ID is required'); + + const id = `chk_${randomUUID()}`; + const now = new Date(); + const expiresInMin = input.expiresInMinutes || 30; + const expiresAt = new Date(now.getTime() + expiresInMin * 60 * 1000).toISOString(); + + const session: CheckoutSession = { + id, + merchantId: input.merchantId, + merchantName: input.brand?.brandName || 'AgenticPay Merchant', + amount: Number(input.amount.toFixed(2)), + currency: input.currency.toUpperCase(), + description: input.description, + allowedMethods: input.allowedMethods || ['crypto', 'card', 'wallet'], + status: 'created', + customerEmail: input.customerEmail, + brand: input.brand, + expiresAt, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + + this.sessions.set(id, session); + + // Schedule abandoned recovery reminder email and expiration + this.scheduleSessionLifecycle(id, expiresInMin); + + return session; + } + + getById(id: string): CheckoutSession | undefined { + const session = this.sessions.get(id); + if (!session) return undefined; + + // Check if expired and update status + const now = new Date().getTime(); + const exp = new Date(session.expiresAt).getTime(); + if (now > exp && (session.status === 'created' || session.status === 'payment_pending' || session.status === 'processing')) { + session.status = 'expired'; + session.updatedAt = this.nowIso(); + this.sessions.set(id, session); + } + + return session; + } + + updatePaymentMethod(id: string, method: CheckoutPaymentMethod): CheckoutSession { + const session = this.getById(id); + if (!session) this.notFound('Checkout session', id); + this.validate(session.status === 'created' || session.status === 'payment_pending', 'Session is not in payable state'); + this.validate(session.allowedMethods.includes(method), `Payment method ${method} is not allowed for this session`); + + session.selectedMethod = method; + session.status = 'payment_pending'; + session.updatedAt = this.nowIso(); + + this.sessions.set(id, session); + return session; + } + + lockExchangeRate(id: string): CheckoutSession { + const session = this.getById(id); + if (!session) this.notFound('Checkout session', id); + this.validate(session.status === 'payment_pending', 'Payment method must be selected to lock rates'); + this.validate(session.selectedMethod === 'crypto', 'Rates can only be locked for crypto payments'); + + const rates = this.getExchangeRates(); + const baseRate = rates[session.currency] || 1.0; + const cryptoRate = rates['XLM'] || 0.12; // default relative to USD + const rate = Number((baseRate / cryptoRate).toFixed(6)); + + const now = new Date(); + const expiresAt = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutes rate guarantee + + session.lockedRate = { + rate, + lockedAt: now.toISOString(), + expiresAt, + pair: `XLM/${session.currency}`, + }; + session.updatedAt = this.nowIso(); + + this.sessions.set(id, session); + return session; + } + + async processPayment(id: string, details: { cardToken?: string; walletAddress?: string }): Promise { + const session = this.getById(id); + if (!session) this.notFound('Checkout session', id); + this.validate(session.status === 'payment_pending', 'Session is not ready for payment'); + this.validate(!!session.selectedMethod, 'No payment method selected'); + + session.status = 'processing'; + session.updatedAt = this.nowIso(); + this.sessions.set(id, session); + + // Simulate processing delay + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Simulate random failure 5% of the time, otherwise complete + const isSuccess = Math.random() >= 0.05; + + if (isSuccess) { + session.status = 'completed'; + session.transactionId = `tx_${randomUUID().replace(/-/g, '').slice(0, 16)}`; + + // Clear timers + this.clearTimers(id); + } else { + session.status = 'payment_pending'; // revert to pending + } + + session.updatedAt = this.nowIso(); + this.sessions.set(id, session); + return session; + } + + expireSession(id: string): CheckoutSession { + const session = this.sessions.get(id); + if (!session) this.notFound('Checkout session', id); + + if (session.status === 'created' || session.status === 'payment_pending' || session.status === 'processing') { + session.status = 'expired'; + session.updatedAt = this.nowIso(); + this.sessions.set(id, session); + } + + this.clearTimers(id); + return session; + } + + async markAbandoned(id: string): Promise { + const session = this.sessions.get(id); + if (!session) this.notFound('Checkout session', id); + + if (session.status === 'created' || session.status === 'payment_pending') { + session.status = 'abandoned'; + session.updatedAt = this.nowIso(); + this.sessions.set(id, session); + + // Trigger recovery email if email is present + if (session.customerEmail) { + await this.sendRecoveryEmail(session); + } + } + + return session; + } + + getExchangeRates(): Record { + return { + USD: 1.0, + EUR: 0.92, + GBP: 0.79, + XLM: 0.12, + USDC: 1.0, + ETH: 3500.0, + BTC: 65000.0, + }; + } + + generateReceipt(id: string): string { + const session = this.getById(id); + if (!session) this.notFound('Checkout session', id); + this.validate(session.status === 'completed', 'Receipt is only available for completed transactions'); + + const brandName = session.brand?.brandName || 'AgenticPay Merchant'; + const accentColor = session.brand?.accentColor || '#0052FF'; + + return ` + + + + Receipt for ${brandName} + + + +
+
+

Payment Receipt

+

${brandName}

+
+
+
${new Intl.NumberFormat('en-US', { style: 'currency', currency: session.currency }).format(session.amount)}
+
+ Session ID + ${session.id} +
+
+ Transaction ID + ${session.transactionId || 'N/A'} +
+
+ Payment Method + ${session.selectedMethod || 'N/A'} +
+
+ Date + ${new Date(session.updatedAt).toLocaleString()} +
+
+ Status + Success +
+
+
+ + +`; + } + + private scheduleSessionLifecycle(id: string, expiresInMin: number) { + // Schedule expiration + const expireTimer = setTimeout(() => { + this.expireSession(id); + }, expiresInMin * 60 * 1000); + + // Schedule abandonment reminder 15 minutes before expiration (or 15 min from now if session is 30 min) + const reminderDelay = Math.max(1, expiresInMin - 15) * 60 * 1000; + const reminderTimer = setTimeout(() => { + this.markAbandoned(id); + }, reminderDelay); + + this.timers.set(`${id}_expire`, expireTimer); + this.timers.set(`${id}_reminder`, reminderTimer); + } + + private clearTimers(id: string) { + const expireTimer = this.timers.get(`${id}_expire`); + const reminderTimer = this.timers.get(`${id}_reminder`); + + if (expireTimer) clearTimeout(expireTimer); + if (reminderTimer) clearTimeout(reminderTimer); + + this.timers.delete(`${id}_expire`); + this.timers.delete(`${id}_reminder`); + } + + private async sendRecoveryEmail(session: CheckoutSession): Promise { + try { + const pathsToTry = [ + path.join(__dirname, '../templates/abandoned-checkout.html'), + path.join(process.cwd(), 'backend/src/templates/abandoned-checkout.html'), + path.join(process.cwd(), 'src/templates/abandoned-checkout.html'), + ]; + + let templateStr = ''; + for (const p of pathsToTry) { + if (fs.existsSync(p)) { + templateStr = fs.readFileSync(p, 'utf-8'); + break; + } + } + + if (!templateStr) { + console.warn('[CheckoutService] Abandoned recovery email template not found. Using default minimal template.'); + templateStr = ` +

Complete Your Purchase

+

Hi, you left your checkout page. Complete it here: {{checkoutUrl}}

+

Amount: {{amount}} {{currency}}

+ `; + } + + const checkoutUrl = `https://pay.agenticpay.com/checkout/${session.id}`; + const renderedHtml = this.emailTemplateEngine.render(templateStr, { + brand: session.brand, + amount: session.amount, + currency: session.currency, + description: session.description, + sessionId: session.id, + checkoutUrl, + }); + + console.log(`[CheckoutService] Sending recovery email to ${session.customerEmail} for checkout ${session.id}`); + + await this.emailDeliveryService.send({ + to: session.customerEmail!, + subject: `Complete your purchase at ${session.brand?.brandName || 'Merchant'}`, + html: renderedHtml, + }); + } catch (err) { + console.error('[CheckoutService] Failed to send recovery email:', err); + } + } + + resetForTests(): void { + this.sessions.clear(); + for (const timer of this.timers.values()) { + clearTimeout(timer); + } + this.timers.clear(); + } +} + +export const checkoutService = new CheckoutService(); diff --git a/backend/src/templates/abandoned-checkout.html b/backend/src/templates/abandoned-checkout.html new file mode 100644 index 00000000..db533996 --- /dev/null +++ b/backend/src/templates/abandoned-checkout.html @@ -0,0 +1,174 @@ + + + + + + Complete Your Payment + + + +
+
+
+

Finish Your Purchase

+
+
+
+ {{#if brand.logoUrl}} + + {{/if}} +

{{default brand.brandName "AgenticPay Merchant"}}

+
+ +

+ We noticed you left items in your cart. No worries, we saved your checkout session so you can pick up right where you left off! +

+ +
+ {{#if description}} +
+ Description + {{description}} +
+ {{/if}} +
+ Session ID + {{sessionId}} +
+
+ Total Amount + {{formatCurrency amount currency}} +
+
+ + + +

+ Note: This checkout session will expire soon. Please complete your payment to ensure the exchange rate is secured. +

+
+ +
+
+ + diff --git a/frontend/app/checkout/[id]/layout.tsx b/frontend/app/checkout/[id]/layout.tsx new file mode 100644 index 00000000..fd6cc401 --- /dev/null +++ b/frontend/app/checkout/[id]/layout.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { Metadata } from 'next'; +import '../checkout.css'; + +export const metadata: Metadata = { + title: 'Secure Checkout — AgenticPay', + description: 'Hosted checkout page powered by AgenticPay. Pay using crypto, cards, or web3 wallets instantly.', + robots: { + index: false, + follow: false, + }, + openGraph: { + title: 'Secure Checkout — AgenticPay', + description: 'Secure, instant collection gateway. Pay via crypto, cards, or web3 wallets.', + type: 'website', + }, +}; + +interface CheckoutLayoutProps { + children: React.ReactNode; +} + +export default function CheckoutLayout({ children }: CheckoutLayoutProps) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/app/checkout/[id]/page.tsx b/frontend/app/checkout/[id]/page.tsx new file mode 100644 index 00000000..5a284cd1 --- /dev/null +++ b/frontend/app/checkout/[id]/page.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { CheckoutPage } from '@/components/checkout/CheckoutPage'; + +interface PageProps { + params: Promise<{ id: string }> | { id: string }; +} + +export default async function Page({ params }: PageProps) { + const resolvedParams = await params; + const { id } = resolvedParams; + + return ; +} diff --git a/frontend/app/checkout/checkout.css b/frontend/app/checkout/checkout.css new file mode 100644 index 00000000..12ac535c --- /dev/null +++ b/frontend/app/checkout/checkout.css @@ -0,0 +1,560 @@ +/* ========================================================================== + Premium Hosted Checkout Stylesheet (Glassmorphism & Responsive layout) + ========================================================================== */ + +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap'); + +:root { + --primary-accent: var(--brand-accent, #0052ff); + --primary-accent-rgb: 0, 82, 255; + --bg-gradient-start: #0f172a; + --bg-gradient-end: #020617; + + --glass-bg: rgba(30, 41, 59, 0.45); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-border-focus: rgba(255, 255, 255, 0.25); + --glass-shadow: 0 24px 60px -15px rgba(0, 0, 0, 0.5); + + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --text-error: #f87171; + --text-success: #4ade80; + + --border-radius-lg: 16px; + --border-radius-md: 12px; + --border-radius-sm: 8px; + + --font-display: 'Outfit', sans-serif; + --font-body: 'Plus Jakarta Sans', sans-serif; + + --transition-fast: 0.15s ease; + --transition-normal: 0.25s cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Base resets & layout */ +.checkout-wrapper { + min-height: 100vh; + background: radial-gradient(circle at top right, rgba(var(--primary-accent-rgb), 0.15), transparent 45%), + linear-gradient(135deg, var(--bg-gradient-start), var(--bg-gradient-end)); + font-family: var(--font-body); + color: var(--text-primary); + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 24px; + box-sizing: border-box; +} + +.checkout-container { + width: 100%; + max-width: 1080px; + margin: auto; + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 32px; + align-items: start; +} + +@media (max-width: 900px) { + .checkout-container { + grid-template-columns: 1fr; + gap: 24px; + max-width: 520px; + } + + .checkout-order-summary { + order: -1; + } +} + +/* Glassmorphism Card base */ +.checkout-card { + background: var(--glass-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--glass-border); + border-radius: var(--border-radius-lg); + box-shadow: var(--glass-shadow); + overflow: hidden; + transition: border-color var(--transition-normal); +} + +.checkout-card:hover { + border-color: rgba(var(--primary-accent-rgb), 0.2); +} + +/* Header Branding */ +.checkout-brand-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 24px; + border-bottom: 1px solid var(--glass-border); +} + +.checkout-brand-logo { + display: flex; + align-items: center; + gap: 12px; +} + +.checkout-logo-img { + width: 42px; + height: 42px; + border-radius: var(--border-radius-sm); + border: 1px solid var(--glass-border); + object-fit: cover; +} + +.checkout-logo-placeholder { + width: 42px; + height: 42px; + border-radius: var(--border-radius-sm); + background: linear-gradient(135deg, var(--primary-accent), rgba(var(--primary-accent-rgb), 0.5)); + display: grid; + place-items: center; + font-family: var(--font-display); + font-weight: 800; + font-size: 20px; + color: white; + box-shadow: 0 4px 12px rgba(var(--primary-accent-rgb), 0.3); +} + +.checkout-brand-name { + font-family: var(--font-display); + font-size: 18px; + font-weight: 700; + margin: 0; + letter-spacing: -0.02em; +} + +.checkout-brand-subtitle { + font-size: 11px; + color: var(--text-secondary); + margin: 2px 0 0; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +/* Step Progress Indicator */ +.checkout-progress-bar { + display: flex; + justify-content: space-between; + padding: 20px 24px; + background: rgba(15, 23, 42, 0.3); + border-bottom: 1px solid var(--glass-border); +} + +.checkout-step { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + position: relative; + transition: color var(--transition-fast); +} + +.checkout-step-num { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid var(--glass-border); + display: grid; + place-items: center; + font-size: 10px; + background: rgba(15, 23, 42, 0.5); + transition: all var(--transition-normal); +} + +.checkout-step.active { + color: var(--text-primary); +} + +.checkout-step.active .checkout-step-num { + border-color: var(--primary-accent); + background: var(--primary-accent); + color: white; + box-shadow: 0 0 12px rgba(var(--primary-accent-rgb), 0.4); +} + +.checkout-step.completed { + color: var(--text-success); +} + +.checkout-step.completed .checkout-step-num { + border-color: var(--text-success); + background: rgba(74, 222, 128, 0.1); + color: var(--text-success); +} + +/* Forms & Content */ +.checkout-content { + padding: 32px; +} + +.checkout-title { + font-family: var(--font-display); + font-size: 24px; + font-weight: 700; + margin: 0 0 8px; + letter-spacing: -0.01em; +} + +.checkout-subtitle { + font-size: 14px; + color: var(--text-secondary); + margin: 0 0 24px; + line-height: 1.5; +} + +/* Tabs Payment Method Selector */ +.checkout-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + background: rgba(15, 23, 42, 0.4); + padding: 6px; + border-radius: var(--border-radius-md); + border: 1px solid var(--glass-border); + margin-bottom: 24px; +} + +.checkout-tab-btn { + background: transparent; + border: none; + color: var(--text-secondary); + padding: 12px 8px; + font-family: var(--font-body); + font-weight: 600; + font-size: 13px; + border-radius: var(--border-radius-sm); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + transition: all var(--transition-normal); +} + +.checkout-tab-btn:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.03); +} + +.checkout-tab-btn.active { + color: white; + background: var(--primary-accent); + box-shadow: 0 4px 12px rgba(var(--primary-accent-rgb), 0.25); +} + +/* Order Summary Column */ +.checkout-order-summary { + padding: 32px; + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; +} + +.summary-price-box { + text-align: center; + padding: 24px 0; + border-bottom: 1px solid var(--glass-border); +} + +.summary-amount { + font-family: var(--font-display); + font-size: 42px; + font-weight: 800; + color: white; + letter-spacing: -0.02em; + margin: 0; +} + +.summary-currency { + font-size: 14px; + color: var(--text-secondary); + font-weight: 600; + letter-spacing: 0.05em; + margin-top: 4px; + display: block; +} + +.summary-details-list { + margin: 24px 0 0; + padding: 0; + list-style: none; +} + +.summary-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + font-size: 13px; +} + +.summary-row .label { + color: var(--text-secondary); +} + +.summary-row .value { + font-weight: 600; + color: var(--text-primary); +} + +/* Rate Widget Display */ +.rate-widget { + background: rgba(var(--primary-accent-rgb), 0.04); + border: 1px dashed rgba(var(--primary-accent-rgb), 0.25); + border-radius: var(--border-radius-md); + padding: 16px; + margin-top: 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.rate-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; +} + +.rate-ticker { + font-weight: 700; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 6px; +} + +.rate-arrow { + font-size: 10px; + transition: transform var(--transition-normal); +} + +.rate-arrow.up { color: var(--text-success); } +.rate-arrow.down { color: var(--text-error); } + +.rate-timer { + color: var(--text-secondary); +} + +.rate-lock-btn { + background: rgba(var(--primary-accent-rgb), 0.1); + color: var(--primary-accent); + border: 1px solid rgba(var(--primary-accent-rgb), 0.3); + border-radius: var(--border-radius-sm); + padding: 8px 12px; + font-size: 12px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all var(--transition-normal); +} + +.rate-lock-btn:hover { + background: var(--primary-accent); + color: white; + border-color: var(--primary-accent); + box-shadow: 0 4px 12px rgba(var(--primary-accent-rgb), 0.2); +} + +.rate-lock-btn.locked { + background: rgba(74, 222, 128, 0.1); + color: var(--text-success); + border-color: rgba(74, 222, 128, 0.3); + cursor: default; +} + +/* Action button */ +.checkout-btn { + width: 100%; + background: var(--primary-accent); + border: none; + border-radius: var(--border-radius-md); + color: white; + font-family: var(--font-body); + font-weight: 700; + font-size: 15px; + padding: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + transition: all var(--transition-normal); + box-shadow: 0 4px 16px rgba(var(--primary-accent-rgb), 0.2); +} + +.checkout-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(var(--primary-accent-rgb), 0.35); +} + +.checkout-btn:active { + transform: translateY(0); +} + +.checkout-btn:disabled { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + color: var(--text-muted); + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.checkout-btn-secondary { + width: 100%; + background: transparent; + border: 1px solid var(--glass-border); + border-radius: var(--border-radius-md); + color: var(--text-secondary); + font-family: var(--font-body); + font-weight: 600; + font-size: 14px; + padding: 14px; + cursor: pointer; + text-decoration: none; + display: inline-grid; + place-items: center; + margin-top: 12px; + transition: all var(--transition-normal); +} + +.checkout-btn-secondary:hover { + color: var(--text-primary); + border-color: var(--glass-border-focus); + background: rgba(255, 255, 255, 0.02); +} + +/* Form Inputs */ +.checkout-field { + margin-bottom: 20px; +} + +.checkout-label { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.checkout-input { + width: 100%; + background: rgba(15, 23, 42, 0.3); + border: 1px solid var(--glass-border); + border-radius: var(--border-radius-sm); + padding: 12px 14px; + font-family: var(--font-body); + color: white; + font-size: 14px; + transition: all var(--transition-normal); + box-sizing: border-box; +} + +.checkout-input:focus { + outline: none; + border-color: var(--primary-accent); + background: rgba(15, 23, 42, 0.5); + box-shadow: 0 0 0 3px rgba(var(--primary-accent-rgb), 0.15); +} + +/* Animated success checkmark */ +.checkmark-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 0 20px; +} + +.checkmark-circle { + width: 80px; + height: 80px; + border-radius: 50%; + background: rgba(74, 222, 128, 0.1); + border: 2px solid var(--text-success); + display: grid; + place-items: center; + animation: scaleUp 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; +} + +.checkmark-icon { + width: 32px; + height: 32px; + stroke: var(--text-success); + stroke-width: 4; + fill: none; + stroke-dasharray: 48; + stroke-dashoffset: 48; + animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.3s forwards; +} + +@keyframes scaleUp { + 0% { transform: scale(0); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +@keyframes stroke { + 100% { stroke-dashoffset: 0; } +} + +/* Countdown bar / timeout warning */ +.timeout-bar { + width: 100%; + background: rgba(255, 255, 255, 0.05); + height: 4px; + position: absolute; + bottom: 0; + left: 0; +} + +.timeout-progress { + height: 100%; + background: linear-gradient(90deg, var(--primary-accent), var(--text-error)); + width: 100%; + transition: width 1s linear; +} + +/* Footer info */ +.checkout-footer { + margin-top: 40px; + font-size: 12px; + color: var(--text-muted); + text-align: center; + display: flex; + align-items: center; + gap: 6px; +} + +.checkout-footer svg { + width: 14px; + height: 14px; + fill: var(--text-muted); +} + +/* Simple Skeleton loader states */ +.skeleton { + background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.05) 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; + border-radius: var(--border-radius-sm); +} + +@keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} diff --git a/frontend/components/checkout/CheckoutConfirmation.tsx b/frontend/components/checkout/CheckoutConfirmation.tsx new file mode 100644 index 00000000..cbdb26c2 --- /dev/null +++ b/frontend/components/checkout/CheckoutConfirmation.tsx @@ -0,0 +1,122 @@ +'use client'; + +import React from 'react'; +import { api } from '@/frontend/lib/api'; + +interface CheckoutConfirmationProps { + sessionId: string; + amount: number; + currency: string; + method?: string; + transactionId?: string; + merchantName: string; + redirectUrl?: string; +} + +export const CheckoutConfirmation: React.FC = ({ + sessionId, + amount, + currency, + method, + transactionId, + merchantName, + redirectUrl, +}) => { + const handleDownloadReceipt = async () => { + try { + const receiptUrl = api.checkout.getReceiptUrl(sessionId); + + // Fetch HTML blob from backend receipt route + const response = await fetch(receiptUrl); + if (!response.ok) throw new Error('Receipt download failed'); + const blob = await response.blob(); + + // Trigger local download + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `receipt_${sessionId}.html`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error('Failed to download receipt:', err); + } + }; + + const handleReturnToMerchant = () => { + if (redirectUrl) { + window.location.href = redirectUrl; + } + }; + + return ( +
+
+
+ + + +
+
+ +

Payment Confirmed!

+

+ Your payment to {merchantName} has been successfully processed. An official receipt has been generated. +

+ +
+
+ Amount Paid + + {new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)} + +
+ +
+ Payment Method + {method || 'N/A'} +
+ +
+ Transaction ID + + {transactionId || 'N/A'} + +
+ +
+ Timestamp + {new Date().toLocaleString()} +
+
+ + + + {redirectUrl ? ( + + ) : ( + + Return to Dashboard + + )} +
+ ); +}; diff --git a/frontend/components/checkout/CheckoutPage.tsx b/frontend/components/checkout/CheckoutPage.tsx new file mode 100644 index 00000000..6298cc00 --- /dev/null +++ b/frontend/components/checkout/CheckoutPage.tsx @@ -0,0 +1,393 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { api } from '@/frontend/lib/api'; +import { PaymentMethodSelector } from './PaymentMethodSelector'; +import { CheckoutConfirmation } from './CheckoutConfirmation'; +import { CheckoutPaymentMethod, CheckoutSessionStatus } from '@/backend/src/services/checkout'; + +interface CheckoutPageProps { + id: string; +} + +export const CheckoutPage: React.FC = ({ id }) => { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [step, setStep] = useState<'summary' | 'details' | 'processing' | 'confirmation' | 'expired'>('summary'); + const [selectedMethod, setSelectedMethod] = useState(undefined); + const [paymentDetails, setPaymentDetails] = useState({}); + const [processing, setProcessing] = useState(false); + + // Rate lock state + const [lockedRate, setLockedRate] = useState<{ rate: number; expiresAt: string } | undefined>(undefined); + + // Time remaining count + const [timeRemaining, setTimeRemaining] = useState(0); + const timerRef = useRef(null); + + // Fetch session details on mount + useEffect(() => { + const fetchSession = async () => { + try { + const response = await api.checkout.getSession(id); + const s = response.data; + setSession(s); + setSelectedMethod(s.selectedMethod); + if (s.lockedRate) { + setLockedRate({ rate: s.lockedRate.rate, expiresAt: s.lockedRate.expiresAt }); + } + + // Map status to UI step + if (s.status === 'completed') { + setStep('confirmation'); + } else if (s.status === 'expired') { + setStep('expired'); + } else if (s.status === 'payment_pending') { + setStep('details'); + } + + // Initialize countdown + const diff = new Date(s.expiresAt).getTime() - Date.now(); + setTimeRemaining(Math.max(0, Math.floor(diff / 1000))); + + setLoading(false); + } catch (err: any) { + setError(err.message || 'Failed to load checkout session'); + setLoading(false); + } + }; + + fetchSession(); + }, [id]); + + // Session expiry countdown interval + useEffect(() => { + if (!session || step === 'confirmation' || step === 'expired') return; + + timerRef.current = setInterval(() => { + const diff = new Date(session.expiresAt).getTime() - Date.now(); + const seconds = Math.max(0, Math.floor(diff / 1000)); + setTimeRemaining(seconds); + + if (seconds <= 0) { + setStep('expired'); + if (timerRef.current) clearInterval(timerRef.current); + } + }, 1000); + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [session, step]); + + // Prevent leaving/back buttons during active payment processing + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (processing) { + e.preventDefault(); + e.returnValue = 'Payment is in progress. Are you sure you want to leave?'; + return e.returnValue; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [processing]); + + const handleSelectMethod = async (method: CheckoutPaymentMethod) => { + setSelectedMethod(method); + try { + const response = await api.checkout.selectPaymentMethod(id, method); + setSession(response.data); + setStep('details'); + } catch (err) { + console.error('Failed to select payment method:', err); + } + }; + + const handleRateLocked = (rate: number, expiresAt: string) => { + setLockedRate({ rate, expiresAt }); + }; + + const handleExecutePayment = async () => { + if (!selectedMethod || processing) return; + setProcessing(true); + setStep('processing'); + + try { + const response = await api.checkout.processPayment(id, paymentDetails); + const updatedSession = response.data; + setSession(updatedSession); + + if (updatedSession.status === 'completed') { + setStep('confirmation'); + } else { + // Returned to pending (e.g. simulation failure) + setStep('details'); + alert('Payment processing failed. Please try again.'); + } + } catch (err: any) { + setStep('details'); + alert(err.message || 'Payment execution failed.'); + } finally { + setProcessing(false); + } + }; + + const formatCountdown = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !session) { + return ( +
+
+
+
⚠️
+

Checkout Session Error

+

+ {error || 'This checkout session was not found or is invalid.'} +

+ Return to Dashboard +
+
+
+ ); + } + + const brandName = session.brand?.brandName || 'AgenticPay Merchant'; + const logoUrl = session.brand?.logoUrl; + const accentColor = session.brand?.accentColor || '#0052FF'; + + // Total session progress duration (default to 30 min = 1800 sec) + const percentRemaining = Math.min(100, (timeRemaining / 1800) * 100); + + return ( +
+
+ + {/* Step Flow Card */} +
+
+
+ {logoUrl ? ( + {brandName} + ) : ( +
{brandName.charAt(0)}
+ )} +
+

{brandName}

+

Secure Payment Gate

+
+
+ + {step !== 'confirmation' && step !== 'expired' && ( +
+ ⏱️ {formatCountdown(timeRemaining)} +
+ )} +
+ +
+
+
1
+ Summary +
+
+
2
+ Method +
+
+
3
+ Payment +
+
+ +
+ {step !== 'confirmation' && step !== 'expired' && ( +
+
+
+ )} +
+ + {step === 'summary' && ( +
+

Review Order Summary

+

+ Please verify the payment details and select your preferred collection method to proceed. +

+ +
+
+ Description + {session.description || 'Secure merchant invoice payment'} +
+
+ Invoice currency + {session.currency} +
+
+ Merchant Account ID + {session.merchantId} +
+
+ + +
+ )} + + {step === 'details' && ( +
+

Choose Payment Method

+

+ Collection is fully automated. Choose crypto for near-instant low-fee blockchain clearance. +

+ + + + {selectedMethod && ( + + )} + + +
+ )} + + {step === 'processing' && ( +
+
+

Securing Transaction...

+

+ Executing automated smart contracts on the Stellar / Payment mesh network. Do not close this tab or navigate away. +

+
+ )} + + {step === 'confirmation' && ( + + )} + + {step === 'expired' && ( +
+
+

Checkout Session Expired

+

+ This checkout session has timed out. Merchant rates can only be locked temporarily to prevent price slippage. +

+ Return to Dashboard +
+ )} +
+ + {/* Side summary card */} + {step !== 'confirmation' && step !== 'expired' && ( + + )} + +
+
+ ); +}; diff --git a/frontend/components/checkout/ExchangeRateDisplay.tsx b/frontend/components/checkout/ExchangeRateDisplay.tsx new file mode 100644 index 00000000..cb3a6937 --- /dev/null +++ b/frontend/components/checkout/ExchangeRateDisplay.tsx @@ -0,0 +1,170 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { api } from '@/frontend/lib/api'; + +interface ExchangeRateDisplayProps { + sessionId: string; + currency: string; + onRateLocked?: (rate: number, expiresAt: string) => void; + lockedRate?: { + rate: number; + expiresAt: string; + }; +} + +export const ExchangeRateDisplay: React.FC = ({ + sessionId, + currency, + onRateLocked, + lockedRate, +}) => { + const [rate, setRate] = useState(null); + const [prevRate, setPrevRate] = useState(null); + const [loading, setLoading] = useState(true); + const [locking, setLocking] = useState(false); + const [timeLeft, setTimeLeft] = useState(0); + const timerRef = useRef(null); + + // Poll current rate from backend every 10 seconds unless rate is locked + useEffect(() => { + if (lockedRate) return; + + const fetchRate = async () => { + try { + const response = await api.checkout.getExchangeRates(); + const rates = response.data; + const baseRate = rates[currency.toUpperCase()] || 1.0; + const cryptoRate = rates['XLM'] || 0.12; + const calculatedRate = Number((baseRate / cryptoRate).toFixed(6)); + + setRate((prev) => { + if (prev !== null && prev !== calculatedRate) { + setPrevRate(prev); + } + return calculatedRate; + }); + setLoading(false); + } catch (err) { + console.error('Failed to fetch exchange rates:', err); + } + }; + + fetchRate(); + const interval = setInterval(fetchRate, 10000); + + return () => clearInterval(interval); + }, [currency, lockedRate]); + + // Handle rate lock timer countdown + useEffect(() => { + if (!lockedRate) { + setTimeLeft(0); + if (timerRef.current) clearInterval(timerRef.current); + return; + } + + const calculateTimeLeft = () => { + const diff = new Date(lockedRate.expiresAt).getTime() - Date.now(); + const seconds = Math.max(0, Math.floor(diff / 1000)); + setTimeLeft(seconds); + + if (seconds <= 0) { + if (timerRef.current) clearInterval(timerRef.current); + } + }; + + calculateTimeLeft(); + timerRef.current = setInterval(calculateTimeLeft, 1000); + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [lockedRate]); + + const handleLockRate = async () => { + if (locking || lockedRate) return; + setLocking(true); + + try { + const response = await api.checkout.lockRate(sessionId); + const updatedSession = response.data; + if (updatedSession.lockedRate && onRateLocked) { + onRateLocked(updatedSession.lockedRate.rate, updatedSession.lockedRate.expiresAt); + } + } catch (err) { + console.error('Failed to lock exchange rate:', err); + } finally { + setLocking(false); + } + }; + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + if (loading && !lockedRate) { + return ( +
+
+
+ ); + } + + const currentDisplayRate = lockedRate ? lockedRate.rate : rate; + const isLocked = !!lockedRate; + + // Determine rate trajectory arrow + const isUp = prevRate !== null && rate !== null && rate > prevRate; + const isDown = prevRate !== null && rate !== null && rate < prevRate; + + return ( +
+
+
+ XLM / {currency} + {!isLocked && ( + + {isUp ? '▲' : isDown ? '▼' : '●'} + + )} +
+
+ {isLocked ? ( + Rate Locked + ) : ( + Live updates + )} +
+
+ +
+
+ {currentDisplayRate?.toFixed(4)} {currency} / XLM +
+ + {isLocked ? ( +
+ 🔒 {formatTime(timeLeft)} +
+ ) : ( + + )} +
+ +
+ {isLocked + ? 'Guaranteed rate locked for transaction completion. Will auto-expire on timer.' + : 'Exchange rates fluctuate. Lock rate to secure price during checkout.'} +
+
+ ); +}; diff --git a/frontend/components/checkout/PaymentMethodSelector.tsx b/frontend/components/checkout/PaymentMethodSelector.tsx new file mode 100644 index 00000000..340dd89c --- /dev/null +++ b/frontend/components/checkout/PaymentMethodSelector.tsx @@ -0,0 +1,224 @@ +'use client'; + +import React from 'react'; +import { CheckoutPaymentMethod } from '@/backend/src/services/checkout'; +import { ExchangeRateDisplay } from './ExchangeRateDisplay'; + +interface PaymentMethodSelectorProps { + sessionId: string; + currency: string; + amount: number; + allowedMethods: CheckoutPaymentMethod[]; + selectedMethod?: CheckoutPaymentMethod; + onMethodSelected: (method: CheckoutPaymentMethod) => void; + lockedRate?: { + rate: number; + expiresAt: string; + }; + onRateLocked?: (rate: number, expiresAt: string) => void; + paymentDetails: { + cardNumber?: string; + cardExpiry?: string; + cardCvc?: string; + walletAddress?: string; + }; + onDetailsChange: (details: any) => void; +} + +export const PaymentMethodSelector: React.FC = ({ + sessionId, + currency, + amount, + allowedMethods, + selectedMethod, + onMethodSelected, + lockedRate, + onRateLocked, + paymentDetails, + onDetailsChange, +}) => { + const getCryptoAmount = () => { + if (lockedRate) { + return (amount / lockedRate.rate).toFixed(4); + } + // Estimated amount based on default rate of 0.12 if not locked/polled yet + return (amount / 0.12).toFixed(4); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + onDetailsChange({ ...paymentDetails, [name]: value }); + }; + + const estimatedFees = { + crypto: '0.0001 XLM (~$0.00001)', + card: `${new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount * 0.029 + 0.3)} (2.9% + $0.30)`, + wallet: '0.0002 XLM (Network gas)', + }; + + return ( +
+
+ {allowedMethods.includes('crypto') && ( + + )} + {allowedMethods.includes('card') && ( + + )} + {allowedMethods.includes('wallet') && ( + + )} +
+ +
+ {selectedMethod === 'crypto' && ( +
+ + +
+
+ + +
+ +
+
+ {/* Mock QR Code using external utility */} + Stellar QR Code +
+
+
Scan QR to Pay
+
+ Send exactly {getCryptoAmount()} XLM to the address above. +
+
+ Stellar Network. Est fee: {estimatedFees.crypto} +
+
+
+
+
+ )} + + {selectedMethod === 'card' && ( +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ 🔒 Secured Stripe Elements. Processing fees apply: {estimatedFees.card} +
+
+ )} + + {selectedMethod === 'wallet' && ( +
+
🔌
+

Connect Web3 Wallet

+

+ Connect your Stellar Albedo, Rabe, or WalletConnect-compatible wallet to proceed with single-click checkout. +

+ + + +
+ Est Network Gas fee: {estimatedFees.wallet} +
+
+ )} + + {!selectedMethod && ( +
+ Select a payment method above to complete your order +
+ )} +
+
+ ); +}; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index afa12a32..2ccc68c0 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -242,4 +242,32 @@ export const api = { method: 'POST', }), }, + + /** + * Hosted Checkout API + */ + checkout: { + createSession: async (payload: any) => apiCall('/checkout/sessions', { + method: 'POST', + body: JSON.stringify(payload), + }), + getSession: async (id: string) => apiCall(`/checkout/sessions/${id}`, { + method: 'GET', + }), + selectPaymentMethod: async (id: string, method: string) => apiCall(`/checkout/sessions/${id}/payment-method`, { + method: 'POST', + body: JSON.stringify({ method }), + }), + lockRate: async (id: string) => apiCall(`/checkout/sessions/${id}/lock-rate`, { + method: 'POST', + }), + processPayment: async (id: string, details: any) => apiCall(`/checkout/sessions/${id}/pay`, { + method: 'POST', + body: JSON.stringify(details), + }), + getReceiptUrl: (id: string) => `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}/checkout/sessions/${id}/receipt`, + getExchangeRates: async () => apiCall('/checkout/exchange-rates', { + method: 'GET', + }), + }, }; \ No newline at end of file