From 6894f091fd8427300ba6c16d829f413abc6586dc Mon Sep 17 00:00:00 2001 From: Omair Saleh Date: Tue, 3 Mar 2026 05:19:54 +0800 Subject: [PATCH] waha: QR pairing in dashboard, whatsapp/qr API, settings overhaul - /api/whatsapp/qr: GET returns session status + QR image, POST starts/restarts session - Settings page: WhatsApp panel shows QR code for pairing, connected status with phone info - WAHA session started with webhook pointing to /api/whatsapp/webhook - WAHA_API_URL updated to external https://waha.quikcue.com (cross-stack DNS doesn't work) - Auto-polls every 5 seconds during QR scan state - Shows connected state with phone number, push name, feature summary --- .../src/app/api/whatsapp/qr/route.ts | 123 +++++ .../src/app/dashboard/settings/page.tsx | 419 ++++++++---------- 2 files changed, 317 insertions(+), 225 deletions(-) create mode 100644 pledge-now-pay-later/src/app/api/whatsapp/qr/route.ts 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

Failed to load settings

- 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. -
-
- - update("bankName", e.target.value)} /> -
-
- - update("bankAccountName", e.target.value)} /> -
+
+
update("bankName", e.target.value)} placeholder="e.g. Barclays" />
+
update("bankAccountName", e.target.value)} />
-
-
- - update("bankSortCode", e.target.value)} placeholder="XX-XX-XX" /> -
-
- - update("bankAccountNo", e.target.value)} placeholder="XXXXXXXX" /> -
+
+
update("bankSortCode", e.target.value)} placeholder="20-30-80" />
+
update("bankAccountNo", e.target.value)} placeholder="12345678" />
-
- - update("refPrefix", e.target.value)} maxLength={4} /> -

Max 4 chars. Used in payment references, e.g. {settings.refPrefix}-XXXX-50

-
- @@ -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. -
- - update("gcAccessToken", e.target.value)} - placeholder="sandbox_xxxxx or live_xxxxx" - /> -
-
- -
+
update("gcAccessToken", e.target.value)} placeholder="sandbox_xxxxx or live_xxxxx" />
+
+ +
{["sandbox", "live"].map((env) => ( - ))}
- {settings.gcEnvironment === "live" && ( -

⚠️ Live mode will create real Direct Debit mandates

- )}
- - {/* WhatsApp (WAHA) */} - - {/* Branding */} - - Branding - - - Customise the look of your pledge pages. - + Branding -
- - update("name", e.target.value)} - /> +
update("name", e.target.value)} />
+
+ +
update("primaryColor", e.target.value)} className="w-12 h-9 p-0.5" /> update("primaryColor", e.target.value)} className="flex-1" />
-
- -
- update("primaryColor", e.target.value)} - className="w-14 h-11 p-1" - /> - update("primaryColor", e.target.value)} - className="flex-1" - /> -
-
- @@ -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

+
+
+

Reminders

+

4-step

+
+
+

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 */} + WhatsApp QR Code +
+ +
+
+ ) : ( +
+ +
+ )} +
+

Scan with WhatsApp

+

QR refreshes automatically every 5 seconds

+
+ +
+
+
+ ) + } + + // 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
  • +
+
+
+ +

+ Uses WAHA (WhatsApp HTTP API) · No WhatsApp Business API required · Free tier +

+
)