diff --git a/.env.example b/.env.example index f0c162ea..ecf1af29 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ VITE_API_URL=http://localhost:3001/api # Production: https://join.eventbuzz.live — local eventbuzz Vite often :5174 # VITE_EVENTBUZZ_PUBLIC_URL=https://join.eventbuzz.live +# Engagement hub (unique per-participant WhatsApp links) +# Production: https://engage.eventbuzz.live — local EventBuzz_Engagement Vite :5175 +# VITE_ENGAGEMENT_PUBLIC_URL=http://localhost:5175 + # GrapesJS microsite builder (Vite app in /website_builder). Iframe src + postMessage target origin. VITE_WEBSITE_BUILDER_URL=http://localhost:5173 diff --git a/dist/index.html b/dist/index.html index eba30edb..7a0a77f0 100644 --- a/dist/index.html +++ b/dist/index.html @@ -12,7 +12,7 @@ rel="stylesheet" /> - + diff --git a/src/app/components/attendance/AttendancePage.tsx b/src/app/components/attendance/AttendancePage.tsx index 6565e71c..e84b186d 100644 --- a/src/app/components/attendance/AttendancePage.tsx +++ b/src/app/components/attendance/AttendancePage.tsx @@ -24,7 +24,9 @@ import type { AttendanceRecord, AttendanceSearchParticipant, AttendanceCheckInRow, + AttendanceWhatsAppNotifySettings, } from "../../../services/api/types"; +import { AttendanceWhatsAppNotifyFields } from "./AttendanceWhatsAppNotifyFields"; function publicAttendanceUrl(token: string) { return `${window.location.origin}/attendance/${token}`; @@ -92,8 +94,16 @@ interface FormState { includeStatuses: AttendanceMembershipStatus[]; includeTags: string[]; includeParticipantListIds: string[]; + whatsappNotify: AttendanceWhatsAppNotifySettings; } +const emptyWhatsappNotify: AttendanceWhatsAppNotifySettings = { + enabled: false, + whatsappTemplateId: null, + waHeaderImageUrl: "", + waOverrides: null, +}; + const emptyForm: FormState = { name: "", description: "", @@ -104,6 +114,7 @@ const emptyForm: FormState = { includeStatuses: ["confirmed", "checked_in"], includeTags: [], includeParticipantListIds: [], + whatsappNotify: { ...emptyWhatsappNotify }, }; export function AttendancePage() { @@ -221,6 +232,14 @@ export function AttendancePage() { : ["confirmed", "checked_in"], includeTags: audience?.tags || [], includeParticipantListIds: (audience?.participantListIds || []).map(String), + whatsappNotify: { + enabled: Boolean(s.settings?.whatsappNotify?.enabled), + whatsappTemplateId: s.settings?.whatsappNotify?.whatsappTemplateId + ? String(s.settings.whatsappNotify.whatsappTemplateId) + : null, + waHeaderImageUrl: s.settings?.whatsappNotify?.waHeaderImageUrl || "", + waOverrides: s.settings?.whatsappNotify?.waOverrides || null, + }, }); setPanelOpen(true); }; @@ -273,6 +292,10 @@ export function AttendancePage() { setError("Select at least one contact list for the attendance list."); return; } + if (form.whatsappNotify.enabled && !form.whatsappNotify.whatsappTemplateId) { + setError("Select a WhatsApp template for check-in notify, or turn the option off."); + return; + } setSaving(true); const payload = { name: form.name.trim(), @@ -288,6 +311,14 @@ export function AttendancePage() { participantListIds: form.audienceMode === "participant_lists" ? form.includeParticipantListIds : [], }, + whatsappNotify: form.whatsappNotify.enabled + ? { + enabled: true, + whatsappTemplateId: form.whatsappNotify.whatsappTemplateId || null, + waHeaderImageUrl: form.whatsappNotify.waHeaderImageUrl || "", + waOverrides: form.whatsappNotify.waOverrides || null, + } + : { enabled: false, whatsappTemplateId: null, waHeaderImageUrl: "", waOverrides: null }, }, }; const res = editingId @@ -946,6 +977,11 @@ export function AttendancePage() { /> Enable public check-in link + + setForm((f) => ({ ...f, whatsappNotify }))} + />
| undefined { + const out: Record = {}; + 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[]): 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 ( +
+
{title}
+ {rows.map((row, idx) => ( +
+ {`{{${idx + 1}}}`} + + {row.mode === "merge" ? ( + + ) : row.mode === "fixed" ? ( + { + const next = [...rows]; + next[idx] = { mode: "fixed", fixed: e.target.value }; + setRows(next); + }} + /> + ) : ( + + {defaultBindings[idx] ? `Uses ${defaultBindings[idx]}` : "Sample / binding from template"} + + )} +
+ ))} +
+ ); +} + +export function AttendanceWhatsAppNotifyFields({ + value, + onChange, +}: { + value: AttendanceWhatsAppNotifySettings; + onChange: (next: AttendanceWhatsAppNotifySettings) => void; +}) { + const [templates, setTemplates] = useState([]); + const [detail, setDetail] = useState(null); + const [headerRows, setHeaderRows] = useState([]); + const [bodyRows, setBodyRows] = useState([]); + const [urlRows, setUrlRows] = useState([]); + 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[])); + 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, + ) => { + const vb = detail?.variableBindings || { header: [], body: [], url: [] }; + const waOverrides: NonNullable = {}; + 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 ( +
+ +

+ Sent after the first successful check-in. Duplicate check-ins do not resend. +

+ + {value.enabled ? ( + <> + + + {showImage ? ( + + ) : null} + + {detail ? ( +
+ { + setHeaderRows(next); + emitOverrides(next, bodyRows, urlRows); + }} + /> + { + setBodyRows(next); + emitOverrides(headerRows, next, urlRows); + }} + /> + { + setUrlRows(next); + emitOverrides(headerRows, bodyRows, next); + }} + /> +
+ ) : null} + + ) : null} +
+ ); +} diff --git a/src/app/components/dashboard/EngagementManagePanel.tsx b/src/app/components/dashboard/EngagementManagePanel.tsx new file mode 100644 index 00000000..25401918 --- /dev/null +++ b/src/app/components/dashboard/EngagementManagePanel.tsx @@ -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("flow"); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [config, setConfig] = useState(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) => ( + + ); + + const placementField = ( + key: keyof typeof draft.features, + label: string, + options: { label: string; value: EngagementPlacement }[], + sibling: SiblingStatus, + ) => ( + + + ); + + const updateCustom = (index: number, patch: Partial) => { + setDraft((d) => ({ + ...d, + custom: d.custom.map((f, i) => (i === index ? { ...f, ...patch } : f)), + })); + }; + + return ( + void save()} + panelWidth="min(720px,100vw)" + > +
+ {tabBtn("flow", "Flow")} + {tabBtn("fields", "Profile fields")} + {tabBtn("links", "Links")} +
+ +
+ {error ? ( +

{error}

+ ) : null} + {loading ?

Loading…

: null} + + {!loading && tab === "flow" ? ( +
+ + {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)} + + + +
+ ) : null} + + {!loading && tab === "fields" ? ( +
+ + + + + + + {draft.custom.map((field, index) => ( +
+ + + + + {field.type === "select" ? ( + +