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:
parent
b7b3cd19a5
commit
2c86688b2d
|
|
@ -16,6 +16,10 @@ VITE_API_URL=http://localhost:3001/api
|
|||
# Production: https://join.eventbuzz.live — local eventbuzz Vite often :5174
|
||||
# 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.
|
||||
|
||||
VITE_WEBSITE_BUILDER_URL=http://localhost:5173
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
rel="stylesheet"
|
||||
/>
|
||||
<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">
|
||||
</head>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ import type {
|
|||
AttendanceRecord,
|
||||
AttendanceSearchParticipant,
|
||||
AttendanceCheckInRow,
|
||||
AttendanceWhatsAppNotifySettings,
|
||||
} from "../../../services/api/types";
|
||||
import { AttendanceWhatsAppNotifyFields } from "./AttendanceWhatsAppNotifyFields";
|
||||
|
||||
function publicAttendanceUrl(token: string) {
|
||||
return `${window.location.origin}/attendance/${token}`;
|
||||
|
|
@ -92,8 +94,16 @@ interface FormState {
|
|||
includeStatuses: AttendanceMembershipStatus[];
|
||||
includeTags: string[];
|
||||
includeParticipantListIds: string[];
|
||||
whatsappNotify: AttendanceWhatsAppNotifySettings;
|
||||
}
|
||||
|
||||
const emptyWhatsappNotify: AttendanceWhatsAppNotifySettings = {
|
||||
enabled: false,
|
||||
whatsappTemplateId: null,
|
||||
waHeaderImageUrl: "",
|
||||
waOverrides: null,
|
||||
};
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
description: "",
|
||||
|
|
@ -104,6 +114,7 @@ const emptyForm: FormState = {
|
|||
includeStatuses: ["confirmed", "checked_in"],
|
||||
includeTags: [],
|
||||
includeParticipantListIds: [],
|
||||
whatsappNotify: { ...emptyWhatsappNotify },
|
||||
};
|
||||
|
||||
export function AttendancePage() {
|
||||
|
|
@ -221,6 +232,14 @@ export function AttendancePage() {
|
|||
: ["confirmed", "checked_in"],
|
||||
includeTags: audience?.tags || [],
|
||||
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);
|
||||
};
|
||||
|
|
@ -273,6 +292,10 @@ export function AttendancePage() {
|
|||
setError("Select at least one contact list for the attendance list.");
|
||||
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);
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
|
|
@ -288,6 +311,14 @@ export function AttendancePage() {
|
|||
participantListIds:
|
||||
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
|
||||
|
|
@ -946,6 +977,11 @@ export function AttendancePage() {
|
|||
/>
|
||||
Enable public check-in link
|
||||
</label>
|
||||
|
||||
<AttendanceWhatsAppNotifyFields
|
||||
value={form.whatsappNotify}
|
||||
onChange={(whatsappNotify) => setForm((f) => ({ ...f, whatsappNotify }))}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
|
@ -23,15 +23,18 @@ import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
|||
import { PhotoWallManagePanel } from "./PhotoWallManagePanel";
|
||||
import { SocialShareManagePanel } from "./SocialShareManagePanel";
|
||||
import { AiAvatarManagePanel } from "./AiAvatarManagePanel";
|
||||
import { EngagementManagePanel } from "./EngagementManagePanel";
|
||||
import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel";
|
||||
import { photoWallApi } from "../../../services/api/photoWall";
|
||||
import { eventPhotoSearchApi } from "../../../services/api/eventPhotoSearch";
|
||||
import { socialShareApi } from "../../../services/api/socialShare";
|
||||
import { aiAvatarApi } from "../../../services/api/aiAvatar";
|
||||
import { engagementApi } from "../../../services/api/engagement";
|
||||
import { photoWallUploadUrl, photoWallPresentUrl } from "../../utils/photoWallUrls";
|
||||
import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls";
|
||||
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
|
||||
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
|
||||
import { engagementUrlPattern } from "../../utils/engagementUrls";
|
||||
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
||||
import { WhatsAppBlastHistoryPanel } from "./WhatsAppBlastHistoryPanel";
|
||||
import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel";
|
||||
|
|
@ -253,6 +256,8 @@ export function EventHomeModules() {
|
|||
const canWriteSocialShare = canModule("social_share", "write");
|
||||
const canReadAiAvatar = canModule("ai_avatar", "read");
|
||||
const canWriteAiAvatar = canModule("ai_avatar", "write");
|
||||
const canReadEngagement = canModule("engagement", "read");
|
||||
const canWriteEngagement = canModule("engagement", "write");
|
||||
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
||||
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
||||
const canReadWaNotifications = canModule("custom_template_for_wa_messaging", "read");
|
||||
|
|
@ -328,6 +333,10 @@ export function EventHomeModules() {
|
|||
const [aiAvatarCount, setAiAvatarCount] = useState(0);
|
||||
const [aiAvatarToken, setAiAvatarToken] = useState<string | null>(null);
|
||||
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 [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
||||
|
|
@ -738,6 +747,27 @@ export function EventHomeModules() {
|
|||
void 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 () => {
|
||||
if (!selectedEventId) {
|
||||
setPhotoSearchEnabled(false);
|
||||
|
|
@ -891,6 +921,13 @@ export function EventHomeModules() {
|
|||
: aiAvatarEnabled
|
||||
? `Enabled · ${aiAvatarCount} avatar${aiAvatarCount === 1 ? "" : "s"}`
|
||||
: "Disabled";
|
||||
const engagementMeta = !selectedEventId
|
||||
? "Select an event to configure engagement"
|
||||
: engagementLoading
|
||||
? "Loading…"
|
||||
: engagementEnabled
|
||||
? "Enabled · unique participant links"
|
||||
: "Disabled";
|
||||
const photoSearchMeta = !selectedEventId
|
||||
? "Select an event to configure photo search"
|
||||
: 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) => {
|
||||
if (!selectedEventId) return;
|
||||
setPhotoSearchLoading(true);
|
||||
|
|
@ -1674,6 +1735,75 @@ export function EventHomeModules() {
|
|||
}
|
||||
/>
|
||||
) : 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 ? (
|
||||
<ModuleCard
|
||||
icon={<Sparkles size={18} />}
|
||||
|
|
@ -2111,6 +2241,16 @@ export function EventHomeModules() {
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{showEngagementPanel && selectedEventId ? (
|
||||
<EngagementManagePanel
|
||||
eventId={selectedEventId}
|
||||
eventName={eventName ?? undefined}
|
||||
canWrite={canWriteEngagement}
|
||||
onClose={() => setShowEngagementPanel(false)}
|
||||
onChanged={() => void loadEngagementMeta()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showWaBlastHistory && selectedEventId ? (
|
||||
<WhatsAppBlastHistoryPanel
|
||||
eventId={selectedEventId}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type PhotoWallPhotoRow,
|
||||
type PhotoWallEventHeader,
|
||||
type PhotoWallPresentPhotoShape,
|
||||
type PhotoWallMarqueeDirection,
|
||||
type Form,
|
||||
type FormWithSubmissionCount,
|
||||
} from "../../../services/api";
|
||||
|
|
@ -78,6 +79,8 @@ export function PhotoWallManagePanel({
|
|||
createParticipant: false,
|
||||
moderationMode: "auto" as "auto" | "approval",
|
||||
photosPerRow: 4,
|
||||
totalRows: 3,
|
||||
marqueeDirection: "alternate" as PhotoWallMarqueeDirection,
|
||||
maxPhotoWidth: 320,
|
||||
maxPhotoHeight: 320,
|
||||
gapPx: 8,
|
||||
|
|
@ -125,6 +128,13 @@ export function PhotoWallManagePanel({
|
|||
createParticipant: Boolean(wallRes.data.createParticipant),
|
||||
moderationMode: wallRes.data.moderationMode || "auto",
|
||||
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,
|
||||
maxPhotoHeight: wallRes.data.maxPhotoHeight ?? 320,
|
||||
gapPx: wallRes.data.gapPx ?? 8,
|
||||
|
|
@ -208,6 +218,8 @@ export function PhotoWallManagePanel({
|
|||
createParticipant: draft.createParticipant,
|
||||
moderationMode: draft.moderationMode,
|
||||
photosPerRow: draft.photosPerRow,
|
||||
totalRows: draft.totalRows,
|
||||
marqueeDirection: draft.marqueeDirection,
|
||||
maxPhotoWidth: draft.maxPhotoWidth,
|
||||
maxPhotoHeight: draft.maxPhotoHeight,
|
||||
gapPx: draft.gapPx,
|
||||
|
|
@ -626,6 +638,7 @@ export function PhotoWallManagePanel({
|
|||
<Grid cols={2}>
|
||||
<Field>
|
||||
<Label text="Photos per row" />
|
||||
<HelperText text="Columns per lane, and how many cards each row needs before marquee starts." />
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
|
|
@ -638,6 +651,41 @@ export function PhotoWallManagePanel({
|
|||
onBlur={focusOut}
|
||||
/>
|
||||
</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>
|
||||
<Label text="Gap (px)" />
|
||||
<HelperText text="Space between photos on the presenter grid." />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ function eventIdFromFormRef(ev: Form["event"] | undefined): string {
|
|||
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"];
|
||||
/** Other forms: union of feedback + registration field labels (order preserved, no duplicates). */
|
||||
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;
|
||||
}
|
||||
|
||||
function fieldAllowsOtherConfig(type: string): boolean {
|
||||
return type === "Dropdown" || type === "Rank";
|
||||
}
|
||||
|
||||
function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
||||
const nextPatch: Partial<Question> = { type: next };
|
||||
if (isSectionFieldType(next)) {
|
||||
|
|
@ -53,7 +57,7 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
|||
} else {
|
||||
nextPatch.options = [];
|
||||
}
|
||||
if (next !== "Dropdown") {
|
||||
if (!fieldAllowsOtherConfig(next)) {
|
||||
nextPatch.allowOther = false;
|
||||
nextPatch.otherLabel = undefined;
|
||||
}
|
||||
|
|
@ -63,8 +67,8 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
|||
return nextPatch;
|
||||
}
|
||||
|
||||
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */
|
||||
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]);
|
||||
/** 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", "Rank"]);
|
||||
|
||||
/** Choice / selectable fields whose submitted labels can become participant tags. */
|
||||
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 && (
|
||||
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
|
||||
)}
|
||||
{q.type === "Dropdown" && (
|
||||
{fieldAllowsOtherConfig(q.type) && (
|
||||
<DropdownOtherConfigEditor
|
||||
allowOther={q.allowOther}
|
||||
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 })} />
|
||||
</Field>
|
||||
)}
|
||||
{q.type === "Dropdown" && (
|
||||
{fieldAllowsOtherConfig(q.type) && (
|
||||
<Field span2>
|
||||
<DropdownOtherConfigEditor
|
||||
allowOther={q.allowOther}
|
||||
|
|
@ -582,6 +586,7 @@ function typeFromApi(
|
|||
if (t === "select" && opts?.length === 11) return "NPS (0–10)";
|
||||
if (t === "radio") return "Radio";
|
||||
if (t === "checkboxes") return "Checkboxes";
|
||||
if (t === "rank") return "Rank";
|
||||
if (t === "select") {
|
||||
if (mode === "registration") return "Dropdown";
|
||||
if (mode === "feedback") return "Multiple Choice";
|
||||
|
|
@ -619,7 +624,7 @@ function withFieldRemarks(field: FormField, remarks?: string): 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;
|
||||
const otherLabel = q.otherLabel?.trim() || "Other";
|
||||
return { ...field, allowOther: true, otherLabel };
|
||||
|
|
@ -668,6 +673,14 @@ function toApiField(q: Question, mode: FormMode): FormField {
|
|||
if (q.type === "Checkboxes") {
|
||||
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") {
|
||||
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
|
||||
}
|
||||
|
|
@ -753,8 +766,8 @@ function fromApiFields(fields: FormField[] | undefined, qTypes: string[], mode:
|
|||
type: typ,
|
||||
required: f.required ? "Yes" : "No",
|
||||
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
|
||||
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined,
|
||||
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined,
|
||||
allowOther: fieldAllowsOtherConfig(typ) ? !!f.allowOther : undefined,
|
||||
otherLabel: fieldAllowsOtherConfig(typ) ? (f.otherLabel?.trim() || "Other") : undefined,
|
||||
mapsTo: f.mapsTo,
|
||||
customMapsKey: f.customMapsKey,
|
||||
fixed: f.isFixed,
|
||||
|
|
@ -774,8 +787,8 @@ function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: For
|
|||
type: typ,
|
||||
required: requiredFromApiRegField(f),
|
||||
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
|
||||
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined,
|
||||
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined,
|
||||
allowOther: fieldAllowsOtherConfig(typ) ? !!f.allowOther : undefined,
|
||||
otherLabel: fieldAllowsOtherConfig(typ) ? (f.otherLabel?.trim() || "Other") : undefined,
|
||||
mapsTo: f.mapsTo,
|
||||
customMapsKey: f.customMapsKey,
|
||||
fixed: f.isFixed === true,
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
|||
.map((f: FormField) => {
|
||||
const key = (f.name || f.label || "").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 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>
|
||||
{tableFieldColumns.map((col) => (
|
||||
<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>
|
||||
))}
|
||||
{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 (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);
|
||||
if (!s || s === "[object Object]") return "—";
|
||||
if (s.length > 500) return `${s.slice(0, 500)}…`;
|
||||
return s;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ function buildEmbedSnippet(botId: string, accentColor?: string | null) {
|
|||
const apiBase = CONCIERGE_API_BASE;
|
||||
const widgetSrc = apiBase.replace(/\/api\/?$/, "") + "/embed/concierge-widget.js";
|
||||
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() {
|
||||
|
|
@ -172,6 +172,8 @@ export function WhatsAppChatbotsPage() {
|
|||
wabaPhoneNumberId: bot.wabaPhoneNumberId,
|
||||
eventId: nextEventId || undefined,
|
||||
enabled: Boolean(bot.enabled),
|
||||
voiceEnabled: Boolean(bot.voiceEnabled),
|
||||
videoAvatarEnabled: bot.videoAvatarEnabled !== false,
|
||||
greeting: bot.greeting,
|
||||
registration: bot.registration,
|
||||
knowledge: bot.knowledge
|
||||
|
|
@ -536,6 +538,29 @@ export function WhatsAppChatbotsPage() {
|
|||
/>
|
||||
Enable chatbot
|
||||
</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 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
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 {
|
||||
conciergeBotsApi,
|
||||
uploadMediaFile,
|
||||
whatsappTemplatesApi,
|
||||
type ConciergeBotRecord,
|
||||
type ConciergeBotReception,
|
||||
type ConciergeDripStats,
|
||||
type ConciergeReceptionDrip,
|
||||
type ConciergeReceptionInfo,
|
||||
} from "../../../services/api";
|
||||
import type { WhatsAppTemplateRecord } from "../../../services/api/types";
|
||||
import { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { WhatsAppCtaEditor, normalizeCtasForSave, type WhatsAppCtaItem } from "../whatsapp/WhatsAppCtaEditor";
|
||||
import { FlatpickrDatetimeInput, toDatetimeLocalValue } from "../ui/FlatpickrDatetimeInput";
|
||||
|
||||
const IS: React.CSSProperties = {
|
||||
width: "100%",
|
||||
|
|
@ -69,6 +71,7 @@ function receptionFromBot(bot: ConciergeBotRecord): ConciergeBotReception {
|
|||
const r = bot.reception || {};
|
||||
return {
|
||||
enabled: Boolean(r.enabled),
|
||||
allowNonRegistered: Boolean(r.allowNonRegistered),
|
||||
prefillText: r.prefillText || "Hi",
|
||||
welcomeMessage: r.welcomeMessage || DEFAULT_WELCOME,
|
||||
unmatchedBadgePrompt: r.unmatchedBadgePrompt || DEFAULT_BADGE,
|
||||
|
|
@ -93,22 +96,44 @@ export function WhatsAppReceptionTab({
|
|||
const [templates, setTemplates] = useState<WhatsAppTemplateRecord[]>([]);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
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(() => {
|
||||
setReception(receptionFromBot(bot));
|
||||
}, [bot._id, bot.updatedAt]);
|
||||
|
||||
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);
|
||||
}, [bot._id]);
|
||||
}, [bot._id, qrWithLogo]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInfo();
|
||||
}, [loadInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
loadInfo();
|
||||
whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => {
|
||||
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 }));
|
||||
|
||||
|
|
@ -118,6 +143,39 @@ export function WhatsAppReceptionTab({
|
|||
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 () => {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
|
|
@ -180,6 +238,34 @@ export function WhatsAppReceptionTab({
|
|||
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) => {
|
||||
if (d.trigger === "absolute") return true;
|
||||
return Number(d.delayMinutes || 0) > 24 * 60;
|
||||
|
|
@ -198,6 +284,41 @@ export function WhatsAppReceptionTab({
|
|||
/>
|
||||
Enable reception check-in
|
||||
</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 && (
|
||||
<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
|
||||
|
|
@ -222,17 +343,37 @@ export function WhatsAppReceptionTab({
|
|||
src={`data:image/png;base64,${info.qrPngBase64}`}
|
||||
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 }}>
|
||||
Open wa.me link
|
||||
</a>
|
||||
<a
|
||||
href={`data:image/png;base64,${info.qrPngBase64}`}
|
||||
download="reception-whatsapp-qr.png"
|
||||
style={{ ...btnSecondary, textDecoration: "none" }}
|
||||
<button
|
||||
type="button"
|
||||
style={btnSecondary}
|
||||
disabled={Boolean(qrDownloading)}
|
||||
onClick={() => void downloadQr("png")}
|
||||
>
|
||||
<Download size={14} /> Download QR
|
||||
</a>
|
||||
<Download size={14} /> {qrDownloading === "png" ? "Downloading…" : "Download PNG"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={btnSecondary}
|
||||
disabled={Boolean(qrDownloading)}
|
||||
onClick={() => void downloadQr("svg")}
|
||||
>
|
||||
<Download size={14} /> {qrDownloading === "svg" ? "Downloading…" : "Download SVG"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -257,7 +398,7 @@ export function WhatsAppReceptionTab({
|
|||
/>
|
||||
</label>
|
||||
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
|
||||
Placeholders: {"{firstName}"}, {"{profileName}"}, {"{eventName}"}
|
||||
Placeholders: {"{firstName}"}, {"{profileName}"}, {"{eventName}"}, {"{engagementUrl}"}
|
||||
</p>
|
||||
<label>
|
||||
If not matched — ask for badge photo
|
||||
|
|
@ -267,6 +408,12 @@ export function WhatsAppReceptionTab({
|
|||
onChange={(e) => patch({ unmatchedBadgePrompt: e.target.value })}
|
||||
/>
|
||||
</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" }}>
|
||||
After badge fails — see reception
|
||||
<textarea
|
||||
|
|
@ -283,6 +430,8 @@ export function WhatsAppReceptionTab({
|
|||
Drips are queued when a visitor messages (or is identified), not when you click Save.
|
||||
If someone already checked in, they need to send another WhatsApp message to pick up a newly added drip.
|
||||
Upcoming agenda drips list the next sessions at send time (not when you save).
|
||||
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>
|
||||
<label style={{ display: "block", marginBottom: 10 }}>
|
||||
Send drips to
|
||||
|
|
@ -300,16 +449,78 @@ export function WhatsAppReceptionTab({
|
|||
key={drip._id || i}
|
||||
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 10, marginBottom: 10 }}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, gap: 8, flexWrap: "wrap" }}>
|
||||
<strong style={{ fontSize: 13 }}>Message {i + 1}</strong>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...btnSecondary, color: "var(--destructive)" }}
|
||||
onClick={() => patch({ drips: (reception.drips || []).filter((_, j) => j !== i) })}
|
||||
>
|
||||
<Trash2 size={14} /> Remove
|
||||
</button>
|
||||
<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
|
||||
type="button"
|
||||
style={{ ...btnSecondary, color: "var(--destructive)" }}
|
||||
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
|
||||
</button>
|
||||
</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>
|
||||
Type
|
||||
<select
|
||||
|
|
@ -343,11 +554,14 @@ export function WhatsAppReceptionTab({
|
|||
{drip.trigger === "absolute" ? (
|
||||
<label>
|
||||
Send at
|
||||
<input
|
||||
type="datetime-local"
|
||||
<FlatpickrDatetimeInput
|
||||
style={IS}
|
||||
value={drip.sendAt ? String(drip.sendAt).slice(0, 16) : ""}
|
||||
onChange={(e) => patchDrip(i, { sendAt: e.target.value ? new Date(e.target.value).toISOString() : "" })}
|
||||
value={toDatetimeLocalValue(drip.sendAt || undefined)}
|
||||
onChange={(local) =>
|
||||
patchDrip(i, {
|
||||
sendAt: local ? new Date(local).toISOString() : "",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@ export function normalizeCtasForSave(ctas: WhatsAppCtaItem[] | undefined): Whats
|
|||
}
|
||||
if (type === "url") {
|
||||
if (rows.length > 1) throw new Error("Only one URL button is allowed per message.");
|
||||
if (!/^https?:\/\//i.test(rows[0].value)) {
|
||||
throw new Error("URL buttons must start with http:// or https://");
|
||||
if (!/^https?:\/\//i.test(rows[0].value) && !/\{engagementUrl\}/i.test(rows[0].value)) {
|
||||
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) }];
|
||||
}
|
||||
|
|
@ -106,6 +106,8 @@ export function WhatsAppCtaEditor({
|
|||
<span style={{ fontSize: compact ? 12 : "var(--text-sm)", fontWeight: 500 }}>Buttons (optional)</span>
|
||||
<span style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.4 }}>
|
||||
Up to 3 Reply buttons, or one URL button — not both. Only sent inside the 24-hour chat window.
|
||||
For unique participant links, set the URL to exactly {"{engagementUrl}"} (the visitor must already be
|
||||
identified so we can build their link).
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -161,7 +163,7 @@ export function WhatsAppCtaEditor({
|
|||
style={IS}
|
||||
value={row.value}
|
||||
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 })}
|
||||
/>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { api } from './client';
|
||||
import { api, fetchWithAuth } from './client';
|
||||
|
||||
export type ConciergeBotStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ export interface ConciergeReceptionDrip {
|
|||
|
||||
export interface ConciergeBotReception {
|
||||
enabled?: boolean;
|
||||
allowNonRegistered?: boolean;
|
||||
prefillText?: string;
|
||||
welcomeMessage?: string;
|
||||
unmatchedBadgePrompt?: string;
|
||||
|
|
@ -41,10 +42,12 @@ export interface ConciergeReceptionInfo {
|
|||
displayDigits: string;
|
||||
waMeUrl: string;
|
||||
qrPngBase64: string;
|
||||
qrWithLogo?: boolean;
|
||||
canSendMessages: boolean;
|
||||
graphAccess: boolean;
|
||||
resolveError: string | null;
|
||||
prefillText: string;
|
||||
sessionCount?: number;
|
||||
}
|
||||
|
||||
export interface ConciergeBotKnowledge {
|
||||
|
|
@ -64,6 +67,7 @@ export type ConciergeFaqCategory =
|
|||
| 'venue_parking'
|
||||
| 'venue_parking_transport'
|
||||
| 'venue_food'
|
||||
| 'accommodation'
|
||||
| 'venue_accessibility'
|
||||
| 'wifi'
|
||||
| 'app_account'
|
||||
|
|
@ -110,6 +114,10 @@ export interface ConciergeBotRecord {
|
|||
/** Website widget accent (hex or rgb). */
|
||||
accentColor?: string;
|
||||
enabled: boolean;
|
||||
/** Website embed: LiveKit voice session. */
|
||||
voiceEnabled?: boolean;
|
||||
/** Avatar page: SIMLI video. If false, show a voice waveform. */
|
||||
videoAvatarEnabled?: boolean;
|
||||
status: ConciergeBotStatus;
|
||||
greeting?: ConciergeBotGreeting;
|
||||
registration?: ConciergeBotRegistration;
|
||||
|
|
@ -157,6 +165,14 @@ export interface Event {
|
|||
[k: string]: any;
|
||||
}
|
||||
|
||||
export interface ConciergeDripStats {
|
||||
pending: number;
|
||||
sent: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const conciergeBotsApi = {
|
||||
list(params?: { organization?: string }) {
|
||||
const sp = new URLSearchParams();
|
||||
|
|
@ -185,6 +201,8 @@ export const conciergeBotsApi = {
|
|||
registration?: ConciergeBotRegistration;
|
||||
knowledge?: ConciergeBotKnowledge;
|
||||
enabled?: boolean;
|
||||
voiceEnabled?: boolean;
|
||||
videoAvatarEnabled?: boolean;
|
||||
}) {
|
||||
return api.post<{ bot: ConciergeBotRecord; warning?: string }>('/concierge-bots', payload);
|
||||
},
|
||||
|
|
@ -200,6 +218,8 @@ export const conciergeBotsApi = {
|
|||
knowledge: ConciergeBotKnowledge;
|
||||
faqs: ConciergeFaqItem[];
|
||||
enabled: boolean;
|
||||
voiceEnabled: boolean;
|
||||
videoAvatarEnabled: boolean;
|
||||
reception: ConciergeBotReception;
|
||||
eventId: string;
|
||||
}>,
|
||||
|
|
@ -207,8 +227,52 @@ export const conciergeBotsApi = {
|
|||
return api.put<{ bot: ConciergeBotRecord; eventChanged?: boolean }>(`/concierge-bots/${botId}`, payload);
|
||||
},
|
||||
|
||||
getReception(botId: string) {
|
||||
return api.get<ConciergeReceptionInfo>(`/concierge-bots/${botId}/reception`);
|
||||
getReception(botId: string, opts?: { logo?: boolean }) {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -38,6 +38,17 @@ export {
|
|||
type AiAvatarAccessMode,
|
||||
type AiAvatarEventHeader,
|
||||
} from './aiAvatar';
|
||||
export {
|
||||
engagementApi,
|
||||
type EngagementConfig,
|
||||
type EngagementUpdatePayload,
|
||||
type EngagementPlacement,
|
||||
type EngagementFeatures,
|
||||
type EngagementProfileFields,
|
||||
type EngagementCustomField,
|
||||
type EngagementCustomFieldType,
|
||||
type EngagementEventHeader,
|
||||
} from './engagement';
|
||||
export {
|
||||
photoWallApi,
|
||||
type PhotoWallConfig,
|
||||
|
|
@ -47,6 +58,7 @@ export {
|
|||
type PhotoWallModerationMode,
|
||||
type PhotoWallPhotoStatus,
|
||||
type PhotoWallPresentPhotoShape,
|
||||
type PhotoWallMarqueeDirection,
|
||||
type PhotoWallEventHeader,
|
||||
} from './photoWall';
|
||||
|
||||
|
|
@ -130,6 +142,7 @@ export {
|
|||
type ConciergeBotRecord,
|
||||
type ConciergeBotReception,
|
||||
type ConciergeReceptionDrip,
|
||||
type ConciergeDripStats,
|
||||
type ConciergeReceptionInfo,
|
||||
type ConciergeFaqCategory,
|
||||
type ConciergeFaqItem,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export type PhotoWallAccessMode = 'open' | 'login';
|
|||
export type PhotoWallModerationMode = 'auto' | 'approval';
|
||||
export type PhotoWallPhotoStatus = 'pending' | 'approved' | 'removed';
|
||||
export type PhotoWallPresentPhotoShape = 'rect' | 'rounded' | 'circle';
|
||||
export type PhotoWallMarqueeDirection = 'left' | 'right' | 'alternate';
|
||||
|
||||
export interface PhotoWallEventHeader {
|
||||
showLogo?: boolean;
|
||||
|
|
@ -67,6 +68,8 @@ export interface PhotoWallConfig {
|
|||
createParticipant: boolean;
|
||||
moderationMode: PhotoWallModerationMode;
|
||||
photosPerRow: number;
|
||||
totalRows: number;
|
||||
marqueeDirection: PhotoWallMarqueeDirection;
|
||||
maxPhotoWidth: number;
|
||||
maxPhotoHeight: number;
|
||||
gapPx: number;
|
||||
|
|
@ -130,6 +133,8 @@ export type PhotoWallUpdatePayload = Partial<{
|
|||
createParticipant: boolean;
|
||||
moderationMode: PhotoWallModerationMode;
|
||||
photosPerRow: number;
|
||||
totalRows: number;
|
||||
marqueeDirection: PhotoWallMarqueeDirection;
|
||||
maxPhotoWidth: number;
|
||||
maxPhotoHeight: number;
|
||||
gapPx: number;
|
||||
|
|
|
|||
|
|
@ -595,7 +595,7 @@ export interface FormField {
|
|||
type?: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
/** Dropdown only: include an Other option; submitted value is the custom text. */
|
||||
/** Dropdown / Rank: include an Other option. */
|
||||
allowOther?: boolean;
|
||||
otherLabel?: string;
|
||||
placeholder?: string;
|
||||
|
|
@ -860,11 +860,23 @@ export interface AttendanceAudienceSettings {
|
|||
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 {
|
||||
allowDuplicateCheckIn?: boolean;
|
||||
allowAssignBlank?: boolean;
|
||||
publicLinkEnabled?: boolean;
|
||||
audience?: AttendanceAudienceSettings;
|
||||
whatsappNotify?: AttendanceWhatsAppNotifySettings;
|
||||
}
|
||||
|
||||
export interface AttendanceRecord {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import type { FormField, PublicFormDefinition, PublicFormEventSummary } from "..
|
|||
import type { FormEventHeaderDisplay } from "../app/utils/formEventHeaderDisplay";
|
||||
import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames";
|
||||
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 { COUNTRY_NAMES, DEFAULT_COUNTRY, statesForCountry } from "../app/data/countryStates";
|
||||
import { RegistrationTicketStep } from "../components/forms/RegistrationTicketStep";
|
||||
|
|
@ -245,6 +251,14 @@ function fieldValueToData(
|
|||
if (field.type === "checkboxes" && Array.isArray(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") {
|
||||
return { [k]: value === true || value === "true" ? "Yes" : "" };
|
||||
}
|
||||
|
|
@ -317,6 +331,23 @@ export function PublicFormView() {
|
|||
for (const field of form.fields) {
|
||||
if (field.type === "section") continue;
|
||||
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 (isCountryStateField(field)) {
|
||||
|
|
@ -829,6 +860,25 @@ export function PublicFormView() {
|
|||
</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)) {
|
||||
const { country: countryKey, state: stateKey } = countryStateKeys(field);
|
||||
const countryVal = String(values[countryKey] ?? "") || DEFAULT_COUNTRY;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface ImportMetaEnv {
|
|||
readonly VITE_GOOGLE_CLIENT_ID?: string;
|
||||
/** Optional Windows installer URL for photographer photo uploader. */
|
||||
readonly VITE_PHOTOGRAPHER_UPLOADER_URL?: string;
|
||||
readonly VITE_ENGAGEMENT_PUBLIC_URL?: string;
|
||||
}
|
||||
|
||||
/** Google Identity Services (loaded from accounts.google.com/gsi/client) */
|
||||
|
|
|
|||
Loading…
Reference in New Issue