From 6a59ac6007dae0c4a0eab92d4dd5f71b40fd4dac Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 14 Mar 2026 19:08:38 +0000 Subject: [PATCH] feat: Wise-style payment flow with send & request pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /pay — step-by-step send flow: amount → recipient → method → review → pay Supports card, bank transfer (ACH/SEPA), and crypto wallet Auto-fills sender email from EncryptID account Tracks recipient email for notification on token receipt Prefillable via query params (from payment request links) - /pay/request — create shareable payment request links Generates prefilled /pay URLs with amount, currency, recipient, note Copy, email, or native share support - API proxy for onramp session creation (avoids CORS) - Transak widget email pre-fill from session Co-Authored-By: Claude Opus 4.6 --- app/api/proxy/onramp/session/route.ts | 38 ++ app/pay/page.tsx | 34 ++ app/pay/request/page.tsx | 7 + components/pay/PaymentFlow.tsx | 605 ++++++++++++++++++++++++++ components/pay/RequestFlow.tsx | 246 +++++++++++ 5 files changed, 930 insertions(+) create mode 100644 app/api/proxy/onramp/session/route.ts create mode 100644 app/pay/page.tsx create mode 100644 app/pay/request/page.tsx create mode 100644 components/pay/PaymentFlow.tsx create mode 100644 components/pay/RequestFlow.tsx diff --git a/app/api/proxy/onramp/session/route.ts b/app/api/proxy/onramp/session/route.ts new file mode 100644 index 0000000..8d3354c --- /dev/null +++ b/app/api/proxy/onramp/session/route.ts @@ -0,0 +1,38 @@ +/** + * Proxy: POST /api/proxy/onramp/session + * + * Forwards session creation to the onramp-service so the frontend + * can initiate a Transak payment session without CORS issues. + */ + +import { NextRequest, NextResponse } from 'next/server' + +const ONRAMP_URL = process.env.ONRAMP_SERVICE_URL || 'http://payment-onramp:3002' + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + + const res = await fetch(`${ONRAMP_URL}/api/onramp/transak/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + walletAddress: body.walletAddress, + fiatAmount: body.fiatAmount, + fiatCurrency: body.fiatCurrency, + email: body.email, + }), + }) + + if (!res.ok) { + const err = await res.text() + return NextResponse.json({ error: err }, { status: res.status }) + } + + const data = await res.json() + return NextResponse.json(data) + } catch (error) { + console.error('Onramp session proxy error:', error) + return NextResponse.json({ error: 'Failed to create session' }, { status: 500 }) + } +} diff --git a/app/pay/page.tsx b/app/pay/page.tsx new file mode 100644 index 0000000..aa21b0a --- /dev/null +++ b/app/pay/page.tsx @@ -0,0 +1,34 @@ +'use client' + +import { useSearchParams } from 'next/navigation' +import { Suspense } from 'react' +import PaymentFlow from '@/components/pay/PaymentFlow' + +function PayPage() { + const params = useSearchParams() + + const prefill = { + amount: params.get('amount') ? parseFloat(params.get('amount')!) : undefined, + currency: params.get('currency') || undefined, + recipientEmail: params.get('to') || undefined, + recipientName: params.get('name') || undefined, + description: params.get('note') || undefined, + recipientWallet: params.get('wallet') || undefined, + flowId: params.get('flow') || undefined, + funnelId: params.get('funnel') || undefined, + } + + return +} + +export default function PayPageWrapper() { + return ( + +
+
+ }> + +
+ ) +} diff --git a/app/pay/request/page.tsx b/app/pay/request/page.tsx new file mode 100644 index 0000000..09a0fc5 --- /dev/null +++ b/app/pay/request/page.tsx @@ -0,0 +1,7 @@ +'use client' + +import RequestFlow from '@/components/pay/RequestFlow' + +export default function RequestPage() { + return +} diff --git a/components/pay/PaymentFlow.tsx b/components/pay/PaymentFlow.tsx new file mode 100644 index 0000000..10e1db9 --- /dev/null +++ b/components/pay/PaymentFlow.tsx @@ -0,0 +1,605 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { useAuthStore } from '@/lib/auth' +import TransakWidget from '../TransakWidget' + +// ── Types ── + +type PaymentMethod = 'card' | 'bank_transfer' | 'crypto_wallet' +type Step = 'amount' | 'recipient' | 'method' | 'review' | 'processing' | 'complete' + +interface PaymentFlowProps { + /** Pre-fill from a payment request link */ + prefill?: { + recipientEmail?: string + recipientWallet?: string + recipientName?: string + amount?: number + currency?: string + description?: string + flowId?: string + funnelId?: string + } +} + +const CURRENCIES = [ + { code: 'USD', symbol: '$', name: 'US Dollar' }, + { code: 'EUR', symbol: '\u20ac', name: 'Euro' }, + { code: 'GBP', symbol: '\u00a3', name: 'British Pound' }, +] + +const PAYMENT_METHODS: { id: PaymentMethod; label: string; description: string; icon: string; eta: string; fee: string }[] = [ + { id: 'bank_transfer', label: 'Bank transfer', description: 'ACH, SEPA, or local bank', icon: 'bank', eta: '1\u20133 business days', fee: '~1%' }, + { id: 'card', label: 'Debit or credit card', description: 'Visa, Mastercard, Apple Pay', icon: 'card', eta: 'Instant', fee: '~3\u20135%' }, + { id: 'crypto_wallet', label: 'Crypto wallet', description: 'Send USDC directly on Base', icon: 'wallet', eta: 'Instant', fee: 'Gas only' }, +] + +// ── Helpers ── + +function formatCurrency(amount: number, currency: string): string { + const c = CURRENCIES.find(cc => cc.code === currency) + return `${c?.symbol || ''}${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} + +// ── Icons ── + +function MethodIcon({ type }: { type: string }) { + if (type === 'bank') return ( + + + + ) + if (type === 'card') return ( + + + + ) + return ( + + + + ) +} + +function CheckIcon() { + return ( + + + + ) +} + +function ArrowIcon() { + return ( + + + + ) +} + +// ── Progress Bar ── + +function StepProgress({ current, steps }: { current: Step; steps: Step[] }) { + const visibleSteps = steps.filter(s => s !== 'processing' && s !== 'complete') + const currentIdx = visibleSteps.indexOf(current) + + return ( +
+ {visibleSteps.map((step, i) => ( +
+
+
+ ))} +
+ ) +} + +// ── Main Component ── + +export default function PaymentFlow({ prefill }: PaymentFlowProps) { + const { isAuthenticated, username, did, login } = useAuthStore() + + // Form state + const [step, setStep] = useState(prefill?.amount ? 'recipient' : 'amount') + const [amount, setAmount] = useState(prefill?.amount?.toString() || '') + const [currency, setCurrency] = useState(prefill?.currency || 'USD') + const [recipientEmail, setRecipientEmail] = useState(prefill?.recipientEmail || '') + const [recipientName, setRecipientName] = useState(prefill?.recipientName || '') + const [description, setDescription] = useState(prefill?.description || '') + const [method, setMethod] = useState(null) + const [transakUrl, setTransakUrl] = useState(null) + const [sessionId, setSessionId] = useState(null) + const [error, setError] = useState(null) + const [senderEmail, setSenderEmail] = useState('') + + // Auto-fill sender email from auth + useEffect(() => { + if (isAuthenticated && username) { + // Try to fetch email from /api/me + fetch('/api/me') + .then(r => r.json()) + .then(data => { + if (data.user?.email) setSenderEmail(data.user.email) + }) + .catch(() => {}) + } + }, [isAuthenticated, username]) + + const numericAmount = parseFloat(amount) || 0 + const fee = method === 'card' ? numericAmount * 0.04 : method === 'bank_transfer' ? numericAmount * 0.01 : 0 + const totalReceived = numericAmount - fee + const selectedCurrency = CURRENCIES.find(c => c.code === currency) + + const allSteps: Step[] = ['amount', 'recipient', 'method', 'review', 'processing', 'complete'] + + // ── Step handlers ── + + const handleAmountNext = useCallback(() => { + if (numericAmount < 1) { setError('Minimum amount is 1.00'); return } + setError(null) + setStep('recipient') + }, [numericAmount]) + + const handleRecipientNext = useCallback(() => { + if (!recipientEmail && !prefill?.recipientWallet) { + setError('Recipient email is required') + return + } + setError(null) + setStep('method') + }, [recipientEmail, prefill?.recipientWallet]) + + const handleMethodSelect = useCallback((m: PaymentMethod) => { + setMethod(m) + setError(null) + setStep('review') + }, []) + + const handleConfirm = useCallback(async () => { + setError(null) + setStep('processing') + + if (method === 'crypto_wallet') { + // For direct crypto, just show the wallet address to send to + setStep('complete') + return + } + + try { + // Create Transak session via onramp API + const paymentMethod = method === 'bank_transfer' + ? 'us_bank_transfer,sepa_bank_transfer,gbp_bank_transfer' + : 'credit_debit_card' + + const walletAddress = prefill?.recipientWallet || '0x0000000000000000000000000000000000000000' + + const res = await fetch('/api/proxy/onramp/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + walletAddress, + fiatAmount: numericAmount, + fiatCurrency: currency, + email: senderEmail || recipientEmail, + paymentMethod, + }), + }) + + if (!res.ok) throw new Error('Failed to create payment session') + + const data = await res.json() + setSessionId(data.sessionId) + setTransakUrl(data.redirectUrl) + } catch (err) { + setError(err instanceof Error ? err.message : 'Payment failed') + setStep('review') + } + }, [method, numericAmount, currency, senderEmail, recipientEmail, prefill]) + + const handleTransakComplete = useCallback((orderId: string) => { + setTransakUrl(null) + setSessionId(orderId) + setStep('complete') + }, []) + + const handleBack = useCallback(() => { + const idx = allSteps.indexOf(step) + if (idx > 0) setStep(allSteps[idx - 1]) + }, [step, allSteps]) + + return ( +
+
+ + {/* Header */} +
+
+ {step !== 'amount' && step !== 'complete' && ( + + )} +

+ {step === 'complete' ? 'Payment sent' : 'Send money'} +

+
+ {isAuthenticated ? ( +
+ + {username} +
+ ) : ( + + )} +
+ + {step !== 'complete' && step !== 'processing' && ( + + )} + + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* ── Step 1: Amount ── */} + {step === 'amount' && ( +
+
+ + +
+ { + const v = e.target.value.replace(/[^0-9.]/g, '') + if (v.split('.').length <= 2) setAmount(v) + }} + placeholder="0.00" + className="flex-1 text-4xl font-light text-slate-900 outline-none bg-transparent placeholder:text-slate-300" + autoFocus + /> + +
+ + {numericAmount > 0 && ( +

+ {selectedCurrency?.symbol}{numericAmount.toFixed(2)} {currency} {'\u2192'} ~{numericAmount.toFixed(2)} USDC +

+ )} +
+ +
+
+ Recipient gets + + ~{numericAmount > 0 ? numericAmount.toFixed(2) : '0.00'} USDC + +
+

+ 1:1 stablecoin conversion. CRDT tokens are minted to the recipient's EncryptID wallet. +

+
+ +
+ +
+
+ )} + + {/* ── Step 2: Recipient ── */} + {step === 'recipient' && ( +
+
+ +

They'll receive CRDT tokens in their EncryptID wallet

+ +
+
+ + setRecipientEmail(e.target.value)} + placeholder="name@example.com" + className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition-all" + autoFocus + /> +
+ +
+ + setRecipientName(e.target.value)} + placeholder="Their name" + className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition-all" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="e.g. Dinner split, Project contribution" + className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition-all" + /> +
+ + {/* Your email (payer) */} +
+ + setSenderEmail(e.target.value)} + placeholder="your@email.com" + className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition-all" + /> +

We'll send you a confirmation receipt

+
+
+
+ +
+ +
+
+ )} + + {/* ── Step 3: Payment Method ── */} + {step === 'method' && ( +
+
+ +

+ Sending {formatCurrency(numericAmount, currency)} to {recipientName || recipientEmail} +

+
+ +
+ {PAYMENT_METHODS.map(m => ( + + ))} +
+
+ )} + + {/* ── Step 4: Review ── */} + {step === 'review' && method && ( +
+
+ + + {/* Transfer visualization */} +
+
+
+ {username?.charAt(0)?.toUpperCase() || '?'} +
+
+ {username || 'You'} +
+
+ +
+
+ {formatCurrency(numericAmount, currency)} +
+ + + +
+ +
+
+ {(recipientName || recipientEmail).charAt(0)?.toUpperCase() || '?'} +
+
+ {recipientName || recipientEmail} +
+
+
+ + {/* Details */} +
+
+ You send + {formatCurrency(numericAmount, currency)} +
+
+ Fee ({method === 'card' ? '~4%' : method === 'bank_transfer' ? '~1%' : '0%'}) + -{formatCurrency(fee, currency)} +
+
+ They receive + ~{totalReceived.toFixed(2)} CRDT +
+ + {description && ( +
+ Note + {description} +
+ )} + +
+ Payment method + {PAYMENT_METHODS.find(m2 => m2.id === method)?.label} +
+ +
+ Arrives + {PAYMENT_METHODS.find(m2 => m2.id === method)?.eta} +
+ +
+ Recipient email + {recipientEmail} +
+ + {senderEmail && ( +
+ Receipt to + {senderEmail} +
+ )} +
+
+ +
+

+ CRDT tokens are backed 1:1 by USDC stablecoins held in escrow on Base. The recipient will be notified by email. +

+
+ +
+ +
+
+ )} + + {/* ── Processing ── */} + {step === 'processing' && !transakUrl && ( +
+
+

Setting up your payment...

+

You'll be redirected to complete the payment

+
+ )} + + {/* ── Complete ── */} + {step === 'complete' && ( +
+
+
+ + + +
+

Payment sent!

+

+ {formatCurrency(numericAmount, currency)} is on its way to {recipientName || recipientEmail} +

+ +
+
+ Amount + {formatCurrency(numericAmount, currency)} +
+
+ CRDT minted + ~{totalReceived.toFixed(2)} CRDT +
+
+ To + {recipientEmail} +
+ {sessionId && ( +
+ Reference + {sessionId.slice(0, 12)}... +
+ )} +
+ +
+

+ The recipient will receive an email notification when the tokens arrive in their wallet. + {senderEmail && ` A receipt has been sent to ${senderEmail}.`} +

+
+
+ +
+ + + Done + +
+
+ )} + + {/* Footer */} +
+

+ Powered by rSpace Payment Infrastructure +

+
+ + {/* Transak Widget Modal */} + {transakUrl && ( + { setTransakUrl(null); setStep('review') }} + onComplete={handleTransakComplete} + /> + )} +
+
+ ) +} diff --git a/components/pay/RequestFlow.tsx b/components/pay/RequestFlow.tsx new file mode 100644 index 0000000..d0130d4 --- /dev/null +++ b/components/pay/RequestFlow.tsx @@ -0,0 +1,246 @@ +'use client' + +import { useState, useCallback } from 'react' +import { useAuthStore } from '@/lib/auth' + +type Step = 'details' | 'share' + +interface RequestFlowProps { + onCreated?: (link: string) => void +} + +const CURRENCIES = [ + { code: 'USD', symbol: '$' }, + { code: 'EUR', symbol: '\u20ac' }, + { code: 'GBP', symbol: '\u00a3' }, +] + +export default function RequestFlow({ onCreated }: RequestFlowProps) { + const { isAuthenticated, username, login } = useAuthStore() + + const [step, setStep] = useState('details') + const [amount, setAmount] = useState('') + const [currency, setCurrency] = useState('USD') + const [description, setDescription] = useState('') + const [recipientEmail, setRecipientEmail] = useState('') + const [paymentLink, setPaymentLink] = useState('') + const [copied, setCopied] = useState(false) + + const numericAmount = parseFloat(amount) || 0 + const selectedCurrency = CURRENCIES.find(c => c.code === currency) + + const handleCreate = useCallback(() => { + // Build the payment link with prefilled params + const params = new URLSearchParams() + if (numericAmount > 0) params.set('amount', numericAmount.toString()) + if (currency !== 'USD') params.set('currency', currency) + if (recipientEmail) params.set('to', recipientEmail) + if (username) params.set('name', username) + if (description) params.set('note', description) + + const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://rfunds.online' + const link = `${baseUrl}/pay?${params.toString()}` + setPaymentLink(link) + setStep('share') + onCreated?.(link) + }, [numericAmount, currency, recipientEmail, username, description, onCreated]) + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(paymentLink) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [paymentLink]) + + return ( +
+
+ + {/* Header */} +
+

+ {step === 'share' ? 'Share payment link' : 'Request money'} +

+ {isAuthenticated ? ( +
+ + {username} +
+ ) : ( + + )} +
+ + {/* ── Step 1: Details ── */} + {step === 'details' && ( +
+
+ + +
+ { + const v = e.target.value.replace(/[^0-9.]/g, '') + if (v.split('.').length <= 2) setAmount(v) + }} + placeholder="0.00" + className="flex-1 text-4xl font-light text-slate-900 outline-none bg-transparent placeholder:text-slate-300" + autoFocus + /> + +
+ +
+
+ + setRecipientEmail(e.target.value)} + placeholder="you@example.com" + className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition-all" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="e.g. Dinner split, Invoice #123" + className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition-all" + /> +
+
+
+ +
+ +
+
+ )} + + {/* ── Step 2: Share ── */} + {step === 'share' && ( +
+
+
+ + + +
+ +

Payment link ready

+

+ Share this link to receive {selectedCurrency?.symbol}{numericAmount.toFixed(2)} {currency} +

+ + {/* Link display */} +
+
+ + +
+
+ + {/* Summary */} +
+
+ Amount + {selectedCurrency?.symbol}{numericAmount.toFixed(2)} {currency} +
+ {description && ( +
+ Note + {description} +
+ )} + {recipientEmail && ( +
+ Notify + {recipientEmail} +
+ )} +
+ + {/* Share options */} +
+ + Email link + + +
+
+ +
+ +
+
+ )} + + {/* Footer */} +
+

+ Powered by rSpace Payment Infrastructure +

+
+
+
+ ) +}