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
449 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|