{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.
);
}
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 (
Placeholders supported: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{event.link}}'}, {'{{promo.title}}'}, {'{{promo.link}}'}. Start body with <html> or <div> to send HTML.