Events and the organisation profile now have an address, with a "Directions" link and an embedded Google Maps view (no API key required) shown on event pages, event cards, and the Contact page. New events default their location to the org's configured address.
1219 lines
54 KiB
TypeScript
1219 lines
54 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";
|
|
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 (
|
|
<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 [primaryColor, setPrimaryColor] = useState("#4F46E5");
|
|
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
|
|
const [accentColor, setAccentColor] = useState("#EC4899");
|
|
const [logoUrl, setLogoUrl] = useState("");
|
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
|
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const [faviconUrl, setFaviconUrl] = useState("");
|
|
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
|
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
|
|
const faviconRef = 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 || "");
|
|
// 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<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 uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
|
|
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<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);
|
|
const currentFaviconSrc = faviconPreview || (faviconUrl ? resolveToApiOrigin(faviconUrl) : null);
|
|
|
|
return (
|
|
<div className={activeTab === "branding" ? "max-w-6xl mx-auto w-full" : "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={activeTab === "branding" ? "grid lg:grid-cols-5 gap-5 items-start" : ""}>
|
|
<div className={`border rounded-xl p-5 bg-white shadow-sm ${activeTab === "branding" ? "lg:col-span-3" : ""}`}>
|
|
{/* ── 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="Your Organisation"
|
|
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" hint="Used as the default location for new events.">
|
|
<input className={inputCls} placeholder="123 Church St, City"
|
|
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
|
|
{orgAddress.trim() && (
|
|
<a href={mapsSearchUrl(orgAddress.trim())} target="_blank" rel="noopener noreferrer"
|
|
className="inline-block text-xs text-brand-600 hover:underline mt-1">
|
|
View on map ↗
|
|
</a>
|
|
)}
|
|
</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>
|
|
|
|
<div className="grid sm:grid-cols-3 gap-4">
|
|
<ColorPickerField label="Primary" hint="Buttons, links, nav highlights, and the email header/CTA."
|
|
value={primaryColor} onChange={setPrimaryColor} />
|
|
<ColorPickerField label="Secondary" hint="Subtle backgrounds and outline-button borders/text."
|
|
value={secondaryColor} onChange={setSecondaryColor} />
|
|
<ColorPickerField label="Accent" hint="Hover states and highlight chips/badges."
|
|
value={accentColor} onChange={setAccentColor} />
|
|
</div>
|
|
|
|
<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); setSuggestedColors([]); if (fileRef.current) fileRef.current.value = ""; }}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
)}
|
|
<input ref={fileRef} type="file" accept="image/*" onChange={e => handleLogoFileChange(e.target.files?.[0])} className="text-sm" />
|
|
{suggestedColors.length > 0 && (
|
|
<div className="mt-3 border rounded-lg p-3 bg-gray-50">
|
|
<p className="text-xs font-medium text-gray-600 mb-2">Suggested from your logo — click a swatch to apply it</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{suggestedColors.map((hex) => (
|
|
<div key={hex} className="flex items-center gap-1 border rounded-lg bg-white p-1">
|
|
<div className="w-6 h-6 rounded" style={{ background: hex }} title={hex} />
|
|
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setPrimaryColor(hex)}>P</button>
|
|
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setSecondaryColor(hex)}>S</button>
|
|
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setAccentColor(hex)}>A</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Field>
|
|
|
|
<Field label="Favicon" hint="ICO, PNG, or SVG, max 2 MB, ideally square. Shown in the browser tab — falls back to the default if not set.">
|
|
{currentFaviconSrc && (
|
|
<div className="mb-3 flex items-center gap-3">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={currentFaviconSrc} alt="Current favicon" className="h-8 w-8 object-contain border rounded p-1 bg-gray-50" />
|
|
<button
|
|
type="button"
|
|
className="text-xs text-red-500 hover:text-red-700"
|
|
onClick={() => { setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null); if (faviconRef.current) faviconRef.current.value = ""; }}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
)}
|
|
<input ref={faviconRef} type="file" accept=".ico,.png,.svg,image/x-icon,image/png,image/svg+xml" onChange={e => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setFaviconFile(file);
|
|
setFaviconPreview(URL.createObjectURL(file));
|
|
}} className="text-sm" />
|
|
</Field>
|
|
|
|
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={resetBranding}
|
|
className="px-4 py-2 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Reset to defaults
|
|
</button>
|
|
<div className="flex items-center 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>
|
|
)}
|
|
<button
|
|
type="button"
|
|
disabled={saving}
|
|
onClick={saveBranding}
|
|
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>
|
|
</div>
|
|
</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>
|
|
|
|
{activeTab === "branding" && (
|
|
<div className="lg:col-span-2 lg:sticky lg:top-4">
|
|
<BrandingPreviewPanel
|
|
primaryColor={primaryColor}
|
|
secondaryColor={secondaryColor}
|
|
accentColor={accentColor}
|
|
logoSrc={currentLogoSrc}
|
|
orgName={orgName}
|
|
orgTagline={orgTagline}
|
|
/>
|
|
</div>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|