feat: Wise-style payment flow with send & request pages

- /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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-14 19:08:38 +00:00
parent 8772168c01
commit 6a59ac6007
5 changed files with 930 additions and 0 deletions

View File

@ -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 })
}
}

34
app/pay/page.tsx Normal file
View File

@ -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 <PaymentFlow prefill={prefill} />
}
export default function PayPageWrapper() {
return (
<Suspense fallback={
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
<div className="w-8 h-8 border-4 border-emerald-200 border-t-emerald-600 rounded-full animate-spin" />
</div>
}>
<PayPage />
</Suspense>
)
}

7
app/pay/request/page.tsx Normal file
View File

@ -0,0 +1,7 @@
'use client'
import RequestFlow from '@/components/pay/RequestFlow'
export default function RequestPage() {
return <RequestFlow />
}

View File

@ -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 (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0012 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75z" />
</svg>
)
if (type === 'card') return (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
return (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6.75A2.25 2.25 0 0019.5 4.5h-15A2.25 2.25 0 002.25 6.75V9" />
</svg>
)
}
function CheckIcon() {
return (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
}
function ArrowIcon() {
return (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
)
}
// ── 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 (
<div className="flex items-center gap-1 mb-8">
{visibleSteps.map((step, i) => (
<div key={step} className="flex items-center gap-1 flex-1">
<div className={`h-1 flex-1 rounded-full transition-colors duration-300 ${
i <= currentIdx ? 'bg-emerald-500' : 'bg-slate-200'
}`} />
</div>
))}
</div>
)
}
// ── Main Component ──
export default function PaymentFlow({ prefill }: PaymentFlowProps) {
const { isAuthenticated, username, did, login } = useAuthStore()
// Form state
const [step, setStep] = useState<Step>(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<PaymentMethod | null>(null)
const [transakUrl, setTransakUrl] = useState<string | null>(null)
const [sessionId, setSessionId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-slate-50 flex items-start justify-center pt-8 pb-16 px-4">
<div className="w-full max-w-[480px]">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
{step !== 'amount' && step !== 'complete' && (
<button onClick={handleBack} className="w-8 h-8 rounded-full hover:bg-slate-200 flex items-center justify-center transition-colors">
<svg className="w-5 h-5 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
)}
<h1 className="text-lg font-semibold text-slate-900">
{step === 'complete' ? 'Payment sent' : 'Send money'}
</h1>
</div>
{isAuthenticated ? (
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-full border border-slate-200 text-sm text-slate-600">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
{username}
</div>
) : (
<button onClick={login} className="px-3 py-1.5 bg-emerald-600 text-white rounded-full text-sm font-medium hover:bg-emerald-500 transition-colors">
Sign in
</button>
)}
</div>
{step !== 'complete' && step !== 'processing' && (
<StepProgress current={step} steps={allSteps} />
)}
{/* Error banner */}
{error && (
<div className="mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
{error}
</div>
)}
{/* ── Step 1: Amount ── */}
{step === 'amount' && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6">
<label className="text-sm font-medium text-slate-500 block mb-4">You send</label>
<div className="flex items-center gap-3 mb-1">
<input
type="text"
inputMode="decimal"
value={amount}
onChange={e => {
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
/>
<select
value={currency}
onChange={e => setCurrency(e.target.value)}
className="text-lg font-medium text-slate-700 bg-slate-100 px-3 py-2 rounded-xl border-0 outline-none cursor-pointer hover:bg-slate-200 transition-colors appearance-none"
>
{CURRENCIES.map(c => (
<option key={c.code} value={c.code}>{c.code}</option>
))}
</select>
</div>
{numericAmount > 0 && (
<p className="text-sm text-slate-400 mt-2">
{selectedCurrency?.symbol}{numericAmount.toFixed(2)} {currency} {'\u2192'} ~{numericAmount.toFixed(2)} USDC
</p>
)}
</div>
<div className="border-t border-slate-100 p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-slate-500">Recipient gets</span>
<span className="text-sm font-semibold text-emerald-600">
~{numericAmount > 0 ? numericAmount.toFixed(2) : '0.00'} USDC
</span>
</div>
<p className="text-xs text-slate-400">
1:1 stablecoin conversion. CRDT tokens are minted to the recipient's EncryptID wallet.
</p>
</div>
<div className="border-t border-slate-100 p-4">
<button
onClick={handleAmountNext}
disabled={numericAmount < 1}
className="w-full py-3.5 bg-emerald-600 text-white rounded-xl text-sm font-semibold hover:bg-emerald-500 disabled:bg-slate-200 disabled:text-slate-400 transition-colors"
>
Continue
</button>
</div>
</div>
)}
{/* ── Step 2: Recipient ── */}
{step === 'recipient' && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6">
<label className="text-sm font-medium text-slate-500 block mb-1">Who are you sending to?</label>
<p className="text-xs text-slate-400 mb-5">They'll receive CRDT tokens in their EncryptID wallet</p>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-slate-600 block mb-1.5">Recipient email</label>
<input
type="email"
value={recipientEmail}
onChange={e => 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
/>
</div>
<div>
<label className="text-xs font-medium text-slate-600 block mb-1.5">Name (optional)</label>
<input
type="text"
value={recipientName}
onChange={e => 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"
/>
</div>
<div>
<label className="text-xs font-medium text-slate-600 block mb-1.5">What's it for? (optional)</label>
<input
type="text"
value={description}
onChange={e => 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"
/>
</div>
{/* Your email (payer) */}
<div className="pt-2 border-t border-slate-100">
<label className="text-xs font-medium text-slate-600 block mb-1.5">Your email (for receipt)</label>
<input
type="email"
value={senderEmail}
onChange={e => 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"
/>
<p className="text-xs text-slate-400 mt-1">We'll send you a confirmation receipt</p>
</div>
</div>
</div>
<div className="border-t border-slate-100 p-4">
<button
onClick={handleRecipientNext}
disabled={!recipientEmail && !prefill?.recipientWallet}
className="w-full py-3.5 bg-emerald-600 text-white rounded-xl text-sm font-semibold hover:bg-emerald-500 disabled:bg-slate-200 disabled:text-slate-400 transition-colors"
>
Continue
</button>
</div>
</div>
)}
{/* ── Step 3: Payment Method ── */}
{step === 'method' && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 pb-2">
<label className="text-sm font-medium text-slate-500 block mb-1">How would you like to pay?</label>
<p className="text-xs text-slate-400 mb-5">
Sending {formatCurrency(numericAmount, currency)} to {recipientName || recipientEmail}
</p>
</div>
<div className="px-4 pb-4 space-y-2">
{PAYMENT_METHODS.map(m => (
<button
key={m.id}
onClick={() => handleMethodSelect(m.id)}
className="w-full flex items-center gap-4 p-4 rounded-xl border border-slate-200 hover:border-emerald-400 hover:bg-emerald-50/50 transition-all text-left group"
>
<div className="w-12 h-12 rounded-xl bg-slate-100 group-hover:bg-emerald-100 flex items-center justify-center text-slate-500 group-hover:text-emerald-600 transition-colors flex-shrink-0">
<MethodIcon type={m.icon} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-900">{m.label}</div>
<div className="text-xs text-slate-500">{m.description}</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-xs font-medium text-slate-700">{m.fee}</div>
<div className="text-xs text-slate-400">{m.eta}</div>
</div>
<ArrowIcon />
</button>
))}
</div>
</div>
)}
{/* ── Step 4: Review ── */}
{step === 'review' && method && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6">
<label className="text-sm font-medium text-slate-500 block mb-5">Review and confirm</label>
{/* Transfer visualization */}
<div className="flex items-center gap-4 mb-6">
<div className="flex-1 text-center">
<div className="w-12 h-12 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-2">
<span className="text-lg">{username?.charAt(0)?.toUpperCase() || '?'}</span>
</div>
<div className="text-xs font-medium text-slate-700 truncate">
{username || 'You'}
</div>
</div>
<div className="flex flex-col items-center gap-1">
<div className="text-lg font-semibold text-slate-900">
{formatCurrency(numericAmount, currency)}
</div>
<svg className="w-8 h-4 text-emerald-500" fill="none" viewBox="0 0 32 16">
<path d="M0 8h28m0 0l-6-6m6 6l-6 6" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="flex-1 text-center">
<div className="w-12 h-12 rounded-full bg-violet-100 flex items-center justify-center mx-auto mb-2">
<span className="text-lg">{(recipientName || recipientEmail).charAt(0)?.toUpperCase() || '?'}</span>
</div>
<div className="text-xs font-medium text-slate-700 truncate">
{recipientName || recipientEmail}
</div>
</div>
</div>
{/* Details */}
<div className="space-y-3 border-t border-slate-100 pt-4">
<div className="flex justify-between text-sm">
<span className="text-slate-500">You send</span>
<span className="font-medium text-slate-900">{formatCurrency(numericAmount, currency)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Fee ({method === 'card' ? '~4%' : method === 'bank_transfer' ? '~1%' : '0%'})</span>
<span className="text-slate-600">-{formatCurrency(fee, currency)}</span>
</div>
<div className="flex justify-between text-sm border-t border-dashed border-slate-200 pt-3">
<span className="text-slate-500">They receive</span>
<span className="font-semibold text-emerald-600">~{totalReceived.toFixed(2)} CRDT</span>
</div>
{description && (
<div className="flex justify-between text-sm pt-1">
<span className="text-slate-500">Note</span>
<span className="text-slate-700 text-right max-w-[60%] truncate">{description}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-slate-500">Payment method</span>
<span className="text-slate-700">{PAYMENT_METHODS.find(m2 => m2.id === method)?.label}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Arrives</span>
<span className="text-slate-700">{PAYMENT_METHODS.find(m2 => m2.id === method)?.eta}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">Recipient email</span>
<span className="text-slate-700">{recipientEmail}</span>
</div>
{senderEmail && (
<div className="flex justify-between text-sm">
<span className="text-slate-500">Receipt to</span>
<span className="text-slate-700">{senderEmail}</span>
</div>
)}
</div>
</div>
<div className="bg-amber-50 border-t border-amber-100 px-6 py-3">
<p className="text-xs text-amber-700">
CRDT tokens are backed 1:1 by USDC stablecoins held in escrow on Base. The recipient will be notified by email.
</p>
</div>
<div className="border-t border-slate-100 p-4">
<button
onClick={handleConfirm}
className="w-full py-3.5 bg-emerald-600 text-white rounded-xl text-sm font-semibold hover:bg-emerald-500 transition-colors"
>
Confirm and send {formatCurrency(numericAmount, currency)}
</button>
</div>
</div>
)}
{/* ── Processing ── */}
{step === 'processing' && !transakUrl && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
<div className="w-12 h-12 border-4 border-emerald-200 border-t-emerald-600 rounded-full animate-spin mx-auto mb-4" />
<p className="text-sm font-medium text-slate-700">Setting up your payment...</p>
<p className="text-xs text-slate-400 mt-1">You'll be redirected to complete the payment</p>
</div>
)}
{/* ── Complete ── */}
{step === 'complete' && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-8 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<h2 className="text-xl font-semibold text-slate-900 mb-1">Payment sent!</h2>
<p className="text-sm text-slate-500 mb-6">
{formatCurrency(numericAmount, currency)} is on its way to {recipientName || recipientEmail}
</p>
<div className="bg-slate-50 rounded-xl p-4 text-left space-y-2 mb-6">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Amount</span>
<span className="font-medium text-slate-900">{formatCurrency(numericAmount, currency)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">CRDT minted</span>
<span className="font-medium text-emerald-600">~{totalReceived.toFixed(2)} CRDT</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-500">To</span>
<span className="text-slate-700">{recipientEmail}</span>
</div>
{sessionId && (
<div className="flex justify-between text-sm">
<span className="text-slate-500">Reference</span>
<span className="text-slate-500 font-mono text-xs">{sessionId.slice(0, 12)}...</span>
</div>
)}
</div>
<div className="space-y-2">
<p className="text-xs text-slate-400">
The recipient will receive an email notification when the tokens arrive in their wallet.
{senderEmail && ` A receipt has been sent to ${senderEmail}.`}
</p>
</div>
</div>
<div className="border-t border-slate-100 p-4 flex gap-2">
<button
onClick={() => { setStep('amount'); setAmount(''); setRecipientEmail(''); setRecipientName(''); setDescription(''); setMethod(null); setSessionId(null) }}
className="flex-1 py-3 bg-slate-100 text-slate-700 rounded-xl text-sm font-medium hover:bg-slate-200 transition-colors"
>
Send another
</button>
<a
href="/"
className="flex-1 py-3 bg-emerald-600 text-white rounded-xl text-sm font-medium hover:bg-emerald-500 transition-colors text-center"
>
Done
</a>
</div>
</div>
)}
{/* Footer */}
<div className="mt-6 text-center">
<p className="text-xs text-slate-400">
Powered by <span className="font-medium">rSpace</span> Payment Infrastructure
</p>
</div>
{/* Transak Widget Modal */}
{transakUrl && (
<TransakWidget
widgetUrl={transakUrl}
onClose={() => { setTransakUrl(null); setStep('review') }}
onComplete={handleTransakComplete}
/>
)}
</div>
</div>
)
}

View File

@ -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<Step>('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 (
<div className="min-h-screen bg-slate-50 flex items-start justify-center pt-8 pb-16 px-4">
<div className="w-full max-w-[480px]">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-lg font-semibold text-slate-900">
{step === 'share' ? 'Share payment link' : 'Request money'}
</h1>
{isAuthenticated ? (
<div className="flex items-center gap-2 px-3 py-1.5 bg-white rounded-full border border-slate-200 text-sm text-slate-600">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
{username}
</div>
) : (
<button onClick={login} className="px-3 py-1.5 bg-emerald-600 text-white rounded-full text-sm font-medium hover:bg-emerald-500 transition-colors">
Sign in
</button>
)}
</div>
{/* ── Step 1: Details ── */}
{step === 'details' && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6">
<label className="text-sm font-medium text-slate-500 block mb-4">How much do you want to receive?</label>
<div className="flex items-center gap-3 mb-6">
<input
type="text"
inputMode="decimal"
value={amount}
onChange={e => {
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
/>
<select
value={currency}
onChange={e => setCurrency(e.target.value)}
className="text-lg font-medium text-slate-700 bg-slate-100 px-3 py-2 rounded-xl border-0 outline-none cursor-pointer hover:bg-slate-200 transition-colors appearance-none"
>
{CURRENCIES.map(c => (
<option key={c.code} value={c.code}>{c.code}</option>
))}
</select>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-slate-600 block mb-1.5">Your email (to receive notification)</label>
<input
type="email"
value={recipientEmail}
onChange={e => 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"
/>
</div>
<div>
<label className="text-xs font-medium text-slate-600 block mb-1.5">What's it for? (shown to payer)</label>
<input
type="text"
value={description}
onChange={e => 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"
/>
</div>
</div>
</div>
<div className="border-t border-slate-100 p-4">
<button
onClick={handleCreate}
disabled={numericAmount < 1}
className="w-full py-3.5 bg-emerald-600 text-white rounded-xl text-sm font-semibold hover:bg-emerald-500 disabled:bg-slate-200 disabled:text-slate-400 transition-colors"
>
Create payment link
</button>
</div>
</div>
)}
{/* ── Step 2: Share ── */}
{step === 'share' && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 text-center">
<div className="w-16 h-16 rounded-full bg-violet-100 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-4.318a4.5 4.5 0 00-7.093-1.657l-4.5 4.5a4.5 4.5 0 006.364 6.364l1.757-1.757" />
</svg>
</div>
<h2 className="text-lg font-semibold text-slate-900 mb-1">Payment link ready</h2>
<p className="text-sm text-slate-500 mb-6">
Share this link to receive {selectedCurrency?.symbol}{numericAmount.toFixed(2)} {currency}
</p>
{/* Link display */}
<div className="bg-slate-50 rounded-xl p-4 mb-4">
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={paymentLink}
className="flex-1 text-xs text-slate-600 bg-transparent outline-none font-mono truncate"
/>
<button
onClick={handleCopy}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors flex-shrink-0 ${
copied
? 'bg-emerald-100 text-emerald-700'
: 'bg-emerald-600 text-white hover:bg-emerald-500'
}`}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
{/* Summary */}
<div className="bg-slate-50 rounded-xl p-4 text-left space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-slate-500">Amount</span>
<span className="font-medium text-slate-900">{selectedCurrency?.symbol}{numericAmount.toFixed(2)} {currency}</span>
</div>
{description && (
<div className="flex justify-between text-sm">
<span className="text-slate-500">Note</span>
<span className="text-slate-700">{description}</span>
</div>
)}
{recipientEmail && (
<div className="flex justify-between text-sm">
<span className="text-slate-500">Notify</span>
<span className="text-slate-700">{recipientEmail}</span>
</div>
)}
</div>
{/* Share options */}
<div className="flex gap-2">
<a
href={`mailto:?subject=${encodeURIComponent(`Payment request: ${selectedCurrency?.symbol}${numericAmount.toFixed(2)}`)}&body=${encodeURIComponent(`Hi,\n\nI'm requesting a payment of ${selectedCurrency?.symbol}${numericAmount.toFixed(2)} ${currency}${description ? ` for: ${description}` : ''}.\n\nYou can pay here: ${paymentLink}\n\nThanks!`)}`}
className="flex-1 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-xs font-medium hover:bg-slate-200 transition-colors text-center"
>
Email link
</a>
<button
onClick={() => {
if (navigator.share) {
navigator.share({
title: `Payment request: ${selectedCurrency?.symbol}${numericAmount.toFixed(2)}`,
text: description || `Payment of ${selectedCurrency?.symbol}${numericAmount.toFixed(2)} ${currency}`,
url: paymentLink,
}).catch(() => {})
} else {
handleCopy()
}
}}
className="flex-1 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-xs font-medium hover:bg-slate-200 transition-colors"
>
Share
</button>
</div>
</div>
<div className="border-t border-slate-100 p-4">
<button
onClick={() => { setStep('details'); setAmount(''); setDescription(''); setPaymentLink('') }}
className="w-full py-3 bg-white border border-slate-200 text-slate-700 rounded-xl text-sm font-medium hover:bg-slate-50 transition-colors"
>
Create another request
</button>
</div>
</div>
)}
{/* Footer */}
<div className="mt-6 text-center">
<p className="text-xs text-slate-400">
Powered by <span className="font-medium">rSpace</span> Payment Infrastructure
</p>
</div>
</div>
</div>
)
}