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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user