"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";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
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 (
{label} {required && * }
{children}
{hint &&
{hint}
}
);
}
function SaveBar({
saving, onSave, result,
}: {
saving: boolean;
onSave: () => void;
result: { ok: boolean; message: string } | null;
}) {
return (
{result ? (
{result.ok ? "✓" : "✗"} {result.message}
) : (
)}
{saving ? "Saving…" : "Save"}
);
}
export default function SiteSettingsPage() {
return (
Loading settings…}>
);
}
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(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 [primaryColor, setPrimaryColor] = useState("#4F46E5");
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
const [accentColor, setAccentColor] = useState("#EC4899");
const [logoUrl, setLogoUrl] = useState("");
const [logoFile, setLogoFile] = useState(null);
const [logoPreview, setLogoPreview] = useState(null);
const [suggestedColors, setSuggestedColors] = useState([]);
const fileRef = useRef(null);
const [faviconUrl, setFaviconUrl] = useState("");
const [faviconFile, setFaviconFile] = useState(null);
const [faviconPreview, setFaviconPreview] = useState(null);
const faviconRef = useRef(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>("/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 || "");
// primary_color falls back to the legacy accent_color value (which used
// to be "the one brand color" before this 3-color system existed).
// accent_color is only trusted as the NEW tertiary color once
// primary_color already exists — otherwise it's still doing legacy-
// primary duty and must not also seed the Accent field.
setPrimaryColor(s.primary_color || s.accent_color || "#4F46E5");
setSecondaryColor(s.secondary_color || "#8B5CF6");
setAccentColor(s.primary_color ? (s.accent_color || "#EC4899") : "#EC4899");
setLogoUrl(s.logo_url || "");
setFaviconUrl(s.favicon_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) => {
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 uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise => {
const fd = new FormData();
fd.append("image", file);
const res = await fetch(`${API_BASE}/api/uploads/${kind}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
const data = await res.json();
return data.url || "";
};
const saveBranding = async () => {
if (!token) return;
setSaving(true);
setResult(null);
try {
let finalLogoUrl = logoUrl;
if (logoFile) finalLogoUrl = await uploadBrandingAsset(logoFile, "logo") || logoUrl;
let finalFaviconUrl = faviconUrl;
if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
body: {
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor,
logo_url: finalLogoUrl,
favicon_url: finalFaviconUrl,
},
});
setLogoUrl(finalLogoUrl);
setLogoFile(null);
setLogoPreview(null);
setFaviconUrl(finalFaviconUrl);
setFaviconFile(null);
setFaviconPreview(null);
setSuggestedColors([]);
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} finally {
setSaving(false);
}
};
const resetBranding = async () => {
if (!token) return;
if (!confirm("Reset branding to defaults? This clears your custom colors, logo, and favicon.")) return;
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
});
setPrimaryColor("#4F46E5");
setSecondaryColor("#8B5CF6");
setAccentColor("#EC4899");
setLogoUrl(""); setLogoFile(null); setLogoPreview(null);
setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null);
setSuggestedColors([]);
setResult({ ok: true, message: "Reset to defaults." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to reset" });
} finally {
setSaving(false);
}
};
const handleLogoFileChange = async (file: File | undefined) => {
if (!file) return;
setLogoFile(file);
const preview = URL.createObjectURL(file);
setLogoPreview(preview);
setSuggestedColors(await extractDominantColors(preview));
};
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
const saveSmtp = async () => {
if (!token) return;
setSaving(true);
setResult(null);
try {
const updates: Record = {
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 Loading settings…
;
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
const currentFaviconSrc = faviconPreview || (faviconUrl ? resolveToApiOrigin(faviconUrl) : null);
return (
{/* Header */}
Site Settings
Configure your organisation, branding, email, and legal pages.
router.push("/dashboard/admin")}
>
Back
{/* Tab bar */}
{TABS.map((tab) => {
const Icon = tab.icon;
return (
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"
}`}
>
{tab.label}
);
})}
{/* ── Organisation ─────────────────────────────────────────────────── */}
{activeTab === "organisation" && (
)}
{/* ── Branding ─────────────────────────────────────────────────────── */}
{activeTab === "branding" && (
Branding
{currentLogoSrc && (
{/* eslint-disable-next-line @next/next/no-img-element */}
{ setLogoUrl(""); setLogoFile(null); setLogoPreview(null); setSuggestedColors([]); if (fileRef.current) fileRef.current.value = ""; }}
>
Remove
)}
handleLogoFileChange(e.target.files?.[0])} className="text-sm" />
{suggestedColors.length > 0 && (
Suggested from your logo — click a swatch to apply it
{suggestedColors.map((hex) => (
setPrimaryColor(hex)}>P
setSecondaryColor(hex)}>S
setAccentColor(hex)}>A
))}
)}
{currentFaviconSrc && (
{/* eslint-disable-next-line @next/next/no-img-element */}
{ setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null); if (faviconRef.current) faviconRef.current.value = ""; }}
>
Remove
)}
{
const file = e.target.files?.[0];
if (!file) return;
setFaviconFile(file);
setFaviconPreview(URL.createObjectURL(file));
}} className="text-sm" />
Reset to defaults
{result && (
{result.ok ? "✓" : "✗"} {result.message}
)}
{saving ? "Saving…" : "Save"}
)}
{/* ── Notifications ─────────────────────────────────────────────────── */}
{activeTab === "notifications" && (
Notifications
setNotifEmails(e.target.value)} />
)}
{/* ── Email / SMTP ──────────────────────────────────────────────────── */}
{activeTab === "email" && (
)}
{/* ── Legal ─────────────────────────────────────────────────────────── */}
{activeTab === "legal" && (
)}
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
{activeTab === "whatsapp" &&
}
{activeTab === "branding" && (
)}
);
}
// ─── 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 = {
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 = {
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 (
);
}
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 {children}
;
}
function StepIndicator({ step }: { step: number }) {
const steps = [
{ n: 1, label: "Access Token" },
{ n: 2, label: "Session Instance" },
{ n: 3, label: "Connected" },
];
return (
{steps.map((s, i) => {
const done = step > s.n;
const current = step === s.n;
return (
{done ? "✓" : s.n}
{s.label}
{i < steps.length - 1 && (
)}
);
})}
);
}
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 = {
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 (
{isBusy && }
{isBusy ? busyLabel : label}
);
}
/** `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(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(null);
const [statusMsg, setStatusMsg] = useState(null);
const [qrSrc, setQrSrc] = useState(null);
const [pairingPhone, setPairingPhone] = useState("");
const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(null);
const fetchConfig = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch("/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("/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(`/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("/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 (
Loading…
);
}
return (
WhatsApp integration
Powered by WAWP — used to send tickets and notifications via WhatsApp.
{actionMsg &&
{actionMsg.text} }
{step === 1 && (
)}
{step === 2 && (
Step 2 — Set Up Session Instance
Token: {cfg?.tokenMasked}
You need a WAWP session instance. Either create a brand-new one, or enter an existing Instance ID.
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
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
{instanceMode === "create" && (
Click below to create a new WAWP session. The Instance ID will be saved automatically.
{savingInstance && }
{savingInstance ? "Creating…" : "Create Instance"}
)}
{instanceMode === "enter" && (
)}
← Change access token
)}
{step === 3 && (
<>
Session Status
Refresh
{status === null ? (
Fetching status…
) : (
{STATUS_ICONS[status] ?? "⚪"}
{status}
)}
{statusMsg && {statusMsg}
}
{status === "FAILED" && (
The session has failed. The system will attempt to auto-restart. You can also restart manually below.
)}
{status && POLLING_STATUSES.has(status) && (
Auto-refreshing every 5 seconds…
)}
Token: {cfg?.tokenMasked || "—"}
Instance: {cfg?.instanceId || "—"}
{status === "SCAN_QR_CODE" && (
Scan QR Code
Refresh QR
Open WhatsApp → Linked Devices → Link a Device, then scan the code below.
{qrSrc ? (
// eslint-disable-next-line @next/next/no-img-element
) : (
Loading QR…
)}
QR codes expire after ~20 seconds — click Refresh QR if it stops working.
)}
{status === "SCAN_QR_CODE" && (
)}
Session Controls
doAction("start")} />
doAction("restart")} />
{
if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return;
doAction("logout");
}} />
Instance Management
Create a brand-new instance or permanently delete the current one. Deleting will require you to set up a new instance.
doAction("create-instance")} />
{
if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return;
doAction("delete-instance");
}} />
Update Credentials
expand ▾
>
)}
WhatsApp notifications powered by{" "}
WAWP
. Session auto-recovers on failure; admin alert sent if recovery fails.
);
}