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

@@ -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,
},
})
}