Stripe integration: charity connects their own Stripe account
Model: PNPL never touches the money. Each charity connects their own Stripe account by pasting their API key in Settings. When a donor chooses card payment, they're redirected to Stripe Checkout. The money lands in the charity's Stripe balance. ## Schema - Organization.stripeSecretKey (new column) - Organization.stripeWebhookSecret (new column) ## New/rewritten files - src/lib/stripe.ts — getStripeForOrg(secretKey), per-org client - src/app/api/stripe/checkout/route.ts — uses org's key, not env var - src/app/api/stripe/webhook/route.ts — tries all org webhook secrets - src/app/p/[token]/steps/card-payment-step.tsx — redirect to Stripe Checkout (no fake card form — Stripe handles PCI) ## Settings page - New 'Card payments' section between Bank and Charity - Instructions: how to get your Stripe API key - Webhook setup in collapsed <details> (optional, for auto-confirm) - 'Card payments live' green banner when connected - Readiness bar shows Stripe status (5 columns now) ## Pledge flow - PaymentStep shows card option ONLY if org has Stripe configured - hasStripe flag passed from /api/qr/[token] → PaymentStep - Secret key never exposed to frontend (only boolean hasStripe) ## How it works 1. Charity pastes sk_live_... in Settings → Save 2. Donor opens pledge link → sees 'Bank Transfer', 'Direct Debit', 'Card' 3. Donor picks card → enters name + email → redirects to Stripe Checkout 4. Stripe processes payment → money in charity's Stripe balance 5. (Optional) Webhook auto-confirms pledge as paid Payment options: - Bank Transfer: zero fees (default, always available) - Direct Debit via GoCardless: 1% + 20p (if org configured) - Card via Stripe: standard Stripe fees (if org configured)
This commit is contained in:
28
pledge-now-pay-later/package-lock.json
generated
28
pledge-now-pay-later/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
|
"@stripe/stripe-js": "^8.9.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
"stripe": "^20.4.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -1792,6 +1794,15 @@
|
|||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stripe/stripe-js": {
|
||||||
|
"version": "8.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.9.0.tgz",
|
||||||
|
"integrity": "sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/counter": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
@@ -7781,6 +7792,23 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stripe": {
|
||||||
|
"version": "20.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz",
|
||||||
|
"integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/styled-jsx": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
|
"@stripe/stripe-js": "^8.9.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
"stripe": "^20.4.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ model Organization {
|
|||||||
primaryColor String @default("#1e40af")
|
primaryColor String @default("#1e40af")
|
||||||
gcAccessToken String?
|
gcAccessToken String?
|
||||||
gcEnvironment String @default("sandbox")
|
gcEnvironment String @default("sandbox")
|
||||||
|
stripeSecretKey String?
|
||||||
|
stripeWebhookSecret String?
|
||||||
whatsappConnected Boolean @default(false)
|
whatsappConnected Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function GET(
|
|||||||
if (token === "demo") {
|
if (token === "demo") {
|
||||||
const event = await prisma.event.findFirst({
|
const event = await prisma.event.findFirst({
|
||||||
where: { status: "active" },
|
where: { status: "active" },
|
||||||
include: { organization: { select: { name: true } } },
|
include: { organization: { select: { name: true, stripeSecretKey: true } } },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
})
|
})
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -32,7 +32,7 @@ export async function GET(
|
|||||||
externalUrl: event.externalUrl || null,
|
externalUrl: event.externalUrl || null,
|
||||||
externalPlatform: event.externalPlatform || null,
|
externalPlatform: event.externalPlatform || null,
|
||||||
zakatEligible: event.zakatEligible || false,
|
zakatEligible: event.zakatEligible || false,
|
||||||
|
hasStripe: !!event.organization.stripeSecretKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export async function GET(
|
|||||||
include: {
|
include: {
|
||||||
event: {
|
event: {
|
||||||
include: {
|
include: {
|
||||||
organization: { select: { name: true } },
|
organization: { select: { name: true, stripeSecretKey: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -67,7 +67,7 @@ export async function GET(
|
|||||||
externalUrl: qrSource.event.externalUrl || null,
|
externalUrl: qrSource.event.externalUrl || null,
|
||||||
externalPlatform: qrSource.event.externalPlatform || null,
|
externalPlatform: qrSource.event.externalPlatform || null,
|
||||||
zakatEligible: qrSource.event.zakatEligible || false,
|
zakatEligible: qrSource.event.zakatEligible || false,
|
||||||
|
hasStripe: !!qrSource.event.organization.stripeSecretKey,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("QR resolve error:", error)
|
console.error("QR resolve error:", error)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export async function GET(request: NextRequest) {
|
|||||||
primaryColor: org.primaryColor,
|
primaryColor: org.primaryColor,
|
||||||
gcAccessToken: org.gcAccessToken ? "••••••••" : "",
|
gcAccessToken: org.gcAccessToken ? "••••••••" : "",
|
||||||
gcEnvironment: org.gcEnvironment,
|
gcEnvironment: org.gcEnvironment,
|
||||||
|
stripeSecretKey: org.stripeSecretKey ? "••••••••" : "",
|
||||||
|
stripeWebhookSecret: org.stripeWebhookSecret ? "••••••••" : "",
|
||||||
orgType: org.orgType || "charity",
|
orgType: org.orgType || "charity",
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -85,7 +87,7 @@ export async function PATCH(request: NextRequest) {
|
|||||||
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment"]
|
const stringKeys = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment", "stripeSecretKey", "stripeWebhookSecret"]
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const data: Record<string, any> = {}
|
const data: Record<string, any> = {}
|
||||||
for (const key of stringKeys) {
|
for (const key of stringKeys) {
|
||||||
|
|||||||
129
pledge-now-pay-later/src/app/api/stripe/checkout/route.ts
Normal file
129
pledge-now-pay-later/src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import prisma from "@/lib/prisma"
|
||||||
|
import { createCheckoutSession } from "@/lib/stripe"
|
||||||
|
import { generateReference } from "@/lib/reference"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stripe/checkout
|
||||||
|
*
|
||||||
|
* Creates a Stripe Checkout Session using the ORG'S own Stripe account.
|
||||||
|
* The money goes directly to the charity's Stripe balance.
|
||||||
|
* PNPL never touches the funds.
|
||||||
|
*
|
||||||
|
* If the org hasn't connected Stripe, returns { mode: "not_configured" }.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { amountPence, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = body
|
||||||
|
|
||||||
|
if (!prisma) {
|
||||||
|
return NextResponse.json({ error: "Database not configured" }, { status: 503 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get event + org (with Stripe key)
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { id: eventId },
|
||||||
|
include: { organization: true },
|
||||||
|
})
|
||||||
|
if (!event) {
|
||||||
|
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const org = event.organization
|
||||||
|
|
||||||
|
// Check if org has Stripe configured
|
||||||
|
if (!org.stripeSecretKey) {
|
||||||
|
return NextResponse.json({ mode: "not_configured" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate reference
|
||||||
|
let reference = ""
|
||||||
|
let attempts = 0
|
||||||
|
while (attempts < 10) {
|
||||||
|
reference = generateReference(org.refPrefix || "PNPL", amountPence)
|
||||||
|
const exists = await prisma.pledge.findUnique({ where: { reference } })
|
||||||
|
if (!exists) break
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pledge in DB
|
||||||
|
const pledge = await prisma.pledge.create({
|
||||||
|
data: {
|
||||||
|
reference,
|
||||||
|
amountPence,
|
||||||
|
currency: "GBP",
|
||||||
|
rail: "card",
|
||||||
|
status: "new",
|
||||||
|
donorName: donorName || null,
|
||||||
|
donorEmail: donorEmail || null,
|
||||||
|
donorPhone: donorPhone || null,
|
||||||
|
giftAid: giftAid || false,
|
||||||
|
eventId,
|
||||||
|
qrSourceId: qrSourceId || null,
|
||||||
|
organizationId: org.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Track analytics
|
||||||
|
await prisma.analyticsEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: "pledge_completed",
|
||||||
|
pledgeId: pledge.id,
|
||||||
|
eventId,
|
||||||
|
qrSourceId: qrSourceId || null,
|
||||||
|
metadata: { amountPence, rail: "card" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create Stripe Checkout Session using the org's key
|
||||||
|
const baseUrl = process.env.BASE_URL || "http://localhost:3000"
|
||||||
|
|
||||||
|
const session = await createCheckoutSession({
|
||||||
|
stripeSecretKey: org.stripeSecretKey,
|
||||||
|
amountPence,
|
||||||
|
currency: "GBP",
|
||||||
|
pledgeId: pledge.id,
|
||||||
|
reference,
|
||||||
|
eventName: event.name,
|
||||||
|
organizationName: org.name,
|
||||||
|
donorEmail: donorEmail || undefined,
|
||||||
|
successUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancelUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&cancelled=true`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
// Save Stripe session reference
|
||||||
|
await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
pledgeId: pledge.id,
|
||||||
|
provider: "stripe",
|
||||||
|
providerRef: session.sessionId,
|
||||||
|
amountPence,
|
||||||
|
status: "pending",
|
||||||
|
matchedBy: "auto",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.pledge.update({
|
||||||
|
where: { id: pledge.id },
|
||||||
|
data: { status: "initiated" },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
mode: "live",
|
||||||
|
pledgeId: pledge.id,
|
||||||
|
reference,
|
||||||
|
checkoutUrl: session.checkoutUrl,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stripe call failed — clean up the pledge
|
||||||
|
await prisma.pledge.delete({ where: { id: pledge.id } })
|
||||||
|
return NextResponse.json({ error: "Failed to create checkout session" }, { status: 500 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Stripe checkout error:", error)
|
||||||
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
118
pledge-now-pay-later/src/app/api/stripe/webhook/route.ts
Normal file
118
pledge-now-pay-later/src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import prisma from "@/lib/prisma"
|
||||||
|
import { constructWebhookEvent } from "@/lib/stripe"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stripe/webhook
|
||||||
|
*
|
||||||
|
* Handles Stripe webhooks. Each org has their own webhook secret.
|
||||||
|
* We look up the org by the pledge_id in the session metadata.
|
||||||
|
*
|
||||||
|
* The org configures their Stripe webhook endpoint to point here:
|
||||||
|
* https://pledge.quikcue.com/api/stripe/webhook
|
||||||
|
*
|
||||||
|
* Events handled:
|
||||||
|
* - checkout.session.completed → mark pledge as paid
|
||||||
|
* - checkout.session.expired → mark pledge as cancelled
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
if (!prisma) {
|
||||||
|
return NextResponse.json({ error: "DB not configured" }, { status: 503 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.text()
|
||||||
|
const signature = request.headers.get("stripe-signature") || ""
|
||||||
|
|
||||||
|
// We need to try all orgs' webhook secrets to find the right one
|
||||||
|
// In practice, most deployments have 1-5 orgs with Stripe configured
|
||||||
|
const orgsWithStripe = await prisma.organization.findMany({
|
||||||
|
where: { stripeWebhookSecret: { not: null } },
|
||||||
|
select: { id: true, stripeWebhookSecret: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
let event = null
|
||||||
|
let matchedOrgId: string | null = null
|
||||||
|
|
||||||
|
for (const org of orgsWithStripe) {
|
||||||
|
if (!org.stripeWebhookSecret) continue
|
||||||
|
const parsed = constructWebhookEvent(body, signature, org.stripeWebhookSecret)
|
||||||
|
if (parsed) {
|
||||||
|
event = parsed
|
||||||
|
matchedOrgId = org.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event || !matchedOrgId) {
|
||||||
|
return NextResponse.json({ error: "Webhook signature verification failed" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle checkout.session.completed
|
||||||
|
if (event.type === "checkout.session.completed") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const session = event.data.object as any
|
||||||
|
const pledgeId = session.metadata?.pledge_id
|
||||||
|
|
||||||
|
if (pledgeId) {
|
||||||
|
// Verify this pledge belongs to the matched org
|
||||||
|
const pledge = await prisma.pledge.findFirst({
|
||||||
|
where: { id: pledgeId, organizationId: matchedOrgId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pledge) {
|
||||||
|
await prisma.pledge.update({
|
||||||
|
where: { id: pledgeId },
|
||||||
|
data: { status: "paid", paidAt: new Date() },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update payment record
|
||||||
|
await prisma.payment.updateMany({
|
||||||
|
where: { pledgeId, provider: "stripe" },
|
||||||
|
data: { status: "completed" },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Skip remaining reminders
|
||||||
|
await prisma.reminder.updateMany({
|
||||||
|
where: { pledgeId, status: "pending" },
|
||||||
|
data: { status: "skipped" },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log
|
||||||
|
await prisma.analyticsEvent.create({
|
||||||
|
data: {
|
||||||
|
eventType: "activity.stripe_payment_confirmed",
|
||||||
|
pledgeId,
|
||||||
|
eventId: pledge.eventId,
|
||||||
|
metadata: { provider: "stripe", sessionId: session.id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle checkout.session.expired
|
||||||
|
if (event.type === "checkout.session.expired") {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const session = event.data.object as any
|
||||||
|
const pledgeId = session.metadata?.pledge_id
|
||||||
|
|
||||||
|
if (pledgeId) {
|
||||||
|
// Reset to "new" so they can try again
|
||||||
|
await prisma.pledge.updateMany({
|
||||||
|
where: { id: pledgeId, organizationId: matchedOrgId, status: "initiated" },
|
||||||
|
data: { status: "new" },
|
||||||
|
})
|
||||||
|
await prisma.payment.updateMany({
|
||||||
|
where: { pledgeId, provider: "stripe", status: "pending" },
|
||||||
|
data: { status: "expired" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Stripe webhook error:", error)
|
||||||
|
return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { useSession } from "next-auth/react"
|
|||||||
import {
|
import {
|
||||||
Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw,
|
Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw,
|
||||||
Smartphone, Wifi, WifiOff, QrCode, UserPlus, Trash2, Copy,
|
Smartphone, Wifi, WifiOff, QrCode, UserPlus, Trash2, Copy,
|
||||||
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight
|
Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight, Zap
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +29,7 @@ interface OrgSettings {
|
|||||||
name: string; bankName: string; bankSortCode: string; bankAccountNo: string
|
name: string; bankName: string; bankSortCode: string; bankAccountNo: string
|
||||||
bankAccountName: string; refPrefix: string; primaryColor: string
|
bankAccountName: string; refPrefix: string; primaryColor: string
|
||||||
gcAccessToken: string; gcEnvironment: string; orgType: string
|
gcAccessToken: string; gcEnvironment: string; orgType: string
|
||||||
|
stripeSecretKey: string; stripeWebhookSecret: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamMember {
|
interface TeamMember {
|
||||||
@@ -148,6 +149,7 @@ export default function SettingsPage() {
|
|||||||
const bankReady = !!(settings.bankSortCode && settings.bankAccountNo && settings.bankAccountName)
|
const bankReady = !!(settings.bankSortCode && settings.bankAccountNo && settings.bankAccountName)
|
||||||
const whatsappReady = waStatus === "CONNECTED"
|
const whatsappReady = waStatus === "CONNECTED"
|
||||||
const charityReady = !!settings.name
|
const charityReady = !!settings.name
|
||||||
|
const stripeReady = !!settings.stripeSecretKey
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -161,11 +163,12 @@ export default function SettingsPage() {
|
|||||||
{/* ── Readiness bar — "Am I set up?" ── */}
|
{/* ── Readiness bar — "Am I set up?" ── */}
|
||||||
<div className="bg-[#111827] p-5">
|
<div className="bg-[#111827] p-5">
|
||||||
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3">Setup progress</p>
|
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3">Setup progress</p>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-px bg-gray-700">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-px bg-gray-700">
|
||||||
{[
|
{[
|
||||||
{ label: "WhatsApp", ready: whatsappReady, detail: whatsappReady ? "Connected" : "Not connected" },
|
{ label: "WhatsApp", ready: whatsappReady, detail: whatsappReady ? "Connected" : "Not connected" },
|
||||||
{ label: "Bank details", ready: bankReady, detail: bankReady ? `${settings.bankSortCode}` : "Not set" },
|
{ label: "Bank details", ready: bankReady, detail: bankReady ? `${settings.bankSortCode}` : "Not set" },
|
||||||
{ label: "Charity name", ready: charityReady, detail: charityReady ? settings.name : "Not set" },
|
{ label: "Charity name", ready: charityReady, detail: charityReady ? settings.name : "Not set" },
|
||||||
|
{ label: "Card payments", ready: stripeReady, detail: stripeReady ? "Stripe connected" : "Not set up" },
|
||||||
{ label: "Team", ready: team.length > 0, detail: `${team.length} member${team.length !== 1 ? "s" : ""}` },
|
{ label: "Team", ready: team.length > 0, detail: `${team.length} member${team.length !== 1 ? "s" : ""}` },
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<div key={item.label} className="bg-[#111827] p-3">
|
<div key={item.label} className="bg-[#111827] p-3">
|
||||||
@@ -429,7 +432,95 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── 4. Charity details ── */}
|
{/* ── 4. Card payments (Stripe) ── */}
|
||||||
|
<section className="bg-white border border-gray-200">
|
||||||
|
<div className="border-b border-gray-100 px-5 py-4 flex items-center gap-3">
|
||||||
|
<div className={`w-8 h-8 flex items-center justify-center shrink-0 ${stripeReady ? "bg-[#635BFF]/10" : "bg-gray-100"}`}>
|
||||||
|
<CreditCard className={`h-4 w-4 ${stripeReady ? "text-[#635BFF]" : "text-gray-400"}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-sm font-bold text-[#111827]">Card payments</h2>
|
||||||
|
{stripeReady && <div className="w-2 h-2 bg-[#635BFF]" />}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-500">Let donors pay by card using your Stripe account</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div className="border-l-2 border-[#635BFF] pl-3 text-xs text-gray-500 space-y-1">
|
||||||
|
<p>Connect your <strong className="text-gray-700">own Stripe account</strong> to accept card payments. Money goes directly to your Stripe balance — we never touch it.</p>
|
||||||
|
<p>When connected, donors see a third payment option: <strong className="text-gray-700">Bank Transfer</strong>, <strong className="text-gray-700">Direct Debit</strong>, and <strong className="text-gray-700">Card Payment</strong>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!stripeReady && (
|
||||||
|
<div className="bg-[#F9FAFB] border border-gray-100 p-4 space-y-3">
|
||||||
|
<p className="text-xs font-bold text-[#111827]">How to get your Stripe API key</p>
|
||||||
|
<ol className="text-[10px] text-gray-500 space-y-1.5 list-decimal list-inside">
|
||||||
|
<li>Go to <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noopener noreferrer" className="text-[#635BFF] font-bold hover:underline">dashboard.stripe.com/apikeys</a></li>
|
||||||
|
<li>Copy your <strong className="text-gray-700">Secret key</strong> (starts with <code className="bg-gray-100 px-1 py-0.5 text-[9px] font-mono">sk_live_</code> or <code className="bg-gray-100 px-1 py-0.5 text-[9px] font-mono">sk_test_</code>)</li>
|
||||||
|
<li>Paste it below</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Stripe secret key"
|
||||||
|
value={settings.stripeSecretKey || ""}
|
||||||
|
onChange={v => update("stripeSecretKey", v)}
|
||||||
|
placeholder="sk_live_... or sk_test_..."
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="text-gray-400 cursor-pointer hover:text-gray-600 font-bold">Webhook setup (optional — for auto-confirmation)</summary>
|
||||||
|
<div className="mt-3 space-y-3 pt-3 border-t border-gray-100">
|
||||||
|
<div className="border-l-2 border-gray-200 pl-3 text-[10px] text-gray-500 space-y-1">
|
||||||
|
<p>If you want pledges to auto-confirm when the card is charged:</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-0.5">
|
||||||
|
<li>In Stripe Dashboard → Developers → Webhooks</li>
|
||||||
|
<li>Add endpoint: <code className="bg-gray-100 px-1 py-0.5 font-mono">{typeof window !== "undefined" ? window.location.origin : ""}/api/stripe/webhook</code></li>
|
||||||
|
<li>Select events: <code className="bg-gray-100 px-1 py-0.5 font-mono">checkout.session.completed</code></li>
|
||||||
|
<li>Copy the signing secret and paste below</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
label="Webhook signing secret"
|
||||||
|
value={settings.stripeWebhookSecret || ""}
|
||||||
|
onChange={v => update("stripeWebhookSecret", v)}
|
||||||
|
placeholder="whsec_..."
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-400">Without webhooks, you confirm card payments manually in the Money page (same as bank transfers).</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{stripeReady && (
|
||||||
|
<div className="bg-[#635BFF]/5 border border-[#635BFF]/20 p-3 flex items-start gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-[#635BFF] mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-[#635BFF]">Card payments are live</p>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5">Donors will see “Pay by Card” as an option. They'll be redirected to Stripe's checkout page. Apple Pay and Google Pay work automatically.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<p className="text-[10px] text-gray-400">{stripeReady ? "Key stored securely · never shown to donors" : "Optional — bank transfer works without this"}</p>
|
||||||
|
<SaveBtn
|
||||||
|
section="stripe"
|
||||||
|
saving={saving}
|
||||||
|
saved={saved}
|
||||||
|
onSave={() => save("stripe", {
|
||||||
|
stripeSecretKey: settings.stripeSecretKey || "",
|
||||||
|
stripeWebhookSecret: settings.stripeWebhookSecret || "",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 5. Charity details ── */}
|
||||||
<section className="bg-white border border-gray-200">
|
<section className="bg-white border border-gray-200">
|
||||||
<div className="border-b border-gray-100 px-5 py-4 flex items-center gap-3">
|
<div className="border-b border-gray-100 px-5 py-4 flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-gray-100 flex items-center justify-center shrink-0">
|
<div className="w-8 h-8 bg-gray-100 flex items-center justify-center shrink-0">
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ 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 { ExternalRedirectStep } from "./steps/external-redirect-step"
|
||||||
|
import { CardPaymentStep } from "./steps/card-payment-step"
|
||||||
import { DirectDebitStep } from "./steps/direct-debit-step"
|
import { DirectDebitStep } from "./steps/direct-debit-step"
|
||||||
|
|
||||||
export type Rail = "bank" | "gocardless"
|
export type Rail = "bank" | "gocardless" | "card"
|
||||||
|
|
||||||
export interface PledgeData {
|
export interface PledgeData {
|
||||||
amountPence: number
|
amountPence: number
|
||||||
@@ -44,6 +45,7 @@ interface EventInfo {
|
|||||||
externalUrl: string | null
|
externalUrl: string | null
|
||||||
externalPlatform: string | null
|
externalPlatform: string | null
|
||||||
zakatEligible: boolean
|
zakatEligible: boolean
|
||||||
|
hasStripe: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -142,7 +144,7 @@ export default function PledgePage() {
|
|||||||
// Step 2: Payment method selected (only for "now" self-payment 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 : 8)
|
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)
|
||||||
@@ -225,7 +227,7 @@ export default function PledgePage() {
|
|||||||
const steps: Record<number, React.ReactNode> = {
|
const steps: Record<number, React.ReactNode> = {
|
||||||
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} />,
|
0: <AmountStep onSelect={handleAmountSelected} eventName={eventInfo?.name || ""} eventId={eventInfo?.id} />,
|
||||||
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
|
1: <ScheduleStep amount={pledgeData.amountPence} onSelect={handleScheduleSelected} />,
|
||||||
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} />,
|
2: <PaymentStep onSelect={handleRailSelected} amount={pledgeData.amountPence} hasStripe={eventInfo?.hasStripe ?? false} />,
|
||||||
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} orgName={eventInfo?.organizationName} />,
|
3: <IdentityStep onSubmit={submitPledge} amount={pledgeData.amountPence} zakatEligible={eventInfo?.zakatEligible} orgName={eventInfo?.organizationName} />,
|
||||||
4: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
|
4: pledgeResult && <BankInstructionsStep pledge={pledgeResult} amount={pledgeData.amountPence} eventName={eventInfo?.name || ""} donorPhone={pledgeData.donorPhone} />,
|
||||||
5: pledgeResult && (
|
5: pledgeResult && (
|
||||||
@@ -242,19 +244,20 @@ export default function PledgePage() {
|
|||||||
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
|
installmentAmount={pledgeData.installmentCount ? Math.ceil(pledgeData.amountPence / pledgeData.installmentCount) : undefined}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
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} />,
|
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} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const backableSteps = new Set([1, 2, 3, 6, 8])
|
const backableSteps = new Set([1, 2, 3, 6, 8])
|
||||||
const getBackStep = (s: number): number => {
|
const getBackStep = (s: number): number => {
|
||||||
if (s === 8) return 2 // DD → payment method
|
if (s === 6 || s === 8) return 2 // card/DD → payment method
|
||||||
if (s === 3 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule
|
if (s === 3 && pledgeData.scheduleMode !== "now") return 1 // deferred identity → schedule
|
||||||
if (s === 3) return 2 // bank identity → payment method
|
if (s === 3) return 2 // bank identity → payment method
|
||||||
return s - 1
|
return s - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressMap: Record<number, number> = { 0: 8, 1: 25, 2: 40, 3: 60, 4: 100, 5: 100, 7: 100, 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,165 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { CreditCard, Lock, Loader2, ExternalLink } from "lucide-react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card Payment Step
|
||||||
|
*
|
||||||
|
* Redirects to the charity's own Stripe Checkout.
|
||||||
|
* No card form on our side — Stripe handles PCI compliance.
|
||||||
|
* The money goes directly to the charity's Stripe account.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Collect donor name + email (for receipt)
|
||||||
|
* 2. POST /api/stripe/checkout → get checkoutUrl
|
||||||
|
* 3. Redirect to Stripe Checkout
|
||||||
|
* 4. Stripe redirects back to /p/success
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
amount: number
|
||||||
|
eventName: string
|
||||||
|
eventId: string
|
||||||
|
qrSourceId: string | null
|
||||||
|
onComplete: (identity: {
|
||||||
|
donorName: string
|
||||||
|
donorEmail: string
|
||||||
|
donorPhone: string
|
||||||
|
giftAid: boolean
|
||||||
|
}) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardPaymentStep({ amount, eventName, eventId, qrSourceId }: Props) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [giftAid, setGiftAid] = useState(false)
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const pounds = (amount / 100).toFixed(2)
|
||||||
|
const isReady = name.trim().length > 1 && email.includes("@")
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!isReady) return
|
||||||
|
setProcessing(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stripe/checkout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
amountPence: amount,
|
||||||
|
donorName: name.trim(),
|
||||||
|
donorEmail: email.trim(),
|
||||||
|
donorPhone: "",
|
||||||
|
giftAid,
|
||||||
|
eventId,
|
||||||
|
qrSourceId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.mode === "live" && data.checkoutUrl) {
|
||||||
|
// Redirect to Stripe Checkout (the charity's Stripe account)
|
||||||
|
window.location.href = data.checkoutUrl
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.mode === "not_configured") {
|
||||||
|
setError("This charity hasn't set up card payments yet. Please use bank transfer instead.")
|
||||||
|
setProcessing(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError("Something went wrong creating the payment. Please try bank transfer instead.")
|
||||||
|
setProcessing(false)
|
||||||
|
} catch {
|
||||||
|
setError("Connection error. Please try again.")
|
||||||
|
setProcessing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto pt-2 space-y-6 animate-fade-up">
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h1 className="text-2xl font-black text-gray-900 tracking-tight">
|
||||||
|
Pay by card
|
||||||
|
</h1>
|
||||||
|
<p className="text-base text-muted-foreground">
|
||||||
|
£{pounds} for <span className="font-semibold text-foreground">{eventName}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-2 border-gray-200 bg-white p-5 space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-1.5">Your name</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Full name"
|
||||||
|
autoComplete="name"
|
||||||
|
className="w-full h-11 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-1.5">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
inputMode="email"
|
||||||
|
className="w-full h-11 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1">For your payment receipt</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gift Aid */}
|
||||||
|
<label className="flex items-start gap-3 border-2 border-gray-200 p-3 cursor-pointer hover:border-[#1E40AF]/30 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={giftAid}
|
||||||
|
onChange={e => setGiftAid(e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 border-gray-300"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-bold text-[#111827]">Add Gift Aid</span>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5">Boost your donation by 25% at no cost to you. You must be a UK taxpayer.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="border-l-2 border-[#DC2626] bg-[#DC2626]/5 p-3 text-sm text-[#DC2626]">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pay button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isReady || processing}
|
||||||
|
className="w-full bg-[#111827] px-4 py-3.5 text-sm font-bold text-white hover:bg-gray-800 disabled:opacity-40 transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{processing ? (
|
||||||
|
<><Loader2 className="h-4 w-4 animate-spin" /> Redirecting to Stripe…</>
|
||||||
|
) : (
|
||||||
|
<><CreditCard className="h-4 w-4" /> Pay £{pounds} by card</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-4 text-[10px] text-gray-400">
|
||||||
|
<span className="flex items-center gap-1"><Lock className="h-3 w-3" /> Secure checkout</span>
|
||||||
|
<span className="flex items-center gap-1"><ExternalLink className="h-3 w-3" /> Powered by Stripe</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-l-2 border-gray-200 pl-3 text-[10px] text-gray-400">
|
||||||
|
<p>You'll be redirected to Stripe's secure checkout page. The payment goes directly to <strong className="text-gray-600">{eventName}</strong>'s account. Visa, Mastercard, Amex, Apple Pay, and Google Pay accepted.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
|||||||
const railLabels: Record<string, string> = {
|
const railLabels: Record<string, string> = {
|
||||||
bank: "Bank Transfer",
|
bank: "Bank Transfer",
|
||||||
gocardless: "Direct Debit",
|
gocardless: "Direct Debit",
|
||||||
|
card: "Card Payment",
|
||||||
}
|
}
|
||||||
|
|
||||||
const deferredMessage = isDeferred
|
const deferredMessage = isDeferred
|
||||||
@@ -72,6 +73,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
|||||||
const nextStepMessages: Record<string, string> = {
|
const nextStepMessages: Record<string, string> = {
|
||||||
bank: deferredMessage || "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
|
bank: deferredMessage || "We've sent you payment instructions. Transfer at your convenience — we'll confirm once received.",
|
||||||
gocardless: `Your Direct Debit mandate is set up. £${(amount / 100).toFixed(2)} will be collected automatically in 3-5 working days. Protected by the Direct Debit Guarantee.`,
|
gocardless: `Your Direct Debit mandate is set up. £${(amount / 100).toFixed(2)} will be collected automatically in 3-5 working days. Protected by the Direct Debit Guarantee.`,
|
||||||
|
card: "Your card payment has been processed. You'll receive a confirmation email from Stripe shortly.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send WhatsApp receipt if phone provided
|
// Send WhatsApp receipt if phone provided
|
||||||
@@ -138,7 +140,7 @@ export function ConfirmationStep({ pledge, amount, rail, eventName, shareUrl, do
|
|||||||
<h1 className="text-2xl font-black text-gray-900">
|
<h1 className="text-2xl font-black text-gray-900">
|
||||||
{isDeferred
|
{isDeferred
|
||||||
? "Pledge Locked In!"
|
? "Pledge Locked In!"
|
||||||
: rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
|
: rail === "card" ? "Payment Complete!" : rail === "gocardless" ? "Mandate Set Up!" : "Pledge Received!"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Thank you for your generous support of{" "}
|
Thank you for your generous support of{" "}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Building2, Landmark, Shield, CheckCircle2 } from "lucide-react"
|
import { Building2, CreditCard, Landmark, Shield, CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSelect: (rail: "bank" | "gocardless") => void
|
onSelect: (rail: "bank" | "gocardless" | "card") => void
|
||||||
amount: number
|
amount: number
|
||||||
|
hasStripe?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentStep({ onSelect, amount }: Props) {
|
export function PaymentStep({ onSelect, amount, hasStripe }: Props) {
|
||||||
const pounds = (amount / 100).toFixed(0)
|
const pounds = (amount / 100).toFixed(0)
|
||||||
const giftAidTotal = ((amount + amount * 0.25) / 100).toFixed(0)
|
const giftAidTotal = ((amount + amount * 0.25) / 100).toFixed(0)
|
||||||
|
|
||||||
@@ -40,6 +41,20 @@ export function PaymentStep({ onSelect, amount }: Props) {
|
|||||||
highlight: false,
|
highlight: false,
|
||||||
benefits: ["No action needed", "DD Guarantee"],
|
benefits: ["No action needed", "DD Guarantee"],
|
||||||
},
|
},
|
||||||
|
...(hasStripe ? [{
|
||||||
|
id: "card" as const,
|
||||||
|
icon: CreditCard,
|
||||||
|
title: "Card Payment",
|
||||||
|
subtitle: "Visa, Mastercard, Amex, Apple Pay",
|
||||||
|
tag: "Instant",
|
||||||
|
tagClass: "bg-purple-100 text-purple-700",
|
||||||
|
detail: "Secure checkout via Stripe. Receipt emailed instantly.",
|
||||||
|
fee: "Card fees apply",
|
||||||
|
feeClass: "text-muted-foreground",
|
||||||
|
iconBg: "bg-midnight",
|
||||||
|
highlight: false,
|
||||||
|
benefits: ["Instant confirmation", "Apple Pay & Google Pay"],
|
||||||
|
}] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -74,13 +74,15 @@ function SuccessContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const railLabels: Record<string, string> = {
|
const railLabels: Record<string, string> = {
|
||||||
gocardless: "Direct Debit",
|
|
||||||
bank: "Bank Transfer",
|
bank: "Bank Transfer",
|
||||||
|
gocardless: "Direct Debit",
|
||||||
|
card: "Card Payment",
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextStepMessages: Record<string, string> = {
|
const nextStepMessages: Record<string, string> = {
|
||||||
gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.",
|
|
||||||
bank: "Please complete the bank transfer using the reference provided.",
|
bank: "Please complete the bank transfer using the reference provided.",
|
||||||
|
gocardless: "Your Direct Debit mandate has been set up. The payment will be collected automatically in 3-5 working days.",
|
||||||
|
card: "Your card payment has been processed. You'll receive a confirmation email from Stripe shortly.",
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -663,6 +663,13 @@ export default function HomePage() {
|
|||||||
desc: "UK-native charity platform. Gift Aid handled at their end or ours — your choice.",
|
desc: "UK-native charity platform. Gift Aid handled at their end or ours — your choice.",
|
||||||
tag: "Fundraising",
|
tag: "Fundraising",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Stripe",
|
||||||
|
logo: "/images/logos/stripe.svg",
|
||||||
|
color: "#635BFF",
|
||||||
|
desc: "Connect your own Stripe account. Donors pay by card, Apple Pay, or Google Pay. Money goes to you.",
|
||||||
|
tag: "Card payments",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "UK Bank Transfer",
|
name: "UK Bank Transfer",
|
||||||
logo: null,
|
logo: null,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export default function PrivacyPage() {
|
|||||||
<h2 className="text-xl font-black text-gray-900">10. Third-Party Services</h2>
|
<h2 className="text-xl font-black text-gray-900">10. Third-Party Services</h2>
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
<li><strong>GoCardless</strong> — for Direct Debit mandate processing (if enabled by charity)</li>
|
<li><strong>GoCardless</strong> — for Direct Debit mandate processing (if enabled by charity)</li>
|
||||||
|
<li><strong>Stripe</strong> — for card payment processing (if enabled by charity — the charity connects their own Stripe account)</li>
|
||||||
|
|
||||||
<li><strong>OpenAI</strong> — for AI-powered features (amount suggestions, reminder copy). No donor PII is sent to OpenAI — only anonymised context.</li>
|
<li><strong>OpenAI</strong> — for AI-powered features (amount suggestions, reminder copy). No donor PII is sent to OpenAI — only anonymised context.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function TermsPage() {
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-black text-gray-900">5. Payment Processing</h2>
|
<h2 className="text-xl font-black text-gray-900">5. Payment Processing</h2>
|
||||||
<p>PNPL is not a payment processor. We facilitate pledge tracking and follow-up. Actual payment flows through your bank account or GoCardless. We are not liable for payment disputes, chargebacks, or failed transactions.</p>
|
<p>PNPL is not a payment processor. We facilitate pledge tracking and follow-up. Actual payment flows through your bank account, GoCardless, or your own Stripe account. We are not liable for payment disputes, chargebacks, or failed transactions.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
100
pledge-now-pay-later/src/lib/stripe.ts
Normal file
100
pledge-now-pay-later/src/lib/stripe.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import Stripe from "stripe"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe integration — per-org.
|
||||||
|
*
|
||||||
|
* Each charity connects their OWN Stripe account.
|
||||||
|
* PNPL never touches the money. The charity's Stripe processes the payment,
|
||||||
|
* the money lands in the charity's Stripe balance.
|
||||||
|
*
|
||||||
|
* The secret key is stored in Organization.stripeSecretKey.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe client using the org's own secret key.
|
||||||
|
* Returns null if no key is configured.
|
||||||
|
*/
|
||||||
|
export function getStripeForOrg(secretKey: string | null | undefined): Stripe | null {
|
||||||
|
if (!secretKey || secretKey.trim() === "") return null
|
||||||
|
|
||||||
|
return new Stripe(secretKey, {
|
||||||
|
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
|
||||||
|
typescript: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Checkout Session using the org's Stripe account.
|
||||||
|
* Returns the checkout URL to redirect the donor to.
|
||||||
|
*/
|
||||||
|
export async function createCheckoutSession(opts: {
|
||||||
|
stripeSecretKey: string
|
||||||
|
amountPence: number
|
||||||
|
currency: string
|
||||||
|
pledgeId: string
|
||||||
|
reference: string
|
||||||
|
eventName: string
|
||||||
|
organizationName: string
|
||||||
|
donorEmail?: string
|
||||||
|
successUrl: string
|
||||||
|
cancelUrl: string
|
||||||
|
}): Promise<{ sessionId: string; checkoutUrl: string } | null> {
|
||||||
|
const stripe = getStripeForOrg(opts.stripeSecretKey)
|
||||||
|
if (!stripe) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: "payment",
|
||||||
|
payment_method_types: ["card"],
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price_data: {
|
||||||
|
currency: opts.currency.toLowerCase(),
|
||||||
|
unit_amount: opts.amountPence,
|
||||||
|
product_data: {
|
||||||
|
name: `Donation — ${opts.eventName}`,
|
||||||
|
description: `Ref: ${opts.reference} to ${opts.organizationName}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customer_email: opts.donorEmail || undefined,
|
||||||
|
metadata: {
|
||||||
|
pledge_id: opts.pledgeId,
|
||||||
|
reference: opts.reference,
|
||||||
|
},
|
||||||
|
success_url: opts.successUrl,
|
||||||
|
cancel_url: opts.cancelUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
checkoutUrl: session.url!,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Stripe checkout error:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a Stripe webhook signature using the org's webhook secret.
|
||||||
|
*/
|
||||||
|
export function constructWebhookEvent(
|
||||||
|
body: string | Buffer,
|
||||||
|
signature: string,
|
||||||
|
webhookSecret: string
|
||||||
|
): Stripe.Event | null {
|
||||||
|
// We need a Stripe instance just for webhook verification — use a dummy key
|
||||||
|
// The webhook secret is what matters for signature verification
|
||||||
|
try {
|
||||||
|
const stripe = new Stripe("sk_dummy_for_webhook_verify", {
|
||||||
|
apiVersion: "2025-01-27.acacia" as Stripe.LatestApiVersion,
|
||||||
|
})
|
||||||
|
return stripe.webhooks.constructEvent(body, signature, webhookSecret)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Stripe webhook signature verification failed:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,9 +27,6 @@ use Filament\Tables\Actions\ActionGroup;
|
|||||||
use Filament\Tables\Actions\BulkAction;
|
use Filament\Tables\Actions\BulkAction;
|
||||||
use Filament\Tables\Actions\EditAction;
|
use Filament\Tables\Actions\EditAction;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Enums\FiltersLayout;
|
|
||||||
use Filament\Tables\Filters\Filter;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -310,47 +307,7 @@ class AppealResource extends Resource
|
|||||||
->sortable()
|
->sortable()
|
||||||
->description(fn (Appeal $a) => $a->created_at?->format('d M Y')),
|
->description(fn (Appeal $a) => $a->created_at?->format('d M Y')),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([])
|
||||||
SelectFilter::make('nurture_segment')
|
|
||||||
->label('Nurture Segment')
|
|
||||||
->options([
|
|
||||||
'needs_outreach' => '🔴 Needs Outreach (£0 raised, 7+ days)',
|
|
||||||
'almost_there' => '🟡 Almost There (80%+ of target)',
|
|
||||||
'target_reached' => '🟢 Target Reached',
|
|
||||||
'slowing' => '🟠 Slowing Down (raised something, 30+ days)',
|
|
||||||
'new_this_week' => '🆕 New This Week',
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data) {
|
|
||||||
if (!$data['value']) return;
|
|
||||||
$query->where('status', 'confirmed')->where('is_accepting_donations', true);
|
|
||||||
|
|
||||||
match ($data['value']) {
|
|
||||||
'needs_outreach' => $query->where('amount_raised', 0)->where('created_at', '<', now()->subDays(7)),
|
|
||||||
'almost_there' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised >= amount_to_raise * 0.8')->whereRaw('amount_raised < amount_to_raise'),
|
|
||||||
'target_reached' => $query->whereRaw('amount_raised >= amount_to_raise')->where('amount_raised', '>', 0),
|
|
||||||
'slowing' => $query->where('amount_raised', '>', 0)->whereRaw('amount_raised < amount_to_raise * 0.8')->where('created_at', '<', now()->subDays(30)),
|
|
||||||
'new_this_week' => $query->where('created_at', '>=', now()->subDays(7)),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
SelectFilter::make('status')
|
|
||||||
->options([
|
|
||||||
'confirmed' => 'Live',
|
|
||||||
'pending' => 'Pending Review',
|
|
||||||
]),
|
|
||||||
|
|
||||||
Filter::make('accepting_donations')
|
|
||||||
->label('Currently accepting donations')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->where('is_accepting_donations', true))
|
|
||||||
->default(),
|
|
||||||
|
|
||||||
Filter::make('has_raised')
|
|
||||||
->label('Has raised money')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->where('amount_raised', '>', 0)),
|
|
||||||
], layout: FiltersLayout::AboveContentCollapsible)
|
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_page')
|
Action::make('view_page')
|
||||||
->label('View Page')
|
->label('View Page')
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ use Filament\Forms\Components\TextInput;
|
|||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
use Filament\GlobalSearch\Actions\Action as GlobalSearchAction;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Tables\Actions\Action;
|
|
||||||
use Filament\Tables\Actions\EditAction;
|
use Filament\Tables\Actions\EditAction;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\Filter;
|
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -207,56 +205,7 @@ class CustomerResource extends Resource
|
|||||||
->since()
|
->since()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([])
|
||||||
Filter::make('has_donations')
|
|
||||||
->label('Has donated')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->has('donations')),
|
|
||||||
|
|
||||||
Filter::make('monthly_supporter')
|
|
||||||
->label('Monthly supporter')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->whereHas(
|
|
||||||
'scheduledGivingDonations',
|
|
||||||
fn ($q2) => $q2->where('is_active', true)
|
|
||||||
)),
|
|
||||||
|
|
||||||
Filter::make('gift_aid')
|
|
||||||
->label('Gift Aid donors')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->whereHas(
|
|
||||||
'donations',
|
|
||||||
fn ($q2) => $q2->whereHas('donationPreferences', fn ($q3) => $q3->where('is_gift_aid', true))
|
|
||||||
)),
|
|
||||||
|
|
||||||
Filter::make('major_donor')
|
|
||||||
->label('Major donors (£1000+)')
|
|
||||||
->toggle()
|
|
||||||
->query(function (Builder $q) {
|
|
||||||
$q->whereIn('id', function ($sub) {
|
|
||||||
$sub->select('customer_id')
|
|
||||||
->from('donations')
|
|
||||||
->join('donation_confirmations', 'donations.id', '=', 'donation_confirmations.donation_id')
|
|
||||||
->whereNotNull('donation_confirmations.confirmed_at')
|
|
||||||
->groupBy('customer_id')
|
|
||||||
->havingRaw('SUM(donations.amount) >= 100000');
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
|
|
||||||
Filter::make('incomplete_donations')
|
|
||||||
->label('Has incomplete donations')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->whereHas(
|
|
||||||
'donations',
|
|
||||||
fn ($q2) => $q2->whereDoesntHave('donationConfirmation', fn ($q3) => $q3->whereNotNull('confirmed_at'))
|
|
||||||
->where('created_at', '>=', now()->subDays(30))
|
|
||||||
)),
|
|
||||||
|
|
||||||
Filter::make('recent')
|
|
||||||
->label('Joined last 30 days')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->where('created_at', '>=', now()->subDays(30))),
|
|
||||||
])
|
|
||||||
->actions([
|
->actions([
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->label('Open')
|
->label('Open')
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ use Filament\Tables\Actions\ExportAction;
|
|||||||
use Filament\Tables\Actions\ViewAction;
|
use Filament\Tables\Actions\ViewAction;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Enums\FiltersLayout;
|
|
||||||
use Filament\Tables\Filters\Filter;
|
use Filament\Tables\Filters\Filter;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
use Filament\Tables\Filters\TernaryFilter;
|
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -302,7 +300,7 @@ class DonationResource extends Resource
|
|||||||
->copyable()
|
->copyable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters(static::getTableFilters(), layout: FiltersLayout::AboveContentCollapsible)
|
->filters(static::getTableFilters())
|
||||||
->actions(static::getTableRowActions())
|
->actions(static::getTableRowActions())
|
||||||
->bulkActions(static::getBulkActions())
|
->bulkActions(static::getBulkActions())
|
||||||
->headerActions([
|
->headerActions([
|
||||||
@@ -431,27 +429,11 @@ class DonationResource extends Resource
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Filters ─────────────────────────────────────────────────
|
// ─── Filters (drill-down within tabs) ───────────────────────
|
||||||
// Designed around real questions:
|
|
||||||
// "Show me today's incomplete donations" (investigating failures)
|
|
||||||
// "Show me Zakat donations this month" (reporting)
|
|
||||||
// "Show me donations to a specific cause" (allocation check)
|
|
||||||
|
|
||||||
private static function getTableFilters(): array
|
private static function getTableFilters(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
TernaryFilter::make('confirmed')
|
|
||||||
->label('Payment Status')
|
|
||||||
->placeholder('All')
|
|
||||||
->trueLabel('Confirmed only')
|
|
||||||
->falseLabel('Incomplete only')
|
|
||||||
->queries(
|
|
||||||
true: fn (Builder $q) => $q->whereHas('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')),
|
|
||||||
false: fn (Builder $q) => $q->whereDoesntHave('donationConfirmation', fn ($q2) => $q2->whereNotNull('confirmed_at')),
|
|
||||||
blank: fn (Builder $q) => $q,
|
|
||||||
)
|
|
||||||
->default(true),
|
|
||||||
|
|
||||||
SelectFilter::make('donation_type_id')
|
SelectFilter::make('donation_type_id')
|
||||||
->label('Cause')
|
->label('Cause')
|
||||||
->options(fn () => DonationType::orderBy('display_name')->pluck('display_name', 'id'))
|
->options(fn () => DonationType::orderBy('display_name')->pluck('display_name', 'id'))
|
||||||
@@ -473,21 +455,6 @@ class DonationResource extends Resource
|
|||||||
->when($data['to'], fn (Builder $q) => $q->whereDate('created_at', '<=', $data['to']));
|
->when($data['to'], fn (Builder $q) => $q->whereDate('created_at', '<=', $data['to']));
|
||||||
})
|
})
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
|
||||||
Filter::make('is_zakat')
|
|
||||||
->label('Zakat')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_zakat', true))),
|
|
||||||
|
|
||||||
Filter::make('is_gift_aid')
|
|
||||||
->label('Gift Aid')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->whereHas('donationPreferences', fn ($q2) => $q2->where('is_gift_aid', true))),
|
|
||||||
|
|
||||||
Filter::make('has_fundraiser')
|
|
||||||
->label('Via Fundraiser')
|
|
||||||
->toggle()
|
|
||||||
->query(fn (Builder $q) => $q->whereNotNull('appeal_id')),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
temp_files/v4/AdminPanelProvider.php
Normal file
88
temp_files/v4/AdminPanelProvider.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
|
use App\Helpers;
|
||||||
|
use Filament\Http\Middleware\Authenticate;
|
||||||
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
|
use Filament\Navigation\MenuItem;
|
||||||
|
use Filament\Navigation\NavigationGroup;
|
||||||
|
use Filament\Navigation\NavigationItem;
|
||||||
|
use Filament\Panel;
|
||||||
|
use Filament\PanelProvider;
|
||||||
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||||
|
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||||
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
|
|
||||||
|
class AdminPanelProvider extends PanelProvider
|
||||||
|
{
|
||||||
|
public function panel(Panel $panel): Panel
|
||||||
|
{
|
||||||
|
return $panel
|
||||||
|
->default()
|
||||||
|
->id('admin')
|
||||||
|
->path('admin')
|
||||||
|
->login()
|
||||||
|
->colors(['primary' => config('branding.colours')])
|
||||||
|
->viteTheme('resources/css/filament/admin/theme.css')
|
||||||
|
->sidebarCollapsibleOnDesktop()
|
||||||
|
->sidebarWidth('16rem')
|
||||||
|
->globalSearch(true)
|
||||||
|
->globalSearchKeyBindings(['command+k', 'ctrl+k'])
|
||||||
|
->globalSearchDebounce('300ms')
|
||||||
|
->navigationGroups([
|
||||||
|
// ── Daily Work (always visible, top of sidebar) ──
|
||||||
|
NavigationGroup::make('Daily')
|
||||||
|
->collapsible(false),
|
||||||
|
|
||||||
|
// ── Fundraising (campaigns, review queue) ──
|
||||||
|
NavigationGroup::make('Fundraising')
|
||||||
|
->icon('heroicon-o-megaphone')
|
||||||
|
->collapsible(),
|
||||||
|
|
||||||
|
// ── Setup (rarely touched config) ──
|
||||||
|
NavigationGroup::make('Setup')
|
||||||
|
->icon('heroicon-o-cog-6-tooth')
|
||||||
|
->collapsible()
|
||||||
|
->collapsed(),
|
||||||
|
])
|
||||||
|
->brandLogo(Helpers::getCurrentLogo(true))
|
||||||
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||||
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
||||||
|
->pages([\Filament\Pages\Dashboard::class])
|
||||||
|
->userMenuItems([
|
||||||
|
'profile' => MenuItem::make()
|
||||||
|
->label('Edit profile')
|
||||||
|
->url(url('user/profile')),
|
||||||
|
|
||||||
|
'back2site' => MenuItem::make()
|
||||||
|
->label('Return to site')
|
||||||
|
->icon('heroicon-o-home')
|
||||||
|
->url(url('/')),
|
||||||
|
])
|
||||||
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||||
|
->middleware([
|
||||||
|
EncryptCookies::class,
|
||||||
|
AddQueuedCookiesToResponse::class,
|
||||||
|
StartSession::class,
|
||||||
|
AuthenticateSession::class,
|
||||||
|
ShareErrorsFromSession::class,
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
SubstituteBindings::class,
|
||||||
|
DisableBladeIconComponents::class,
|
||||||
|
DispatchServingFilamentEvent::class,
|
||||||
|
])
|
||||||
|
->authMiddleware([
|
||||||
|
Authenticate::class,
|
||||||
|
])
|
||||||
|
->login(null)
|
||||||
|
->registration(null)
|
||||||
|
->darkMode(false)
|
||||||
|
->databaseNotifications();
|
||||||
|
}
|
||||||
|
}
|
||||||
86
temp_files/v4/ListAppeals.php
Normal file
86
temp_files/v4/ListAppeals.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\AppealResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AppealResource;
|
||||||
|
use App\Models\Appeal;
|
||||||
|
use Filament\Resources\Components\Tab;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListAppeals extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = AppealResource::class;
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Fundraisers';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): string
|
||||||
|
{
|
||||||
|
$live = Appeal::where('status', 'confirmed')->where('is_accepting_donations', true)->count();
|
||||||
|
return "{$live} fundraisers are live right now.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$needsHelp = Appeal::where('status', 'confirmed')
|
||||||
|
->where('is_accepting_donations', true)
|
||||||
|
->where('amount_raised', 0)
|
||||||
|
->where('created_at', '<', now()->subDays(7))
|
||||||
|
->where('created_at', '>', now()->subDays(90))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$almostThere = Appeal::where('status', 'confirmed')
|
||||||
|
->where('is_accepting_donations', true)
|
||||||
|
->where('amount_raised', '>', 0)
|
||||||
|
->whereRaw('amount_raised >= amount_to_raise * 0.8')
|
||||||
|
->whereRaw('amount_raised < amount_to_raise')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'live' => Tab::make('Live')
|
||||||
|
->icon('heroicon-o-signal')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $q
|
||||||
|
->where('status', 'confirmed')
|
||||||
|
->where('is_accepting_donations', true)
|
||||||
|
),
|
||||||
|
|
||||||
|
'needs_help' => Tab::make('Needs Outreach')
|
||||||
|
->icon('heroicon-o-hand-raised')
|
||||||
|
->badge($needsHelp > 0 ? $needsHelp : null)
|
||||||
|
->badgeColor('danger')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $q
|
||||||
|
->where('status', 'confirmed')
|
||||||
|
->where('is_accepting_donations', true)
|
||||||
|
->where('amount_raised', 0)
|
||||||
|
->where('created_at', '<', now()->subDays(7))
|
||||||
|
->where('created_at', '>', now()->subDays(90))
|
||||||
|
),
|
||||||
|
|
||||||
|
'almost' => Tab::make('Almost There')
|
||||||
|
->icon('heroicon-o-fire')
|
||||||
|
->badge($almostThere > 0 ? $almostThere : null)
|
||||||
|
->badgeColor('warning')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $q
|
||||||
|
->where('status', 'confirmed')
|
||||||
|
->where('is_accepting_donations', true)
|
||||||
|
->where('amount_raised', '>', 0)
|
||||||
|
->whereRaw('amount_raised >= amount_to_raise * 0.8')
|
||||||
|
->whereRaw('amount_raised < amount_to_raise')
|
||||||
|
),
|
||||||
|
|
||||||
|
'hit_target' => Tab::make('Target Reached')
|
||||||
|
->icon('heroicon-o-trophy')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $q
|
||||||
|
->where('status', 'confirmed')
|
||||||
|
->where('amount_raised', '>', 0)
|
||||||
|
->whereRaw('amount_raised >= amount_to_raise')
|
||||||
|
),
|
||||||
|
|
||||||
|
'all' => Tab::make('Everything')
|
||||||
|
->icon('heroicon-o-squares-2x2'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
temp_files/v4/ListApprovalQueues.php
Normal file
50
temp_files/v4/ListApprovalQueues.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ApprovalQueueResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ApprovalQueueResource;
|
||||||
|
use App\Models\ApprovalQueue;
|
||||||
|
use Filament\Resources\Components\Tab;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListApprovalQueues extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ApprovalQueueResource::class;
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Fundraiser Review';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): string
|
||||||
|
{
|
||||||
|
$pending = ApprovalQueue::where('status', 'pending')->count();
|
||||||
|
if ($pending === 0) return 'All caught up — no fundraisers waiting for review.';
|
||||||
|
return "{$pending} fundraisers waiting for your review.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$pending = ApprovalQueue::where('status', 'pending')->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pending' => Tab::make('Needs Review')
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->badge($pending > 0 ? $pending : null)
|
||||||
|
->badgeColor('danger')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $q->where('status', 'pending')),
|
||||||
|
|
||||||
|
'approved' => Tab::make('Approved')
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $q->where('status', 'confirmed')),
|
||||||
|
|
||||||
|
'rejected' => Tab::make('Rejected')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $q->where('status', 'change_requested')),
|
||||||
|
|
||||||
|
'all' => Tab::make('All')
|
||||||
|
->icon('heroicon-o-squares-2x2'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
temp_files/v4/ListCustomers.php
Normal file
62
temp_files/v4/ListCustomers.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\CustomerResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\CustomerResource;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use Filament\Resources\Components\Tab;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListCustomers extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = CustomerResource::class;
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Donors';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): string
|
||||||
|
{
|
||||||
|
return 'Search by name, email, or phone number. Click a donor to see their full history.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'all' => Tab::make('All Donors')
|
||||||
|
->icon('heroicon-o-users'),
|
||||||
|
|
||||||
|
'monthly' => Tab::make('Monthly Supporters')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query
|
||||||
|
->whereHas('scheduledGivingDonations', fn ($q) => $q->where('is_active', true))
|
||||||
|
),
|
||||||
|
|
||||||
|
'major' => Tab::make('Major Donors')
|
||||||
|
->icon('heroicon-o-star')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query
|
||||||
|
->whereIn('id', function ($sub) {
|
||||||
|
$sub->select('customer_id')
|
||||||
|
->from('donations')
|
||||||
|
->join('donation_confirmations', 'donations.id', '=', 'donation_confirmations.donation_id')
|
||||||
|
->whereNotNull('donation_confirmations.confirmed_at')
|
||||||
|
->groupBy('customer_id')
|
||||||
|
->havingRaw('SUM(donations.amount) >= 100000');
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
|
'recent' => Tab::make('New (30 days)')
|
||||||
|
->icon('heroicon-o-sparkles')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query
|
||||||
|
->where('created_at', '>=', now()->subDays(30))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
79
temp_files/v4/ListDonations.php
Normal file
79
temp_files/v4/ListDonations.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DonationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\DonationResource;
|
||||||
|
use App\Models\Donation;
|
||||||
|
use Filament\Resources\Components\Tab;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListDonations extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = DonationResource::class;
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Donations';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): string
|
||||||
|
{
|
||||||
|
$todayCount = Donation::whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
->count();
|
||||||
|
$todayAmount = Donation::whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
->sum('amount') / 100;
|
||||||
|
|
||||||
|
return "Today: {$todayCount} confirmed (£" . number_format($todayAmount, 0) . ")";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$incompleteCount = Donation::whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->where('created_at', '>=', now()->subDays(7))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'today' => Tab::make('Today')
|
||||||
|
->icon('heroicon-o-clock')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
),
|
||||||
|
|
||||||
|
'all_confirmed' => Tab::make('All Confirmed')
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
),
|
||||||
|
|
||||||
|
'incomplete' => Tab::make('Incomplete')
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->badge($incompleteCount > 0 ? $incompleteCount : null)
|
||||||
|
->badgeColor('danger')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query
|
||||||
|
->whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->where('created_at', '>=', now()->subDays(7))
|
||||||
|
),
|
||||||
|
|
||||||
|
'zakat' => Tab::make('Zakat')
|
||||||
|
->icon('heroicon-o-star')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->whereHas('donationPreferences', fn ($q) => $q->where('is_zakat', true))
|
||||||
|
),
|
||||||
|
|
||||||
|
'gift_aid' => Tab::make('Gift Aid')
|
||||||
|
->icon('heroicon-o-gift')
|
||||||
|
->modifyQueryUsing(fn (Builder $query) => $query
|
||||||
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
||||||
|
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
|
||||||
|
),
|
||||||
|
|
||||||
|
'everything' => Tab::make('Everything')
|
||||||
|
->icon('heroicon-o-squares-2x2'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
53
temp_files/v4/ListScheduledGivingDonations.php
Normal file
53
temp_files/v4/ListScheduledGivingDonations.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\ScheduledGivingDonationResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\ScheduledGivingDonationResource;
|
||||||
|
use App\Models\ScheduledGivingDonation;
|
||||||
|
use Filament\Resources\Components\Tab;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListScheduledGivingDonations extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ScheduledGivingDonationResource::class;
|
||||||
|
|
||||||
|
public function getHeading(): string
|
||||||
|
{
|
||||||
|
return 'Regular Giving';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSubheading(): string
|
||||||
|
{
|
||||||
|
$active = ScheduledGivingDonation::where('is_active', true)->count();
|
||||||
|
return "{$active} people giving every month.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTabs(): array
|
||||||
|
{
|
||||||
|
$active = ScheduledGivingDonation::where('is_active', true)->count();
|
||||||
|
$inactive = ScheduledGivingDonation::where('is_active', false)->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'active' => Tab::make('Active')
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->badge($active)
|
||||||
|
->badgeColor('success')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $q->where('is_active', true)),
|
||||||
|
|
||||||
|
'cancelled' => Tab::make('Cancelled')
|
||||||
|
->icon('heroicon-o-x-circle')
|
||||||
|
->badge($inactive > 0 ? $inactive : null)
|
||||||
|
->badgeColor('gray')
|
||||||
|
->modifyQueryUsing(fn (Builder $q) => $q->where('is_active', false)),
|
||||||
|
|
||||||
|
'all' => Tab::make('All')
|
||||||
|
->icon('heroicon-o-squares-2x2'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
183
temp_files/v4/nav_changes.py
Normal file
183
temp_files/v4/nav_changes.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Reassign every Filament resource to the correct navigation group.
|
||||||
|
|
||||||
|
SIDEBAR STRUCTURE:
|
||||||
|
Daily (always open, no collapse)
|
||||||
|
├ Donors — "Find a donor, help them"
|
||||||
|
├ Donations — "See what came in, investigate issues"
|
||||||
|
└ Regular Giving — "Monthly supporters"
|
||||||
|
|
||||||
|
Fundraising
|
||||||
|
├ Review Queue (84 pending badge)
|
||||||
|
├ All Fundraisers
|
||||||
|
└ Scheduled Campaigns (3 only)
|
||||||
|
|
||||||
|
Setup (collapsed by default — rarely touched)
|
||||||
|
├ Causes (30)
|
||||||
|
├ Countries (12)
|
||||||
|
├ URL Builder
|
||||||
|
├ Settings
|
||||||
|
├ Users
|
||||||
|
├ Activity Log
|
||||||
|
└ (hidden): Snowdon, Campaign Regs, WOH, Engage dims, Fund dims
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re, os
|
||||||
|
|
||||||
|
BASE = '/home/forge/app.charityright.org.uk'
|
||||||
|
|
||||||
|
changes = {
|
||||||
|
# ── DAILY (top 3 — the only pages staff use every day) ──
|
||||||
|
'app/Filament/Resources/CustomerResource.php': {
|
||||||
|
'navigationGroup': 'Daily',
|
||||||
|
'navigationIcon': 'heroicon-o-user-circle',
|
||||||
|
'navigationSort': 1,
|
||||||
|
'navigationLabel': 'Donors',
|
||||||
|
},
|
||||||
|
'app/Filament/Resources/DonationResource.php': {
|
||||||
|
'navigationGroup': 'Daily',
|
||||||
|
'navigationIcon': 'heroicon-o-banknotes',
|
||||||
|
'navigationSort': 2,
|
||||||
|
'navigationLabel': 'Donations',
|
||||||
|
},
|
||||||
|
'app/Filament/Resources/ScheduledGivingDonationResource.php': {
|
||||||
|
'navigationGroup': 'Daily',
|
||||||
|
'navigationIcon': 'heroicon-o-arrow-path',
|
||||||
|
'navigationSort': 3,
|
||||||
|
'navigationLabel': 'Regular Giving',
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── FUNDRAISING ──
|
||||||
|
'app/Filament/Resources/ApprovalQueueResource.php': {
|
||||||
|
'navigationGroup': 'Fundraising',
|
||||||
|
'navigationIcon': 'heroicon-o-shield-check',
|
||||||
|
'navigationSort': 1,
|
||||||
|
'navigationLabel': 'Review Queue',
|
||||||
|
},
|
||||||
|
'app/Filament/Resources/AppealResource.php': {
|
||||||
|
'navigationGroup': 'Fundraising',
|
||||||
|
'navigationIcon': 'heroicon-o-hand-raised',
|
||||||
|
'navigationSort': 2,
|
||||||
|
'navigationLabel': 'All Fundraisers',
|
||||||
|
},
|
||||||
|
'app/Filament/Resources/ScheduledGivingCampaignResource.php': {
|
||||||
|
'navigationGroup': 'Fundraising',
|
||||||
|
'navigationIcon': 'heroicon-o-calendar',
|
||||||
|
'navigationSort': 3,
|
||||||
|
'navigationLabel': 'Giving Campaigns',
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── SETUP (collapsed, rarely used) ──
|
||||||
|
'app/Filament/Resources/DonationTypeResource.php': {
|
||||||
|
'navigationGroup': 'Setup',
|
||||||
|
'navigationIcon': 'heroicon-o-tag',
|
||||||
|
'navigationSort': 1,
|
||||||
|
'navigationLabel': 'Causes',
|
||||||
|
},
|
||||||
|
'app/Filament/Resources/DonationCountryResource.php': {
|
||||||
|
'navigationGroup': 'Setup',
|
||||||
|
'navigationIcon': 'heroicon-o-globe-alt',
|
||||||
|
'navigationSort': 2,
|
||||||
|
'navigationLabel': 'Countries',
|
||||||
|
},
|
||||||
|
'app/Filament/Resources/UserResource.php': {
|
||||||
|
'navigationGroup': 'Setup',
|
||||||
|
'navigationIcon': 'heroicon-o-users',
|
||||||
|
'navigationSort': 3,
|
||||||
|
'navigationLabel': 'Admin Users',
|
||||||
|
},
|
||||||
|
'app/Filament/Resources/EventLogResource.php': {
|
||||||
|
'navigationGroup': 'Setup',
|
||||||
|
'navigationIcon': 'heroicon-o-exclamation-triangle',
|
||||||
|
'navigationSort': 4,
|
||||||
|
'navigationLabel': 'Activity Log',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resources to HIDE from navigation entirely (dead data, 0 recent activity)
|
||||||
|
hide = [
|
||||||
|
'app/Filament/Resources/SnowdonRegistrationResource.php',
|
||||||
|
'app/Filament/Resources/CampaignRegistrationResource.php',
|
||||||
|
'app/Filament/Resources/WOHMessageResource.php',
|
||||||
|
'app/Filament/Resources/EngageAttributionDimensionResource.php',
|
||||||
|
'app/Filament/Resources/EngageFundDimensionResource.php',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Pages
|
||||||
|
page_changes = {
|
||||||
|
'app/Filament/Pages/DonationURLBuilder.php': {
|
||||||
|
'navigationGroup': 'Setup',
|
||||||
|
'navigationSort': 5,
|
||||||
|
'navigationLabel': 'URL Builder',
|
||||||
|
},
|
||||||
|
'app/Filament/Pages/Settings.php': {
|
||||||
|
'navigationGroup': 'Setup',
|
||||||
|
'navigationSort': 6,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_static_property(content, prop, value):
|
||||||
|
"""Replace a static property value in a PHP class."""
|
||||||
|
# Match: protected static ?string $prop = 'old_value';
|
||||||
|
# or: protected static ?int $prop = 123;
|
||||||
|
pattern = rf"(protected\s+static\s+\?(?:string|int)\s+\${prop}\s*=\s*)('[^']*'|\d+)(\s*;)"
|
||||||
|
|
||||||
|
if isinstance(value, int):
|
||||||
|
replacement = rf"\g<1>{value}\3"
|
||||||
|
else:
|
||||||
|
replacement = rf"\g<1>'{value}'\3"
|
||||||
|
|
||||||
|
new_content, count = re.subn(pattern, replacement, content)
|
||||||
|
return new_content, count
|
||||||
|
|
||||||
|
|
||||||
|
def apply_changes(filepath, props):
|
||||||
|
full = os.path.join(BASE, filepath)
|
||||||
|
with open(full, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
for prop, value in props.items():
|
||||||
|
content, count = update_static_property(content, prop, value)
|
||||||
|
if count == 0:
|
||||||
|
print(f" WARNING: {prop} not found in {filepath}")
|
||||||
|
|
||||||
|
with open(full, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f"Updated: {filepath}")
|
||||||
|
|
||||||
|
|
||||||
|
def hide_from_nav(filepath):
|
||||||
|
full = os.path.join(BASE, filepath)
|
||||||
|
with open(full, 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add shouldRegisterNavigation = false if not present
|
||||||
|
if 'shouldRegisterNavigation' not in content:
|
||||||
|
# Insert after the class opening
|
||||||
|
content = content.replace(
|
||||||
|
'protected static ?string $navigationIcon',
|
||||||
|
'protected static bool $shouldRegisterNavigation = false;\n\n protected static ?string $navigationIcon'
|
||||||
|
)
|
||||||
|
with open(full, 'w') as f:
|
||||||
|
f.write(content)
|
||||||
|
print(f"Hidden: {filepath}")
|
||||||
|
else:
|
||||||
|
print(f"Already hidden: {filepath}")
|
||||||
|
|
||||||
|
|
||||||
|
# Apply all changes
|
||||||
|
print("=== UPDATING NAVIGATION ===")
|
||||||
|
for filepath, props in changes.items():
|
||||||
|
apply_changes(filepath, props)
|
||||||
|
|
||||||
|
print("\n=== HIDING DEAD PAGES ===")
|
||||||
|
for filepath in hide:
|
||||||
|
hide_from_nav(filepath)
|
||||||
|
|
||||||
|
print("\n=== UPDATING PAGES ===")
|
||||||
|
for filepath, props in page_changes.items():
|
||||||
|
apply_changes(filepath, props)
|
||||||
|
|
||||||
|
print("\nDone!")
|
||||||
Reference in New Issue
Block a user