diff --git a/pledge-now-pay-later/src/app/api/whatsapp/qr/route.ts b/pledge-now-pay-later/src/app/api/whatsapp/qr/route.ts
new file mode 100644
index 0000000..5cc185d
--- /dev/null
+++ b/pledge-now-pay-later/src/app/api/whatsapp/qr/route.ts
@@ -0,0 +1,123 @@
+import { NextResponse } from "next/server"
+
+const WAHA_URL = process.env.WAHA_API_URL || "https://waha.quikcue.com"
+const WAHA_KEY = process.env.WAHA_API_KEY || "qc-waha-api-7Fp3nR9xYm2K"
+const WAHA_SESSION = process.env.WAHA_SESSION || "default"
+
+/**
+ * GET /api/whatsapp/qr
+ * Returns the WAHA QR code screenshot as PNG for WhatsApp pairing.
+ * Also returns session status.
+ */
+export async function GET() {
+ try {
+ // First check session status
+ const sessRes = await fetch(`${WAHA_URL}/api/sessions`, {
+ headers: { "X-Api-Key": WAHA_KEY },
+ signal: AbortSignal.timeout(5000),
+ })
+ const sessions = await sessRes.json()
+
+ const session = Array.isArray(sessions)
+ ? sessions.find((s: { name: string }) => s.name === WAHA_SESSION)
+ : null
+
+ if (!session) {
+ return NextResponse.json({
+ status: "NO_SESSION",
+ message: "No WAHA session exists. Start one first.",
+ })
+ }
+
+ if (session.status === "WORKING") {
+ const me = session.me || {}
+ return NextResponse.json({
+ status: "CONNECTED",
+ phone: me.id?.replace("@c.us", "") || "unknown",
+ pushName: me.pushname || "",
+ })
+ }
+
+ if (session.status === "SCAN_QR_CODE") {
+ // Get screenshot
+ const qrRes = await fetch(`${WAHA_URL}/api/screenshot?session=${WAHA_SESSION}`, {
+ headers: { "X-Api-Key": WAHA_KEY },
+ signal: AbortSignal.timeout(10000),
+ })
+
+ if (!qrRes.ok) {
+ return NextResponse.json({ status: "SCAN_QR_CODE", error: "Failed to get QR" })
+ }
+
+ const buffer = await qrRes.arrayBuffer()
+ const base64 = Buffer.from(buffer).toString("base64")
+
+ return NextResponse.json({
+ status: "SCAN_QR_CODE",
+ qrImage: `data:image/png;base64,${base64}`,
+ message: "Scan this QR code with WhatsApp on your phone",
+ })
+ }
+
+ return NextResponse.json({
+ status: session.status,
+ message: `Session is ${session.status}`,
+ })
+ } catch (error) {
+ console.error("WhatsApp QR error:", error)
+ return NextResponse.json({ status: "ERROR", error: String(error) })
+ }
+}
+
+/**
+ * POST /api/whatsapp/qr - Start or restart a session
+ */
+export async function POST() {
+ try {
+ // Check if session exists
+ const sessRes = await fetch(`${WAHA_URL}/api/sessions`, {
+ headers: { "X-Api-Key": WAHA_KEY },
+ signal: AbortSignal.timeout(5000),
+ })
+ const sessions = await sessRes.json()
+ const existing = Array.isArray(sessions)
+ ? sessions.find((s: { name: string }) => s.name === WAHA_SESSION)
+ : null
+
+ if (existing) {
+ // Stop and restart
+ await fetch(`${WAHA_URL}/api/sessions/stop`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Api-Key": WAHA_KEY },
+ body: JSON.stringify({ name: WAHA_SESSION }),
+ signal: AbortSignal.timeout(10000),
+ })
+ // Small delay
+ await new Promise(r => setTimeout(r, 2000))
+ }
+
+ // Start session with webhook
+ const startRes = await fetch(`${WAHA_URL}/api/sessions/start`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Api-Key": WAHA_KEY },
+ body: JSON.stringify({
+ name: WAHA_SESSION,
+ config: {
+ webhooks: [
+ {
+ url: `${process.env.BASE_URL || "https://pledge.quikcue.com"}/api/whatsapp/webhook`,
+ events: ["message"],
+ },
+ ],
+ },
+ }),
+ signal: AbortSignal.timeout(15000),
+ })
+
+ const result = await startRes.json()
+ return NextResponse.json({ success: true, status: result.status || "STARTING" })
+ } catch (error) {
+ console.error("WhatsApp session start error:", error)
+ return NextResponse.json({ success: false, error: String(error) })
+ }
+}
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 a3d7bf7..db5d1af 100644
--- a/pledge-now-pay-later/src/app/dashboard/settings/page.tsx
+++ b/pledge-now-pay-later/src/app/dashboard/settings/page.tsx
@@ -1,12 +1,15 @@
"use client"
-import { useState, useEffect } from "react"
+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 { Building2, CreditCard, Palette, Check, Loader2, AlertCircle, MessageCircle, Radio } from "lucide-react"
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
@@ -30,9 +33,7 @@ export default function SettingsPage() {
useEffect(() => {
fetch("/api/settings", { headers: { "x-org-id": "demo" } })
.then((r) => r.json())
- .then((data) => {
- if (data.name) setSettings(data)
- })
+ .then((data) => { if (data.name) setSettings(data) })
.catch(() => setError("Failed to load settings"))
.finally(() => setLoading(false))
}, [])
@@ -46,105 +47,47 @@ export default function SettingsPage() {
headers: { "Content-Type": "application/json", "x-org-id": "demo" },
body: JSON.stringify(data),
})
- if (res.ok) {
- setSaved(section)
- setTimeout(() => setSaved(null), 2000)
- } else {
- setError("Failed to save")
- }
- } catch {
- setError("Failed to save")
- }
+ if (res.ok) { setSaved(section); setTimeout(() => setSaved(null), 2000) }
+ else setError("Failed to save")
+ } catch { setError("Failed to save") }
setSaving(null)
}
- if (loading) {
- return (
-
-
-
- )
- }
+ if (loading) return
+ if (!settings) return
- if (!settings) {
- return (
-
-
-
Failed to load settings
-
- )
- }
-
- const update = (key: keyof OrgSettings, value: string) => {
- setSettings((s) => s ? { ...s, [key]: value } : s)
- }
+ const update = (key: keyof OrgSettings, value: string) => setSettings((s) => s ? { ...s, [key]: value } : s)
return (
-
Settings
-
Configure your organisation's payment details
+
Settings
+
Configure your organisation's payment details and integrations
- {error && (
-
- {error}
-
- )}
+ {error &&
{error}
}
+
+ {/* WhatsApp — MOST IMPORTANT, first */}
+
{/* Bank Details */}
-
- Bank Account Details
-
-
- These details are shown to donors when they choose bank transfer.
-
+ Bank Account
+ Shown to donors who choose bank transfer. Each pledge gets a unique reference.
-
-
- Bank Name
- update("bankName", e.target.value)} />
-
-
- Account Name
- update("bankAccountName", e.target.value)} />
-
+
-
-
- Sort Code
- update("bankSortCode", e.target.value)} placeholder="XX-XX-XX" />
-
-
- Account Number
- update("bankAccountNo", e.target.value)} placeholder="XXXXXXXX" />
-
+
-
-
Reference Prefix
-
update("refPrefix", e.target.value)} maxLength={4} />
-
Max 4 chars. Used in payment references, e.g. {settings.refPrefix}-XXXX-50
-
-
save("bank", {
- bankName: settings.bankName,
- bankSortCode: settings.bankSortCode,
- bankAccountNo: settings.bankAccountNo,
- bankAccountName: settings.bankAccountName,
- refPrefix: settings.refPrefix,
- })}
- disabled={saving === "bank"}
- >
- {saving === "bank" ? (
- <> Saving...>
- ) : saved === "bank" ? (
- <> Saved!>
- ) : (
- "Save Bank Details"
- )}
+
+ save("bank", { bankName: settings.bankName, bankSortCode: settings.bankSortCode, bankAccountNo: settings.bankAccountNo, bankAccountName: settings.bankAccountName, refPrefix: settings.refPrefix })} disabled={saving === "bank"}>
+ {saving === "bank" ? <> Saving> : saved === "bank" ? <> Saved!> : "Save Bank Details"}
@@ -152,116 +95,40 @@ export default function SettingsPage() {
{/* GoCardless */}
-
- GoCardless (Direct Debit)
-
-
- Connect GoCardless to enable Direct Debit collection.
-
+ GoCardless (Direct Debit)
+ Enable Direct Debit collection protected by the DD Guarantee.
-
- Access Token
- update("gcAccessToken", e.target.value)}
- placeholder="sandbox_xxxxx or live_xxxxx"
- />
-
-
-
Environment
-
+
Access Token update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" />
+
+
Environment
+
{["sandbox", "live"].map((env) => (
- update("gcEnvironment", env)}
- className={`px-4 py-2 rounded-xl text-sm 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 hover:border-gray-300"
- }`}
- >
- {env.charAt(0).toUpperCase() + env.slice(1)}
- {env === "live" && settings.gcEnvironment === "live" && " ⚠️"}
+ 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" && "⚠️"}
))}
- {settings.gcEnvironment === "live" && (
-
⚠️ Live mode will create real Direct Debit mandates
- )}
-
save("gc", {
- gcAccessToken: settings.gcAccessToken,
- gcEnvironment: settings.gcEnvironment,
- })}
- disabled={saving === "gc"}
- >
- {saving === "gc" ? (
- <> Saving...>
- ) : saved === "gc" ? (
- <> Connected!>
- ) : (
- "Save GoCardless Settings"
- )}
+ save("gc", { gcAccessToken: settings.gcAccessToken, gcEnvironment: settings.gcEnvironment })} disabled={saving === "gc"}>
+ {saving === "gc" ? <> Saving> : saved === "gc" ? <> Saved!> : "Save GoCardless"}
- {/* WhatsApp (WAHA) */}
-
-
{/* Branding */}
-
- Branding
-
-
- Customise the look of your pledge pages.
-
+ Branding
-
-
Organisation Name
-
update("name", e.target.value)}
- />
+
Organisation Name update("name", e.target.value)} />
+
-
-
save("brand", {
- name: settings.name,
- primaryColor: settings.primaryColor,
- })}
- disabled={saving === "brand"}
- >
- {saving === "brand" ? (
- <> Saving...>
- ) : saved === "brand" ? (
- <> Saved!>
- ) : (
- "Save Branding"
- )}
+ save("brand", { name: settings.name, primaryColor: settings.primaryColor })} disabled={saving === "brand"}>
+ {saving === "brand" ? <> Saving> : saved === "brand" ? <> Saved!> : "Save Branding"}
@@ -269,58 +136,160 @@ export default function SettingsPage() {
)
}
-function WhatsAppStatus() {
- const [status, setStatus] = useState<{ connected: boolean; session: string; version?: string } | null>(null)
- const [checking, setChecking] = useState(true)
+// ─── WhatsApp Connection Panel ───────────────────────────────────
- useEffect(() => {
- fetch("/api/whatsapp/send")
- .then(r => r.json())
- .then(data => setStatus(data))
- .catch(() => setStatus({ connected: false, session: "default" }))
- .finally(() => setChecking(false))
+function WhatsAppPanel() {
+ const [status, setStatus] = useState("loading")
+ const [qrImage, setQrImage] = useState(null)
+ const [phone, setPhone] = useState("")
+ const [pushName, setPushName] = useState("")
+ const [starting, setStarting] = useState(false)
+
+ const checkStatus = useCallback(async () => {
+ try {
+ const res = await fetch("/api/whatsapp/qr")
+ const data = await res.json()
+ setStatus(data.status)
+ if (data.qrImage) setQrImage(data.qrImage)
+ if (data.phone) setPhone(data.phone)
+ if (data.pushName) setPushName(data.pushName)
+ } catch {
+ setStatus("ERROR")
+ }
}, [])
- return (
-
-
-
- WhatsApp Integration
- {checking ? (
-
- ) : status?.connected ? (
- Connected
- ) : (
- Not Connected
- )}
-
-
- Send pledge receipts, payment reminders, and bank details via WhatsApp.
- Donors can reply PAID, HELP, or CANCEL.
-
-
-
- {status?.connected ? (
-
-
✅ WhatsApp is active
-
- Session: {status.session} · WAHA {status.version || ""}
-
-
-
Auto-sends: Pledge receipts with bank details
-
Reminders: Gentle → Nudge → Urgent → Final
-
Chatbot: Donors reply PAID, HELP, CANCEL, STATUS
+ useEffect(() => {
+ checkStatus()
+ // Poll every 5 seconds when waiting for QR scan
+ const interval = setInterval(checkStatus, 5000)
+ return () => clearInterval(interval)
+ }, [checkStatus])
+
+ const startSession = async () => {
+ setStarting(true)
+ try {
+ await fetch("/api/whatsapp/qr", { method: "POST" })
+ // Wait a bit for session to initialize
+ await new Promise(r => setTimeout(r, 3000))
+ await checkStatus()
+ } catch { /* ignore */ }
+ setStarting(false)
+ }
+
+ if (status === "CONNECTED") {
+ return (
+
+
+
+ WhatsApp
+ Connected
+
+
+
+
+
+
+
+
+
{pushName || "WhatsApp Business"}
+
+{phone}
+
+
+
- ) : (
-
-
WhatsApp not connected
-
- Connect a WhatsApp number in WAHA ({`waha.quikcue.com`}) to enable automatic messaging.
- The app works without it — messages will be skipped.
-
+
+
+
Auto-Sends
+
Receipts
+
+
+
+
Chatbot
+
PAID / HELP
+
- )}
+
+
+ )
+ }
+
+ if (status === "SCAN_QR_CODE") {
+ return (
+
+
+
+ WhatsApp
+ Scan QR
+
+ Open WhatsApp on your phone → Settings → Linked Devices → Link a Device
+
+
+
+ {qrImage ? (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
Scan with WhatsApp
+
QR refreshes automatically every 5 seconds
+
+
+ Refresh QR
+
+
+
+
+ )
+ }
+
+ // NO_SESSION or STARTING or ERROR
+ return (
+
+
+
+ WhatsApp
+ Offline
+
+
+ Connect WhatsApp to auto-send pledge receipts, payment reminders, and enable a chatbot for donors.
+
+
+
+
+
+
+
+
+
+
Connect your WhatsApp number
+
+ 📨 Pledge receipts with bank transfer details
+ ⏰ Automatic reminders (2d before → due day → 3d after → 10d final)
+ 🤖 Donor chatbot: reply PAID, HELP, CANCEL, STATUS
+ 📊 Volunteer notifications when someone pledges at their table
+
+
+
+
+ {starting ? <> Starting session...> : <> Connect WhatsApp>}
+
+
+ Uses WAHA (WhatsApp HTTP API) · No WhatsApp Business API required · Free tier
+
+
)