diff --git a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx index 06efc21..a36a753 100644 --- a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx +++ b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx @@ -4,25 +4,43 @@ import { useState, useEffect, useCallback } from "react" import { useSession } from "next-auth/react" import { Check, Loader2, AlertCircle, MessageCircle, Radio, RefreshCw, - Smartphone, Wifi, WifiOff, QrCode, UserPlus, Trash2, Copy, - Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight, Zap + Smartphone, Wifi, WifiOff, UserPlus, Trash2, Copy, + Users, Crown, Eye, Building2, CreditCard, Palette, ChevronRight, + Zap, Pencil } from "lucide-react" /** - * /dashboard/settings — Aaisha's control panel + * /dashboard/settings — The Checklist Page * - * Telepathic approach: What is she thinking each time she visits? + * DESIGN INSIGHT: + * Settings pages are boring because they're designed as REFERENCE PAGES — + * "here are all the knobs, turn them yourself." * - * First visit: "They told me to connect WhatsApp. Where?" - * Second visit: "Is everything working? What else do I need to set up?" - * Monthly: "I need to invite Imam Yusuf" / "Let me check bank details" - * Rarely: "What name shows to donors?" / "Direct Debit?" + * But Aaisha's mental model is a CHECKLIST: + * "Am I set up? What's left? Let me fix the one thing that's missing." * - * The page opens with a READINESS BAR — dark section showing - * what's configured vs what's missing. Aaisha sees instantly: - * "WhatsApp ✓ · Bank details ✗ · Team: 1 member" + * So the page is a flat list of items. Each item has 3 visual states: * - * Then sections in order of how often she needs them. + * ✓ 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 { @@ -37,10 +55,10 @@ interface TeamMember { } const ROLE_META: Record = { - org_admin: { label: "Admin", desc: "Full access — settings, money, everything", icon: Crown, color: "text-[#1E40AF]", bg: "bg-[#1E40AF]/10" }, + 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" }, + 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() { @@ -54,6 +72,9 @@ export default function SettingsPage() { 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) @@ -64,7 +85,7 @@ export default function SettingsPage() { const [inviteResult, setInviteResult] = useState<{ email: string; tempPassword: string } | null>(null) const [copiedCred, setCopiedCred] = useState(false) - // WhatsApp status (pulled from child, exposed for readiness bar) + // WhatsApp const [waStatus, setWaStatus] = useState("loading") useEffect(() => { @@ -79,16 +100,30 @@ export default function SettingsPage() { .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); setTimeout(() => setSaved(null), 2000) } - else setError("Failed to save") + 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) @@ -103,40 +138,26 @@ export default function SettingsPage() { 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") - } + } 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 }), - }) + 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? They'll lose access immediately.")) return + if (!confirm("Remove this team member?")) return try { - await fetch("/api/team", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId }), - }) + 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) + setCopiedCred(true); setTimeout(() => setCopiedCred(false), 2000) } if (loading) return
@@ -145,14 +166,25 @@ export default function SettingsPage() { 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 checks + // Readiness const bankReady = !!(settings.bankSortCode && settings.bankAccountNo && settings.bankAccountName) const whatsappReady = waStatus === "CONNECTED" - const charityReady = !!settings.name 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 ── */}
@@ -160,535 +192,304 @@ export default function SettingsPage() {

Settings

- {/* ── Readiness bar — "Am I set up?" ── */} -
-

Setup progress

-
- {[ - { label: "WhatsApp", ready: whatsappReady, detail: whatsappReady ? "Connected" : "Not connected" }, - { label: "Bank details", ready: bankReady, detail: bankReady ? `${settings.bankSortCode}` : "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" : ""}` }, - ].map(item => ( -
-
-
-

{item.label}

-
-

{item.detail}

-
- ))} + {/* ── Progress — human sentence, not a grid ── */} +
+
+
+
+
+ {doneCount}/{totalCount}
+

{headerMsg}

{error &&
{error}
} - {/* ── 1. WhatsApp ── */} - + {/* ── The Checklist ── */} +
- {/* ── 2. Team management ── */} - {isAdmin && ( -
-
-
-
- -
-
-

Team

-

People who can access your dashboard

-
+ {/* ▸ 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

- -
- {/* Invite form */} - {showInvite && !inviteResult && ( -
-

Invite a team member

-
-
- - setInviteEmail(e.target.value)} - placeholder="imam@mosque.org" - className="w-full h-10 px-3 border-2 border-gray-200 bg-white text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" - /> -
-
- - setInviteName(e.target.value)} - placeholder="Imam Yusuf" - className="w-full h-10 px-3 border-2 border-gray-200 bg-white text-sm placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" - /> + {/* 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
-
- -
- {Object.entries(ROLE_META).filter(([k]) => k !== "org_admin" || currentUser?.role === "super_admin").map(([key, r]) => { - const active = inviteRole === key - return ( - - ) - })} -
-
-
- - -
-
- )} + )} - {/* Invite result — credentials shown once */} - {inviteResult && ( -
-
-
- -
-

Account created

-
-
-

Share these login details. The password is shown only once.

-
-
-
- Email - {inviteResult.email} - Password - {inviteResult.tempPassword} - Login URL - {typeof window !== "undefined" ? window.location.origin : ""}/login -
-
-
- - -
- -
- )} - - {/* Team list — gap-px grid */} - {team.length > 0 ? ( -
- {team.map(m => { - const r = ROLE_META[m.role] || ROLE_META.staff - const isMe = m.id === currentUser?.id - const RoleIcon = r.icon - return ( -
-
- -
-
-
-

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

- {isMe && You} -
-

{m.email}

-
-
- {isAdmin && !isMe ? ( - - ) : ( - {r.label} - )} - {isAdmin && !isMe && ( - - )} -
-
- ) - })} -
- ) : ( -
- -

Just you for now. Invite community leaders to track their pledges.

-
- )} -
- )} - - {/* ── 3. Bank account ── */} -
-
-
- -
-
-
-

Bank account

- {bankReady &&
} -
-

Where donors send their payment

-
-
- -
-
-

When someone pledges, they see these details with instructions to transfer. Each pledge gets a unique reference so you can match payments.

-
- -
- 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 references like {settings.refPrefix || "PNPL"}-A2F4-50 -

-
-
- - {/* Donor preview — what they actually see */} - {bankReady && ( -
-

What donors see after pledging

-
-

Transfer to:

-
- Bank - {settings.bankName} - Name - {settings.bankAccountName} - Sort code - {settings.bankSortCode} - Account - {settings.bankAccountNo} - Reference - {settings.refPrefix || "PNPL"}-A2F4-50 -
-
-
- )} - -
-

Changes apply to new pledges immediately

- save("bank", { bankName: settings.bankName, bankSortCode: settings.bankSortCode, bankAccountNo: settings.bankAccountNo, bankAccountName: settings.bankAccountName, refPrefix: settings.refPrefix })} + onCancel={() => setOpen(null)} />
-
-
+ - {/* ── 4. Card payments (Stripe) ── */} -
-
-
- -
-
-
-

Card payments

- {stripeReady &&
} -
-

Let donors pay by card using your Stripe account

-
-
- -
-
-

Connect your own Stripe account to accept card payments. Money goes directly to your Stripe balance — we never touch it.

-

When connected, donors see a third payment option: Bank Transfer, Direct Debit, and Card Payment.

-
- - {!stripeReady && ( -
-

How to get your Stripe API key

-
    -
  1. Go to dashboard.stripe.com/apikeys
  2. -
  3. Copy your Secret key (starts with sk_live_ or sk_test_)
  4. -
  5. Paste it below
  6. -
-
- )} - - update("stripeSecretKey", v)} - placeholder="sk_live_... or sk_test_..." - type="password" - /> - -
- Webhook setup (optional — for auto-confirmation) -
-
-

If you want pledges to auto-confirm when the card is charged:

-
    -
  1. In Stripe Dashboard → Developers → Webhooks
  2. -
  3. Add endpoint: {typeof window !== "undefined" ? window.location.origin : ""}/api/stripe/webhook
  4. -
  5. Select events: checkout.session.completed
  6. -
  7. Copy the signing secret and paste below
  8. -
-
- update("stripeWebhookSecret", v)} - placeholder="whsec_..." - type="password" - /> -

Without webhooks, you confirm card payments manually in the Money page (same as bank transfers).

-
-
- - {stripeReady && ( -
- -
-

Card payments are live

-

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.

+ {/* ▸ 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" />
- )} - -
-

{stripeReady ? "Key stored securely · never shown to donors" : "Optional — bank transfer works without this"}

- save("stripe", { - stripeSecretKey: settings.stripeSecretKey || "", - stripeWebhookSecret: settings.stripeWebhookSecret || "", - })} - /> -
-
-
- - {/* ── 5. Charity details ── */} -
-
-
- -
-
-

Your charity

-

Name and brand shown on pledge pages and WhatsApp messages

-
-
- -
- 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 placeholder:text-gray-300 focus:border-[#1E40AF] outline-none" - /> -
-
- - {/* Brand preview */} -
-

Preview — pledge page header

-
-
-
- {(settings.name || "P")[0].toUpperCase()} + {/* Preview */} +
+

Pledge page header

+
+
+ {(settings.name || "P")[0].toUpperCase()}
{settings.name || "Your Charity"}
-
- -
- save("brand", { name: settings.name, primaryColor: settings.primaryColor })} + onCancel={() => setOpen(null)} />
-
-
+ - {/* ── 5. Direct Debit (collapsed) ── */} -
- -
- -
-
-

Direct Debit

-

GoCardless integration — advanced

-
- -
-
-
-

Accept Direct Debit via GoCardless. Donors set up a mandate and payments are collected automatically. Most charities don't need this — bank transfer works fine.

-
- update("gcAccessToken", v)} - placeholder="sandbox_xxxxx or live_xxxxx" - type="password" - /> -
- -
- {(["sandbox", "live"] as const).map(env => ( - - ))} + {/* ▸ 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.
-
-
- save("gc", { gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment })} + {!stripeReady && ( +
+

How to get your key

+
    +
  1. Go to dashboard.stripe.com/apikeys
  2. +
  3. Copy the Secret key (sk_live_ or sk_test_)
  4. +
  5. Paste below
  6. +
+
+ )} + 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)} />
+
+ + {/* ▸ 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)} + /> +
+
+
+
+ ) +} + + +// ─── 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 */} +
+ + {/* Action hint */} + {!isOpen && ( + configured ? ( + + Edit + + ) : ( + Set up → + ) + )} + + {isOpen && } + + + {/* Expanded form */} + {isOpen && ( +
+
+ {children} +
+
+ )}
) } -// ─── Reusable Field component ──────────────────────────────── +// ─── WhatsApp Row (special: has QR flow) ───────────────────── -function Field({ label, value, onChange, placeholder, type = "text", maxLength }: { - label: string; value: string; onChange: (v: string) => void - placeholder?: string; type?: string; maxLength?: number +function WhatsAppRow({ waStatus, onStatusChange, isOpen, onToggle }: { + waStatus: string; onStatusChange: (s: string) => void + isOpen: boolean; onToggle: () => void }) { - 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" - /> -
- ) -} - - -// ─── Save button component ─────────────────────────────────── - -function SaveBtn({ section, saving, saved, onSave }: { - section: string; saving: string | null; saved: string | null; onSave: () => void -}) { - const isSaving = saving === section - const isSaved = saved === section - - return ( - - ) -} - - -// ─── WhatsApp Connection Panel ─────────────────────────────── - -function WhatsAppPanel({ onStatusChange }: { onStatusChange?: (status: string) => void }) { - const [status, setStatus] = useState("loading") const [qrImage, setQrImage] = useState(null) const [phone, setPhone] = useState("") const [pushName, setPushName] = useState("") @@ -699,16 +500,12 @@ function WhatsAppPanel({ onStatusChange }: { onStatusChange?: (status: string) = try { const res = await fetch("/api/whatsapp/qr") const data = await res.json() - setStatus(data.status) - onStatusChange?.(data.status) + 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 { - setStatus("ERROR") - onStatusChange?.("ERROR") - } + } catch { onStatusChange("ERROR") } }, [onStatusChange]) useEffect(() => { checkStatus() }, [checkStatus]) @@ -724,115 +521,261 @@ function WhatsAppPanel({ onStatusChange }: { onStatusChange?: (status: string) = setStarting(false) } - // ── Connected state ── - if (status === "CONNECTED") { - return ( -
-
-
- -
-
-
-

WhatsApp

- - Connected - -
-

{pushName || "WhatsApp"} · +{phone}

-
- -
+ const connected = waStatus === "CONNECTED" + const scanning = waStatus === "SCAN_QR_CODE" && showQr - {/* What's active */} -
- {[ - { label: "Receipts", desc: "Auto-sent when someone pledges" }, - { label: "Reminders", desc: "4-step nudge sequence" }, - { label: "Chatbot", desc: "PAID, HELP, CANCEL replies" }, - ].map(f => ( -
-

{f.label}

-

{f.desc}

-
- ))} -
-
- ) - } - - // ── QR scanning state ── - if (status === "SCAN_QR_CODE" && showQr) { - return ( -
-
-
- -
-
-

WhatsApp

-

Waiting for QR scan…

-
-
- -
- {qrImage ? ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - WhatsApp QR Code -
- ) : ( -
- -
- )} -
-

Scan with your phone

-
-

WhatsApp → Settings → Linked Devices → Link a Device

-
-
- -
-
- ) - } - - // ── Not connected state ── return ( -
-
-
- +
+ {/* Summary row */} + -
-
-

When you connect, donors automatically receive:

-
-

Pledge receipt with your bank details — within seconds

-

Payment reminders — 4-step sequence over 14 days

-

Chatbot replies — they text PAID, HELP, or CANCEL

+ {/* 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 && } +
) }