"use client" import { useState, useEffect, useCallback } from "react" import { useSession } from "next-auth/react" import { Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw, Smartphone, Wifi, WifiOff, UserPlus, Trash2, Copy, Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight, Zap, Pencil, Mail } from "lucide-react" /** * /dashboard/settings — The Checklist Page * * DESIGN INSIGHT: * Settings pages are boring because they're designed as REFERENCE PAGES — * "here are all the knobs, turn them yourself." * * But Aaisha's mental model is a CHECKLIST: * "Am I set up? What's left? Let me fix the one thing that's missing." * * So the page is a flat list of items. Each item has 3 visual states: * * ✓ CONFIGURED → collapsed to a single summary line * "Bank account · Barclays · ****5678" * Click [Edit] to expand the form. * * ○ NEEDS SETUP → expanded with instructions + form * The first unconfigured item auto-expands. * These are visually loud — they're the "todo." * * → EDITING → expanded form with Save/Cancel * When you save, it auto-collapses back to the summary. * Brief green flash confirms it worked. * * The result: * - When everything's configured: page is SHORT. Just green checkmarks. * - When something's missing: that section is the focus. * - No "wall of forms" feeling. * * HEADER: * Instead of a dark stats bar, a CONTEXTUAL SENTENCE: * "You're all set" or "2 things left before you go live" */ interface OrgSettings { name: string; bankName: string; bankSortCode: string; bankAccountNo: string bankAccountName: string; refPrefix: string; primaryColor: string gcAccessToken: string; gcEnvironment: string; orgType: string stripeSecretKey: string; stripeWebhookSecret: string emailProvider: string; emailApiKey: string; emailFromAddress: string; emailFromName: string smsProvider: string; smsAccountSid: string; smsAuthToken: string; smsFromNumber: string } interface TeamMember { id: string; email: string; name: string | null; role: string; createdAt: string } const ROLE_META: Record = { org_admin: { label: "Admin", desc: "Full access — settings, money, everything", icon: Crown, color: "text-[#1E40AF]", bg: "bg-[#1E40AF]/10" }, community_leader: { label: "Community Leader", desc: "Their own links and pledges. Can't change settings.", icon: Users, color: "text-[#F59E0B]", bg: "bg-[#F59E0B]/10" }, staff: { label: "Staff", desc: "Can view pledges and reports, read-only", icon: Eye, color: "text-gray-600", bg: "bg-gray-100" }, volunteer: { label: "Volunteer", desc: "Minimal access — they mostly use the live feed link", icon: Eye, color: "text-gray-400", bg: "bg-gray-50" }, } export default function SettingsPage() { const { data: session } = useSession() // eslint-disable-next-line @typescript-eslint/no-explicit-any const currentUser = session?.user as any const [settings, setSettings] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(null) const [saved, setSaved] = useState(null) const [error, setError] = useState(null) // Which section is expanded const [open, setOpen] = useState(null) // Team const [team, setTeam] = useState([]) const [showInvite, setShowInvite] = useState(false) const [inviteEmail, setInviteEmail] = useState("") const [inviteName, setInviteName] = useState("") const [inviteRole, setInviteRole] = useState("community_leader") const [inviting, setInviting] = useState(false) const [inviteResult, setInviteResult] = useState<{ email: string; tempPassword: string } | null>(null) const [copiedCred, setCopiedCred] = useState(false) // WhatsApp const [waStatus, setWaStatus] = useState("loading") useEffect(() => { Promise.all([ fetch("/api/settings").then(r => r.json()), fetch("/api/team").then(r => r.json()).catch(() => ({ members: [] })), ]).then(([settingsData, teamData]) => { if (settingsData.name) setSettings(settingsData) if (teamData.members) setTeam(teamData.members) }) .catch(() => setError("Failed to load settings")) .finally(() => setLoading(false)) }, []) // Auto-expand first unconfigured section useEffect(() => { if (!settings || open) return const bankOk = !!(settings.bankSortCode && settings.bankAccountNo) if (!bankOk) { setOpen("bank"); return } // Everything essential is done — don't auto-expand }, [settings, open]) const save = async (section: string, data: Record) => { setSaving(section); setError(null) try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }) if (res.ok) { setSaved(section) // Auto-collapse after save setTimeout(() => { setOpen(null); setSaved(null) }, 1200) } else setError("Failed to save") } catch { setError("Failed to save") } setSaving(null) } const toggle = (id: string) => setOpen(o => o === id ? null : id) // Team actions const inviteMember = async () => { if (!inviteEmail.trim()) return setInviting(true) try { const res = await fetch("/api/team", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: inviteEmail.trim(), name: inviteName.trim(), role: inviteRole }), }) const data = await res.json() if (res.ok) { setTeam(prev => [...prev, { id: data.id, email: data.email, name: data.name, role: data.role, createdAt: new Date().toISOString() }]) setInviteResult({ email: data.email, tempPassword: data.tempPassword }) setInviteEmail(""); setInviteName("") } else { setError(data.error || "Failed to invite") } } catch { setError("Failed to invite") } setInviting(false) } const changeRole = async (userId: string, role: string) => { try { await fetch("/api/team", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, role }) }) setTeam(prev => prev.map(m => m.id === userId ? { ...m, role } : m)) } catch { setError("Failed to update role") } } const removeMember = async (userId: string) => { if (!confirm("Remove this team member?")) return try { await fetch("/api/team", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId }) }) setTeam(prev => prev.filter(m => m.id !== userId)) } catch { setError("Failed to remove") } } const copyCredentials = (email: string, password: string) => { navigator.clipboard.writeText(`Email: ${email}\nPassword: ${password}\nLogin: ${window.location.origin}/login`) setCopiedCred(true); setTimeout(() => setCopiedCred(false), 2000) } if (loading) return
if (!settings) return

Failed to load settings

const update = (key: keyof OrgSettings, value: string) => setSettings(s => s ? { ...s, [key]: value } : s) const isAdmin = currentUser?.role === "org_admin" || currentUser?.role === "super_admin" // Readiness const bankReady = !!(settings.bankSortCode && settings.bankAccountNo && settings.bankAccountName) const whatsappReady = waStatus === "CONNECTED" const stripeReady = !!settings.stripeSecretKey const charityReady = !!settings.name const essentials = [whatsappReady, bankReady, charityReady] const doneCount = essentials.filter(Boolean).length const totalCount = essentials.length // Header message const headerMsg = doneCount === totalCount ? "You're all set. Donors can pledge and you'll get WhatsApp notifications." : doneCount === 0 ? "Let's get you set up. Start with WhatsApp — it takes 30 seconds." : `${totalCount - doneCount} thing${totalCount - doneCount > 1 ? "s" : ""} left before you go live.` return (
{/* ── Header — human progress, not a form page ── */}

Settings

{settings.name}

{headerMsg}

{doneCount}/{totalCount}
{error &&
{error}
} {/* ━━ TWO-COLUMN: Checklist left, Education right ━━━━━━ */}
{/* LEFT: The Checklist */}
{/* ▸ WhatsApp ─────────────────────────── */} toggle("whatsapp")} /> {/* ▸ Bank account ─────────────────────── */} } title="Bank account" summary={bankReady ? `${settings.bankName || "Bank"} · ${settings.bankSortCode} · ****${settings.bankAccountNo.slice(-4)}` : "Where donors send their payment"} isOpen={open === "bank"} onToggle={() => toggle("bank")} saving={saving === "bank"} saved={saved === "bank"} >
Donors see these details after pledging, with a unique reference to match their payment.
update("bankName", v)} placeholder="e.g. Barclays" /> update("bankAccountName", v)} placeholder="e.g. Al Furqan Mosque" />
update("bankSortCode", v)} placeholder="20-30-80" /> update("bankAccountNo", v)} placeholder="12345678" />
update("refPrefix", v)} maxLength={4} />

Donors see {settings.refPrefix || "PNPL"}-A2F4-50

{/* Live preview */} {bankReady && (

What donors see

Bank{settings.bankName} Name{settings.bankAccountName} Sort code{settings.bankSortCode} Account{settings.bankAccountNo} Reference{settings.refPrefix || "PNPL"}-A2F4-50
)} save("bank", { bankName: settings.bankName, bankSortCode: settings.bankSortCode, bankAccountNo: settings.bankAccountNo, bankAccountName: settings.bankAccountName, refPrefix: settings.refPrefix })} onCancel={() => setOpen(null)} />
{/* ▸ Charity name ─────────────────────── */} } title="Your charity" summary={charityReady ? {settings.name} : "Name and colour shown on pledge pages" } isOpen={open === "charity"} onToggle={() => toggle("charity")} saving={saving === "brand"} saved={saved === "brand"} >
update("name", v)} placeholder="e.g. Al Furqan Mosque" />
update("primaryColor", e.target.value)} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
update("primaryColor", e.target.value)} className="flex-1 h-10 px-3 border-2 border-gray-200 text-sm font-mono focus:border-[#1E40AF] outline-none" />
{/* Preview */}

Pledge page header

{(settings.name || "P")[0].toUpperCase()}
{settings.name || "Your Charity"}
save("brand", { name: settings.name, primaryColor: settings.primaryColor })} onCancel={() => setOpen(null)} />
{/* ▸ Card payments (Stripe) ────────────── */} } title="Card payments" summary={stripeReady ? "Stripe connected · donors can pay by card" : "Optional — let donors pay by Visa, Mastercard, Apple Pay"} isOpen={open === "stripe"} onToggle={() => toggle("stripe")} optional saving={saving === "stripe"} saved={saved === "stripe"} >
Connect your own Stripe account. Money goes to your balance — we never touch it.
{!stripeReady && (

How to get your key

  1. Go to dashboard.stripe.com/apikeys
  2. Copy the Secret key (sk_live_ or sk_test_)
  3. Paste below
)} update("stripeSecretKey", v)} placeholder="sk_live_... or sk_test_..." type="password" />
Webhook (optional — auto-confirms payments)

Add this endpoint in Stripe → Developers → Webhooks:

{typeof window !== "undefined" ? window.location.origin : ""}/api/stripe/webhook

Event: checkout.session.completed

update("stripeWebhookSecret", v)} placeholder="whsec_..." type="password" />
{stripeReady && (

Live. Donors see “Pay by Card.” Visa, Mastercard, Amex, Apple Pay, Google Pay.

)} save("stripe", { stripeSecretKey: settings.stripeSecretKey || "", stripeWebhookSecret: settings.stripeWebhookSecret || "" })} onCancel={() => setOpen(null)} />
{/* ▸ Email ─────────────────────────────── */} } title="Email" summary={settings.emailApiKey ? `${settings.emailProvider || "Resend"} · ${settings.emailFromAddress}` : "Send receipts and reminders by email"} isOpen={open === "email"} onToggle={() => toggle("email")} optional saving={saving === "email"} saved={saved === "email"} >
Send receipts and reminders to donors who don't have WhatsApp. Connect your own email provider — messages come from your domain.
{["resend", "sendgrid"].map(p => ( ))}
{(settings.emailProvider || "resend") === "resend" && !settings.emailApiKey && (

Free: 3,000 emails/month at resend.com

)}
update("emailApiKey", v)} placeholder={settings.emailProvider === "sendgrid" ? "SG.xxxxx" : "re_xxxxx"} type="password" />
update("emailFromAddress", v)} placeholder="donations@mymosque.org" /> update("emailFromName", v)} placeholder="Al Furqan Mosque" />
save("email", { emailProvider: settings.emailProvider || "resend", emailApiKey: settings.emailApiKey || "", emailFromAddress: settings.emailFromAddress || "", emailFromName: settings.emailFromName || "" })} onCancel={() => setOpen(null)} />
{/* ▸ SMS ──────────────────────────────── */} } title="SMS" summary={settings.smsAccountSid ? `Twilio · ${settings.smsFromNumber}` : "Text reminders for donors without WhatsApp"} isOpen={open === "sms"} onToggle={() => toggle("sms")} optional saving={saving === "sms"} saved={saved === "sms"} >
Send SMS reminders via Twilio. Reaches donors who don't have WhatsApp and haven't provided an email. Pay-as-you-go (~3p per SMS).
{!settings.smsAccountSid && (

Get your Twilio credentials

  1. Sign up at twilio.com
  2. Copy your Account SID and Auth Token from the dashboard
  3. Buy a phone number (or use the trial number)
)} update("smsAccountSid", v)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" /> update("smsAuthToken", v)} placeholder="Your auth token" type="password" /> update("smsFromNumber", v)} placeholder="+447123456789" /> save("sms", { smsProvider: "twilio", smsAccountSid: settings.smsAccountSid || "", smsAuthToken: settings.smsAuthToken || "", smsFromNumber: settings.smsFromNumber || "" })} onCancel={() => setOpen(null)} />
{/* ▸ Team ─────────────────────────────── */} {isAdmin && ( toggle("team")} showInvite={showInvite} setShowInvite={setShowInvite} inviteEmail={inviteEmail} setInviteEmail={setInviteEmail} inviteName={inviteName} setInviteName={setInviteName} inviteRole={inviteRole} setInviteRole={setInviteRole} inviting={inviting} inviteMember={inviteMember} inviteResult={inviteResult} setInviteResult={setInviteResult} copiedCred={copiedCred} copyCredentials={copyCredentials} changeRole={changeRole} removeMember={removeMember} orgName={settings?.name} /> )} {/* ▸ Direct Debit ─────────────────────── */} } title="Direct Debit" summary={settings.gcAccessToken ? `GoCardless · ${settings.gcEnvironment}` : "GoCardless — most charities don't need this"} isOpen={open === "gc"} onToggle={() => toggle("gc")} optional dimmed saving={saving === "gc"} saved={saved === "gc"} >
Accept Direct Debit via GoCardless. Donors set up a mandate and payments auto-collect. Bank transfer works fine for most charities.
update("gcAccessToken", v)} placeholder="sandbox_xxxxx or live_xxxxx" type="password" />
{(["sandbox", "live"] as const).map(env => ( ))}
save("gc", { gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment })} onCancel={() => setOpen(null)} />
{/* RIGHT: Education + Context */}
{/* What each setting does */}

What you're setting up

{[ { n: "01", title: "WhatsApp", desc: "Scan a QR code to connect your phone. Donors get receipts and reminders automatically. They can reply PAID, HELP, or CANCEL.", essential: true }, { n: "02", title: "Bank account", desc: "Your sort code and account number. Shown to donors after they pledge so they know where to send money.", essential: true }, { n: "03", title: "Your charity", desc: "Name and brand colour shown on pledge pages. Donors see this when they tap your link.", essential: true }, { n: "04", title: "Card payments", desc: "Connect Stripe to let donors pay by Visa, Mastercard, or Apple Pay. Money goes straight to your account.", essential: false }, { n: "05", title: "Team", desc: "Invite community leaders and volunteers. They get their own pledge links and can see their own results.", essential: false }, ].map(s => (
{s.n}

{s.title} {s.essential && Required}

{s.desc}

))}
{/* Privacy & data */}

Privacy & data

Your data stays yours. We never access your Stripe account, bank details, or WhatsApp messages. Everything is stored encrypted.

GDPR compliant. Donor consent is recorded at pledge time. You can export or delete all data anytime.

No vendor lock-in. Download your full data as CSV from Reports. Your donors, your data, always.

{/* Common questions */}

Common questions

{[ { q: "Do I need Stripe?", a: "No — most charities use bank transfer only. Stripe is optional for orgs that want card payments." }, { q: "Can I change my bank details later?", a: "Yes. New pledges will show the updated details. Existing pledges keep the original reference." }, { q: "What happens if WhatsApp disconnects?", a: "Reminders pause until you reconnect. Come back here, scan the QR again. It takes 30 seconds." }, { q: "Can volunteers see financial data?", a: "No. Volunteers only see their own link performance. Admins see everything." }, ].map(item => (

{item.q}

{item.a}

))}
{/* Need help? */}

Need help setting up?

Our team can walk you through the setup in 15 minutes. Free, no strings attached.

Get in touch →
) } // ─── SettingRow: the core pattern ──────────────────────────── // Collapsed = one line. Expanded = form. function SettingRow({ configured, icon, title, summary, isOpen, onToggle, children, optional, dimmed, saving, saved }: { configured: boolean; icon: React.ReactNode; title: string; summary: React.ReactNode isOpen: boolean; onToggle: () => void; children: React.ReactNode optional?: boolean; dimmed?: boolean; saving?: boolean; saved?: boolean }) { return (
{/* Summary row — always visible */} {/* Expanded form */} {isOpen && (
{children}
)}
) } // ─── WhatsApp Row (special: has QR flow) ───────────────────── function WhatsAppRow({ waStatus, onStatusChange, isOpen, onToggle }: { waStatus: string; onStatusChange: (s: string) => void isOpen: boolean; onToggle: () => void }) { const [qrImage, setQrImage] = useState(null) const [phone, setPhone] = useState("") const [pushName, setPushName] = useState("") const [starting, setStarting] = useState(false) const [showQr, setShowQr] = useState(false) const checkStatus = useCallback(async () => { try { const res = await fetch("/api/whatsapp/qr") const data = await res.json() onStatusChange(data.status) if (data.screenshot) setQrImage(data.screenshot) if (data.phone) setPhone(data.phone) if (data.pushName) setPushName(data.pushName) if (data.status === "CONNECTED") setShowQr(false) } catch { onStatusChange("ERROR") } }, [onStatusChange]) useEffect(() => { checkStatus() }, [checkStatus]) useEffect(() => { if (!showQr) return; const i = setInterval(checkStatus, 5000); return () => clearInterval(i) }, [showQr, checkStatus]) const startSession = async () => { setStarting(true); setShowQr(true) try { await fetch("/api/whatsapp/qr", { method: "POST" }) await new Promise(r => setTimeout(r, 3000)) await checkStatus() } catch { /* */ } setStarting(false) } const connected = waStatus === "CONNECTED" const scanning = waStatus === "SCAN_QR_CODE" && showQr return (
{/* Summary row */} {/* Expanded content */} {isOpen && (
{/* Connected: show features */} {connected && ( <>

{pushName || "WhatsApp"}

+{phone}

{[ { label: "Receipts", desc: "Auto-sent on pledge" }, { label: "Reminders", desc: "4-step nudge sequence" }, { label: "Chatbot", desc: "PAID, HELP, CANCEL" }, ].map(f => (

{f.label}

{f.desc}

))}
)} {/* Scanning: show QR */} {scanning && (
{qrImage ? (
{/* eslint-disable-next-line @next/next/no-img-element */} QR
) : (
)}

WhatsApp → Settings → Linked Devices → Link a Device

)} {/* Not connected: show connect button */} {!connected && !scanning && ( <>

When connected, donors automatically receive:

Pledge receipt with your bank details — within seconds

Payment reminders — 4-step sequence over 14 days

Chatbot — they text PAID, HELP, or CANCEL

)}
)}
) } // ─── Team Row (special: has member list + invite) ──────────── function TeamRow({ team, currentUser, isOpen, onToggle, showInvite, setShowInvite, inviteEmail, setInviteEmail, inviteName, setInviteName, inviteRole, setInviteRole, inviting, inviteMember, inviteResult, setInviteResult, copiedCred, copyCredentials, changeRole, removeMember, orgName }: { // eslint-disable-next-line @typescript-eslint/no-explicit-any team: TeamMember[]; currentUser: any; isOpen: boolean; onToggle: () => void showInvite: boolean; setShowInvite: (v: boolean) => void inviteEmail: string; setInviteEmail: (v: string) => void inviteName: string; setInviteName: (v: string) => void inviteRole: string; setInviteRole: (v: string) => void inviting: boolean; inviteMember: () => void inviteResult: { email: string; tempPassword: string } | null; setInviteResult: (v: null) => void copiedCred: boolean; copyCredentials: (e: string, p: string) => void changeRole: (id: string, role: string) => void; removeMember: (id: string) => void; orgName?: string }) { const isAdmin = currentUser?.role === "org_admin" || currentUser?.role === "super_admin" const summary = team.length === 0 ? "Just you — invite community leaders to track their pledges" : team.length === 1 ? "1 member" : `${team.length} members · ${team.filter(m => m.role === "community_leader").length} leader${team.filter(m => m.role === "community_leader").length !== 1 ? "s" : ""}` return (
{isOpen && (
{/* Invite button */} {!showInvite && !inviteResult && ( )} {/* Invite form */} {showInvite && !inviteResult && (
{Object.entries(ROLE_META).filter(([k]) => k !== "org_admin" || currentUser?.role === "super_admin").map(([key, r]) => ( ))}
)} {/* Invite result */} {inviteResult && (

Account created

Email{inviteResult.email} Password{inviteResult.tempPassword} Login{typeof window !== "undefined" ? window.location.origin : ""}/login
)} {/* Member list */} {team.length > 0 && (
{team.map(m => { const r = ROLE_META[m.role] || ROLE_META.staff const isMe = m.id === currentUser?.id return (

{m.name || m.email.split("@")[0]} {isMe && you}

{isAdmin && !isMe ? ( <> ) : ( {r.label} )}
) })}
)}
)}
) } // ─── Reusable pieces ───────────────────────────────────────── function Field({ label, value, onChange, placeholder, type = "text", maxLength }: { label: string; value: string; onChange: (v: string) => void placeholder?: string; type?: string; maxLength?: number }) { return (
onChange(e.target.value)} placeholder={placeholder} maxLength={maxLength} className="w-full h-10 px-3 border-2 border-gray-200 text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none transition-colors" />
) } function SaveRow({ section, saving, saved, onSave, onCancel }: { section: string; saving: string | null; saved: string | null onSave: () => void; onCancel: () => void }) { const isSaving = saving === section const isSaved = saved === section return (
{!isSaved && }
) }