Files
hope-events/frontend/src/app/dashboard/supervisor/whatsapp-attendees/page.tsx
T
joshuaandClaude Sonnet 5 f3e6525467 Add registration status badges and auto-dismissing dashboard messages
Two consistency fixes requested after the payment-method work:

1. Registration status (pending/confirmed/partial_paid/paid/cancelled)
   was printed as a raw string on the user dashboard. Added
   RegistrationStatusBadge mirroring the existing EventStatusBadge
   pattern, using the same status colors already established on
   dashboard/admin/registrations.

2. Inline success/error banners across dashboard pages persisted
   indefinitely. Added a shared useDismissingState hook (drop-in
   useState replacement that auto-clears a truthy value after 7s,
   resetting the timer on each update) and swapped it in across ~24
   dashboard files. Excluded: message-only modal dialogs (ticket-
   scanning's success/error confirmations) and two states that mix
   live form-validation feedback with async results inside actively-
   open forms (the registration-edit modal's editError, the event
   create/edit modal's error) - those keep persisting until the user
   acts, since auto-hiding a "fix this field" message mid-edit would
   be a regression. Also fixed at-the-door's existing bespoke
   auto-dismiss timers (10s/15s, one mislabeled as "5s") to the same
   consistent 7s, and removed admin/settings' manual x dismiss button
   in favor of the same auto-only behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 10:17:53 +02:00

942 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
type UserEntry = { id: string; name: string; phone: string; pref: string };
function toLocalInputValue(d: Date) {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function WhatsAppAttendeesPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<WhatsAppAttendeesPageInner />
</Suspense>
);
}
// ─── Attendees dropdown with preference indicators ────────────────────────────
function AttendeesCheckboxDropdown({
attendees,
loading,
selectedIds,
onChange,
channel = "whatsapp",
}: {
attendees: (Attendee | UserEntry)[];
loading: boolean;
selectedIds: string[];
onChange: (ids: string[]) => void;
channel?: "whatsapp" | "email";
}) {
const [open, setOpen] = useState(false);
const allIds = useMemo(() => attendees.map((a) => a.id), [attendees]);
const allSelected = selectedIds.length > 0 && selectedIds.length === allIds.length;
const toggleAll = (checked: boolean) => onChange(checked ? allIds : []);
const toggleId = (id: string) => {
if (selectedIds.includes(id)) onChange(selectedIds.filter((x) => x !== id));
else onChange([...selectedIds, id]);
};
const prefMatch = (pref: string) =>
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
const prefLabel = (pref: string) => {
if (pref === "both") return "both";
if (pref === "whatsapp") return "WA";
if (pref === "email") return "email";
return pref || "email";
};
const prefColor = (pref: string, match: boolean) =>
match ? "text-green-700 bg-green-50" : "text-amber-700 bg-amber-50";
const summary = loading
? "Loading…"
: attendees.length === 0
? "No attendees"
: allSelected
? `All (${attendees.length})`
: selectedIds.length === 0
? "None selected"
: `${selectedIds.length} selected`;
const mismatched = selectedIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && !prefMatch(a.pref);
});
return (
<div className="relative block w-full max-w-xs">
<button
type="button"
className="w-full border rounded px-3 py-2 text-sm bg-white hover:bg-gray-50 text-left"
onClick={() => setOpen((o) => !o)}
>
{summary}
{mismatched.length > 0 && (
<span className="ml-2 text-xs text-amber-600">({mismatched.length} pref mismatch)</span>
)}
</button>
{open && (
<div className="absolute z-10 mt-1 w-64 max-h-72 overflow-auto bg-white border rounded shadow">
<div className="px-3 py-2 border-b sticky top-0 bg-white space-y-1">
<label className="text-sm flex items-center gap-2">
<input type="checkbox" checked={allSelected} onChange={(e) => toggleAll(e.target.checked)} />
<span className="font-medium">Select all</span>
</label>
<div className="flex gap-1 flex-wrap">
<button
type="button"
className="text-xs px-2 py-0.5 rounded border border-green-300 text-green-700 hover:bg-green-50"
onClick={() => onChange(attendees.filter((a) => prefMatch(a.pref)).map((a) => a.id))}
>
Select {channel === "whatsapp" ? "WhatsApp/both" : "Email/both"}
</button>
<button
type="button"
className="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-600 hover:bg-gray-50"
onClick={() => onChange([])}
>
Clear
</button>
</div>
</div>
{loading ? (
<div className="px-3 py-2 text-sm text-gray-500">Loading</div>
) : attendees.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">No attendees with phone numbers</div>
) : (
<ul className="py-1">
{attendees.map((a) => {
const checked = selectedIds.includes(a.id);
const match = prefMatch(a.pref);
return (
<li key={a.id} className={`px-3 py-1 hover:bg-gray-50 ${!match ? "opacity-75" : ""}`}>
<label className="flex items-center gap-2 text-sm min-w-0">
<input type="checkbox" checked={checked} onChange={() => toggleId(a.id)} />
<span className="truncate flex-1">
{a.name ? `${a.name} (${a.phone})` : a.phone}
</span>
<span className={`text-[10px] px-1 rounded shrink-0 ${prefColor(a.pref, match)}`}>
{prefLabel(a.pref)}
</span>
</label>
</li>
);
})}
</ul>
)}
<div className="px-3 py-2 border-t bg-gray-50 text-right">
<button
type="button"
className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-100"
onClick={() => setOpen(false)}
>
Done
</button>
</div>
</div>
)}
</div>
);
}
// ─── Preference warning banner ────────────────────────────────────────────────
function PrefWarning({ attendees, selectedIds, channel }: { attendees: (Attendee|UserEntry)[]; selectedIds: string[]; channel: "whatsapp" | "email" }) {
const prefMatch = (pref: string) =>
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
const mismatched = useMemo(
() => selectedIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && !prefMatch(a.pref);
}),
[attendees, selectedIds, channel]
);
if (mismatched.length === 0) return null;
return (
<div className="p-3 border rounded bg-amber-50 text-amber-800 text-xs">
<strong>{mismatched.length} selected attendee(s)</strong> have a notification preference that doesn&apos;t include{" "}
{channel === "whatsapp" ? "WhatsApp" : "email"}. They will still receive the message, but it may not be their preferred channel.
{" "}<button
type="button"
className="underline ml-1"
onClick={() => {/* handled by dropdown */}}
>
Use the dropdown to filter by preference.
</button>
</div>
);
}
// ─── WhatsApp Automations Panel ───────────────────────────────────────────────
function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) {
const [autoEventId, setAutoEventId] = React.useState<string>("");
const currentEvent = React.useMemo(() => (events || []).find((e: any) => e.id === autoEventId), [events, autoEventId]);
const preWeekDefault = `Hi {{name}}\n\nJust a friendly reminder that {{event.title}} is one week away!\n\nEvent details: {{event.link}}`;
const finalDefault = `Hi {{name}}\n\nFinal reminder for {{event.title}}.\nPlease have your QR code/ticket ready at entry.\n\nEvent details: {{event.link}}`;
const thanksDefault = `Hi {{name}}\n\nThank you for joining us at {{event.title}}!\nWe hope you had a great time. See you next time!`;
const promoDefault = `Hi {{name}}\n\nWe'd love to see you at our next event: {{promo.title}}.\nFind out more and register here: {{promo.link}}`;
const [enablePre, setEnablePre] = React.useState(true);
const [enableFinal, setEnableFinal] = React.useState(true);
const [enableThanks, setEnableThanks] = React.useState(true);
const [enablePromo, setEnablePromo] = React.useState(false);
const [preBody, setPreBody] = React.useState(preWeekDefault);
const [finalBody, setFinalBody] = React.useState(finalDefault);
const [thanksBody, setThanksBody] = React.useState(thanksDefault);
const [promoBody, setPromoBody] = React.useState(promoDefault);
const [finalHours, setFinalHours] = React.useState<"24" | "48">("24");
const [preWhen, setPreWhen] = React.useState<string>("");
const [finalWhen, setFinalWhen] = React.useState<string>("");
const [thanksWhen, setThanksWhen] = React.useState<string>("");
const [promoWhen, setPromoWhen] = React.useState<string>("");
const [promoEventId, setPromoEventId] = React.useState<string>("");
React.useEffect(() => {
if (!currentEvent) { setPreWhen(""); setFinalWhen(""); setThanksWhen(""); setPromoWhen(""); return; }
try {
const start = new Date(currentEvent.startDate);
const end = new Date(currentEvent.endDate || currentEvent.startDate);
const pre = new Date(start); pre.setDate(pre.getDate() - 7); pre.setHours(9, 0, 0, 0);
setPreWhen(toLocalInputValue(pre));
const fin = new Date(start); fin.setHours(fin.getHours() - (finalHours === "48" ? 48 : 24));
setFinalWhen(toLocalInputValue(fin));
const ty = new Date(end); ty.setDate(ty.getDate() + 1); ty.setHours(9, 0, 0, 0);
setThanksWhen(toLocalInputValue(ty));
const pr = new Date(end); pr.setDate(pr.getDate() + 3); pr.setHours(9, 0, 0, 0);
setPromoWhen(toLocalInputValue(pr));
} catch {}
}, [currentEvent, finalHours]);
const onSchedule = async () => {
try {
onError(null as any); onInfo(null as any);
if (!token) { onError("Not authenticated"); return; }
if (!autoEventId) { onError("Please select an event"); return; }
const jobs: any[] = [];
if (enablePre && preBody.trim() && preWhen) jobs.push({ message: preBody, scheduledAt: new Date(preWhen).toISOString() });
if (enableFinal && finalBody.trim() && finalWhen) jobs.push({ message: finalBody, scheduledAt: new Date(finalWhen).toISOString() });
if (enableThanks && thanksBody.trim() && thanksWhen) jobs.push({ message: thanksBody, scheduledAt: new Date(thanksWhen).toISOString() });
if (enablePromo && promoBody.trim() && promoWhen) jobs.push({ message: promoBody, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined });
if (jobs.length === 0) { onError("Please enable at least one automation and fill in details"); return; }
let scheduled = 0;
for (const job of jobs) {
await apiFetch(`/api/events/${encodeURIComponent(autoEventId)}/whatsapp-attendees/schedule`, {
method: "POST", authToken: token,
body: { message: job.message, scheduledAt: job.scheduledAt, filter: { status: "paid" } },
});
scheduled++;
}
onInfo(`Scheduled ${scheduled} WhatsApp automation(s).`);
} catch (e: any) {
onError(e?.message || "Failed to schedule automations");
}
};
return (
<div className="grid gap-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={(e) => setAutoEventId(e.target.value)}>
<option value="">Select an event</option>
{(events || []).map((ev: any) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
</div>
<div className="grid gap-4">
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Pre-Event Reminder (1 week before)</legend>
<label className="inline-flex items-center gap-2 text-sm mb-2">
<input type="checkbox" checked={enablePre} onChange={(e) => setEnablePre(e.target.checked)} /> Enable
</label>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={preWhen} onChange={(e) => setPreWhen(e.target.value)} />
</div>
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={preBody} onChange={(e) => setPreBody(e.target.value)} />
</fieldset>
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Final Reminder (2448 hrs before)</legend>
<div className="flex items-center gap-4 mb-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={enableFinal} onChange={(e) => setEnableFinal(e.target.checked)} /> Enable
</label>
<label className="text-xs text-gray-600">Hours before:</label>
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={(e) => setFinalHours(e.target.value as any)}>
<option value="24">24</option>
<option value="48">48</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={finalWhen} onChange={(e) => setFinalWhen(e.target.value)} />
</div>
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={finalBody} onChange={(e) => setFinalBody(e.target.value)} />
</fieldset>
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Thank You / Wrap Up</legend>
<label className="inline-flex items-center gap-2 text-sm mb-2">
<input type="checkbox" checked={enableThanks} onChange={(e) => setEnableThanks(e.target.checked)} /> Enable
</label>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={thanksWhen} onChange={(e) => setThanksWhen(e.target.value)} />
</div>
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={thanksBody} onChange={(e) => setThanksBody(e.target.value)} />
</fieldset>
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Next Event Promo</legend>
<div className="flex items-center gap-4 mb-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={enablePromo} onChange={(e) => setEnablePromo(e.target.checked)} /> Enable
</label>
</div>
<div className="grid sm:grid-cols-2 gap-3 mb-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={(e) => setPromoEventId(e.target.value)}>
<option value="">Select event to promote</option>
{(events || []).map((ev: any) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={promoWhen} onChange={(e) => setPromoWhen(e.target.value)} />
</div>
</div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={promoBody} onChange={(e) => setPromoBody(e.target.value)} />
</fieldset>
</div>
<div className="flex items-center gap-2">
<button type="button" className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" onClick={onSchedule}>
Schedule selected
</button>
<div className="text-[11px] text-gray-500">
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{event.link}}"}, {"{{promo.title}}"}, {"{{promo.link}}"}
</div>
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
function WhatsAppAttendeesPageInner() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const preselectEventId = search?.get("eventId") || "";
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<any[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const events = useMemo(() => {
const now = Date.now();
if (evIncludePast) return allEvents;
return allEvents.filter((ev) => !isNaN(new Date(ev.endDate).getTime()) && new Date(ev.endDate).getTime() > now);
}, [allEvents, evIncludePast]);
useEffect(() => {
const loadEvents = async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
setAllEvents((evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
} catch (e: any) {
setError(e?.message || "Failed to load events");
} finally {
setLoadingEvents(false);
}
};
loadEvents();
}, [user, token]);
// ── Attendees tab ──────────────────────────────────────────────────────────
const [eventId, setEventId] = useState<string>(preselectEventId);
useEffect(() => { if (preselectEventId) setEventId(preselectEventId); }, [preselectEventId]);
const [templateKey, setTemplateKey] = useState<"custom" | "payment_reminder" | "event_reminder" | "tickets">("custom");
const [message, setMessage] = useState("");
const [messageDirty, setMessageDirty] = useState(false);
const [status, setStatus] = useState<"any" | "paid" | "unpaid" | "partial_paid" | "cancelled">("any");
const [attendees, setAttendees] = useState<Attendee[]>([]);
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
const [loadingAttendees, setLoadingAttendees] = useState(false);
const [previewCount, setPreviewCount] = useState<number | null>(null);
const [previewSample, setPreviewSample] = useState<{ phone: string; name?: string }[] | null>(null);
const [sending, setSending] = useState(false);
const [scheduledAtLocal, setScheduledAtLocal] = useState<string>("");
const [showInfo, setShowInfo] = useState(false);
const currentEvent = useMemo(() => (events || []).find((e) => e.id === eventId), [events, eventId]);
useEffect(() => {
const title = currentEvent?.title || "the event";
if (templateKey === "payment_reminder") {
if (!messageDirty) setMessage(`Hi {{name}}\n\nFriendly reminder: you have an outstanding balance for ${title}.\n\nPlease settle your balance to secure your tickets. Thank you!`);
} else if (templateKey === "event_reminder") {
if (!messageDirty) setMessage(`Hi {{name}}\n\nA quick reminder about ${title}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`);
} else if (templateKey === "tickets") {
setMessageDirty(false); setMessage("");
}
}, [templateKey, currentEvent]);
useEffect(() => {
const run = async () => {
try {
setLoadingAttendees(true); setAttendees([]); setSelectedAttendeeIds([]);
if (!eventId || !token) return;
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const uniq = new Map<string, Attendee>();
(regs || []).forEach((r: any) => {
const u = r?.user;
if (u?.id && u?.phoneNumber && !u.email?.endsWith("@guest.local") && u.isActive !== false) {
uniq.set(u.id, { id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" });
}
});
const list = Array.from(uniq.values()).sort((a, b) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
setAttendees(list);
// Default: select those with whatsapp/both preference
const matching = list.filter((a) => a.pref === "whatsapp" || a.pref === "both").map((a) => a.id);
setSelectedAttendeeIds(matching.length > 0 ? matching : list.map((a) => a.id));
} catch { } finally { setLoadingAttendees(false); }
};
run();
}, [eventId, token]);
const buildPayload = (dryRun?: boolean) => {
const payload: any = {
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
template: templateKey,
dryRun: dryRun || undefined,
};
if (templateKey !== "tickets") payload.message = message;
return payload;
};
const onPreview = async () => {
try {
setError(null); setInfo(null); setPreviewCount(null); setPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s) with a phone number.`);
} catch (e: any) { setError(e?.message || "Failed to preview"); }
};
const resetAttendeesForm = () => {
setTemplateKey("custom"); setMessage(""); setMessageDirty(false);
setStatus("any"); setScheduledAtLocal(""); setPreviewCount(null); setPreviewSample(null);
setSelectedAttendeeIds(attendees.map((a) => a.id));
};
const onSend = async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
setSending(true);
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
};
const onScheduleSend = async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
if (!scheduledAtLocal) { setError("Scheduled time is required"); return; }
const whenIso = new Date(scheduledAtLocal).toISOString();
const payload: any = {
message: message || undefined,
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
template: templateKey, scheduledAt: whenIso,
};
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled.");
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to schedule"); }
};
// ── Broadcasts tab ─────────────────────────────────────────────────────────
const [users, setUsers] = useState<UserEntry[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [broadcastEventId, setBroadcastEventId] = useState<string>("");
const [broadcastMessage, setBroadcastMessage] = useState("");
const [broadcastPhones, setBroadcastPhones] = useState("");
const [broadcastPreviewCount, setBroadcastPreviewCount] = useState<number | null>(null);
const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{ phone: string; name?: string }[] | null>(null);
const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>("");
useEffect(() => {
const run = async () => {
try {
if (!token) return;
setLoadingUsers(true);
const allUsers = await fetchAllUsers(token);
const mapped = allUsers
.filter((u: any) => u.isActive !== false && !u.email?.endsWith("@guest.local") && u.phoneNumber)
.map((u: any) => ({ id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" }))
.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
setUsers(mapped);
} catch { } finally { setLoadingUsers(false); }
};
run();
}, [token]);
const resetBroadcastForm = () => {
setSelectedUserIds([]); setBroadcastEventId(""); setBroadcastMessage("");
setBroadcastPhones(""); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
setBroadcastScheduledAtLocal("");
};
// ── Scheduled tab ──────────────────────────────────────────────────────────
type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; payload?: any };
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
const [loadingScheduled, setLoadingScheduled] = useState(false);
const [editing, setEditing] = useState<ScheduledJob | null>(null);
const [editMessage, setEditMessage] = useState<string>("");
const [editWhen, setEditWhen] = useState<string>("");
const [savingEdit, setSavingEdit] = useState(false);
const loadScheduled = async () => {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails`, { authToken: token });
// Filter to only WhatsApp jobs
const all = Array.isArray(res?.jobs) ? res.jobs : [];
setScheduled(all.filter((j) => j.channel === "whatsapp" || (j.broadcast && j.channel === "whatsapp")));
} catch { } finally { setLoadingScheduled(false); }
};
useEffect(() => { if (tab === "scheduled") loadScheduled(); }, [tab, token]);
const saveEdit = async () => {
if (!editing) return;
try {
setSavingEdit(true);
if (!token) { setError("Not authenticated"); return; }
const body: any = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editMessage.trim()) body.text = editMessage;
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
setInfo("Scheduled message updated.");
setEditing(null);
loadScheduled();
} catch (e: any) { setError(e?.message || "Failed to update"); } finally { setSavingEdit(false); }
};
const removeJob = async (job: ScheduledJob) => {
try {
if (!token) { setError("Not authenticated"); return; }
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: "DELETE", authToken: token });
setInfo("Scheduled message removed.");
loadScheduled();
} catch (e: any) { setError(e?.message || "Failed to remove"); }
};
// ── Render ─────────────────────────────────────────────────────────────────
const mismatchedAttendees = useMemo(
() => selectedAttendeeIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && a.pref !== "whatsapp" && a.pref !== "both";
}),
[attendees, selectedAttendeeIds]
);
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">WhatsApp Attendees</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
Back
</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use this page.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
{/* Tabs */}
<div className="mb-4 flex items-center gap-2 flex-wrap">
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => (
<label key={t} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
{t.charAt(0).toUpperCase() + t.slice(1)}
</label>
))}
</div>
{/* ── Attendees Tab ── */}
{tab === "attendees" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<div className="flex gap-2 items-center">
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0" value={eventId} onChange={(e) => setEventId(e.target.value)}>
<option value="">Select an event</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? " (past)" : ""}</option>
))}
</select>
{loadingEvents && <span className="text-xs text-gray-500">Loading</span>}
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={(e) => setEvIncludePast(e.target.checked)} />
Include past events
</label>
</div>
<div className="grid sm:grid-cols-3 gap-3">
<div>
<div className="flex items-center justify-between">
<label className="block text-xs text-gray-600 mb-1">Template</label>
<button type="button" className="text-[11px] text-gray-600 hover:text-gray-900 inline-flex items-center gap-1" onClick={() => setShowInfo(true)}>
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-gray-300 text-[10px]">i</span>
Info
</button>
</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={(e) => setTemplateKey(e.target.value as any)}>
<option value="custom">Custom message</option>
<option value="payment_reminder">Payment reminder</option>
<option value="event_reminder">Event reminder</option>
<option value="tickets">Send ticket PDFs</option>
</select>
</div>
<div className="sm:col-span-2">
<div className="text-[11px] text-gray-500 mt-6">
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}
</div>
</div>
</div>
{showInfo && (
<div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => setShowInfo(false)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold">Dynamic parameters</h3>
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setShowInfo(false)}>Close</button>
</div>
<div className="text-[12px] text-gray-700 break-words">
<p className="mb-2">Personalize your message per recipient using these placeholders.</p>
<ul className="list-disc pl-5 space-y-1 mb-2">
<li><code>{"{{name}}"}</code> attendee&apos;s name.</li>
<li><code>{"{{event.title}}"}</code> the event title.</li>
<li><code>{"{{event.start}}"}</code> the event start date/time.</li>
<li><code>{"{{balance}}"}</code> outstanding balance for the event.</li>
</ul>
<p className="text-[11px] text-gray-500 mt-2">
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
<span className="text-green-700 bg-green-50 px-1 rounded">both</span> = WhatsApp + Email,{" "}
<span className="text-amber-700 bg-amber-50 px-1 rounded">email</span> = Email only (will still receive message).
</p>
</div>
</div>
</div>
</div>
)}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Payment status filter</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={(e) => setStatus(e.target.value as any)}>
<option value="any">Any</option>
<option value="paid">Paid</option>
<option value="unpaid">Unpaid (pending or partial)</option>
<option value="partial_paid">Partial paid</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
{templateKey !== "tickets" ? (
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={message}
onChange={(e) => { setMessage(e.target.value); setMessageDirty(true); }}
placeholder="Write your WhatsApp message to attendees..." />
</div>
) : (
<div className="p-3 border rounded bg-gray-50 text-sm text-gray-700">
This will send each selected attendee their ticket PDF(s) for this event via WhatsApp.
</div>
)}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Attendees</label>
<AttendeesCheckboxDropdown attendees={attendees} loading={loadingAttendees} selectedIds={selectedAttendeeIds} onChange={setSelectedAttendeeIds} channel="whatsapp" />
<div className="text-[11px] text-gray-500 mt-1">Attendees with WhatsApp/both preference are pre-selected.</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={scheduledAtLocal} onChange={(e) => setScheduledAtLocal(e.target.value)} />
<div className="text-[10px] text-gray-500 mt-1">Leave empty to send immediately.</div>
</div>
</div>
{mismatchedAttendees.length > 0 && (
<div className="p-3 border rounded bg-amber-50 text-amber-800 text-xs">
<strong>{mismatchedAttendees.length} selected attendee(s)</strong> prefer email only they will still receive this message but it&apos;s not their preferred channel.
</div>
)}
<div className="flex items-center flex-wrap gap-2">
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={onPreview}>
Preview recipients
</button>
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={onSend}>
{sending ? "Sending…" : "Send now"}
</button>
<button type="button" disabled={sending || !scheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={onScheduleSend}>
Schedule send
</button>
{previewCount != null && <span className="text-xs text-gray-600">Preview: {previewCount} recipient(s)</span>}
</div>
{previewSample && previewSample.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-600 mb-1">First {previewSample.length} recipient(s):</div>
<ul className="text-xs text-gray-800 list-disc pl-5 space-y-0.5">
{previewSample.map((r, idx) => (
<li key={idx}>{r.name ? `${r.name} (${r.phone})` : r.phone}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* ── Automations Tab ── */}
{tab === "automations" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<WAAutomationsPanel events={events} token={token} onInfo={setInfo} onError={setError} />
</div>
)}
{/* ── Broadcasts Tab ── */}
{tab === "broadcasts" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="grid gap-3">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Users (optional)</label>
<AttendeesCheckboxDropdown attendees={users} loading={loadingUsers} selectedIds={selectedUserIds} onChange={setSelectedUserIds} channel="whatsapp" />
<div className="text-[11px] text-gray-500 mb-1">Only users with phone numbers shown. Green = WhatsApp/both preference.</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={(e) => setBroadcastEventId(e.target.value)}>
<option value="">No event</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
</div>
</div>
{selectedUserIds.length > 0 && (
<PrefWarning attendees={users} selectedIds={selectedUserIds} channel="whatsapp" />
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={broadcastMessage}
onChange={(e) => setBroadcastMessage(e.target.value)}
placeholder="Write your message... Use {{name}}, {{event.title}}, {{event.start}}, {{event.link}}" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Additional phone numbers (one per line)</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={broadcastPhones}
onChange={(e) => setBroadcastPhones(e.target.value)}
placeholder={`0821234567\nJane Doe <0721234567>\n27831234567`} />
<div className="text-[11px] text-gray-500 mt-1">Formats: 0821234567 or Name &lt;0821234567&gt;</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={broadcastScheduledAtLocal} onChange={(e) => setBroadcastScheduledAtLocal(e.target.value)} />
<div className="text-[10px] text-gray-500 mt-1">Leave empty to send immediately.</div>
</div>
<div className="flex items-center flex-wrap gap-2">
<button type="button" className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={async () => {
try {
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
const res = await apiFetch(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
} catch (e: any) { setError(e?.message || "Failed to preview"); }
}}>Preview recipients</button>
<button type="button" className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const res = await apiFetch(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
resetBroadcastForm();
} catch (e: any) { setError(e?.message || "Failed to send"); }
}}>Send now</button>
<button type="button" disabled={!broadcastScheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const res = await apiFetch(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled.");
resetBroadcastForm();
} catch (e: any) { setError(e?.message || "Failed to schedule broadcast"); }
}}>Schedule send</button>
{broadcastPreviewCount != null && <span className="text-xs text-gray-600">Preview: {broadcastPreviewCount} recipient(s)</span>}
</div>
{broadcastPreviewSample && broadcastPreviewSample.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-600 mb-1">First {broadcastPreviewSample.length} recipient(s):</div>
<ul className="text-xs text-gray-800 list-disc pl-5 space-y-0.5">
{broadcastPreviewSample.map((r, idx) => (
<li key={idx}>{r.name ? `${r.name} (${r.phone})` : r.phone}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* ── Scheduled Tab ── */}
{tab === "scheduled" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-medium">Scheduled WhatsApp Messages</h2>
<button type="button" className="text-sm px-2 py-1 rounded border bg-white hover:bg-gray-50" onClick={loadScheduled}>Refresh</button>
</div>
{loadingScheduled ? (
<div className="text-sm text-gray-600">Loading</div>
) : scheduled.length === 0 ? (
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Items sent more than a week ago are hidden.</div>
) : (
<ul className="divide-y border rounded">
{scheduled.map((job) => (
<li key={job.id} className="p-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 flex items-center gap-2">
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-green-50 text-green-700">{job.broadcast ? "broadcast" : "attendees"}</span>
<span className="truncate text-gray-700">{job.payload?.message ? String(job.payload.message).slice(0, 60) + (String(job.payload.message).length > 60 ? "…" : "") : "(no message)"}</span>
</div>
<div className="text-xs text-gray-600 mt-1">
<span className="mr-2">Status: {job.status}</span>
<span className="mr-2">Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}</span>
{job.sentAt && <span>Sent: {(() => { try { return new Date(job.sentAt!).toLocaleString(); } catch { return job.sentAt; } })()}</span>}
{job.lastError && <span className="ml-2 text-red-600">Error: {job.lastError}</span>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
onClick={() => { setEditing(job); setEditMessage(job.payload?.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
Edit
</button>
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" onClick={() => removeJob(job)}>
Remove
</button>
</div>
</li>
))}
</ul>
)}
{editing && (
<div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => setEditing(null)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold">Edit scheduled WhatsApp message</h3>
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setEditing(null)}>Close</button>
</div>
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={6} value={editMessage} onChange={(e) => setEditMessage(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={editWhen} onChange={(e) => setEditWhen(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<button type="button" disabled={savingEdit} className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={saveEdit}>
{savingEdit ? "Saving…" : "Save changes"}
</button>
<button type="button" className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}