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>
942 lines
51 KiB
TypeScript
942 lines
51 KiB
TypeScript
"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'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 (24–48 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'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'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 <0821234567></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>
|
||
);
|
||
} |