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:
2026-03-04 22:46:08 +08:00
parent 62be460643
commit 3b46222118
27 changed files with 1292 additions and 151 deletions

View File

@@ -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",

View File

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

View File

@@ -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

View File

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

View File

@@ -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) {

View 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 })
}
}

View 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 })
}
}

View File

@@ -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 &ldquo;Pay by Card&rdquo; as an option. They&apos;ll be redirected to Stripe&apos;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">

View File

@@ -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 (

View File

@@ -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&apos;ll be redirected to Stripe&apos;s secure checkout page. The payment goes directly to <strong className="text-gray-600">{eventName}</strong>&apos;s account. Visa, Mastercard, Amex, Apple Pay, and Google Pay accepted.</p>
</div>
</div>
)
}

View File

@@ -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{" "}

View File

@@ -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 (

View File

@@ -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 (

View File

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

View File

@@ -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>

View File

@@ -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>

View 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
}
}

View File

@@ -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')

View File

@@ -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')

View File

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

View 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();
}
}

View 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'),
];
}
}

View 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'),
];
}
}

View 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 [];
}
}

View 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'),
];
}
}

View 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 [];
}
}

View 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!")