Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -0,0 +1,830 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user