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:
216
pledge-now-pay-later/src/app/v/[code]/page.tsx
Normal file
216
pledge-now-pay-later/src/app/v/[code]/page.tsx
Normal 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}'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user