From 0e8df76f895eeff2e9f42d69d4141e87d14cd56f Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Tue, 3 Mar 2026 06:42:11 +0800 Subject: [PATCH] fundraiser mode: external platforms, role-aware onboarding, show-don't-gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCHEMA: - Organization.orgType: 'charity' | 'fundraiser' - Organization.whatsappConnected: boolean - Event.paymentMode: 'self' (bank transfer) | 'external' (redirect to URL) - Event.externalUrl: fundraising page URL - Event.externalPlatform: launchgood, enthuse, justgiving, gofundme, other ONBOARDING (role-aware): - Dashboard shows getting-started banner AT TOP, not full-page blocker - First-time users see role picker: 'Charity/Mosque' vs 'Personal Fundraiser' - POST /api/onboarding sets orgType - Charity checklist: bank details → WhatsApp → create fundraiser → share link - Fundraiser checklist: add fundraising page → WhatsApp → share pledge link → first pledge - WhatsApp is now a core onboarding step for both types - Banner is dismissable via X button - Dashboard always shows stats (with zeros), progress bar, empty-state card SHOW DON'T GATE: - Stats cards show immediately (with zeros, slightly faded) - Collection progress bar always visible - Empty-state card says 'Your pledge data will appear here' - Getting started is a guidance banner, not a lock screen EXTERNAL PAYMENT FLOW: - Events can be paymentMode='external' with externalUrl - Pledge flow: amount → identity → 'Donate on LaunchGood' redirect (skips schedule + payment method) - ExternalRedirectStep: branded per platform (LaunchGood green, Enthuse purple, etc.) - Marks pledge as 'initiated' when donor clicks through - WhatsApp sends donation link instead of bank details - Share button shares the external URL EVENT CREATION: - Payment mode toggle: 'Bank transfer' vs 'External page' - External shows URL input + platform dropdown - Fundraiser orgs default to external mode - Platform badge on event cards PLATFORMS SUPPORTED: 🌙 LaunchGood, 💜 Enthuse, 💛 JustGiving, 💚 GoFundMe, 🔗 Other/Custom --- pledge-now-pay-later/package-lock.json | 20 +- .../20260303_fundraiser_mode/migration.sql | 10 + pledge-now-pay-later/prisma/schema.prisma | 27 +- .../src/app/api/events/route.ts | 15 +- .../src/app/api/onboarding/route.ts | 79 ++- .../src/app/api/qr/[token]/route.ts | 6 + .../src/app/dashboard/events/page.tsx | 102 +++- .../src/app/dashboard/page.tsx | 557 +++++++++++------- .../src/app/p/[token]/page.tsx | 30 +- .../[token]/steps/external-redirect-step.tsx | 187 ++++++ pledge-now-pay-later/src/lib/validators.ts | 3 + 11 files changed, 767 insertions(+), 269 deletions(-) create mode 100644 pledge-now-pay-later/prisma/migrations/20260303_fundraiser_mode/migration.sql create mode 100644 pledge-now-pay-later/src/app/p/[token]/steps/external-redirect-step.tsx diff --git a/pledge-now-pay-later/package-lock.json b/pledge-now-pay-later/package-lock.json index 00ade23..0958e57 100644 --- a/pledge-now-pay-later/package-lock.json +++ b/pledge-now-pay-later/package-lock.json @@ -173,7 +173,8 @@ "version": "0.3.15", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -1150,6 +1151,7 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.2.tgz", "integrity": "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/client-runtime-utils": "7.4.2" }, @@ -1427,6 +1429,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1487,6 +1490,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2006,6 +2010,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3304,6 +3309,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3473,6 +3479,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4354,6 +4361,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -4947,6 +4955,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5891,6 +5900,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", @@ -6073,6 +6083,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6292,6 +6303,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -6331,6 +6343,7 @@ "integrity": "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.4.2", "@prisma/dev": "0.20.0", @@ -6466,6 +6479,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6478,6 +6492,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7477,6 +7492,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7542,6 +7558,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -7666,6 +7683,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/pledge-now-pay-later/prisma/migrations/20260303_fundraiser_mode/migration.sql b/pledge-now-pay-later/prisma/migrations/20260303_fundraiser_mode/migration.sql new file mode 100644 index 0000000..bd4ed01 --- /dev/null +++ b/pledge-now-pay-later/prisma/migrations/20260303_fundraiser_mode/migration.sql @@ -0,0 +1,10 @@ +-- Organization type: charity processes payments, fundraiser redirects to external platform +ALTER TABLE "Organization" ADD COLUMN "orgType" TEXT NOT NULL DEFAULT 'charity'; + +-- Per-event: can this event process payments itself, or redirect to an external page? +ALTER TABLE "Event" ADD COLUMN "paymentMode" TEXT NOT NULL DEFAULT 'self'; +ALTER TABLE "Event" ADD COLUMN "externalUrl" TEXT; +ALTER TABLE "Event" ADD COLUMN "externalPlatform" TEXT; + +-- WhatsApp connected flag (set when QR scanned + session active) +ALTER TABLE "Organization" ADD COLUMN "whatsappConnected" BOOLEAN NOT NULL DEFAULT false; diff --git a/pledge-now-pay-later/prisma/schema.prisma b/pledge-now-pay-later/prisma/schema.prisma index f6a4b47..1e479c6 100644 --- a/pledge-now-pay-later/prisma/schema.prisma +++ b/pledge-now-pay-later/prisma/schema.prisma @@ -11,6 +11,7 @@ model Organization { id String @id @default(cuid()) name String slug String @unique + orgType String @default("charity") // charity | fundraiser country String @default("UK") timezone String @default("Europe/London") bankName String? @@ -22,6 +23,7 @@ model Organization { primaryColor String @default("#1e40af") gcAccessToken String? gcEnvironment String @default("sandbox") + whatsappConnected Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -48,17 +50,20 @@ model User { } model Event { - id String @id @default(cuid()) - name String - slug String - description String? - eventDate DateTime? - location String? - goalAmount Int? // in pence - currency String @default("GBP") - status String @default("active") // draft, active, closed, archived - organizationId String - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + name String + slug String + description String? + eventDate DateTime? + location String? + goalAmount Int? // in pence + currency String @default("GBP") + status String @default("active") // draft, active, closed, archived + paymentMode String @default("self") // self = we show bank details, external = redirect to URL + externalUrl String? // e.g. https://launchgood.com/my-campaign + externalPlatform String? // launchgood, enthuse, justgiving, gofundme, other + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/pledge-now-pay-later/src/app/api/events/route.ts b/pledge-now-pay-later/src/app/api/events/route.ts index 89cb047..081cfa0 100644 --- a/pledge-now-pay-later/src/app/api/events/route.ts +++ b/pledge-now-pay-later/src/app/api/events/route.ts @@ -43,7 +43,8 @@ export async function GET(request: NextRequest) { orderBy: { createdAt: "desc" }, }) as EventRow[] - const formatted = events.map((e: EventRow) => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const formatted = events.map((e: any) => ({ id: e.id, name: e.name, slug: e.slug, @@ -51,6 +52,9 @@ export async function GET(request: NextRequest) { location: e.location, goalAmount: e.goalAmount, status: e.status, + paymentMode: e.paymentMode || "self", + externalPlatform: e.externalPlatform || null, + externalUrl: e.externalUrl || null, pledgeCount: e._count.pledges, qrSourceCount: e._count.qrSources, totalPledged: e.pledges.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0), @@ -92,7 +96,14 @@ export async function POST(request: NextRequest) { const event = await prisma.event.create({ data: { - ...parsed.data, + name: parsed.data.name, + description: parsed.data.description, + currency: parsed.data.currency, + goalAmount: parsed.data.goalAmount, + location: parsed.data.location, + paymentMode: parsed.data.paymentMode || "self", + externalUrl: parsed.data.externalUrl, + externalPlatform: parsed.data.externalPlatform, slug: slug + "-" + Date.now().toString(36), eventDate: parsed.data.eventDate ? new Date(parsed.data.eventDate) : null, organizationId: orgId, diff --git a/pledge-now-pay-later/src/app/api/onboarding/route.ts b/pledge-now-pay-later/src/app/api/onboarding/route.ts index 1797793..8c708e4 100644 --- a/pledge-now-pay-later/src/app/api/onboarding/route.ts +++ b/pledge-now-pay-later/src/app/api/onboarding/route.ts @@ -1,38 +1,95 @@ -import { NextResponse } from "next/server" +import { NextRequest, NextResponse } from "next/server" import prisma from "@/lib/prisma" import { getOrgId } from "@/lib/session" /** - * GET /api/onboarding — check setup progress for current org + * GET /api/onboarding — role-aware setup progress + * Returns different checklist for charities vs fundraisers + * orgType=null means user hasn't chosen yet */ export async function GET() { const orgId = await getOrgId(null) - if (!orgId || !prisma) return NextResponse.json({ steps: [] }) + if (!orgId || !prisma) return NextResponse.json({ steps: [], needsRole: true }) const [org, eventCount, qrCount, pledgeCount] = await Promise.all([ prisma.organization.findUnique({ where: { id: orgId }, - select: { name: true, bankSortCode: true, bankAccountNo: true, bankAccountName: true }, + select: { + name: true, orgType: true, bankSortCode: true, bankAccountNo: true, + whatsappConnected: true, + }, }), prisma.event.count({ where: { organizationId: orgId } }), prisma.qrSource.count({ where: { event: { organizationId: orgId } } }), prisma.pledge.count({ where: { organizationId: orgId } }), ]) + // Check if event has external URL (for fundraiser check) + const hasExternalEvent = await prisma.event.count({ + where: { organizationId: orgId, paymentMode: "external", externalUrl: { not: null } }, + }) + + const orgType = org?.orgType || null // null = hasn't picked yet const hasBank = !!(org?.bankSortCode && org?.bankAccountNo) const hasEvent = eventCount > 0 const hasQr = qrCount > 0 const hasPledge = pledgeCount > 0 + const hasWhatsApp = org?.whatsappConnected || false - const steps = [ - { id: "bank", label: "Add bank details", desc: "So donors know where to send money", done: hasBank, href: "/dashboard/settings" }, - { id: "event", label: "Create an event", desc: "Give your fundraiser a name", done: hasEvent, href: "/dashboard/events" }, - { id: "qr", label: "Generate a QR code", desc: "One per table or volunteer", done: hasQr, href: hasEvent ? "/dashboard/events" : "/dashboard/events" }, - { id: "pledge", label: "Get your first pledge", desc: "Share the link or scan the QR", done: hasPledge, href: "/dashboard/pledges" }, - ] + // Build steps based on org type + type Step = { id: string; label: string; desc: string; done: boolean; href: string; action?: string } + const steps: Step[] = [] + + if (!orgType || orgType === "charity") { + // CHARITY flow — needs bank details + steps.push( + { id: "bank", label: "Add bank details", desc: "So donors know where to send money", done: hasBank, href: "/dashboard/settings" }, + { id: "whatsapp", label: "Connect WhatsApp", desc: "Auto-send receipts & reminders to donors", done: hasWhatsApp, href: "/dashboard/settings", action: "whatsapp" }, + { id: "event", label: "Create a fundraiser", desc: "Give your campaign a name & goal", done: hasEvent, href: "/dashboard/events" }, + { id: "share", label: "Share your first link", desc: "Generate a QR code or copy the link", done: hasQr, href: "/dashboard/events" }, + ) + } else { + // FUNDRAISER flow — needs external URL, no bank + steps.push( + { id: "event", label: "Add your fundraising page", desc: "Paste your LaunchGood, Enthuse or JustGiving link", done: hasExternalEvent > 0, href: "/dashboard/events" }, + { id: "whatsapp", label: "Connect WhatsApp", desc: "Auto-remind donors to complete their pledge", done: hasWhatsApp, href: "/dashboard/settings", action: "whatsapp" }, + { id: "share", label: "Share your pledge link", desc: "Generate a QR or copy the link to share", done: hasQr, href: "/dashboard/events" }, + { id: "pledge", label: "Get your first pledge", desc: "Share with friends & family to start", done: hasPledge, href: "/dashboard/pledges" }, + ) + } const completed = steps.filter(s => s.done).length const allDone = completed === steps.length - return NextResponse.json({ steps, completed, total: steps.length, allDone, orgName: org?.name }) + return NextResponse.json({ + steps, + completed, + total: steps.length, + allDone, + orgType, + needsRole: !orgType || orgType === "charity", // charity is default — show role picker if not explicitly set + orgName: org?.name, + }) +} + +/** + * POST /api/onboarding — set org type (charity vs fundraiser) + */ +export async function POST(request: NextRequest) { + const orgId = await getOrgId(null) + if (!orgId || !prisma) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + const body = await request.json() + const orgType = body.orgType + + if (!["charity", "fundraiser"].includes(orgType)) { + return NextResponse.json({ error: "Invalid orgType" }, { status: 400 }) + } + + await prisma.organization.update({ + where: { id: orgId }, + data: { orgType }, + }) + + return NextResponse.json({ ok: true, orgType }) } diff --git a/pledge-now-pay-later/src/app/api/qr/[token]/route.ts b/pledge-now-pay-later/src/app/api/qr/[token]/route.ts index 9e8cbaa..f513eaa 100644 --- a/pledge-now-pay-later/src/app/api/qr/[token]/route.ts +++ b/pledge-now-pay-later/src/app/api/qr/[token]/route.ts @@ -28,6 +28,9 @@ export async function GET( organizationName: event.organization.name, qrSourceId: null, qrSourceLabel: null, + paymentMode: event.paymentMode || "self", + externalUrl: event.externalUrl || null, + externalPlatform: event.externalPlatform || null, }) } @@ -58,6 +61,9 @@ export async function GET( organizationName: qrSource.event.organization.name, qrSourceId: qrSource.id, qrSourceLabel: qrSource.label, + paymentMode: qrSource.event.paymentMode || "self", + externalUrl: qrSource.event.externalUrl || null, + externalPlatform: qrSource.event.externalPlatform || null, }) } catch (error) { console.error("QR resolve error:", error) diff --git a/pledge-now-pay-later/src/app/dashboard/events/page.tsx b/pledge-now-pay-later/src/app/dashboard/events/page.tsx index b57e51f..cfecc4b 100644 --- a/pledge-now-pay-later/src/app/dashboard/events/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/events/page.tsx @@ -9,7 +9,7 @@ import { Textarea } from "@/components/ui/textarea" import { Badge } from "@/components/ui/badge" import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { formatPence } from "@/lib/utils" -import { Plus, QrCode, Calendar, MapPin, Target } from "lucide-react" +import { Plus, QrCode, Calendar, MapPin, Target, ExternalLink } from "lucide-react" import Link from "next/link" interface EventSummary { @@ -24,6 +24,16 @@ interface EventSummary { qrSourceCount: number totalPledged: number totalCollected: number + paymentMode?: string + externalPlatform?: string +} + +const platformNames: Record = { + launchgood: "🌙 LaunchGood", + enthuse: "💜 Enthuse", + justgiving: "💛 JustGiving", + gofundme: "💚 GoFundMe", + other: "🔗 External", } export default function EventsPage() { @@ -42,7 +52,17 @@ export default function EventsPage() { .finally(() => setLoading(false)) }, []) const [creating, setCreating] = useState(false) - const [form, setForm] = useState({ name: "", description: "", location: "", eventDate: "", goalAmount: "" }) + const [orgType, setOrgType] = useState(null) + const [form, setForm] = useState({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: "self" as "self" | "external", externalUrl: "", externalPlatform: "" }) + + // Fetch org type to customize the form + useEffect(() => { + fetch("/api/onboarding").then(r => r.json()).then(d => { + if (d.orgType) setOrgType(d.orgType) + // Auto-set external mode for fundraisers + if (d.orgType === "fundraiser") setForm(f => ({ ...f, paymentMode: "external" })) + }).catch(() => {}) + }, []) const handleCreate = async () => { setCreating(true) @@ -51,16 +71,21 @@ export default function EventsPage() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - ...form, + name: form.name, + description: form.description || undefined, + location: form.location || undefined, goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined, eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : undefined, + paymentMode: form.paymentMode, + externalUrl: form.paymentMode === "external" ? form.externalUrl : undefined, + externalPlatform: form.paymentMode === "external" ? (form.externalPlatform || "other") : undefined, }), }) if (res.ok) { const event = await res.json() setEvents((prev) => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev]) setShowCreate(false) - setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "" }) + setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: orgType === "fundraiser" ? "external" : "self", externalUrl: "", externalPlatform: "" }) } } catch { // handle error @@ -106,9 +131,14 @@ export default function EventsPage() { )} - - {event.status} - +
+ {event.paymentMode === "external" && event.externalPlatform && ( + {platformNames[event.externalPlatform] || "External"} + )} + + {event.status} + +
@@ -211,11 +241,67 @@ export default function EventsPage() { onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))} /> + + {/* Payment mode toggle */} +
+ +
+ + +
+
+ + {form.paymentMode === "external" && ( +
+
+ +
+ + setForm((f) => ({ ...f, externalUrl: e.target.value }))} + className="pl-9" + /> +
+
+
+ + +
+
+ )} +
-
diff --git a/pledge-now-pay-later/src/app/dashboard/page.tsx b/pledge-now-pay-later/src/app/dashboard/page.tsx index 861cec7..b2c360f 100644 --- a/pledge-now-pay-later/src/app/dashboard/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/page.tsx @@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Progress } from "@/components/ui/progress" import { formatPence } from "@/lib/utils" -import { TrendingUp, Users, Banknote, AlertTriangle, Calendar, Clock, CheckCircle2, ArrowRight, Loader2, MessageCircle, ExternalLink, Circle } from "lucide-react" +import { TrendingUp, Users, Banknote, AlertTriangle, Calendar, Clock, CheckCircle2, ArrowRight, Loader2, MessageCircle, ExternalLink, Circle, X, Building2, Heart } from "lucide-react" import Link from "next/link" interface DashboardData { @@ -24,14 +24,128 @@ interface DashboardData { }> } +interface OnboardingData { + steps: Array<{ id: string; label: string; desc: string; done: boolean; href: string; action?: string }> + completed: number + total: number + allDone: boolean + orgType: string | null + needsRole: boolean + orgName: string +} + const statusIcons: Record = { new: Clock, initiated: TrendingUp, paid: CheckCircle2, overdue: AlertTriangle } const statusColors: Record = { new: "secondary", initiated: "warning", paid: "success", overdue: "destructive" } +// ─── Role Picker ──────────────────────────────────────────── +function RolePicker({ onSelect }: { onSelect: (role: string) => void }) { + return ( +
+ + +
+ ) +} + +// ─── Getting Started Banner ───────────────────────────────── +function GettingStartedBanner({ + ob, + onSetRole, + dismissed, + onDismiss, +}: { + ob: OnboardingData + onSetRole: (role: string) => void + dismissed: boolean + onDismiss: () => void +}) { + const [showRolePicker, setShowRolePicker] = useState(!ob.orgType || ob.orgType === "charity") + + if (ob.allDone || dismissed) return null + + // First-time: show role picker + const isFirstTime = ob.completed === 0 && (!ob.orgType || ob.orgType === "charity") + + return ( +
+ {/* Dismiss X */} + + +
+
+ 🤲 +
+
+

+ {isFirstTime ? "Welcome! What best describes you?" : `Getting started · ${ob.completed}/${ob.total}`} +

+ {!isFirstTime && ( + + )} +
+
+ + {isFirstTime && showRolePicker ? ( + { onSetRole(role); setShowRolePicker(false) }} /> + ) : ( +
+ {ob.steps.map((step, i) => { + const isNext = !step.done && ob.steps.slice(0, i).every(s => s.done) + return ( + +
+ {step.done ? ( + + ) : isNext ? ( +
{i + 1}
+ ) : ( + + )} +
+

{step.label}

+
+ {isNext && } +
+ + ) + })} +
+ )} +
+ ) +} + +// ─── Main Dashboard ───────────────────────────────────────── export default function DashboardPage() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [whatsappStatus, setWhatsappStatus] = useState(null) - const [onboarding, setOnboarding] = useState<{ steps: Array<{ id: string; label: string; desc: string; done: boolean; href: string }>; completed: number; total: number; allDone: boolean } | null>(null) + const [ob, setOb] = useState(null) + const [bannerDismissed, setBannerDismissed] = useState(false) const fetchData = useCallback(() => { fetch("/api/dashboard") @@ -44,11 +158,23 @@ export default function DashboardPage() { useEffect(() => { fetchData() fetch("/api/whatsapp/send").then(r => r.json()).then(d => setWhatsappStatus(d.connected)).catch(() => {}) - fetch("/api/onboarding").then(r => r.json()).then(d => { if (d.steps) setOnboarding(d) }).catch(() => {}) + fetch("/api/onboarding").then(r => r.json()).then(d => { if (d.steps) setOb(d) }).catch(() => {}) const interval = setInterval(fetchData, 15000) return () => clearInterval(interval) }, [fetchData]) + const handleSetRole = async (role: string) => { + await fetch("/api/onboarding", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ orgType: role }), + }) + // Refresh onboarding state + const res = await fetch("/api/onboarding") + const d = await res.json() + if (d.steps) setOb(d) + } + if (loading) { return (
@@ -57,78 +183,31 @@ export default function DashboardPage() { ) } - if (!data || (data.summary.totalPledges === 0 && onboarding && !onboarding.allDone)) { - // Show getting-started checklist - const ob = onboarding - return ( -
-
-
- 🤲 -
-

Let's get you set up

-

4 quick steps, then you're collecting pledges

-
- - {ob && ( - <> - -

{ob.completed} of {ob.total} done

- -
- {ob.steps.map((step, i) => { - const isNext = !step.done && ob.steps.slice(0, i).every(s => s.done) - return ( - -
- {step.done ? ( - - ) : isNext ? ( -
{i + 1}
- ) : ( - - )} -
-

{step.label}

-

{step.desc}

-
- {isNext && } -
- - ) - })} -
- - )} - - {(!ob || ob.completed === 0) && ( -
-

- 💡 Tip: Add your bank details first — that's the only thing you need before donors can pledge. -

-
- )} -
- ) - } - - const s = data.summary - const upcomingPledges = data.pledges.filter(p => + const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0, overdueRate: 0 } + const byStatus = data?.byStatus || {} + const topSources = data?.topSources || [] + const pledges = data?.pledges || [] + const upcomingPledges = pledges.filter(p => p.isDeferred && p.dueDate && p.status !== "paid" && p.status !== "cancelled" ).sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime()) - const recentPledges = data.pledges.filter(p => p.status !== "cancelled").slice(0, 8) - const overduePledges = data.pledges.filter(p => p.status === "overdue") - const needsAction = [...overduePledges, ...upcomingPledges.filter(p => { - const due = new Date(p.dueDate!) - return due.getTime() - Date.now() < 2 * 86400000 // due in 2 days - })].slice(0, 5) + const recentPledges = pledges.filter(p => p.status !== "cancelled").slice(0, 8) + const needsAction = [ + ...pledges.filter(p => p.status === "overdue"), + ...upcomingPledges.filter(p => { + const due = new Date(p.dueDate!) + return due.getTime() - Date.now() < 2 * 86400000 + }) + ].slice(0, 5) + + const isEmpty = s.totalPledges === 0 return (
+ {/* Getting-started banner — always at top, not a blocker */} + {ob && !ob.allDone && ( + setBannerDismissed(true)} /> + )} +

Dashboard

@@ -139,17 +218,19 @@ export default function DashboardPage() { {whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"} )} - Auto-refreshes every 15s + {isEmpty ? "Your data will appear here" : "Auto-refreshes every 15s"}

- - - + {!isEmpty && ( + + + + )}
- {/* Stats */} + {/* Stats — always show, even with zeros */}
- +
@@ -160,7 +241,7 @@ export default function DashboardPage() {
- +
@@ -171,7 +252,7 @@ export default function DashboardPage() {
- +
@@ -182,12 +263,12 @@ export default function DashboardPage() {
- 10 ? "border-danger-red/30" : ""}> + 10 ? "border-danger-red/30" : ""}>
-

{data.byStatus.overdue || 0}

+

{byStatus.overdue || 0}

Overdue

@@ -195,8 +276,8 @@ export default function DashboardPage() {
- {/* Collection progress */} - + {/* Collection progress — always visible */} +
Pledged → Collected @@ -210,159 +291,179 @@ export default function DashboardPage() { -
- {/* Needs attention */} - 0 ? "border-warm-amber/30" : ""}> - - - Needs Attention - {needsAction.length > 0 && {needsAction.length}} - - - - {needsAction.length === 0 ? ( -

All clear! No urgent items.

- ) : ( - needsAction.map(p => ( -
-
-

{p.donorName || "Anonymous"}

-

- {formatPence(p.amountPence)} · {p.eventName} - {p.dueDate && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`} -

-
- - {p.status === "overdue" ? "Overdue" : "Due soon"} - -
- )) - )} - {needsAction.length > 0 && ( - - View all + {isEmpty ? ( + /* Empty state — gentle nudge, not a blocker */ + + + +

Your pledge data will appear here

+

+ Once you share your first link and donors start pledging, you'll see live stats, payment tracking, and reminders. +

+
+ + - )} +
- - {/* Upcoming payments */} - - - - Upcoming Payments - - - - {upcomingPledges.length === 0 ? ( -

No scheduled payments

- ) : ( - upcomingPledges.slice(0, 5).map(p => ( -
-
-
- {new Date(p.dueDate!).getDate()} -
- {new Date(p.dueDate!).toLocaleDateString("en-GB", { month: "short" })} + ) : ( + <> +
+ {/* Needs attention */} + 0 ? "border-warm-amber/30" : ""}> + + + Needs Attention + {needsAction.length > 0 && {needsAction.length}} + + + + {needsAction.length === 0 ? ( +

All clear! No urgent items.

+ ) : ( + needsAction.map(p => ( +
+
+

{p.donorName || "Anonymous"}

+

+ {formatPence(p.amountPence)} · {p.eventName} + {p.dueDate && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`} +

+
+ + {p.status === "overdue" ? "Overdue" : "Due soon"} +
-
-

{p.donorName || "Anonymous"}

-

- {p.eventName} - {p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`} -

-
-
- {formatPence(p.amountPence)} -
- )) - )} - - -
+ )) + )} + {needsAction.length > 0 && ( + + View all + + )} + + - {/* Pipeline + Sources */} -
- - - Pipeline by Status - - - {Object.entries(data.byStatus).map(([status, count]) => { - const Icon = statusIcons[status] || Clock - return ( -
-
- - {status} -
- {count} -
- ) - })} -
-
- - - - Top Sources - - - {data.topSources.length === 0 ? ( -

Create QR codes to track sources

- ) : ( - data.topSources.slice(0, 6).map((src, i) => ( -
-
- {i + 1} - {src.label} - {src.count} pledges -
- {formatPence(src.amount)} -
- )) - )} -
-
-
- - {/* Recent pledges */} - - - Recent Pledges - - - - - -
- {recentPledges.map(p => { - const sc = statusColors[p.status] || "secondary" - return ( -
-
-
- {(p.donorName || "A")[0].toUpperCase()} + {/* Upcoming payments */} + + + + Upcoming Payments + + + + {upcomingPledges.length === 0 ? ( +

No scheduled payments

+ ) : ( + upcomingPledges.slice(0, 5).map(p => ( +
+
+
+ {new Date(p.dueDate!).getDate()} +
+ {new Date(p.dueDate!).toLocaleDateString("en-GB", { month: "short" })} +
+
+

{p.donorName || "Anonymous"}

+

+ {p.eventName} + {p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`} +

+
+
+ {formatPence(p.amountPence)}
-
-

{p.donorName || "Anonymous"}

-

- {p.eventName} · {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} - {p.dueDate && !p.paidAt && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`} - {p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`} -

-
-
-
- {formatPence(p.amountPence)} - {p.status} -
-
- ) - })} + )) + )} + +
- - + + {/* Pipeline + Sources */} +
+ + + Pipeline by Status + + + {Object.entries(byStatus).map(([status, count]) => { + const Icon = statusIcons[status] || Clock + return ( +
+
+ + {status} +
+ {count} +
+ ) + })} +
+
+ + + + Top Sources + + + {topSources.length === 0 ? ( +

Create QR codes to track sources

+ ) : ( + topSources.slice(0, 6).map((src, i) => ( +
+
+ {i + 1} + {src.label} + {src.count} pledges +
+ {formatPence(src.amount)} +
+ )) + )} +
+
+
+ + {/* Recent pledges */} + + + Recent Pledges + + + + + +
+ {recentPledges.map(p => { + const sc = statusColors[p.status] || "secondary" + return ( +
+
+
+ {(p.donorName || "A")[0].toUpperCase()} +
+
+

{p.donorName || "Anonymous"}

+

+ {p.eventName} · {new Date(p.createdAt).toLocaleDateString("en-GB", { day: "numeric", month: "short" })} + {p.dueDate && !p.paidAt && ` · Due ${new Date(p.dueDate).toLocaleDateString("en-GB", { day: "numeric", month: "short" })}`} + {p.installmentTotal && p.installmentTotal > 1 && ` · ${p.installmentNumber}/${p.installmentTotal}`} +

+
+
+
+ {formatPence(p.amountPence)} + {p.status} +
+
+ ) + })} +
+
+
+ + )}
) } diff --git a/pledge-now-pay-later/src/app/p/[token]/page.tsx b/pledge-now-pay-later/src/app/p/[token]/page.tsx index 64bd471..99f5a1d 100644 --- a/pledge-now-pay-later/src/app/p/[token]/page.tsx +++ b/pledge-now-pay-later/src/app/p/[token]/page.tsx @@ -8,6 +8,7 @@ import { PaymentStep } from "./steps/payment-step" import { IdentityStep } from "./steps/identity-step" import { ConfirmationStep } from "./steps/confirmation-step" import { BankInstructionsStep } from "./steps/bank-instructions-step" +import { ExternalRedirectStep } from "./steps/external-redirect-step" import { CardPaymentStep } from "./steps/card-payment-step" import { DirectDebitStep } from "./steps/direct-debit-step" @@ -33,6 +34,9 @@ interface EventInfo { organizationName: string qrSourceId: string | null qrSourceLabel: string | null + paymentMode: "self" | "external" + externalUrl: string | null + externalPlatform: string | null } /* @@ -85,13 +89,21 @@ export default function PledgePage() { }).catch(() => {}) }, [token]) + const isExternal = eventInfo?.paymentMode === "external" && eventInfo?.externalUrl + // Step 0: Amount selected const handleAmountSelected = (amountPence: number) => { setPledgeData((d) => ({ ...d, amountPence })) - setStep(1) // → Schedule step + if (isExternal) { + // External events: amount → identity → redirect (skip schedule + payment method) + setPledgeData((d) => ({ ...d, amountPence, rail: "bank", scheduleMode: "now" })) + setStep(3) // → Identity + } else { + setStep(1) // → Schedule step + } } - // Step 1: Schedule selected + // Step 1: Schedule selected (self-payment events only) const handleScheduleSelected = (schedule: { mode: "now" | "date" | "installments" dueDate?: string @@ -110,16 +122,15 @@ export default function PledgePage() { setStep(2) // → Payment method selection } else { // Deferred or installments: skip payment method, go to identity - // Payment method will be chosen when the due date arrives - setPledgeData((d) => ({ ...d, rail: "bank" })) // default to bank for deferred + setPledgeData((d) => ({ ...d, rail: "bank" })) setStep(3) // → Identity } } - // Step 2: Payment method selected (only for "now" mode) + // Step 2: Payment method selected (only for "now" self-payment mode) const handleRailSelected = (rail: Rail) => { setPledgeData((d) => ({ ...d, rail })) - setStep(rail === "bank" ? 3 : rail === "card" ? 6 : 8) // identity or card/DD + setStep(rail === "bank" ? 3 : rail === "card" ? 6 : 8) } // Submit pledge (from identity step, or card/DD steps) @@ -142,7 +153,9 @@ export default function PledgePage() { setPledgeResult(result) // Where to go after pledge is created: - if (finalData.scheduleMode === "now" && finalData.rail === "bank") { + if (isExternal) { + setStep(7) // External redirect + } else if (finalData.scheduleMode === "now" && finalData.rail === "bank") { setStep(4) // Bank instructions } else { setStep(5) // Confirmation @@ -209,6 +222,7 @@ export default function PledgePage() { /> ), 6: , + 7: pledgeResult && , 8: , } @@ -220,7 +234,7 @@ export default function PledgePage() { return s - 1 } - const progressMap: Record = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 8: 60 } + const progressMap: Record = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 7: 100, 8: 60 } const progressPercent = progressMap[step] || 10 return ( diff --git a/pledge-now-pay-later/src/app/p/[token]/steps/external-redirect-step.tsx b/pledge-now-pay-later/src/app/p/[token]/steps/external-redirect-step.tsx new file mode 100644 index 0000000..5e9cb6d --- /dev/null +++ b/pledge-now-pay-later/src/app/p/[token]/steps/external-redirect-step.tsx @@ -0,0 +1,187 @@ +"use client" + +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Check, MessageCircle, Share2, Sparkles, ExternalLink, ArrowRight } from "lucide-react" + +const platformBranding: Record = { + launchgood: { name: "LaunchGood", color: "#00C389", icon: "🌙" }, + enthuse: { name: "Enthuse", color: "#6B4FBB", icon: "💜" }, + justgiving: { name: "JustGiving", color: "#AD29B6", icon: "💛" }, + gofundme: { name: "GoFundMe", color: "#00B964", icon: "💚" }, + other: { name: "Fundraising Page", color: "#3B82F6", icon: "🔗" }, +} + +interface Props { + pledge: { id: string; reference: string } + amount: number + eventName: string + externalUrl: string + externalPlatform?: string | null + donorPhone?: string +} + +export function ExternalRedirectStep({ pledge, amount, eventName, externalUrl, externalPlatform, donorPhone }: Props) { + const [clicked, setClicked] = useState(false) + const [whatsappSent, setWhatsappSent] = useState(false) + const platform = platformBranding[externalPlatform || "other"] || platformBranding.other + + // Send WhatsApp with link + useEffect(() => { + if (!donorPhone || whatsappSent) return + fetch("/api/whatsapp/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "receipt", + phone: donorPhone, + data: { + amountPounds: (amount / 100).toFixed(0), + eventName, + reference: pledge.reference, + rail: "external", + externalUrl, + }, + }), + }).then(() => setWhatsappSent(true)).catch(() => {}) + }, [donorPhone, whatsappSent, amount, eventName, pledge.reference, externalUrl]) + + const handleDonate = () => { + setClicked(true) + if (navigator.vibrate) navigator.vibrate([10, 50, 10]) + // Mark as initiated + fetch(`/api/pledges/${pledge.id}/mark-initiated`, { method: "POST" }).catch(() => {}) + // Open external URL + window.open(externalUrl, "_blank") + } + + if (clicked) { + return ( +
+
+
+
+ +
+
+ +

Jazak'Allah Khair!

+

+ Complete your £{(amount / 100).toFixed(0)} donation on {platform.name}. +

+ + {whatsappSent && ( +
+ Link sent to your WhatsApp ✓ +
+ )} + + + +
+ Pledge ref + {pledge.reference} +
+
+ Donate via + {platform.icon} {platform.name} +
+
+
+ + {/* Re-open link */} + + + {/* Share */} +
+
+ +

Know someone who'd donate too?

+
+
+ + +
+
+ +

+ We'll send you a gentle reminder if we don't see a donation come through. +

+
+ ) + } + + return ( +
+
+
+ {platform.icon} +
+

+ Donate £{(amount / 100).toFixed(0)} +

+

+ You'll be taken to {platform.name} to complete your donation +

+
+ + {whatsappSent && ( +
+ Donation link also sent to your WhatsApp +
+ )} + + {/* Preview card */} + +
+ +
+ Campaign + {eventName} +
+
+ Amount + £{(amount / 100).toFixed(0)} +
+
+ Platform + {platform.icon} {platform.name} +
+
+ + + {/* Big CTA */} + + +

+ Your pledge is recorded. We'll follow up to make sure it goes through 🤝 +

+
+ ) +} diff --git a/pledge-now-pay-later/src/lib/validators.ts b/pledge-now-pay-later/src/lib/validators.ts index 45f49f0..3ea5807 100644 --- a/pledge-now-pay-later/src/lib/validators.ts +++ b/pledge-now-pay-later/src/lib/validators.ts @@ -7,6 +7,9 @@ export const createEventSchema = z.object({ location: z.string().max(500).optional(), goalAmount: z.number().int().positive().optional(), // pence currency: z.string().default('GBP'), + paymentMode: z.enum(['self', 'external']).default('self'), + externalUrl: z.string().url().max(1000).optional(), + externalPlatform: z.enum(['launchgood', 'enthuse', 'justgiving', 'gofundme', 'other']).optional(), }) export const createQrSourceSchema = z.object({