Files
hope-events/frontend/src/app/dashboard/supervisor/email-attendees/page.tsx
T
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:00:10 +02:00

1186 lines
63 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";
import { Mail } from "lucide-react";
type Attendee = { id: string; name: string; email: string; pref: string };
export default function EmailAttendeesPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<EmailAttendeesPageInner />
</Suspense>
);
}
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 (
<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 left-0 right-0 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-brand-300 text-brand-700 hover:bg-brand-50"
onClick={() => onChange(attendees.filter(a => prefMatch(a.pref)).map(a => a.id))}
>
Select {channel === "email" ? "Email/both" : "WhatsApp/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 found</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.email}>` : a.email}
</span>
<span className={`text-[10px] px-1 rounded shrink-0 ${match ? "text-brand-700 bg-brand-50" : "text-amber-700 bg-amber-50"}`}>
{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>
);
}
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 (
<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 === "email" ? "email" : "WhatsApp"}. They will still receive the message, but it may not be their preferred channel.
{" "}Use the dropdown to filter by preference.
</div>
);
}
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<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(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<any[]>([]);
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<any[]>("/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<string>(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<string[]>([]);
const [broadcastEventId, setBroadcastEventId] = useState<string>("");
const [broadcastSubject, setBroadcastSubject] = useState("");
const [broadcastBody, setBroadcastBody] = useState("");
const [broadcastEmails, setBroadcastEmails] = useState("");
const [broadcastPreviewCount, setBroadcastPreviewCount] = useState<number|null>(null);
const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{email:string;name?:string}[]|null>(null);
const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>("");
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<ScheduledJob[]>([]);
const [loadingScheduled, setLoadingScheduled] = useState(false);
const [editing, setEditing] = useState<ScheduledJob | null>(null);
const [editSubject, setEditSubject] = useState<string>('');
const [editBody, setEditBody] = 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 });
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<Attendee[]>([]);
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
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<number | null>(null);
const [previewSample, setPreviewSample] = useState<{email:string;name?:string}[] | null>(null);
const [sending, setSending] = useState(false);
const [scheduledAtLocal, setScheduledAtLocal] = useState<string>("");
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<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?.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 (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Mail className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Email Attendees</h1>
</div>
<div className="flex items-center gap-2">
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
</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 like on Payments page */}
<div className="mb-4 flex items-center gap-2 flex-wrap">
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'attendees' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="attendees" className="hidden" checked={tab==='attendees'} onChange={() => setTab('attendees')} />
Attendees
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'automations' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="automations" className="hidden" checked={tab==='automations'} onChange={() => setTab('automations')} />
Automations
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'broadcasts' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="broadcasts" className="hidden" checked={tab==='broadcasts'} onChange={() => setTab('broadcasts')} />
Broadcasts
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'scheduled' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="scheduled" className="hidden" checked={tab==='scheduled'} onChange={() => setTab('scheduled')} />
Scheduled
</label>
</div>
{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 max-w-full" 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" aria-label="About dynamic placeholders" 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 (with outstanding)</option>
<option value="event_reminder">Event reminder (date & time)</option>
<option value="tickets">Tickets email (attachments)</option>
</select>
</div>
<div className="sm:col-span-2">
<div className="text-[11px] text-gray-500 mt-6">Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'}, {'{{payment.link}}'} <button type="button" className="ml-2 underline hover:no-underline" onClick={() => setShowInfo(true)}>Learn more</button></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">You can personalize your subject and message using these placeholders. They will be replaced per recipient when sending.</p>
<ul className="list-disc pl-5 space-y-1 mb-2">
<li><code>{'{{name}}'}</code> attendees name.</li>
<li><code>{'{{event.title}}'}</code> the event title.</li>
<li><code>{'{{event.start}}'}</code> the event start date/time (local).</li>
<li><code>{'{{balance}}'}</code> outstanding amount across the attendees registrations for the selected event.</li>
<li><code>{'{{payment.link}}'}</code> a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</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">Subject</label>
<input className="w-full border rounded px-3 py-2 text-sm" disabled={templateKey==='tickets'} value={subject} onChange={e => { setSubject(e.target.value); setSubjectDirty(true); }} placeholder="Subject" />
</div>
<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={body} onChange={e => { setBody(e.target.value); setBodyDirty(true); }} placeholder="Write your message to attendees..." />
<div className="text-[11px] text-gray-500 mt-1">Hint: Start with &lt;html&gt; or &lt;div&gt; to send HTML; otherwise plain text will be sent.</div>
</div>
) : (
<div className="p-3 border rounded bg-gray-50 text-sm text-gray-700">This will send each selected attendee their ticket(s) as attachments for this event.</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="email"
/>
<div className="text-[11px] text-gray-500 mb-1">Email/both attendees are pre-selected. Use the dropdown to refine.</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. Uses your local timezone.</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 WhatsApp only — they will still receive this email 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-brand-600 text-white hover:bg-brand-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={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; }
}
const whenIso = new Date(scheduledAtLocal).toISOString();
const payload = {
subject,
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
template: templateKey,
scheduledAt: whenIso,
} as any;
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/schedule`, { method: 'POST', authToken: token, body: payload });
if (res?.job?.id) {
setInfo('Email scheduled. It will be sent around the specified time.');
} else {
setInfo('Scheduled.');
}
// Reset form to default state
resetAttendeesForm();
} catch (e:any) {
setError(e?.message || 'Failed to schedule email');
}
}}>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} className="break-words">{r.name ? `${r.name} <${r.email}>` : r.email}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{tab === 'automations' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<AutomationsPanel events={events} token={token} onInfo={setInfo} onError={setError} />
</div>
)}
{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="email"
/>
<div className="text-[11px] text-gray-500 mb-1">Select users from your database to include. Green = Email/both preference. You can also add external emails below.</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="email" />
)}
<div>
<div className="flex items-center justify-between">
<label className="block text-xs text-gray-600 mb-1">Subject</label>
<button type="button" aria-label="About dynamic placeholders" 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>
<input className="w-full border rounded px-3 py-2 text-sm" value={broadcastSubject} onChange={e => setBroadcastSubject(e.target.value)} placeholder="Subject (supports {{name}}, {{event.title}}, {{event.start}}, {{event.link}})" />
</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={8} value={broadcastBody} onChange={e => setBroadcastBody(e.target.value)} placeholder="Write your message... You can use {{name}}, {{event.title}}, {{event.start}}, {{event.link}}" />
<div className="text-[11px] text-gray-500 mt-1">Hint: Start with &lt;html&gt; or &lt;div&gt; to send HTML; otherwise plain text will be sent.</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Additional emails (one per line)</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={6} value={broadcastEmails} onChange={e => setBroadcastEmails(e.target.value)}
placeholder={
`email@example.com
Jane Doe <jane@example.com>
'John Smith' <john@domain.co.za>`
} />
<div className="text-[11px] text-gray-500 mt-1">Tips: You can paste a list. Supported formats: email@example.com or Name &lt;email@example.com&gt;.</div>
</div>
<div className="grid sm:grid-cols-2 gap-3">
<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. Uses your local timezone.</div>
</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; }
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const body: any = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined };
const res = await apiFetch(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
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 recipients');
}
}}>Preview recipients</button>
<button type="button" className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
if (!token) { setError('Not authenticated'); return; }
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const payload: any = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
// Reset broadcast form
resetBroadcastForm();
} catch (e:any) {
setError(e?.message || 'Failed to send broadcast');
}
}}>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 (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const payload: any = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.');
// Reset broadcast form
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} className="break-words">{r.name ? `${r.name} <${r.email}>` : r.email}</li>
))}
</ul>
</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">You can personalize your subject and message using these placeholders. They will be replaced per recipient when sending.</p>
<ul className="list-disc pl-5 space-y-1 mb-2">
<li><code>{'{{name}}'}</code> — recipients name (if available).</li>
<li><code>{'{{event.title}}'}</code> — the event title (when an event is selected).</li>
<li><code>{'{{event.start}}'}</code> — the event start date/time (local).</li>
<li><code>{'{{event.link}}'}</code> — a link to the event details page.</li>
</ul>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, check out <code>{'{{event.title}}'}</code>: <code>{'{{event.link}}'}</code>.</p>
<p className="text-[11px] text-gray-500">Attendees tab also supports <code>{'{{balance}}'}</code>; broadcasts do not calculate balances.</p>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)}
{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">Manage Scheduled Emails</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 items. 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-gray-50">{job.kind}</span>
<span className="truncate">{job.subject || '(no subject)'}</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 break-words">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={() => openEdit(job)}>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 email</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">Subject</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editSubject} onChange={e => setEditSubject(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Message (optional)</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={6} value={editBody} onChange={e => setEditBody(e.target.value)} placeholder="Leave empty to keep current content; or enter new content (HTML supported if starting with <)"></textarea>
</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-brand-600 text-white hover:bg-brand-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>
);
}
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<string>('');
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: `Well 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: `Dont Miss Our Next Event: {{promo.title}}`,
body: `Hi {{name}}\n\nWed 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<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>('');
// 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(`Well See You Tomorrow at ${title}!`);
setThanksSubject(`Thanks for Joining ${title}!`);
setPromoSubject(`Dont 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 (
<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 className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Subject</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={preSubject} onChange={e => setPreSubject(e.target.value)} placeholder="1 Week to Go: {{event.title}}!" />
</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={preWhen} onChange={e => setPreWhen(e.target.value)} />
</div>
</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={5} 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 className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Subject</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={finalSubject} onChange={e => setFinalSubject(e.target.value)} placeholder="Well See You Tomorrow at {{event.title}}!" />
</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>
</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={5} 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 className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Subject</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={thanksSubject} onChange={e => setThanksSubject(e.target.value)} placeholder="Thanks for Joining {{event.title}}!" />
</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={thanksWhen} onChange={e => setThanksWhen(e.target.value)} />
</div>
</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={5} 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>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Subject</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={promoSubject} onChange={e => setPromoSubject(e.target.value)} placeholder="Dont Miss Our Next Event: {{promo.title}}" />
</div>
</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={5} 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 supported: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{event.link}}'}, {'{{promo.title}}'}, {'{{promo.link}}'}. Start body with &lt;html&gt; or &lt;div&gt; to send HTML.</div>
</div>
</div>
);
}