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:
parent
57f73cb6c2
commit
b7b3cd19a5
|
|
@ -12,7 +12,7 @@
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-BVkPIZas.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,12 @@
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"quill": "1.3.7",
|
"quill": "1.3.7",
|
||||||
"react-quill": "2.0.0",
|
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react-dnd": "16.0.1",
|
"react-dnd": "16.0.1",
|
||||||
"react-dnd-html5-backend": "16.0.1",
|
"react-dnd-html5-backend": "16.0.1",
|
||||||
"react-hook-form": "7.55.0",
|
"react-hook-form": "7.55.0",
|
||||||
"react-popper": "2.3.0",
|
"react-popper": "2.3.0",
|
||||||
|
"react-quill": "2.0.0",
|
||||||
"react-resizable-panels": "2.1.7",
|
"react-resizable-panels": "2.1.7",
|
||||||
"react-responsive-masonry": "2.7.1",
|
"react-responsive-masonry": "2.7.1",
|
||||||
"react-router": "7.13.0",
|
"react-router": "7.13.0",
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ interface FormState {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
allowDuplicateCheckIn: boolean;
|
allowDuplicateCheckIn: boolean;
|
||||||
|
allowAssignBlank: boolean;
|
||||||
publicLinkEnabled: boolean;
|
publicLinkEnabled: boolean;
|
||||||
audienceMode: AttendanceAudienceMode;
|
audienceMode: AttendanceAudienceMode;
|
||||||
includeStatuses: AttendanceMembershipStatus[];
|
includeStatuses: AttendanceMembershipStatus[];
|
||||||
|
|
@ -97,6 +98,7 @@ const emptyForm: FormState = {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
allowDuplicateCheckIn: false,
|
allowDuplicateCheckIn: false,
|
||||||
|
allowAssignBlank: false,
|
||||||
publicLinkEnabled: true,
|
publicLinkEnabled: true,
|
||||||
audienceMode: "all",
|
audienceMode: "all",
|
||||||
includeStatuses: ["confirmed", "checked_in"],
|
includeStatuses: ["confirmed", "checked_in"],
|
||||||
|
|
@ -210,6 +212,7 @@ export function AttendancePage() {
|
||||||
name: s.name,
|
name: s.name,
|
||||||
description: s.description || "",
|
description: s.description || "",
|
||||||
allowDuplicateCheckIn: Boolean(s.settings?.allowDuplicateCheckIn),
|
allowDuplicateCheckIn: Boolean(s.settings?.allowDuplicateCheckIn),
|
||||||
|
allowAssignBlank: Boolean(s.settings?.allowAssignBlank),
|
||||||
publicLinkEnabled: s.settings?.publicLinkEnabled !== false,
|
publicLinkEnabled: s.settings?.publicLinkEnabled !== false,
|
||||||
audienceMode: audience?.mode || "all",
|
audienceMode: audience?.mode || "all",
|
||||||
includeStatuses:
|
includeStatuses:
|
||||||
|
|
@ -276,6 +279,7 @@ export function AttendancePage() {
|
||||||
description: form.description.trim(),
|
description: form.description.trim(),
|
||||||
settings: {
|
settings: {
|
||||||
allowDuplicateCheckIn: form.allowDuplicateCheckIn,
|
allowDuplicateCheckIn: form.allowDuplicateCheckIn,
|
||||||
|
allowAssignBlank: form.allowAssignBlank,
|
||||||
publicLinkEnabled: form.publicLinkEnabled,
|
publicLinkEnabled: form.publicLinkEnabled,
|
||||||
audience: {
|
audience: {
|
||||||
mode: form.audienceMode,
|
mode: form.audienceMode,
|
||||||
|
|
@ -926,6 +930,14 @@ export function AttendancePage() {
|
||||||
/>
|
/>
|
||||||
Allow duplicate check-ins
|
Allow duplicate check-ins
|
||||||
</label>
|
</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" }}>
|
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
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 { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
@ -33,6 +33,9 @@ import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls";
|
||||||
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
|
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
|
||||||
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
|
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
|
||||||
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
||||||
|
import { WhatsAppBlastHistoryPanel } from "./WhatsAppBlastHistoryPanel";
|
||||||
|
import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel";
|
||||||
|
import { whatsappBlastsApi } from "../../../services/api/whatsappBlasts";
|
||||||
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
||||||
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
||||||
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
import { AddQuizPollModal, buildQuizPollSettingsPayload } from "../quiz/AddQuizPollModal";
|
||||||
|
|
@ -252,6 +255,8 @@ export function EventHomeModules() {
|
||||||
const canWriteAiAvatar = canModule("ai_avatar", "write");
|
const canWriteAiAvatar = canModule("ai_avatar", "write");
|
||||||
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
||||||
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
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 canReadExhibitors = canModule("exhibitors", "read");
|
||||||
const canWriteExhibitors = canModule("exhibitors", "write");
|
const canWriteExhibitors = canModule("exhibitors", "write");
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
|
|
@ -327,6 +332,10 @@ export function EventHomeModules() {
|
||||||
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||||
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
||||||
const [showEmailCampaignsPanel, setShowEmailCampaignsPanel] = 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 [exhibitorCount, setExhibitorCount] = useState(0);
|
||||||
const [exhibitorLoading, setExhibitorLoading] = useState(false);
|
const [exhibitorLoading, setExhibitorLoading] = useState(false);
|
||||||
const [showExhibitorsPanel, setShowExhibitorsPanel] = useState(false);
|
const [showExhibitorsPanel, setShowExhibitorsPanel] = useState(false);
|
||||||
|
|
@ -600,6 +609,27 @@ export function EventHomeModules() {
|
||||||
void loadEmailCampaignMeta();
|
void loadEmailCampaignMeta();
|
||||||
}, [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 () => {
|
const loadExhibitorMeta = useCallback(async () => {
|
||||||
if (!selectedEventId) {
|
if (!selectedEventId) {
|
||||||
setExhibitorCount(0);
|
setExhibitorCount(0);
|
||||||
|
|
@ -823,6 +853,11 @@ export function EventHomeModules() {
|
||||||
: emailCampaignLoading
|
: emailCampaignLoading
|
||||||
? "Loading…"
|
? "Loading…"
|
||||||
: `${emailCampaignCount} email campaign${emailCampaignCount === 1 ? "" : "s"}`;
|
: `${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
|
const exhibitorMeta = !selectedEventId
|
||||||
? "Select an event to manage exhibitors"
|
? "Select an event to manage exhibitors"
|
||||||
: exhibitorLoading
|
: exhibitorLoading
|
||||||
|
|
@ -1787,6 +1822,52 @@ export function EventHomeModules() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 ? (
|
{canReadEmailCampaigns ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Mail size={18} />}
|
icon={<Mail size={18} />}
|
||||||
|
|
@ -2030,6 +2111,23 @@ export function EventHomeModules() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{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 ? (
|
{showEmailCampaignsPanel && selectedEventId ? (
|
||||||
<EventEmailCampaignsPanel
|
<EventEmailCampaignsPanel
|
||||||
eventId={selectedEventId}
|
eventId={selectedEventId}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type PhotoWallConfig,
|
type PhotoWallConfig,
|
||||||
type PhotoWallPhotoRow,
|
type PhotoWallPhotoRow,
|
||||||
type PhotoWallEventHeader,
|
type PhotoWallEventHeader,
|
||||||
|
type PhotoWallPresentPhotoShape,
|
||||||
type Form,
|
type Form,
|
||||||
type FormWithSubmissionCount,
|
type FormWithSubmissionCount,
|
||||||
} from "../../../services/api";
|
} from "../../../services/api";
|
||||||
|
|
@ -36,6 +37,16 @@ import {
|
||||||
type Tab = "photos" | "settings" | "frame" | "links";
|
type Tab = "photos" | "settings" | "frame" | "links";
|
||||||
|
|
||||||
const DEFAULT_HEADER = eventHeaderFromApi(null);
|
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({
|
export function PhotoWallManagePanel({
|
||||||
eventId,
|
eventId,
|
||||||
|
|
@ -70,10 +81,15 @@ export function PhotoWallManagePanel({
|
||||||
maxPhotoWidth: 320,
|
maxPhotoWidth: 320,
|
||||||
maxPhotoHeight: 320,
|
maxPhotoHeight: 320,
|
||||||
gapPx: 8,
|
gapPx: 8,
|
||||||
|
presentMarginTopPx: 0,
|
||||||
|
presentMarginLeftPx: 0,
|
||||||
|
presentMarginRightPx: 0,
|
||||||
|
presentPhotoShape: "rounded" as PhotoWallPresentPhotoShape,
|
||||||
showUploaderNameOnWall: false,
|
showUploaderNameOnWall: false,
|
||||||
requireUploadCrop: false,
|
requireUploadCrop: false,
|
||||||
formId: "" as string,
|
formId: "" as string,
|
||||||
eventHeader: DEFAULT_HEADER,
|
eventHeader: DEFAULT_HEADER,
|
||||||
|
presenterEventHeader: DEFAULT_PRESENTER_HEADER,
|
||||||
frameEnabled: false,
|
frameEnabled: false,
|
||||||
frameImageUrl: "" as string,
|
frameImageUrl: "" as string,
|
||||||
frameImageLayout: defaultFrameImageLayout() as PhotoWallFrameImageLayout,
|
frameImageLayout: defaultFrameImageLayout() as PhotoWallFrameImageLayout,
|
||||||
|
|
@ -112,10 +128,30 @@ export function PhotoWallManagePanel({
|
||||||
maxPhotoWidth: wallRes.data.maxPhotoWidth ?? 320,
|
maxPhotoWidth: wallRes.data.maxPhotoWidth ?? 320,
|
||||||
maxPhotoHeight: wallRes.data.maxPhotoHeight ?? 320,
|
maxPhotoHeight: wallRes.data.maxPhotoHeight ?? 320,
|
||||||
gapPx: wallRes.data.gapPx ?? 8,
|
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),
|
showUploaderNameOnWall: Boolean(wallRes.data.showUploaderNameOnWall),
|
||||||
requireUploadCrop: Boolean(wallRes.data.requireUploadCrop),
|
requireUploadCrop: Boolean(wallRes.data.requireUploadCrop),
|
||||||
formId: wallRes.data.formId ? String(wallRes.data.formId) : "",
|
formId: wallRes.data.formId ? String(wallRes.data.formId) : "",
|
||||||
eventHeader: eventHeaderFromApi(wallRes.data.eventHeader as PhotoWallEventHeader | null),
|
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),
|
frameEnabled: Boolean(wallRes.data.frameEnabled),
|
||||||
frameImageUrl: wallRes.data.frameImageUrl || "",
|
frameImageUrl: wallRes.data.frameImageUrl || "",
|
||||||
frameImageLayout: normalizeFrameImageLayout(wallRes.data.frameImageLayout),
|
frameImageLayout: normalizeFrameImageLayout(wallRes.data.frameImageLayout),
|
||||||
|
|
@ -175,10 +211,15 @@ export function PhotoWallManagePanel({
|
||||||
maxPhotoWidth: draft.maxPhotoWidth,
|
maxPhotoWidth: draft.maxPhotoWidth,
|
||||||
maxPhotoHeight: draft.maxPhotoHeight,
|
maxPhotoHeight: draft.maxPhotoHeight,
|
||||||
gapPx: draft.gapPx,
|
gapPx: draft.gapPx,
|
||||||
|
presentMarginTopPx: draft.presentMarginTopPx,
|
||||||
|
presentMarginLeftPx: draft.presentMarginLeftPx,
|
||||||
|
presentMarginRightPx: draft.presentMarginRightPx,
|
||||||
|
presentPhotoShape: draft.presentPhotoShape,
|
||||||
showUploaderNameOnWall: draft.showUploaderNameOnWall,
|
showUploaderNameOnWall: draft.showUploaderNameOnWall,
|
||||||
requireUploadCrop: draft.requireUploadCrop,
|
requireUploadCrop: draft.requireUploadCrop,
|
||||||
formId: draft.collectDetails && draft.formId ? draft.formId : null,
|
formId: draft.collectDetails && draft.formId ? draft.formId : null,
|
||||||
eventHeader: eventHeaderToApi(draft.eventHeader),
|
eventHeader: eventHeaderToApi(draft.eventHeader),
|
||||||
|
presenterEventHeader: eventHeaderToApi(draft.presenterEventHeader),
|
||||||
};
|
};
|
||||||
const res = await photoWallApi.update(eventId, payload);
|
const res = await photoWallApi.update(eventId, payload);
|
||||||
if (res.error || !res.data) throw new Error(res.error || "Save failed");
|
if (res.error || !res.data) throw new Error(res.error || "Save failed");
|
||||||
|
|
@ -528,14 +569,14 @@ export function PhotoWallManagePanel({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Event header on visitor page" />
|
<Label text="Event header on upload page" />
|
||||||
<HelperText text="Choose which event details appear above the upload form. Same options as public forms." />
|
<HelperText text="Choose which event details appear above the visitor upload form." />
|
||||||
<Grid cols={2}>
|
<Grid cols={2}>
|
||||||
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => (
|
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => (
|
||||||
<Field key={key}>
|
<Field key={`upload-${key}`}>
|
||||||
<Label text={EVENT_HEADER_TOGGLE_LABELS[key]} />
|
<Label text={EVENT_HEADER_TOGGLE_LABELS[key]} />
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
name={`pw-header-${key}`}
|
name={`pw-header-upload-${key}`}
|
||||||
value={draft.eventHeader[key]}
|
value={draft.eventHeader[key]}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
setDraft((d) => ({
|
setDraft((d) => ({
|
||||||
|
|
@ -553,6 +594,35 @@ export function PhotoWallManagePanel({
|
||||||
</Grid>
|
</Grid>
|
||||||
</Field>
|
</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}>
|
<Grid cols={2}>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Photos per row" />
|
<Label text="Photos per row" />
|
||||||
|
|
@ -570,6 +640,7 @@ export function PhotoWallManagePanel({
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Gap (px)" />
|
<Label text="Gap (px)" />
|
||||||
|
<HelperText text="Space between photos on the presenter grid." />
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
|
|
@ -582,6 +653,77 @@ export function PhotoWallManagePanel({
|
||||||
onBlur={focusOut}
|
onBlur={focusOut}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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>
|
<Field>
|
||||||
<Label text="Max photo width (px)" />
|
<Label text="Max photo width (px)" />
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -316,6 +316,28 @@ export function WhatsAppChatbotsPage() {
|
||||||
await loadBot(bot._id);
|
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 () => {
|
const handleSyncEvent = async () => {
|
||||||
if (!bot) return;
|
if (!bot) return;
|
||||||
await conciergeBotsApi.syncEventKnowledge(bot._id);
|
await conciergeBotsApi.syncEventKnowledge(bot._id);
|
||||||
|
|
@ -627,6 +649,29 @@ export function WhatsAppChatbotsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 }}>
|
<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>
|
<h3 style={{ margin: "0 0 8px", fontSize: 14 }}>Website embed code</h3>
|
||||||
<p style={{ margin: "0 0 8px", fontSize: 12, color: "var(--muted-foreground)" }}>
|
<p style={{ margin: "0 0 8px", fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
} from "../../../services/api";
|
} from "../../../services/api";
|
||||||
import type { WhatsAppTemplateRecord } from "../../../services/api/types";
|
import type { WhatsAppTemplateRecord } from "../../../services/api/types";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
|
import { WhatsAppCtaEditor, normalizeCtasForSave, type WhatsAppCtaItem } from "../whatsapp/WhatsAppCtaEditor";
|
||||||
|
|
||||||
const IS: React.CSSProperties = {
|
const IS: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|
@ -57,7 +58,10 @@ function emptyDrip(): ConciergeReceptionDrip {
|
||||||
body: "",
|
body: "",
|
||||||
mediaType: "none",
|
mediaType: "none",
|
||||||
mediaUrl: "",
|
mediaUrl: "",
|
||||||
|
contentKind: "custom",
|
||||||
|
agendaCount: 3,
|
||||||
templateId: "",
|
templateId: "",
|
||||||
|
ctas: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,14 +121,37 @@ export function WhatsAppReceptionTab({
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
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, {
|
const res = await conciergeBotsApi.update(bot._id, {
|
||||||
reception: {
|
reception: {
|
||||||
...reception,
|
...reception,
|
||||||
drips: (reception.drips || []).map((d) => ({
|
drips: dripsPayload,
|
||||||
...d,
|
|
||||||
templateId: d.templateId || null,
|
|
||||||
sendAt: d.sendAt || null,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
|
@ -252,6 +279,11 @@ export function WhatsAppReceptionTab({
|
||||||
|
|
||||||
<section style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
|
<section style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
|
||||||
<h3 style={{ margin: "0 0 10px", fontSize: 14 }}>Drip messages</h3>
|
<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 }}>
|
<label style={{ display: "block", marginBottom: 10 }}>
|
||||||
Send drips to
|
Send drips to
|
||||||
<select
|
<select
|
||||||
|
|
@ -265,7 +297,7 @@ export function WhatsAppReceptionTab({
|
||||||
</label>
|
</label>
|
||||||
{(reception.drips || []).map((drip, i) => (
|
{(reception.drips || []).map((drip, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={drip._id || i}
|
||||||
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 10, marginBottom: 10 }}
|
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 10, marginBottom: 10 }}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
|
||||||
|
|
@ -278,6 +310,22 @@ export function WhatsAppReceptionTab({
|
||||||
<Trash2 size={14} /> Remove
|
<Trash2 size={14} /> Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<label>
|
||||||
When
|
When
|
||||||
<select
|
<select
|
||||||
|
|
@ -314,14 +362,34 @@ export function WhatsAppReceptionTab({
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{drip.contentKind === "upcoming_agenda" && (
|
||||||
<label>
|
<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
|
<textarea
|
||||||
style={{ ...IS, minHeight: 64 }}
|
style={{ ...IS, minHeight: 64 }}
|
||||||
value={drip.body || ""}
|
value={drip.body || ""}
|
||||||
onChange={(e) => patchDrip(i, { body: e.target.value })}
|
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>
|
</label>
|
||||||
|
{drip.contentKind !== "upcoming_agenda" && (
|
||||||
|
<>
|
||||||
<label>
|
<label>
|
||||||
Media
|
Media
|
||||||
<input
|
<input
|
||||||
|
|
@ -342,11 +410,24 @@ export function WhatsAppReceptionTab({
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</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" }}>
|
<p style={{ fontSize: 12, color: "var(--destructive)", margin: "6px 0" }}>
|
||||||
This send may fall outside WhatsApp’s 24-hour window. Pick an approved template.
|
This send may fall outside WhatsApp’s 24-hour window. Pick an approved template.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{drip.contentKind !== "upcoming_agenda" && (
|
||||||
<label>
|
<label>
|
||||||
Template (if outside 24h)
|
Template (if outside 24h)
|
||||||
<select
|
<select
|
||||||
|
|
@ -362,6 +443,7 @@ export function WhatsAppReceptionTab({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button type="button" style={btnSecondary} onClick={() => patch({ drips: [...(reception.drips || []), emptyDrip()] })}>
|
<button type="button" style={btnSecondary} onClick={() => patch({ drips: [...(reception.drips || []), emptyDrip()] })}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,13 +13,17 @@ export interface ConciergeBotRegistration {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConciergeReceptionDrip {
|
export interface ConciergeReceptionDrip {
|
||||||
|
_id?: string;
|
||||||
trigger?: "after_first_inbound" | "after_match" | "absolute";
|
trigger?: "after_first_inbound" | "after_match" | "absolute";
|
||||||
delayMinutes?: number;
|
delayMinutes?: number;
|
||||||
sendAt?: string | null;
|
sendAt?: string | null;
|
||||||
body?: string;
|
body?: string;
|
||||||
mediaType?: "none" | "image" | "video";
|
mediaType?: "none" | "image" | "video";
|
||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
|
contentKind?: "custom" | "upcoming_agenda";
|
||||||
|
agendaCount?: number;
|
||||||
templateId?: string | null;
|
templateId?: string | null;
|
||||||
|
ctas?: Array<{ type: "reply" | "url"; label: string; value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConciergeBotReception {
|
export interface ConciergeBotReception {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export {
|
||||||
type PhotoWallAccessMode,
|
type PhotoWallAccessMode,
|
||||||
type PhotoWallModerationMode,
|
type PhotoWallModerationMode,
|
||||||
type PhotoWallPhotoStatus,
|
type PhotoWallPhotoStatus,
|
||||||
|
type PhotoWallPresentPhotoShape,
|
||||||
type PhotoWallEventHeader,
|
type PhotoWallEventHeader,
|
||||||
} from './photoWall';
|
} from './photoWall';
|
||||||
|
|
||||||
|
|
@ -123,6 +124,7 @@ export { uploadMediaFile, listMedia, type UploadResult, type UploadMediaOptions,
|
||||||
export { contentCollectionsApi } from './contentCollections';
|
export { contentCollectionsApi } from './contentCollections';
|
||||||
export { organizationApi } from './organization';
|
export { organizationApi } from './organization';
|
||||||
export { whatsappTemplatesApi } from './whatsappTemplates';
|
export { whatsappTemplatesApi } from './whatsappTemplates';
|
||||||
|
export { whatsappBlastsApi, type WhatsAppBlast, type WhatsAppBlastStatus } from './whatsappBlasts';
|
||||||
export {
|
export {
|
||||||
conciergeBotsApi,
|
conciergeBotsApi,
|
||||||
type ConciergeBotRecord,
|
type ConciergeBotRecord,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { api, fetchWithAuth } from './client';
|
||||||
export type PhotoWallAccessMode = 'open' | 'login';
|
export type PhotoWallAccessMode = 'open' | 'login';
|
||||||
export type PhotoWallModerationMode = 'auto' | 'approval';
|
export type PhotoWallModerationMode = 'auto' | 'approval';
|
||||||
export type PhotoWallPhotoStatus = 'pending' | 'approved' | 'removed';
|
export type PhotoWallPhotoStatus = 'pending' | 'approved' | 'removed';
|
||||||
|
export type PhotoWallPresentPhotoShape = 'rect' | 'rounded' | 'circle';
|
||||||
|
|
||||||
export interface PhotoWallEventHeader {
|
export interface PhotoWallEventHeader {
|
||||||
showLogo?: boolean;
|
showLogo?: boolean;
|
||||||
|
|
@ -69,6 +70,10 @@ export interface PhotoWallConfig {
|
||||||
maxPhotoWidth: number;
|
maxPhotoWidth: number;
|
||||||
maxPhotoHeight: number;
|
maxPhotoHeight: number;
|
||||||
gapPx: number;
|
gapPx: number;
|
||||||
|
presentMarginTopPx: number;
|
||||||
|
presentMarginLeftPx: number;
|
||||||
|
presentMarginRightPx: number;
|
||||||
|
presentPhotoShape: PhotoWallPresentPhotoShape;
|
||||||
showUploaderNameOnWall: boolean;
|
showUploaderNameOnWall: boolean;
|
||||||
requireUploadCrop: boolean;
|
requireUploadCrop: boolean;
|
||||||
frameEnabled: boolean;
|
frameEnabled: boolean;
|
||||||
|
|
@ -87,6 +92,7 @@ export interface PhotoWallConfig {
|
||||||
whatsapp?: boolean;
|
whatsapp?: boolean;
|
||||||
};
|
};
|
||||||
eventHeader: PhotoWallEventHeader;
|
eventHeader: PhotoWallEventHeader;
|
||||||
|
presenterEventHeader: PhotoWallEventHeader;
|
||||||
uploadToken: string | null;
|
uploadToken: string | null;
|
||||||
presentToken: string | null;
|
presentToken: string | null;
|
||||||
photoCounts: PhotoWallCounts;
|
photoCounts: PhotoWallCounts;
|
||||||
|
|
@ -127,6 +133,10 @@ export type PhotoWallUpdatePayload = Partial<{
|
||||||
maxPhotoWidth: number;
|
maxPhotoWidth: number;
|
||||||
maxPhotoHeight: number;
|
maxPhotoHeight: number;
|
||||||
gapPx: number;
|
gapPx: number;
|
||||||
|
presentMarginTopPx: number;
|
||||||
|
presentMarginLeftPx: number;
|
||||||
|
presentMarginRightPx: number;
|
||||||
|
presentPhotoShape: PhotoWallPresentPhotoShape;
|
||||||
showUploaderNameOnWall: boolean;
|
showUploaderNameOnWall: boolean;
|
||||||
requireUploadCrop: boolean;
|
requireUploadCrop: boolean;
|
||||||
frameEnabled: boolean;
|
frameEnabled: boolean;
|
||||||
|
|
@ -145,6 +155,7 @@ export type PhotoWallUpdatePayload = Partial<{
|
||||||
whatsapp: boolean;
|
whatsapp: boolean;
|
||||||
};
|
};
|
||||||
eventHeader: PhotoWallEventHeader;
|
eventHeader: PhotoWallEventHeader;
|
||||||
|
presenterEventHeader: PhotoWallEventHeader;
|
||||||
regenerateTokens: boolean;
|
regenerateTokens: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -862,6 +862,7 @@ export interface AttendanceAudienceSettings {
|
||||||
|
|
||||||
export interface AttendanceSettings {
|
export interface AttendanceSettings {
|
||||||
allowDuplicateCheckIn?: boolean;
|
allowDuplicateCheckIn?: boolean;
|
||||||
|
allowAssignBlank?: boolean;
|
||||||
publicLinkEnabled?: boolean;
|
publicLinkEnabled?: boolean;
|
||||||
audience?: AttendanceAudienceSettings;
|
audience?: AttendanceAudienceSettings;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue