Files
calvana/pledge-now-pay-later/src/app/dashboard/settings/page.tsx
Omair Saleh f87aec7beb full terminology overhaul + zakat fund types + fund allocation
POSITIONING FIX — PNPL is NOT just 'QR codes at events':
- Charities collecting at events (QR per table)
- High-net-worth donor outreach (personal links via WhatsApp/email)
- Org-to-org pledges (multi-charity projects)
- Personal fundraisers (LaunchGood/Enthuse redirect)

TERMINOLOGY (throughout app):
- Events → Campaigns (sidebar, pages, create dialogs, onboarding)
- QR Codes page → Pledge Links (sharing-first, QR is one option)
- Scans → Clicks (not just QR scans)
- 'New Event' → 'New Campaign'
- 'Create QR Code' → 'Create Pledge Link'
- Source label: 'Table Name' → 'Source / Channel'

SHARING (pledge links page):
- 4-button share row: Copy · WhatsApp · Email · More (native share)
- Each link shows its full URL
- Create dialog suggests: 'WhatsApp Family Group, Table 5, Instagram Bio'
- QR code is still shown but as one option, not the hero

LANDING PAGE (complete rewrite):
- Hero: 'Collect pledges. Convert them into donations.'
- 4 use case cards: Events, HNW Donors, Org-to-Org, Personal Fundraisers
- 'Share anywhere' section: WhatsApp, QR, Email, Instagram, Twitter, 1-on-1
- Platform support: Bank Transfer, LaunchGood, Enthuse, JustGiving, GoFundMe, Any URL
- Islamic fund types section: Zakat, Sadaqah, Sadaqah Jariyah, Lillah, Fitrana

ZAKAT & FUND TYPES:
- Organization.zakatEnabled toggle in Settings
- Pledge.fundType: general, zakat, sadaqah, lillah, fitrana
- Identity step: fund type picker (5 options) when org has zakatEnabled
- Zakat note: Quran 9:60 categories reference
- Settings: toggle card with fund type descriptions

FUND ALLOCATION:
- Event.fundAllocation: 'Mosque Building Fund', 'Orphan Sponsorship' etc.
- Charities can also add external URL for reference/allocation (not just fundraisers)
- Shows on campaign cards and pledge flow
2026-03-03 07:00:04 +08:00

351 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useEffect, useCallback } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import {
Building2, CreditCard, Palette, Check, Loader2, AlertCircle,
MessageCircle, Radio, QrCode, RefreshCw, Smartphone, Wifi, WifiOff
} from "lucide-react"
interface OrgSettings {
name: string
bankName: string
bankSortCode: string
bankAccountNo: string
bankAccountName: string
refPrefix: string
primaryColor: string
gcAccessToken: string
gcEnvironment: string
orgType: string
zakatEnabled: boolean
}
export default function SettingsPage() {
const [settings, setSettings] = useState<OrgSettings | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState<string | null>(null)
const [saved, setSaved] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((data) => { if (data.name) setSettings(data) })
.catch(() => setError("Failed to load settings"))
.finally(() => setLoading(false))
}, [])
const save = async (section: string, data: Record<string, string>) => {
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")
} catch { setError("Failed to save") }
setSaving(null)
}
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-8 w-8 text-trust-blue animate-spin" /></div>
if (!settings) return <div className="text-center py-20"><AlertCircle className="h-8 w-8 text-danger-red mx-auto mb-2" /><p className="text-muted-foreground">Failed to load settings</p></div>
const update = (key: keyof OrgSettings, value: string) => setSettings((s) => s ? { ...s, [key]: value } : s)
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-black text-gray-900">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Configure your organisation&apos;s payment details and integrations</p>
</div>
{error && <div className="rounded-xl bg-danger-red/10 border border-danger-red/20 p-3 text-sm text-danger-red">{error}</div>}
{/* WhatsApp — MOST IMPORTANT, first */}
<WhatsAppPanel />
{/* Bank Details */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2"><Building2 className="h-4 w-4 text-trust-blue" /> Bank Account</CardTitle>
<CardDescription className="text-xs">Shown to donors who choose bank transfer. Each pledge gets a unique reference.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Bank Name</Label><Input value={settings.bankName} onChange={(e) => update("bankName", e.target.value)} placeholder="e.g. Barclays" /></div>
<div><Label className="text-xs">Account Name</Label><Input value={settings.bankAccountName} onChange={(e) => update("bankAccountName", e.target.value)} /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div><Label className="text-xs">Sort Code</Label><Input value={settings.bankSortCode} onChange={(e) => update("bankSortCode", e.target.value)} placeholder="20-30-80" /></div>
<div><Label className="text-xs">Account Number</Label><Input value={settings.bankAccountNo} onChange={(e) => update("bankAccountNo", e.target.value)} placeholder="12345678" /></div>
</div>
<div><Label className="text-xs">Reference Prefix</Label><Input value={settings.refPrefix} onChange={(e) => update("refPrefix", e.target.value)} maxLength={4} className="w-24" /><p className="text-[10px] text-muted-foreground mt-1">e.g. {settings.refPrefix}-XXXX-50</p></div>
<Button size="sm" onClick={() => save("bank", { bankName: settings.bankName, bankSortCode: settings.bankSortCode, bankAccountNo: settings.bankAccountNo, bankAccountName: settings.bankAccountName, refPrefix: settings.refPrefix })} disabled={saving === "bank"}>
{saving === "bank" ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Saving</> : saved === "bank" ? <><Check className="h-3.5 w-3.5 mr-1.5" /> Saved!</> : "Save Bank Details"}
</Button>
</CardContent>
</Card>
{/* GoCardless */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2"><CreditCard className="h-4 w-4 text-trust-blue" /> GoCardless (Direct Debit)</CardTitle>
<CardDescription className="text-xs">Enable Direct Debit collection protected by the DD Guarantee.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div><Label className="text-xs">Access Token</Label><Input type="password" value={settings.gcAccessToken} onChange={(e) => update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" /></div>
<div>
<Label className="text-xs">Environment</Label>
<div className="flex gap-2 mt-1">
{["sandbox", "live"].map((env) => (
<button key={env} onClick={() => update("gcEnvironment", env)} className={`px-3 py-1.5 rounded-lg text-xs font-medium border-2 transition-colors ${settings.gcEnvironment === env ? env === "live" ? "border-danger-red bg-danger-red/5 text-danger-red" : "border-trust-blue bg-trust-blue/5 text-trust-blue" : "border-gray-200 text-muted-foreground"}`}>
{env.charAt(0).toUpperCase() + env.slice(1)} {env === "live" && settings.gcEnvironment === "live" && "⚠️"}
</button>
))}
</div>
</div>
<Button size="sm" onClick={() => save("gc", { gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment })} disabled={saving === "gc"}>
{saving === "gc" ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Saving</> : saved === "gc" ? <><Check className="h-3.5 w-3.5 mr-1.5" /> Saved!</> : "Save GoCardless"}
</Button>
</CardContent>
</Card>
{/* Zakat & Fund Types */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2"> Zakat & Fund Types</CardTitle>
<CardDescription className="text-xs">Let donors specify their donation type (Zakat, Sadaqah, Lillah, Fitrana)</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<button
onClick={() => {
setSettings(s => s ? { ...s, zakatEnabled: !s.zakatEnabled } : s)
fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ zakatEnabled: !settings.zakatEnabled }) }).then(() => { setSaved("zakat"); setTimeout(() => setSaved(null), 2000) }).catch(() => {})
}}
className={`w-full flex items-center justify-between rounded-xl border-2 p-4 transition-all ${
settings.zakatEnabled ? "border-trust-blue bg-trust-blue/5" : "border-gray-200"
}`}
>
<div className="text-left">
<p className="text-sm font-bold">{settings.zakatEnabled ? "Fund types enabled" : "Enable fund types"}</p>
<p className="text-xs text-muted-foreground mt-0.5">Donors can choose: Zakat · Sadaqah · Lillah · Fitrana · General</p>
</div>
<div className={`w-11 h-6 rounded-full transition-colors ${settings.zakatEnabled ? "bg-trust-blue" : "bg-gray-200"}`}>
<div className={`w-5 h-5 bg-white rounded-full shadow-sm mt-0.5 transition-transform ${settings.zakatEnabled ? "translate-x-5.5 ml-[22px]" : "translate-x-0.5 ml-[2px]"}`} />
</div>
</button>
{settings.zakatEnabled && (
<div className="rounded-xl bg-trust-blue/5 border border-trust-blue/10 p-3 space-y-1.5 text-xs text-muted-foreground animate-fade-in">
<p>🌙 <strong>Zakat</strong> Obligatory 2.5% annual charity</p>
<p>🤲 <strong>Sadaqah / General</strong> Voluntary donations</p>
<p>🌱 <strong>Sadaqah Jariyah</strong> Ongoing charity (buildings, wells)</p>
<p>🕌 <strong>Lillah</strong> For the mosque / institution</p>
<p>🍽 <strong>Fitrana</strong> Zakat al-Fitr (before Eid)</p>
</div>
)}
</CardContent>
</Card>
{/* Branding */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2"><Palette className="h-4 w-4 text-trust-blue" /> Branding</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div><Label className="text-xs">Organisation Name</Label><Input value={settings.name} onChange={(e) => update("name", e.target.value)} /></div>
<div>
<Label className="text-xs">Primary Colour</Label>
<div className="flex gap-2 mt-1"><Input type="color" value={settings.primaryColor} onChange={(e) => update("primaryColor", e.target.value)} className="w-12 h-9 p-0.5" /><Input value={settings.primaryColor} onChange={(e) => update("primaryColor", e.target.value)} className="flex-1" /></div>
</div>
<Button size="sm" onClick={() => save("brand", { name: settings.name, primaryColor: settings.primaryColor })} disabled={saving === "brand"}>
{saving === "brand" ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Saving</> : saved === "brand" ? <><Check className="h-3.5 w-3.5 mr-1.5" /> Saved!</> : "Save Branding"}
</Button>
</CardContent>
</Card>
</div>
)
}
// ─── WhatsApp Connection Panel ───────────────────────────────────
function WhatsAppPanel() {
const [status, setStatus] = useState<string>("loading")
const [qrImage, setQrImage] = useState<string | null>(null)
const [phone, setPhone] = useState<string>("")
const [pushName, setPushName] = useState<string>("")
const [starting, setStarting] = useState(false)
const [showQr, setShowQr] = useState(false) // only true after user clicks Connect
const checkStatus = useCallback(async () => {
try {
const res = await fetch("/api/whatsapp/qr")
const data = await res.json()
setStatus(data.status)
if (data.screenshot) setQrImage(data.screenshot)
if (data.phone) setPhone(data.phone)
if (data.pushName) setPushName(data.pushName)
// Auto-show QR panel once connected (user paired successfully)
if (data.status === "CONNECTED") setShowQr(false)
} catch {
setStatus("ERROR")
}
}, [])
// On mount: just check if already connected. Don't start polling yet.
useEffect(() => { checkStatus() }, [checkStatus])
// Poll only when user has clicked Connect and we're waiting for scan
useEffect(() => {
if (!showQr) return
const interval = setInterval(checkStatus, 5000)
return () => clearInterval(interval)
}, [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 { /* ignore */ }
setStarting(false)
}
if (status === "CONNECTED") {
return (
<Card className="border-[#25D366]/30 bg-[#25D366]/[0.02]">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-[#25D366]" /> WhatsApp
<Badge variant="success" className="gap-1 ml-1"><Radio className="h-2.5 w-2.5" /> Connected</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-[#25D366]/10 flex items-center justify-center">
<Smartphone className="h-6 w-6 text-[#25D366]" />
</div>
<div>
<p className="font-medium text-sm">{pushName || "WhatsApp Business"}</p>
<p className="text-xs text-muted-foreground">+{phone}</p>
</div>
<div className="ml-auto">
<Wifi className="h-5 w-5 text-[#25D366]" />
</div>
</div>
<div className="mt-4 pt-3 border-t border-[#25D366]/10 grid grid-cols-3 gap-3">
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Auto-Sends</p>
<p className="text-xs font-medium">Receipts</p>
</div>
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Reminders</p>
<p className="text-xs font-medium">4-step</p>
</div>
<div className="text-center">
<p className="text-[10px] text-muted-foreground">Chatbot</p>
<p className="text-xs font-medium">PAID / HELP</p>
</div>
</div>
</CardContent>
</Card>
)
}
if (status === "SCAN_QR_CODE" && showQr) {
return (
<Card className="border-warm-amber/30">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-[#25D366]" /> WhatsApp
<Badge variant="warning" className="gap-1 ml-1"><QrCode className="h-2.5 w-2.5" /> Scan QR</Badge>
</CardTitle>
<CardDescription className="text-xs">Open WhatsApp on your phone Settings Linked Devices Link a Device</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center gap-4">
{qrImage ? (
<div className="relative">
{/* Crop to QR area: the screenshot shows full WhatsApp web page.
QR code is roughly in center. We use overflow hidden + object positioning. */}
<div className="w-72 h-72 rounded-xl border-2 border-[#25D366]/20 overflow-hidden bg-white">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrImage}
alt="WhatsApp QR Code"
className="w-[200%] h-auto max-w-none"
style={{ marginLeft: "-30%", marginTop: "-35%" }}
/>
</div>
<div className="absolute -bottom-2 -right-2 w-8 h-8 rounded-full bg-[#25D366] flex items-center justify-center shadow-lg">
<MessageCircle className="h-4 w-4 text-white" />
</div>
</div>
) : (
<div className="w-72 h-72 rounded-xl border-2 border-dashed border-muted flex items-center justify-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
</div>
)}
<div className="text-center space-y-1">
<p className="text-sm font-medium">Scan with WhatsApp</p>
<p className="text-xs text-muted-foreground">Open WhatsApp Settings Linked Devices Link a Device</p>
<p className="text-xs text-muted-foreground">Auto-refreshes every 5 seconds</p>
</div>
<Button variant="outline" size="sm" onClick={checkStatus} className="gap-1.5">
<RefreshCw className="h-3 w-3" /> Refresh
</Button>
</div>
</CardContent>
</Card>
)
}
// NO_SESSION or STARTING or ERROR
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MessageCircle className="h-4 w-4 text-muted-foreground" /> WhatsApp
<Badge variant="secondary" className="gap-1 ml-1"><WifiOff className="h-2.5 w-2.5" /> Offline</Badge>
</CardTitle>
<CardDescription className="text-xs">
Connect WhatsApp to auto-send pledge receipts, payment reminders, and enable a chatbot for donors.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-xl bg-muted/50 p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-[#25D366]/10 flex items-center justify-center flex-shrink-0">
<Smartphone className="h-5 w-5 text-[#25D366]" />
</div>
<div>
<p className="text-sm font-medium">Connect your WhatsApp number</p>
<ul className="text-xs text-muted-foreground mt-1 space-y-0.5">
<li>📨 Pledge receipts with bank transfer details</li>
<li> Automatic reminders (2d before due day 3d after 10d final)</li>
<li>🤖 Donor chatbot: reply PAID, HELP, CANCEL, STATUS</li>
<li>📊 Volunteer notifications when someone pledges at their table</li>
</ul>
</div>
</div>
<Button onClick={startSession} disabled={starting} className="w-full bg-[#25D366] hover:bg-[#25D366]/90 text-white">
{starting ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Starting session...</> : <><MessageCircle className="h-4 w-4 mr-2" /> Connect WhatsApp</>}
</Button>
<p className="text-[10px] text-muted-foreground text-center">
Uses WAHA (WhatsApp HTTP API) · No WhatsApp Business API required · Free tier
</p>
</div>
</CardContent>
</Card>
)
}