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
|
# Production: https://join.eventbuzz.live — local eventbuzz Vite often :5174
|
||||||
# VITE_EVENTBUZZ_PUBLIC_URL=https://join.eventbuzz.live
|
# VITE_EVENTBUZZ_PUBLIC_URL=https://join.eventbuzz.live
|
||||||
|
|
||||||
|
# Engagement hub (unique per-participant WhatsApp links)
|
||||||
|
# Production: https://engage.eventbuzz.live — local EventBuzz_Engagement Vite :5175
|
||||||
|
# VITE_ENGAGEMENT_PUBLIC_URL=http://localhost:5175
|
||||||
|
|
||||||
# GrapesJS microsite builder (Vite app in /website_builder). Iframe src + postMessage target origin.
|
# GrapesJS microsite builder (Vite app in /website_builder). Iframe src + postMessage target origin.
|
||||||
|
|
||||||
VITE_WEBSITE_BUILDER_URL=http://localhost:5173
|
VITE_WEBSITE_BUILDER_URL=http://localhost:5173
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
<script type="module" crossorigin src="/assets/index-BjfPElUV.js"></script>
|
<script type="module" crossorigin src="/assets/index-DqvnLLBm.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BVkPIZas.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BVkPIZas.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ import type {
|
||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
AttendanceSearchParticipant,
|
AttendanceSearchParticipant,
|
||||||
AttendanceCheckInRow,
|
AttendanceCheckInRow,
|
||||||
|
AttendanceWhatsAppNotifySettings,
|
||||||
} from "../../../services/api/types";
|
} from "../../../services/api/types";
|
||||||
|
import { AttendanceWhatsAppNotifyFields } from "./AttendanceWhatsAppNotifyFields";
|
||||||
|
|
||||||
function publicAttendanceUrl(token: string) {
|
function publicAttendanceUrl(token: string) {
|
||||||
return `${window.location.origin}/attendance/${token}`;
|
return `${window.location.origin}/attendance/${token}`;
|
||||||
|
|
@ -92,8 +94,16 @@ interface FormState {
|
||||||
includeStatuses: AttendanceMembershipStatus[];
|
includeStatuses: AttendanceMembershipStatus[];
|
||||||
includeTags: string[];
|
includeTags: string[];
|
||||||
includeParticipantListIds: string[];
|
includeParticipantListIds: string[];
|
||||||
|
whatsappNotify: AttendanceWhatsAppNotifySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyWhatsappNotify: AttendanceWhatsAppNotifySettings = {
|
||||||
|
enabled: false,
|
||||||
|
whatsappTemplateId: null,
|
||||||
|
waHeaderImageUrl: "",
|
||||||
|
waOverrides: null,
|
||||||
|
};
|
||||||
|
|
||||||
const emptyForm: FormState = {
|
const emptyForm: FormState = {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
|
@ -104,6 +114,7 @@ const emptyForm: FormState = {
|
||||||
includeStatuses: ["confirmed", "checked_in"],
|
includeStatuses: ["confirmed", "checked_in"],
|
||||||
includeTags: [],
|
includeTags: [],
|
||||||
includeParticipantListIds: [],
|
includeParticipantListIds: [],
|
||||||
|
whatsappNotify: { ...emptyWhatsappNotify },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AttendancePage() {
|
export function AttendancePage() {
|
||||||
|
|
@ -221,6 +232,14 @@ export function AttendancePage() {
|
||||||
: ["confirmed", "checked_in"],
|
: ["confirmed", "checked_in"],
|
||||||
includeTags: audience?.tags || [],
|
includeTags: audience?.tags || [],
|
||||||
includeParticipantListIds: (audience?.participantListIds || []).map(String),
|
includeParticipantListIds: (audience?.participantListIds || []).map(String),
|
||||||
|
whatsappNotify: {
|
||||||
|
enabled: Boolean(s.settings?.whatsappNotify?.enabled),
|
||||||
|
whatsappTemplateId: s.settings?.whatsappNotify?.whatsappTemplateId
|
||||||
|
? String(s.settings.whatsappNotify.whatsappTemplateId)
|
||||||
|
: null,
|
||||||
|
waHeaderImageUrl: s.settings?.whatsappNotify?.waHeaderImageUrl || "",
|
||||||
|
waOverrides: s.settings?.whatsappNotify?.waOverrides || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
setPanelOpen(true);
|
setPanelOpen(true);
|
||||||
};
|
};
|
||||||
|
|
@ -273,6 +292,10 @@ export function AttendancePage() {
|
||||||
setError("Select at least one contact list for the attendance list.");
|
setError("Select at least one contact list for the attendance list.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (form.whatsappNotify.enabled && !form.whatsappNotify.whatsappTemplateId) {
|
||||||
|
setError("Select a WhatsApp template for check-in notify, or turn the option off.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
|
|
@ -288,6 +311,14 @@ export function AttendancePage() {
|
||||||
participantListIds:
|
participantListIds:
|
||||||
form.audienceMode === "participant_lists" ? form.includeParticipantListIds : [],
|
form.audienceMode === "participant_lists" ? form.includeParticipantListIds : [],
|
||||||
},
|
},
|
||||||
|
whatsappNotify: form.whatsappNotify.enabled
|
||||||
|
? {
|
||||||
|
enabled: true,
|
||||||
|
whatsappTemplateId: form.whatsappNotify.whatsappTemplateId || null,
|
||||||
|
waHeaderImageUrl: form.whatsappNotify.waHeaderImageUrl || "",
|
||||||
|
waOverrides: form.whatsappNotify.waOverrides || null,
|
||||||
|
}
|
||||||
|
: { enabled: false, whatsappTemplateId: null, waHeaderImageUrl: "", waOverrides: null },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const res = editingId
|
const res = editingId
|
||||||
|
|
@ -946,6 +977,11 @@ export function AttendancePage() {
|
||||||
/>
|
/>
|
||||||
Enable public check-in link
|
Enable public check-in link
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<AttendanceWhatsAppNotifyFields
|
||||||
|
value={form.whatsappNotify}
|
||||||
|
onChange={(whatsappNotify) => setForm((f) => ({ ...f, whatsappNotify }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -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 React, { useCallback, useEffect, useState } from "react";
|
||||||
import { BarChart2, CalendarDays, Check, FileText, Globe, Image, Images, Link2, Mail, MessageSquare, Palette, Plus, Share2, ShieldCheck, Sparkles, Store, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
import { BarChart2, CalendarDays, Check, FileText, Globe, Image, Images, Link2, Mail, MessageSquare, Palette, Plus, Share2, ShieldCheck, Sparkles, Store, Tag, Ticket, Trophy, Upload, UserCircle } from "lucide-react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
@ -23,15 +23,18 @@ import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||||
import { PhotoWallManagePanel } from "./PhotoWallManagePanel";
|
import { PhotoWallManagePanel } from "./PhotoWallManagePanel";
|
||||||
import { SocialShareManagePanel } from "./SocialShareManagePanel";
|
import { SocialShareManagePanel } from "./SocialShareManagePanel";
|
||||||
import { AiAvatarManagePanel } from "./AiAvatarManagePanel";
|
import { AiAvatarManagePanel } from "./AiAvatarManagePanel";
|
||||||
|
import { EngagementManagePanel } from "./EngagementManagePanel";
|
||||||
import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel";
|
import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel";
|
||||||
import { photoWallApi } from "../../../services/api/photoWall";
|
import { photoWallApi } from "../../../services/api/photoWall";
|
||||||
import { eventPhotoSearchApi } from "../../../services/api/eventPhotoSearch";
|
import { eventPhotoSearchApi } from "../../../services/api/eventPhotoSearch";
|
||||||
import { socialShareApi } from "../../../services/api/socialShare";
|
import { socialShareApi } from "../../../services/api/socialShare";
|
||||||
import { aiAvatarApi } from "../../../services/api/aiAvatar";
|
import { aiAvatarApi } from "../../../services/api/aiAvatar";
|
||||||
|
import { engagementApi } from "../../../services/api/engagement";
|
||||||
import { photoWallUploadUrl, photoWallPresentUrl } from "../../utils/photoWallUrls";
|
import { photoWallUploadUrl, photoWallPresentUrl } from "../../utils/photoWallUrls";
|
||||||
import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls";
|
import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls";
|
||||||
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
|
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
|
||||||
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
|
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
|
||||||
|
import { engagementUrlPattern } from "../../utils/engagementUrls";
|
||||||
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
||||||
import { WhatsAppBlastHistoryPanel } from "./WhatsAppBlastHistoryPanel";
|
import { WhatsAppBlastHistoryPanel } from "./WhatsAppBlastHistoryPanel";
|
||||||
import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel";
|
import { SendWhatsAppNotificationPanel } from "./SendWhatsAppNotificationPanel";
|
||||||
|
|
@ -253,6 +256,8 @@ export function EventHomeModules() {
|
||||||
const canWriteSocialShare = canModule("social_share", "write");
|
const canWriteSocialShare = canModule("social_share", "write");
|
||||||
const canReadAiAvatar = canModule("ai_avatar", "read");
|
const canReadAiAvatar = canModule("ai_avatar", "read");
|
||||||
const canWriteAiAvatar = canModule("ai_avatar", "write");
|
const canWriteAiAvatar = canModule("ai_avatar", "write");
|
||||||
|
const canReadEngagement = canModule("engagement", "read");
|
||||||
|
const canWriteEngagement = canModule("engagement", "write");
|
||||||
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
||||||
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
||||||
const canReadWaNotifications = canModule("custom_template_for_wa_messaging", "read");
|
const canReadWaNotifications = canModule("custom_template_for_wa_messaging", "read");
|
||||||
|
|
@ -328,6 +333,10 @@ export function EventHomeModules() {
|
||||||
const [aiAvatarCount, setAiAvatarCount] = useState(0);
|
const [aiAvatarCount, setAiAvatarCount] = useState(0);
|
||||||
const [aiAvatarToken, setAiAvatarToken] = useState<string | null>(null);
|
const [aiAvatarToken, setAiAvatarToken] = useState<string | null>(null);
|
||||||
const [copiedAiAvatarLink, setCopiedAiAvatarLink] = useState(false);
|
const [copiedAiAvatarLink, setCopiedAiAvatarLink] = useState(false);
|
||||||
|
const [showEngagementPanel, setShowEngagementPanel] = useState(false);
|
||||||
|
const [engagementEnabled, setEngagementEnabled] = useState(false);
|
||||||
|
const [engagementLoading, setEngagementLoading] = useState(false);
|
||||||
|
const [copiedEngagementLink, setCopiedEngagementLink] = useState(false);
|
||||||
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
|
const [emailCampaignCount, setEmailCampaignCount] = useState(0);
|
||||||
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
const [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||||
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
||||||
|
|
@ -738,6 +747,27 @@ export function EventHomeModules() {
|
||||||
void loadAiAvatarMeta();
|
void loadAiAvatarMeta();
|
||||||
}, [loadAiAvatarMeta]);
|
}, [loadAiAvatarMeta]);
|
||||||
|
|
||||||
|
const loadEngagementMeta = useCallback(async () => {
|
||||||
|
if (!selectedEventId || !canReadEngagement) {
|
||||||
|
setEngagementEnabled(false);
|
||||||
|
setEngagementLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEngagementLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await engagementApi.get(selectedEventId);
|
||||||
|
setEngagementEnabled(Boolean(res.data?.enabled));
|
||||||
|
} catch {
|
||||||
|
setEngagementEnabled(false);
|
||||||
|
} finally {
|
||||||
|
setEngagementLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedEventId, canReadEngagement]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadEngagementMeta();
|
||||||
|
}, [loadEngagementMeta]);
|
||||||
|
|
||||||
const loadPhotoSearchMeta = useCallback(async () => {
|
const loadPhotoSearchMeta = useCallback(async () => {
|
||||||
if (!selectedEventId) {
|
if (!selectedEventId) {
|
||||||
setPhotoSearchEnabled(false);
|
setPhotoSearchEnabled(false);
|
||||||
|
|
@ -891,6 +921,13 @@ export function EventHomeModules() {
|
||||||
: aiAvatarEnabled
|
: aiAvatarEnabled
|
||||||
? `Enabled · ${aiAvatarCount} avatar${aiAvatarCount === 1 ? "" : "s"}`
|
? `Enabled · ${aiAvatarCount} avatar${aiAvatarCount === 1 ? "" : "s"}`
|
||||||
: "Disabled";
|
: "Disabled";
|
||||||
|
const engagementMeta = !selectedEventId
|
||||||
|
? "Select an event to configure engagement"
|
||||||
|
: engagementLoading
|
||||||
|
? "Loading…"
|
||||||
|
: engagementEnabled
|
||||||
|
? "Enabled · unique participant links"
|
||||||
|
: "Disabled";
|
||||||
const photoSearchMeta = !selectedEventId
|
const photoSearchMeta = !selectedEventId
|
||||||
? "Select an event to configure photo search"
|
? "Select an event to configure photo search"
|
||||||
: photoSearchLoading
|
: photoSearchLoading
|
||||||
|
|
@ -982,6 +1019,30 @@ export function EventHomeModules() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEngagementToggle = async (enabled: boolean) => {
|
||||||
|
if (!selectedEventId || !canWriteEngagement) return;
|
||||||
|
setEngagementLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await engagementApi.update(selectedEventId, { enabled });
|
||||||
|
if (!res.error && res.data) {
|
||||||
|
setEngagementEnabled(Boolean(res.data.enabled));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setEngagementLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyEngagementLink = async () => {
|
||||||
|
if (!selectedEventId) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(engagementUrlPattern(selectedEventId));
|
||||||
|
setCopiedEngagementLink(true);
|
||||||
|
window.setTimeout(() => setCopiedEngagementLink(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePhotoSearchToggle = async (enabled: boolean) => {
|
const handlePhotoSearchToggle = async (enabled: boolean) => {
|
||||||
if (!selectedEventId) return;
|
if (!selectedEventId) return;
|
||||||
setPhotoSearchLoading(true);
|
setPhotoSearchLoading(true);
|
||||||
|
|
@ -1674,6 +1735,75 @@ export function EventHomeModules() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canReadEngagement ? (
|
||||||
|
<ModuleCard
|
||||||
|
icon={<UserCircle size={18} />}
|
||||||
|
title="Engagement"
|
||||||
|
meta={engagementMeta}
|
||||||
|
description="WhatsApp profile hub: DP, details, and activity flow"
|
||||||
|
headerAction={
|
||||||
|
engagementEnabled && selectedEventId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Copy unique URL pattern"
|
||||||
|
onClick={() => void copyEngagementLink()}
|
||||||
|
style={formHeaderIconButtonStyle()}
|
||||||
|
>
|
||||||
|
{copiedEngagementLink ? <Check size={14} /> : <Link2 size={14} />}
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{engagementEnabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled || engagementLoading || !canWriteEngagement}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(),
|
||||||
|
opacity: actionsDisabled || engagementLoading || !canWriteEngagement ? 0.55 : 1,
|
||||||
|
cursor:
|
||||||
|
actionsDisabled || engagementLoading || !canWriteEngagement
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => void handleEngagementToggle(false)}
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled || engagementLoading || !canWriteEngagement}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(true),
|
||||||
|
opacity: actionsDisabled || engagementLoading || !canWriteEngagement ? 0.55 : 1,
|
||||||
|
cursor:
|
||||||
|
actionsDisabled || engagementLoading || !canWriteEngagement
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => void handleEngagementToggle(true)}
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(engagementEnabled),
|
||||||
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setShowEngagementPanel(true)}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{canReadAiAvatar ? (
|
{canReadAiAvatar ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Sparkles size={18} />}
|
icon={<Sparkles size={18} />}
|
||||||
|
|
@ -2111,6 +2241,16 @@ export function EventHomeModules() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showEngagementPanel && selectedEventId ? (
|
||||||
|
<EngagementManagePanel
|
||||||
|
eventId={selectedEventId}
|
||||||
|
eventName={eventName ?? undefined}
|
||||||
|
canWrite={canWriteEngagement}
|
||||||
|
onClose={() => setShowEngagementPanel(false)}
|
||||||
|
onChanged={() => void loadEngagementMeta()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showWaBlastHistory && selectedEventId ? (
|
{showWaBlastHistory && selectedEventId ? (
|
||||||
<WhatsAppBlastHistoryPanel
|
<WhatsAppBlastHistoryPanel
|
||||||
eventId={selectedEventId}
|
eventId={selectedEventId}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type PhotoWallPhotoRow,
|
type PhotoWallPhotoRow,
|
||||||
type PhotoWallEventHeader,
|
type PhotoWallEventHeader,
|
||||||
type PhotoWallPresentPhotoShape,
|
type PhotoWallPresentPhotoShape,
|
||||||
|
type PhotoWallMarqueeDirection,
|
||||||
type Form,
|
type Form,
|
||||||
type FormWithSubmissionCount,
|
type FormWithSubmissionCount,
|
||||||
} from "../../../services/api";
|
} from "../../../services/api";
|
||||||
|
|
@ -78,6 +79,8 @@ export function PhotoWallManagePanel({
|
||||||
createParticipant: false,
|
createParticipant: false,
|
||||||
moderationMode: "auto" as "auto" | "approval",
|
moderationMode: "auto" as "auto" | "approval",
|
||||||
photosPerRow: 4,
|
photosPerRow: 4,
|
||||||
|
totalRows: 3,
|
||||||
|
marqueeDirection: "alternate" as PhotoWallMarqueeDirection,
|
||||||
maxPhotoWidth: 320,
|
maxPhotoWidth: 320,
|
||||||
maxPhotoHeight: 320,
|
maxPhotoHeight: 320,
|
||||||
gapPx: 8,
|
gapPx: 8,
|
||||||
|
|
@ -125,6 +128,13 @@ export function PhotoWallManagePanel({
|
||||||
createParticipant: Boolean(wallRes.data.createParticipant),
|
createParticipant: Boolean(wallRes.data.createParticipant),
|
||||||
moderationMode: wallRes.data.moderationMode || "auto",
|
moderationMode: wallRes.data.moderationMode || "auto",
|
||||||
photosPerRow: wallRes.data.photosPerRow ?? 4,
|
photosPerRow: wallRes.data.photosPerRow ?? 4,
|
||||||
|
totalRows: wallRes.data.totalRows ?? 3,
|
||||||
|
marqueeDirection:
|
||||||
|
wallRes.data.marqueeDirection === "left" ||
|
||||||
|
wallRes.data.marqueeDirection === "right" ||
|
||||||
|
wallRes.data.marqueeDirection === "alternate"
|
||||||
|
? wallRes.data.marqueeDirection
|
||||||
|
: "alternate",
|
||||||
maxPhotoWidth: wallRes.data.maxPhotoWidth ?? 320,
|
maxPhotoWidth: wallRes.data.maxPhotoWidth ?? 320,
|
||||||
maxPhotoHeight: wallRes.data.maxPhotoHeight ?? 320,
|
maxPhotoHeight: wallRes.data.maxPhotoHeight ?? 320,
|
||||||
gapPx: wallRes.data.gapPx ?? 8,
|
gapPx: wallRes.data.gapPx ?? 8,
|
||||||
|
|
@ -208,6 +218,8 @@ export function PhotoWallManagePanel({
|
||||||
createParticipant: draft.createParticipant,
|
createParticipant: draft.createParticipant,
|
||||||
moderationMode: draft.moderationMode,
|
moderationMode: draft.moderationMode,
|
||||||
photosPerRow: draft.photosPerRow,
|
photosPerRow: draft.photosPerRow,
|
||||||
|
totalRows: draft.totalRows,
|
||||||
|
marqueeDirection: draft.marqueeDirection,
|
||||||
maxPhotoWidth: draft.maxPhotoWidth,
|
maxPhotoWidth: draft.maxPhotoWidth,
|
||||||
maxPhotoHeight: draft.maxPhotoHeight,
|
maxPhotoHeight: draft.maxPhotoHeight,
|
||||||
gapPx: draft.gapPx,
|
gapPx: draft.gapPx,
|
||||||
|
|
@ -626,6 +638,7 @@ export function PhotoWallManagePanel({
|
||||||
<Grid cols={2}>
|
<Grid cols={2}>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Photos per row" />
|
<Label text="Photos per row" />
|
||||||
|
<HelperText text="Columns per lane, and how many cards each row needs before marquee starts." />
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
|
@ -638,6 +651,41 @@ export function PhotoWallManagePanel({
|
||||||
onBlur={focusOut}
|
onBlur={focusOut}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Rows on presenter wall" />
|
||||||
|
<HelperText text="Marquee starts once every row has “photos per row” cards." />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={8}
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={draft.totalRows}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, totalRows: Number(e.target.value) || 3 }))}
|
||||||
|
style={IS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Marquee direction" />
|
||||||
|
<HelperText text="Alternate scrolls adjacent rows in opposite directions." />
|
||||||
|
<RadioGroup
|
||||||
|
name="pw-marquee-direction"
|
||||||
|
value={draft.marqueeDirection}
|
||||||
|
onChange={(v) =>
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
marqueeDirection:
|
||||||
|
v === "left" || v === "right" || v === "alternate" ? v : "alternate",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ label: "Left", value: "left" },
|
||||||
|
{ label: "Right", value: "right" },
|
||||||
|
{ label: "Alternate rows", value: "alternate" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Gap (px)" />
|
<Label text="Gap (px)" />
|
||||||
<HelperText text="Space between photos on the presenter grid." />
|
<HelperText text="Space between photos on the presenter grid." />
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "");
|
return String((ev as { _id?: string })._id ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
const FEEDBACK_Q_TYPES = ["Star Rating", "NPS (0–10)", "Multiple Choice", "Radio", "Short Text", "Long Text", "Checkboxes", "Yes / No", "Section"];
|
const FEEDBACK_Q_TYPES = ["Star Rating", "NPS (0–10)", "Multiple Choice", "Radio", "Short Text", "Long Text", "Checkboxes", "Yes / No", "Rank", "Section"];
|
||||||
const REG_FIELD_TYPES = ["Text", "Email", "Phone", "Dropdown", "Radio", "Checkboxes", "Yes / No", "Date", "Time", "File Upload", "Country State", "Section"];
|
const REG_FIELD_TYPES = ["Text", "Email", "Phone", "Dropdown", "Radio", "Checkboxes", "Yes / No", "Date", "Time", "File Upload", "Country State", "Section"];
|
||||||
/** Other forms: union of feedback + registration field labels (order preserved, no duplicates). */
|
/** Other forms: union of feedback + registration field labels (order preserved, no duplicates). */
|
||||||
const OTHER_Q_TYPES = [...new Set([...FEEDBACK_Q_TYPES, ...REG_FIELD_TYPES])];
|
const OTHER_Q_TYPES = [...new Set([...FEEDBACK_Q_TYPES, ...REG_FIELD_TYPES])];
|
||||||
|
|
@ -38,6 +38,10 @@ function isSectionFieldType(type: string): boolean {
|
||||||
return type === SECTION_FIELD_TYPE;
|
return type === SECTION_FIELD_TYPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldAllowsOtherConfig(type: string): boolean {
|
||||||
|
return type === "Dropdown" || type === "Rank";
|
||||||
|
}
|
||||||
|
|
||||||
function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
||||||
const nextPatch: Partial<Question> = { type: next };
|
const nextPatch: Partial<Question> = { type: next };
|
||||||
if (isSectionFieldType(next)) {
|
if (isSectionFieldType(next)) {
|
||||||
|
|
@ -53,7 +57,7 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
||||||
} else {
|
} else {
|
||||||
nextPatch.options = [];
|
nextPatch.options = [];
|
||||||
}
|
}
|
||||||
if (next !== "Dropdown") {
|
if (!fieldAllowsOtherConfig(next)) {
|
||||||
nextPatch.allowOther = false;
|
nextPatch.allowOther = false;
|
||||||
nextPatch.otherLabel = undefined;
|
nextPatch.otherLabel = undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -63,8 +67,8 @@ function patchForFieldTypeChange(next: string, q: Question): Partial<Question> {
|
||||||
return nextPatch;
|
return nextPatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox). */
|
/** Types that need a configurable list of choices (dropdown / MC / radio / multi-checkbox / rank). */
|
||||||
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes"]);
|
const OPTION_FIELD_TYPES = new Set(["Multiple Choice", "Dropdown", "Radio", "Checkboxes", "Rank"]);
|
||||||
|
|
||||||
/** Choice / selectable fields whose submitted labels can become participant tags. */
|
/** Choice / selectable fields whose submitted labels can become participant tags. */
|
||||||
const CONVERT_TO_TAG_FIELD_TYPES = new Set([...OPTION_FIELD_TYPES, "Yes / No"]);
|
const CONVERT_TO_TAG_FIELD_TYPES = new Set([...OPTION_FIELD_TYPES, "Yes / No"]);
|
||||||
|
|
@ -325,7 +329,7 @@ function QuestionRow({ q, types, onPatch, onRemove, idx, canRemove, dragIndex, o
|
||||||
{showChoices && (
|
{showChoices && (
|
||||||
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
|
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
|
||||||
)}
|
)}
|
||||||
{q.type === "Dropdown" && (
|
{fieldAllowsOtherConfig(q.type) && (
|
||||||
<DropdownOtherConfigEditor
|
<DropdownOtherConfigEditor
|
||||||
allowOther={q.allowOther}
|
allowOther={q.allowOther}
|
||||||
otherLabel={q.otherLabel}
|
otherLabel={q.otherLabel}
|
||||||
|
|
@ -413,7 +417,7 @@ function RegExtraFieldRow({ q, types, onPatch, onRemove, idx, dragIndex, onReord
|
||||||
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
|
<FieldOptionsEditor options={q.options ?? []} onChange={(opts) => onPatch({ options: opts })} />
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{q.type === "Dropdown" && (
|
{fieldAllowsOtherConfig(q.type) && (
|
||||||
<Field span2>
|
<Field span2>
|
||||||
<DropdownOtherConfigEditor
|
<DropdownOtherConfigEditor
|
||||||
allowOther={q.allowOther}
|
allowOther={q.allowOther}
|
||||||
|
|
@ -582,6 +586,7 @@ function typeFromApi(
|
||||||
if (t === "select" && opts?.length === 11) return "NPS (0–10)";
|
if (t === "select" && opts?.length === 11) return "NPS (0–10)";
|
||||||
if (t === "radio") return "Radio";
|
if (t === "radio") return "Radio";
|
||||||
if (t === "checkboxes") return "Checkboxes";
|
if (t === "checkboxes") return "Checkboxes";
|
||||||
|
if (t === "rank") return "Rank";
|
||||||
if (t === "select") {
|
if (t === "select") {
|
||||||
if (mode === "registration") return "Dropdown";
|
if (mode === "registration") return "Dropdown";
|
||||||
if (mode === "feedback") return "Multiple Choice";
|
if (mode === "feedback") return "Multiple Choice";
|
||||||
|
|
@ -619,7 +624,7 @@ function withFieldRemarks(field: FormField, remarks?: string): FormField {
|
||||||
}
|
}
|
||||||
|
|
||||||
function withSelectOther(field: FormField, q: Question): FormField {
|
function withSelectOther(field: FormField, q: Question): FormField {
|
||||||
if (q.type !== "Dropdown") return field;
|
if (!fieldAllowsOtherConfig(q.type)) return field;
|
||||||
if (!q.allowOther) return field;
|
if (!q.allowOther) return field;
|
||||||
const otherLabel = q.otherLabel?.trim() || "Other";
|
const otherLabel = q.otherLabel?.trim() || "Other";
|
||||||
return { ...field, allowOther: true, otherLabel };
|
return { ...field, allowOther: true, otherLabel };
|
||||||
|
|
@ -668,6 +673,14 @@ function toApiField(q: Question, mode: FormMode): FormField {
|
||||||
if (q.type === "Checkboxes") {
|
if (q.type === "Checkboxes") {
|
||||||
return withName(withUiType({ label, type: "checkboxes", required, options: choiceOpts }, q.type, persistUiType));
|
return withName(withUiType({ label, type: "checkboxes", required, options: choiceOpts }, q.type, persistUiType));
|
||||||
}
|
}
|
||||||
|
if (q.type === "Rank") {
|
||||||
|
return withName(
|
||||||
|
withSelectOther(
|
||||||
|
withUiType({ label, type: "rank", required, options: choiceOpts }, q.type, persistUiType),
|
||||||
|
q,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (q.type === "Short Text") {
|
if (q.type === "Short Text") {
|
||||||
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
|
return withName(withUiType({ label, type: "text", required }, q.type, persistUiType));
|
||||||
}
|
}
|
||||||
|
|
@ -753,8 +766,8 @@ function fromApiFields(fields: FormField[] | undefined, qTypes: string[], mode:
|
||||||
type: typ,
|
type: typ,
|
||||||
required: f.required ? "Yes" : "No",
|
required: f.required ? "Yes" : "No",
|
||||||
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
|
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
|
||||||
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined,
|
allowOther: fieldAllowsOtherConfig(typ) ? !!f.allowOther : undefined,
|
||||||
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined,
|
otherLabel: fieldAllowsOtherConfig(typ) ? (f.otherLabel?.trim() || "Other") : undefined,
|
||||||
mapsTo: f.mapsTo,
|
mapsTo: f.mapsTo,
|
||||||
customMapsKey: f.customMapsKey,
|
customMapsKey: f.customMapsKey,
|
||||||
fixed: f.isFixed,
|
fixed: f.isFixed,
|
||||||
|
|
@ -774,8 +787,8 @@ function fromApiFieldSingle(f: FormField, i: number, qTypes: string[], mode: For
|
||||||
type: typ,
|
type: typ,
|
||||||
required: requiredFromApiRegField(f),
|
required: requiredFromApiRegField(f),
|
||||||
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
|
options: OPTION_FIELD_TYPES.has(typ) ? optionsFromApi(f) : [],
|
||||||
allowOther: typ === "Dropdown" ? !!f.allowOther : undefined,
|
allowOther: fieldAllowsOtherConfig(typ) ? !!f.allowOther : undefined,
|
||||||
otherLabel: typ === "Dropdown" ? (f.otherLabel?.trim() || "Other") : undefined,
|
otherLabel: fieldAllowsOtherConfig(typ) ? (f.otherLabel?.trim() || "Other") : undefined,
|
||||||
mapsTo: f.mapsTo,
|
mapsTo: f.mapsTo,
|
||||||
customMapsKey: f.customMapsKey,
|
customMapsKey: f.customMapsKey,
|
||||||
fixed: f.isFixed === true,
|
fixed: f.isFixed === true,
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,9 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
.map((f: FormField) => {
|
.map((f: FormField) => {
|
||||||
const key = (f.name || f.label || "").trim();
|
const key = (f.name || f.label || "").trim();
|
||||||
const label = (f.label || f.name || "").trim();
|
const label = (f.label || f.name || "").trim();
|
||||||
return key || label ? { key: key || label, label: label || key } : null;
|
return key || label ? { key: key || label, label: label || key, type: f.type || "" } : null;
|
||||||
})
|
})
|
||||||
.filter(Boolean) as { key: string; label: string }[];
|
.filter(Boolean) as { key: string; label: string; type: string }[];
|
||||||
const tableFieldColumns = fieldColumns.slice(0, SUBMISSIONS_TABLE_PREVIEW_FIELDS);
|
const tableFieldColumns = fieldColumns.slice(0, SUBMISSIONS_TABLE_PREVIEW_FIELDS);
|
||||||
const tableHiddenFieldCount = Math.max(0, fieldColumns.length - tableFieldColumns.length);
|
const tableHiddenFieldCount = Math.max(0, fieldColumns.length - tableFieldColumns.length);
|
||||||
|
|
||||||
|
|
@ -541,7 +541,7 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
<td style={{ padding: "10px 12px", wordBreak: "break-all" }}>{participantEmail(s.participant)}</td>
|
<td style={{ padding: "10px 12px", wordBreak: "break-all" }}>{participantEmail(s.participant)}</td>
|
||||||
{tableFieldColumns.map((col) => (
|
{tableFieldColumns.map((col) => (
|
||||||
<td key={col.key} style={{ padding: "10px 12px", wordBreak: "break-word", maxWidth: 220 }}>
|
<td key={col.key} style={{ padding: "10px 12px", wordBreak: "break-word", maxWidth: 220 }}>
|
||||||
{formatCell(o[col.key] ?? o[col.label])}
|
{formatCell(o[col.key] ?? o[col.label], col.type)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{tableHiddenFieldCount > 0 ? (
|
{tableHiddenFieldCount > 0 ? (
|
||||||
|
|
@ -666,11 +666,43 @@ export function FormSubmissionsPanel({ formId, formName, onClose }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCell(v: unknown): string {
|
function formatCell(v: unknown, fieldType?: string): string {
|
||||||
if (v == null) return "—";
|
if (v == null) return "—";
|
||||||
if (typeof v === "boolean") return v ? "Yes" : "No";
|
if (typeof v === "boolean") return v ? "Yes" : "No";
|
||||||
if (Array.isArray(v)) return v.join(", ");
|
const sep = fieldType === "rank" ? " → " : ", ";
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
const joined = v.map((x) => String(x ?? "").trim()).filter(Boolean).join(sep);
|
||||||
|
return joined || "—";
|
||||||
|
}
|
||||||
|
if (typeof v === "object") {
|
||||||
|
const rec = v as Record<string, unknown>;
|
||||||
|
if (rec.ranks && typeof rec.ranks === "object" && !Array.isArray(rec.ranks)) {
|
||||||
|
const items: { label: string; rank: number }[] = [];
|
||||||
|
for (const [label, rank] of Object.entries(rec.ranks as Record<string, unknown>)) {
|
||||||
|
if (label === "__other__") {
|
||||||
|
const text = String(rec.otherText ?? "").trim();
|
||||||
|
const n = Number(rank);
|
||||||
|
if (text && Number.isFinite(n) && n > 0) items.push({ label: text, rank: n });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const n = Number(rank);
|
||||||
|
if (Number.isFinite(n) && n > 0) items.push({ label: String(label), rank: n });
|
||||||
|
}
|
||||||
|
items.sort((a, b) => a.rank - b.rank);
|
||||||
|
if (items.length) return items.map((i) => i.label).join(sep);
|
||||||
|
}
|
||||||
|
const keys = Object.keys(rec);
|
||||||
|
if (keys.length > 0 && keys.every((k) => /^\d+$/.test(k))) {
|
||||||
|
const joined = keys
|
||||||
|
.sort((a, b) => Number(a) - Number(b))
|
||||||
|
.map((k) => String(rec[k] ?? "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(sep);
|
||||||
|
return joined || "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
const s = String(v);
|
const s = String(v);
|
||||||
|
if (!s || s === "[object Object]") return "—";
|
||||||
if (s.length > 500) return `${s.slice(0, 500)}…`;
|
if (s.length > 500) return `${s.slice(0, 500)}…`;
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ function buildEmbedSnippet(botId: string, accentColor?: string | null) {
|
||||||
const apiBase = CONCIERGE_API_BASE;
|
const apiBase = CONCIERGE_API_BASE;
|
||||||
const widgetSrc = apiBase.replace(/\/api\/?$/, "") + "/embed/concierge-widget.js";
|
const widgetSrc = apiBase.replace(/\/api\/?$/, "") + "/embed/concierge-widget.js";
|
||||||
const accent = String(accentColor || "").trim() || "rgb(13, 148, 136)";
|
const accent = String(accentColor || "").trim() || "rgb(13, 148, 136)";
|
||||||
return `<script>\n window.EB_CONCIERGE = { botId: \"${botId}\", apiBase: \"${apiBase}\", accentColor: \"${accent}\" };\n</script>\n<script async src=\"${widgetSrc}?v=22\"></script>`;
|
return `<script>\n window.EB_CONCIERGE = { botId: \"${botId}\", apiBase: \"${apiBase}\", accentColor: \"${accent}\" };\n</script>\n<script async src=\"${widgetSrc}?v=26\"></script>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WhatsAppChatbotsPage() {
|
export function WhatsAppChatbotsPage() {
|
||||||
|
|
@ -172,6 +172,8 @@ export function WhatsAppChatbotsPage() {
|
||||||
wabaPhoneNumberId: bot.wabaPhoneNumberId,
|
wabaPhoneNumberId: bot.wabaPhoneNumberId,
|
||||||
eventId: nextEventId || undefined,
|
eventId: nextEventId || undefined,
|
||||||
enabled: Boolean(bot.enabled),
|
enabled: Boolean(bot.enabled),
|
||||||
|
voiceEnabled: Boolean(bot.voiceEnabled),
|
||||||
|
videoAvatarEnabled: bot.videoAvatarEnabled !== false,
|
||||||
greeting: bot.greeting,
|
greeting: bot.greeting,
|
||||||
registration: bot.registration,
|
registration: bot.registration,
|
||||||
knowledge: bot.knowledge
|
knowledge: bot.knowledge
|
||||||
|
|
@ -536,6 +538,29 @@ export function WhatsAppChatbotsPage() {
|
||||||
/>
|
/>
|
||||||
Enable chatbot
|
Enable chatbot
|
||||||
</label>
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(bot.voiceEnabled)}
|
||||||
|
onChange={(e) => setBot({ ...bot, voiceEnabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Enable voice in website embed
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "flex-start", gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bot.videoAvatarEnabled !== false}
|
||||||
|
disabled={!bot.voiceEnabled}
|
||||||
|
onChange={(e) => setBot({ ...bot, videoAvatarEnabled: e.target.checked })}
|
||||||
|
style={{ marginTop: 3 }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Enable video avatar
|
||||||
|
<span style={{ display: "block", fontSize: 11, color: "var(--muted-foreground)" }}>
|
||||||
|
SIMLI talking-head on the avatar page. If off, the page shows a voice waveform instead.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { Plus, Trash2, Download, Settings } from "lucide-react";
|
import { Plus, Trash2, Download, Settings, BarChart2, Eraser } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
conciergeBotsApi,
|
conciergeBotsApi,
|
||||||
uploadMediaFile,
|
uploadMediaFile,
|
||||||
whatsappTemplatesApi,
|
whatsappTemplatesApi,
|
||||||
type ConciergeBotRecord,
|
type ConciergeBotRecord,
|
||||||
type ConciergeBotReception,
|
type ConciergeBotReception,
|
||||||
|
type ConciergeDripStats,
|
||||||
type ConciergeReceptionDrip,
|
type ConciergeReceptionDrip,
|
||||||
type ConciergeReceptionInfo,
|
type ConciergeReceptionInfo,
|
||||||
} from "../../../services/api";
|
} from "../../../services/api";
|
||||||
import type { WhatsAppTemplateRecord } from "../../../services/api/types";
|
import type { WhatsAppTemplateRecord } from "../../../services/api/types";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import { WhatsAppCtaEditor, normalizeCtasForSave, type WhatsAppCtaItem } from "../whatsapp/WhatsAppCtaEditor";
|
import { WhatsAppCtaEditor, normalizeCtasForSave, type WhatsAppCtaItem } from "../whatsapp/WhatsAppCtaEditor";
|
||||||
|
import { FlatpickrDatetimeInput, toDatetimeLocalValue } from "../ui/FlatpickrDatetimeInput";
|
||||||
|
|
||||||
const IS: React.CSSProperties = {
|
const IS: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|
@ -69,6 +71,7 @@ function receptionFromBot(bot: ConciergeBotRecord): ConciergeBotReception {
|
||||||
const r = bot.reception || {};
|
const r = bot.reception || {};
|
||||||
return {
|
return {
|
||||||
enabled: Boolean(r.enabled),
|
enabled: Boolean(r.enabled),
|
||||||
|
allowNonRegistered: Boolean(r.allowNonRegistered),
|
||||||
prefillText: r.prefillText || "Hi",
|
prefillText: r.prefillText || "Hi",
|
||||||
welcomeMessage: r.welcomeMessage || DEFAULT_WELCOME,
|
welcomeMessage: r.welcomeMessage || DEFAULT_WELCOME,
|
||||||
unmatchedBadgePrompt: r.unmatchedBadgePrompt || DEFAULT_BADGE,
|
unmatchedBadgePrompt: r.unmatchedBadgePrompt || DEFAULT_BADGE,
|
||||||
|
|
@ -93,22 +96,44 @@ export function WhatsAppReceptionTab({
|
||||||
const [templates, setTemplates] = useState<WhatsAppTemplateRecord[]>([]);
|
const [templates, setTemplates] = useState<WhatsAppTemplateRecord[]>([]);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [qrWithLogo, setQrWithLogo] = useState(false);
|
||||||
|
const [qrDownloading, setQrDownloading] = useState<"png" | "svg" | null>(null);
|
||||||
|
const [statsDripId, setStatsDripId] = useState<string | null>(null);
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [statsError, setStatsError] = useState<string | null>(null);
|
||||||
|
const [stats, setStats] = useState<ConciergeDripStats | null>(null);
|
||||||
|
const [clearingSessions, setClearingSessions] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReception(receptionFromBot(bot));
|
setReception(receptionFromBot(bot));
|
||||||
}, [bot._id, bot.updatedAt]);
|
}, [bot._id, bot.updatedAt]);
|
||||||
|
|
||||||
const loadInfo = useCallback(async () => {
|
const loadInfo = useCallback(async () => {
|
||||||
const res = await conciergeBotsApi.getReception(bot._id);
|
const res = await conciergeBotsApi.getReception(bot._id, { logo: qrWithLogo });
|
||||||
if (!res.error && res.data) setInfo(res.data);
|
if (!res.error && res.data) setInfo(res.data);
|
||||||
}, [bot._id]);
|
}, [bot._id, qrWithLogo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadInfo();
|
||||||
|
}, [loadInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadInfo();
|
|
||||||
whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => {
|
whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => {
|
||||||
if (!r.error) setTemplates(r.data?.templates || []);
|
if (!r.error) setTemplates(r.data?.templates || []);
|
||||||
});
|
});
|
||||||
}, [loadInfo]);
|
}, []);
|
||||||
|
|
||||||
|
const downloadQr = async (format: "png" | "svg") => {
|
||||||
|
setQrDownloading(format);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
await conciergeBotsApi.downloadReceptionQr(bot._id, format, { logo: qrWithLogo });
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : "QR download failed");
|
||||||
|
} finally {
|
||||||
|
setQrDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const patch = (partial: Partial<ConciergeBotReception>) => setReception((prev) => ({ ...prev, ...partial }));
|
const patch = (partial: Partial<ConciergeBotReception>) => setReception((prev) => ({ ...prev, ...partial }));
|
||||||
|
|
||||||
|
|
@ -118,6 +143,39 @@ export function WhatsAppReceptionTab({
|
||||||
patch({ drips });
|
patch({ drips });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDripStats = async (dripId: string) => {
|
||||||
|
if (!dripId) {
|
||||||
|
setStatsError("Save reception first so this drip gets an id.");
|
||||||
|
setStats(null);
|
||||||
|
setStatsDripId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statsDripId === dripId) {
|
||||||
|
setStatsDripId(null);
|
||||||
|
setStats(null);
|
||||||
|
setStatsError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatsDripId(dripId);
|
||||||
|
setStatsLoading(true);
|
||||||
|
setStatsError(null);
|
||||||
|
setStats(null);
|
||||||
|
try {
|
||||||
|
const res = await conciergeBotsApi.getDripStats(bot._id, dripId);
|
||||||
|
if (res.error) {
|
||||||
|
setStatsError(res.error);
|
||||||
|
setStats(null);
|
||||||
|
} else {
|
||||||
|
setStats(res.data?.stats ?? null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStatsError("Failed to load drip stats.");
|
||||||
|
setStats(null);
|
||||||
|
} finally {
|
||||||
|
setStatsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
|
|
@ -180,6 +238,34 @@ export function WhatsAppReceptionTab({
|
||||||
setActiveNav("settings");
|
setActiveNav("settings");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearReceptionList = async () => {
|
||||||
|
const count = info?.sessionCount ?? 0;
|
||||||
|
const ok = window.confirm(
|
||||||
|
count
|
||||||
|
? `Clear ${count} reception WhatsApp visitor${count === 1 ? "" : "s"} for this chatbot? The next message from those numbers will send the welcome and drip sequence again.`
|
||||||
|
: "Clear the reception WhatsApp list for this chatbot? The next message from any previous number will send the welcome and drip sequence again.",
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
setClearingSessions(true);
|
||||||
|
setMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await conciergeBotsApi.clearReceptionSessions(bot._id);
|
||||||
|
if (res.error) {
|
||||||
|
setMsg(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = res.data?.sessionsCleared ?? 0;
|
||||||
|
setMsg(
|
||||||
|
n
|
||||||
|
? `Cleared ${n} reception visitor${n === 1 ? "" : "s"}. Next inbound from those numbers is treated as new.`
|
||||||
|
: "Reception list was already empty.",
|
||||||
|
);
|
||||||
|
await loadInfo();
|
||||||
|
} finally {
|
||||||
|
setClearingSessions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const outside24h = (d: ConciergeReceptionDrip) => {
|
const outside24h = (d: ConciergeReceptionDrip) => {
|
||||||
if (d.trigger === "absolute") return true;
|
if (d.trigger === "absolute") return true;
|
||||||
return Number(d.delayMinutes || 0) > 24 * 60;
|
return Number(d.delayMinutes || 0) > 24 * 60;
|
||||||
|
|
@ -198,6 +284,41 @@ export function WhatsAppReceptionTab({
|
||||||
/>
|
/>
|
||||||
Enable reception check-in
|
Enable reception check-in
|
||||||
</label>
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "flex-start", gap: 8, marginBottom: 10 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(reception.allowNonRegistered)}
|
||||||
|
onChange={(e) => patch({ allowNonRegistered: e.target.checked })}
|
||||||
|
disabled={!info?.canSendMessages}
|
||||||
|
style={{ marginTop: 3 }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Allow non-registered participants
|
||||||
|
<span style={{ display: "block", fontSize: 11, color: "var(--muted-foreground)", marginTop: 2 }}>
|
||||||
|
If their WhatsApp number or name is not on the guest list, add them to this event using their phone
|
||||||
|
number and profile name. Badge matching still runs when the match is unclear.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ margin: "4px 0 12px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={btnSecondary}
|
||||||
|
disabled={clearingSessions}
|
||||||
|
onClick={() => void clearReceptionList()}
|
||||||
|
>
|
||||||
|
<Eraser size={14} />
|
||||||
|
{clearingSessions
|
||||||
|
? "Clearing…"
|
||||||
|
: info?.sessionCount
|
||||||
|
? `Clear reception list (${info.sessionCount})`
|
||||||
|
: "Clear reception list"}
|
||||||
|
</button>
|
||||||
|
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "6px 0 0" }}>
|
||||||
|
Removes all WhatsApp visitors for this desk so the next message from an existing number is treated as
|
||||||
|
new (welcome + drips).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{!info?.canSendMessages && (
|
{!info?.canSendMessages && (
|
||||||
<p style={{ fontSize: 12, color: "var(--muted-foreground)", margin: "0 0 10px" }}>
|
<p style={{ fontSize: 12, color: "var(--muted-foreground)", margin: "0 0 10px" }}>
|
||||||
WhatsApp Cloud is not ready to send. Configure WABA ID, token, and phone number ID in Organisation
|
WhatsApp Cloud is not ready to send. Configure WABA ID, token, and phone number ID in Organisation
|
||||||
|
|
@ -222,17 +343,37 @@ export function WhatsAppReceptionTab({
|
||||||
src={`data:image/png;base64,${info.qrPngBase64}`}
|
src={`data:image/png;base64,${info.qrPngBase64}`}
|
||||||
style={{ width: 180, height: 180, background: "#fff", borderRadius: 8 }}
|
style={{ width: 180, height: 180, background: "#fff", borderRadius: 8 }}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 8, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
<label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8, fontSize: 13 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={qrWithLogo}
|
||||||
|
onChange={(e) => setQrWithLogo(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Include WhatsApp logo
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 8px" }}>
|
||||||
|
Logo uses high error correction so the code still scans. Prefer Meta’s brand guidelines for posters.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: 4, display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
<a href={info.waMeUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
|
<a href={info.waMeUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
|
||||||
Open wa.me link
|
Open wa.me link
|
||||||
</a>
|
</a>
|
||||||
<a
|
<button
|
||||||
href={`data:image/png;base64,${info.qrPngBase64}`}
|
type="button"
|
||||||
download="reception-whatsapp-qr.png"
|
style={btnSecondary}
|
||||||
style={{ ...btnSecondary, textDecoration: "none" }}
|
disabled={Boolean(qrDownloading)}
|
||||||
|
onClick={() => void downloadQr("png")}
|
||||||
>
|
>
|
||||||
<Download size={14} /> Download QR
|
<Download size={14} /> {qrDownloading === "png" ? "Downloading…" : "Download PNG"}
|
||||||
</a>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={btnSecondary}
|
||||||
|
disabled={Boolean(qrDownloading)}
|
||||||
|
onClick={() => void downloadQr("svg")}
|
||||||
|
>
|
||||||
|
<Download size={14} /> {qrDownloading === "svg" ? "Downloading…" : "Download SVG"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -257,7 +398,7 @@ export function WhatsAppReceptionTab({
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
|
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
|
||||||
Placeholders: {"{firstName}"}, {"{profileName}"}, {"{eventName}"}
|
Placeholders: {"{firstName}"}, {"{profileName}"}, {"{eventName}"}, {"{engagementUrl}"}
|
||||||
</p>
|
</p>
|
||||||
<label>
|
<label>
|
||||||
If not matched — ask for badge photo
|
If not matched — ask for badge photo
|
||||||
|
|
@ -267,6 +408,12 @@ export function WhatsAppReceptionTab({
|
||||||
onChange={(e) => patch({ unmatchedBadgePrompt: e.target.value })}
|
onChange={(e) => patch({ unmatchedBadgePrompt: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{reception.allowNonRegistered && (
|
||||||
|
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
|
||||||
|
With non-registered guests allowed, this prompt is only used when a match is unclear (for example several
|
||||||
|
people share the same name).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<label style={{ marginTop: 8, display: "block" }}>
|
<label style={{ marginTop: 8, display: "block" }}>
|
||||||
After badge fails — see reception
|
After badge fails — see reception
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -283,6 +430,8 @@ export function WhatsAppReceptionTab({
|
||||||
Drips are queued when a visitor messages (or is identified), not when you click Save.
|
Drips are queued when a visitor messages (or is identified), not when you click Save.
|
||||||
If someone already checked in, they need to send another WhatsApp message to pick up a newly added drip.
|
If someone already checked in, they need to send another WhatsApp message to pick up a newly added drip.
|
||||||
Upcoming agenda drips list the next sessions at send time (not when you save).
|
Upcoming agenda drips list the next sessions at send time (not when you save).
|
||||||
|
Unique profile links: put {"{engagementUrl}"} in the body or as a URL button (session window only).
|
||||||
|
Approved templates outside 24h cannot take a fully dynamic button URL unless Meta allows a suffix variable.
|
||||||
</p>
|
</p>
|
||||||
<label style={{ display: "block", marginBottom: 10 }}>
|
<label style={{ display: "block", marginBottom: 10 }}>
|
||||||
Send drips to
|
Send drips to
|
||||||
|
|
@ -300,16 +449,78 @@ export function WhatsAppReceptionTab({
|
||||||
key={drip._id || i}
|
key={drip._id || i}
|
||||||
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 10, marginBottom: 10 }}
|
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 10, marginBottom: 10 }}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8, gap: 8, flexWrap: "wrap" }}>
|
||||||
<strong style={{ fontSize: 13 }}>Message {i + 1}</strong>
|
<strong style={{ fontSize: 13 }}>Message {i + 1}</strong>
|
||||||
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={btnSecondary}
|
||||||
|
title={drip._id ? "Show send stats for this drip" : "Save reception first to track stats"}
|
||||||
|
disabled={!drip._id || (statsLoading && statsDripId === drip._id)}
|
||||||
|
onClick={() => void loadDripStats(String(drip._id || ""))}
|
||||||
|
>
|
||||||
|
<BarChart2 size={14} /> Stats
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
style={{ ...btnSecondary, color: "var(--destructive)" }}
|
style={{ ...btnSecondary, color: "var(--destructive)" }}
|
||||||
onClick={() => patch({ drips: (reception.drips || []).filter((_, j) => j !== i) })}
|
onClick={() => {
|
||||||
|
if (drip._id && statsDripId === drip._id) {
|
||||||
|
setStatsDripId(null);
|
||||||
|
setStats(null);
|
||||||
|
setStatsError(null);
|
||||||
|
}
|
||||||
|
patch({ drips: (reception.drips || []).filter((_, j) => j !== i) });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} /> Remove
|
<Trash2 size={14} /> Remove
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{drip._id && statsDripId === drip._id && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 10,
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statsLoading ? (
|
||||||
|
<p style={{ margin: 0, color: "var(--muted)" }}>Loading stats…</p>
|
||||||
|
) : statsError ? (
|
||||||
|
<p style={{ margin: 0, color: "var(--destructive)" }}>{statsError}</p>
|
||||||
|
) : stats ? (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0, 1fr))", gap: 8 }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["Pending", stats.pending],
|
||||||
|
["Sent", stats.sent],
|
||||||
|
["Skipped", stats.skipped],
|
||||||
|
["Failed", stats.failed],
|
||||||
|
["Total", stats.total],
|
||||||
|
] as const
|
||||||
|
).map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<div style={{ fontSize: 10, textTransform: "uppercase", color: "var(--muted)", fontWeight: 600 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ margin: 0, color: "var(--muted)" }}>No jobs yet for this drip.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!drip._id && (
|
||||||
|
<p style={{ margin: "0 0 8px", fontSize: 12, color: "var(--muted)" }}>
|
||||||
|
Save reception to enable stats for this new drip.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<label>
|
<label>
|
||||||
Type
|
Type
|
||||||
<select
|
<select
|
||||||
|
|
@ -343,11 +554,14 @@ export function WhatsAppReceptionTab({
|
||||||
{drip.trigger === "absolute" ? (
|
{drip.trigger === "absolute" ? (
|
||||||
<label>
|
<label>
|
||||||
Send at
|
Send at
|
||||||
<input
|
<FlatpickrDatetimeInput
|
||||||
type="datetime-local"
|
|
||||||
style={IS}
|
style={IS}
|
||||||
value={drip.sendAt ? String(drip.sendAt).slice(0, 16) : ""}
|
value={toDatetimeLocalValue(drip.sendAt || undefined)}
|
||||||
onChange={(e) => patchDrip(i, { sendAt: e.target.value ? new Date(e.target.value).toISOString() : "" })}
|
onChange={(local) =>
|
||||||
|
patchDrip(i, {
|
||||||
|
sendAt: local ? new Date(local).toISOString() : "",
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@ export function normalizeCtasForSave(ctas: WhatsAppCtaItem[] | undefined): Whats
|
||||||
}
|
}
|
||||||
if (type === "url") {
|
if (type === "url") {
|
||||||
if (rows.length > 1) throw new Error("Only one URL button is allowed per message.");
|
if (rows.length > 1) throw new Error("Only one URL button is allowed per message.");
|
||||||
if (!/^https?:\/\//i.test(rows[0].value)) {
|
if (!/^https?:\/\//i.test(rows[0].value) && !/\{engagementUrl\}/i.test(rows[0].value)) {
|
||||||
throw new Error("URL buttons must start with http:// or https://");
|
throw new Error("URL buttons must start with http:// or https://, or use {engagementUrl}");
|
||||||
}
|
}
|
||||||
return [{ type: "url", label: rows[0].label, value: rows[0].value.slice(0, 2000) }];
|
return [{ type: "url", label: rows[0].label, value: rows[0].value.slice(0, 2000) }];
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +106,8 @@ export function WhatsAppCtaEditor({
|
||||||
<span style={{ fontSize: compact ? 12 : "var(--text-sm)", fontWeight: 500 }}>Buttons (optional)</span>
|
<span style={{ fontSize: compact ? 12 : "var(--text-sm)", fontWeight: 500 }}>Buttons (optional)</span>
|
||||||
<span style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.4 }}>
|
<span style={{ fontSize: 11, color: "var(--muted)", lineHeight: 1.4 }}>
|
||||||
Up to 3 Reply buttons, or one URL button — not both. Only sent inside the 24-hour chat window.
|
Up to 3 Reply buttons, or one URL button — not both. Only sent inside the 24-hour chat window.
|
||||||
|
For unique participant links, set the URL to exactly {"{engagementUrl}"} (the visitor must already be
|
||||||
|
identified so we can build their link).
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -161,7 +163,7 @@ export function WhatsAppCtaEditor({
|
||||||
style={IS}
|
style={IS}
|
||||||
value={row.value}
|
value={row.value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={row.type === "url" ? "https://…" : "Same as label if empty"}
|
placeholder={row.type === "url" ? "{engagementUrl} or https://…" : "Same as label if empty"}
|
||||||
onChange={(e) => patchRow(i, { value: e.target.value })}
|
onChange={(e) => patchRow(i, { value: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
export type ConciergeBotStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
|
||||||
|
|
||||||
|
|
@ -28,6 +28,7 @@ export interface ConciergeReceptionDrip {
|
||||||
|
|
||||||
export interface ConciergeBotReception {
|
export interface ConciergeBotReception {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
allowNonRegistered?: boolean;
|
||||||
prefillText?: string;
|
prefillText?: string;
|
||||||
welcomeMessage?: string;
|
welcomeMessage?: string;
|
||||||
unmatchedBadgePrompt?: string;
|
unmatchedBadgePrompt?: string;
|
||||||
|
|
@ -41,10 +42,12 @@ export interface ConciergeReceptionInfo {
|
||||||
displayDigits: string;
|
displayDigits: string;
|
||||||
waMeUrl: string;
|
waMeUrl: string;
|
||||||
qrPngBase64: string;
|
qrPngBase64: string;
|
||||||
|
qrWithLogo?: boolean;
|
||||||
canSendMessages: boolean;
|
canSendMessages: boolean;
|
||||||
graphAccess: boolean;
|
graphAccess: boolean;
|
||||||
resolveError: string | null;
|
resolveError: string | null;
|
||||||
prefillText: string;
|
prefillText: string;
|
||||||
|
sessionCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConciergeBotKnowledge {
|
export interface ConciergeBotKnowledge {
|
||||||
|
|
@ -64,6 +67,7 @@ export type ConciergeFaqCategory =
|
||||||
| 'venue_parking'
|
| 'venue_parking'
|
||||||
| 'venue_parking_transport'
|
| 'venue_parking_transport'
|
||||||
| 'venue_food'
|
| 'venue_food'
|
||||||
|
| 'accommodation'
|
||||||
| 'venue_accessibility'
|
| 'venue_accessibility'
|
||||||
| 'wifi'
|
| 'wifi'
|
||||||
| 'app_account'
|
| 'app_account'
|
||||||
|
|
@ -110,6 +114,10 @@ export interface ConciergeBotRecord {
|
||||||
/** Website widget accent (hex or rgb). */
|
/** Website widget accent (hex or rgb). */
|
||||||
accentColor?: string;
|
accentColor?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** Website embed: LiveKit voice session. */
|
||||||
|
voiceEnabled?: boolean;
|
||||||
|
/** Avatar page: SIMLI video. If false, show a voice waveform. */
|
||||||
|
videoAvatarEnabled?: boolean;
|
||||||
status: ConciergeBotStatus;
|
status: ConciergeBotStatus;
|
||||||
greeting?: ConciergeBotGreeting;
|
greeting?: ConciergeBotGreeting;
|
||||||
registration?: ConciergeBotRegistration;
|
registration?: ConciergeBotRegistration;
|
||||||
|
|
@ -157,6 +165,14 @@ export interface Event {
|
||||||
[k: string]: any;
|
[k: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConciergeDripStats {
|
||||||
|
pending: number;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const conciergeBotsApi = {
|
export const conciergeBotsApi = {
|
||||||
list(params?: { organization?: string }) {
|
list(params?: { organization?: string }) {
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
|
|
@ -185,6 +201,8 @@ export const conciergeBotsApi = {
|
||||||
registration?: ConciergeBotRegistration;
|
registration?: ConciergeBotRegistration;
|
||||||
knowledge?: ConciergeBotKnowledge;
|
knowledge?: ConciergeBotKnowledge;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
voiceEnabled?: boolean;
|
||||||
|
videoAvatarEnabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return api.post<{ bot: ConciergeBotRecord; warning?: string }>('/concierge-bots', payload);
|
return api.post<{ bot: ConciergeBotRecord; warning?: string }>('/concierge-bots', payload);
|
||||||
},
|
},
|
||||||
|
|
@ -200,6 +218,8 @@ export const conciergeBotsApi = {
|
||||||
knowledge: ConciergeBotKnowledge;
|
knowledge: ConciergeBotKnowledge;
|
||||||
faqs: ConciergeFaqItem[];
|
faqs: ConciergeFaqItem[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
voiceEnabled: boolean;
|
||||||
|
videoAvatarEnabled: boolean;
|
||||||
reception: ConciergeBotReception;
|
reception: ConciergeBotReception;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
}>,
|
}>,
|
||||||
|
|
@ -207,8 +227,52 @@ export const conciergeBotsApi = {
|
||||||
return api.put<{ bot: ConciergeBotRecord; eventChanged?: boolean }>(`/concierge-bots/${botId}`, payload);
|
return api.put<{ bot: ConciergeBotRecord; eventChanged?: boolean }>(`/concierge-bots/${botId}`, payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
getReception(botId: string) {
|
getReception(botId: string, opts?: { logo?: boolean }) {
|
||||||
return api.get<ConciergeReceptionInfo>(`/concierge-bots/${botId}/reception`);
|
const q = opts?.logo ? '?logo=1' : '';
|
||||||
|
return api.get<ConciergeReceptionInfo>(`/concierge-bots/${botId}/reception${q}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getDripStats(botId: string, dripId: string) {
|
||||||
|
return api.get<{ dripId: string; stats: ConciergeDripStats }>(
|
||||||
|
`/concierge-bots/${botId}/drips/${encodeURIComponent(dripId)}/stats`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearReceptionSessions(botId: string) {
|
||||||
|
return api.post<{ sessionsCleared: number; dripJobsCleared: number }>(
|
||||||
|
`/concierge-bots/${botId}/reception/sessions/clear`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadReceptionQr(botId: string, format: 'png' | 'svg', opts?: { logo?: boolean }) {
|
||||||
|
const logoQ = opts?.logo ? '?logo=1' : '';
|
||||||
|
const path =
|
||||||
|
format === 'svg'
|
||||||
|
? `/concierge-bots/${botId}/reception-qr.svg${logoQ}`
|
||||||
|
: `/concierge-bots/${botId}/reception-qr.png${logoQ}`;
|
||||||
|
const res = await fetchWithAuth(path, { method: 'GET', skipGlobalLoading: true });
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(text || `Download failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const filename =
|
||||||
|
format === 'svg'
|
||||||
|
? opts?.logo
|
||||||
|
? 'reception-whatsapp-qr-logo.svg'
|
||||||
|
: 'reception-whatsapp-qr.svg'
|
||||||
|
: opts?.logo
|
||||||
|
? 'reception-whatsapp-qr-logo.png'
|
||||||
|
: 'reception-whatsapp-qr.png';
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
},
|
},
|
||||||
|
|
||||||
remove(botId: string) {
|
remove(botId: string) {
|
||||||
|
|
|
||||||
|
|
@ -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 AiAvatarAccessMode,
|
||||||
type AiAvatarEventHeader,
|
type AiAvatarEventHeader,
|
||||||
} from './aiAvatar';
|
} from './aiAvatar';
|
||||||
|
export {
|
||||||
|
engagementApi,
|
||||||
|
type EngagementConfig,
|
||||||
|
type EngagementUpdatePayload,
|
||||||
|
type EngagementPlacement,
|
||||||
|
type EngagementFeatures,
|
||||||
|
type EngagementProfileFields,
|
||||||
|
type EngagementCustomField,
|
||||||
|
type EngagementCustomFieldType,
|
||||||
|
type EngagementEventHeader,
|
||||||
|
} from './engagement';
|
||||||
export {
|
export {
|
||||||
photoWallApi,
|
photoWallApi,
|
||||||
type PhotoWallConfig,
|
type PhotoWallConfig,
|
||||||
|
|
@ -47,6 +58,7 @@ export {
|
||||||
type PhotoWallModerationMode,
|
type PhotoWallModerationMode,
|
||||||
type PhotoWallPhotoStatus,
|
type PhotoWallPhotoStatus,
|
||||||
type PhotoWallPresentPhotoShape,
|
type PhotoWallPresentPhotoShape,
|
||||||
|
type PhotoWallMarqueeDirection,
|
||||||
type PhotoWallEventHeader,
|
type PhotoWallEventHeader,
|
||||||
} from './photoWall';
|
} from './photoWall';
|
||||||
|
|
||||||
|
|
@ -130,6 +142,7 @@ export {
|
||||||
type ConciergeBotRecord,
|
type ConciergeBotRecord,
|
||||||
type ConciergeBotReception,
|
type ConciergeBotReception,
|
||||||
type ConciergeReceptionDrip,
|
type ConciergeReceptionDrip,
|
||||||
|
type ConciergeDripStats,
|
||||||
type ConciergeReceptionInfo,
|
type ConciergeReceptionInfo,
|
||||||
type ConciergeFaqCategory,
|
type ConciergeFaqCategory,
|
||||||
type ConciergeFaqItem,
|
type ConciergeFaqItem,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export type PhotoWallAccessMode = 'open' | 'login';
|
||||||
export type PhotoWallModerationMode = 'auto' | 'approval';
|
export type PhotoWallModerationMode = 'auto' | 'approval';
|
||||||
export type PhotoWallPhotoStatus = 'pending' | 'approved' | 'removed';
|
export type PhotoWallPhotoStatus = 'pending' | 'approved' | 'removed';
|
||||||
export type PhotoWallPresentPhotoShape = 'rect' | 'rounded' | 'circle';
|
export type PhotoWallPresentPhotoShape = 'rect' | 'rounded' | 'circle';
|
||||||
|
export type PhotoWallMarqueeDirection = 'left' | 'right' | 'alternate';
|
||||||
|
|
||||||
export interface PhotoWallEventHeader {
|
export interface PhotoWallEventHeader {
|
||||||
showLogo?: boolean;
|
showLogo?: boolean;
|
||||||
|
|
@ -67,6 +68,8 @@ export interface PhotoWallConfig {
|
||||||
createParticipant: boolean;
|
createParticipant: boolean;
|
||||||
moderationMode: PhotoWallModerationMode;
|
moderationMode: PhotoWallModerationMode;
|
||||||
photosPerRow: number;
|
photosPerRow: number;
|
||||||
|
totalRows: number;
|
||||||
|
marqueeDirection: PhotoWallMarqueeDirection;
|
||||||
maxPhotoWidth: number;
|
maxPhotoWidth: number;
|
||||||
maxPhotoHeight: number;
|
maxPhotoHeight: number;
|
||||||
gapPx: number;
|
gapPx: number;
|
||||||
|
|
@ -130,6 +133,8 @@ export type PhotoWallUpdatePayload = Partial<{
|
||||||
createParticipant: boolean;
|
createParticipant: boolean;
|
||||||
moderationMode: PhotoWallModerationMode;
|
moderationMode: PhotoWallModerationMode;
|
||||||
photosPerRow: number;
|
photosPerRow: number;
|
||||||
|
totalRows: number;
|
||||||
|
marqueeDirection: PhotoWallMarqueeDirection;
|
||||||
maxPhotoWidth: number;
|
maxPhotoWidth: number;
|
||||||
maxPhotoHeight: number;
|
maxPhotoHeight: number;
|
||||||
gapPx: number;
|
gapPx: number;
|
||||||
|
|
|
||||||
|
|
@ -595,7 +595,7 @@ export interface FormField {
|
||||||
type?: string;
|
type?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
options?: string[];
|
options?: string[];
|
||||||
/** Dropdown only: include an Other option; submitted value is the custom text. */
|
/** Dropdown / Rank: include an Other option. */
|
||||||
allowOther?: boolean;
|
allowOther?: boolean;
|
||||||
otherLabel?: string;
|
otherLabel?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|
@ -860,11 +860,23 @@ export interface AttendanceAudienceSettings {
|
||||||
participantListIds?: string[];
|
participantListIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttendanceWhatsAppNotifySettings {
|
||||||
|
enabled?: boolean;
|
||||||
|
whatsappTemplateId?: string | null;
|
||||||
|
waHeaderImageUrl?: string;
|
||||||
|
waOverrides?: {
|
||||||
|
header?: Record<string, { kind: 'merge' | 'fixed'; key?: string; value?: string }>;
|
||||||
|
body?: Record<string, { kind: 'merge' | 'fixed'; key?: string; value?: string }>;
|
||||||
|
url?: Record<string, { kind: 'merge' | 'fixed'; key?: string; value?: string }>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AttendanceSettings {
|
export interface AttendanceSettings {
|
||||||
allowDuplicateCheckIn?: boolean;
|
allowDuplicateCheckIn?: boolean;
|
||||||
allowAssignBlank?: boolean;
|
allowAssignBlank?: boolean;
|
||||||
publicLinkEnabled?: boolean;
|
publicLinkEnabled?: boolean;
|
||||||
audience?: AttendanceAudienceSettings;
|
audience?: AttendanceAudienceSettings;
|
||||||
|
whatsappNotify?: AttendanceWhatsAppNotifySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttendanceRecord {
|
export interface AttendanceRecord {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ import type { FormField, PublicFormDefinition, PublicFormEventSummary } from "..
|
||||||
import type { FormEventHeaderDisplay } from "../app/utils/formEventHeaderDisplay";
|
import type { FormEventHeaderDisplay } from "../app/utils/formEventHeaderDisplay";
|
||||||
import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames";
|
import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames";
|
||||||
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
|
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
|
||||||
|
import { FormRankField } from "../app/components/forms/FormRankField";
|
||||||
|
import {
|
||||||
|
parseRankValue,
|
||||||
|
rankValueToOrderedLabels,
|
||||||
|
validateRankValue,
|
||||||
|
} from "../app/components/forms/rankFieldUtils";
|
||||||
import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields";
|
import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields";
|
||||||
import { COUNTRY_NAMES, DEFAULT_COUNTRY, statesForCountry } from "../app/data/countryStates";
|
import { COUNTRY_NAMES, DEFAULT_COUNTRY, statesForCountry } from "../app/data/countryStates";
|
||||||
import { RegistrationTicketStep } from "../components/forms/RegistrationTicketStep";
|
import { RegistrationTicketStep } from "../components/forms/RegistrationTicketStep";
|
||||||
|
|
@ -245,6 +251,14 @@ function fieldValueToData(
|
||||||
if (field.type === "checkboxes" && Array.isArray(value)) {
|
if (field.type === "checkboxes" && Array.isArray(value)) {
|
||||||
return { [k]: value };
|
return { [k]: value };
|
||||||
}
|
}
|
||||||
|
if (field.type === "rank") {
|
||||||
|
const ordered = rankValueToOrderedLabels(
|
||||||
|
parseRankValue(value),
|
||||||
|
Array.isArray(field.options) ? field.options.map(String) : [],
|
||||||
|
field.allowOther,
|
||||||
|
);
|
||||||
|
return { [k]: ordered };
|
||||||
|
}
|
||||||
if (field.type === "checkbox") {
|
if (field.type === "checkbox") {
|
||||||
return { [k]: value === true || value === "true" ? "Yes" : "" };
|
return { [k]: value === true || value === "true" ? "Yes" : "" };
|
||||||
}
|
}
|
||||||
|
|
@ -317,6 +331,23 @@ export function PublicFormView() {
|
||||||
for (const field of form.fields) {
|
for (const field of form.fields) {
|
||||||
if (field.type === "section") continue;
|
if (field.type === "section") continue;
|
||||||
const display = fieldDisplayLabel(field);
|
const display = fieldDisplayLabel(field);
|
||||||
|
|
||||||
|
if (field.type === "rank") {
|
||||||
|
const k = fieldDataKey(field);
|
||||||
|
if (!k) continue;
|
||||||
|
const err = validateRankValue(
|
||||||
|
parseRankValue(values[k]),
|
||||||
|
Array.isArray(field.options) ? field.options.map(String) : [],
|
||||||
|
{
|
||||||
|
required: !!field.required,
|
||||||
|
allowOther: field.allowOther,
|
||||||
|
displayLabel: display,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (err) return err;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!field.required) continue;
|
if (!field.required) continue;
|
||||||
|
|
||||||
if (isCountryStateField(field)) {
|
if (isCountryStateField(field)) {
|
||||||
|
|
@ -829,6 +860,25 @@ export function PublicFormView() {
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (field.type === "rank" && (field.options?.length ?? 0) > 0) {
|
||||||
|
return (
|
||||||
|
<div key={`field-${index}-${k}`}>
|
||||||
|
<div style={labelStyle}>
|
||||||
|
{display}
|
||||||
|
{req ? <span style={{ color: "var(--destructive, #dc2626)" }}> *</span> : null}
|
||||||
|
</div>
|
||||||
|
<FormRankField
|
||||||
|
options={field.options!.map(String)}
|
||||||
|
value={values[k]}
|
||||||
|
onChange={(v) => setField(k, v)}
|
||||||
|
allowOther={field.allowOther}
|
||||||
|
otherLabel={field.otherLabel}
|
||||||
|
required={!!req}
|
||||||
|
/>
|
||||||
|
<FieldRemarks text={field.remarks} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (isCountryStateField(field)) {
|
if (isCountryStateField(field)) {
|
||||||
const { country: countryKey, state: stateKey } = countryStateKeys(field);
|
const { country: countryKey, state: stateKey } = countryStateKeys(field);
|
||||||
const countryVal = String(values[countryKey] ?? "") || DEFAULT_COUNTRY;
|
const countryVal = String(values[countryKey] ?? "") || DEFAULT_COUNTRY;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ interface ImportMetaEnv {
|
||||||
readonly VITE_GOOGLE_CLIENT_ID?: string;
|
readonly VITE_GOOGLE_CLIENT_ID?: string;
|
||||||
/** Optional Windows installer URL for photographer photo uploader. */
|
/** Optional Windows installer URL for photographer photo uploader. */
|
||||||
readonly VITE_PHOTOGRAPHER_UPLOADER_URL?: string;
|
readonly VITE_PHOTOGRAPHER_UPLOADER_URL?: string;
|
||||||
|
readonly VITE_ENGAGEMENT_PUBLIC_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google Identity Services (loaded from accounts.google.com/gsi/client) */
|
/** Google Identity Services (loaded from accounts.google.com/gsi/client) */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue