"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"; import { Mail } from "lucide-react"; type Attendee = { id: string; name: string; email: string; pref: string }; export default function EmailAttendeesPage() { return ( Loading...}> ); } function AttendeesCheckboxDropdown({ attendees, loading, selectedIds, onChange, channel = "email", }: { attendees: { id: string; name: string; email?: string; pref: string }[]; loading: boolean; selectedIds: string[]; onChange: (ids: string[]) => void; channel?: "email" | "whatsapp"; }) { const [open, setOpen] = useState(false); const allIds = useMemo(() => attendees.map(a => a.id), [attendees]); const allSelected = selectedIds.length > 0 && selectedIds.length === allIds.length; const noneSelected = selectedIds.length === 0; const prefMatch = (pref: string) => channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || 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 mismatched = selectedIds.filter(id => { const a = attendees.find(x => x.id === id); return a && !prefMatch(a.pref); }); 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 summary = loading ? "Loading attendees…" : attendees.length === 0 ? "No attendees" : allSelected ? `All attendees (${attendees.length})` : noneSelected ? "None selected" : `${selectedIds.length} selected`; return (
{open && (
{loading ? (
Loading…
) : attendees.length === 0 ? (
No attendees found
) : ( )}
)}
); } function PrefWarning({ attendees, selectedIds, channel }: { attendees: { id: string; pref: string }[]; selectedIds: string[]; channel: "email" | "whatsapp" }) { const prefMatch = (pref: string) => channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || 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 (
{mismatched.length} selected attendee(s) have a notification preference that doesn't include{" "} {channel === "email" ? "email" : "WhatsApp"}. They will still receive the message, but it may not be their preferred channel. {" "}Use the dropdown to filter by preference.
); } function EmailAttendeesPageInner() { 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]); // Load events for selection const [loadingEvents, setLoadingEvents] = useState(false); const [error, setError] = useDismissingState(null); const [info, setInfo] = useDismissingState(null); // Tabs: attendees (current), automations (coming soon), broadcasts const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees'); const isAdmin = user?.role === "admin"; const [allEvents, setAllEvents] = useState([]); const [evIncludePast, setEvIncludePast] = useState(false); const events = useMemo(() => { const now = Date.now(); if (evIncludePast) return allEvents; return allEvents.filter(ev => { const t = new Date(ev.endDate).getTime(); return !isNaN(t) && t > now; }); }, [allEvents, evIncludePast]); const loadEvents = async () => { try { setLoadingEvents(true); const evs = await apiFetch("/api/events/all?includePast=true", { authToken: token || undefined }); const sorted = (evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()); setAllEvents(sorted); } catch (e: any) { setError(e?.message || "Failed to load events"); } finally { setLoadingEvents(false); } }; useEffect(() => { loadEvents(); }, [user, token]); // Load users for broadcasts 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')) .map((u: any) => ({ id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' })) .sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })); setUsers(mapped); } catch (e) { // ignore for now } finally { setLoadingUsers(false); } }; run(); }, [token]); // Form state const [eventId, setEventId] = useState(preselectEventId); useEffect(() => { if (preselectEventId) setEventId(preselectEventId); }, [preselectEventId]); const [templateKey, setTemplateKey] = useState<'custom'|'payment_reminder'|'event_reminder'|'tickets'>('custom'); const [showInfo, setShowInfo] = useState(false); // Broadcasts state const [users, setUsers] = useState<{id:string;name:string;email:string;pref:string}[]>([]); const [loadingUsers, setLoadingUsers] = useState(false); const [selectedUserIds, setSelectedUserIds] = useState([]); const [broadcastEventId, setBroadcastEventId] = useState(""); const [broadcastSubject, setBroadcastSubject] = useState(""); const [broadcastBody, setBroadcastBody] = useState(""); const [broadcastEmails, setBroadcastEmails] = useState(""); const [broadcastPreviewCount, setBroadcastPreviewCount] = useState(null); const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{email:string;name?:string}[]|null>(null); const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState(""); const resetBroadcastForm = () => { setSelectedUserIds([]); setBroadcastEventId(""); setBroadcastSubject(""); setBroadcastBody(""); setBroadcastEmails(""); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null); setBroadcastScheduledAtLocal(""); }; // Scheduled jobs state type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; hasHtml?: boolean; hasText?: boolean }; const [scheduled, setScheduled] = useState([]); const [loadingScheduled, setLoadingScheduled] = useState(false); const [editing, setEditing] = useState(null); const [editSubject, setEditSubject] = useState(''); const [editBody, setEditBody] = useState(''); const [editWhen, setEditWhen] = useState(''); 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 }); setScheduled(Array.isArray(res?.jobs) ? res.jobs : []); } catch (e) { // ignore here; surfaces via UI when tab open } finally { setLoadingScheduled(false); } }; useEffect(() => { if (tab === 'scheduled') loadScheduled(); }, [tab, token]); const openEdit = (job: ScheduledJob) => { setEditing(job); setEditSubject(job.subject || ''); setEditBody(''); // body not included in list; will let user set a new one if needed try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(''); } }; 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 (editSubject.trim().length || editBody.trim().length) { body.subject = editSubject; if (editBody.trim()) { if (editBody.trim().startsWith('<')) body.html = editBody; else body.text = editBody; } else { // if clearing body, explicitly set text to empty to override body.text = ''; body.html = ''; } } await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: 'PATCH', authToken: token, body }); setInfo('Scheduled email updated.'); setEditing(null); loadScheduled(); } catch (e:any) { setError(e?.message || 'Failed to update scheduled email'); } 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 email removed.'); loadScheduled(); } catch (e:any) { setError(e?.message || 'Failed to remove scheduled email'); } }; const [subject, setSubject] = useState(""); const [body, setBody] = useState(""); const [subjectDirty, setSubjectDirty] = useState(false); const [bodyDirty, setBodyDirty] = useState(false); const [status, setStatus] = useState<'any'|'paid'|'unpaid'|'partial_paid'|'cancelled'>("any"); // Attendees list and selection const [attendees, setAttendees] = useState([]); const [selectedAttendeeIds, setSelectedAttendeeIds] = useState([]); const [loadingAttendees, setLoadingAttendees] = useState(false); // Event helper to prefill templates const currentEvent = useMemo(() => (events || []).find(e => e.id === eventId), [events, eventId]); useEffect(() => { const title = currentEvent?.title || 'the event'; if (templateKey === 'payment_reminder') { if (!subjectDirty) setSubject(`Payment reminder: ${title}`); if (!bodyDirty) setBody(`Hi {{name}}\n\nThis is a friendly reminder that you have an outstanding balance of {{balance}} for {{event.title}}.\nEvent starts: {{event.start}}\n\nPlease settle your balance to secure your tickets. Thank you!`); } else if (templateKey === 'event_reminder') { if (!subjectDirty) setSubject(`Reminder: ${title}`); if (!bodyDirty) setBody(`Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`); } else if (templateKey === 'tickets') { // Tickets template does not require subject/body setSubjectDirty(false); setBodyDirty(false); setSubject(''); setBody(''); } else { // custom: do not overwrite user content unless not dirty and we have event change if (!subjectDirty && subject) setSubject(subject); // keep as-is if (!bodyDirty && body) setBody(body); } // When event changes, refresh defaults if not dirty }, [templateKey, currentEvent, subjectDirty, bodyDirty]); const [previewCount, setPreviewCount] = useState(null); const [previewSample, setPreviewSample] = useState<{email:string;name?:string}[] | null>(null); const [sending, setSending] = useState(false); const [scheduledAtLocal, setScheduledAtLocal] = useState(""); const mismatchedAttendees = useMemo( () => selectedAttendeeIds.filter(id => { const a = attendees.find(x => x.id === id); return a && a.pref !== 'email' && a.pref !== 'both'; }), [attendees, selectedAttendeeIds] ); // Load attendees when event changes useEffect(() => { const run = async () => { try { setLoadingAttendees(true); setAttendees([]); setSelectedAttendeeIds([]); if (!eventId || !token) return; const regs = await apiFetch(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token }); const uniq = new Map(); (regs || []).forEach((r: any) => { const u = r?.user; if (u?.id && u?.email && !u.email.endsWith('@guest.local') && u.isActive !== false) { uniq.set(u.id, { id: u.id, name: u.name || '', email: u.email || '', 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 email/both preference; fall back to all const matching = list.filter(a => a.pref === 'email' || a.pref === 'both').map(a => a.id); setSelectedAttendeeIds(matching.length > 0 ? matching : list.map(a => a.id)); } catch (e) { // ignore } finally { setLoadingAttendees(false); } }; run(); }, [eventId, token]); 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') { if (!subject.trim()) { setError("Subject is required"); return; } if (!body.trim()) { setError("Message is required"); return; } } const payload: any = { subject: subject || "(no subject)", filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined }, dryRun: true, template: templateKey, }; if (templateKey !== 'tickets') { if (body.trim().startsWith('<')) payload.html = body; else payload.text = body; } const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload }); setPreviewCount(res?.matched ?? 0); setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null); setInfo(`Matched ${res?.matched ?? 0} recipient(s).`); } catch (e: any) { setError(e?.message || 'Failed to preview recipients'); } }; const resetAttendeesForm = () => { setTemplateKey('custom'); setSubject(''); setBody(''); setSubjectDirty(false); setBodyDirty(false); setStatus('any'); setScheduledAtLocal(''); setPreviewCount(null); setPreviewSample(null); // Keep event selection but reselect email/both attendees const matching = attendees.filter(a => a.pref === 'email' || a.pref === 'both').map(a => a.id); setSelectedAttendeeIds(matching.length > 0 ? matching : 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') { if (!subject.trim()) { setError("Subject is required"); return; } if (!body.trim()) { setError("Message is required"); return; } } setSending(true); const payload: any = { subject, filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined }, template: templateKey, }; if (templateKey !== 'tickets') { if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n'); } const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload }); const queued = res?.queued ?? res?.matched ?? 0; setInfo(`Queued ${queued} recipient(s) for sending.`); // Reset form to default state resetAttendeesForm(); } catch (e: any) { setError(e?.message || 'Failed to send emails'); } finally { setSending(false); } }; return (

Email Attendees

{!canView && (
You need supervisor or admin access to use this page.
)} {error &&
{error}
} {info &&
{info}
} {/* Tabs like on Payments page */}
{tab === 'attendees' && (
{loadingEvents && Loading…}
Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'}, {'{{payment.link}}'}
{showInfo && (
setShowInfo(false)} />

Dynamic parameters

You can personalize your subject and message using these placeholders. They will be replaced per recipient when sending.

  • {'{{name}}'} — attendee’s name.
  • {'{{event.title}}'} — the event title.
  • {'{{event.start}}'} — the event start date/time (local).
  • {'{{balance}}'} — outstanding amount across the attendee’s registrations for the selected event.
  • {'{{payment.link}}'} — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).

Example: Hi {'{{name}}'}, your balance is {'{{balance}}'}. Pay here: {'{{payment.link}}'}

To add new placeholders, extend backend/src/utils/placeholders.js and update this help.

)}
{ setSubject(e.target.value); setSubjectDirty(true); }} placeholder="Subject" />
{templateKey !== 'tickets' ? (
setEditWhen(e.target.value)} />
)} )} ); } function toLocalInputValue(d: Date) { const pad = (n: number) => String(n).padStart(2, '0'); const y = d.getFullYear(); const m = pad(d.getMonth() + 1); const day = pad(d.getDate()); const hh = pad(d.getHours()); const mm = pad(d.getMinutes()); return `${y}-${m}-${day}T${hh}:${mm}`; } function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) { const [autoEventId, setAutoEventId] = React.useState(''); const currentEvent = React.useMemo(() => (events || []).find((e:any) => e.id === autoEventId), [events, autoEventId]); // Defaults builders const preWeekDefault = React.useMemo(() => ({ subject: `1 Week to Go: {{event.title}}!`, body: `Hi {{name}}\n\nJust a friendly reminder that {{event.title}} is one week away.\n\nVenue: [add venue]\nTime: {{event.start}}\nParking: [add parking info]\nPacking list: [add items if needed]\n\nMore details: {{event.link}}`, }), []); const finalDefault = React.useMemo(() => ({ subject: `We’ll See You Tomorrow at {{event.title}}!`, body: `Hi {{name}}\n\nFinal reminder for {{event.title}}.\nArrival instructions: [add arrival info]\nPlease have your QR code/ticket ready at entry.\n\nEvent details: {{event.link}}`, }), []); const thanksDefault = React.useMemo(() => ({ subject: `Thanks for Joining {{event.title}}!`, body: `Hi {{name}}\n\nThank you for joining us at {{event.title}}!\nHighlights: [add highlights]\nPhotos/recordings: [add links]\nSponsor shoutouts: [add sponsors]\n\nSee you next time!`, }), []); const promoDefault = React.useMemo(() => ({ subject: `Don’t Miss Our Next Event: {{promo.title}}`, body: `Hi {{name}}\n\nWe’d love to see you at our next event: {{promo.title}}.\nFind out more and register here: {{promo.link}}`, }), []); // Enable toggles const [enablePre, setEnablePre] = React.useState(true); const [enableFinal, setEnableFinal] = React.useState(true); const [enableThanks, setEnableThanks] = React.useState(true); const [enablePromo, setEnablePromo] = React.useState(false); // Subjects/Bodies const [preSubject, setPreSubject] = React.useState(preWeekDefault.subject); const [preBody, setPreBody] = React.useState(preWeekDefault.body); const [finalSubject, setFinalSubject] = React.useState(finalDefault.subject); const [finalBody, setFinalBody] = React.useState(finalDefault.body); const [thanksSubject, setThanksSubject] = React.useState(thanksDefault.subject); const [thanksBody, setThanksBody] = React.useState(thanksDefault.body); const [promoSubject, setPromoSubject] = React.useState(promoDefault.subject); const [promoBody, setPromoBody] = React.useState(promoDefault.body); // Timing const [finalHours, setFinalHours] = React.useState<'24'|'48'>('24'); const [preWhen, setPreWhen] = React.useState(''); const [finalWhen, setFinalWhen] = React.useState(''); const [thanksWhen, setThanksWhen] = React.useState(''); const [promoWhen, setPromoWhen] = React.useState(''); const [promoEventId, setPromoEventId] = React.useState(''); // Compute defaults when event changes or finalHours change React.useEffect(() => { if (!currentEvent) { setPreWhen(''); setFinalWhen(''); setThanksWhen(''); setPromoWhen(''); return; } try { const start = new Date(currentEvent.startDate); const end = new Date(currentEvent.endDate || currentEvent.startDate); // Pre-event: 7 days before start at 09:00 const pre = new Date(start); pre.setDate(pre.getDate() - 7); pre.setHours(9, 0, 0, 0); setPreWhen(toLocalInputValue(pre)); // Final reminder: hours before start const fin = new Date(start); fin.setHours(fin.getHours() - (finalHours === '48' ? 48 : 24)); setFinalWhen(toLocalInputValue(fin)); // Thanks: day after end at 09:00 const ty = new Date(end); ty.setDate(ty.getDate() + 1); ty.setHours(9, 0, 0, 0); setThanksWhen(toLocalInputValue(ty)); // Promo: 3 days after end at 09:00 const pr = new Date(end); pr.setDate(pr.getDate() + 3); pr.setHours(9, 0, 0, 0); setPromoWhen(toLocalInputValue(pr)); // Prefill subjects again using event title (user can edit afterward) const title = currentEvent.title || 'the event'; setPreSubject(`1 Week to Go: ${title}!`); setFinalSubject(`We’ll See You Tomorrow at ${title}!`); setThanksSubject(`Thanks for Joining ${title}!`); setPromoSubject(`Don’t Miss Our Next Event: {{promo.title}`); } 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 && preSubject.trim() && preBody.trim() && preWhen) { const payload: any = { subject: preSubject, scheduledAt: new Date(preWhen).toISOString() }; if (preBody.trim().startsWith('<')) payload.html = preBody; else payload.text = preBody; jobs.push(payload); } if (enableFinal && finalSubject.trim() && finalBody.trim() && finalWhen) { const payload: any = { subject: finalSubject, scheduledAt: new Date(finalWhen).toISOString() }; if (finalBody.trim().startsWith('<')) payload.html = finalBody; else payload.text = finalBody; jobs.push(payload); } if (enableThanks && thanksSubject.trim() && thanksBody.trim() && thanksWhen) { const payload: any = { subject: thanksSubject, scheduledAt: new Date(thanksWhen).toISOString() }; if (thanksBody.trim().startsWith('<')) payload.html = thanksBody; else payload.text = thanksBody; jobs.push(payload); } if (enablePromo && promoSubject.trim() && promoBody.trim() && promoWhen) { const payload: any = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined }; if (promoBody.trim().startsWith('<')) payload.html = promoBody; else payload.text = promoBody; jobs.push(payload); } if (jobs.length === 0) { onError('Please enable at least one automation and fill in details'); return; } const res = await apiFetch(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } }); onInfo(res?.message || `Scheduled ${jobs.length} automation(s).`); // Reset to defaults (keep event selection) setEnablePre(true); setEnableFinal(true); setEnableThanks(true); setEnablePromo(false); setPreSubject(preWeekDefault.subject); setPreBody(preWeekDefault.body); setFinalSubject(finalDefault.subject); setFinalBody(finalDefault.body); setFinalHours('24'); setThanksSubject(thanksDefault.subject); setThanksBody(thanksDefault.body); setPromoSubject(promoDefault.subject); setPromoBody(promoDefault.body); setPromoEventId(''); } catch (e:any) { onError(e?.message || 'Failed to schedule automations'); } }; return (
Pre-Event Reminder (1 week before)
setPreSubject(e.target.value)} placeholder="1 Week to Go: {{event.title}}!" />
setPreWhen(e.target.value)} />