fundraiser mode: external platforms, role-aware onboarding, show-don't-gate
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
This commit is contained in:
20
pledge-now-pay-later/package-lock.json
generated
20
pledge-now-pay-later/package-lock.json
generated
@@ -173,7 +173,8 @@
|
|||||||
"version": "0.3.15",
|
"version": "0.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz",
|
||||||
"integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
|
"integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@electric-sql/pglite-socket": {
|
"node_modules/@electric-sql/pglite-socket": {
|
||||||
"version": "0.0.20",
|
"version": "0.0.20",
|
||||||
@@ -1150,6 +1151,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.2.tgz",
|
||||||
"integrity": "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA==",
|
"integrity": "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client-runtime-utils": "7.4.2"
|
"@prisma/client-runtime-utils": "7.4.2"
|
||||||
},
|
},
|
||||||
@@ -1427,6 +1429,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1487,6 +1490,7 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -2006,6 +2010,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3304,6 +3309,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -3473,6 +3479,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -4354,6 +4361,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
||||||
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
|
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -4947,6 +4955,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -5891,6 +5900,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.11.0",
|
"pg-connection-string": "^2.11.0",
|
||||||
"pg-pool": "^3.12.0",
|
"pg-pool": "^3.12.0",
|
||||||
@@ -6073,6 +6083,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -6292,6 +6303,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@@ -6331,6 +6343,7 @@
|
|||||||
"integrity": "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==",
|
"integrity": "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/config": "7.4.2",
|
"@prisma/config": "7.4.2",
|
||||||
"@prisma/dev": "0.20.0",
|
"@prisma/dev": "0.20.0",
|
||||||
@@ -6466,6 +6479,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -6478,6 +6492,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -7477,6 +7492,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -7542,6 +7558,7 @@
|
|||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -7666,6 +7683,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -11,6 +11,7 @@ model Organization {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
|
orgType String @default("charity") // charity | fundraiser
|
||||||
country String @default("UK")
|
country String @default("UK")
|
||||||
timezone String @default("Europe/London")
|
timezone String @default("Europe/London")
|
||||||
bankName String?
|
bankName String?
|
||||||
@@ -22,6 +23,7 @@ model Organization {
|
|||||||
primaryColor String @default("#1e40af")
|
primaryColor String @default("#1e40af")
|
||||||
gcAccessToken String?
|
gcAccessToken String?
|
||||||
gcEnvironment String @default("sandbox")
|
gcEnvironment String @default("sandbox")
|
||||||
|
whatsappConnected Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -57,6 +59,9 @@ model Event {
|
|||||||
goalAmount Int? // in pence
|
goalAmount Int? // in pence
|
||||||
currency String @default("GBP")
|
currency String @default("GBP")
|
||||||
status String @default("active") // draft, active, closed, archived
|
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
|
organizationId String
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ export async function GET(request: NextRequest) {
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
}) as EventRow[]
|
}) 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,
|
id: e.id,
|
||||||
name: e.name,
|
name: e.name,
|
||||||
slug: e.slug,
|
slug: e.slug,
|
||||||
@@ -51,6 +52,9 @@ export async function GET(request: NextRequest) {
|
|||||||
location: e.location,
|
location: e.location,
|
||||||
goalAmount: e.goalAmount,
|
goalAmount: e.goalAmount,
|
||||||
status: e.status,
|
status: e.status,
|
||||||
|
paymentMode: e.paymentMode || "self",
|
||||||
|
externalPlatform: e.externalPlatform || null,
|
||||||
|
externalUrl: e.externalUrl || null,
|
||||||
pledgeCount: e._count.pledges,
|
pledgeCount: e._count.pledges,
|
||||||
qrSourceCount: e._count.qrSources,
|
qrSourceCount: e._count.qrSources,
|
||||||
totalPledged: e.pledges.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0),
|
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({
|
const event = await prisma.event.create({
|
||||||
data: {
|
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),
|
slug: slug + "-" + Date.now().toString(36),
|
||||||
eventDate: parsed.data.eventDate ? new Date(parsed.data.eventDate) : null,
|
eventDate: parsed.data.eventDate ? new Date(parsed.data.eventDate) : null,
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
|
|||||||
@@ -1,38 +1,95 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import prisma from "@/lib/prisma"
|
import prisma from "@/lib/prisma"
|
||||||
import { getOrgId } from "@/lib/session"
|
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() {
|
export async function GET() {
|
||||||
const orgId = await getOrgId(null)
|
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([
|
const [org, eventCount, qrCount, pledgeCount] = await Promise.all([
|
||||||
prisma.organization.findUnique({
|
prisma.organization.findUnique({
|
||||||
where: { id: orgId },
|
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.event.count({ where: { organizationId: orgId } }),
|
||||||
prisma.qrSource.count({ where: { event: { organizationId: orgId } } }),
|
prisma.qrSource.count({ where: { event: { organizationId: orgId } } }),
|
||||||
prisma.pledge.count({ where: { 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 hasBank = !!(org?.bankSortCode && org?.bankAccountNo)
|
||||||
const hasEvent = eventCount > 0
|
const hasEvent = eventCount > 0
|
||||||
const hasQr = qrCount > 0
|
const hasQr = qrCount > 0
|
||||||
const hasPledge = pledgeCount > 0
|
const hasPledge = pledgeCount > 0
|
||||||
|
const hasWhatsApp = org?.whatsappConnected || false
|
||||||
|
|
||||||
const steps = [
|
// 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: "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: "whatsapp", label: "Connect WhatsApp", desc: "Auto-send receipts & reminders to donors", done: hasWhatsApp, href: "/dashboard/settings", action: "whatsapp" },
|
||||||
{ id: "qr", label: "Generate a QR code", desc: "One per table or volunteer", done: hasQr, href: hasEvent ? "/dashboard/events" : "/dashboard/events" },
|
{ id: "event", label: "Create a fundraiser", desc: "Give your campaign a name & goal", done: hasEvent, href: "/dashboard/events" },
|
||||||
{ id: "pledge", label: "Get your first pledge", desc: "Share the link or scan the QR", done: hasPledge, href: "/dashboard/pledges" },
|
{ 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 completed = steps.filter(s => s.done).length
|
||||||
const allDone = completed === steps.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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export async function GET(
|
|||||||
organizationName: event.organization.name,
|
organizationName: event.organization.name,
|
||||||
qrSourceId: null,
|
qrSourceId: null,
|
||||||
qrSourceLabel: 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,
|
organizationName: qrSource.event.organization.name,
|
||||||
qrSourceId: qrSource.id,
|
qrSourceId: qrSource.id,
|
||||||
qrSourceLabel: qrSource.label,
|
qrSourceLabel: qrSource.label,
|
||||||
|
paymentMode: qrSource.event.paymentMode || "self",
|
||||||
|
externalUrl: qrSource.event.externalUrl || null,
|
||||||
|
externalPlatform: qrSource.event.externalPlatform || null,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("QR resolve error:", error)
|
console.error("QR resolve error:", error)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Textarea } from "@/components/ui/textarea"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { formatPence } from "@/lib/utils"
|
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"
|
import Link from "next/link"
|
||||||
|
|
||||||
interface EventSummary {
|
interface EventSummary {
|
||||||
@@ -24,6 +24,16 @@ interface EventSummary {
|
|||||||
qrSourceCount: number
|
qrSourceCount: number
|
||||||
totalPledged: number
|
totalPledged: number
|
||||||
totalCollected: number
|
totalCollected: number
|
||||||
|
paymentMode?: string
|
||||||
|
externalPlatform?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformNames: Record<string, string> = {
|
||||||
|
launchgood: "🌙 LaunchGood",
|
||||||
|
enthuse: "💜 Enthuse",
|
||||||
|
justgiving: "💛 JustGiving",
|
||||||
|
gofundme: "💚 GoFundMe",
|
||||||
|
other: "🔗 External",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
@@ -42,7 +52,17 @@ export default function EventsPage() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [form, setForm] = useState({ name: "", description: "", location: "", eventDate: "", goalAmount: "" })
|
const [orgType, setOrgType] = useState<string | null>(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 () => {
|
const handleCreate = async () => {
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
@@ -51,16 +71,21 @@ export default function EventsPage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
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,
|
goalAmount: form.goalAmount ? Math.round(parseFloat(form.goalAmount) * 100) : undefined,
|
||||||
eventDate: form.eventDate ? new Date(form.eventDate).toISOString() : 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) {
|
if (res.ok) {
|
||||||
const event = await res.json()
|
const event = await res.json()
|
||||||
setEvents((prev) => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
|
setEvents((prev) => [{ ...event, pledgeCount: 0, qrSourceCount: 0, totalPledged: 0, totalCollected: 0 }, ...prev])
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "" })
|
setForm({ name: "", description: "", location: "", eventDate: "", goalAmount: "", paymentMode: orgType === "fundraiser" ? "external" : "self", externalUrl: "", externalPlatform: "" })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// handle error
|
// handle error
|
||||||
@@ -106,10 +131,15 @@ export default function EventsPage() {
|
|||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{event.paymentMode === "external" && event.externalPlatform && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">{platformNames[event.externalPlatform] || "External"}</Badge>
|
||||||
|
)}
|
||||||
<Badge variant={event.status === "active" ? "success" : "secondary"}>
|
<Badge variant={event.status === "active" ? "success" : "secondary"}>
|
||||||
{event.status}
|
{event.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
@@ -211,11 +241,67 @@ export default function EventsPage() {
|
|||||||
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Payment mode toggle */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>How do donors pay?</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, paymentMode: "self" }))}
|
||||||
|
className={`rounded-xl border p-3 text-left text-xs transition-all ${form.paymentMode === "self" ? "border-trust-blue bg-trust-blue/5" : "border-gray-200"}`}
|
||||||
|
>
|
||||||
|
<span className="font-bold block">🏦 Bank transfer</span>
|
||||||
|
<span className="text-muted-foreground">We show our bank details</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm(f => ({ ...f, paymentMode: "external" }))}
|
||||||
|
className={`rounded-xl border p-3 text-left text-xs transition-all ${form.paymentMode === "external" ? "border-warm-amber bg-warm-amber/5" : "border-gray-200"}`}
|
||||||
|
>
|
||||||
|
<span className="font-bold block">🔗 External page</span>
|
||||||
|
<span className="text-muted-foreground">LaunchGood, Enthuse, etc.</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.paymentMode === "external" && (
|
||||||
|
<div className="space-y-3 rounded-xl border border-warm-amber/20 bg-warm-amber/5 p-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Fundraising page URL *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<ExternalLink className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="https://launchgood.com/my-campaign"
|
||||||
|
value={form.externalUrl}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, externalUrl: e.target.value }))}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Platform</Label>
|
||||||
|
<select
|
||||||
|
value={form.externalPlatform}
|
||||||
|
onChange={(e) => setForm(f => ({ ...f, externalPlatform: e.target.value }))}
|
||||||
|
className="w-full rounded-xl border border-gray-200 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Select platform...</option>
|
||||||
|
<option value="launchgood">🌙 LaunchGood</option>
|
||||||
|
<option value="enthuse">💜 Enthuse</option>
|
||||||
|
<option value="justgiving">💛 JustGiving</option>
|
||||||
|
<option value="gofundme">💚 GoFundMe</option>
|
||||||
|
<option value="other">🔗 Other / Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">
|
<Button variant="outline" onClick={() => setShowCreate(false)} className="flex-1">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate} disabled={!form.name || creating} className="flex-1">
|
<Button onClick={handleCreate} disabled={!form.name || creating || (form.paymentMode === "external" && !form.externalUrl)} className="flex-1">
|
||||||
{creating ? "Creating..." : "Create Event"}
|
{creating ? "Creating..." : "Create Event"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { formatPence } from "@/lib/utils"
|
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"
|
import Link from "next/link"
|
||||||
|
|
||||||
interface DashboardData {
|
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<string, typeof Clock> = { new: Clock, initiated: TrendingUp, paid: CheckCircle2, overdue: AlertTriangle }
|
const statusIcons: Record<string, typeof Clock> = { new: Clock, initiated: TrendingUp, paid: CheckCircle2, overdue: AlertTriangle }
|
||||||
const statusColors: Record<string, "secondary" | "warning" | "success" | "destructive"> = { new: "secondary", initiated: "warning", paid: "success", overdue: "destructive" }
|
const statusColors: Record<string, "secondary" | "warning" | "success" | "destructive"> = { new: "secondary", initiated: "warning", paid: "success", overdue: "destructive" }
|
||||||
|
|
||||||
|
// ─── Role Picker ────────────────────────────────────────────
|
||||||
|
function RolePicker({ onSelect }: { onSelect: (role: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect("charity")}
|
||||||
|
className="rounded-2xl border-2 border-gray-100 hover:border-trust-blue bg-white p-5 text-center space-y-2 transition-all hover:shadow-md group"
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-12 h-12 rounded-xl bg-trust-blue/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<Building2 className="h-6 w-6 text-trust-blue" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-gray-900">Charity / Mosque</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-tight">We collect donations directly via bank transfer</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect("fundraiser")}
|
||||||
|
className="rounded-2xl border-2 border-gray-100 hover:border-warm-amber bg-white p-5 text-center space-y-2 transition-all hover:shadow-md group"
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-12 h-12 rounded-xl bg-warm-amber/10 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<Heart className="h-6 w-6 text-warm-amber" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-gray-900">Personal Fundraiser</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-tight">I have a page on LaunchGood, Enthuse, JustGiving, etc.</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<div className="rounded-2xl border border-trust-blue/20 bg-gradient-to-r from-trust-blue/5 via-white to-warm-amber/5 p-5 space-y-4 relative">
|
||||||
|
{/* Dismiss X */}
|
||||||
|
<button onClick={onDismiss} className="absolute top-3 right-3 text-muted-foreground hover:text-foreground p-1">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-trust-blue to-blue-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-white text-lg">🤲</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-gray-900">
|
||||||
|
{isFirstTime ? "Welcome! What best describes you?" : `Getting started · ${ob.completed}/${ob.total}`}
|
||||||
|
</h2>
|
||||||
|
{!isFirstTime && (
|
||||||
|
<Progress value={(ob.completed / ob.total) * 100} className="h-1.5 mt-1.5 w-32" indicatorClassName="bg-gradient-to-r from-trust-blue to-success-green" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFirstTime && showRolePicker ? (
|
||||||
|
<RolePicker onSelect={(role) => { onSetRole(role); setShowRolePicker(false) }} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{ob.steps.map((step, i) => {
|
||||||
|
const isNext = !step.done && ob.steps.slice(0, i).every(s => s.done)
|
||||||
|
return (
|
||||||
|
<Link key={step.id} href={step.href}>
|
||||||
|
<div className={`flex items-center gap-2.5 rounded-xl border px-3 py-2.5 transition-all ${
|
||||||
|
step.done ? "bg-success-green/5 border-success-green/20" :
|
||||||
|
isNext ? "bg-trust-blue/5 border-trust-blue/20 shadow-sm" :
|
||||||
|
"bg-white border-gray-100"
|
||||||
|
}`}>
|
||||||
|
{step.done ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-success-green flex-shrink-0" />
|
||||||
|
) : isNext ? (
|
||||||
|
<div className="h-4 w-4 rounded-full bg-trust-blue text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0">{i + 1}</div>
|
||||||
|
) : (
|
||||||
|
<Circle className="h-4 w-4 text-gray-300 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`text-xs font-medium truncate ${step.done ? "text-success-green line-through" : isNext ? "text-gray-900" : "text-gray-400"}`}>{step.label}</p>
|
||||||
|
</div>
|
||||||
|
{isNext && <ArrowRight className="h-3 w-3 text-trust-blue flex-shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Dashboard ─────────────────────────────────────────
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [data, setData] = useState<DashboardData | null>(null)
|
const [data, setData] = useState<DashboardData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [whatsappStatus, setWhatsappStatus] = useState<boolean | null>(null)
|
const [whatsappStatus, setWhatsappStatus] = useState<boolean | null>(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<OnboardingData | null>(null)
|
||||||
|
const [bannerDismissed, setBannerDismissed] = useState(false)
|
||||||
|
|
||||||
const fetchData = useCallback(() => {
|
const fetchData = useCallback(() => {
|
||||||
fetch("/api/dashboard")
|
fetch("/api/dashboard")
|
||||||
@@ -44,11 +158,23 @@ export default function DashboardPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
fetch("/api/whatsapp/send").then(r => r.json()).then(d => setWhatsappStatus(d.connected)).catch(() => {})
|
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)
|
const interval = setInterval(fetchData, 15000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [fetchData])
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
@@ -57,78 +183,31 @@ export default function DashboardPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || (data.summary.totalPledges === 0 && onboarding && !onboarding.allDone)) {
|
const s = data?.summary || { totalPledges: 0, totalPledgedPence: 0, totalCollectedPence: 0, collectionRate: 0, overdueRate: 0 }
|
||||||
// Show getting-started checklist
|
const byStatus = data?.byStatus || {}
|
||||||
const ob = onboarding
|
const topSources = data?.topSources || []
|
||||||
return (
|
const pledges = data?.pledges || []
|
||||||
<div className="max-w-lg mx-auto space-y-6 py-4">
|
const upcomingPledges = pledges.filter(p =>
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<div className="inline-flex h-14 w-14 rounded-2xl bg-gradient-to-br from-trust-blue to-blue-600 items-center justify-center shadow-lg shadow-trust-blue/20">
|
|
||||||
<span className="text-white text-2xl">🤲</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-black text-gray-900">Let's get you set up</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">4 quick steps, then you're collecting pledges</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ob && (
|
|
||||||
<>
|
|
||||||
<Progress value={(ob.completed / ob.total) * 100} className="h-2" indicatorClassName="bg-gradient-to-r from-trust-blue to-success-green" />
|
|
||||||
<p className="text-xs text-center text-muted-foreground">{ob.completed} of {ob.total} done</p>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{ob.steps.map((step, i) => {
|
|
||||||
const isNext = !step.done && ob.steps.slice(0, i).every(s => s.done)
|
|
||||||
return (
|
|
||||||
<Link key={step.id} href={step.href}>
|
|
||||||
<div className={`flex items-center gap-3 rounded-xl border p-4 transition-all ${
|
|
||||||
step.done ? "bg-success-green/5 border-success-green/20" :
|
|
||||||
isNext ? "bg-trust-blue/5 border-trust-blue/20 shadow-sm" :
|
|
||||||
"bg-white border-gray-100"
|
|
||||||
}`}>
|
|
||||||
{step.done ? (
|
|
||||||
<CheckCircle2 className="h-5 w-5 text-success-green flex-shrink-0" />
|
|
||||||
) : isNext ? (
|
|
||||||
<div className="h-5 w-5 rounded-full bg-trust-blue text-white text-xs font-bold flex items-center justify-center flex-shrink-0">{i + 1}</div>
|
|
||||||
) : (
|
|
||||||
<Circle className="h-5 w-5 text-gray-300 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className={`text-sm font-medium ${step.done ? "text-success-green line-through" : isNext ? "text-gray-900" : "text-gray-400"}`}>{step.label}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{step.desc}</p>
|
|
||||||
</div>
|
|
||||||
{isNext && <ArrowRight className="h-4 w-4 text-trust-blue flex-shrink-0" />}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(!ob || ob.completed === 0) && (
|
|
||||||
<div className="bg-warm-amber/5 rounded-xl border border-warm-amber/20 p-4 text-center">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
💡 <strong>Tip:</strong> Add your bank details first — that's the only thing you need before donors can pledge.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = data.summary
|
|
||||||
const upcomingPledges = data.pledges.filter(p =>
|
|
||||||
p.isDeferred && p.dueDate && p.status !== "paid" && p.status !== "cancelled"
|
p.isDeferred && p.dueDate && p.status !== "paid" && p.status !== "cancelled"
|
||||||
).sort((a, b) => new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime())
|
).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 recentPledges = pledges.filter(p => p.status !== "cancelled").slice(0, 8)
|
||||||
const overduePledges = data.pledges.filter(p => p.status === "overdue")
|
const needsAction = [
|
||||||
const needsAction = [...overduePledges, ...upcomingPledges.filter(p => {
|
...pledges.filter(p => p.status === "overdue"),
|
||||||
|
...upcomingPledges.filter(p => {
|
||||||
const due = new Date(p.dueDate!)
|
const due = new Date(p.dueDate!)
|
||||||
return due.getTime() - Date.now() < 2 * 86400000 // due in 2 days
|
return due.getTime() - Date.now() < 2 * 86400000
|
||||||
})].slice(0, 5)
|
})
|
||||||
|
].slice(0, 5)
|
||||||
|
|
||||||
|
const isEmpty = s.totalPledges === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Getting-started banner — always at top, not a blocker */}
|
||||||
|
{ob && !ob.allDone && (
|
||||||
|
<GettingStartedBanner ob={ob} onSetRole={handleSetRole} dismissed={bannerDismissed} onDismiss={() => setBannerDismissed(true)} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-black text-gray-900">Dashboard</h1>
|
<h1 className="text-2xl font-black text-gray-900">Dashboard</h1>
|
||||||
@@ -139,17 +218,19 @@ export default function DashboardPage() {
|
|||||||
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
|
{whatsappStatus ? "WhatsApp connected" : "WhatsApp offline"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
Auto-refreshes every 15s
|
{isEmpty ? "Your data will appear here" : "Auto-refreshes every 15s"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!isEmpty && (
|
||||||
<Link href="/dashboard/pledges">
|
<Link href="/dashboard/pledges">
|
||||||
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
|
<Button variant="outline" size="sm">View All Pledges <ArrowRight className="h-3 w-3 ml-1" /></Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats — always show, even with zeros */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<Card>
|
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||||
<CardContent className="pt-5 pb-4">
|
<CardContent className="pt-5 pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="rounded-xl bg-trust-blue/10 p-2.5"><Users className="h-5 w-5 text-trust-blue" /></div>
|
<div className="rounded-xl bg-trust-blue/10 p-2.5"><Users className="h-5 w-5 text-trust-blue" /></div>
|
||||||
@@ -160,7 +241,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||||
<CardContent className="pt-5 pb-4">
|
<CardContent className="pt-5 pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="rounded-xl bg-warm-amber/10 p-2.5"><Banknote className="h-5 w-5 text-warm-amber" /></div>
|
<div className="rounded-xl bg-warm-amber/10 p-2.5"><Banknote className="h-5 w-5 text-warm-amber" /></div>
|
||||||
@@ -171,7 +252,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||||
<CardContent className="pt-5 pb-4">
|
<CardContent className="pt-5 pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="rounded-xl bg-success-green/10 p-2.5"><TrendingUp className="h-5 w-5 text-success-green" /></div>
|
<div className="rounded-xl bg-success-green/10 p-2.5"><TrendingUp className="h-5 w-5 text-success-green" /></div>
|
||||||
@@ -182,12 +263,12 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className={s.overdueRate > 10 ? "border-danger-red/30" : ""}>
|
<Card className={isEmpty ? "opacity-60" : s.overdueRate > 10 ? "border-danger-red/30" : ""}>
|
||||||
<CardContent className="pt-5 pb-4">
|
<CardContent className="pt-5 pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="rounded-xl bg-danger-red/10 p-2.5"><AlertTriangle className="h-5 w-5 text-danger-red" /></div>
|
<div className="rounded-xl bg-danger-red/10 p-2.5"><AlertTriangle className="h-5 w-5 text-danger-red" /></div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-black">{data.byStatus.overdue || 0}</p>
|
<p className="text-2xl font-black">{byStatus.overdue || 0}</p>
|
||||||
<p className="text-xs text-muted-foreground">Overdue</p>
|
<p className="text-xs text-muted-foreground">Overdue</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,8 +276,8 @@ export default function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collection progress */}
|
{/* Collection progress — always visible */}
|
||||||
<Card>
|
<Card className={isEmpty ? "opacity-60" : ""}>
|
||||||
<CardContent className="pt-5 pb-4">
|
<CardContent className="pt-5 pb-4">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-sm font-medium">Pledged → Collected</span>
|
<span className="text-sm font-medium">Pledged → Collected</span>
|
||||||
@@ -210,6 +291,24 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
|
/* Empty state — gentle nudge, not a blocker */
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-10 text-center space-y-3">
|
||||||
|
<Calendar className="h-10 w-10 text-muted-foreground/40 mx-auto" />
|
||||||
|
<h3 className="text-sm font-bold text-gray-900">Your pledge data will appear here</h3>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
|
||||||
|
Once you share your first link and donors start pledging, you'll see live stats, payment tracking, and reminders.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-center pt-2">
|
||||||
|
<Link href="/dashboard/events">
|
||||||
|
<Button size="sm">Create a Fundraiser →</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="grid lg:grid-cols-2 gap-4">
|
<div className="grid lg:grid-cols-2 gap-4">
|
||||||
{/* Needs attention */}
|
{/* Needs attention */}
|
||||||
<Card className={needsAction.length > 0 ? "border-warm-amber/30" : ""}>
|
<Card className={needsAction.length > 0 ? "border-warm-amber/30" : ""}>
|
||||||
@@ -288,7 +387,7 @@ export default function DashboardPage() {
|
|||||||
<CardTitle className="text-base">Pipeline by Status</CardTitle>
|
<CardTitle className="text-base">Pipeline by Status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{Object.entries(data.byStatus).map(([status, count]) => {
|
{Object.entries(byStatus).map(([status, count]) => {
|
||||||
const Icon = statusIcons[status] || Clock
|
const Icon = statusIcons[status] || Clock
|
||||||
return (
|
return (
|
||||||
<div key={status} className="flex items-center justify-between">
|
<div key={status} className="flex items-center justify-between">
|
||||||
@@ -308,10 +407,10 @@ export default function DashboardPage() {
|
|||||||
<CardTitle className="text-base">Top Sources</CardTitle>
|
<CardTitle className="text-base">Top Sources</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{data.topSources.length === 0 ? (
|
{topSources.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">Create QR codes to track sources</p>
|
<p className="text-sm text-muted-foreground py-4 text-center">Create QR codes to track sources</p>
|
||||||
) : (
|
) : (
|
||||||
data.topSources.slice(0, 6).map((src, i) => (
|
topSources.slice(0, 6).map((src, i) => (
|
||||||
<div key={i} className="flex items-center justify-between">
|
<div key={i} className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-bold text-muted-foreground w-5">{i + 1}</span>
|
<span className="text-xs font-bold text-muted-foreground w-5">{i + 1}</span>
|
||||||
@@ -363,6 +462,8 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PaymentStep } from "./steps/payment-step"
|
|||||||
import { IdentityStep } from "./steps/identity-step"
|
import { IdentityStep } from "./steps/identity-step"
|
||||||
import { ConfirmationStep } from "./steps/confirmation-step"
|
import { ConfirmationStep } from "./steps/confirmation-step"
|
||||||
import { BankInstructionsStep } from "./steps/bank-instructions-step"
|
import { BankInstructionsStep } from "./steps/bank-instructions-step"
|
||||||
|
import { ExternalRedirectStep } from "./steps/external-redirect-step"
|
||||||
import { CardPaymentStep } from "./steps/card-payment-step"
|
import { CardPaymentStep } from "./steps/card-payment-step"
|
||||||
import { DirectDebitStep } from "./steps/direct-debit-step"
|
import { DirectDebitStep } from "./steps/direct-debit-step"
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ interface EventInfo {
|
|||||||
organizationName: string
|
organizationName: string
|
||||||
qrSourceId: string | null
|
qrSourceId: string | null
|
||||||
qrSourceLabel: string | null
|
qrSourceLabel: string | null
|
||||||
|
paymentMode: "self" | "external"
|
||||||
|
externalUrl: string | null
|
||||||
|
externalPlatform: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -85,13 +89,21 @@ export default function PledgePage() {
|
|||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [token])
|
}, [token])
|
||||||
|
|
||||||
|
const isExternal = eventInfo?.paymentMode === "external" && eventInfo?.externalUrl
|
||||||
|
|
||||||
// Step 0: Amount selected
|
// Step 0: Amount selected
|
||||||
const handleAmountSelected = (amountPence: number) => {
|
const handleAmountSelected = (amountPence: number) => {
|
||||||
setPledgeData((d) => ({ ...d, amountPence }))
|
setPledgeData((d) => ({ ...d, amountPence }))
|
||||||
|
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
|
setStep(1) // → Schedule step
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: Schedule selected
|
// Step 1: Schedule selected (self-payment events only)
|
||||||
const handleScheduleSelected = (schedule: {
|
const handleScheduleSelected = (schedule: {
|
||||||
mode: "now" | "date" | "installments"
|
mode: "now" | "date" | "installments"
|
||||||
dueDate?: string
|
dueDate?: string
|
||||||
@@ -110,16 +122,15 @@ export default function PledgePage() {
|
|||||||
setStep(2) // → Payment method selection
|
setStep(2) // → Payment method selection
|
||||||
} else {
|
} else {
|
||||||
// Deferred or installments: skip payment method, go to identity
|
// Deferred or installments: skip payment method, go to identity
|
||||||
// Payment method will be chosen when the due date arrives
|
setPledgeData((d) => ({ ...d, rail: "bank" }))
|
||||||
setPledgeData((d) => ({ ...d, rail: "bank" })) // default to bank for deferred
|
|
||||||
setStep(3) // → Identity
|
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) => {
|
const handleRailSelected = (rail: Rail) => {
|
||||||
setPledgeData((d) => ({ ...d, 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)
|
// Submit pledge (from identity step, or card/DD steps)
|
||||||
@@ -142,7 +153,9 @@ export default function PledgePage() {
|
|||||||
setPledgeResult(result)
|
setPledgeResult(result)
|
||||||
|
|
||||||
// Where to go after pledge is created:
|
// 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
|
setStep(4) // Bank instructions
|
||||||
} else {
|
} else {
|
||||||
setStep(5) // Confirmation
|
setStep(5) // Confirmation
|
||||||
@@ -209,6 +222,7 @@ export default function PledgePage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
6: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
6: <CardPaymentStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
||||||
|
7: pledgeResult && <ExternalRedirectStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} externalUrl={eventInfo?.externalUrl || ""} externalPlatform={eventInfo?.externalPlatform} donorPhone={pledgeData.donorPhone} />,
|
||||||
8: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
8: <DirectDebitStep amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} organizationName={eventInfo?.organizationName || ""} eventId={eventInfo?.id || ""} qrSourceId={eventInfo?.qrSourceId || null} onComplete={submitPledge} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +234,7 @@ export default function PledgePage() {
|
|||||||
return s - 1
|
return s - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 8: 60 }
|
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 6: 60, 7: 100, 8: 60 }
|
||||||
const progressPercent = progressMap[step] || 10
|
const progressPercent = progressMap[step] || 10
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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<string, { name: string; color: string; icon: string }> = {
|
||||||
|
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 (
|
||||||
|
<div className="max-w-md mx-auto pt-8 text-center space-y-6 animate-fade-up">
|
||||||
|
<div className="relative inline-flex items-center justify-center">
|
||||||
|
<div className="absolute w-20 h-20 rounded-full bg-success-green/20 animate-pulse-ring" />
|
||||||
|
<div className="relative w-20 h-20 rounded-full bg-gradient-to-br from-success-green to-emerald-500 flex items-center justify-center shadow-xl shadow-success-green/30">
|
||||||
|
<Check className="h-10 w-10 text-white" strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-black text-gray-900">Jazak'Allah Khair!</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Complete your <span className="font-bold text-foreground">£{(amount / 100).toFixed(0)}</span> donation on {platform.name}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{whatsappSent && (
|
||||||
|
<div className="rounded-xl bg-[#25D366]/10 border border-[#25D366]/20 p-3 text-sm text-[#25D366] font-medium flex items-center justify-center gap-2 animate-fade-in">
|
||||||
|
<MessageCircle className="h-4 w-4" /> Link sent to your WhatsApp ✓
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="text-left">
|
||||||
|
<CardContent className="pt-4 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Pledge ref</span>
|
||||||
|
<span className="font-mono font-bold text-trust-blue">{pledge.reference}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Donate via</span>
|
||||||
|
<span>{platform.icon} {platform.name}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Re-open link */}
|
||||||
|
<Button onClick={() => window.open(externalUrl, "_blank")} className="w-full" variant="outline">
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" /> Open {platform.name} again
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Share */}
|
||||||
|
<div className="rounded-2xl bg-gradient-to-br from-warm-amber/5 to-orange-50 border border-warm-amber/20 p-5 space-y-3">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-warm-amber" />
|
||||||
|
<p className="text-sm font-bold text-gray-900">Know someone who'd donate too?</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const text = `I just pledged £${(amount / 100).toFixed(0)} to ${eventName}! 🤲\nDonate here: ${externalUrl}`
|
||||||
|
window.open(`https://wa.me/?text=${encodeURIComponent(text)}`, "_blank")
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-[#25D366] hover:bg-[#20BD5A] text-white shadow-lg shadow-[#25D366]/25"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-4 w-4 mr-1" /> WhatsApp
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigator.share?.({ title: eventName, text: `Donate to ${eventName}`, url: externalUrl })}
|
||||||
|
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">
|
||||||
|
We'll send you a gentle reminder if we don't see a donation come through.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto pt-2 space-y-5 animate-fade-up">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl shadow-lg" style={{ background: platform.color + "20" }}>
|
||||||
|
<span className="text-2xl">{platform.icon}</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-black text-gray-900 tracking-tight">
|
||||||
|
Donate £{(amount / 100).toFixed(0)}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You'll be taken to {platform.name} to complete your donation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{whatsappSent && (
|
||||||
|
<div className="rounded-xl bg-[#25D366]/10 border border-[#25D366]/20 p-2.5 text-xs text-[#25D366] font-medium flex items-center justify-center gap-2 animate-fade-in">
|
||||||
|
<MessageCircle className="h-3.5 w-3.5" /> Donation link also sent to your WhatsApp
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview card */}
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="h-1" style={{ background: platform.color }} />
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Campaign</span>
|
||||||
|
<span className="font-medium">{eventName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Amount</span>
|
||||||
|
<span className="font-bold text-xl">£{(amount / 100).toFixed(0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Platform</span>
|
||||||
|
<span className="flex items-center gap-1">{platform.icon} {platform.name}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Big CTA */}
|
||||||
|
<Button
|
||||||
|
size="xl"
|
||||||
|
className="w-full text-white shadow-lg"
|
||||||
|
style={{ background: platform.color }}
|
||||||
|
onClick={handleDonate}
|
||||||
|
>
|
||||||
|
Donate on {platform.name} <ArrowRight className="h-5 w-5 ml-2" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
Your pledge is recorded. We'll follow up to make sure it goes through 🤝
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ export const createEventSchema = z.object({
|
|||||||
location: z.string().max(500).optional(),
|
location: z.string().max(500).optional(),
|
||||||
goalAmount: z.number().int().positive().optional(), // pence
|
goalAmount: z.number().int().positive().optional(), // pence
|
||||||
currency: z.string().default('GBP'),
|
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({
|
export const createQrSourceSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user