feat: remove FPX, add UK charity persona features

- Remove FPX payment rail entirely (Malaysian, not UK)
- Add volunteer portal (/v/[code]) with live pledge tracking
- Add public event page (/e/[slug]) with progress bar + social proof
- Add fundraiser leaderboard (/dashboard/events/[id]/leaderboard)
- Add WhatsApp share buttons on confirmation, bank instructions, volunteer view
- Enhanced Gift Aid UX with +25% bonus display and HMRC declaration text
- Gift Aid report export (HMRC-ready CSV filter)
- Volunteer view link + WhatsApp share on QR code cards
- Updated home page: 4 personas, 3 UK payment rails, 8 features
- Public event API endpoint with privacy-safe donor name truncation
- Volunteer API with stats, conversion rate, auto-refresh
This commit is contained in:
2026-03-03 03:47:18 +08:00
parent 1389c848b2
commit 0236867c88
32 changed files with 2293 additions and 494 deletions

View File

View File

@@ -51,6 +51,9 @@ export async function GET(
scanCount: s.scanCount,
pledgeCount: s._count.pledges,
totalPledged: s.pledges.reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0),
totalCollected: s.pledges
.filter((p: QrPledge) => p.status === "paid")
.reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0),
createdAt: s.createdAt,
}))
)

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server"
import prisma from "@/lib/prisma"
export async function GET(
_req: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params
if (!prisma) {
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
}
// Find event by slug (try both org-scoped and plain slug)
const event = await prisma.event.findFirst({
where: {
OR: [
{ slug },
{ slug: { contains: slug } },
],
status: { in: ["active", "closed"] },
},
include: {
organization: { select: { name: true } },
qrSources: {
select: { code: true, label: true, volunteerName: true },
orderBy: { createdAt: "asc" },
},
pledges: {
select: {
donorName: true,
amountPence: true,
status: true,
giftAid: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
})
if (!event) {
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
const pledges = event.pledges
const totalPledged = pledges.reduce((s, p) => s + p.amountPence, 0)
const totalPaid = pledges
.filter((p) => p.status === "paid")
.reduce((s, p) => s + p.amountPence, 0)
const giftAidCount = pledges.filter((p) => p.giftAid).length
return NextResponse.json({
id: event.id,
name: event.name,
description: event.description,
eventDate: event.eventDate,
location: event.location,
goalAmount: event.goalAmount,
organizationName: event.organization.name,
stats: {
pledgeCount: pledges.length,
totalPledged,
totalPaid,
giftAidCount,
avgPledge: pledges.length > 0 ? Math.round(totalPledged / pledges.length) : 0,
},
recentPledges: pledges.slice(0, 10).map((p) => ({
donorName: p.donorName ? p.donorName.split(" ")[0] + " " + (p.donorName.split(" ")[1]?.[0] || "") + "." : null,
amountPence: p.amountPence,
createdAt: p.createdAt,
giftAid: p.giftAid,
})),
qrCodes: event.qrSources,
})
} catch (error) {
console.error("Public event error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}

View File

@@ -30,11 +30,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Organization not found" }, { status: 404 })
}
const eventId = request.nextUrl.searchParams.get("eventId")
const giftAidOnly = request.nextUrl.searchParams.get("giftAidOnly") === "true"
const pledges = await prisma.pledge.findMany({
where: {
organizationId: orgId,
...(eventId ? { eventId } : {}),
...(giftAidOnly ? { giftAid: true } : {}),
},
include: {
event: { select: { name: true } },
@@ -65,10 +67,14 @@ export async function GET(request: NextRequest) {
const csv = formatCrmExportCsv(rows)
const fileName = giftAidOnly
? `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv`
: `crm-export-${new Date().toISOString().slice(0, 10)}.csv`
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="crm-export-${new Date().toISOString().slice(0, 10)}.csv"`,
"Content-Disposition": `attachment; filename="${fileName}"`,
},
})
} catch (error) {

View File

@@ -0,0 +1,63 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
export async function GET(
_req: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params
const db = prisma
const qrSource = await db.qrSource.findUnique({
where: { code: token },
include: {
event: {
include: { organization: { select: { name: true } } },
},
pledges: {
orderBy: { createdAt: "desc" },
select: {
id: true,
reference: true,
amountPence: true,
status: true,
donorName: true,
giftAid: true,
createdAt: true,
},
},
},
})
if (!qrSource) {
return NextResponse.json({ error: "QR code not found" }, { status: 404 })
}
const pledges = qrSource.pledges
const totalPledgedPence = pledges.reduce((s, p) => s + p.amountPence, 0)
const totalPaidPence = pledges
.filter((p) => p.status === "paid")
.reduce((s, p) => s + p.amountPence, 0)
return NextResponse.json({
qrSource: {
label: qrSource.label,
volunteerName: qrSource.volunteerName,
code: qrSource.code,
scanCount: qrSource.scanCount,
},
event: {
name: qrSource.event.name,
organizationName: qrSource.event.organization.name,
},
pledges,
stats: {
totalPledges: pledges.length,
totalPledgedPence,
totalPaidPence,
conversionRate: qrSource.scanCount > 0
? Math.round((pledges.length / qrSource.scanCount) * 100)
: 0,
},
})
}

View File

@@ -0,0 +1,114 @@
"use client"
import { useState, useEffect } from "react"
import { useParams } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Trophy, ArrowLeft, Loader2 } from "lucide-react"
import Link from "next/link"
interface LeaderEntry {
label: string
volunteerName: string | null
pledgeCount: number
totalPledged: number
totalPaid: number
scanCount: number
conversionRate: number
}
export default function LeaderboardPage() {
const params = useParams()
const eventId = params.id as string
const [entries, setEntries] = useState<LeaderEntry[]>([])
const [loading, setLoading] = useState(true)
const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
useEffect(() => {
const load = () => {
fetch(`/api/events/${eventId}/qr`)
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) {
const sorted = [...data].sort((a, b) => b.totalPledged - a.totalPledged)
setEntries(sorted.map((d) => ({
label: d.label,
volunteerName: d.volunteerName,
pledgeCount: d.pledgeCount,
totalPledged: d.totalPledged,
totalPaid: d.totalCollected || 0,
scanCount: d.scanCount,
conversionRate: d.scanCount > 0 ? Math.round((d.pledgeCount / d.scanCount) * 100) : 0,
})))
}
})
.catch(() => {})
.finally(() => setLoading(false))
}
load()
const interval = setInterval(load, 10000)
return () => clearInterval(interval)
}, [eventId])
const medals = ["🥇", "🥈", "🥉"]
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<div>
<Link href={`/dashboard/events/${eventId}`} className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
<ArrowLeft className="h-3 w-3" /> Back to Event
</Link>
<h1 className="text-3xl font-extrabold text-gray-900 flex items-center gap-3">
<Trophy className="h-8 w-8 text-warm-amber" /> Fundraiser Leaderboard
</h1>
<p className="text-muted-foreground mt-1">Auto-refreshes every 10 seconds perfect for live events</p>
</div>
<div className="space-y-3">
{entries.map((entry, i) => (
<Card key={i} className={`${i < 3 ? "border-warm-amber/30 bg-warm-amber/5" : ""}`}>
<CardContent className="py-4 px-5">
<div className="flex items-center gap-4">
<div className="text-3xl w-12 text-center">
{i < 3 ? medals[i] : <span className="text-lg font-bold text-muted-foreground">#{i + 1}</span>}
</div>
<div className="flex-1">
<p className="font-bold text-lg">{entry.volunteerName || entry.label}</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>{entry.pledgeCount} pledges</span>
<span>{entry.scanCount} scans</span>
<Badge variant={entry.conversionRate >= 50 ? "success" : "secondary"} className="text-xs">
{entry.conversionRate}% conversion
</Badge>
</div>
</div>
<div className="text-right">
<p className="text-2xl font-extrabold text-trust-blue">{formatPence(entry.totalPledged)}</p>
<p className="text-xs text-muted-foreground">{formatPence(entry.totalPaid)} collected</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{entries.length === 0 && (
<Card>
<CardContent className="py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">No QR codes created yet. Create QR codes to see the leaderboard.</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { formatPence } from "@/lib/utils"
import { Plus, Download, QrCode, ExternalLink, Copy, Check, Loader2, ArrowLeft } from "lucide-react"
import { Plus, Download, QrCode, ExternalLink, Copy, Check, Loader2, ArrowLeft, Trophy, Users, MessageCircle } from "lucide-react"
import Link from "next/link"
import { QRCodeCanvas } from "@/components/qr-code"
@@ -104,9 +104,16 @@ export default function EventQRPage() {
{qrSources.length} QR code{qrSources.length !== 1 ? "s" : ""} · {totalScans} scans · {totalPledges} pledges · {formatPence(totalAmount)}
</p>
</div>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" /> New QR Code
</Button>
<div className="flex gap-2">
<Link href={`/dashboard/events/${eventId}/leaderboard`}>
<Button variant="outline">
<Trophy className="h-4 w-4 mr-2" /> Leaderboard
</Button>
</Link>
<Button onClick={() => setShowCreate(true)}>
<Plus className="h-4 w-4 mr-2" /> New QR Code
</Button>
</div>
</div>
{/* QR Grid */}
@@ -187,6 +194,26 @@ export default function EventQRPage() {
</Button>
</a>
</div>
{/* Volunteer & share links */}
<div className="flex gap-2">
<a href={`/v/${qr.code}`} target="_blank" className="flex-1">
<Button variant="outline" size="sm" className="w-full text-xs">
<Users className="h-3 w-3 mr-1" /> Volunteer View
</Button>
</a>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs bg-[#25D366]/5 border-[#25D366]/30 text-[#25D366] hover:bg-[#25D366]/10"
onClick={() => {
const url = `${baseUrl}/p/${qr.code}`
const text = `Hi! Scan this to pledge: ${url}`
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
}}
>
<MessageCircle className="h-3 w-3 mr-1" /> Share
</Button>
</div>
</CardContent>
</Card>
))}

View File

@@ -28,6 +28,7 @@ interface EventSummary {
export default function EventsPage() {
const [events, setEvents] = useState<EventSummary[]>([])
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)

View File

@@ -2,7 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Download, FileSpreadsheet, Webhook } from "lucide-react"
import { Download, FileSpreadsheet, Webhook, Gift } from "lucide-react"
export default function ExportsPage() {
const handleCrmExport = () => {
@@ -12,27 +12,34 @@ export default function ExportsPage() {
a.click()
}
const handleGiftAidExport = () => {
const a = document.createElement("a")
a.href = "/api/exports/crm-pack?giftAidOnly=true"
a.download = `gift-aid-declarations-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-extrabold text-gray-900">Exports</h1>
<p className="text-muted-foreground mt-1">Export data for your CRM and automation tools</p>
<p className="text-muted-foreground mt-1">Export data for your CRM, HMRC, and automation tools</p>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" /> CRM Export Pack
</CardTitle>
<CardDescription>
Download all pledges as CSV with full attribution data, ready to import into your CRM.
All pledges as CSV with full attribution data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-1">
<p>Includes:</p>
<ul className="list-disc list-inside space-y-0.5">
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li>Donor name, email, phone</li>
<li>Pledge amount and status</li>
<li>Payment method and reference</li>
@@ -42,7 +49,37 @@ export default function ExportsPage() {
</ul>
</div>
<Button onClick={handleCrmExport} className="w-full">
<Download className="h-4 w-4 mr-2" /> Download CRM Pack (CSV)
<Download className="h-4 w-4 mr-2" /> Download CRM Pack
</Button>
</CardContent>
</Card>
<Card className="border-success-green/30">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Gift className="h-5 w-5 text-success-green" /> Gift Aid Report
</CardTitle>
<CardDescription>
HMRC-ready Gift Aid declarations for tax reclaim.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-1">
<p>Includes only Gift Aid-eligible pledges:</p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li>Donor full name (required by HMRC)</li>
<li>Donation amount and date</li>
<li>Gift Aid declaration status</li>
<li>Event and reference for audit trail</li>
</ul>
</div>
<div className="rounded-xl bg-success-green/5 border border-success-green/20 p-3">
<p className="text-xs text-success-green font-medium">
💷 Claim 25p for every £1 donated by a UK taxpayer
</p>
</div>
<Button onClick={handleGiftAidExport} className="w-full bg-success-green hover:bg-success-green/90">
<Download className="h-4 w-4 mr-2" /> Download Gift Aid Report
</Button>
</CardContent>
</Card>
@@ -50,23 +87,23 @@ export default function ExportsPage() {
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Webhook className="h-5 w-5" /> Webhook Events
<Webhook className="h-5 w-5" /> Webhook / API
</CardTitle>
<CardDescription>
Poll pending reminder events for external automation (Zapier, Make, n8n).
Connect to Zapier, Make, or n8n for automation.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground space-y-2">
<p>Endpoint:</p>
<code className="block bg-gray-100 rounded-lg p-3 text-xs font-mono">
GET /api/webhooks?since=2025-01-01T00:00:00Z
<p>Reminder endpoint:</p>
<code className="block bg-gray-100 rounded-lg p-3 text-xs font-mono break-all">
GET /api/webhooks?since=2025-01-01
</code>
<p>Returns pending reminders with donor contact info and pledge details.</p>
<p className="text-xs">Returns pending reminders with donor contact info for external email/SMS.</p>
</div>
<div className="rounded-xl bg-trust-blue/5 border border-trust-blue/20 p-3">
<p className="text-xs text-trust-blue font-medium">
💡 Connect this to Zapier or Make to send emails/SMS automatically
💡 Connect to Zapier or n8n to send automatic reminder emails and SMS
</p>
</div>
</CardContent>

View File

@@ -81,6 +81,7 @@ function PledgesContent() {
fetchPledges()
const interval = setInterval(fetchPledges, 15000)
return () => clearInterval(interval)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventId])
const handleStatusChange = async (pledgeId: string, newStatus: string) => {

View File

@@ -0,0 +1,261 @@
"use client"
import { useState, useEffect } from "react"
import { useParams } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { MessageCircle, Share2, Heart, Users, Banknote, Gift, Loader2, QrCode, Calendar, MapPin } from "lucide-react"
// Badge is available via @/components/ui/badge if needed
interface EventData {
id: string
name: string
description: string | null
eventDate: string | null
location: string | null
goalAmount: number | null
organizationName: string
stats: {
pledgeCount: number
totalPledged: number
totalPaid: number
giftAidCount: number
avgPledge: number
}
recentPledges: Array<{
donorName: string | null
amountPence: number
createdAt: string
giftAid: boolean
}>
qrCodes: Array<{
code: string
label: string
volunteerName: string | null
}>
}
const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
export default function PublicEventPage() {
const params = useParams()
const slug = params.slug as string
const [data, setData] = useState<EventData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
useEffect(() => {
fetch(`/api/events/public/${slug}`)
.then((r) => r.json())
.then((d) => {
if (d.error) setError(d.error)
else setData(d)
})
.catch(() => setError("Failed to load"))
.finally(() => setLoading(false))
const interval = setInterval(() => {
fetch(`/api/events/public/${slug}`)
.then((r) => r.json())
.then((d) => { if (!d.error) setData(d) })
.catch(() => {})
}, 15000)
return () => clearInterval(interval)
}, [slug])
const handleWhatsAppShare = () => {
if (!data) return
const url = `${window.location.origin}/e/${slug}`
const text = `🤲 ${data.name} is raising funds!\n\nPledge here — it takes 15 seconds:\n${url}`
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
}
const handleShare = async () => {
if (!data) return
const url = `${window.location.origin}/e/${slug}`
if (navigator.share) {
await navigator.share({ title: data.name, text: `Pledge to ${data.name}`, url })
} else {
handleWhatsAppShare()
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
</div>
)
}
if (error || !data) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5 p-4">
<div className="text-center space-y-4">
<Heart className="h-12 w-12 text-muted-foreground mx-auto" />
<h1 className="text-xl font-bold">Event not found</h1>
<p className="text-muted-foreground">{error}</p>
</div>
</div>
)
}
const progressPercent = data.goalAmount
? Math.min(100, Math.round((data.stats.totalPledged / data.goalAmount) * 100))
: null
const giftAidBonus = Math.round(data.stats.totalPledged * 0.25 * (data.stats.giftAidCount / Math.max(1, data.stats.pledgeCount)))
return (
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
<div className="max-w-lg mx-auto px-4 py-8 space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<p className="text-sm text-muted-foreground">{data.organizationName}</p>
<h1 className="text-3xl font-extrabold text-gray-900">{data.name}</h1>
{data.description && <p className="text-muted-foreground">{data.description}</p>}
<div className="flex items-center justify-center gap-4 text-xs text-muted-foreground">
{data.eventDate && (
<span className="inline-flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(data.eventDate).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}
</span>
)}
{data.location && (
<span className="inline-flex items-center gap-1">
<MapPin className="h-3 w-3" /> {data.location}
</span>
)}
</div>
</div>
{/* Progress */}
<Card className="overflow-hidden">
<CardContent className="pt-6 space-y-4">
<div className="text-center">
<p className="text-4xl font-extrabold text-trust-blue">{formatPence(data.stats.totalPledged)}</p>
<p className="text-sm text-muted-foreground">
pledged by {data.stats.pledgeCount} {data.stats.pledgeCount === 1 ? "person" : "people"}
</p>
</div>
{progressPercent !== null && data.goalAmount && (
<div>
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>{progressPercent}% of goal</span>
<span>{formatPence(data.goalAmount)}</span>
</div>
<div className="h-4 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-trust-blue to-success-green transition-all duration-1000"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-3 pt-2">
<div className="text-center">
<Users className="h-4 w-4 text-trust-blue mx-auto" />
<p className="font-bold text-sm">{data.stats.pledgeCount}</p>
<p className="text-xs text-muted-foreground">Pledges</p>
</div>
<div className="text-center">
<Banknote className="h-4 w-4 text-success-green mx-auto" />
<p className="font-bold text-sm">{formatPence(data.stats.totalPaid)}</p>
<p className="text-xs text-muted-foreground">Collected</p>
</div>
<div className="text-center">
<Gift className="h-4 w-4 text-warm-amber mx-auto" />
<p className="font-bold text-sm">{formatPence(giftAidBonus)}</p>
<p className="text-xs text-muted-foreground">Gift Aid</p>
</div>
</div>
</CardContent>
</Card>
{/* Pledge CTA — link to first QR code */}
{data.qrCodes.length > 0 && (
<Button
size="xl"
className="w-full text-lg"
onClick={() => window.location.href = `/p/${data.qrCodes[0].code}`}
>
<Heart className="h-5 w-5 mr-2" /> Pledge Now
</Button>
)}
{/* Share */}
<div className="flex gap-2">
<Button onClick={handleWhatsAppShare} className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white">
<MessageCircle className="h-4 w-4 mr-2" /> WhatsApp
</Button>
<Button onClick={handleShare} variant="outline" className="flex-1">
<Share2 className="h-4 w-4 mr-2" /> Share
</Button>
</div>
{/* Recent pledges — social proof */}
{data.recentPledges.length > 0 && (
<div className="space-y-2">
<h2 className="font-bold text-sm text-muted-foreground uppercase tracking-wider">
Recent Pledges
</h2>
{data.recentPledges.map((p, i) => {
const name = p.donorName || "Anonymous"
const ago = formatTimeAgo(p.createdAt)
return (
<div key={i} className="flex items-center gap-3 py-2 border-b last:border-0">
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue">
{name[0].toUpperCase()}
</div>
<div className="flex-1">
<span className="text-sm font-medium">{name}</span>
{p.giftAid && <span className="text-xs ml-1">🎁</span>}
<p className="text-xs text-muted-foreground">{ago}</p>
</div>
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
</div>
)
})}
</div>
)}
{/* Volunteer QR codes */}
{data.qrCodes.length > 1 && (
<div className="space-y-2">
<h2 className="font-bold text-sm text-muted-foreground uppercase tracking-wider">
Pledge via a Fundraiser
</h2>
<div className="grid grid-cols-2 gap-2">
{data.qrCodes.map((qr) => (
<Button
key={qr.code}
variant="outline"
className="h-auto py-3 justify-start"
onClick={() => window.location.href = `/p/${qr.code}`}
>
<QrCode className="h-4 w-4 mr-2 shrink-0" />
<span className="truncate text-xs">{qr.volunteerName || qr.label}</span>
</Button>
))}
</div>
</div>
)}
<p className="text-center text-xs text-muted-foreground">
Powered by <a href="/" className="text-trust-blue hover:underline">Pledge Now, Pay Later</a>
</p>
</div>
</div>
)
}
function formatTimeAgo(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return "Just now"
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return `${Math.floor(hrs / 24)}d ago`
}

View File

@@ -8,10 +8,9 @@ import { IdentityStep } from "./steps/identity-step"
import { ConfirmationStep } from "./steps/confirmation-step"
import { BankInstructionsStep } from "./steps/bank-instructions-step"
import { CardPaymentStep } from "./steps/card-payment-step"
import { FpxPaymentStep } from "./steps/fpx-payment-step"
import { DirectDebitStep } from "./steps/direct-debit-step"
export type Rail = "bank" | "gocardless" | "card" | "fpx"
export type Rail = "bank" | "gocardless" | "card"
export interface PledgeData {
amountPence: number
@@ -30,18 +29,15 @@ interface EventInfo {
qrSourceLabel: string | null
}
// Step indices:
// 0 = Amount selection
// 1 = Payment method selection
// Steps:
// 0 = Amount
// 1 = Payment method
// 2 = Identity (for bank transfer)
// 3 = Bank instructions
// 4 = Confirmation (generic — card, DD, FPX)
// 4 = Confirmation (card, DD)
// 5 = Card payment step
// 6 = FPX payment step
// 7 = Direct Debit step
const STEP_TO_RAIL: Record<number, number> = { 5: 1, 6: 1, 7: 1 } // maps back to payment selection
export default function PledgePage() {
const params = useParams()
const token = params.token as string
@@ -80,7 +76,6 @@ export default function PledgePage() {
setError("Unable to load pledge page")
setLoading(false)
})
// Track pledge_start
fetch("/api/analytics", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -96,10 +91,9 @@ export default function PledgePage() {
const handleRailSelected = (rail: Rail) => {
setPledgeData((d) => ({ ...d, rail }))
const railStepMap: Record<Rail, number> = {
bank: 2, // → identity step → bank instructions
card: 5, // → card payment step (combined identity + card)
fpx: 6, // → FPX step (bank selection + identity + redirect)
gocardless: 7, // → direct debit step (bank details + mandate)
bank: 2,
card: 5,
gocardless: 7,
}
setStep(railStepMap[rail])
}
@@ -119,12 +113,8 @@ export default function PledgePage() {
}),
})
const result = await res.json()
if (result.error) {
setError(result.error)
return
}
if (result.error) { setError(result.error); return }
setPledgeResult(result)
// Bank rail shows bank instructions; everything else shows generic confirmation
setStep(finalData.rail === "bank" ? 3 : 4)
} catch {
setError("Something went wrong. Please try again.")
@@ -151,50 +141,38 @@ export default function PledgePage() {
)
}
const shareUrl = eventInfo?.qrSourceId ? `${typeof window !== "undefined" ? window.location.origin : ""}/p/${token}` : undefined
const steps: Record<number, React.ReactNode> = {
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} />,
1: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
2: <IdentityStep onSubmit={submitPledge} />,
2: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} />,
3: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} />,
4: pledgeResult && <ConfirmationStep pledge={pledgeResult} amount={pledgeData.amountPence} rail={pledgeData.rail} eventName={eventInfo?.name || ""} />,
4: pledgeResult && <ConfirmationStep pledge={pledgeResult} amount={pledgeData.amountPence} rail={pledgeData.rail} eventName={eventInfo?.name || ""} shareUrl={shareUrl} />,
5: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
6: <FpxPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} onComplete={submitPledge} />,
7: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
}
// Determine which steps allow back navigation
const backableSteps = new Set([1, 2, 5, 6, 7])
const getBackStep = (current: number): number => {
if (current in STEP_TO_RAIL) return STEP_TO_RAIL[current] // rail-specific steps → payment selection
return current - 1
const backableSteps = new Set([1, 2, 5, 7])
const getBackStep = (s: number): number => {
if (s === 5 || s === 7) return 1
return s - 1
}
// Progress calculation: steps 0-2 map linearly, 3+ means done
const progressSteps = step >= 3 ? 3 : Math.min(step, 2) + 1
const progressPercent = step >= 5 ? 66 : (progressSteps / 3) * 100 // rail steps show 2/3 progress
const progressPercent = step >= 3 ? 100 : step >= 2 ? 66 : step >= 1 ? 33 : 10
return (
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
{/* Progress bar */}
<div className="fixed top-0 left-0 right-0 h-1 bg-gray-100 z-50">
<div
className="h-full bg-trust-blue transition-all duration-500 ease-out"
style={{ width: `${progressPercent}%` }}
/>
<div className="h-full bg-trust-blue transition-all duration-500 ease-out" style={{ width: `${progressPercent}%` }} />
</div>
{/* Header */}
<div className="pt-6 pb-2 px-4 text-center">
<p className="text-sm text-muted-foreground">{eventInfo?.organizationName}</p>
<p className="text-xs text-muted-foreground/60">{eventInfo?.qrSourceLabel || ""}</p>
</div>
{/* Step content */}
<div className="px-4 pb-8">
{steps[step]}
</div>
<div className="px-4 pb-8">{steps[step]}</div>
{/* Back button */}
{backableSteps.has(step) && (
<div className="fixed bottom-6 left-4">
<button

View File

@@ -3,7 +3,7 @@
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Check, Copy, ExternalLink } from "lucide-react"
import { Check, Copy, ExternalLink, MessageCircle, Share2 } from "lucide-react"
interface Props {
pledge: {
@@ -70,6 +70,35 @@ export function BankInstructionsStep({ pledge, amount, eventName }: Props) {
</div>
</CardContent>
</Card>
{/* Share CTA */}
<div className="rounded-2xl bg-warm-amber/5 border border-warm-amber/20 p-4 space-y-3 text-center">
<p className="text-sm font-semibold text-gray-900">🤲 Know someone who&apos;d donate too?</p>
<div className="flex gap-2">
<Button
onClick={() => {
const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}! 🤲\nPledge here: ${window.location.origin}`
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
}}
className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white"
size="sm"
>
<MessageCircle className="h-4 w-4 mr-1" /> WhatsApp
</Button>
<Button
onClick={async () => {
if (navigator.share) {
await navigator.share({ title: eventName, text: `Pledge to ${eventName}`, url: window.location.origin })
}
}}
variant="outline"
size="sm"
className="flex-1"
>
<Share2 className="h-4 w-4 mr-1" /> Share
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Need help? Contact the charity directly.
</p>

View File

@@ -1,30 +1,42 @@
"use client"
import { Check } from "lucide-react"
import { Check, Share2, MessageCircle } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface Props {
pledge: { id: string; reference: string }
amount: number
rail: string
eventName: string
shareUrl?: string
}
export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl }: Props) {
const railLabels: Record<string, string> = {
bank: "Bank Transfer",
gocardless: "Direct Debit",
card: "Card Payment",
fpx: "FPX Online Banking",
}
const currencySymbol = rail === "fpx" ? "RM" : "£"
const nextStepMessages: Record<string, string> = {
bank: "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
gocardless: "Your Direct Debit mandate has been set up. The payment of " + currencySymbol + (amount / 100).toFixed(2) + " will be collected automatically in 3-5 working days. You'll receive email confirmation from GoCardless.",
card: "Your card payment is being processed. You'll receive a confirmation email shortly.",
fpx: "Your FPX payment has been received and is being verified. You'll receive a confirmation email once the payment is confirmed by your bank.",
gocardless: "Your Direct Debit mandate has been set up. The payment of £" + (amount / 100).toFixed(2) + " will be collected automatically in 3-5 working days. You'll receive email confirmation from GoCardless. Protected by the Direct Debit Guarantee.",
card: "Your card payment has been processed. You'll receive a confirmation email shortly.",
}
const handleWhatsAppShare = () => {
const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}! 🤲\n\nYou can pledge too: ${shareUrl || window.location.origin}`
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
}
const handleShare = async () => {
const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}!`
if (navigator.share) {
await navigator.share({ title: eventName, text, url: shareUrl || window.location.origin })
} else {
handleWhatsAppShare()
}
}
return (
@@ -35,10 +47,10 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
{rail === "fpx" ? "Payment Successful!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
{rail === "gocardless" ? "Mandate Set Up!" : rail === "card" ? "Payment Complete!" : "Pledge Received!"}
</h1>
<p className="text-muted-foreground">
Thank you for your generous {rail === "fpx" ? "donation" : "pledge"} to{" "}
Thank you for your generous {rail === "card" ? "donation" : "pledge"} to{" "}
<span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
@@ -47,7 +59,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
<CardContent className="pt-6 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Amount</span>
<span className="font-bold">{currencySymbol}{(amount / 100).toFixed(2)}</span>
<span className="font-bold">£{(amount / 100).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Payment Method</span>
@@ -63,7 +75,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
<span className="text-sm">3-5 working days</span>
</div>
)}
{rail === "fpx" && (
{rail === "card" && (
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<span className="text-success-green font-semibold">Paid </span>
@@ -72,6 +84,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
</CardContent>
</Card>
{/* What happens next */}
<div className="rounded-2xl bg-trust-blue/5 border border-trust-blue/20 p-4 space-y-2">
<p className="text-sm font-medium text-trust-blue">What happens next?</p>
<p className="text-sm text-muted-foreground">
@@ -79,6 +92,33 @@ export function ConfirmationStep({ pledge, amount, rail, eventName }: Props) {
</p>
</div>
{/* Share / encourage others */}
<div className="rounded-2xl bg-warm-amber/5 border border-warm-amber/20 p-5 space-y-3">
<p className="text-sm font-semibold text-gray-900">
🤲 Spread the word every pledge counts!
</p>
<p className="text-xs text-muted-foreground">
Share with friends and family so they can pledge too.
</p>
<div className="flex gap-2">
<Button
onClick={handleWhatsAppShare}
className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white"
>
<MessageCircle className="h-4 w-4 mr-2" />
WhatsApp
</Button>
<Button
onClick={handleShare}
variant="outline"
className="flex-1"
>
<Share2 className="h-4 w-4 mr-2" />
Share
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
Need help? Contact the charity directly. Ref: {pledge.reference}
</p>

View File

@@ -1,329 +0,0 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Lock, Search, CheckCircle2 } from "lucide-react"
interface Props {
amount: number
eventName: string
onComplete: (identity: {
donorName: string
donorEmail: string
donorPhone: string
giftAid: boolean
}) => void
}
interface Bank {
code: string
name: string
shortName: string
online: boolean
}
const FPX_BANKS: Bank[] = [
{ code: "MBB", name: "Maybank2u", shortName: "Maybank", online: true },
{ code: "CIMB", name: "CIMB Clicks", shortName: "CIMB", online: true },
{ code: "PBB", name: "PBe Bank", shortName: "Public Bank", online: true },
{ code: "RHB", name: "RHB Now", shortName: "RHB", online: true },
{ code: "HLB", name: "Hong Leong Connect", shortName: "Hong Leong", online: true },
{ code: "AMBB", name: "AmOnline", shortName: "AmBank", online: true },
{ code: "BIMB", name: "Bank Islam GO", shortName: "Bank Islam", online: true },
{ code: "BKRM", name: "i-Rakyat", shortName: "Bank Rakyat", online: true },
{ code: "BSN", name: "myBSN", shortName: "BSN", online: true },
{ code: "OCBC", name: "OCBC Online", shortName: "OCBC", online: true },
{ code: "UOB", name: "UOB Personal", shortName: "UOB", online: true },
{ code: "ABB", name: "Affin Online", shortName: "Affin Bank", online: true },
{ code: "ABMB", name: "Alliance Online", shortName: "Alliance Bank", online: true },
{ code: "BMMB", name: "Bank Muamalat", shortName: "Muamalat", online: true },
{ code: "SCB", name: "SC Online", shortName: "Standard Chartered", online: true },
{ code: "HSBC", name: "HSBC Online", shortName: "HSBC", online: true },
{ code: "AGR", name: "AGRONet", shortName: "Agrobank", online: true },
{ code: "KFH", name: "KFH Online", shortName: "KFH", online: true },
]
const BANK_COLORS: Record<string, string> = {
MBB: "bg-yellow-500",
CIMB: "bg-red-600",
PBB: "bg-pink-700",
RHB: "bg-blue-800",
HLB: "bg-blue-600",
AMBB: "bg-green-700",
BIMB: "bg-emerald-700",
BKRM: "bg-blue-900",
BSN: "bg-orange-600",
OCBC: "bg-red-700",
UOB: "bg-blue-700",
ABB: "bg-amber-700",
ABMB: "bg-teal-700",
BMMB: "bg-green-800",
SCB: "bg-green-600",
HSBC: "bg-red-500",
AGR: "bg-green-900",
KFH: "bg-yellow-700",
}
type Phase = "select" | "identity" | "redirecting" | "processing"
export function FpxPaymentStep({ amount, eventName, onComplete }: Props) {
const [phase, setPhase] = useState<Phase>("select")
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
const [search, setSearch] = useState("")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const ringgit = (amount / 100).toFixed(2)
const filteredBanks = search
? FPX_BANKS.filter(
(b) =>
b.name.toLowerCase().includes(search.toLowerCase()) ||
b.shortName.toLowerCase().includes(search.toLowerCase()) ||
b.code.toLowerCase().includes(search.toLowerCase())
)
: FPX_BANKS
const handleBankSelect = (bank: Bank) => {
setSelectedBank(bank)
}
const handleContinueToIdentity = () => {
if (!selectedBank) return
setPhase("identity")
}
const handleSubmit = async () => {
const errs: Record<string, string> = {}
if (!email.includes("@")) errs.email = "Valid email required"
setErrors(errs)
if (Object.keys(errs).length > 0) return
setPhase("redirecting")
// Simulate FPX redirect flow
await new Promise((r) => setTimeout(r, 2000))
setPhase("processing")
await new Promise((r) => setTimeout(r, 1500))
onComplete({
donorName: name,
donorEmail: email,
donorPhone: phone,
giftAid: false, // Gift Aid not applicable for MYR
})
}
// Redirecting phase
if (phase === "redirecting") {
return (
<div className="max-w-md mx-auto pt-16 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-trust-blue/10">
<div className="h-10 w-10 border-4 border-trust-blue border-t-transparent rounded-full animate-spin" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Redirecting to {selectedBank?.name}
</h1>
<p className="text-muted-foreground">
You&apos;ll be taken to your bank&apos;s secure login page to authorize the payment of <span className="font-bold text-foreground">RM{ringgit}</span>
</p>
</div>
<div className="rounded-2xl bg-gray-50 border p-4">
<div className="flex items-center justify-center gap-3">
<div className={`w-10 h-10 rounded-lg ${BANK_COLORS[selectedBank?.code || ""] || "bg-gray-500"} flex items-center justify-center`}>
<span className="text-white font-bold text-xs">{selectedBank?.code}</span>
</div>
<span className="font-semibold">{selectedBank?.name}</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Do not close this window. You will be redirected back automatically.
</p>
</div>
)
}
// Processing phase
if (phase === "processing") {
return (
<div className="max-w-md mx-auto pt-16 text-center space-y-6">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-success-green/10">
<div className="h-10 w-10 border-4 border-success-green border-t-transparent rounded-full animate-spin" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
Processing Payment
</h1>
<p className="text-muted-foreground">
Verifying your payment with {selectedBank?.shortName}...
</p>
</div>
</div>
)
}
// Identity phase
if (phase === "identity") {
return (
<div className="max-w-md mx-auto pt-4 space-y-6">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">Your Details</h1>
<p className="text-muted-foreground">
Before we redirect you to <span className="font-semibold text-foreground">{selectedBank?.name}</span>
</p>
</div>
{/* Selected bank indicator */}
<div className="rounded-2xl border-2 border-trust-blue/20 bg-trust-blue/5 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${BANK_COLORS[selectedBank?.code || ""] || "bg-gray-500"} flex items-center justify-center`}>
<span className="text-white font-bold text-xs">{selectedBank?.code}</span>
</div>
<div>
<p className="font-semibold text-sm">{selectedBank?.name}</p>
<p className="text-xs text-muted-foreground">FPX Online Banking</p>
</div>
</div>
<div className="text-right">
<p className="font-bold text-lg">RM{ringgit}</p>
<p className="text-xs text-muted-foreground">{eventName}</p>
</div>
</div>
</div>
<div className="rounded-2xl border-2 border-gray-200 bg-white p-5 space-y-4">
<div className="space-y-2">
<Label htmlFor="fpx-name">Full Name <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
id="fpx-name"
placeholder="Your full name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fpx-email">Email</Label>
<Input
id="fpx-email"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
inputMode="email"
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && <p className="text-xs text-red-500">{errors.email}</p>}
<p className="text-xs text-muted-foreground">We&apos;ll send your receipt here</p>
</div>
<div className="space-y-2">
<Label htmlFor="fpx-phone">Phone <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
id="fpx-phone"
type="tel"
placeholder="+60 12-345 6789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
autoComplete="tel"
inputMode="tel"
/>
</div>
</div>
<Button size="xl" className="w-full" onClick={handleSubmit}>
<Lock className="h-5 w-5 mr-2" />
Pay RM{ringgit} via {selectedBank?.shortName}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Secured by FPX Bank Negara Malaysia</span>
</div>
</div>
)
}
// Bank selection phase (default)
return (
<div className="max-w-md mx-auto pt-4 space-y-5">
<div className="text-center space-y-2">
<h1 className="text-2xl font-extrabold text-gray-900">
FPX Online Banking
</h1>
<p className="text-lg text-muted-foreground">
Pay <span className="font-bold text-foreground">RM{ringgit}</span>{" "}
for <span className="font-semibold text-foreground">{eventName}</span>
</p>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search your bank..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{/* Bank list */}
<div className="grid grid-cols-2 gap-2 max-h-[400px] overflow-y-auto pr-1">
{filteredBanks.map((bank) => (
<button
key={bank.code}
onClick={() => handleBankSelect(bank)}
className={`
text-left rounded-xl border-2 p-3 transition-all active:scale-[0.98]
${selectedBank?.code === bank.code
? "border-trust-blue bg-trust-blue/5 shadow-md shadow-trust-blue/10"
: "border-gray-200 bg-white hover:border-gray-300"
}
`}
>
<div className="flex items-center gap-2.5">
<div className={`w-9 h-9 rounded-lg ${BANK_COLORS[bank.code] || "bg-gray-500"} flex items-center justify-center flex-shrink-0`}>
<span className="text-white font-bold text-[10px] leading-none">{bank.code}</span>
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-xs text-gray-900 truncate">{bank.shortName}</p>
<p className="text-[10px] text-muted-foreground truncate">{bank.name}</p>
</div>
{selectedBank?.code === bank.code && (
<CheckCircle2 className="h-4 w-4 text-trust-blue flex-shrink-0" />
)}
</div>
</button>
))}
</div>
{filteredBanks.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-4">No banks found matching &quot;{search}&quot;</p>
)}
{/* Continue */}
<Button
size="xl"
className="w-full"
disabled={!selectedBank}
onClick={handleContinueToIdentity}
>
Continue with {selectedBank?.shortName || "selected bank"}
</Button>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
<span>Powered by FPX regulated by Bank Negara Malaysia</span>
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Gift, Shield } from "lucide-react"
interface Props {
onSubmit: (data: {
@@ -12,9 +13,10 @@ interface Props {
donorPhone: string
giftAid: boolean
}) => void
amount: number
}
export function IdentityStep({ onSubmit }: Props) {
export function IdentityStep({ onSubmit, amount }: Props) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [phone, setPhone] = useState("")
@@ -23,6 +25,7 @@ export function IdentityStep({ onSubmit }: Props) {
const hasContact = email.includes("@") || phone.length >= 10
const isValid = hasContact
const giftAidBonus = Math.round(amount * 0.25)
const handleSubmit = async () => {
if (!isValid) return
@@ -47,10 +50,10 @@ export function IdentityStep({ onSubmit }: Props) {
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Label htmlFor="name">Full Name <span className="text-muted-foreground font-normal">(for Gift Aid)</span></Label>
<Input
id="name"
placeholder="Your name"
placeholder="Your full name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
@@ -68,6 +71,9 @@ export function IdentityStep({ onSubmit }: Props) {
autoComplete="email"
inputMode="email"
/>
<p className="text-xs text-muted-foreground">
We&apos;ll send your payment instructions and receipt here
</p>
</div>
<div className="relative flex items-center">
@@ -77,7 +83,7 @@ export function IdentityStep({ onSubmit }: Props) {
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Label htmlFor="phone">Mobile Number</Label>
<Input
id="phone"
type="tel"
@@ -87,23 +93,54 @@ export function IdentityStep({ onSubmit }: Props) {
autoComplete="tel"
inputMode="tel"
/>
<p className="text-xs text-muted-foreground">
We can send reminders via SMS if you prefer
</p>
</div>
{/* Gift Aid */}
<label className="flex items-start gap-3 rounded-2xl border-2 border-gray-200 bg-white p-4 cursor-pointer hover:border-trust-blue/50 transition-colors">
<input
type="checkbox"
checked={giftAid}
onChange={(e) => setGiftAid(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-gray-300 text-trust-blue focus:ring-trust-blue"
/>
<div>
<span className="font-semibold text-gray-900">Add Gift Aid</span>
<p className="text-sm text-muted-foreground mt-0.5">
Boost your donation by 25% at no extra cost to you. You must be a UK taxpayer.
</p>
{/* Gift Aid — prominent UK-specific */}
<div
onClick={() => setGiftAid(!giftAid)}
className={`rounded-2xl border-2 p-5 cursor-pointer transition-all ${
giftAid
? "border-success-green bg-success-green/5 shadow-md shadow-success-green/10"
: "border-gray-200 bg-white hover:border-success-green/50"
}`}
>
<div className="flex items-start gap-4">
<div className={`rounded-xl p-2.5 ${giftAid ? "bg-success-green/10" : "bg-gray-100"}`}>
<Gift className={`h-6 w-6 ${giftAid ? "text-success-green" : "text-gray-400"}`} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={giftAid}
onChange={() => {}}
className="h-5 w-5 rounded border-gray-300 text-success-green focus:ring-success-green"
/>
<span className="font-bold text-gray-900">Add Gift Aid</span>
{giftAid && (
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-success-green text-white">
+£{(giftAidBonus / 100).toFixed(0)} free
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
Boost your £{(amount / 100).toFixed(0)} pledge to{" "}
<span className="font-bold text-success-green">£{((amount + giftAidBonus) / 100).toFixed(0)}</span> at no extra cost.
HMRC adds 25% the charity claims it back.
</p>
{giftAid && (
<p className="text-xs text-muted-foreground mt-2 italic">
I confirm I am a UK taxpayer and understand that if I pay less Income Tax and/or
Capital Gains Tax than the amount of Gift Aid claimed on all my donations in that
tax year it is my responsibility to pay any difference.
</p>
)}
</div>
</div>
</label>
</div>
</div>
<Button
@@ -115,9 +152,10 @@ export function IdentityStep({ onSubmit }: Props) {
{submitting ? "Submitting..." : "Complete Pledge ✓"}
</Button>
<p className="text-center text-xs text-muted-foreground">
We&apos;ll only use this to send payment details and confirm receipt.
</p>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Shield className="h-3 w-3" />
<span>Your data is kept secure and only used for this pledge</span>
</div>
</div>
)
}

View File

@@ -1,9 +1,9 @@
"use client"
import { Building2, CreditCard, Landmark, Globe } from "lucide-react"
import { Building2, CreditCard, Landmark } from "lucide-react"
interface Props {
onSelect: (rail: "bank" | "gocardless" | "card" | "fpx") => void
onSelect: (rail: "bank" | "gocardless" | "card") => void
amount: number
}
@@ -18,34 +18,31 @@ export function PaymentStep({ onSelect, amount }: Props) {
subtitle: "Zero fees — 100% goes to charity",
tag: "Recommended",
tagColor: "bg-success-green text-white",
detail: "Use your banking app to transfer directly",
detail: "Use your banking app to transfer directly. We'll give you the details.",
fee: "No fees",
feeColor: "text-success-green",
},
{
id: "gocardless" as const,
icon: Landmark,
title: "Direct Debit",
subtitle: "Automatic collection — set and forget",
tag: "Low fees",
tag: "Set up once",
tagColor: "bg-trust-blue/10 text-trust-blue",
detail: "We'll collect via GoCardless",
detail: "We'll collect via GoCardless. Protected by the Direct Debit Guarantee.",
fee: "1% + 20p",
feeColor: "text-muted-foreground",
},
{
id: "card" as const,
icon: CreditCard,
title: "Card Payment via Stripe",
subtitle: "Pay now by Visa, Mastercard, Amex",
tag: "Stripe",
title: "Debit or Credit Card",
subtitle: "Pay instantly by Visa, Mastercard, or Amex",
tag: "Instant",
tagColor: "bg-purple-100 text-purple-700",
detail: "Secure payment powered by Stripe",
},
{
id: "fpx" as const,
icon: Globe,
title: "FPX Online Banking",
subtitle: "Pay via Malaysian bank account",
tag: "Malaysia",
tagColor: "bg-amber-500/10 text-amber-700",
detail: "Instant payment from 18 Malaysian banks",
detail: "Secure payment powered by Stripe. Receipt emailed immediately.",
fee: "1.4% + 20p",
feeColor: "text-muted-foreground",
},
]
@@ -56,7 +53,7 @@ export function PaymentStep({ onSelect, amount }: Props) {
How would you like to pay?
</h1>
<p className="text-lg text-muted-foreground">
Pledge: <span className="font-bold text-foreground">£{pounds}</span>
Your pledge: <span className="font-bold text-foreground">£{pounds}</span>
</p>
</div>
@@ -72,16 +69,17 @@ export function PaymentStep({ onSelect, amount }: Props) {
<opt.icon className="h-6 w-6 text-trust-blue" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-gray-900">{opt.title}</span>
{opt.tag && (
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${opt.tagColor}`}>
{opt.tag}
</span>
)}
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${opt.tagColor}`}>
{opt.tag}
</span>
</div>
<p className="text-sm text-muted-foreground mt-0.5">{opt.subtitle}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{opt.detail}</p>
<p className={`text-xs font-medium mt-1 ${opt.feeColor}`}>
Fee: {opt.fee}
</p>
</div>
<div className="text-muted-foreground/40 group-hover:text-trust-blue transition-colors text-xl">
@@ -90,6 +88,10 @@ export function PaymentStep({ onSelect, amount }: Props) {
</button>
))}
</div>
<p className="text-center text-xs text-muted-foreground">
All payments are secure. Bank transfers mean 100% reaches the charity.
</p>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { CreditCard, Landmark, Building2, Globe, QrCode, BarChart3, Bell, Download } from "lucide-react"
import { CreditCard, Landmark, Building2, QrCode, BarChart3, Bell, Download, Users, Gift, MessageCircle, Share2, Smartphone } from "lucide-react"
export default function Home() {
return (
@@ -17,7 +17,7 @@ export default function Home() {
<span className="text-trust-blue">Pay Later</span>
</h1>
<p className="text-xl text-muted-foreground max-w-lg mx-auto">
Turn &quot;I&apos;ll donate later&quot; into tracked pledges with automatic payment follow-up. Zero fees on bank transfers.
Turn &quot;I&apos;ll donate later&quot; into tracked pledges with automatic follow-up. Built for UK charity fundraising events.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
@@ -38,25 +38,50 @@ export default function Home() {
<div className="text-xs text-muted-foreground">Pledge time</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-warm-amber">85%+</div>
<div className="text-xs text-muted-foreground">Collection rate</div>
<div className="text-2xl font-bold text-warm-amber">+25%</div>
<div className="text-xs text-muted-foreground">Gift Aid boost</div>
</div>
</div>
</div>
</div>
{/* Who is this for? */}
<div className="bg-white border-y py-16">
<div className="max-w-4xl mx-auto px-4">
<h2 className="text-2xl font-bold text-center mb-2">Built for everyone in your fundraising chain</h2>
<p className="text-center text-muted-foreground mb-8">From the charity manager to the donor&apos;s phone</p>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ icon: BarChart3, title: "Charity Managers", desc: "Live dashboard, bank reconciliation, Gift Aid reports. See every pound from pledge to collection.", color: "text-trust-blue" },
{ icon: Users, title: "Volunteers", desc: "Personal QR codes, leaderboard, own pledge tracker. Know exactly who pledged at your table.", color: "text-warm-amber" },
{ icon: Smartphone, title: "Donors", desc: "15-second pledge on your phone. Clear bank details, copy buttons, reminders until paid.", color: "text-success-green" },
{ icon: Share2, title: "Personal Fundraisers", desc: "Share your pledge link on WhatsApp. Track friends and family pledges with a progress bar.", color: "text-purple-600" },
].map((p, i) => (
<div key={i} className="rounded-2xl border bg-white p-5 space-y-3 hover:shadow-md transition-shadow">
<p.icon className={`h-8 w-8 ${p.color}`} />
<h3 className="font-bold">{p.title}</h3>
<p className="text-xs text-muted-foreground leading-relaxed">{p.desc}</p>
</div>
))}
</div>
</div>
</div>
{/* Payment Methods */}
<div className="max-w-4xl mx-auto px-4 pb-16">
<h2 className="text-2xl font-bold text-center mb-8">4 Payment Rails, One Platform</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="max-w-4xl mx-auto px-4 py-16">
<h2 className="text-2xl font-bold text-center mb-2">3 UK Payment Rails, One Platform</h2>
<p className="text-center text-muted-foreground mb-8">Every method a UK donor expects</p>
<div className="grid sm:grid-cols-3 gap-4">
{[
{ icon: Building2, title: "Bank Transfer", desc: "Zero fees — 100% to charity", color: "text-success-green" },
{ icon: Landmark, title: "Direct Debit", desc: "GoCardless auto-collection", color: "text-trust-blue" },
{ icon: CreditCard, title: "Card via Stripe", desc: "Visa, Mastercard, Amex", color: "text-purple-600" },
{ icon: Globe, title: "FPX Banking", desc: "Malaysian online banking", color: "text-amber-600" },
{ icon: Building2, title: "Bank Transfer", desc: "Zero fees — 100% to charity. Unique reference for auto-matching.", color: "text-success-green", tag: "0% fees" },
{ icon: Landmark, title: "Direct Debit", desc: "GoCardless auto-collection. Protected by the Direct Debit Guarantee.", color: "text-trust-blue", tag: "Set & forget" },
{ icon: CreditCard, title: "Card via Stripe", desc: "Visa, Mastercard, Amex. Instant payment and receipt.", color: "text-purple-600", tag: "Instant" },
].map((m, i) => (
<div key={i} className="rounded-2xl border bg-white p-5 text-center space-y-2 hover:shadow-md transition-shadow">
<m.icon className={`h-8 w-8 mx-auto ${m.color}`} />
<div key={i} className="rounded-2xl border bg-white p-6 text-center space-y-3 hover:shadow-md transition-shadow">
<m.icon className={`h-10 w-10 mx-auto ${m.color}`} />
<span className={`inline-block text-xs font-bold px-3 py-1 rounded-full ${
i === 0 ? "bg-success-green/10 text-success-green" : i === 1 ? "bg-trust-blue/10 text-trust-blue" : "bg-purple-100 text-purple-700"
}`}>{m.tag}</span>
<h3 className="font-bold">{m.title}</h3>
<p className="text-xs text-muted-foreground">{m.desc}</p>
</div>
@@ -67,13 +92,17 @@ export default function Home() {
{/* Features */}
<div className="bg-white border-y py-16">
<div className="max-w-4xl mx-auto px-4">
<h2 className="text-2xl font-bold text-center mb-8">Everything You Need</h2>
<h2 className="text-2xl font-bold text-center mb-8">Everything a UK charity needs</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ icon: QrCode, title: "QR Codes", desc: "Per-table, per-volunteer attribution tracking" },
{ icon: BarChart3, title: "Live Dashboard", desc: "Real-time pledge pipeline with auto-refresh" },
{ icon: Bell, title: "Smart Reminders", desc: "4-step follow-up sequence via email/SMS" },
{ icon: Download, title: "Bank Reconciliation", desc: "CSV import, auto-match by reference" },
{ icon: QrCode, title: "QR Attribution", desc: "Per-table, per-volunteer tracking. Know who raised what." },
{ icon: Gift, title: "Gift Aid Built In", desc: "One-tap declaration. HMRC-ready export. +25% on every eligible pledge." },
{ icon: Bell, title: "Smart Reminders", desc: "Automated follow-up via email and SMS until the pledge is paid." },
{ icon: Download, title: "Bank Reconciliation", desc: "Upload your CSV statement. Auto-match by unique reference." },
{ icon: MessageCircle, title: "WhatsApp Sharing", desc: "Donors share their pledge with friends. Viral fundraising built in." },
{ icon: Users, title: "Volunteer Portal", desc: "Each volunteer sees their own pledges and conversion rate." },
{ icon: BarChart3, title: "Live Dashboard", desc: "Real-time ticker during events. Pipeline from pledge to payment." },
{ icon: Share2, title: "Fundraiser Pages", desc: "Shareable links with progress bars. Perfect for personal campaigns." },
].map((f, i) => (
<div key={i} className="space-y-2">
<f.icon className="h-6 w-6 text-trust-blue" />
@@ -86,8 +115,9 @@ export default function Home() {
</div>
{/* Footer */}
<footer className="py-8 px-4 text-center text-xs text-muted-foreground">
<p>Pledge Now, Pay Later Built for UK charities.</p>
<footer className="py-8 px-4 text-center text-xs text-muted-foreground space-y-2">
<p>Pledge Now, Pay Later Built for UK charities by <a href="https://calvana.quikcue.com" className="text-trust-blue hover:underline">QuikCue</a>.</p>
<p>Free forever. No hidden fees. No card required.</p>
</footer>
</div>
)

View File

@@ -0,0 +1,216 @@
"use client"
import { useState, useEffect } from "react"
import { useParams } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { MessageCircle, Share2, QrCode, TrendingUp, Users, Banknote, Loader2 } from "lucide-react"
interface VolunteerData {
qrSource: {
label: string
volunteerName: string | null
code: string
scanCount: number
}
event: {
name: string
organizationName: string
}
pledges: Array<{
id: string
reference: string
amountPence: number
status: string
donorName: string | null
createdAt: string
giftAid: boolean
}>
stats: {
totalPledges: number
totalPledgedPence: number
totalPaidPence: number
conversionRate: number
}
}
const statusColors: Record<string, "default" | "secondary" | "success" | "warning" | "destructive"> = {
new: "secondary",
initiated: "warning",
paid: "success",
overdue: "destructive",
}
export default function VolunteerPage() {
const params = useParams()
const code = params.code as string
const [data, setData] = useState<VolunteerData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
useEffect(() => {
fetch(`/api/qr/${code}/volunteer`)
.then((r) => r.json())
.then((d) => {
if (d.error) setError(d.error)
else setData(d)
})
.catch(() => setError("Failed to load"))
.finally(() => setLoading(false))
const interval = setInterval(() => {
fetch(`/api/qr/${code}/volunteer`)
.then((r) => r.json())
.then((d) => { if (!d.error) setData(d) })
.catch(() => {})
}, 15000)
return () => clearInterval(interval)
}, [code])
const handleWhatsAppShare = () => {
if (!data) return
const url = `${window.location.origin}/p/${code}`
const text = `🤲 Pledge to ${data.event.name}!\n\nMake a pledge here — it only takes 15 seconds:\n${url}`
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
}
const handleShare = async () => {
if (!data) return
const url = `${window.location.origin}/p/${code}`
if (navigator.share) {
await navigator.share({ title: data.event.name, text: `Pledge to ${data.event.name}`, url })
} else {
handleWhatsAppShare()
}
}
const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
<Loader2 className="h-8 w-8 text-trust-blue animate-spin" />
</div>
)
}
if (error || !data) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5 p-4">
<div className="text-center space-y-4">
<QrCode className="h-12 w-12 text-muted-foreground mx-auto" />
<h1 className="text-xl font-bold">QR code not found</h1>
<p className="text-muted-foreground">{error}</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-trust-blue/5 via-white to-warm-amber/5">
<div className="max-w-lg mx-auto px-4 py-8 space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<p className="text-sm text-muted-foreground">{data.event.organizationName}</p>
<h1 className="text-2xl font-extrabold text-gray-900">{data.event.name}</h1>
<p className="text-lg font-semibold text-trust-blue">
{data.qrSource.volunteerName || data.qrSource.label}&apos;s Pledges
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<Card>
<CardContent className="pt-4 pb-3 text-center">
<Users className="h-5 w-5 text-trust-blue mx-auto mb-1" />
<p className="text-2xl font-bold">{data.stats.totalPledges}</p>
<p className="text-xs text-muted-foreground">Pledges</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 text-center">
<Banknote className="h-5 w-5 text-warm-amber mx-auto mb-1" />
<p className="text-2xl font-bold">{formatPence(data.stats.totalPledgedPence)}</p>
<p className="text-xs text-muted-foreground">Pledged</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 text-center">
<TrendingUp className="h-5 w-5 text-success-green mx-auto mb-1" />
<p className="text-2xl font-bold">{formatPence(data.stats.totalPaidPence)}</p>
<p className="text-xs text-muted-foreground">Paid</p>
</CardContent>
</Card>
</div>
{/* Progress bar */}
{data.stats.totalPledgedPence > 0 && (
<div>
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>Collection progress</span>
<span>{Math.round((data.stats.totalPaidPence / data.stats.totalPledgedPence) * 100)}%</span>
</div>
<div className="h-3 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-trust-blue to-success-green transition-all duration-1000"
style={{ width: `${Math.round((data.stats.totalPaidPence / data.stats.totalPledgedPence) * 100)}%` }}
/>
</div>
</div>
)}
{/* Share buttons */}
<div className="flex gap-2">
<Button onClick={handleWhatsAppShare} className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white">
<MessageCircle className="h-4 w-4 mr-2" /> Share on WhatsApp
</Button>
<Button onClick={handleShare} variant="outline" className="flex-1">
<Share2 className="h-4 w-4 mr-2" /> Share Link
</Button>
</div>
{/* Pledge list */}
<div className="space-y-2">
<h2 className="font-bold text-sm text-muted-foreground uppercase tracking-wider">
My Pledges ({data.pledges.length})
</h2>
{data.pledges.length === 0 ? (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground">No pledges yet. Share your link to start collecting!</p>
</CardContent>
</Card>
) : (
data.pledges.map((p) => (
<Card key={p.id} className="hover:shadow-sm transition-shadow">
<CardContent className="py-3 px-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-sm">{p.donorName || "Anonymous"}</p>
<p className="text-xs text-muted-foreground">
{new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
{p.giftAid && " · 🎁 Gift Aid"}
</p>
</div>
<div className="text-right flex items-center gap-2">
<span className="font-bold">{formatPence(p.amountPence)}</span>
<Badge variant={statusColors[p.status] || "secondary"} className="text-xs">
{p.status === "paid" ? "Paid ✓" : p.status}
</Badge>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Auto-refresh indicator */}
<p className="text-center text-xs text-muted-foreground">
Updates automatically every 15 seconds
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,120 @@
"use client"
import { useState, useEffect } from "react"
import { Badge } from "@/components/ui/badge"
import { Banknote, TrendingUp, Radio } from "lucide-react"
interface TickerPledge {
id: string
donorName: string | null
amountPence: number
status: string
rail: string
createdAt: string
qrSourceLabel: string | null
giftAid: boolean
}
interface LiveTickerProps {
eventId: string
}
const formatPence = (p: number) => `£${(p / 100).toLocaleString("en-GB", { minimumFractionDigits: 0 })}`
export function LiveTicker({ eventId }: LiveTickerProps) {
const [pledges, setPledges] = useState<TickerPledge[]>([])
const [isLive, setIsLive] = useState(false)
useEffect(() => {
const load = async () => {
try {
const res = await fetch(`/api/pledges?eventId=${eventId}&limit=10`)
const data = await res.json()
if (data.pledges) {
setPledges(data.pledges.map((p: Record<string, unknown>) => ({
id: p.id,
donorName: p.donorName,
amountPence: p.amountPence,
status: p.status,
rail: p.rail,
createdAt: p.createdAt,
qrSourceLabel: p.qrSourceLabel,
giftAid: p.giftAid,
})))
setIsLive(true)
}
} catch {
setIsLive(false)
}
}
load()
const interval = setInterval(load, 8000)
return () => clearInterval(interval)
}, [eventId])
if (pledges.length === 0) return null
const totalToday = pledges.reduce((s, p) => s + p.amountPence, 0)
return (
<div className="rounded-2xl border bg-white p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-bold text-sm flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-trust-blue" /> Live Feed
</h3>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground">
{formatPence(totalToday)} recent
</span>
{isLive && (
<Badge variant="success" className="gap-1">
<Radio className="h-3 w-3 animate-pulse" /> Live
</Badge>
)}
</div>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{pledges.map((p, i) => {
const name = p.donorName || "Anonymous"
const initials = name.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase()
const ago = formatTimeAgo(p.createdAt)
return (
<div
key={p.id}
className={`flex items-center gap-3 py-2 ${i === 0 ? "animate-slide-in" : ""}`}
>
<div className="w-8 h-8 rounded-full bg-trust-blue/10 flex items-center justify-center text-xs font-bold text-trust-blue shrink-0">
{initials}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">{name}</span>
{p.giftAid && <span className="text-xs">🎁</span>}
</div>
<p className="text-xs text-muted-foreground">
{p.qrSourceLabel && `${p.qrSourceLabel} · `}{ago}
</p>
</div>
<div className="flex items-center gap-2">
<Banknote className="h-3.5 w-3.5 text-success-green" />
<span className="font-bold text-sm">{formatPence(p.amountPence)}</span>
</div>
</div>
)
})}
</div>
</div>
)
}
function formatTimeAgo(dateStr: string) {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return "Just now"
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return `${Math.floor(hrs / 24)}d ago`
}