Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar shell, per-page help guides, and a layout/content pass across every remaining page) plus backend fixes to the dashboard KPI stats: - Admin/Supervisor dashboard KPIs (revenue, donations, registrations, tickets sold) now use a rolling trailing-month window (today back one calendar month, e.g. 9 May - 8 June if today is 8 June) instead of calendar month-to-date, which under-counted for most of the month. The comparison window shifts the same way, so like is still compared with like. - Reports deep-links from those stat tiles now match the same window (range=trailing_month, replacing range=this_month). - Design tokens (brand-* Tailwind scale + shadcn CSS variables), a site-wide contextual help button, fixed dashboard sidebar/navbar, Admin/Supervisor/Staff/User dashboard rebuilds backed by a new GET /api/stats/overview endpoint, a dedicated Contact page, Site Settings restyle with WhatsApp config folded in, and an Account activity feed backed by a new SecurityEvent model. - Every remaining page (home, events, registration flow, auth, legal, payment results, and every Admin/Supervisor/Staff/User tool page) restyled onto the same design tokens, several with real layout upgrades (home hero, events list/detail, donate page, auth pages). - 20+ new dedicated help guides so the whole site has page-specific help content instead of falling back to a generic guide. - Assorted fixes surfaced along the way: donation-leg double-counting in payment stats, donations not counting toward revenue, refund netting in per-method report breakdowns, and donation over-allocation after a refund. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1073 lines
47 KiB
TypeScript
1073 lines
47 KiB
TypeScript
"use client";
|
|
|
|
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
|
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
|
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react";
|
|
|
|
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
|
|
|
|
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
|
{ id: "organisation", label: "Organisation", icon: Building2 },
|
|
{ id: "branding", label: "Branding", icon: Palette },
|
|
{ id: "notifications", label: "Notifications", icon: Bell },
|
|
{ id: "email", label: "Email", icon: Mail },
|
|
{ id: "legal", label: "Legal", icon: Scale },
|
|
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
|
];
|
|
|
|
const inputCls =
|
|
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
|
|
|
function Field({
|
|
label, hint, required, children,
|
|
}: {
|
|
label: string; hint?: string; required?: boolean; children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
{label} {required && <span className="text-red-500">*</span>}
|
|
</label>
|
|
{children}
|
|
{hint && <p className="text-xs text-gray-400 mt-1">{hint}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SaveBar({
|
|
saving, onSave, result,
|
|
}: {
|
|
saving: boolean;
|
|
onSave: () => void;
|
|
result: { ok: boolean; message: string } | null;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
|
|
{result ? (
|
|
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
|
|
{result.ok ? "✓" : "✗"} {result.message}
|
|
</span>
|
|
) : (
|
|
<span />
|
|
)}
|
|
<button
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={onSave}
|
|
className="px-6 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
|
>
|
|
{saving ? "Saving…" : "Save"}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SiteSettingsPage() {
|
|
return (
|
|
<Suspense fallback={<div className="p-6 text-sm text-gray-500">Loading settings…</div>}>
|
|
<SiteSettingsPageInner />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function SiteSettingsPageInner() {
|
|
const { token } = useAuth();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { reload: reloadSettings } = useSiteSettings();
|
|
|
|
const initialTab = TABS.some(t => t.id === searchParams.get("tab")) ? (searchParams.get("tab") as TabId) : "organisation";
|
|
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
|
const [loadingInitial, setLoadingInitial] = useState(true);
|
|
|
|
// Per-tab save state
|
|
const [saving, setSaving] = useState(false);
|
|
const [result, setResult] = useDismissingState<{ ok: boolean; message: string } | null>(null);
|
|
|
|
// ── Organisation ──
|
|
const [orgName, setOrgName] = useState("");
|
|
const [orgTagline, setOrgTagline] = useState("");
|
|
const [orgEmail, setOrgEmail] = useState("");
|
|
const [orgPhone, setOrgPhone] = useState("");
|
|
const [orgAddress, setOrgAddress] = useState("");
|
|
const [appBaseUrl, setAppBaseUrl] = useState("");
|
|
|
|
// ── Branding ──
|
|
const [accentColor, setAccentColor] = useState("#2563eb");
|
|
const [logoUrl, setLogoUrl] = useState("");
|
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
// ── Notifications ──
|
|
const [notifEmails, setNotifEmails] = useState("");
|
|
|
|
// ── SMTP ──
|
|
const [smtpHost, setSmtpHost] = useState("");
|
|
const [smtpPort, setSmtpPort] = useState("587");
|
|
const [smtpSecure, setSmtpSecure] = useState(false);
|
|
const [smtpFrom, setSmtpFrom] = useState("");
|
|
const [smtpUser, setSmtpUser] = useState("");
|
|
const [smtpPass, setSmtpPass] = useState("");
|
|
const [smtpPassSet, setSmtpPassSet] = useState(false);
|
|
const [smtpTesting, setSmtpTesting] = useState(false);
|
|
const [smtpTestResult, setSmtpTestResult] = useDismissingState<{ ok: boolean; message: string; raw?: string } | null>(null);
|
|
|
|
// ── Legal ──
|
|
const [legalOperatorName, setLegalOperatorName] = useState("");
|
|
const [legalIoName, setLegalIoName] = useState("");
|
|
const [legalIoEmail, setLegalIoEmail] = useState("");
|
|
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
|
|
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
|
|
|
|
// ── Load all settings once ────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token })
|
|
.then((s) => {
|
|
setOrgName(s.org_name || "");
|
|
setOrgTagline(s.org_tagline || "");
|
|
setOrgEmail(s.org_email || "");
|
|
setOrgPhone(s.org_phone || "");
|
|
setOrgAddress(s.org_address || "");
|
|
setAppBaseUrl(s.app_base_url || "");
|
|
setAccentColor(s.accent_color || "#2563eb");
|
|
setLogoUrl(s.logo_url || "");
|
|
setNotifEmails(s.reg_notification_emails || "");
|
|
setSmtpHost(s.smtp_host || "");
|
|
setSmtpPort(s.smtp_port || "587");
|
|
setSmtpSecure((s.smtp_secure || "").toLowerCase() === "true");
|
|
setSmtpFrom(s.smtp_from || "");
|
|
setSmtpUser(s.smtp_user || "");
|
|
setSmtpPassSet(!!s.smtp_pass && s.smtp_pass !== "");
|
|
setSmtpPass("");
|
|
setLegalOperatorName(s.legal_operator_name || "");
|
|
setLegalIoName(s.legal_io_name || "");
|
|
setLegalIoEmail(s.legal_io_email || "");
|
|
setLegalWebsiteUrl(s.legal_website_url || "");
|
|
setLegalEffectiveDate(s.legal_effective_date || "");
|
|
})
|
|
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
|
.finally(() => setLoadingInitial(false));
|
|
}, [token]);
|
|
|
|
// Clear result when switching tabs
|
|
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
|
|
|
|
// ── Save helpers ──────────────────────────────────────────────────────────
|
|
const save = async (updates: Record<string, string>) => {
|
|
if (!token) return;
|
|
setSaving(true);
|
|
setResult(null);
|
|
try {
|
|
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
|
|
setResult({ ok: true, message: "Saved." });
|
|
reloadSettings();
|
|
} catch (e: any) {
|
|
setResult({ ok: false, message: e?.message || "Failed to save" });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveOrganisation = () => save({
|
|
org_name: orgName.trim(),
|
|
org_tagline: orgTagline.trim(),
|
|
org_email: orgEmail.trim(),
|
|
org_phone: orgPhone.trim(),
|
|
org_address: orgAddress.trim(),
|
|
app_base_url: appBaseUrl.trim(),
|
|
});
|
|
|
|
const saveBranding = async () => {
|
|
if (!token) return;
|
|
setSaving(true);
|
|
setResult(null);
|
|
try {
|
|
let finalLogoUrl = logoUrl;
|
|
if (logoFile) {
|
|
const fd = new FormData();
|
|
fd.append("image", logoFile);
|
|
const res = await fetch(`${API_BASE}/api/uploads/logo`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
body: fd,
|
|
});
|
|
if (!res.ok) throw new Error("Logo upload failed");
|
|
const data = await res.json();
|
|
finalLogoUrl = data.url || logoUrl;
|
|
}
|
|
await apiFetch("/api/settings", {
|
|
method: "PUT", authToken: token,
|
|
body: { accent_color: accentColor, logo_url: finalLogoUrl },
|
|
});
|
|
setLogoUrl(finalLogoUrl);
|
|
setLogoFile(null);
|
|
setLogoPreview(null);
|
|
setResult({ ok: true, message: "Saved." });
|
|
reloadSettings();
|
|
} catch (e: any) {
|
|
setResult({ ok: false, message: e?.message || "Failed to save" });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
|
|
|
|
const saveSmtp = async () => {
|
|
if (!token) return;
|
|
setSaving(true);
|
|
setResult(null);
|
|
try {
|
|
const updates: Record<string, string> = {
|
|
smtp_host: smtpHost.trim(),
|
|
smtp_port: smtpPort.trim(),
|
|
smtp_secure: smtpSecure ? "true" : "false",
|
|
smtp_from: smtpFrom.trim(),
|
|
smtp_user: smtpUser.trim(),
|
|
};
|
|
if (smtpPass.trim() !== "") updates.smtp_pass = smtpPass.trim();
|
|
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
|
|
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
|
|
setResult({ ok: true, message: "Saved." });
|
|
reloadSettings();
|
|
} catch (e: any) {
|
|
setResult({ ok: false, message: e?.message || "Failed to save" });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const saveLegal = () => save({
|
|
legal_operator_name: legalOperatorName.trim(),
|
|
legal_io_name: legalIoName.trim(),
|
|
legal_io_email: legalIoEmail.trim(),
|
|
legal_website_url: legalWebsiteUrl.trim(),
|
|
legal_effective_date: legalEffectiveDate.trim(),
|
|
});
|
|
|
|
const handleTestSmtp = async () => {
|
|
if (!token) return;
|
|
setSmtpTesting(true);
|
|
setSmtpTestResult(null);
|
|
try {
|
|
await apiFetch("/api/settings/test-smtp", {
|
|
method: "POST",
|
|
authToken: token,
|
|
body: {
|
|
host: smtpHost.trim(),
|
|
port: smtpPort.trim(),
|
|
secure: smtpSecure,
|
|
from: smtpFrom.trim(),
|
|
user: smtpUser.trim(),
|
|
pass: smtpPass.trim() !== "" ? smtpPass.trim() : "••••••••",
|
|
},
|
|
});
|
|
setSmtpTestResult({ ok: true, message: "Connection successful — check your inbox for a test email." });
|
|
} catch (e: any) {
|
|
setSmtpTestResult({ ok: false, message: e?.message || "SMTP test failed", raw: e?.data?.raw });
|
|
} finally {
|
|
setSmtpTesting(false);
|
|
}
|
|
};
|
|
|
|
if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings…</div>;
|
|
|
|
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
|
|
|
|
return (
|
|
<div className="max-w-3xl mx-auto w-full">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-gray-900">Site Settings</h1>
|
|
<p className="text-sm text-gray-500 mt-1">Configure your organisation, branding, email, and legal pages.</p>
|
|
</div>
|
|
<button
|
|
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200"
|
|
onClick={() => router.push("/dashboard/admin")}
|
|
>
|
|
Back
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab bar */}
|
|
<div className="flex items-center gap-2 flex-wrap mb-4">
|
|
{TABS.map((tab) => {
|
|
const Icon = tab.icon;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => switchTab(tab.id)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border transition-colors ${
|
|
activeTab === tab.id
|
|
? "bg-brand-600 text-white border-brand-600"
|
|
: "bg-white text-gray-700 border-gray-200 hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
|
{/* ── Organisation ─────────────────────────────────────────────────── */}
|
|
{activeTab === "organisation" && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-semibold text-gray-900">Organisation details</h2>
|
|
<Field label="Organisation name" required>
|
|
<input className={inputCls} placeholder="Hope Family Church"
|
|
value={orgName} onChange={e => setOrgName(e.target.value)} />
|
|
</Field>
|
|
<Field label="Tagline">
|
|
<input className={inputCls} placeholder="Connecting community through events"
|
|
value={orgTagline} onChange={e => setOrgTagline(e.target.value)} />
|
|
</Field>
|
|
<div className="grid sm:grid-cols-2 gap-4">
|
|
<Field label="Contact email">
|
|
<input type="email" className={inputCls} placeholder="admin@yourchurch.org"
|
|
value={orgEmail} onChange={e => setOrgEmail(e.target.value)} />
|
|
</Field>
|
|
<Field label="Phone">
|
|
<input className={inputCls} placeholder="+27 12 345 6789"
|
|
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
<Field label="Address">
|
|
<input className={inputCls} placeholder="123 Church St, City"
|
|
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
|
|
</Field>
|
|
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
|
|
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
|
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
|
|
</Field>
|
|
<SaveBar saving={saving} onSave={saveOrganisation} result={result} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Branding ─────────────────────────────────────────────────────── */}
|
|
{activeTab === "branding" && (
|
|
<div className="space-y-5">
|
|
<h2 className="text-lg font-semibold text-gray-900">Branding</h2>
|
|
<Field label="Accent / brand colour" hint="Only recolors the organisation name next to the logo in the top navigation bar — every other button, link, and highlight across the site uses a fixed color scheme.">
|
|
<div className="flex items-center gap-3">
|
|
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
|
|
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
|
<input className={`${inputCls} font-mono`} placeholder="#2563eb"
|
|
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
|
</div>
|
|
<div className="mt-2 h-6 rounded-lg transition-colors" style={{ background: accentColor }} />
|
|
</Field>
|
|
|
|
<Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar.">
|
|
{currentLogoSrc && (
|
|
<div className="mb-3 flex items-center gap-3">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={currentLogoSrc} alt="Current logo" className="h-16 object-contain border rounded p-1 bg-gray-50" />
|
|
<button
|
|
type="button"
|
|
className="text-xs text-red-500 hover:text-red-700"
|
|
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); if (fileRef.current) fileRef.current.value = ""; }}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
)}
|
|
<input ref={fileRef} type="file" accept="image/*" onChange={e => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setLogoFile(file);
|
|
setLogoPreview(URL.createObjectURL(file));
|
|
}} className="text-sm" />
|
|
</Field>
|
|
|
|
<SaveBar saving={saving} onSave={saveBranding} result={result} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Notifications ─────────────────────────────────────────────────── */}
|
|
{activeTab === "notifications" && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-semibold text-gray-900">Notifications</h2>
|
|
<Field
|
|
label="Registration notification emails"
|
|
hint="Who gets notified when someone registers for an event. Separate multiple addresses with commas."
|
|
>
|
|
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
|
|
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
|
|
</Field>
|
|
<SaveBar saving={saving} onSave={saveNotifications} result={result} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Email / SMTP ──────────────────────────────────────────────────── */}
|
|
{activeTab === "email" && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-semibold text-gray-900">Email</h2>
|
|
<p className="text-sm text-gray-500">
|
|
Outgoing email for tickets, payment confirmations, and account notifications.
|
|
Leave blank to use server environment variables. The password is stored encrypted.
|
|
</p>
|
|
|
|
<div className="grid sm:grid-cols-2 gap-4">
|
|
<Field label="SMTP host">
|
|
<input className={inputCls} placeholder="smtp.yourprovider.com"
|
|
value={smtpHost} onChange={e => setSmtpHost(e.target.value)} />
|
|
</Field>
|
|
<Field label="Port">
|
|
<input type="number" className={inputCls} placeholder="587"
|
|
value={smtpPort} onChange={e => setSmtpPort(e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input type="checkbox" id="smtpSecure" checked={smtpSecure}
|
|
onChange={e => setSmtpSecure(e.target.checked)} className="rounded" />
|
|
<label htmlFor="smtpSecure" className="text-sm text-gray-700">Use TLS/SSL (port 465)</label>
|
|
</div>
|
|
|
|
<Field label="From address" hint="The address emails appear to come from.">
|
|
<input type="email" className={inputCls} placeholder="no-reply@yourchurch.org"
|
|
value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} />
|
|
</Field>
|
|
|
|
<div className="grid sm:grid-cols-2 gap-4">
|
|
<Field label="SMTP username">
|
|
<input type="email" className={inputCls} placeholder="mail@yourchurch.org"
|
|
value={smtpUser} onChange={e => setSmtpUser(e.target.value)} autoComplete="username" />
|
|
</Field>
|
|
<Field
|
|
label="SMTP password"
|
|
hint={smtpPassSet ? "Password is saved. Leave blank to keep it." : undefined}
|
|
>
|
|
<div className="relative">
|
|
<input type="password" className={inputCls}
|
|
placeholder={smtpPassSet ? "Leave blank to keep current" : "Enter password"}
|
|
value={smtpPass} onChange={e => setSmtpPass(e.target.value)} autoComplete="new-password" />
|
|
{smtpPassSet && smtpPass === "" && (
|
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-green-600 pointer-events-none">● saved</span>
|
|
)}
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
|
|
{/* Test connection */}
|
|
<div className="pt-1 space-y-2">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<button
|
|
type="button"
|
|
disabled={smtpTesting || !smtpHost.trim()}
|
|
onClick={handleTestSmtp}
|
|
className="px-4 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border"
|
|
>
|
|
{smtpTesting ? "Testing…" : "Test connection"}
|
|
</button>
|
|
{smtpTestResult && (
|
|
<span className={`text-sm ${smtpTestResult.ok ? "text-green-600" : "text-red-600"}`}>
|
|
{smtpTestResult.ok ? "✓" : "✗"} {smtpTestResult.message}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{smtpTestResult && !smtpTestResult.ok && smtpTestResult.raw && (
|
|
<details className="text-xs text-gray-500">
|
|
<summary className="cursor-pointer select-none hover:text-gray-700">Show technical details</summary>
|
|
<pre className="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto whitespace-pre-wrap break-all">{smtpTestResult.raw}</pre>
|
|
</details>
|
|
)}
|
|
</div>
|
|
|
|
<SaveBar saving={saving} onSave={saveSmtp} result={result} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Legal ─────────────────────────────────────────────────────────── */}
|
|
{activeTab === "legal" && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-semibold text-gray-900">Legal</h2>
|
|
<p className="text-sm text-gray-500">
|
|
These values populate the Terms of Use and Privacy Policy pages automatically.
|
|
</p>
|
|
|
|
<Field label="Operator / responsible party"
|
|
hint='Shown in the "Owned and operated by" line of the Terms of Use.'>
|
|
<input className={inputCls} placeholder="Jane Smith on behalf of Example Church, City, South Africa"
|
|
value={legalOperatorName} onChange={e => setLegalOperatorName(e.target.value)} />
|
|
</Field>
|
|
<Field label="Website URL (without https://)">
|
|
<input className={inputCls} placeholder="events.yourchurch.org"
|
|
value={legalWebsiteUrl} onChange={e => setLegalWebsiteUrl(e.target.value)} />
|
|
</Field>
|
|
<Field label="Effective date">
|
|
<input className={inputCls} placeholder="April 2026"
|
|
value={legalEffectiveDate} onChange={e => setLegalEffectiveDate(e.target.value)} />
|
|
</Field>
|
|
|
|
<div className="border-t pt-4">
|
|
<p className="text-sm font-medium text-gray-700 mb-3">Information Officer (POPIA)</p>
|
|
<div className="grid sm:grid-cols-2 gap-4">
|
|
<Field label="Full name">
|
|
<input className={inputCls} placeholder="Jane Smith"
|
|
value={legalIoName} onChange={e => setLegalIoName(e.target.value)} />
|
|
</Field>
|
|
<Field label="Email">
|
|
<input type="email" className={inputCls} placeholder="io@yourchurch.org"
|
|
value={legalIoEmail} onChange={e => setLegalIoEmail(e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
|
|
<SaveBar saving={saving} onSave={saveLegal} result={result} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
|
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── WhatsApp tab (migrated from the old standalone /dashboard/admin/whatsapp page) ───
|
|
|
|
type WAStatus =
|
|
| "WORKING"
|
|
| "CONNECTED"
|
|
| "SCAN_QR_CODE"
|
|
| "STARTING"
|
|
| "FAILED"
|
|
| "STOPPED"
|
|
| string;
|
|
|
|
interface ConfigResponse {
|
|
tokenMasked: string;
|
|
instanceId: string;
|
|
hasToken: boolean;
|
|
hasInstance: boolean;
|
|
configured: boolean;
|
|
}
|
|
|
|
interface StatusResponse {
|
|
status: WAStatus;
|
|
message?: string;
|
|
}
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
WORKING: "bg-green-100 text-green-800 border-green-300",
|
|
CONNECTED: "bg-green-100 text-green-800 border-green-300",
|
|
SCAN_QR_CODE: "bg-yellow-100 text-yellow-800 border-yellow-300",
|
|
STARTING: "bg-blue-100 text-blue-800 border-blue-300",
|
|
FAILED: "bg-red-100 text-red-800 border-red-300",
|
|
STOPPED: "bg-gray-100 text-gray-700 border-gray-300",
|
|
};
|
|
|
|
const STATUS_ICONS: Record<string, string> = {
|
|
WORKING: "🟢",
|
|
CONNECTED: "🟢",
|
|
SCAN_QR_CODE: "📷",
|
|
STARTING: "🔄",
|
|
FAILED: "🔴",
|
|
STOPPED: "⚫",
|
|
};
|
|
|
|
const ACTIVE_STATUSES = new Set(["WORKING", "CONNECTED"]);
|
|
const POLLING_STATUSES = new Set(["STARTING", "SCAN_QR_CODE", "FAILED", "STOPPED"]);
|
|
|
|
function Spinner() {
|
|
return (
|
|
<svg className="animate-spin h-4 w-4 text-brand-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function WAAlert({ type, children }: { type: "ok" | "err" | "info"; children: React.ReactNode }) {
|
|
const cls =
|
|
type === "ok" ? "bg-green-50 text-green-800 border-green-200"
|
|
: type === "err" ? "bg-red-50 text-red-800 border-red-200"
|
|
: "bg-blue-50 text-blue-800 border-blue-200";
|
|
return <div className={`p-3 rounded-lg text-sm border ${cls}`}>{children}</div>;
|
|
}
|
|
|
|
function StepIndicator({ step }: { step: number }) {
|
|
const steps = [
|
|
{ n: 1, label: "Access Token" },
|
|
{ n: 2, label: "Session Instance" },
|
|
{ n: 3, label: "Connected" },
|
|
];
|
|
return (
|
|
<div className="flex items-center gap-0">
|
|
{steps.map((s, i) => {
|
|
const done = step > s.n;
|
|
const current = step === s.n;
|
|
return (
|
|
<React.Fragment key={s.n}>
|
|
<div className="flex flex-col items-center">
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors ${
|
|
done ? "bg-green-500 border-green-500 text-white"
|
|
: current ? "bg-brand-600 border-brand-600 text-white"
|
|
: "bg-white border-gray-300 text-gray-400"
|
|
}`}>
|
|
{done ? "✓" : s.n}
|
|
</div>
|
|
<span className={`text-xs mt-1 font-medium ${done || current ? "text-gray-700" : "text-gray-400"}`}>{s.label}</span>
|
|
</div>
|
|
{i < steps.length - 1 && (
|
|
<div className={`flex-1 h-0.5 mb-5 mx-1 transition-colors ${done ? "bg-green-400" : "bg-gray-200"}`} />
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type ButtonColor = "green" | "amber" | "blue" | "red-outline";
|
|
|
|
function ActionButton({
|
|
label, busyLabel, isBusy, disabled, color, onClick,
|
|
}: {
|
|
label: string; busyLabel: string; isBusy: boolean; disabled: boolean; color: ButtonColor; onClick: () => void;
|
|
}) {
|
|
const base = "flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 transition-colors";
|
|
const colors: Record<ButtonColor, string> = {
|
|
green: "bg-green-600 text-white hover:bg-green-700",
|
|
amber: "bg-amber-500 text-white hover:bg-amber-600",
|
|
blue: "bg-blue-600 text-white hover:bg-blue-700",
|
|
"red-outline": "border border-red-600 text-red-600 hover:bg-red-50",
|
|
};
|
|
return (
|
|
<button onClick={onClick} disabled={disabled} className={`${base} ${colors[color]}`}>
|
|
{isBusy && <Spinner />}
|
|
{isBusy ? busyLabel : label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** `active` gates all polling — only run status/QR polling while this tab is actually visible. */
|
|
function WhatsAppTab({ active }: { active: boolean }) {
|
|
const { token } = useAuth();
|
|
|
|
const [cfg, setCfg] = useState<ConfigResponse | null>(null);
|
|
const [cfgLoading, setCfgLoading] = useState(true);
|
|
|
|
const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3;
|
|
|
|
const [inputToken, setInputToken] = useState("");
|
|
const [savingToken, setSavingToken] = useState(false);
|
|
|
|
const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create");
|
|
const [inputInstanceId, setInputInstanceId] = useState("");
|
|
const [savingInstance, setSavingInstance] = useState(false);
|
|
|
|
const [status, setStatus] = useState<WAStatus | null>(null);
|
|
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
|
const [qrSrc, setQrSrc] = useState<string | null>(null);
|
|
const [pairingPhone, setPairingPhone] = useState("");
|
|
|
|
const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
|
|
const fetchConfig = useCallback(async () => {
|
|
if (!token) return;
|
|
try {
|
|
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
|
|
setCfg(res);
|
|
} catch {
|
|
// network error — leave cfg null, tab shows loading state
|
|
} finally {
|
|
setCfgLoading(false);
|
|
}
|
|
}, [token]);
|
|
|
|
useEffect(() => { if (active) fetchConfig(); }, [fetchConfig, active]);
|
|
|
|
const fetchStatus = useCallback(async () => {
|
|
if (!token || step !== 3) return;
|
|
try {
|
|
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
|
|
setStatus(res.status ?? null);
|
|
setStatusMsg(res.message ?? null);
|
|
} catch (e: any) {
|
|
await fetchConfig();
|
|
setStatus("FAILED");
|
|
setStatusMsg(null);
|
|
}
|
|
}, [token, step, fetchConfig]);
|
|
|
|
useEffect(() => { if (active && step === 3) fetchStatus(); }, [step, fetchStatus, active]);
|
|
|
|
// Auto-poll status when not stable — only while this tab is active.
|
|
useEffect(() => {
|
|
if (!active || step !== 3 || status === null) return;
|
|
if (ACTIVE_STATUSES.has(status)) return;
|
|
const id = setInterval(fetchStatus, 5_000);
|
|
return () => clearInterval(id);
|
|
}, [active, step, status, fetchStatus]);
|
|
|
|
const fetchQr = useCallback(async () => {
|
|
if (!token) return;
|
|
try {
|
|
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
|
|
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
|
|
} catch {
|
|
setQrSrc(null);
|
|
}
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
if (active && status === "SCAN_QR_CODE") { fetchQr(); }
|
|
else { setQrSrc(null); }
|
|
}, [status, fetchQr, active]);
|
|
|
|
// Auto-refresh QR every 20s while waiting — only while this tab is active.
|
|
useEffect(() => {
|
|
if (!active || status !== "SCAN_QR_CODE") return;
|
|
const id = setInterval(fetchQr, 20_000);
|
|
return () => clearInterval(id);
|
|
}, [active, status, fetchQr]);
|
|
|
|
const doAction = async (action: string, body?: object) => {
|
|
if (!token) return;
|
|
setBusy(action);
|
|
setActionMsg(null);
|
|
try {
|
|
const res = await apiFetch<any>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
|
|
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
|
|
await fetchStatus();
|
|
await fetchConfig();
|
|
} catch (e: any) {
|
|
let msg = e?.message || `${action} failed.`;
|
|
try { msg = JSON.parse(msg)?.message || msg; } catch {}
|
|
await fetchConfig();
|
|
if (!msg.includes("SESSION_NOT_FOUND")) {
|
|
setActionMsg({ type: "err", text: msg });
|
|
}
|
|
await fetchStatus();
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const saveToken = async () => {
|
|
if (!inputToken.trim()) {
|
|
setActionMsg({ type: "err", text: "Please enter your WAWP access token." });
|
|
return;
|
|
}
|
|
setSavingToken(true);
|
|
setActionMsg(null);
|
|
try {
|
|
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: inputToken.trim(), instanceId: "" } });
|
|
setInputToken("");
|
|
await fetchConfig();
|
|
} catch (e: any) {
|
|
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
|
|
} finally {
|
|
setSavingToken(false);
|
|
}
|
|
};
|
|
|
|
const saveInstanceId = async () => {
|
|
if (!inputInstanceId.trim()) {
|
|
setActionMsg({ type: "err", text: "Please enter the Instance ID." });
|
|
return;
|
|
}
|
|
setSavingInstance(true);
|
|
setActionMsg(null);
|
|
try {
|
|
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "", instanceId: inputInstanceId.trim() } });
|
|
setInputInstanceId("");
|
|
await fetchConfig();
|
|
} catch (e: any) {
|
|
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
|
|
} finally {
|
|
setSavingInstance(false);
|
|
}
|
|
};
|
|
|
|
const createInstance = async () => {
|
|
setSavingInstance(true);
|
|
setActionMsg(null);
|
|
try {
|
|
const res = await apiFetch<any>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
|
|
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
|
|
await fetchConfig();
|
|
} catch (e: any) {
|
|
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
|
|
} finally {
|
|
setSavingInstance(false);
|
|
}
|
|
};
|
|
|
|
const requestPairingCode = async () => {
|
|
if (!pairingPhone.trim()) {
|
|
setActionMsg({ type: "err", text: "Enter your phone number first." });
|
|
return;
|
|
}
|
|
await doAction("request-code", { phoneNumber: pairingPhone.trim() });
|
|
};
|
|
|
|
const resetToken = async () => {
|
|
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
|
|
try {
|
|
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "_clear_", instanceId: "" } });
|
|
} catch {}
|
|
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
|
|
};
|
|
|
|
if (cfgLoading) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
<Spinner /> Loading…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">WhatsApp integration</h2>
|
|
<p className="text-sm text-gray-500">Powered by WAWP — used to send tickets and notifications via WhatsApp.</p>
|
|
</div>
|
|
|
|
<StepIndicator step={step} />
|
|
|
|
{actionMsg && <WAAlert type={actionMsg.type}>{actionMsg.text}</WAAlert>}
|
|
|
|
{step === 1 && (
|
|
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
|
<h3 className="text-base font-semibold">Step 1 — Enter your WAWP Access Token</h3>
|
|
<p className="text-sm text-gray-600">
|
|
Your access token is found in your WAWP account dashboard at{" "}
|
|
<a href="https://app.wawp.net" target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">app.wawp.net</a>.
|
|
</p>
|
|
<div className="space-y-2">
|
|
<label className="block text-xs font-medium text-gray-700">Access Token</label>
|
|
<input
|
|
type="password"
|
|
value={inputToken}
|
|
onChange={e => setInputToken(e.target.value)}
|
|
onKeyDown={e => e.key === "Enter" && saveToken()}
|
|
placeholder="Paste your WAWP access token"
|
|
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
/>
|
|
</div>
|
|
<button onClick={saveToken} disabled={savingToken} className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
|
{savingToken && <Spinner />}
|
|
{savingToken ? "Saving…" : "Save Token & Continue"}
|
|
</button>
|
|
</section>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<section className="border rounded-xl p-5 bg-gray-50 space-y-5">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-base font-semibold">Step 2 — Set Up Session Instance</h3>
|
|
<span className="text-xs text-gray-400 font-mono bg-gray-100 px-2 py-0.5 rounded">Token: {cfg?.tokenMasked}</span>
|
|
</div>
|
|
<p className="text-sm text-gray-600">You need a WAWP session instance. Either create a brand-new one, or enter an existing Instance ID.</p>
|
|
|
|
<div className="flex rounded-lg border overflow-hidden text-sm font-medium">
|
|
<button onClick={() => setInstanceMode("create")} className={`flex-1 px-4 py-2.5 transition-colors ${instanceMode === "create" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50"}`}>
|
|
Create new instance
|
|
</button>
|
|
<button onClick={() => setInstanceMode("enter")} className={`flex-1 px-4 py-2.5 border-l transition-colors ${instanceMode === "enter" ? "bg-brand-600 text-white" : "bg-white text-gray-600 hover:bg-gray-50"}`}>
|
|
Enter existing ID
|
|
</button>
|
|
</div>
|
|
|
|
{instanceMode === "create" && (
|
|
<div className="space-y-3">
|
|
<p className="text-sm text-gray-600">Click below to create a new WAWP session. The Instance ID will be saved automatically.</p>
|
|
<button onClick={createInstance} disabled={savingInstance} className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
|
{savingInstance && <Spinner />}
|
|
{savingInstance ? "Creating…" : "Create Instance"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{instanceMode === "enter" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">Instance ID</label>
|
|
<input
|
|
type="text"
|
|
value={inputInstanceId}
|
|
onChange={e => setInputInstanceId(e.target.value)}
|
|
onKeyDown={e => e.key === "Enter" && saveInstanceId()}
|
|
placeholder="e.g. BF14B761C364"
|
|
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
/>
|
|
</div>
|
|
<button onClick={saveInstanceId} disabled={savingInstance} className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
|
{savingInstance && <Spinner />}
|
|
{savingInstance ? "Saving…" : "Save & Continue"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<button onClick={resetToken} className="text-xs text-gray-400 hover:text-red-500 hover:underline">← Change access token</button>
|
|
</section>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<>
|
|
<section className="border rounded-xl p-5 bg-gray-50 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-base font-semibold">Session Status</h3>
|
|
<button onClick={fetchStatus} className="text-xs text-brand-600 hover:underline">Refresh</button>
|
|
</div>
|
|
|
|
{status === null ? (
|
|
<div className="flex items-center gap-2 text-sm text-gray-500"><Spinner /> Fetching status…</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg">{STATUS_ICONS[status] ?? "⚪"}</span>
|
|
<span className={`inline-flex items-center px-3 py-1 rounded-full border text-sm font-semibold ${STATUS_COLORS[status] ?? "bg-gray-100 text-gray-700 border-gray-300"}`}>{status}</span>
|
|
</div>
|
|
)}
|
|
|
|
{statusMsg && <p className="text-xs text-gray-500">{statusMsg}</p>}
|
|
|
|
{status === "FAILED" && (
|
|
<WAAlert type="err">The session has failed. The system will attempt to auto-restart. You can also restart manually below.</WAAlert>
|
|
)}
|
|
|
|
{status && POLLING_STATUSES.has(status) && (
|
|
<p className="text-xs text-gray-400 flex items-center gap-1"><Spinner /> Auto-refreshing every 5 seconds…</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-3 pt-2 border-t text-xs text-gray-500">
|
|
<span>Token: <span className="font-mono">{cfg?.tokenMasked || "—"}</span></span>
|
|
<span>Instance: <span className="font-mono">{cfg?.instanceId || "—"}</span></span>
|
|
</div>
|
|
</section>
|
|
|
|
{status === "SCAN_QR_CODE" && (
|
|
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-base font-semibold">Scan QR Code</h3>
|
|
<button onClick={fetchQr} className="text-xs text-brand-600 hover:underline">Refresh QR</button>
|
|
</div>
|
|
<p className="text-sm text-gray-600">Open WhatsApp → Linked Devices → Link a Device, then scan the code below.</p>
|
|
{qrSrc ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={qrSrc} alt="WhatsApp QR Code" className="w-56 h-56 border rounded-lg" />
|
|
) : (
|
|
<div className="flex items-center gap-2 text-sm text-gray-400"><Spinner /> Loading QR…</div>
|
|
)}
|
|
<p className="text-xs text-gray-400">QR codes expire after ~20 seconds — click Refresh QR if it stops working.</p>
|
|
</section>
|
|
)}
|
|
|
|
{status === "SCAN_QR_CODE" && (
|
|
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
|
<h3 className="text-base font-semibold">Link by Phone Number Instead</h3>
|
|
<p className="text-sm text-gray-600">Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing code on your phone.</p>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="tel"
|
|
value={pairingPhone}
|
|
onChange={e => setPairingPhone(e.target.value)}
|
|
placeholder="082 123 4567"
|
|
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
|
|
/>
|
|
<button onClick={requestPairingCode} disabled={busy === "request-code"} className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
|
{busy === "request-code" && <Spinner />}
|
|
{busy === "request-code" ? "Sending…" : "Send code"}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
|
<h3 className="text-base font-semibold">Session Controls</h3>
|
|
<div className="flex flex-wrap gap-3">
|
|
<ActionButton label="Start" busyLabel="Starting…" isBusy={busy === "start"} disabled={!!busy} color="green" onClick={() => doAction("start")} />
|
|
<ActionButton label="Restart" busyLabel="Restarting…" isBusy={busy === "restart"} disabled={!!busy} color="amber" onClick={() => doAction("restart")} />
|
|
<ActionButton label="Logout" busyLabel="Logging out…" isBusy={busy === "logout"} disabled={!!busy} color="red-outline" onClick={() => {
|
|
if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return;
|
|
doAction("logout");
|
|
}} />
|
|
</div>
|
|
</section>
|
|
|
|
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
|
|
<h3 className="text-base font-semibold">Instance Management</h3>
|
|
<p className="text-sm text-gray-600">Create a brand-new instance or permanently delete the current one. Deleting will require you to set up a new instance.</p>
|
|
<div className="flex flex-wrap gap-3">
|
|
<ActionButton label="Create New Instance" busyLabel="Creating…" isBusy={busy === "create-instance"} disabled={!!busy} color="blue" onClick={() => doAction("create-instance")} />
|
|
<ActionButton label="Delete Instance" busyLabel="Deleting…" isBusy={busy === "delete-instance"} disabled={!!busy} color="red-outline" onClick={() => {
|
|
if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return;
|
|
doAction("delete-instance");
|
|
}} />
|
|
</div>
|
|
</section>
|
|
|
|
<details className="border rounded-xl bg-gray-50">
|
|
<summary className="p-5 cursor-pointer text-sm font-semibold text-gray-700 select-none list-none flex items-center justify-between">
|
|
<span>Update Credentials</span>
|
|
<span className="text-gray-400 text-xs">expand ▾</span>
|
|
</summary>
|
|
<div className="px-5 pb-5 space-y-3 border-t pt-4">
|
|
<p className="text-sm text-gray-600">Change your WAWP access token or Instance ID. Leave a field blank to keep the current value.</p>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">New Access Token</label>
|
|
<input type="password" value={inputToken} onChange={e => setInputToken(e.target.value)} placeholder="Leave blank to keep current token" className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 mb-1">New Instance ID</label>
|
|
<input type="text" value={inputInstanceId} onChange={e => setInputInstanceId(e.target.value)} placeholder="Leave blank to keep current instance" className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-500" />
|
|
</div>
|
|
<button
|
|
onClick={async () => {
|
|
if (!inputToken.trim() && !inputInstanceId.trim()) {
|
|
setActionMsg({ type: "err", text: "Enter at least one field to update." });
|
|
return;
|
|
}
|
|
setSavingToken(true);
|
|
setActionMsg(null);
|
|
try {
|
|
await apiFetch("/api/whatsapp/config", {
|
|
method: "POST",
|
|
authToken: token!,
|
|
body: { token: inputToken.trim() || undefined, instanceId: inputInstanceId.trim() || undefined },
|
|
});
|
|
setActionMsg({ type: "ok", text: "Credentials updated." });
|
|
setInputToken("");
|
|
setInputInstanceId("");
|
|
await fetchConfig();
|
|
} catch (e: any) {
|
|
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
|
|
} finally {
|
|
setSavingToken(false);
|
|
}
|
|
}}
|
|
disabled={savingToken}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
|
|
>
|
|
{savingToken && <Spinner />}
|
|
{savingToken ? "Saving…" : "Save Changes"}
|
|
</button>
|
|
</div>
|
|
</details>
|
|
</>
|
|
)}
|
|
|
|
<p className="text-xs text-gray-400 text-center">
|
|
WhatsApp notifications powered by{" "}
|
|
<a href="https://wawp.net" target="_blank" rel="noopener noreferrer" className="hover:underline">WAWP</a>
|
|
. Session auto-recovers on failure; admin alert sent if recovery fails.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|