Add WhatsApp drip/blast CTAs, presenter photo shapes, and notification panels.

Wire reception and Event Home UI for session buttons, photo wall shape settings, blast history, and chatbot delete.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-08-19 09:37:24 +05:30
parent 57f73cb6c2
commit b7b3cd19a5
15 changed files with 1128 additions and 49 deletions

2
dist/index.html vendored
View File

@ -12,7 +12,7 @@
rel="stylesheet"
/>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<script type="module" crossorigin src="/assets/index-BorN-Aau.js"></script>
<script type="module" crossorigin src="/assets/index-BjfPElUV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BVkPIZas.css">
</head>

View File

@ -54,12 +54,12 @@
"next-themes": "0.4.6",
"qrcode": "^1.5.4",
"quill": "1.3.7",
"react-quill": "2.0.0",
"react-day-picker": "8.10.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-hook-form": "7.55.0",
"react-popper": "2.3.0",
"react-quill": "2.0.0",
"react-resizable-panels": "2.1.7",
"react-responsive-masonry": "2.7.1",
"react-router": "7.13.0",

View File

@ -86,6 +86,7 @@ interface FormState {
name: string;
description: string;
allowDuplicateCheckIn: boolean;
allowAssignBlank: boolean;
publicLinkEnabled: boolean;
audienceMode: AttendanceAudienceMode;
includeStatuses: AttendanceMembershipStatus[];
@ -97,6 +98,7 @@ const emptyForm: FormState = {
name: "",
description: "",
allowDuplicateCheckIn: false,
allowAssignBlank: false,
publicLinkEnabled: true,
audienceMode: "all",
includeStatuses: ["confirmed", "checked_in"],
@ -210,6 +212,7 @@ export function AttendancePage() {
name: s.name,
description: s.description || "",
allowDuplicateCheckIn: Boolean(s.settings?.allowDuplicateCheckIn),
allowAssignBlank: Boolean(s.settings?.allowAssignBlank),
publicLinkEnabled: s.settings?.publicLinkEnabled !== false,
audienceMode: audience?.mode || "all",
includeStatuses:
@ -276,6 +279,7 @@ export function AttendancePage() {
description: form.description.trim(),
settings: {
allowDuplicateCheckIn: form.allowDuplicateCheckIn,
allowAssignBlank: form.allowAssignBlank,
publicLinkEnabled: form.publicLinkEnabled,
audience: {
mode: form.audienceMode,
@ -926,6 +930,14 @@ export function AttendancePage() {
/>
Allow duplicate check-ins
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input
type="checkbox"
checked={form.allowAssignBlank}
onChange={(e) => setForm((f) => ({ ...f, allowAssignBlank: e.target.checked }))}
/>
Allow Assign a Blank (spare badge check-in)
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input
type="checkbox"

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from "react";
import { BarChart2, CalendarDays, Check, FileText, Globe, Image, Images, Link2, Mail, Palette, Plus, Share2, ShieldCheck, Sparkles, Store, Tag, Ticket, Trophy, Upload } from "lucide-react";
import { BarChart2, CalendarDays, Check, FileText, Globe, Image, Images, Link2, Mail, MessageSquare, Palette, Plus, Share2, ShieldCheck, Sparkles, Store, Tag, Ticket, Trophy, Upload } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext";
import { useAuth } from "../../contexts/AuthContext";
@ -33,6 +33,9 @@ import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls";
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
import { WhatsAppBlastHistoryPanel } from "./WhatsAppBlastHistoryPanel";
import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel";
import { whatsappBlastsApi } from "../../../services/api/whatsappBlasts";
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
@ -252,6 +255,8 @@ export function EventHomeModules() {
const canWriteAiAvatar = canModule("ai_avatar", "write");
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
const canReadWaNotifications = canModule("custom_template_for_wa_messaging", "read");
const canWriteWaNotifications = canModule("custom_template_for_wa_messaging", "write");
const canReadExhibitors = canModule("exhibitors", "read");
const canWriteExhibitors = canModule("exhibitors", "write");
const bp = useBreakpoint();
@ -327,6 +332,10 @@ export function EventHomeModules() {
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
const [showEmailCampaignsPanel, setShowEmailCampaignsPanel] = useState(false);
const [waBlastCount, setWaBlastCount] = useState(0);
const [waBlastLoading, setWaBlastLoading] = useState(false);
const [showWaBlastHistory, setShowWaBlastHistory] = useState(false);
const [showSendWaNotification, setShowSendWaNotification] = useState(false);
const [exhibitorCount, setExhibitorCount] = useState(0);
const [exhibitorLoading, setExhibitorLoading] = useState(false);
const [showExhibitorsPanel, setShowExhibitorsPanel] = useState(false);
@ -600,6 +609,27 @@ export function EventHomeModules() {
void loadEmailCampaignMeta();
}, [loadEmailCampaignMeta]);
const loadWaBlastMeta = useCallback(async () => {
if (!selectedEventId || !canReadWaNotifications) {
setWaBlastCount(0);
setWaBlastLoading(false);
return;
}
setWaBlastLoading(true);
try {
const res = await whatsappBlastsApi.list(selectedEventId);
setWaBlastCount(res.data?.blasts?.length ?? 0);
} catch {
setWaBlastCount(0);
} finally {
setWaBlastLoading(false);
}
}, [selectedEventId, canReadWaNotifications]);
useEffect(() => {
void loadWaBlastMeta();
}, [loadWaBlastMeta]);
const loadExhibitorMeta = useCallback(async () => {
if (!selectedEventId) {
setExhibitorCount(0);
@ -823,6 +853,11 @@ export function EventHomeModules() {
: emailCampaignLoading
? "Loading…"
: `${emailCampaignCount} email campaign${emailCampaignCount === 1 ? "" : "s"}`;
const waBlastMeta = !selectedEventId
? "Select an event to send WhatsApp notifications"
: waBlastLoading
? "Loading…"
: `${waBlastCount} notification${waBlastCount === 1 ? "" : "s"}`;
const exhibitorMeta = !selectedEventId
? "Select an event to manage exhibitors"
: exhibitorLoading
@ -1787,6 +1822,52 @@ export function EventHomeModules() {
}
/>
) : null}
{canReadWaNotifications ? (
<ModuleCard
icon={<MessageSquare size={18} />}
title="WhatsApp Notifications"
meta={waBlastMeta}
description="Blast a message to this event’s open WhatsApp chats (24-hour window)"
actions={
<>
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setShowWaBlastHistory(true)}
onMouseEnter={(e) => {
if (actionsDisabled) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
if (actionsDisabled) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
}}
>
History
</button>
<button
type="button"
disabled={actionsDisabled || !canWriteWaNotifications}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled || !canWriteWaNotifications ? 0.55 : 1,
cursor: actionsDisabled || !canWriteWaNotifications ? "not-allowed" : "pointer",
}}
onClick={() => setShowSendWaNotification(true)}
>
Send
</button>
</>
}
/>
) : null}
{canReadEmailCampaigns ? (
<ModuleCard
icon={<Mail size={18} />}
@ -2030,7 +2111,24 @@ export function EventHomeModules() {
/>
) : null}
{showEmailCampaignsPanel && selectedEventId ? (
{showWaBlastHistory && selectedEventId ? (
<WhatsAppBlastHistoryPanel
eventId={selectedEventId}
eventName={eventName ?? undefined}
onClose={() => setShowWaBlastHistory(false)}
/>
) : null}
{showSendWaNotification && selectedEventId ? (
<SendWhatsAppNotificationPanel
eventId={selectedEventId}
eventName={eventName ?? undefined}
onClose={() => setShowSendWaNotification(false)}
onSuccess={() => void loadWaBlastMeta()}
/>
) : null}
{showEmailCampaignsPanel && selectedEventId ? (
<EventEmailCampaignsPanel
eventId={selectedEventId}
eventName={eventName ?? undefined}

View File

@ -9,6 +9,7 @@ import {
type PhotoWallConfig,
type PhotoWallPhotoRow,
type PhotoWallEventHeader,
type PhotoWallPresentPhotoShape,
type Form,
type FormWithSubmissionCount,
} from "../../../services/api";
@ -36,6 +37,16 @@ import {
type Tab = "photos" | "settings" | "frame" | "links";
const DEFAULT_HEADER = eventHeaderFromApi(null);
/** Presenter defaults: title on; other chrome off (matches backend normalizePresenterEventHeaderDisplay). */
const DEFAULT_PRESENTER_HEADER = eventHeaderFromApi({
showLogo: false,
showBanner: false,
showTitle: true,
showDateRange: false,
showEventType: false,
showLocationType: false,
showVenue: false,
});
export function PhotoWallManagePanel({
eventId,
@ -70,10 +81,15 @@ export function PhotoWallManagePanel({
maxPhotoWidth: 320,
maxPhotoHeight: 320,
gapPx: 8,
presentMarginTopPx: 0,
presentMarginLeftPx: 0,
presentMarginRightPx: 0,
presentPhotoShape: "rounded" as PhotoWallPresentPhotoShape,
showUploaderNameOnWall: false,
requireUploadCrop: false,
formId: "" as string,
eventHeader: DEFAULT_HEADER,
presenterEventHeader: DEFAULT_PRESENTER_HEADER,
frameEnabled: false,
frameImageUrl: "" as string,
frameImageLayout: defaultFrameImageLayout() as PhotoWallFrameImageLayout,
@ -112,10 +128,30 @@ export function PhotoWallManagePanel({
maxPhotoWidth: wallRes.data.maxPhotoWidth ?? 320,
maxPhotoHeight: wallRes.data.maxPhotoHeight ?? 320,
gapPx: wallRes.data.gapPx ?? 8,
presentMarginTopPx: wallRes.data.presentMarginTopPx ?? 0,
presentMarginLeftPx: wallRes.data.presentMarginLeftPx ?? 0,
presentMarginRightPx: wallRes.data.presentMarginRightPx ?? 0,
presentPhotoShape:
wallRes.data.presentPhotoShape === "rect" ||
wallRes.data.presentPhotoShape === "rounded" ||
wallRes.data.presentPhotoShape === "circle"
? wallRes.data.presentPhotoShape
: "rounded",
showUploaderNameOnWall: Boolean(wallRes.data.showUploaderNameOnWall),
requireUploadCrop: Boolean(wallRes.data.requireUploadCrop),
formId: wallRes.data.formId ? String(wallRes.data.formId) : "",
eventHeader: eventHeaderFromApi(wallRes.data.eventHeader as PhotoWallEventHeader | null),
presenterEventHeader: eventHeaderFromApi(
(wallRes.data.presenterEventHeader as PhotoWallEventHeader | null) ?? {
showLogo: false,
showBanner: false,
showTitle: true,
showDateRange: false,
showEventType: false,
showLocationType: false,
showVenue: false,
},
),
frameEnabled: Boolean(wallRes.data.frameEnabled),
frameImageUrl: wallRes.data.frameImageUrl || "",
frameImageLayout: normalizeFrameImageLayout(wallRes.data.frameImageLayout),
@ -175,10 +211,15 @@ export function PhotoWallManagePanel({
maxPhotoWidth: draft.maxPhotoWidth,
maxPhotoHeight: draft.maxPhotoHeight,
gapPx: draft.gapPx,
presentMarginTopPx: draft.presentMarginTopPx,
presentMarginLeftPx: draft.presentMarginLeftPx,
presentMarginRightPx: draft.presentMarginRightPx,
presentPhotoShape: draft.presentPhotoShape,
showUploaderNameOnWall: draft.showUploaderNameOnWall,
requireUploadCrop: draft.requireUploadCrop,
formId: draft.collectDetails && draft.formId ? draft.formId : null,
eventHeader: eventHeaderToApi(draft.eventHeader),
presenterEventHeader: eventHeaderToApi(draft.presenterEventHeader),
};
const res = await photoWallApi.update(eventId, payload);
if (res.error || !res.data) throw new Error(res.error || "Save failed");
@ -528,14 +569,14 @@ export function PhotoWallManagePanel({
) : null}
<Field>
<Label text="Event header on visitor page" />
<HelperText text="Choose which event details appear above the upload form. Same options as public forms." />
<Label text="Event header on upload page" />
<HelperText text="Choose which event details appear above the visitor upload form." />
<Grid cols={2}>
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => (
<Field key={key}>
<Field key={`upload-${key}`}>
<Label text={EVENT_HEADER_TOGGLE_LABELS[key]} />
<RadioGroup
name={`pw-header-${key}`}
name={`pw-header-upload-${key}`}
value={draft.eventHeader[key]}
onChange={(v) =>
setDraft((d) => ({
@ -553,6 +594,35 @@ export function PhotoWallManagePanel({
</Grid>
</Field>
<Field>
<Label text="Event header on presenter page" />
<HelperText text="Choose which event details appear above the live photo wall display." />
<Grid cols={2}>
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => (
<Field key={`present-${key}`}>
<Label text={EVENT_HEADER_TOGGLE_LABELS[key]} />
<RadioGroup
name={`pw-header-present-${key}`}
value={draft.presenterEventHeader[key]}
onChange={(v) =>
setDraft((d) => ({
...d,
presenterEventHeader: {
...d.presenterEventHeader,
[key]: v as "Yes" | "No",
},
}))
}
options={[
{ label: "Show", value: "Yes" },
{ label: "Hide", value: "No" },
]}
/>
</Field>
))}
</Grid>
</Field>
<Grid cols={2}>
<Field>
<Label text="Photos per row" />
@ -570,6 +640,7 @@ export function PhotoWallManagePanel({
</Field>
<Field>
<Label text="Gap (px)" />
<HelperText text="Space between photos on the presenter grid." />
<input
type="number"
min={0}
@ -582,6 +653,77 @@ export function PhotoWallManagePanel({
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Presenter gap top (px)" />
<HelperText text="Outer space above the photo grid. Default 0." />
<input
type="number"
min={0}
max={2000}
disabled={!canWrite}
value={draft.presentMarginTopPx}
onChange={(e) =>
setDraft((d) => ({ ...d, presentMarginTopPx: Number(e.target.value) || 0 }))
}
style={IS}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Presenter gap left (px)" />
<HelperText text="Outer space to the left of the photo grid. Default 0." />
<input
type="number"
min={0}
max={2000}
disabled={!canWrite}
value={draft.presentMarginLeftPx}
onChange={(e) =>
setDraft((d) => ({ ...d, presentMarginLeftPx: Number(e.target.value) || 0 }))
}
style={IS}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Presenter gap right (px)" />
<HelperText text="Outer space to the right of the photo grid. Default 0." />
<input
type="number"
min={0}
max={2000}
disabled={!canWrite}
value={draft.presentMarginRightPx}
onChange={(e) =>
setDraft((d) => ({ ...d, presentMarginRightPx: Number(e.target.value) || 0 }))
}
style={IS}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Presenter photo shape" />
<HelperText text="Corners on the live presenter wall. Circle forces a square crop." />
<RadioGroup
name="pw-present-photo-shape"
value={draft.presentPhotoShape}
onChange={(v) =>
setDraft((d) => ({
...d,
presentPhotoShape:
v === "rect" || v === "circle" || v === "rounded" ? v : "rounded",
}))
}
options={[
{ label: "Sharp rectangle", value: "rect" },
{ label: "Rounded rectangle", value: "rounded" },
{ label: "Circle", value: "circle" },
]}
/>
</Field>
<Field>
<Label text="Max photo width (px)" />
<input

View File

@ -0,0 +1,241 @@
import React, { useCallback, useEffect, useState } from "react";
import { uploadMediaFile } from "../../../services/api";
import { whatsappBlastsApi } from "../../../services/api/whatsappBlasts";
import { PanelShell, RadioGroup, Label, Field, HelperText, IS, focusIn, focusOut } from "../panels/PanelShell";
import { WhatsAppCtaEditor, normalizeCtasForSave, type WhatsAppCtaItem } from "../whatsapp/WhatsAppCtaEditor";
function toDatetimeLocalValue(d: Date) {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function SendWhatsAppNotificationPanel({
eventId,
eventName,
onClose,
onSuccess,
}: {
eventId: string;
eventName?: string;
onClose: () => void;
onSuccess?: () => void;
}) {
const [body, setBody] = useState("");
const [mediaType, setMediaType] = useState<"none" | "image" | "video">("none");
const [mediaUrl, setMediaUrl] = useState("");
const [ctas, setCtas] = useState<WhatsAppCtaItem[]>([]);
const [when, setWhen] = useState<"now" | "schedule">("now");
const [scheduleLocal, setScheduleLocal] = useState("");
const [eligible, setEligible] = useState<number | null>(null);
const [eligibleError, setEligibleError] = useState("");
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState("");
const loadEligible = useCallback(async () => {
setEligibleError("");
const res = await whatsappBlastsApi.eligibleCount(eventId);
if (res.error) {
setEligible(null);
setEligibleError(res.error);
return;
}
setEligible(res.data?.count ?? 0);
}, [eventId]);
useEffect(() => {
void loadEligible();
const t = window.setInterval(() => void loadEligible(), 30_000);
return () => window.clearInterval(t);
}, [loadEligible]);
const onPickMedia = async (file: File | null) => {
if (!file) return;
const mime = String(file.type || "").toLowerCase();
const nextType = mime.startsWith("video/") ? "video" : mime.startsWith("image/") ? "image" : null;
if (!nextType) {
setErr("Attach an image or video file.");
return;
}
setUploading(true);
setErr("");
try {
const up = await uploadMediaFile(file, { eventId, module: "whatsapp/notifications" });
if (!up.url) {
setErr("Upload failed");
return;
}
setMediaType(nextType);
setMediaUrl(up.url);
} catch (e) {
setErr(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
};
const submit = async () => {
const text = body.trim();
let ctasPayload: WhatsAppCtaItem[] = [];
try {
ctasPayload = normalizeCtasForSave(ctas);
} catch (e) {
setErr(e instanceof Error ? e.message : "Invalid buttons");
return;
}
if (ctasPayload.length && !text) {
setErr("Message text is required when buttons are set.");
return;
}
if (!text && !mediaUrl) {
setErr("Enter a message or attach media.");
return;
}
let sendAt: string | undefined;
if (when === "schedule") {
if (!scheduleLocal) {
setErr("Pick a date and time.");
return;
}
const d = new Date(scheduleLocal);
if (Number.isNaN(d.getTime())) {
setErr("Invalid schedule time.");
return;
}
sendAt = d.toISOString();
}
setSaving(true);
setErr("");
const res = await whatsappBlastsApi.create(eventId, {
body: text,
mediaType: mediaUrl ? mediaType : "none",
mediaUrl: mediaUrl || "",
ctas: ctasPayload,
...(sendAt ? { sendAt } : {}),
});
setSaving(false);
if (res.error) {
setErr(res.error);
return;
}
onSuccess?.();
onClose();
};
const eligibleLine =
eligibleError
? eligibleError
: eligible == null
? "Counting people in the 24-hour WhatsApp window…"
: `${eligible} ${eligible === 1 ? "person" : "people"} on this event can receive this now (open WhatsApp chat, last 24 hours).`;
return (
<PanelShell
onClose={onClose}
title="Send WhatsApp notification"
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home · WhatsApp Notifications"}
description="Sends the same message to everyone on this event with an open WhatsApp chat still inside Meta’s 24-hour window."
completedCount={1}
totalSections={1}
ctaLabel={saving ? "Sending…" : when === "schedule" ? "Schedule" : "Send now"}
ctaDisabled={saving || uploading}
onCtaClick={() => void submit()}
showProgress={false}
panelWidth="min(560px, 100vw)"
>
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 14 }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--card-foreground)" }}>{eligibleLine}</p>
<Field>
<Label text="Message" />
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
rows={6}
placeholder="Write the WhatsApp message…"
style={{ ...IS, minHeight: 120, resize: "vertical" }}
/>
</Field>
<Field>
<Label text="Image or video (optional)" />
{mediaUrl ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<a href={mediaUrl} target="_blank" rel="noreferrer" style={{ fontSize: "var(--text-sm)", color: "var(--primary)" }}>
{mediaType === "video" ? "Video attached" : "Image attached"}
</a>
<button
type="button"
onClick={() => {
setMediaUrl("");
setMediaType("none");
}}
style={{
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
border: "1px solid var(--border)",
background: "var(--secondary)",
borderRadius: "var(--radius-button)",
padding: "4px 8px",
cursor: "pointer",
}}
>
Remove
</button>
</div>
) : (
<input
type="file"
accept="image/*,video/*"
disabled={uploading}
onChange={(e) => {
const file = e.target.files?.[0] || null;
e.target.value = "";
void onPickMedia(file);
}}
/>
)}
<HelperText text={uploading ? "Uploading…" : "Optional. Recipients outside the 24-hour window are skipped."} />
</Field>
<Field>
<WhatsAppCtaEditor value={ctas} onChange={setCtas} />
</Field>
<Field>
<Label text="When" />
<RadioGroup
name="wa-blast-when"
value={when}
onChange={(v) => setWhen(v === "schedule" ? "schedule" : "now")}
options={[
{ label: "Send now", value: "now" },
{ label: "Schedule", value: "schedule" },
]}
/>
</Field>
{when === "schedule" ? (
<Field>
<Label text="Schedule for" required />
<input
type="datetime-local"
value={scheduleLocal}
min={toDatetimeLocalValue(new Date())}
onChange={(e) => setScheduleLocal(e.target.value)}
onFocus={focusIn}
onBlur={focusOut}
style={IS}
/>
</Field>
) : null}
{err ? (
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{err}</p>
) : null}
</div>
</PanelShell>
);
}

View File

@ -0,0 +1,179 @@
import React, { useCallback, useEffect, useState } from "react";
import { MessageSquare } from "lucide-react";
import { whatsappBlastsApi, type WhatsAppBlast } from "../../../services/api/whatsappBlasts";
import { PanelShell } from "../panels/PanelShell";
const LIST_GRID = "minmax(140px, 1.4fr) 72px 72px 88px minmax(140px, 1.2fr) 56px 56px 56px";
function previewBody(body: string): string {
const text = String(body || "").replace(/\s+/g, " ").trim();
if (!text) return "—";
return text.length > 80 ? `${text.slice(0, 77)}…` : text;
}
function formatWhen(b: WhatsAppBlast): string {
if (b.sentAt) {
const d = new Date(b.sentAt);
if (!Number.isNaN(d.getTime())) return d.toLocaleString();
}
if (b.status === "queued" || b.status === "sending") {
const d = new Date(b.sendAt);
if (!Number.isNaN(d.getTime())) {
const prefix = d.getTime() > Date.now() ? "Scheduled" : "Queued";
return `${prefix} ${d.toLocaleString()}`;
}
}
const d = new Date(b.sendAt);
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
}
function mediaLabel(b: WhatsAppBlast): string {
if (b.mediaType === "image") return "Image";
if (b.mediaType === "video") return "Video";
return "None";
}
function buttonsLabel(b: WhatsAppBlast): string {
const n = Array.isArray(b.ctas) ? b.ctas.length : 0;
if (!n) return "—";
return n === 1 ? "1 button" : `${n} buttons`;
}
export function WhatsAppBlastHistoryPanel({
eventId,
eventName,
onClose,
}: {
eventId: string;
eventName?: string;
onClose: () => void;
}) {
const [blasts, setBlasts] = useState<WhatsAppBlast[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const res = await whatsappBlastsApi.list(eventId);
if (res.error) {
setError(res.error);
setBlasts([]);
} else {
setBlasts(res.data?.blasts ?? []);
}
} catch {
setError("Failed to load notifications.");
setBlasts([]);
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => {
void load();
}, [load]);
return (
<PanelShell
onClose={onClose}
title="WhatsApp notifications"
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home · WhatsApp Notifications"}
description="Past and scheduled blasts to people in this event with an open WhatsApp chat (24-hour window)."
completedCount={0}
totalSections={1}
ctaLabel=""
showProgress={false}
hidePrimaryCta
panelWidth="min(920px, 100vw)"
>
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: 12 }}>
{error ? (
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--destructive)" }}>{error}</p>
) : null}
<div
style={{
background: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-card)",
overflow: "auto",
}}
>
<div style={{ minWidth: 720 }}>
<div
style={{
display: "grid",
gridTemplateColumns: LIST_GRID,
padding: "8px 16px",
background: "var(--secondary)",
borderBottom: "1px solid var(--border)",
gap: "8px",
}}
>
{["Message", "Media", "Buttons", "Status", "When", "Sent", "Skip", "Fail"].map((h) => (
<span
key={h}
style={{
fontSize: "10px",
fontFamily: "var(--font-noto-sans)",
fontWeight: 600,
color: "var(--muted)",
textTransform: "uppercase",
}}
>
{h}
</span>
))}
</div>
{loading ? (
<p style={{ padding: 24, fontSize: "var(--text-sm)", color: "var(--muted)", margin: 0 }}>Loading…</p>
) : blasts.length === 0 ? (
<div style={{ padding: 24, display: "flex", flexDirection: "column", gap: 8, alignItems: "flex-start" }}>
<MessageSquare size={20} style={{ color: "var(--muted)" }} />
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
No WhatsApp notifications for this event yet.
</p>
</div>
) : (
blasts.map((b) => (
<div
key={b._id}
style={{
display: "grid",
gridTemplateColumns: LIST_GRID,
alignItems: "center",
padding: "12px 16px",
borderBottom: "1px solid var(--border)",
gap: "8px",
}}
>
<span style={{ fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--foreground)" }}>
{previewBody(b.body)}
</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--card-foreground)" }}>
{b.mediaUrl ? (
<a href={b.mediaUrl} target="_blank" rel="noreferrer" style={{ color: "var(--primary)" }}>
{mediaLabel(b)}
</a>
) : (
mediaLabel(b)
)}
</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--card-foreground)" }}>{buttonsLabel(b)}</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--card-foreground)" }}>{b.status}</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>{formatWhen(b)}</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>{b.stats?.sent ?? 0}</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>{b.stats?.skipped ?? 0}</span>
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)" }}>{b.stats?.failed ?? 0}</span>
</div>
))
)}
</div>
</div>
</div>
</PanelShell>
);
}

View File

@ -316,6 +316,28 @@ export function WhatsAppChatbotsPage() {
await loadBot(bot._id);
};
const handleDeleteBot = async () => {
if (!bot) return;
const label = bot.name || eventName(bot);
if (
!window.confirm(
`Delete chatbot “${label}”? This cannot be undone. WhatsApp chat sessions are not deleted.`,
)
) {
return;
}
setMsg(null);
const res = await conciergeBotsApi.remove(bot._id);
if (res.error) {
setMsg(res.error);
return;
}
setSelectedId(null);
setBot(null);
setMsg("Chatbot deleted");
await loadBots();
};
const handleSyncEvent = async () => {
if (!bot) return;
await conciergeBotsApi.syncEventKnowledge(bot._id);
@ -627,6 +649,29 @@ export function WhatsAppChatbotsPage() {
</button>
</div>
<div
style={{
marginTop: 16,
paddingTop: 16,
borderTop: "1px solid var(--border)",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 8,
}}
>
<button
type="button"
style={{ ...btnSecondary, color: "var(--destructive)", borderColor: "var(--destructive)" }}
onClick={() => void handleDeleteBot()}
>
<Trash2 size={14} /> Delete chatbot
</button>
<span style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
Permanently removes this bot. Uncheck “Enable chatbot” to pause it instead.
</span>
</div>
<div style={{ marginTop: 14, padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
<h3 style={{ margin: "0 0 8px", fontSize: 14 }}>Website embed code</h3>
<p style={{ margin: "0 0 8px", fontSize: 12, color: "var(--muted-foreground)" }}>

View File

@ -11,6 +11,7 @@ import {
} from "../../../services/api";
import type { WhatsAppTemplateRecord } from "../../../services/api/types";
import { useAppNav } from "../../contexts/AppNavContext";
import { WhatsAppCtaEditor, normalizeCtasForSave, type WhatsAppCtaItem } from "../whatsapp/WhatsAppCtaEditor";
const IS: React.CSSProperties = {
width: "100%",
@ -57,7 +58,10 @@ function emptyDrip(): ConciergeReceptionDrip {
body: "",
mediaType: "none",
mediaUrl: "",
contentKind: "custom",
agendaCount: 3,
templateId: "",
ctas: [],
};
}
@ -117,14 +121,37 @@ export function WhatsAppReceptionTab({
const save = async () => {
setSaving(true);
setMsg(null);
let dripsPayload: ConciergeReceptionDrip[];
try {
dripsPayload = (reception.drips || []).map((d) => {
const ctas =
d.contentKind === "upcoming_agenda" ? [] : normalizeCtasForSave((d.ctas || []) as WhatsAppCtaItem[]);
if (ctas.length && !String(d.body || "").trim()) {
throw new Error("Drip message text is required when buttons are set.");
}
return {
_id: d._id,
trigger: d.trigger,
delayMinutes: d.delayMinutes,
body: d.body,
mediaType: d.contentKind === "upcoming_agenda" ? "none" : d.mediaType,
mediaUrl: d.contentKind === "upcoming_agenda" ? "" : d.mediaUrl,
contentKind: d.contentKind || "custom",
agendaCount: d.agendaCount,
templateId: d.templateId || null,
sendAt: d.sendAt || null,
ctas,
};
});
} catch (e) {
setSaving(false);
setMsg(e instanceof Error ? e.message : "Invalid buttons");
return;
}
const res = await conciergeBotsApi.update(bot._id, {
reception: {
...reception,
drips: (reception.drips || []).map((d) => ({
...d,
templateId: d.templateId || null,
sendAt: d.sendAt || null,
})),
drips: dripsPayload,
},
});
setSaving(false);
@ -252,6 +279,11 @@ export function WhatsAppReceptionTab({
<section style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
<h3 style={{ margin: "0 0 10px", fontSize: 14 }}>Drip messages</h3>
<p style={{ fontSize: 12, color: "var(--muted-foreground)", margin: "0 0 10px" }}>
Drips are queued when a visitor messages (or is identified), not when you click Save.
If someone already checked in, they need to send another WhatsApp message to pick up a newly added drip.
Upcoming agenda drips list the next sessions at send time (not when you save).
</p>
<label style={{ display: "block", marginBottom: 10 }}>
Send drips to
<select
@ -265,7 +297,7 @@ export function WhatsAppReceptionTab({
</label>
{(reception.drips || []).map((drip, i) => (
<div
key={i}
key={drip._id || i}
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 10, marginBottom: 10 }}
>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
@ -278,6 +310,22 @@ export function WhatsAppReceptionTab({
<Trash2 size={14} /> Remove
</button>
</div>
<label>
Type
<select
style={IS}
value={drip.contentKind || "custom"}
onChange={(e) =>
patchDrip(i, {
contentKind: e.target.value as ConciergeReceptionDrip["contentKind"],
...(e.target.value === "upcoming_agenda" ? { mediaType: "none" as const, mediaUrl: "" } : {}),
})
}
>
<option value="custom">Custom message</option>
<option value="upcoming_agenda">Upcoming agenda</option>
</select>
</label>
<label>
When
<select
@ -314,14 +362,34 @@ export function WhatsAppReceptionTab({
/>
</label>
)}
{drip.contentKind === "upcoming_agenda" && (
<label>
Body
How many upcoming sessions
<input
type="number"
min={1}
max={8}
style={IS}
value={drip.agendaCount ?? 3}
onChange={(e) => patchDrip(i, { agendaCount: Number(e.target.value) })}
/>
</label>
)}
<label>
{drip.contentKind === "upcoming_agenda" ? "Intro (optional)" : "Body"}
<textarea
style={{ ...IS, minHeight: 64 }}
value={drip.body || ""}
onChange={(e) => patchDrip(i, { body: e.target.value })}
placeholder={
drip.contentKind === "upcoming_agenda"
? "e.g. Here are the next sessions on the agenda."
: undefined
}
/>
</label>
{drip.contentKind !== "upcoming_agenda" && (
<>
<label>
Media
<input
@ -342,11 +410,24 @@ export function WhatsAppReceptionTab({
</button>
</p>
)}
{outside24h(drip) && (
<WhatsAppCtaEditor
value={(drip.ctas || []) as WhatsAppCtaItem[]}
onChange={(ctas) => patchDrip(i, { ctas })}
compact
/>
</>
)}
{outside24h(drip) && drip.contentKind === "upcoming_agenda" && (
<p style={{ fontSize: 12, color: "var(--destructive)", margin: "6px 0" }}>
If this fires after WhatsApp’s 24-hour window, the agenda drip is skipped (live session lists cannot use a template).
</p>
)}
{outside24h(drip) && drip.contentKind !== "upcoming_agenda" && (
<p style={{ fontSize: 12, color: "var(--destructive)", margin: "6px 0" }}>
This send may fall outside WhatsApp’s 24-hour window. Pick an approved template.
</p>
)}
{drip.contentKind !== "upcoming_agenda" && (
<label>
Template (if outside 24h)
<select
@ -362,6 +443,7 @@ export function WhatsAppReceptionTab({
))}
</select>
</label>
)}
</div>
))}
<button type="button" style={btnSecondary} onClick={() => patch({ drips: [...(reception.drips || []), emptyDrip()] })}>

View File

@ -0,0 +1,206 @@
import React from "react";
import { Plus, Trash2 } from "lucide-react";
export type WhatsAppCtaType = "reply" | "url";
export interface WhatsAppCtaItem {
type: WhatsAppCtaType;
label: string;
value: string;
}
const IS: React.CSSProperties = {
width: "100%",
padding: "8px 12px",
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
background: "var(--input-background)",
fontSize: "var(--text-sm)",
color: "var(--card-foreground)",
boxSizing: "border-box",
};
const btnSecondary: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
borderRadius: "var(--radius-button)",
background: "var(--secondary)",
color: "var(--card-foreground)",
border: "1px solid var(--border)",
cursor: "pointer",
fontSize: "var(--text-sm)",
};
function emptyCta(type: WhatsAppCtaType = "reply"): WhatsAppCtaItem {
return { type, label: "", value: "" };
}
/** Normalize for API: drop incomplete rows; enforce Meta same-type / single-URL rules. */
export function normalizeCtasForSave(ctas: WhatsAppCtaItem[] | undefined): WhatsAppCtaItem[] {
const rows = (ctas || [])
.map((c) => ({
type: c.type === "url" ? ("url" as const) : ("reply" as const),
label: String(c.label || "").trim().slice(0, 20),
value: String(c.value || "").trim(),
}))
.filter((c) => c.label && (c.type === "reply" ? true : c.value));
if (!rows.length) return [];
const type = rows[0].type;
if (rows.some((r) => r.type !== type)) {
throw new Error("Buttons must all be Reply or all be URL (Meta limitation).");
}
if (type === "url") {
if (rows.length > 1) throw new Error("Only one URL button is allowed per message.");
if (!/^https?:\/\//i.test(rows[0].value)) {
throw new Error("URL buttons must start with http:// or https://");
}
return [{ type: "url", label: rows[0].label, value: rows[0].value.slice(0, 2000) }];
}
return rows.slice(0, 3).map((r) => ({
type: "reply" as const,
label: r.label,
value: (r.value || r.label).slice(0, 256),
}));
}
export function WhatsAppCtaEditor({
value,
onChange,
disabled,
compact,
}: {
value: WhatsAppCtaItem[];
onChange: (next: WhatsAppCtaItem[]) => void;
disabled?: boolean;
compact?: boolean;
}) {
const lockedType = value.length ? value[0].type : null;
const canAdd =
!disabled &&
value.length < 3 &&
(lockedType !== "url" || value.length === 0) &&
!(lockedType === "url" && value.length >= 1);
const addRow = (type: WhatsAppCtaType) => {
if (disabled) return;
if (lockedType && lockedType !== type) return;
if (type === "url" && value.length >= 1) return;
if (value.length >= 3) return;
onChange([...value, emptyCta(type)]);
};
const patchRow = (index: number, partial: Partial<WhatsAppCtaItem>) => {
const next = value.map((row, i) => (i === index ? { ...row, ...partial } : row));
onChange(next);
};
const removeRow = (index: number) => {
onChange(value.filter((_, i) => i !== index));
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: compact ? 8 : 10 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: compact ? 12 : "var(--text-sm)", fontWeight: 500 }}>Buttons (optional)</span>
<span style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.4 }}>
Up to 3 Reply buttons, or one URL button — not both. Only sent inside the 24-hour chat window.
</span>
</div>
{value.map((row, i) => (
<div
key={i}
style={{
display: "grid",
gridTemplateColumns: "88px 1fr 1fr auto",
gap: 8,
alignItems: "end",
}}
>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 11 }}>
Type
<select
style={IS}
value={row.type}
disabled={disabled || (lockedType != null && value.length > 1)}
onChange={(e) => {
const type = e.target.value === "url" ? "url" : "reply";
if (type === "url" && value.length > 1) return;
if (lockedType && lockedType !== type && value.length > 1) return;
// Changing type on first row when alone is fine; if others exist, keep homogeneous
const nextType = type;
onChange(
value.map((r, idx) =>
idx === i ? { ...r, type: nextType } : value.length === 1 ? r : { ...r, type: nextType },
),
);
}}
>
<option value="reply">Reply</option>
<option value="url" disabled={value.length > 1}>
URL
</option>
</select>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 11 }}>
Label
<input
style={IS}
value={row.label}
maxLength={20}
disabled={disabled}
placeholder="Button text"
onChange={(e) => patchRow(i, { label: e.target.value })}
/>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 11 }}>
{row.type === "url" ? "URL" : "Reply id"}
<input
style={IS}
value={row.value}
disabled={disabled}
placeholder={row.type === "url" ? "https://…" : "Same as label if empty"}
onChange={(e) => patchRow(i, { value: e.target.value })}
/>
</label>
<button
type="button"
style={{ ...btnSecondary, padding: 8 }}
disabled={disabled}
aria-label="Remove button"
onClick={() => removeRow(i)}
>
<Trash2 size={14} />
</button>
</div>
))}
{!disabled && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
type="button"
style={btnSecondary}
disabled={!canAdd || (lockedType != null && lockedType !== "reply")}
onClick={() => addRow("reply")}
>
<Plus size={14} /> Add reply
</button>
<button
type="button"
style={btnSecondary}
disabled={
disabled ||
value.length >= 1 ||
(lockedType != null && lockedType !== "url")
}
onClick={() => addRow("url")}
>
<Plus size={14} /> Add URL
</button>
</div>
)}
</div>
);
}

View File

@ -13,13 +13,17 @@ export interface ConciergeBotRegistration {
}
export interface ConciergeReceptionDrip {
_id?: string;
trigger?: "after_first_inbound" | "after_match" | "absolute";
delayMinutes?: number;
sendAt?: string | null;
body?: string;
mediaType?: "none" | "image" | "video";
mediaUrl?: string;
contentKind?: "custom" | "upcoming_agenda";
agendaCount?: number;
templateId?: string | null;
ctas?: Array<{ type: "reply" | "url"; label: string; value: string }>;
}
export interface ConciergeBotReception {

View File

@ -46,6 +46,7 @@ export {
type PhotoWallAccessMode,
type PhotoWallModerationMode,
type PhotoWallPhotoStatus,
type PhotoWallPresentPhotoShape,
type PhotoWallEventHeader,
} from './photoWall';
@ -123,6 +124,7 @@ export { uploadMediaFile, listMedia, type UploadResult, type UploadMediaOptions,
export { contentCollectionsApi } from './contentCollections';
export { organizationApi } from './organization';
export { whatsappTemplatesApi } from './whatsappTemplates';
export { whatsappBlastsApi, type WhatsAppBlast, type WhatsAppBlastStatus } from './whatsappBlasts';
export {
conciergeBotsApi,
type ConciergeBotRecord,

View File

@ -3,6 +3,7 @@ import { api, fetchWithAuth } from './client';
export type PhotoWallAccessMode = 'open' | 'login';
export type PhotoWallModerationMode = 'auto' | 'approval';
export type PhotoWallPhotoStatus = 'pending' | 'approved' | 'removed';
export type PhotoWallPresentPhotoShape = 'rect' | 'rounded' | 'circle';
export interface PhotoWallEventHeader {
showLogo?: boolean;
@ -69,6 +70,10 @@ export interface PhotoWallConfig {
maxPhotoWidth: number;
maxPhotoHeight: number;
gapPx: number;
presentMarginTopPx: number;
presentMarginLeftPx: number;
presentMarginRightPx: number;
presentPhotoShape: PhotoWallPresentPhotoShape;
showUploaderNameOnWall: boolean;
requireUploadCrop: boolean;
frameEnabled: boolean;
@ -87,6 +92,7 @@ export interface PhotoWallConfig {
whatsapp?: boolean;
};
eventHeader: PhotoWallEventHeader;
presenterEventHeader: PhotoWallEventHeader;
uploadToken: string | null;
presentToken: string | null;
photoCounts: PhotoWallCounts;
@ -127,6 +133,10 @@ export type PhotoWallUpdatePayload = Partial<{
maxPhotoWidth: number;
maxPhotoHeight: number;
gapPx: number;
presentMarginTopPx: number;
presentMarginLeftPx: number;
presentMarginRightPx: number;
presentPhotoShape: PhotoWallPresentPhotoShape;
showUploaderNameOnWall: boolean;
requireUploadCrop: boolean;
frameEnabled: boolean;
@ -145,6 +155,7 @@ export type PhotoWallUpdatePayload = Partial<{
whatsapp: boolean;
};
eventHeader: PhotoWallEventHeader;
presenterEventHeader: PhotoWallEventHeader;
regenerateTokens: boolean;
}>;

View File

@ -862,6 +862,7 @@ export interface AttendanceAudienceSettings {
export interface AttendanceSettings {
allowDuplicateCheckIn?: boolean;
allowAssignBlank?: boolean;
publicLinkEnabled?: boolean;
audience?: AttendanceAudienceSettings;
}

View File

@ -0,0 +1,56 @@
import { api } from './client';
export type WhatsAppBlastStatus = 'queued' | 'sending' | 'sent' | 'failed';
export type WhatsAppBlastMediaType = 'none' | 'image' | 'video';
export interface WhatsAppBlastStats {
eligible: number;
sent: number;
skipped: number;
failed: number;
}
export interface WhatsAppBlastCta {
type: 'reply' | 'url';
label: string;
value: string;
}
export interface WhatsAppBlast {
_id: string;
event: string;
organization: string;
body: string;
mediaType: WhatsAppBlastMediaType;
mediaUrl: string;
ctas?: WhatsAppBlastCta[];
sendAt: string;
status: WhatsAppBlastStatus;
stats: WhatsAppBlastStats;
lastError: string;
sentAt: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface CreateWhatsAppBlastPayload {
body?: string;
mediaType?: WhatsAppBlastMediaType;
mediaUrl?: string;
sendAt?: string;
ctas?: WhatsAppBlastCta[];
}
export const whatsappBlastsApi = {
list(eventId: string) {
return api.get<{ blasts: WhatsAppBlast[] }>(`/events/${eventId}/whatsapp-blasts`);
},
eligibleCount(eventId: string) {
return api.get<{ count: number }>(`/events/${eventId}/whatsapp-blasts/eligible-count`);
},
create(eventId: string, payload: CreateWhatsAppBlastPayload) {
return api.post<WhatsAppBlast>(`/events/${eventId}/whatsapp-blasts`, payload);
},
};