3d381944d2
Next.js + Express event management app for Hope Family Church.
830 lines
33 KiB
TypeScript
830 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useRouter } from "next/navigation";
|
|
import { apiFetch } from "@/lib/api";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
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-indigo-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 Alert({
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function WhatsAppAdminPage() {
|
|
const { user, loading, token } = useAuth();
|
|
const router = useRouter();
|
|
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (!user || !isAdmin) router.replace("/dashboard");
|
|
}, [user, loading, isAdmin, router]);
|
|
|
|
// ── Config state (drives wizard steps) ──────────────────────────────────────
|
|
const [cfg, setCfg] = useState<ConfigResponse | null>(null);
|
|
const [cfgLoading, setCfgLoading] = useState(true);
|
|
|
|
// Derived wizard step: 1 = no token, 2 = token but no instance, 3 = fully configured
|
|
const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3;
|
|
|
|
// ── Step 1 inputs ────────────────────────────────────────────────────────────
|
|
const [inputToken, setInputToken] = useState("");
|
|
const [savingToken, setSavingToken] = useState(false);
|
|
|
|
// ── Step 2 inputs ────────────────────────────────────────────────────────────
|
|
const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create");
|
|
const [inputInstanceId, setInputInstanceId] = useState("");
|
|
const [savingInstance, setSavingInstance] = useState(false);
|
|
|
|
// ── Step 3: session state ────────────────────────────────────────────────────
|
|
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("");
|
|
|
|
// ── Shared action feedback ───────────────────────────────────────────────────
|
|
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
|
|
// ── Load config ──────────────────────────────────────────────────────────────
|
|
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, user sees loading state
|
|
} finally {
|
|
setCfgLoading(false);
|
|
}
|
|
}, [token]);
|
|
|
|
useEffect(() => { fetchConfig(); }, [fetchConfig]);
|
|
|
|
// ── Status fetch (step 3 only) ───────────────────────────────────────────────
|
|
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) {
|
|
// Re-fetch config — if the session was not found, backend clears the
|
|
// instance ID and the step recomputes to 2 (Session Instance setup).
|
|
await fetchConfig();
|
|
setStatus("FAILED");
|
|
setStatusMsg(null);
|
|
}
|
|
}, [token, step, fetchConfig]);
|
|
|
|
useEffect(() => { if (step === 3) fetchStatus(); }, [step, fetchStatus]);
|
|
|
|
// Auto-poll status when not stable
|
|
useEffect(() => {
|
|
if (step !== 3 || status === null) return;
|
|
if (ACTIVE_STATUSES.has(status)) return;
|
|
const id = setInterval(fetchStatus, 5_000);
|
|
return () => clearInterval(id);
|
|
}, [step, status, fetchStatus]);
|
|
|
|
// ── QR fetch ─────────────────────────────────────────────────────────────────
|
|
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 (status === "SCAN_QR_CODE") { fetchQr(); }
|
|
else { setQrSrc(null); }
|
|
}, [status, fetchQr]);
|
|
|
|
// Auto-refresh QR every 20s while waiting
|
|
useEffect(() => {
|
|
if (status !== "SCAN_QR_CODE") return;
|
|
const id = setInterval(fetchQr, 20_000);
|
|
return () => clearInterval(id);
|
|
}, [status, fetchQr]);
|
|
|
|
// ── Generic session action ───────────────────────────────────────────────────
|
|
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 {}
|
|
// SESSION_NOT_FOUND: backend cleared the instance ID — re-fetch config so
|
|
// the wizard steps back to Step 2; no need to show an error message.
|
|
await fetchConfig();
|
|
if (!msg.includes("SESSION_NOT_FOUND")) {
|
|
setActionMsg({ type: "err", text: msg });
|
|
}
|
|
await fetchStatus();
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
// ─── Step 1: Save token ──────────────────────────────────────────────────────
|
|
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);
|
|
}
|
|
};
|
|
|
|
// ─── Step 2: Enter existing instance ID ──────────────────────────────────────
|
|
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() },
|
|
// token left blank → backend keeps existing token
|
|
});
|
|
setInputInstanceId("");
|
|
await fetchConfig();
|
|
} catch (e: any) {
|
|
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
|
|
} finally {
|
|
setSavingInstance(false);
|
|
}
|
|
};
|
|
|
|
// ─── Step 2: Create new instance ─────────────────────────────────────────────
|
|
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);
|
|
}
|
|
};
|
|
|
|
// ─── Pairing code ────────────────────────────────────────────────────────────
|
|
const requestPairingCode = async () => {
|
|
if (!pairingPhone.trim()) {
|
|
setActionMsg({ type: "err", text: "Enter your phone number first." });
|
|
return;
|
|
}
|
|
await doAction("request-code", { phoneNumber: pairingPhone.trim() });
|
|
};
|
|
|
|
// ─── Reset credentials (go back to step 1) ───────────────────────────────────
|
|
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 {}
|
|
// Force a re-read — even if the above fails, clear local state
|
|
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
|
|
};
|
|
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
// Render
|
|
// ────────────────────────────────────────────────────────────────────────────
|
|
|
|
if (loading || cfgLoading) {
|
|
return (
|
|
<div className="max-w-xl mx-auto w-full p-6 flex items-center gap-2 text-sm text-gray-500">
|
|
<Spinner /> Loading…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-xl mx-auto w-full p-6 space-y-6">
|
|
{/* Back button */}
|
|
<button
|
|
onClick={() => router.push("/dashboard")}
|
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
|
</svg>
|
|
Back
|
|
</button>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-3xl">💬</span>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold leading-tight">WhatsApp Integration</h1>
|
|
<p className="text-sm text-gray-500">Powered by WAWP</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step indicator */}
|
|
<StepIndicator step={step} />
|
|
|
|
{/* Global action message */}
|
|
{actionMsg && (
|
|
<Alert type={actionMsg.type}>{actionMsg.text}</Alert>
|
|
)}
|
|
|
|
{/* ── STEP 1: Enter access token ──────────────────────────────────────── */}
|
|
{step === 1 && (
|
|
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-4">
|
|
<h2 className="text-lg font-semibold">Step 1 — Enter your WAWP Access Token</h2>
|
|
<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-indigo-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-indigo-500"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={saveToken}
|
|
disabled={savingToken}
|
|
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
|
>
|
|
{savingToken && <Spinner />}
|
|
{savingToken ? "Saving…" : "Save Token & Continue"}
|
|
</button>
|
|
</section>
|
|
)}
|
|
|
|
{/* ── STEP 2: Instance ID ─────────────────────────────────────────────── */}
|
|
{step === 2 && (
|
|
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-5">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">Step 2 — Set Up Session Instance</h2>
|
|
<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>
|
|
|
|
{/* Tab toggle */}
|
|
<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-indigo-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-indigo-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-indigo-600 text-white text-sm font-medium hover:bg-indigo-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-indigo-500"
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={saveInstanceId}
|
|
disabled={savingInstance}
|
|
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-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: Full management ─────────────────────────────────────────── */}
|
|
{step === 3 && (
|
|
<>
|
|
{/* Status card */}
|
|
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">Session Status</h2>
|
|
<button
|
|
onClick={fetchStatus}
|
|
className="text-xs text-indigo-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" && (
|
|
<Alert type="err">
|
|
The session has failed. The system will attempt to auto-restart. You can also
|
|
restart manually below.
|
|
</Alert>
|
|
)}
|
|
|
|
{status && POLLING_STATUSES.has(status) && (
|
|
<p className="text-xs text-gray-400 flex items-center gap-1">
|
|
<Spinner /> Auto-refreshing every 5 seconds…
|
|
</p>
|
|
)}
|
|
|
|
{/* Config info strip */}
|
|
<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>
|
|
|
|
{/* QR Code */}
|
|
{status === "SCAN_QR_CODE" && (
|
|
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">Scan QR Code</h2>
|
|
<button
|
|
onClick={fetchQr}
|
|
className="text-xs text-indigo-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 ? (
|
|
<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>
|
|
)}
|
|
|
|
{/* Pairing code */}
|
|
{status === "SCAN_QR_CODE" && (
|
|
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
|
<h2 className="text-lg font-semibold">Link by Phone Number Instead</h2>
|
|
<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-indigo-500"
|
|
/>
|
|
<button
|
|
onClick={requestPairingCode}
|
|
disabled={busy === "request-code"}
|
|
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
|
|
>
|
|
{busy === "request-code" && <Spinner />}
|
|
{busy === "request-code" ? "Sending…" : "Send code"}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* Session controls */}
|
|
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
|
<h2 className="text-lg font-semibold">Session Controls</h2>
|
|
<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>
|
|
|
|
{/* Instance management */}
|
|
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
|
<h2 className="text-lg font-semibold">Instance Management</h2>
|
|
<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>
|
|
|
|
{/* Update credentials */}
|
|
<details className="border rounded-xl bg-white shadow-sm">
|
|
<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-indigo-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-indigo-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-indigo-600 text-white text-sm font-medium hover:bg-indigo-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>
|
|
);
|
|
}
|
|
|
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
|
|
|
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-indigo-600 border-indigo-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>
|
|
);
|
|
} |