Add engagement hub, rank fields, and attendance WhatsApp notify.

Wire reception drip/agenda settings and dashboard modules so organizers can manage engagement links and form ranking from the console.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-09-30 09:39:34 +05:30
parent b7b3cd19a5
commit 2c86688b2d
22 changed files with 1902 additions and 51 deletions

View File

@ -16,6 +16,10 @@ VITE_API_URL=http://localhost:3001/api
# Production: https://join.eventbuzz.live — local eventbuzz Vite often :5174 # Production: https://join.eventbuzz.live — local eventbuzz Vite often :5174
# VITE_EVENTBUZZ_PUBLIC_URL=https://join.eventbuzz.live # VITE_EVENTBUZZ_PUBLIC_URL=https://join.eventbuzz.live
# Engagement hub (unique per-participant WhatsApp links)
# Production: https://engage.eventbuzz.live — local EventBuzz_Engagement Vite :5175
# VITE_ENGAGEMENT_PUBLIC_URL=http://localhost:5175
# GrapesJS microsite builder (Vite app in /website_builder). Iframe src + postMessage target origin. # GrapesJS microsite builder (Vite app in /website_builder). Iframe src + postMessage target origin.
VITE_WEBSITE_BUILDER_URL=http://localhost:5173 VITE_WEBSITE_BUILDER_URL=http://localhost:5173

2
dist/index.html vendored
View File

@ -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-BjfPElUV.js"></script> <script type="module" crossorigin src="/assets/index-DqvnLLBm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BVkPIZas.css"> <link rel="stylesheet" crossorigin href="/assets/index-BVkPIZas.css">
</head> </head>

View File

@ -24,7 +24,9 @@ import type {
AttendanceRecord, AttendanceRecord,
AttendanceSearchParticipant, AttendanceSearchParticipant,
AttendanceCheckInRow, AttendanceCheckInRow,
AttendanceWhatsAppNotifySettings,
} from "../../../services/api/types"; } from "../../../services/api/types";
import { AttendanceWhatsAppNotifyFields } from "./AttendanceWhatsAppNotifyFields";
function publicAttendanceUrl(token: string) { function publicAttendanceUrl(token: string) {
return `${window.location.origin}/attendance/${token}`; return `${window.location.origin}/attendance/${token}`;
@ -92,8 +94,16 @@ interface FormState {
includeStatuses: AttendanceMembershipStatus[]; includeStatuses: AttendanceMembershipStatus[];
includeTags: string[]; includeTags: string[];
includeParticipantListIds: string[]; includeParticipantListIds: string[];
whatsappNotify: AttendanceWhatsAppNotifySettings;
} }
const emptyWhatsappNotify: AttendanceWhatsAppNotifySettings = {
enabled: false,
whatsappTemplateId: null,
waHeaderImageUrl: "",
waOverrides: null,
};
const emptyForm: FormState = { const emptyForm: FormState = {
name: "", name: "",
description: "", description: "",
@ -104,6 +114,7 @@ const emptyForm: FormState = {
includeStatuses: ["confirmed", "checked_in"], includeStatuses: ["confirmed", "checked_in"],
includeTags: [], includeTags: [],
includeParticipantListIds: [], includeParticipantListIds: [],
whatsappNotify: { ...emptyWhatsappNotify },
}; };
export function AttendancePage() { export function AttendancePage() {
@ -221,6 +232,14 @@ export function AttendancePage() {
: ["confirmed", "checked_in"], : ["confirmed", "checked_in"],
includeTags: audience?.tags || [], includeTags: audience?.tags || [],
includeParticipantListIds: (audience?.participantListIds || []).map(String), includeParticipantListIds: (audience?.participantListIds || []).map(String),
whatsappNotify: {
enabled: Boolean(s.settings?.whatsappNotify?.enabled),
whatsappTemplateId: s.settings?.whatsappNotify?.whatsappTemplateId
? String(s.settings.whatsappNotify.whatsappTemplateId)
: null,
waHeaderImageUrl: s.settings?.whatsappNotify?.waHeaderImageUrl || "",
waOverrides: s.settings?.whatsappNotify?.waOverrides || null,
},
}); });
setPanelOpen(true); setPanelOpen(true);
}; };
@ -273,6 +292,10 @@ export function AttendancePage() {
setError("Select at least one contact list for the attendance list."); setError("Select at least one contact list for the attendance list.");
return; return;
} }
if (form.whatsappNotify.enabled && !form.whatsappNotify.whatsappTemplateId) {
setError("Select a WhatsApp template for check-in notify, or turn the option off.");
return;
}
setSaving(true); setSaving(true);
const payload = { const payload = {
name: form.name.trim(), name: form.name.trim(),
@ -288,6 +311,14 @@ export function AttendancePage() {
participantListIds: participantListIds:
form.audienceMode === "participant_lists" ? form.includeParticipantListIds : [], form.audienceMode === "participant_lists" ? form.includeParticipantListIds : [],
}, },
whatsappNotify: form.whatsappNotify.enabled
? {
enabled: true,
whatsappTemplateId: form.whatsappNotify.whatsappTemplateId || null,
waHeaderImageUrl: form.whatsappNotify.waHeaderImageUrl || "",
waOverrides: form.whatsappNotify.waOverrides || null,
}
: { enabled: false, whatsappTemplateId: null, waHeaderImageUrl: "", waOverrides: null },
}, },
}; };
const res = editingId const res = editingId
@ -946,6 +977,11 @@ export function AttendancePage() {
/> />
Enable public check-in link Enable public check-in link
</label> </label>
<AttendanceWhatsAppNotifyFields
value={form.whatsappNotify}
onChange={(whatsappNotify) => setForm((f) => ({ ...f, whatsappNotify }))}
/>
</div> </div>
<div <div
style={{ style={{

View File

@ -0,0 +1,340 @@
import React, { useEffect, useState } from "react";
import { whatsappTemplatesApi } from "../../../services/api";
import type { WaOverrideSlot } from "../../../services/api/campaigns";
import type {
AttendanceWhatsAppNotifySettings,
WhatsAppTemplateRecord,
WhatsAppVariableBindings,
} from "../../../services/api/types";
import { WA_TEMPLATE_MERGE_OPTIONS } from "../messaging/mergePlaceholders";
type SlotMode = "default" | "merge" | "fixed";
type SlotRow = { mode: SlotMode; mergeKey?: string; fixed?: string };
function emptySlotRow(): SlotRow {
return { mode: "default" };
}
function parseSlotsFromOverrides(
n: number,
section: "header" | "body" | "url",
notify: AttendanceWhatsAppNotifySettings | undefined,
defaultBindings: string[] | undefined,
): SlotRow[] {
const o = notify?.waOverrides?.[section];
const out: SlotRow[] = [];
for (let i = 0; i < n; i += 1) {
const slot = String(i + 1);
const raw = o?.[slot] as WaOverrideSlot | undefined;
if (!raw) {
out.push({ mode: "default" });
continue;
}
if (raw.kind === "fixed") {
out.push({ mode: "fixed", fixed: raw.value || "" });
} else {
const def = String(defaultBindings?.[i] || "").trim();
if (def && raw.key === def) {
out.push({ mode: "default" });
} else {
out.push({ mode: "merge", mergeKey: raw.key || "" });
}
}
}
return out;
}
function buildSectionOverrides(
rows: SlotRow[],
defaultBindings: string[] | undefined,
): Record<string, WaOverrideSlot> | undefined {
const out: Record<string, WaOverrideSlot> = {};
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
const def = String(defaultBindings?.[i] || "").trim();
if (row.mode === "default") continue;
if (row.mode === "fixed") {
out[String(i + 1)] = { kind: "fixed", value: String(row.fixed || "") };
} else if (row.mode === "merge") {
const k = String(row.mergeKey || "").trim();
if (k && k !== def) out[String(i + 1)] = { kind: "merge", key: k };
}
}
return Object.keys(out).length ? out : undefined;
}
function hasImageHeader(components: Record<string, unknown>[]): boolean {
return components.some(
(c) =>
String(c.type || "").toUpperCase() === "HEADER" && String(c.format || "").toUpperCase() === "IMAGE",
);
}
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",
};
function SlotTable({
title,
rows,
setRows,
defaultBindings,
}: {
title: string;
rows: SlotRow[];
setRows: (next: SlotRow[]) => void;
defaultBindings: string[];
}) {
if (!rows.length) return null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600 }}>{title}</div>
{rows.map((row, idx) => (
<div
key={idx}
style={{
display: "grid",
gridTemplateColumns: "72px 100px 1fr",
gap: 8,
alignItems: "center",
}}
>
<span style={{ fontSize: 12, color: "var(--muted)" }}>{`{{${idx + 1}}}`}</span>
<select
style={IS}
value={row.mode}
onChange={(e) => {
const mode = e.target.value as SlotMode;
const next = [...rows];
next[idx] =
mode === "merge"
? {
mode: "merge",
mergeKey:
WA_TEMPLATE_MERGE_OPTIONS.find((o) => o.mergeKey)?.mergeKey ||
"contact.firstName",
}
: mode === "fixed"
? { mode: "fixed", fixed: "" }
: { mode: "default" };
setRows(next);
}}
>
<option value="default">Template default</option>
<option value="merge">Merge field</option>
<option value="fixed">Fixed text</option>
</select>
{row.mode === "merge" ? (
<select
style={IS}
value={row.mergeKey || ""}
onChange={(e) => {
const next = [...rows];
next[idx] = { mode: "merge", mergeKey: e.target.value };
setRows(next);
}}
>
{WA_TEMPLATE_MERGE_OPTIONS.filter((o) => o.mergeKey).map((o) => (
<option key={o.mergeKey} value={o.mergeKey}>
{o.label}
</option>
))}
</select>
) : row.mode === "fixed" ? (
<input
style={IS}
value={row.fixed || ""}
placeholder="Fixed value"
onChange={(e) => {
const next = [...rows];
next[idx] = { mode: "fixed", fixed: e.target.value };
setRows(next);
}}
/>
) : (
<span style={{ fontSize: 11, color: "var(--muted)" }}>
{defaultBindings[idx] ? `Uses ${defaultBindings[idx]}` : "Sample / binding from template"}
</span>
)}
</div>
))}
</div>
);
}
export function AttendanceWhatsAppNotifyFields({
value,
onChange,
}: {
value: AttendanceWhatsAppNotifySettings;
onChange: (next: AttendanceWhatsAppNotifySettings) => void;
}) {
const [templates, setTemplates] = useState<WhatsAppTemplateRecord[]>([]);
const [detail, setDetail] = useState<WhatsAppTemplateRecord | null>(null);
const [headerRows, setHeaderRows] = useState<SlotRow[]>([]);
const [bodyRows, setBodyRows] = useState<SlotRow[]>([]);
const [urlRows, setUrlRows] = useState<SlotRow[]>([]);
const [showImage, setShowImage] = useState(false);
useEffect(() => {
void whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => {
if (!r.error) setTemplates(r.data?.templates || []);
});
}, []);
useEffect(() => {
const id = value.whatsappTemplateId;
if (!id) {
setDetail(null);
setHeaderRows([]);
setBodyRows([]);
setUrlRows([]);
setShowImage(false);
return;
}
void whatsappTemplatesApi.get(String(id)).then((r) => {
if (!r.data) return;
setDetail(r.data);
const vb: WhatsAppVariableBindings = r.data.variableBindings || { header: [], body: [], url: [] };
setShowImage(hasImageHeader((r.data.components || []) as Record<string, unknown>[]));
setHeaderRows(parseSlotsFromOverrides((vb.header || []).length, "header", value, vb.header));
setBodyRows(parseSlotsFromOverrides((vb.body || []).length, "body", value, vb.body));
setUrlRows(parseSlotsFromOverrides((vb.url || []).length, "url", value, vb.url));
});
// Only re-load detail when template id changes; slot edits push via emitOverrides
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value.whatsappTemplateId]);
const emitOverrides = (
nextHeader: SlotRow[],
nextBody: SlotRow[],
nextUrl: SlotRow[],
partial?: Partial<AttendanceWhatsAppNotifySettings>,
) => {
const vb = detail?.variableBindings || { header: [], body: [], url: [] };
const waOverrides: NonNullable<AttendanceWhatsAppNotifySettings["waOverrides"]> = {};
const h = buildSectionOverrides(nextHeader, vb.header);
const b = buildSectionOverrides(nextBody, vb.body);
const u = buildSectionOverrides(nextUrl, vb.url);
if (h) waOverrides.header = h;
if (b) waOverrides.body = b;
if (u) waOverrides.url = u;
onChange({
...value,
...partial,
waOverrides: Object.keys(waOverrides).length ? waOverrides : null,
});
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 10,
padding: 12,
border: "1px solid var(--border)",
borderRadius: 8,
background: "var(--secondary)",
}}
>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)", cursor: "pointer" }}>
<input
type="checkbox"
checked={Boolean(value.enabled)}
onChange={(e) =>
onChange({
...value,
enabled: e.target.checked,
...(e.target.checked
? {}
: { whatsappTemplateId: null, waHeaderImageUrl: "", waOverrides: null }),
})
}
/>
Send WhatsApp template on check-in (once per person)
</label>
<p style={{ margin: 0, fontSize: 11, color: "var(--muted)" }}>
Sent after the first successful check-in. Duplicate check-ins do not resend.
</p>
{value.enabled ? (
<>
<label style={{ fontSize: 12, fontWeight: 500 }}>
Template
<select
style={{ ...IS, marginTop: 6 }}
value={value.whatsappTemplateId || ""}
onChange={(e) =>
onChange({
...value,
whatsappTemplateId: e.target.value || null,
waOverrides: null,
waHeaderImageUrl: "",
})
}
>
<option value="">Select APPROVED template…</option>
{templates.map((t) => (
<option key={t._id} value={t._id}>
{t.name} ({t.language})
</option>
))}
</select>
</label>
{showImage ? (
<label style={{ fontSize: 12, fontWeight: 500 }}>
Header image URL
<input
style={{ ...IS, marginTop: 6 }}
value={value.waHeaderImageUrl || ""}
placeholder="https://…"
onChange={(e) => onChange({ ...value, waHeaderImageUrl: e.target.value })}
/>
</label>
) : null}
{detail ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<SlotTable
title="Header variables"
rows={headerRows}
defaultBindings={detail.variableBindings?.header || []}
setRows={(next) => {
setHeaderRows(next);
emitOverrides(next, bodyRows, urlRows);
}}
/>
<SlotTable
title="Body variables"
rows={bodyRows}
defaultBindings={detail.variableBindings?.body || []}
setRows={(next) => {
setBodyRows(next);
emitOverrides(headerRows, next, urlRows);
}}
/>
<SlotTable
title="URL button variables"
rows={urlRows}
defaultBindings={detail.variableBindings?.url || []}
setRows={(next) => {
setUrlRows(next);
emitOverrides(headerRows, bodyRows, next);
}}
/>
</div>
) : null}
</>
) : null}
</div>
);
}

View File

@ -0,0 +1,512 @@
import React, { useCallback, useEffect, useState } from "react";
import { Check, Copy, Plus, Trash2 } from "lucide-react";
import { PanelShell, Label, Field, Grid, IS, SS, focusIn, focusOut, HelperText, RadioGroup } from "../panels/PanelShell";
import {
engagementApi,
aiAvatarApi,
socialShareApi,
photoWallApi,
eventPhotoSearchApi,
type EngagementConfig,
type EngagementCustomField,
type EngagementCustomFieldType,
type EngagementPlacement,
} from "../../../services/api";
import { engagementUrlPattern } from "../../utils/engagementUrls";
import {
EVENT_HEADER_TOGGLE_LABELS,
eventHeaderFromApi,
eventHeaderToApi,
type EventHeaderToggleKey,
} from "../../utils/formEventHeaderDisplay";
type Tab = "flow" | "fields" | "links";
const DEFAULT_HEADER = eventHeaderFromApi(null);
const FLOW_OPTIONS: { label: string; value: EngagementPlacement }[] = [
{ label: "Off", value: "off" },
{ label: "In flow", value: "flow" },
{ label: "On profile", value: "profile" },
];
const PROFILE_OPTIONS: { label: string; value: EngagementPlacement }[] = [
{ label: "Off", value: "off" },
{ label: "On profile", value: "profile" },
];
type SiblingStatus = { enabled: boolean; label: string };
function emptyCustom(): EngagementCustomField {
return { key: "", label: "", type: "text", required: false, showOnProfile: false, options: [] };
}
export function EngagementManagePanel({
eventId,
eventName,
canWrite,
onClose,
onChanged,
}: {
eventId: string;
eventName?: string;
canWrite: boolean;
onClose: () => void;
onChanged?: () => void;
}) {
const [tab, setTab] = useState<Tab>("flow");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<EngagementConfig | null>(null);
const [copied, setCopied] = useState(false);
const [siblings, setSiblings] = useState<{
aiAvatar: SiblingStatus;
socialShare: SiblingStatus;
photoWall: SiblingStatus;
photoSearch: SiblingStatus;
}>({
aiAvatar: { enabled: false, label: "AI Avatar" },
socialShare: { enabled: false, label: "Social Share" },
photoWall: { enabled: false, label: "Photo Wall" },
photoSearch: { enabled: false, label: "Photo Search" },
});
const [draft, setDraft] = useState({
eventHeader: DEFAULT_HEADER,
features: {
aiAvatar: "off" as EngagementPlacement,
socialShare: "off" as EngagementPlacement,
photoWall: "off" as EngagementPlacement,
photoSearch: "off" as EngagementPlacement,
},
nameShow: true,
nameRequired: true,
emailShow: true,
emailRequired: false,
custom: [] as EngagementCustomField[],
});
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [eng, ai, social, wall, search] = await Promise.all([
engagementApi.get(eventId),
aiAvatarApi.get(eventId),
socialShareApi.get(eventId),
photoWallApi.get(eventId),
eventPhotoSearchApi.get(eventId),
]);
if (eng.error || !eng.data) {
setError(eng.error || "Failed to load Engagement");
setConfig(null);
return;
}
const data = eng.data;
setConfig(data);
setDraft({
eventHeader: eventHeaderFromApi(data.eventHeader),
features: {
aiAvatar: data.features?.aiAvatar?.placement || "off",
socialShare: data.features?.socialShare?.placement || "off",
photoWall: data.features?.photoWall?.placement || "off",
photoSearch: data.features?.photoSearch?.placement || "off",
},
nameShow: data.profileFields?.name?.show !== false,
nameRequired: data.profileFields?.name?.required !== false,
emailShow: data.profileFields?.email?.show !== false,
emailRequired: Boolean(data.profileFields?.email?.required),
custom: Array.isArray(data.profileFields?.custom) ? data.profileFields.custom : [],
});
setSiblings({
aiAvatar: { enabled: Boolean(ai.data?.enabled), label: "AI Avatar" },
socialShare: { enabled: Boolean(social.data?.enabled), label: "Social Share" },
photoWall: { enabled: Boolean(wall.data?.enabled), label: "Photo Wall" },
photoSearch: { enabled: Boolean(search.data?.enabled), label: "Photo Search" },
});
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load");
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => {
void load();
}, [load]);
const save = async () => {
if (!canWrite) return;
setSaving(true);
setError(null);
try {
const res = await engagementApi.update(eventId, {
eventHeader: eventHeaderToApi(draft.eventHeader),
features: {
aiAvatar: { placement: draft.features.aiAvatar },
socialShare: { placement: draft.features.socialShare },
photoWall: { placement: draft.features.photoWall === "flow" ? "profile" : draft.features.photoWall },
photoSearch: { placement: draft.features.photoSearch === "flow" ? "profile" : draft.features.photoSearch },
},
profileFields: {
name: { show: draft.nameShow, required: draft.nameShow && draft.nameRequired },
email: { show: draft.emailShow, required: draft.emailShow && draft.emailRequired },
custom: draft.custom
.map((f) => ({
...f,
label: f.label.trim(),
options: f.type === "select" ? (f.options || []).map((o) => o.trim()).filter(Boolean) : [],
}))
.filter((f) => f.label),
},
});
if (res.error || !res.data) {
setError(res.error || "Failed to save");
return;
}
setConfig(res.data);
onChanged?.();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
};
const pattern = engagementUrlPattern(eventId);
const copyPattern = async () => {
try {
await navigator.clipboard.writeText(pattern);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
/* ignore */
}
};
const tabBtn = (id: Tab, label: string) => (
<button
key={id}
type="button"
onClick={() => setTab(id)}
style={{
flex: 1,
padding: "8px 10px",
border: "none",
borderBottom: tab === id ? "2px solid var(--primary)" : "2px solid transparent",
background: "transparent",
color: tab === id ? "var(--primary)" : "var(--muted)",
fontFamily: "var(--font-noto-sans)",
fontSize: "var(--text-sm)",
fontWeight: tab === id ? 600 : 400,
cursor: "pointer",
}}
>
{label}
</button>
);
const placementField = (
key: keyof typeof draft.features,
label: string,
options: { label: string; value: EngagementPlacement }[],
sibling: SiblingStatus,
) => (
<Field>
<Label text={label} />
<RadioGroup
name={`eng-${key}`}
value={draft.features[key]}
onChange={(v) =>
setDraft((d) => ({
...d,
features: { ...d.features, [key]: v as EngagementPlacement },
}))
}
options={options}
/>
{!sibling.enabled ? (
<HelperText text={`Enable ${sibling.label} on Event Home before visitors can use this activity.`} />
) : null}
</Field>
);
const updateCustom = (index: number, patch: Partial<EngagementCustomField>) => {
setDraft((d) => ({
...d,
custom: d.custom.map((f, i) => (i === index ? { ...f, ...patch } : f)),
}));
};
return (
<PanelShell
onClose={onClose}
title="Engagement"
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home"}
description="WhatsApp profile hub: DP, details, and which activities sit in the first-visit flow vs on the profile page"
completedCount={0}
totalSections={0}
ctaLabel="Save"
showProgress={false}
hidePrimaryCta={tab === "links"}
ctaDisabled={!canWrite || saving || loading}
onCtaClick={() => void save()}
panelWidth="min(720px,100vw)"
>
<div style={{ display: "flex", borderBottom: "1px solid var(--border)", padding: "0 12px" }}>
{tabBtn("flow", "Flow")}
{tabBtn("fields", "Profile fields")}
{tabBtn("links", "Links")}
</div>
<div style={{ padding: "16px 20px", display: "flex", flexDirection: "column", gap: "14px" }}>
{error ? (
<p style={{ margin: 0, color: "var(--destructive)", fontSize: "var(--text-sm)" }}>{error}</p>
) : null}
{loading ? <p style={{ margin: 0, color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading…</p> : null}
{!loading && tab === "flow" ? (
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
<HelperText text="Profile setup (photo + details) is always first. In-flow activities run once after that, then the visitor lands on their profile. Profile options stay available to repeat." />
{placementField("aiAvatar", "AI Avatar", FLOW_OPTIONS, siblings.aiAvatar)}
{placementField("socialShare", "Social Share", FLOW_OPTIONS, siblings.socialShare)}
{placementField("photoWall", "Upload to Wall", PROFILE_OPTIONS, siblings.photoWall)}
{placementField("photoSearch", "Photo Search gallery", PROFILE_OPTIONS, siblings.photoSearch)}
<Field>
<Label text="Event header on visitor pages" />
<Grid cols={2}>
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => (
<Field key={key}>
<Label text={EVENT_HEADER_TOGGLE_LABELS[key]} />
<RadioGroup
name={`eng-header-${key}`}
value={draft.eventHeader[key]}
onChange={(v) =>
setDraft((d) => ({
...d,
eventHeader: { ...d.eventHeader, [key]: v as "Yes" | "No" },
}))
}
options={[
{ label: "Show", value: "Yes" },
{ label: "Hide", value: "No" },
]}
/>
</Field>
))}
</Grid>
</Field>
</div>
) : null}
{!loading && tab === "fields" ? (
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
<HelperText text="Shown on first-visit setup and when editing from the profile. Tick Show on profile to also display an extra field on the visitor profile." />
<Field>
<Label text="Name" />
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
disabled={!canWrite}
checked={draft.nameShow}
onChange={(e) => setDraft((d) => ({ ...d, nameShow: e.target.checked }))}
/>
Show
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
disabled={!canWrite || !draft.nameShow}
checked={draft.nameShow && draft.nameRequired}
onChange={(e) => setDraft((d) => ({ ...d, nameRequired: e.target.checked }))}
/>
Required
</label>
</Field>
<Field>
<Label text="Email" />
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
disabled={!canWrite}
checked={draft.emailShow}
onChange={(e) => setDraft((d) => ({ ...d, emailShow: e.target.checked }))}
/>
Show
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
disabled={!canWrite || !draft.emailShow}
checked={draft.emailShow && draft.emailRequired}
onChange={(e) => setDraft((d) => ({ ...d, emailRequired: e.target.checked }))}
/>
Required
</label>
</Field>
{draft.custom.map((field, index) => (
<div
key={`${field.key || "new"}-${index}`}
style={{
border: "1px solid var(--border)",
borderRadius: "var(--radius-button)",
padding: 12,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<Field>
<Label text="Label" />
<input
disabled={!canWrite}
value={field.label}
onChange={(e) => updateCustom(index, { label: e.target.value })}
style={IS}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Type" />
<select
disabled={!canWrite}
value={field.type}
onChange={(e) =>
updateCustom(index, { type: e.target.value as EngagementCustomFieldType })
}
style={SS}
onFocus={focusIn}
onBlur={focusOut}
>
<option value="text">Text</option>
<option value="textarea">Long text</option>
<option value="email">Email</option>
<option value="phone">Phone</option>
<option value="select">Dropdown</option>
</select>
</Field>
{field.type === "select" ? (
<Field>
<Label text="Options (one per line)" />
<textarea
disabled={!canWrite}
value={(field.options || []).join("\n")}
onChange={(e) => updateCustom(index, { options: e.target.value.split("\n") })}
rows={3}
style={{ ...IS, minHeight: 72, resize: "vertical" }}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
) : null}
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
disabled={!canWrite}
checked={field.required}
onChange={(e) => updateCustom(index, { required: e.target.checked })}
/>
Required
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}>
<input
type="checkbox"
disabled={!canWrite}
checked={Boolean(field.showOnProfile)}
onChange={(e) => updateCustom(index, { showOnProfile: e.target.checked })}
/>
Show on profile
</label>
{canWrite ? (
<button
type="button"
onClick={() => setDraft((d) => ({ ...d, custom: d.custom.filter((_, i) => i !== index) }))}
style={{
alignSelf: "flex-start",
display: "inline-flex",
alignItems: "center",
gap: 6,
border: "none",
background: "transparent",
color: "var(--destructive)",
cursor: "pointer",
fontSize: "var(--text-sm)",
}}
>
<Trash2 size={14} /> Remove field
</button>
) : null}
</div>
))}
{canWrite ? (
<button
type="button"
onClick={() => setDraft((d) => ({ ...d, custom: [...d.custom, emptyCustom()] }))}
style={{
alignSelf: "flex-start",
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "8px 12px",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: "pointer",
fontSize: "var(--text-sm)",
}}
>
<Plus size={14} /> Add field
</button>
) : null}
</div>
) : null}
{!loading && tab === "links" ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<HelperText text="Each participant gets a unique URL with their event and participant ids. Use {engagementUrl} in a reception drip URL button." />
<Field>
<Label text="URL pattern" />
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input readOnly value={pattern} style={IS} />
<button
type="button"
onClick={() => void copyPattern()}
title="Copy pattern"
style={{
width: 36,
height: 36,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-button)",
border: "1px solid var(--border)",
background: "var(--card)",
cursor: "pointer",
flexShrink: 0,
}}
>
{copied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
</Field>
{config?.enabled ? (
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
Engagement is enabled for this event.
</p>
) : (
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
Enable the module on Event Home before sending links.
</p>
)}
</div>
) : null}
</div>
</PanelShell>
);
}

View File

@ -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, MessageSquare, 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, UserCircle } 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";
@ -23,15 +23,18 @@ import { EventDocumentsPanel } from "./EventDocumentsPanel";
import { PhotoWallManagePanel } from "./PhotoWallManagePanel"; import { PhotoWallManagePanel } from "./PhotoWallManagePanel";
import { SocialShareManagePanel } from "./SocialShareManagePanel"; import { SocialShareManagePanel } from "./SocialShareManagePanel";
import { AiAvatarManagePanel } from "./AiAvatarManagePanel"; import { AiAvatarManagePanel } from "./AiAvatarManagePanel";
import { EngagementManagePanel } from "./EngagementManagePanel";
import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel"; import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel";
import { photoWallApi } from "../../../services/api/photoWall"; import { photoWallApi } from "../../../services/api/photoWall";
import { eventPhotoSearchApi } from "../../../services/api/eventPhotoSearch"; import { eventPhotoSearchApi } from "../../../services/api/eventPhotoSearch";
import { socialShareApi } from "../../../services/api/socialShare"; import { socialShareApi } from "../../../services/api/socialShare";
import { aiAvatarApi } from "../../../services/api/aiAvatar"; import { aiAvatarApi } from "../../../services/api/aiAvatar";
import { engagementApi } from "../../../services/api/engagement";
import { photoWallUploadUrl, photoWallPresentUrl } from "../../utils/photoWallUrls"; import { photoWallUploadUrl, photoWallPresentUrl } from "../../utils/photoWallUrls";
import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls"; 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 { engagementUrlPattern } from "../../utils/engagementUrls";
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel"; import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
import { WhatsAppBlastHistoryPanel } from "./WhatsAppBlastHistoryPanel"; import { WhatsAppBlastHistoryPanel } from "./WhatsAppBlastHistoryPanel";
import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel"; import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel";
@ -253,6 +256,8 @@ export function EventHomeModules() {
const canWriteSocialShare = canModule("social_share", "write"); const canWriteSocialShare = canModule("social_share", "write");
const canReadAiAvatar = canModule("ai_avatar", "read"); const canReadAiAvatar = canModule("ai_avatar", "read");
const canWriteAiAvatar = canModule("ai_avatar", "write"); const canWriteAiAvatar = canModule("ai_avatar", "write");
const canReadEngagement = canModule("engagement", "read");
const canWriteEngagement = canModule("engagement", "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 canReadWaNotifications = canModule("custom_template_for_wa_messaging", "read");
@ -328,6 +333,10 @@ export function EventHomeModules() {
const [aiAvatarCount, setAiAvatarCount] = useState(0); const [aiAvatarCount, setAiAvatarCount] = useState(0);
const [aiAvatarToken, setAiAvatarToken] = useState<string | null>(null); const [aiAvatarToken, setAiAvatarToken] = useState<string | null>(null);
const [copiedAiAvatarLink, setCopiedAiAvatarLink] = useState(false); const [copiedAiAvatarLink, setCopiedAiAvatarLink] = useState(false);
const [showEngagementPanel, setShowEngagementPanel] = useState(false);
const [engagementEnabled, setEngagementEnabled] = useState(false);
const [engagementLoading, setEngagementLoading] = useState(false);
const [copiedEngagementLink, setCopiedEngagementLink] = useState(false);
const [emailCampaignCount, setEmailCampaignCount] = useState(0); const [emailCampaignCount, setEmailCampaignCount] = useState(0);
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false); const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false); const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
@ -738,6 +747,27 @@ export function EventHomeModules() {
void loadAiAvatarMeta(); void loadAiAvatarMeta();
}, [loadAiAvatarMeta]); }, [loadAiAvatarMeta]);
const loadEngagementMeta = useCallback(async () => {
if (!selectedEventId || !canReadEngagement) {
setEngagementEnabled(false);
setEngagementLoading(false);
return;
}
setEngagementLoading(true);
try {
const res = await engagementApi.get(selectedEventId);
setEngagementEnabled(Boolean(res.data?.enabled));
} catch {
setEngagementEnabled(false);
} finally {
setEngagementLoading(false);
}
}, [selectedEventId, canReadEngagement]);
useEffect(() => {
void loadEngagementMeta();
}, [loadEngagementMeta]);
const loadPhotoSearchMeta = useCallback(async () => { const loadPhotoSearchMeta = useCallback(async () => {
if (!selectedEventId) { if (!selectedEventId) {
setPhotoSearchEnabled(false); setPhotoSearchEnabled(false);
@ -891,6 +921,13 @@ export function EventHomeModules() {
: aiAvatarEnabled : aiAvatarEnabled
? `Enabled · ${aiAvatarCount} avatar${aiAvatarCount === 1 ? "" : "s"}` ? `Enabled · ${aiAvatarCount} avatar${aiAvatarCount === 1 ? "" : "s"}`
: "Disabled"; : "Disabled";
const engagementMeta = !selectedEventId
? "Select an event to configure engagement"
: engagementLoading
? "Loading…"
: engagementEnabled
? "Enabled · unique participant links"
: "Disabled";
const photoSearchMeta = !selectedEventId const photoSearchMeta = !selectedEventId
? "Select an event to configure photo search" ? "Select an event to configure photo search"
: photoSearchLoading : photoSearchLoading
@ -982,6 +1019,30 @@ export function EventHomeModules() {
} }
}; };
const handleEngagementToggle = async (enabled: boolean) => {
if (!selectedEventId || !canWriteEngagement) return;
setEngagementLoading(true);
try {
const res = await engagementApi.update(selectedEventId, { enabled });
if (!res.error && res.data) {
setEngagementEnabled(Boolean(res.data.enabled));
}
} finally {
setEngagementLoading(false);
}
};
const copyEngagementLink = async () => {
if (!selectedEventId) return;
try {
await navigator.clipboard.writeText(engagementUrlPattern(selectedEventId));
setCopiedEngagementLink(true);
window.setTimeout(() => setCopiedEngagementLink(false), 1500);
} catch {
/* ignore */
}
};
const handlePhotoSearchToggle = async (enabled: boolean) => { const handlePhotoSearchToggle = async (enabled: boolean) => {
if (!selectedEventId) return; if (!selectedEventId) return;
setPhotoSearchLoading(true); setPhotoSearchLoading(true);
@ -1674,6 +1735,75 @@ export function EventHomeModules() {
} }
/> />
) : null} ) : null}
{canReadEngagement ? (
<ModuleCard
icon={<UserCircle size={18} />}
title="Engagement"
meta={engagementMeta}
description="WhatsApp profile hub: DP, details, and activity flow"
headerAction={
engagementEnabled && selectedEventId ? (
<button
type="button"
title="Copy unique URL pattern"
onClick={() => void copyEngagementLink()}
style={formHeaderIconButtonStyle()}
>
{copiedEngagementLink ? <Check size={14} /> : <Link2 size={14} />}
</button>
) : null
}
actions={
<>
{engagementEnabled ? (
<button
type="button"
disabled={actionsDisabled || engagementLoading || !canWriteEngagement}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled || engagementLoading || !canWriteEngagement ? 0.55 : 1,
cursor:
actionsDisabled || engagementLoading || !canWriteEngagement
? "not-allowed"
: "pointer",
}}
onClick={() => void handleEngagementToggle(false)}
>
Disable
</button>
) : (
<button
type="button"
disabled={actionsDisabled || engagementLoading || !canWriteEngagement}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled || engagementLoading || !canWriteEngagement ? 0.55 : 1,
cursor:
actionsDisabled || engagementLoading || !canWriteEngagement
? "not-allowed"
: "pointer",
}}
onClick={() => void handleEngagementToggle(true)}
>
Enable
</button>
)}
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(engagementEnabled),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setShowEngagementPanel(true)}
>
Manage
</button>
</>
}
/>
) : null}
{canReadAiAvatar ? ( {canReadAiAvatar ? (
<ModuleCard <ModuleCard
icon={<Sparkles size={18} />} icon={<Sparkles size={18} />}
@ -2111,6 +2241,16 @@ export function EventHomeModules() {
/> />
) : null} ) : null}
{showEngagementPanel && selectedEventId ? (
<EngagementManagePanel
eventId={selectedEventId}
eventName={eventName ?? undefined}
canWrite={canWriteEngagement}
onClose={() => setShowEngagementPanel(false)}
onChanged={() => void loadEngagementMeta()}
/>
) : null}
{showWaBlastHistory && selectedEventId ? ( {showWaBlastHistory && selectedEventId ? (
<WhatsAppBlastHistoryPanel <WhatsAppBlastHistoryPanel
eventId={selectedEventId} eventId={selectedEventId}

View File

@ -10,6 +10,7 @@ import {
type PhotoWallPhotoRow, type PhotoWallPhotoRow,
type PhotoWallEventHeader, type PhotoWallEventHeader,
type PhotoWallPresentPhotoShape, type PhotoWallPresentPhotoShape,
type PhotoWallMarqueeDirection,
type Form, type Form,
type FormWithSubmissionCount, type FormWithSubmissionCount,
} from "../../../services/api"; } from "../../../services/api";
@ -78,6 +79,8 @@ export function PhotoWallManagePanel({
createParticipant: false, createParticipant: false,
moderationMode: "auto" as "auto" | "approval", moderationMode: "auto" as "auto" | "approval",
photosPerRow: 4, photosPerRow: 4,
totalRows: 3,
marqueeDirection: "alternate" as PhotoWallMarqueeDirection,
maxPhotoWidth: 320, maxPhotoWidth: 320,
maxPhotoHeight: 320, maxPhotoHeight: 320,
gapPx: 8, gapPx: 8,
@ -125,6 +128,13 @@ export function PhotoWallManagePanel({
createParticipant: Boolean(wallRes.data.createParticipant), createParticipant: Boolean(wallRes.data.createParticipant),
moderationMode: wallRes.data.moderationMode || "auto", moderationMode: wallRes.data.moderationMode || "auto",
photosPerRow: wallRes.data.photosPerRow ?? 4, photosPerRow: wallRes.data.photosPerRow ?? 4,
totalRows: wallRes.data.totalRows ?? 3,
marqueeDirection:
wallRes.data.marqueeDirection === "left" ||
wallRes.data.marqueeDirection === "right" ||
wallRes.data.marqueeDirection === "alternate"
? wallRes.data.marqueeDirection
: "alternate",
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,
@ -208,6 +218,8 @@ export function PhotoWallManagePanel({
createParticipant: draft.createParticipant, createParticipant: draft.createParticipant,
moderationMode: draft.moderationMode, moderationMode: draft.moderationMode,
photosPerRow: draft.photosPerRow, photosPerRow: draft.photosPerRow,
totalRows: draft.totalRows,
marqueeDirection: draft.marqueeDirection,
maxPhotoWidth: draft.maxPhotoWidth, maxPhotoWidth: draft.maxPhotoWidth,
maxPhotoHeight: draft.maxPhotoHeight, maxPhotoHeight: draft.maxPhotoHeight,
gapPx: draft.gapPx, gapPx: draft.gapPx,
@ -626,6 +638,7 @@ export function PhotoWallManagePanel({
<Grid cols={2}> <Grid cols={2}>
<Field> <Field>
<Label text="Photos per row" /> <Label text="Photos per row" />
<HelperText text="Columns per lane, and how many cards each row needs before marquee starts." />
<input <input
type="number" type="number"
min={1} min={1}
@ -638,6 +651,41 @@ export function PhotoWallManagePanel({
onBlur={focusOut} onBlur={focusOut}
/> />
</Field> </Field>
<Field>
<Label text="Rows on presenter wall" />
<HelperText text="Marquee starts once every row has “photos per row” cards." />
<input
type="number"
min={1}
max={8}
disabled={!canWrite}
value={draft.totalRows}
onChange={(e) => setDraft((d) => ({ ...d, totalRows: Number(e.target.value) || 3 }))}
style={IS}
onFocus={focusIn}
onBlur={focusOut}
/>
</Field>
<Field>
<Label text="Marquee direction" />
<HelperText text="Alternate scrolls adjacent rows in opposite directions." />
<RadioGroup
name="pw-marquee-direction"
value={draft.marqueeDirection}
onChange={(v) =>
setDraft((d) => ({
...d,
marqueeDirection:
v === "left" || v === "right" || v === "alternate" ? v : "alternate",
}))
}
options={[
{ label: "Left", value: "left" },
{ label: "Right", value: "right" },
{ label: "Alternate rows", value: "alternate" },
]}
/>
</Field>
<Field> <Field>
<Label text="Gap (px)" /> <Label text="Gap (px)" />
<HelperText text="Space between photos on the presenter grid." /> <HelperText text="Space between photos on the presenter grid." />

View File

@ -0,0 +1,119 @@
import type { CSSProperties } from "react";
import {
OTHER_RANK_KEY,
parseRankValue,
rankSlotCount,
setRankForKey,
type RankFieldValue,
} from "./rankFieldUtils";
const inputStyle: CSSProperties = {
padding: "8px 12px",
border: "1px solid var(--border, #e2e8f0)",
borderRadius: 8,
fontSize: "0.9rem",
boxSizing: "border-box",
background: "var(--input-background, #fff)",
color: "var(--card-foreground, #0f172a)",
};
type Props = {
options: string[];
value: unknown;
onChange: (value: RankFieldValue) => void;
allowOther?: boolean;
otherLabel?: string;
required?: boolean;
};
export function FormRankField({
options,
value,
onChange,
allowOther,
otherLabel,
required,
}: Props) {
const parsed = parseRankValue(value);
const slots = rankSlotCount(options, allowOther);
const ranks = Array.from({ length: slots }, (_, i) => i + 1);
const otherLbl = (otherLabel || "Other").trim() || "Other";
const takenByOthers = (exceptKey: string): Set<number> => {
const s = new Set<number>();
for (const [k, v] of Object.entries(parsed.ranks)) {
if (k === exceptKey) continue;
if (typeof v === "number") s.add(v);
}
return s;
};
const renderSelect = (key: string) => {
const current = parsed.ranks[key];
const taken = takenByOthers(key);
return (
<select
style={{ ...inputStyle, width: "auto", minWidth: 88, cursor: "pointer" }}
value={current === "" || current == null ? "" : String(current)}
required={!!required}
onChange={(e) => {
const raw = e.target.value;
onChange(setRankForKey(parsed, key, raw === "" ? "" : Number(raw)));
}}
>
<option value="">—</option>
{ranks.map((n) => (
<option key={n} value={n} disabled={taken.has(n)}>
{n}
</option>
))}
</select>
);
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{options.map((opt) => (
<div
key={opt}
style={{
display: "flex",
alignItems: "center",
gap: 12,
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<span style={{ fontSize: "0.9rem", flex: "1 1 140px" }}>{opt}</span>
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: "0.85rem" }}>
Rank
{renderSelect(opt)}
</label>
</div>
))}
{allowOther ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<input
style={{ ...inputStyle, flex: "1 1 160px", minWidth: 120 }}
placeholder={otherLbl}
value={parsed.otherText || ""}
required={!!required}
onChange={(e) => onChange({ ...parsed, otherText: e.target.value })}
/>
<label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: "0.85rem" }}>
Rank
{renderSelect(OTHER_RANK_KEY)}
</label>
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,123 @@
/** In-form state for a Rank field before submit. */
export type RankFieldValue = {
/** option label -> rank number, or empty string if unset. Other uses OTHER_RANK_KEY. */
ranks: Record<string, number | "">;
otherText?: string;
};
export const OTHER_RANK_KEY = "__other__";
export function emptyRankValue(): RankFieldValue {
return { ranks: {}, otherText: "" };
}
export function parseRankValue(raw: unknown): RankFieldValue {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return emptyRankValue();
const o = raw as RankFieldValue;
const ranks: Record<string, number | ""> = {};
if (o.ranks && typeof o.ranks === "object") {
for (const [k, v] of Object.entries(o.ranks)) {
if (v === "" || v == null) ranks[k] = "";
else {
const n = Number(v);
ranks[k] = Number.isFinite(n) && n > 0 ? n : "";
}
}
}
return { ranks, otherText: o.otherText != null ? String(o.otherText) : "" };
}
export function rankSlotCount(options: string[], allowOther?: boolean): number {
const n = Array.isArray(options) ? options.length : 0;
return allowOther ? n + 1 : n;
}
/** Ordered labels for submit (rank ascending). Empty if nothing ranked. */
export function rankValueToOrderedLabels(
value: RankFieldValue,
options: string[],
allowOther?: boolean,
): string[] {
const items: { label: string; rank: number }[] = [];
for (const opt of options) {
const r = value.ranks[opt];
if (typeof r === "number" && r > 0) items.push({ label: opt, rank: r });
}
if (allowOther) {
const text = String(value.otherText || "").trim();
const r = value.ranks[OTHER_RANK_KEY];
if (text && typeof r === "number" && r > 0) items.push({ label: text, rank: r });
}
items.sort((a, b) => a.rank - b.rank);
return items.map((i) => i.label);
}
/**
* Returns error message or null.
* - required: full unique ranking 1…N (Other text+rank if allowOther).
* - optional: empty OK; if any rank/other started, must complete fully.
*/
export function validateRankValue(
value: RankFieldValue,
options: string[],
opts: { required?: boolean; allowOther?: boolean; displayLabel?: string },
): string | null {
const label = opts.displayLabel || "This field";
const allowOther = !!opts.allowOther;
const slots = rankSlotCount(options, allowOther);
if (slots < 1) return null;
const otherText = String(value.otherText || "").trim();
const hasAny =
options.some((o) => value.ranks[o] !== "" && value.ranks[o] != null) ||
(allowOther && (otherText !== "" || (value.ranks[OTHER_RANK_KEY] !== "" && value.ranks[OTHER_RANK_KEY] != null)));
if (!opts.required && !hasAny) return null;
const used = new Set<number>();
for (const opt of options) {
const r = value.ranks[opt];
if (r === "" || r == null) {
return `“${label}” requires a rank for every option.`;
}
if (typeof r !== "number" || r < 1 || r > slots) {
return `“${label}” has an invalid rank.`;
}
if (used.has(r)) return `“${label}” ranks must be unique.`;
used.add(r);
}
if (allowOther) {
if (!otherText) return `“${label}” requires Other text when Other is enabled.`;
const r = value.ranks[OTHER_RANK_KEY];
if (r === "" || r == null) return `“${label}” requires a rank for Other.`;
if (typeof r !== "number" || r < 1 || r > slots) {
return `“${label}” has an invalid Other rank.`;
}
if (used.has(r)) return `“${label}” ranks must be unique.`;
used.add(r);
}
if (used.size !== slots) {
return `“${label}” requires ranks 1 through ${slots}.`;
}
return null;
}
export function setRankForKey(
value: RankFieldValue,
key: string,
nextRank: number | "",
): RankFieldValue {
const ranks = { ...value.ranks };
if (nextRank === "" || nextRank == null) {
ranks[key] = "";
return { ...value, ranks };
}
// Clear the same rank from any other key
for (const [k, v] of Object.entries(ranks)) {
if (k !== key && v === nextRank) ranks[k] = "";
}
ranks[key] = nextRank;
return { ...value, ranks };
}

View File

@ -27,7 +27,7 @@ function eventIdFromFormRef(ev: Form["event"] | undefined): string {
return String((ev as { _id?: string })._id ?? ""); return String((ev as { _id?: string })._id ?? "");
} }
const FEEDBACK_Q_TYPES = ["Star Rating", "NPS (0–10)", "Multiple Choice", "Radio", "Short Text", "Long Text", "Checkboxes", "Yes / No", "Section"]; const FEEDBACK_Q_TYPES = ["Star Rating", "NPS (0–10)", "Multiple Choice", "Radio", "Short Text", "Long Text", "Checkboxes", "Yes / No", "Rank", "Section"];
const REG_FIELD_TYPES = ["Text", "Email", "Phone", "Dropdown", "Radio", "Checkboxes", "Yes / No", "Date", "Time", "File Upload", "Country State", "Section"]; const REG_FIELD_TYPES = ["Text", "Email", "Phone", "Dropdown", "Radio", "Checkboxes", "Yes / No", "Date", "Time", "File Upload", "Country State", "Section"];
/** Other forms: union of feedback + registration field labels (order preserved, no duplicates). */ /** Other forms: union of feedback + registration field labels (order preserved, no duplicates). */
const OTHER_Q_TYPES = [...new Set([...FEEDBACK_Q_TYPES, ...REG_FIELD_TYPES])]; const OTHER_Q_TYPES = [...new Set([...FEEDBACK_Q_TYPES, ...REG_FIELD_TYPES])];
@ -38,6 +38,10 @@ function isSectionFieldType(type: string): boolean {
return type === SECTION_FIELD_TYPE; return type === SECTION_FIELD_TYPE;
} }
function fieldAllowsOtherConfig(type: string): boolean {
return type === "Dropdown" || type === "Rank";
}
function patchForFieldTypeChange(next: string, q: Question): Partial<Question> { function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
const nextPatch: Partial<Question> = { type: next }; const nextPatch: Partial<Question> = { type: next };
if (isSectionFieldType(next)) { if (isSectionFieldType(next)) {
@ -53,7 +57,7 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
} else { } else {
nextPatch.options = []; nextPatch.options = [];
} }
if (next !== "Dropdown") { if (!fieldAllowsOtherConfig(next)) {
nextPatch.allowOther = false; nextPatch.allowOther = false;
nextPatch.otherLabel = undefined; nextPatch.otherLabel = undefined;
} }
@ -63,8 +67,8 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
return nextPatch; return nextPatch;
} }
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */ /** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox / rank). */
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]); const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes", "Rank"]);
/** Choice / selectable fields whose submitted labels can become participant tags. */ /** Choice / selectable fields whose submitted labels can become participant tags. */
const CONVERT_TO_TAG_FIELD_TYPES = new Set([...OPTION_FIELD_TYPES, "Yes / No"]); const CONVERT_TO_TAG_FIELD_TYPES = new Set([...OPTION_FIELD_TYPES, "Yes / No"]);
@ -325,7 +329,7 @@ function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove, dragIndex, o
{showChoices && ( {showChoices && (
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} /> <FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
)} )}
{q.type === "Dropdown" && ( {fieldAllowsOtherConfig(q.type) && (
<DropdownOtherConfigEditor <DropdownOtherConfigEditor
allowOther={q.allowOther} allowOther={q.allowOther}
otherLabel={q.otherLabel} otherLabel={q.otherLabel}
@ -413,7 +417,7 @@ function RegExtraFieldRow({ q, types, onPatch, onRemove, idx, dragIndex, onReord
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} /> <FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
</Field> </Field>
)} )}
{q.type === "Dropdown" && ( {fieldAllowsOtherConfig(q.type) && (
<Field span2> <Field span2>
<DropdownOtherConfigEditor <DropdownOtherConfigEditor
allowOther={q.allowOther} allowOther={q.allowOther}
@ -582,6 +586,7 @@ function typeFromApi(
if (t === "select" && opts?.length === 11) return "NPS (0–10)"; if (t === "select" && opts?.length === 11) return "NPS (0–10)";
if (t === "radio") return "Radio"; if (t === "radio") return "Radio";
if (t === "checkboxes") return "Checkboxes"; if (t === "checkboxes") return "Checkboxes";
if (t === "rank") return "Rank";
if (t === "select") { if (t === "select") {
if (mode === "registration") return "Dropdown"; if (mode === "registration") return "Dropdown";
if (mode === "feedback") return "Multiple Choice"; if (mode === "feedback") return "Multiple Choice";
@ -619,7 +624,7 @@ function withFieldRemarks(field: FormField, remarks?: string): FormField {
} }
function withSelectOther(field: FormField, q: Question): FormField { function withSelectOther(field: FormField, q: Question): FormField {
if (q.type !== "Dropdown") return field; if (!fieldAllowsOtherConfig(q.type)) return field;
if (!q.allowOther) return field; if (!q.allowOther) return field;
const otherLabel = q.otherLabel?.trim() || "Other"; const otherLabel = q.otherLabel?.trim() || "Other";
return { ...field, allowOther: true, otherLabel }; return { ...field, allowOther: true, otherLabel };
@ -668,6 +673,14 @@ function toApiField(q: Question, mode: FormMode): FormField {
if (q.type === "Checkboxes") { if (q.type === "Checkboxes") {
return withName(withUiType({ label, type: "checkboxes", required, options: choiceOpts }, q.type, persistUiType)); return withName(withUiType({ label, type: "checkboxes", required, options: choiceOpts }, q.type, persistUiType));
} }
if (q.type === "Rank") {
return withName(
withSelectOther(
withUiType({ label, type: "rank", required, options: choiceOpts }, q.type, persistUiType),
q,
),
);
}
if (q.type === "Short Text") { if (q.type === "Short Text") {
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType)); return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
} }
@ -753,8 +766,8 @@ function fromApiFields(fields: FormField[] | undefined, qTypes: string[], mode:
type: typ, type: typ,
required: f.required ? "Yes" : "No", required: f.required ? "Yes" : "No",
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [], options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined, allowOther: fieldAllowsOtherConfig(typ) ? !!f.allowOther : undefined,
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined, otherLabel: fieldAllowsOtherConfig(typ) ? (f.otherLabel?.trim() || "Other") : undefined,
mapsTo: f.mapsTo, mapsTo: f.mapsTo,
customMapsKey: f.customMapsKey, customMapsKey: f.customMapsKey,
fixed: f.isFixed, fixed: f.isFixed,
@ -774,8 +787,8 @@ function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: For
type: typ, type: typ,
required: requiredFromApiRegField(f), required: requiredFromApiRegField(f),
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [], options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined, allowOther: fieldAllowsOtherConfig(typ) ? !!f.allowOther : undefined,
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined, otherLabel: fieldAllowsOtherConfig(typ) ? (f.otherLabel?.trim() || "Other") : undefined,
mapsTo: f.mapsTo, mapsTo: f.mapsTo,
customMapsKey: f.customMapsKey, customMapsKey: f.customMapsKey,
fixed: f.isFixed === true, fixed: f.isFixed === true,

View File

@ -75,9 +75,9 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
.map((f: FormField) => { .map((f: FormField) => {
const key = (f.name || f.label || "").trim(); const key = (f.name || f.label || "").trim();
const label = (f.label || f.name || "").trim(); const label = (f.label || f.name || "").trim();
return key || label ? { key: key || label, label: label || key } : null; return key || label ? { key: key || label, label: label || key, type: f.type || "" } : null;
}) })
.filter(Boolean) as { key: string; label: string }[]; .filter(Boolean) as { key: string; label: string; type: string }[];
const tableFieldColumns = fieldColumns.slice(0, SUBMISSIONS_TABLE_PREVIEW_FIELDS); const tableFieldColumns = fieldColumns.slice(0, SUBMISSIONS_TABLE_PREVIEW_FIELDS);
const tableHiddenFieldCount = Math.max(0, fieldColumns.length - tableFieldColumns.length); const tableHiddenFieldCount = Math.max(0, fieldColumns.length - tableFieldColumns.length);
@ -541,7 +541,7 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
<td style={{ padding: "10px 12px", wordBreak: "break-all" }}>{participantEmail(s.participant)}</td> <td style={{ padding: "10px 12px", wordBreak: "break-all" }}>{participantEmail(s.participant)}</td>
{tableFieldColumns.map((col) => ( {tableFieldColumns.map((col) => (
<td key={col.key} style={{ padding: "10px 12px", wordBreak: "break-word", maxWidth: 220 }}> <td key={col.key} style={{ padding: "10px 12px", wordBreak: "break-word", maxWidth: 220 }}>
{formatCell(o[col.key] ?? o[col.label])} {formatCell(o[col.key] ?? o[col.label], col.type)}
</td> </td>
))} ))}
{tableHiddenFieldCount > 0 ? ( {tableHiddenFieldCount > 0 ? (
@ -666,11 +666,43 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
); );
} }
function formatCell(v: unknown): string { function formatCell(v: unknown, fieldType?: string): string {
if (v == null) return "—"; if (v == null) return "—";
if (typeof v === "boolean") return v ? "Yes" : "No"; if (typeof v === "boolean") return v ? "Yes" : "No";
if (Array.isArray(v)) return v.join(", "); const sep = fieldType === "rank" ? " → " : ", ";
if (Array.isArray(v)) {
const joined = v.map((x) => String(x ?? "").trim()).filter(Boolean).join(sep);
return joined || "—";
}
if (typeof v === "object") {
const rec = v as Record<string, unknown>;
if (rec.ranks && typeof rec.ranks === "object" && !Array.isArray(rec.ranks)) {
const items: { label: string; rank: number }[] = [];
for (const [label, rank] of Object.entries(rec.ranks as Record<string, unknown>)) {
if (label === "__other__") {
const text = String(rec.otherText ?? "").trim();
const n = Number(rank);
if (text && Number.isFinite(n) && n > 0) items.push({ label: text, rank: n });
continue;
}
const n = Number(rank);
if (Number.isFinite(n) && n > 0) items.push({ label: String(label), rank: n });
}
items.sort((a, b) => a.rank - b.rank);
if (items.length) return items.map((i) => i.label).join(sep);
}
const keys = Object.keys(rec);
if (keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) {
const joined = keys
.sort((a, b) => Number(a) - Number(b))
.map((k) => String(rec[k] ?? "").trim())
.filter(Boolean)
.join(sep);
return joined || "—";
}
}
const s = String(v); const s = String(v);
if (!s || s === "[object Object]") return "—";
if (s.length > 500) return `${s.slice(0, 500)}…`; if (s.length > 500) return `${s.slice(0, 500)}…`;
return s; return s;
} }

View File

@ -83,7 +83,7 @@ function buildEmbedSnippet(botId: string, accentColor?: string | null) {
const apiBase = CONCIERGE_API_BASE; const apiBase = CONCIERGE_API_BASE;
const widgetSrc = apiBase.replace(/\/api\/?$/, "") + "/embed/concierge-widget.js"; const widgetSrc = apiBase.replace(/\/api\/?$/, "") + "/embed/concierge-widget.js";
const accent = String(accentColor || "").trim() || "rgb(13, 148, 136)"; const accent = String(accentColor || "").trim() || "rgb(13, 148, 136)";
return `<script>\n window.EB_CONCIERGE = { botId: \"${botId}\", apiBase: \"${apiBase}\", accentColor: \"${accent}\" };\n</script>\n<script async src=\"${widgetSrc}?v=22\"></script>`; return `<script>\n window.EB_CONCIERGE = { botId: \"${botId}\", apiBase: \"${apiBase}\", accentColor: \"${accent}\" };\n</script>\n<script async src=\"${widgetSrc}?v=26\"></script>`;
} }
export function WhatsAppChatbotsPage() { export function WhatsAppChatbotsPage() {
@ -172,6 +172,8 @@ export function WhatsAppChatbotsPage() {
wabaPhoneNumberId: bot.wabaPhoneNumberId, wabaPhoneNumberId: bot.wabaPhoneNumberId,
eventId: nextEventId || undefined, eventId: nextEventId || undefined,
enabled: Boolean(bot.enabled), enabled: Boolean(bot.enabled),
voiceEnabled: Boolean(bot.voiceEnabled),
videoAvatarEnabled: bot.videoAvatarEnabled !== false,
greeting: bot.greeting, greeting: bot.greeting,
registration: bot.registration, registration: bot.registration,
knowledge: bot.knowledge knowledge: bot.knowledge
@ -536,6 +538,29 @@ export function WhatsAppChatbotsPage() {
/> />
Enable chatbot Enable chatbot
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
type="checkbox"
checked={Boolean(bot.voiceEnabled)}
onChange={(e) => setBot({ ...bot, voiceEnabled: e.target.checked })}
/>
Enable voice in website embed
</label>
<label style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
<input
type="checkbox"
checked={bot.videoAvatarEnabled !== false}
disabled={!bot.voiceEnabled}
onChange={(e) => setBot({ ...bot, videoAvatarEnabled: e.target.checked })}
style={{ marginTop: 3 }}
/>
<span>
Enable video avatar
<span style={{ display: "block", fontSize: 11, color: "var(--muted-foreground)" }}>
SIMLI talking-head on the avatar page. If off, the page shows a voice waveform instead.
</span>
</span>
</label>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}> <label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input <input
type="checkbox" type="checkbox"

View File

@ -1,17 +1,19 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { Plus, Trash2, Download, Settings } from "lucide-react"; import { Plus, Trash2, Download, Settings, BarChart2, Eraser } from "lucide-react";
import { import {
conciergeBotsApi, conciergeBotsApi,
uploadMediaFile, uploadMediaFile,
whatsappTemplatesApi, whatsappTemplatesApi,
type ConciergeBotRecord, type ConciergeBotRecord,
type ConciergeBotReception, type ConciergeBotReception,
type ConciergeDripStats,
type ConciergeReceptionDrip, type ConciergeReceptionDrip,
type ConciergeReceptionInfo, type ConciergeReceptionInfo,
} 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"; import { WhatsAppCtaEditor, normalizeCtasForSave, type WhatsAppCtaItem } from "../whatsapp/WhatsAppCtaEditor";
import { FlatpickrDatetimeInput, toDatetimeLocalValue } from "../ui/FlatpickrDatetimeInput";
const IS: React.CSSProperties = { const IS: React.CSSProperties = {
width: "100%", width: "100%",
@ -69,6 +71,7 @@ function receptionFromBot(bot: ConciergeBotRecord): ConciergeBotReception {
const r = bot.reception || {}; const r = bot.reception || {};
return { return {
enabled: Boolean(r.enabled), enabled: Boolean(r.enabled),
allowNonRegistered: Boolean(r.allowNonRegistered),
prefillText: r.prefillText || "Hi", prefillText: r.prefillText || "Hi",
welcomeMessage: r.welcomeMessage || DEFAULT_WELCOME, welcomeMessage: r.welcomeMessage || DEFAULT_WELCOME,
unmatchedBadgePrompt: r.unmatchedBadgePrompt || DEFAULT_BADGE, unmatchedBadgePrompt: r.unmatchedBadgePrompt || DEFAULT_BADGE,
@ -93,22 +96,44 @@ export function WhatsAppReceptionTab({
const [templates, setTemplates] = useState<WhatsAppTemplateRecord[]>([]); const [templates, setTemplates] = useState<WhatsAppTemplateRecord[]>([]);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [qrWithLogo, setQrWithLogo] = useState(false);
const [qrDownloading, setQrDownloading] = useState<"png" | "svg" | null>(null);
const [statsDripId, setStatsDripId] = useState<string | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [statsError, setStatsError] = useState<string | null>(null);
const [stats, setStats] = useState<ConciergeDripStats | null>(null);
const [clearingSessions, setClearingSessions] = useState(false);
useEffect(() => { useEffect(() => {
setReception(receptionFromBot(bot)); setReception(receptionFromBot(bot));
}, [bot._id, bot.updatedAt]); }, [bot._id, bot.updatedAt]);
const loadInfo = useCallback(async () => { const loadInfo = useCallback(async () => {
const res = await conciergeBotsApi.getReception(bot._id); const res = await conciergeBotsApi.getReception(bot._id, { logo: qrWithLogo });
if (!res.error && res.data) setInfo(res.data); if (!res.error && res.data) setInfo(res.data);
}, [bot._id]); }, [bot._id, qrWithLogo]);
useEffect(() => {
void loadInfo();
}, [loadInfo]);
useEffect(() => { useEffect(() => {
loadInfo();
whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => { whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => {
if (!r.error) setTemplates(r.data?.templates || []); if (!r.error) setTemplates(r.data?.templates || []);
}); });
}, [loadInfo]); }, []);
const downloadQr = async (format: "png" | "svg") => {
setQrDownloading(format);
setMsg(null);
try {
await conciergeBotsApi.downloadReceptionQr(bot._id, format, { logo: qrWithLogo });
} catch (e) {
setMsg(e instanceof Error ? e.message : "QR download failed");
} finally {
setQrDownloading(null);
}
};
const patch = (partial: Partial<ConciergeBotReception>) => setReception((prev) => ({ ...prev, ...partial })); const patch = (partial: Partial<ConciergeBotReception>) => setReception((prev) => ({ ...prev, ...partial }));
@ -118,6 +143,39 @@ export function WhatsAppReceptionTab({
patch({ drips }); patch({ drips });
}; };
const loadDripStats = async (dripId: string) => {
if (!dripId) {
setStatsError("Save reception first so this drip gets an id.");
setStats(null);
setStatsDripId(null);
return;
}
if (statsDripId === dripId) {
setStatsDripId(null);
setStats(null);
setStatsError(null);
return;
}
setStatsDripId(dripId);
setStatsLoading(true);
setStatsError(null);
setStats(null);
try {
const res = await conciergeBotsApi.getDripStats(bot._id, dripId);
if (res.error) {
setStatsError(res.error);
setStats(null);
} else {
setStats(res.data?.stats ?? null);
}
} catch {
setStatsError("Failed to load drip stats.");
setStats(null);
} finally {
setStatsLoading(false);
}
};
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
setMsg(null); setMsg(null);
@ -180,6 +238,34 @@ export function WhatsAppReceptionTab({
setActiveNav("settings"); setActiveNav("settings");
}; };
const clearReceptionList = async () => {
const count = info?.sessionCount ?? 0;
const ok = window.confirm(
count
? `Clear ${count} reception WhatsApp visitor${count === 1 ? "" : "s"} for this chatbot? The next message from those numbers will send the welcome and drip sequence again.`
: "Clear the reception WhatsApp list for this chatbot? The next message from any previous number will send the welcome and drip sequence again.",
);
if (!ok) return;
setClearingSessions(true);
setMsg(null);
try {
const res = await conciergeBotsApi.clearReceptionSessions(bot._id);
if (res.error) {
setMsg(res.error);
return;
}
const n = res.data?.sessionsCleared ?? 0;
setMsg(
n
? `Cleared ${n} reception visitor${n === 1 ? "" : "s"}. Next inbound from those numbers is treated as new.`
: "Reception list was already empty.",
);
await loadInfo();
} finally {
setClearingSessions(false);
}
};
const outside24h = (d: ConciergeReceptionDrip) => { const outside24h = (d: ConciergeReceptionDrip) => {
if (d.trigger === "absolute") return true; if (d.trigger === "absolute") return true;
return Number(d.delayMinutes || 0) > 24 * 60; return Number(d.delayMinutes || 0) > 24 * 60;
@ -198,6 +284,41 @@ export function WhatsAppReceptionTab({
/> />
Enable reception check-in Enable reception check-in
</label> </label>
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 10 }}>
<input
type="checkbox"
checked={Boolean(reception.allowNonRegistered)}
onChange={(e) => patch({ allowNonRegistered: e.target.checked })}
disabled={!info?.canSendMessages}
style={{ marginTop: 3 }}
/>
<span>
Allow non-registered participants
<span style={{ display: "block", fontSize: 11, color: "var(--muted-foreground)", marginTop: 2 }}>
If their WhatsApp number or name is not on the guest list, add them to this event using their phone
number and profile name. Badge matching still runs when the match is unclear.
</span>
</span>
</label>
<div style={{ margin: "4px 0 12px" }}>
<button
type="button"
style={btnSecondary}
disabled={clearingSessions}
onClick={() => void clearReceptionList()}
>
<Eraser size={14} />
{clearingSessions
? "Clearing…"
: info?.sessionCount
? `Clear reception list (${info.sessionCount})`
: "Clear reception list"}
</button>
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "6px 0 0" }}>
Removes all WhatsApp visitors for this desk so the next message from an existing number is treated as
new (welcome + drips).
</p>
</div>
{!info?.canSendMessages && ( {!info?.canSendMessages && (
<p style={{ fontSize: 12, color: "var(--muted-foreground)", margin: "0 0 10px" }}> <p style={{ fontSize: 12, color: "var(--muted-foreground)", margin: "0 0 10px" }}>
WhatsApp Cloud is not ready to send. Configure WABA ID, token, and phone number ID in Organisation WhatsApp Cloud is not ready to send. Configure WABA ID, token, and phone number ID in Organisation
@ -222,17 +343,37 @@ export function WhatsAppReceptionTab({
src={`data:image/png;base64,${info.qrPngBase64}`} src={`data:image/png;base64,${info.qrPngBase64}`}
style={{ width: 180, height: 180, background: "#fff", borderRadius: 8 }} style={{ width: 180, height: 180, background: "#fff", borderRadius: 8 }}
/> />
<div style={{ marginTop: 8, display: "flex", gap: 8, flexWrap: "wrap" }}> <label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8, fontSize: 13 }}>
<input
type="checkbox"
checked={qrWithLogo}
onChange={(e) => setQrWithLogo(e.target.checked)}
/>
Include WhatsApp logo
</label>
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 8px" }}>
Logo uses high error correction so the code still scans. Prefer Meta’s brand guidelines for posters.
</p>
<div style={{ marginTop: 4, display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
<a href={info.waMeUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}> <a href={info.waMeUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
Open wa.me link Open wa.me link
</a> </a>
<a <button
href={`data:image/png;base64,${info.qrPngBase64}`} type="button"
download="reception-whatsapp-qr.png" style={btnSecondary}
style={{ ...btnSecondary, textDecoration: "none" }} disabled={Boolean(qrDownloading)}
onClick={() => void downloadQr("png")}
> >
<Download size={14} /> Download QR <Download size={14} /> {qrDownloading === "png" ? "Downloading…" : "Download PNG"}
</a> </button>
<button
type="button"
style={btnSecondary}
disabled={Boolean(qrDownloading)}
onClick={() => void downloadQr("svg")}
>
<Download size={14} /> {qrDownloading === "svg" ? "Downloading…" : "Download SVG"}
</button>
</div> </div>
</div> </div>
)} )}
@ -257,7 +398,7 @@ export function WhatsAppReceptionTab({
/> />
</label> </label>
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}> <p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
Placeholders: {"{firstName}"}, {"{profileName}"}, {"{eventName}"} Placeholders: {"{firstName}"}, {"{profileName}"}, {"{eventName}"}, {"{engagementUrl}"}
</p> </p>
<label> <label>
If not matched — ask for badge photo If not matched — ask for badge photo
@ -267,6 +408,12 @@ export function WhatsAppReceptionTab({
onChange={(e) => patch({ unmatchedBadgePrompt: e.target.value })} onChange={(e) => patch({ unmatchedBadgePrompt: e.target.value })}
/> />
</label> </label>
{reception.allowNonRegistered && (
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
With non-registered guests allowed, this prompt is only used when a match is unclear (for example several
people share the same name).
</p>
)}
<label style={{ marginTop: 8, display: "block" }}> <label style={{ marginTop: 8, display: "block" }}>
After badge fails — see reception After badge fails — see reception
<textarea <textarea
@ -283,6 +430,8 @@ export function WhatsAppReceptionTab({
Drips are queued when a visitor messages (or is identified), not when you click Save. 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. 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). Upcoming agenda drips list the next sessions at send time (not when you save).
Unique profile links: put {"{engagementUrl}"} in the body or as a URL button (session window only).
Approved templates outside 24h cannot take a fully dynamic button URL unless Meta allows a suffix variable.
</p> </p>
<label style={{ display: "block", marginBottom: 10 }}> <label style={{ display: "block", marginBottom: 10 }}>
Send drips to Send drips to
@ -300,16 +449,78 @@ export function WhatsAppReceptionTab({
key={drip._id || 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, gap: 8, flexWrap: "wrap" }}>
<strong style={{ fontSize: 13 }}>Message {i + 1}</strong> <strong style={{ fontSize: 13 }}>Message {i + 1}</strong>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
style={btnSecondary}
title={drip._id ? "Show send stats for this drip" : "Save reception first to track stats"}
disabled={!drip._id || (statsLoading && statsDripId === drip._id)}
onClick={() => void loadDripStats(String(drip._id || ""))}
>
<BarChart2 size={14} /> Stats
</button>
<button <button
type="button" type="button"
style={{ ...btnSecondary, color: "var(--destructive)" }} style={{ ...btnSecondary, color: "var(--destructive)" }}
onClick={() => patch({ drips: (reception.drips || []).filter((_, j) => j !== i) })} onClick={() => {
if (drip._id && statsDripId === drip._id) {
setStatsDripId(null);
setStats(null);
setStatsError(null);
}
patch({ drips: (reception.drips || []).filter((_, j) => j !== i) });
}}
> >
<Trash2 size={14} /> Remove <Trash2 size={14} /> Remove
</button> </button>
</div> </div>
</div>
{drip._id && statsDripId === drip._id && (
<div
style={{
marginBottom: 10,
padding: 10,
borderRadius: 8,
background: "var(--secondary)",
border: "1px solid var(--border)",
fontSize: 13,
}}
>
{statsLoading ? (
<p style={{ margin: 0, color: "var(--muted)" }}>Loading stats…</p>
) : statsError ? (
<p style={{ margin: 0, color: "var(--destructive)" }}>{statsError}</p>
) : stats ? (
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0, 1fr))", gap: 8 }}>
{(
[
["Pending", stats.pending],
["Sent", stats.sent],
["Skipped", stats.skipped],
["Failed", stats.failed],
["Total", stats.total],
] as const
).map(([label, value]) => (
<div key={label}>
<div style={{ fontSize: 10, textTransform: "uppercase", color: "var(--muted)", fontWeight: 600 }}>
{label}
</div>
<div style={{ fontWeight: 600 }}>{value}</div>
</div>
))}
</div>
) : (
<p style={{ margin: 0, color: "var(--muted)" }}>No jobs yet for this drip.</p>
)}
</div>
)}
{!drip._id && (
<p style={{ margin: "0 0 8px", fontSize: 12, color: "var(--muted)" }}>
Save reception to enable stats for this new drip.
</p>
)}
<label> <label>
Type Type
<select <select
@ -343,11 +554,14 @@ export function WhatsAppReceptionTab({
{drip.trigger === "absolute" ? ( {drip.trigger === "absolute" ? (
<label> <label>
Send at Send at
<input <FlatpickrDatetimeInput
type="datetime-local"
style={IS} style={IS}
value={drip.sendAt ? String(drip.sendAt).slice(0, 16) : ""} value={toDatetimeLocalValue(drip.sendAt || undefined)}
onChange={(e) => patchDrip(i, { sendAt: e.target.value ? new Date(e.target.value).toISOString() : "" })} onChange={(local) =>
patchDrip(i, {
sendAt: local ? new Date(local).toISOString() : "",
})
}
/> />
</label> </label>
) : ( ) : (

View File

@ -53,8 +53,8 @@ export function normalizeCtasForSave(ctas: WhatsAppCtaItem[] | undefined): Whats
} }
if (type === "url") { if (type === "url") {
if (rows.length > 1) throw new Error("Only one URL button is allowed per message."); if (rows.length > 1) throw new Error("Only one URL button is allowed per message.");
if (!/^https?:\/\//i.test(rows[0].value)) { if (!/^https?:\/\//i.test(rows[0].value) && !/\{engagementUrl\}/i.test(rows[0].value)) {
throw new Error("URL buttons must start with http:// or https://"); throw new Error("URL buttons must start with http:// or https://, or use {engagementUrl}");
} }
return [{ type: "url", label: rows[0].label, value: rows[0].value.slice(0, 2000) }]; return [{ type: "url", label: rows[0].label, value: rows[0].value.slice(0, 2000) }];
} }
@ -106,6 +106,8 @@ export function WhatsAppCtaEditor({
<span style={{ fontSize: compact ? 12 : "var(--text-sm)", fontWeight: 500 }}>Buttons (optional)</span> <span style={{ fontSize: compact ? 12 : "var(--text-sm)", fontWeight: 500 }}>Buttons (optional)</span>
<span style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.4 }}> <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. Up to 3 Reply buttons, or one URL button — not both. Only sent inside the 24-hour chat window.
For unique participant links, set the URL to exactly {"{engagementUrl}"} (the visitor must already be
identified so we can build their link).
</span> </span>
</div> </div>
@ -161,7 +163,7 @@ export function WhatsAppCtaEditor({
style={IS} style={IS}
value={row.value} value={row.value}
disabled={disabled} disabled={disabled}
placeholder={row.type === "url" ? "https://…" : "Same as label if empty"} placeholder={row.type === "url" ? "{engagementUrl} or https://…" : "Same as label if empty"}
onChange={(e) => patchRow(i, { value: e.target.value })} onChange={(e) => patchRow(i, { value: e.target.value })}
/> />
</label> </label>

View File

@ -0,0 +1,22 @@
/** Engagement hub host — unique per-participant links. */
export function engagementPublicBase(): string {
const fromEnv =
(typeof import.meta.env.VITE_ENGAGEMENT_PUBLIC_URL === "string" &&
import.meta.env.VITE_ENGAGEMENT_PUBLIC_URL.trim()) ||
"";
return (fromEnv || (import.meta.env.DEV ? "http://localhost:5175" : "https://engage.eventbuzz.live")).replace(
/\/+$/,
"",
);
}
export function engagementParticipantUrl(eventId: string, participantId: string): string {
const base = engagementPublicBase();
return `${base}/${encodeURIComponent(eventId)}/${encodeURIComponent(participantId)}`;
}
export function engagementUrlPattern(eventId?: string): string {
const base = engagementPublicBase();
if (eventId) return `${base}/${eventId}/{participantId}`;
return `${base}/{eventId}/{participantId}`;
}

View File

@ -1,4 +1,4 @@
import { api } from './client'; import { api, fetchWithAuth } from './client';
export type ConciergeBotStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED'; export type ConciergeBotStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
@ -28,6 +28,7 @@ export interface ConciergeReceptionDrip {
export interface ConciergeBotReception { export interface ConciergeBotReception {
enabled?: boolean; enabled?: boolean;
allowNonRegistered?: boolean;
prefillText?: string; prefillText?: string;
welcomeMessage?: string; welcomeMessage?: string;
unmatchedBadgePrompt?: string; unmatchedBadgePrompt?: string;
@ -41,10 +42,12 @@ export interface ConciergeReceptionInfo {
displayDigits: string; displayDigits: string;
waMeUrl: string; waMeUrl: string;
qrPngBase64: string; qrPngBase64: string;
qrWithLogo?: boolean;
canSendMessages: boolean; canSendMessages: boolean;
graphAccess: boolean; graphAccess: boolean;
resolveError: string | null; resolveError: string | null;
prefillText: string; prefillText: string;
sessionCount?: number;
} }
export interface ConciergeBotKnowledge { export interface ConciergeBotKnowledge {
@ -64,6 +67,7 @@ export type ConciergeFaqCategory =
| 'venue_parking' | 'venue_parking'
| 'venue_parking_transport' | 'venue_parking_transport'
| 'venue_food' | 'venue_food'
| 'accommodation'
| 'venue_accessibility' | 'venue_accessibility'
| 'wifi' | 'wifi'
| 'app_account' | 'app_account'
@ -110,6 +114,10 @@ export interface ConciergeBotRecord {
/** Website widget accent (hex or rgb). */ /** Website widget accent (hex or rgb). */
accentColor?: string; accentColor?: string;
enabled: boolean; enabled: boolean;
/** Website embed: LiveKit voice session. */
voiceEnabled?: boolean;
/** Avatar page: SIMLI video. If false, show a voice waveform. */
videoAvatarEnabled?: boolean;
status: ConciergeBotStatus; status: ConciergeBotStatus;
greeting?: ConciergeBotGreeting; greeting?: ConciergeBotGreeting;
registration?: ConciergeBotRegistration; registration?: ConciergeBotRegistration;
@ -157,6 +165,14 @@ export interface Event {
[k: string]: any; [k: string]: any;
} }
export interface ConciergeDripStats {
pending: number;
sent: number;
skipped: number;
failed: number;
total: number;
}
export const conciergeBotsApi = { export const conciergeBotsApi = {
list(params?: { organization?: string }) { list(params?: { organization?: string }) {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
@ -185,6 +201,8 @@ export const conciergeBotsApi = {
registration?: ConciergeBotRegistration; registration?: ConciergeBotRegistration;
knowledge?: ConciergeBotKnowledge; knowledge?: ConciergeBotKnowledge;
enabled?: boolean; enabled?: boolean;
voiceEnabled?: boolean;
videoAvatarEnabled?: boolean;
}) { }) {
return api.post<{ bot: ConciergeBotRecord; warning?: string }>('/concierge-bots', payload); return api.post<{ bot: ConciergeBotRecord; warning?: string }>('/concierge-bots', payload);
}, },
@ -200,6 +218,8 @@ export const conciergeBotsApi = {
knowledge: ConciergeBotKnowledge; knowledge: ConciergeBotKnowledge;
faqs: ConciergeFaqItem[]; faqs: ConciergeFaqItem[];
enabled: boolean; enabled: boolean;
voiceEnabled: boolean;
videoAvatarEnabled: boolean;
reception: ConciergeBotReception; reception: ConciergeBotReception;
eventId: string; eventId: string;
}>, }>,
@ -207,8 +227,52 @@ export const conciergeBotsApi = {
return api.put<{ bot: ConciergeBotRecord; eventChanged?: boolean }>(`/concierge-bots/${botId}`, payload); return api.put<{ bot: ConciergeBotRecord; eventChanged?: boolean }>(`/concierge-bots/${botId}`, payload);
}, },
getReception(botId: string) { getReception(botId: string, opts?: { logo?: boolean }) {
return api.get<ConciergeReceptionInfo>(`/concierge-bots/${botId}/reception`); const q = opts?.logo ? '?logo=1' : '';
return api.get<ConciergeReceptionInfo>(`/concierge-bots/${botId}/reception${q}`);
},
getDripStats(botId: string, dripId: string) {
return api.get<{ dripId: string; stats: ConciergeDripStats }>(
`/concierge-bots/${botId}/drips/${encodeURIComponent(dripId)}/stats`,
);
},
clearReceptionSessions(botId: string) {
return api.post<{ sessionsCleared: number; dripJobsCleared: number }>(
`/concierge-bots/${botId}/reception/sessions/clear`,
{},
);
},
async downloadReceptionQr(botId: string, format: 'png' | 'svg', opts?: { logo?: boolean }) {
const logoQ = opts?.logo ? '?logo=1' : '';
const path =
format === 'svg'
? `/concierge-bots/${botId}/reception-qr.svg${logoQ}`
: `/concierge-bots/${botId}/reception-qr.png${logoQ}`;
const res = await fetchWithAuth(path, { method: 'GET', skipGlobalLoading: true });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `Download failed (${res.status})`);
}
const blob = await res.blob();
const filename =
format === 'svg'
? opts?.logo
? 'reception-whatsapp-qr-logo.svg'
: 'reception-whatsapp-qr.svg'
: opts?.logo
? 'reception-whatsapp-qr-logo.png'
: 'reception-whatsapp-qr.png';
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
}, },
remove(botId: string) { remove(botId: string) {

View File

@ -0,0 +1,76 @@
import { api } from './client';
export type EngagementPlacement = 'off' | 'flow' | 'profile';
export interface EngagementFeaturePlacement {
placement: EngagementPlacement;
}
export interface EngagementEventHeader {
showLogo?: boolean;
showBanner?: boolean;
showTitle?: boolean;
showDateRange?: boolean;
showEventType?: boolean;
showLocationType?: boolean;
showVenue?: boolean;
}
export interface EngagementCoreField {
show: boolean;
required: boolean;
}
export type EngagementCustomFieldType = 'text' | 'textarea' | 'email' | 'phone' | 'select';
export interface EngagementCustomField {
key: string;
label: string;
type: EngagementCustomFieldType;
required: boolean;
showOnProfile?: boolean;
options: string[];
}
export interface EngagementProfileFields {
name: EngagementCoreField;
email: EngagementCoreField;
custom: EngagementCustomField[];
}
export interface EngagementFeatures {
aiAvatar: EngagementFeaturePlacement;
socialShare: EngagementFeaturePlacement;
photoWall: EngagementFeaturePlacement;
photoSearch: EngagementFeaturePlacement;
}
export interface EngagementConfig {
_id: string;
event: string;
organization: string;
enabled: boolean;
eventHeader: EngagementEventHeader;
features: EngagementFeatures;
profileFields: EngagementProfileFields;
createdAt?: string;
updatedAt?: string;
}
export type EngagementUpdatePayload = Partial<{
enabled: boolean;
eventHeader: EngagementEventHeader;
features: Partial<EngagementFeatures>;
profileFields: Partial<EngagementProfileFields>;
}>;
export const engagementApi = {
get(eventId: string) {
return api.get<EngagementConfig>(`/events/${eventId}/engagement`);
},
update(eventId: string, payload: EngagementUpdatePayload) {
return api.patch<EngagementConfig>(`/events/${eventId}/engagement`, payload);
},
};

View File

@ -38,6 +38,17 @@ export {
type AiAvatarAccessMode, type AiAvatarAccessMode,
type AiAvatarEventHeader, type AiAvatarEventHeader,
} from './aiAvatar'; } from './aiAvatar';
export {
engagementApi,
type EngagementConfig,
type EngagementUpdatePayload,
type EngagementPlacement,
type EngagementFeatures,
type EngagementProfileFields,
type EngagementCustomField,
type EngagementCustomFieldType,
type EngagementEventHeader,
} from './engagement';
export { export {
photoWallApi, photoWallApi,
type PhotoWallConfig, type PhotoWallConfig,
@ -47,6 +58,7 @@ export {
type PhotoWallModerationMode, type PhotoWallModerationMode,
type PhotoWallPhotoStatus, type PhotoWallPhotoStatus,
type PhotoWallPresentPhotoShape, type PhotoWallPresentPhotoShape,
type PhotoWallMarqueeDirection,
type PhotoWallEventHeader, type PhotoWallEventHeader,
} from './photoWall'; } from './photoWall';
@ -130,6 +142,7 @@ export {
type ConciergeBotRecord, type ConciergeBotRecord,
type ConciergeBotReception, type ConciergeBotReception,
type ConciergeReceptionDrip, type ConciergeReceptionDrip,
type ConciergeDripStats,
type ConciergeReceptionInfo, type ConciergeReceptionInfo,
type ConciergeFaqCategory, type ConciergeFaqCategory,
type ConciergeFaqItem, type ConciergeFaqItem,

View File

@ -4,6 +4,7 @@ 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 type PhotoWallPresentPhotoShape = 'rect' | 'rounded' | 'circle';
export type PhotoWallMarqueeDirection = 'left' | 'right' | 'alternate';
export interface PhotoWallEventHeader { export interface PhotoWallEventHeader {
showLogo?: boolean; showLogo?: boolean;
@ -67,6 +68,8 @@ export interface PhotoWallConfig {
createParticipant: boolean; createParticipant: boolean;
moderationMode: PhotoWallModerationMode; moderationMode: PhotoWallModerationMode;
photosPerRow: number; photosPerRow: number;
totalRows: number;
marqueeDirection: PhotoWallMarqueeDirection;
maxPhotoWidth: number; maxPhotoWidth: number;
maxPhotoHeight: number; maxPhotoHeight: number;
gapPx: number; gapPx: number;
@ -130,6 +133,8 @@ export type PhotoWallUpdatePayload = Partial<{
createParticipant: boolean; createParticipant: boolean;
moderationMode: PhotoWallModerationMode; moderationMode: PhotoWallModerationMode;
photosPerRow: number; photosPerRow: number;
totalRows: number;
marqueeDirection: PhotoWallMarqueeDirection;
maxPhotoWidth: number; maxPhotoWidth: number;
maxPhotoHeight: number; maxPhotoHeight: number;
gapPx: number; gapPx: number;

View File

@ -595,7 +595,7 @@ export interface FormField {
type?: string; type?: string;
required?: boolean; required?: boolean;
options?: string[]; options?: string[];
/** Dropdown only: include an Other option; submitted value is the custom text. */ /** Dropdown / Rank: include an Other option. */
allowOther?: boolean; allowOther?: boolean;
otherLabel?: string; otherLabel?: string;
placeholder?: string; placeholder?: string;
@ -860,11 +860,23 @@ export interface AttendanceAudienceSettings {
participantListIds?: string[]; participantListIds?: string[];
} }
export interface AttendanceWhatsAppNotifySettings {
enabled?: boolean;
whatsappTemplateId?: string | null;
waHeaderImageUrl?: string;
waOverrides?: {
header?: Record<string, { kind: 'merge' | 'fixed'; key?: string; value?: string }>;
body?: Record<string, { kind: 'merge' | 'fixed'; key?: string; value?: string }>;
url?: Record<string, { kind: 'merge' | 'fixed'; key?: string; value?: string }>;
} | null;
}
export interface AttendanceSettings { export interface AttendanceSettings {
allowDuplicateCheckIn?: boolean; allowDuplicateCheckIn?: boolean;
allowAssignBlank?: boolean; allowAssignBlank?: boolean;
publicLinkEnabled?: boolean; publicLinkEnabled?: boolean;
audience?: AttendanceAudienceSettings; audience?: AttendanceAudienceSettings;
whatsappNotify?: AttendanceWhatsAppNotifySettings;
} }
export interface AttendanceRecord { export interface AttendanceRecord {

View File

@ -5,6 +5,12 @@ import type { FormField, PublicFormDefinition, PublicFormEventSummary } from "..
import type { FormEventHeaderDisplay } from "../app/utils/formEventHeaderDisplay"; import type { FormEventHeaderDisplay } from "../app/utils/formEventHeaderDisplay";
import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames"; import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames";
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput"; import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
import { FormRankField } from "../app/components/forms/FormRankField";
import {
parseRankValue,
rankValueToOrderedLabels,
validateRankValue,
} from "../app/components/forms/rankFieldUtils";
import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields"; import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields";
import { COUNTRY_NAMES, DEFAULT_COUNTRY, statesForCountry } from "../app/data/countryStates"; import { COUNTRY_NAMES, DEFAULT_COUNTRY, statesForCountry } from "../app/data/countryStates";
import { RegistrationTicketStep } from "../components/forms/RegistrationTicketStep"; import { RegistrationTicketStep } from "../components/forms/RegistrationTicketStep";
@ -245,6 +251,14 @@ function fieldValueToData(
if (field.type === "checkboxes" && Array.isArray(value)) { if (field.type === "checkboxes" && Array.isArray(value)) {
return { [k]: value }; return { [k]: value };
} }
if (field.type === "rank") {
const ordered = rankValueToOrderedLabels(
parseRankValue(value),
Array.isArray(field.options) ? field.options.map(String) : [],
field.allowOther,
);
return { [k]: ordered };
}
if (field.type === "checkbox") { if (field.type === "checkbox") {
return { [k]: value === true || value === "true" ? "Yes" : "" }; return { [k]: value === true || value === "true" ? "Yes" : "" };
} }
@ -317,6 +331,23 @@ export function PublicFormView() {
for (const field of form.fields) { for (const field of form.fields) {
if (field.type === "section") continue; if (field.type === "section") continue;
const display = fieldDisplayLabel(field); const display = fieldDisplayLabel(field);
if (field.type === "rank") {
const k = fieldDataKey(field);
if (!k) continue;
const err = validateRankValue(
parseRankValue(values[k]),
Array.isArray(field.options) ? field.options.map(String) : [],
{
required: !!field.required,
allowOther: field.allowOther,
displayLabel: display,
},
);
if (err) return err;
continue;
}
if (!field.required) continue; if (!field.required) continue;
if (isCountryStateField(field)) { if (isCountryStateField(field)) {
@ -829,6 +860,25 @@ export function PublicFormView() {
</fieldset> </fieldset>
); );
} }
if (field.type === "rank" && (field.options?.length ?? 0) > 0) {
return (
<div key={`field-${index}-${k}`}>
<div style={labelStyle}>
{display}
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
</div>
<FormRankField
options={field.options!.map(String)}
value={values[k]}
onChange={(v) => setField(k, v)}
allowOther={field.allowOther}
otherLabel={field.otherLabel}
required={!!req}
/>
<FieldRemarks text={field.remarks} />
</div>
);
}
if (isCountryStateField(field)) { if (isCountryStateField(field)) {
const { country: countryKey, state: stateKey } = countryStateKeys(field); const { country: countryKey, state: stateKey } = countryStateKeys(field);
const countryVal = String(values[countryKey] ?? "") || DEFAULT_COUNTRY; const countryVal = String(values[countryKey] ?? "") || DEFAULT_COUNTRY;

1
src/vite-env.d.ts vendored
View File

@ -8,6 +8,7 @@ interface ImportMetaEnv {
readonly VITE_GOOGLE_CLIENT_ID?: string; readonly VITE_GOOGLE_CLIENT_ID?: string;
/** Optional Windows installer URL for photographer photo uploader. */ /** Optional Windows installer URL for photographer photo uploader. */
readonly VITE_PHOTOGRAPHER_UPLOADER_URL?: string; readonly VITE_PHOTOGRAPHER_UPLOADER_URL?: string;
readonly VITE_ENGAGEMENT_PUBLIC_URL?: string;
} }
/** Google Identity Services (loaded from accounts.google.com/gsi/client) */ /** Google Identity Services (loaded from accounts.google.com/gsi/client) */