Files
calvana/pledge-now-pay-later/src/app/p/[token]/page.tsx
Omair Saleh b6384da417 Embed mini widget + prominent back button
Embed mode (?embed=1 or iframe detection):
- Shows sleek mini card (Make a Pledge) instead of full step 1
- 160px at rest, expands to 700px when user starts the flow
- postMessage resize signal for parent iframe auto-height
- Powered-by footer

Back button:
- Moved from hidden bottom bar to fixed top navigation bar
- ChevronLeft + "Back" text, always visible during backable steps
- Org name centered in header, step label on right
- Progress bar integrated into top bar

Embed code updated:
- iframe starts at height=160 (mini widget height)
- Includes resize listener script for auto-expansion
2026-03-05 18:06:08 +08:00

449 lines
17 KiB
TypeScript

"use client"
import { useState, useEffect } from "react"
import { useParams, useSearchParams } from "next/navigation"
import { AmountStep } from "./steps/amount-step"
import { ScheduleStep } from "./steps/schedule-step"
import { PaymentStep } from "./steps/payment-step"
import { IdentityStep } from "./steps/identity-step"
import { ConfirmationStep } from "./steps/confirmation-step"
import { BankInstructionsStep } from "./steps/bank-instructions-step"
import { ExternalRedirectStep } from "./steps/external-redirect-step"
import { CardPaymentStep } from "./steps/card-payment-step"
import { DirectDebitStep } from "./steps/direct-debit-step"
import { Heart, ChevronLeft, ArrowRight } from "lucide-react"
export type Rail = "bank" | "gocardless" | "card"
export interface PledgeData {
amountPence: number
rail: Rail
donorName: string
donorEmail: string
donorPhone: string
donorAddressLine1: string
donorPostcode: string
giftAid: boolean
isZakat: boolean
emailOptIn: boolean
whatsappOptIn: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
consentMeta?: any
// Scheduling
scheduleMode: "now" | "date" | "installments"
dueDate?: string
installmentCount?: number
installmentDates?: string[]
// Conditional / match funding
isConditional: boolean
conditionType?: "threshold" | "match" | "custom"
conditionText?: string
conditionThreshold?: number
}
interface EventInfo {
id: string
name: string
organizationName: string
qrSourceId: string | null
qrSourceLabel: string | null
paymentMode: "self" | "external"
externalUrl: string | null
externalPlatform: string | null
zakatEligible: boolean
hasStripe: boolean
goalAmount: number | null
}
/*
Flow:
-1 = Mini widget (embed only — sleek card that starts the flow)
0 = Amount
1 = Schedule (When to pay?)
2 = Payment method (if "now") or Identity (if deferred)
3 = Identity (for bank transfer "now")
4 = Bank instructions (now)
5 = Confirmation (generic — card, DD, or deferred pledge)
6 = Card payment
7 = External redirect
8 = Direct Debit
*/
export default function PledgePage() {
const params = useParams()
const searchParams = useSearchParams()
const token = params.token as string
// Detect embed: ?embed=1 query param OR inside an iframe
const [isEmbed, setIsEmbed] = useState(false)
useEffect(() => {
const embedParam = searchParams.get("embed") === "1"
const inIframe = typeof window !== "undefined" && window.self !== window.top
setIsEmbed(embedParam || inIframe)
}, [searchParams])
const [step, setStep] = useState<number>(0)
const [eventInfo, setEventInfo] = useState<EventInfo | null>(null)
const [pledgeData, setPledgeData] = useState<PledgeData>({
amountPence: 0,
rail: "bank",
donorName: "",
donorEmail: "",
donorPhone: "",
donorAddressLine1: "",
donorPostcode: "",
giftAid: false,
isZakat: false,
emailOptIn: false,
whatsappOptIn: false,
scheduleMode: "now",
isConditional: false,
})
const [pledgeResult, setPledgeResult] = useState<{
id: string
reference: string
bankDetails?: { bankName: string; sortCode: string; accountNo: string; accountName: string }
} | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
useEffect(() => {
fetch(`/api/qr/${token}`)
.then((r) => r.json())
.then((data) => {
if (data.error) setError(data.error)
else setEventInfo(data)
setLoading(false)
})
.catch(() => { setError("Unable to load pledge page"); setLoading(false) })
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventType: "pledge_start", metadata: { token } }),
}).catch(() => {})
}, [token])
// Start at mini widget step for embeds once loaded
useEffect(() => {
if (!loading && isEmbed && step === 0) {
setStep(-1)
}
}, [loading, isEmbed, step])
// Notify parent frame of height changes when embedded
useEffect(() => {
if (!isEmbed) return
const sendHeight = () => {
const height = step === -1 ? 160 : 700
window.parent.postMessage({ type: "pnpl-resize", height }, "*")
}
sendHeight()
}, [isEmbed, step])
const isExternal = eventInfo?.paymentMode === "external" && eventInfo?.externalUrl
// Step 0: Amount selected
const handleAmountSelected = (amountPence: number, conditional?: { isConditional: boolean; conditionType?: "threshold" | "match" | "custom"; conditionText?: string; conditionThreshold?: number }) => {
const conditionalData = conditional || { isConditional: false }
setPledgeData((d) => ({ ...d, amountPence, ...conditionalData }))
if (isExternal) {
setPledgeData((d) => ({ ...d, amountPence, rail: "bank", scheduleMode: "now", ...conditionalData }))
setStep(3)
} else {
setStep(1)
}
}
// Step 1: Schedule selected (self-payment events only)
const handleScheduleSelected = (schedule: {
mode: "now" | "date" | "installments"
dueDate?: string
installmentCount?: number
installmentDates?: string[]
}) => {
setPledgeData((d) => ({
...d,
scheduleMode: schedule.mode,
dueDate: schedule.dueDate,
installmentCount: schedule.installmentCount,
installmentDates: schedule.installmentDates,
}))
if (schedule.mode === "now") {
setStep(2) // → Payment method selection
} else {
// Deferred or installments: skip payment method, go to identity
setPledgeData((d) => ({ ...d, rail: "bank" }))
setStep(3) // → Identity
}
}
// Step 2: Payment method selected (only for "now" self-payment mode)
const handleRailSelected = (rail: Rail) => {
setPledgeData((d) => ({ ...d, rail }))
setStep(rail === "bank" ? 3 : rail === "card" ? 6 : 8)
}
// Submit pledge (from identity step, or card/DD steps)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const submitPledge = async (identity: any) => {
const finalData = { ...pledgeData, ...identity }
setPledgeData(finalData)
// Inject IP + user agent into consent metadata for audit trail
const consentMeta = finalData.consentMeta ? {
...finalData.consentMeta,
userAgent: navigator.userAgent,
} : undefined
try {
const res = await fetch("/api/pledges", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...finalData,
consentMeta,
eventId: eventInfo?.id,
qrSourceId: eventInfo?.qrSourceId,
isZakat: finalData.isZakat || false,
}),
})
const result = await res.json()
if (result.error) { setError(result.error); return }
setPledgeResult(result)
// Where to go after pledge is created:
if (isExternal) {
setStep(7) // External redirect
} else if (finalData.scheduleMode === "now" && finalData.rail === "bank") {
setStep(4) // Bank instructions
} else {
setStep(5) // Confirmation
}
} catch {
setError("Something went wrong. Please try again.")
}
}
if (loading) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-paper gap-4">
<div className="w-12 h-12 rounded-lg bg-midnight flex items-center justify-center animate-pulse">
<span className="text-white text-xl">🤲</span>
</div>
<p className="text-trust-blue font-medium animate-pulse">Loading...</p>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-paper p-4">
<div className="text-center space-y-4 animate-fade-up">
<div className="text-5xl">😔</div>
<h1 className="text-xl font-bold text-gray-900">Something went wrong</h1>
<p className="text-muted-foreground">{error}</p>
<button onClick={() => window.location.reload()} className="text-trust-blue font-medium hover:underline">
Try again
</button>
</div>
</div>
)
}
const shareUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/p/${token}`
const isDeferred = pledgeData.scheduleMode !== "now"
// Format due date for display
const dueDateLabel = pledgeData.dueDate
? new Date(pledgeData.dueDate).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" })
: pledgeData.installmentCount
? `${pledgeData.installmentCount} monthly payments`
: undefined
const steps: Record<number, React.ReactNode> = {
[-1]: <MiniWidget eventInfo={eventInfo} onStart={() => setStep(0)} />,
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} goalAmount={eventInfo?.goalAmount} />,
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} hasStripe={eventInfo?.hasStripe ?? false} />,
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} orgName={eventInfo?.organizationName} />,
4: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
5: pledgeResult && (
<ConfirmationStep
pledge={pledgeResult}
amount={isDeferred && pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) * pledgeData.installmentCount : pledgeData.amountPence}
rail={pledgeData.rail}
eventName={eventInfo?.name || ""}
shareUrl={shareUrl}
donorPhone={pledgeData.donorPhone}
isDeferred={isDeferred}
dueDateLabel={dueDateLabel}
installmentCount={pledgeData.installmentCount}
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
isConditional={pledgeData.isConditional}
conditionText={pledgeData.conditionText}
/>
),
6: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
7: pledgeResult && <ExternalRedirectStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} externalUrl={eventInfo?.externalUrl || ""} externalPlatform={eventInfo?.externalPlatform} donorPhone={pledgeData.donorPhone} />,
8: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
}
const backableSteps = new Set([1, 2, 3, 6, 8])
const getBackStep = (s: number): number => {
if (s === 0 && isEmbed) return -1 // amount → mini widget (embed only)
if (s === 6 || s === 8) return 2 // card/DD → payment method
if (s === 3 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule
if (s === 3) return 2 // bank identity → payment method
return s - 1
}
// Can go back from step 0 only in embed mode
const canGoBack = backableSteps.has(step) || (step === 0 && isEmbed)
const isFinished = step === 4 || step === 5 || step === 7
const stepLabels: Record<number, string> = {
[-1]: "", 0: "Amount", 1: "Schedule", 2: "Payment method",
3: "Your details", 4: "Complete", 5: "Complete", 6: "Card payment",
7: "Redirecting", 8: "Direct Debit",
}
const progressMap: Record<number, number> = { [-1]: 0, 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 7: 100, 8: 60 }
const progressPercent = progressMap[step] ?? 10
// Mini widget mode — compact layout, no chrome
if (step === -1) {
return (
<div className={isEmbed ? "bg-paper" : "min-h-screen bg-paper"}>
<div className="px-4 py-4">{steps[-1]}</div>
</div>
)
}
return (
<div className="min-h-screen bg-paper">
{/* ── Top bar: progress + back + step label ── */}
<div className="fixed top-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100">
{/* Progress bar */}
<div className="h-1 bg-gray-100">
<div
className="h-full bg-promise-blue transition-all duration-700 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
{/* Navigation row */}
<div className="flex items-center h-12 px-3">
{canGoBack ? (
<button
onClick={() => setStep(getBackStep(step))}
className="flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors -ml-1 px-2 py-1.5 rounded-lg hover:bg-gray-100 active:bg-gray-200"
>
<ChevronLeft className="h-4 w-4" />
<span>Back</span>
</button>
) : (
<div className="w-16" /> /* Spacer */
)}
<div className="flex-1 text-center">
<p className="text-xs font-medium text-muted-foreground">
{eventInfo?.organizationName}
</p>
</div>
{!isFinished && step >= 0 ? (
<span className="text-[10px] font-medium text-muted-foreground/60 w-16 text-right">
{stepLabels[step]}
</span>
) : (
<div className="w-16" />
)}
</div>
</div>
{/* QR source label */}
{eventInfo?.qrSourceLabel && (
<div className="pt-14 pb-0 px-4 text-center">
<p className="text-[10px] text-muted-foreground/60">{eventInfo.qrSourceLabel}</p>
</div>
)}
{/* Content — padded for fixed header */}
<div className={`px-4 pb-8 ${eventInfo?.qrSourceLabel ? 'pt-2' : 'pt-16'}`}>
{steps[step]}
</div>
</div>
)
}
/* ── Mini Widget ─────────────────────────────────────────────────
Compact card shown in embedded mode. Designed to be:
- 80-120px tall at rest
- Branded but not loud
- Single CTA button that launches the full flow
────────────────────────────────────────────────────────────── */
function MiniWidget({
eventInfo,
onStart,
}: {
eventInfo: EventInfo | null
onStart: () => void
}) {
const [hover, setHover] = useState(false)
return (
<div className="max-w-md mx-auto animate-fade-up">
<button
onClick={onStart}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
className="w-full text-left rounded-2xl border-2 border-gray-200 bg-white p-5 transition-all duration-300 hover:border-trust-blue hover:shadow-xl group cursor-pointer"
>
<div className="flex items-center gap-4">
{/* Icon */}
<div className={`shrink-0 w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300 ${
hover ? "bg-trust-blue scale-110" : "bg-midnight"
}`}>
<Heart className={`h-6 w-6 text-white transition-transform duration-300 ${hover ? "scale-110" : ""}`} />
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<h2 className="text-lg font-black text-gray-900 tracking-tight leading-tight">
Make a Pledge
</h2>
<p className="text-sm text-muted-foreground mt-0.5 truncate">
{eventInfo?.name || "Support this cause"}
</p>
</div>
{/* Arrow */}
<div className={`shrink-0 w-10 h-10 rounded-full flex items-center justify-center transition-all duration-300 ${
hover ? "bg-trust-blue text-white translate-x-0.5" : "bg-gray-100 text-gray-400"
}`}>
<ArrowRight className="h-5 w-5" />
</div>
</div>
{/* Sub-line */}
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-muted-foreground">
💳 Card, bank transfer, or Direct Debit
</span>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-success-green font-medium">
🎁 +25% Gift Aid
</span>
</div>
</button>
{/* Powered by */}
<p className="text-center text-[10px] text-muted-foreground/40 mt-2">
Powered by <span className="font-medium">Pledge Now, Pay Later</span>
</p>
</div>
)
}