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>
1186 lines
63 KiB
TypeScript
1186 lines
63 KiB
TypeScript
"use client";
|
||
|
||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { useRouter, useSearchParams } from "next/navigation";
|
||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||
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'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> — attendee’s 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 attendee’s 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 <html> or <div> 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'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 <html> or <div> 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 <email@example.com>.</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> — recipient’s 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: `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<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(`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 (
|
||
<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 (24–48 hrs before)</legend>
|
||
<div className="flex items-center gap-4 mb-2">
|
||
<label className="inline-flex items-center gap-2 text-sm">
|
||
<input type="checkbox" checked={enableFinal} onChange={e => setEnableFinal(e.target.checked)} /> Enable
|
||
</label>
|
||
<label className="text-xs text-gray-600">Hours before: </label>
|
||
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={e => setFinalHours(e.target.value as any)}>
|
||
<option value="24">24</option>
|
||
<option value="48">48</option>
|
||
</select>
|
||
</div>
|
||
<div 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="We’ll 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="Don’t 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 <html> or <div> to send HTML.</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|