Add reception WhatsApp settings, chatbot event reassignment, and dashboard modules for AI avatar, social share, photo search, and visitor theme.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ab57e3f0a8
commit
57f73cb6c2
|
|
@ -0,0 +1,661 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Check, Copy, Link2, Trash2 } from "lucide-react";
|
||||||
|
import { PanelShell, Label, Field, Grid, IS, SS, focusIn, focusOut, HelperText, RadioGroup } from "../panels/PanelShell";
|
||||||
|
import {
|
||||||
|
aiAvatarApi,
|
||||||
|
uploadMediaFile,
|
||||||
|
type AiAvatarConfig,
|
||||||
|
type AiAvatarEventHeader,
|
||||||
|
type AiAvatarTemplate,
|
||||||
|
} from "../../../services/api";
|
||||||
|
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
|
||||||
|
import {
|
||||||
|
EVENT_HEADER_TOGGLE_LABELS,
|
||||||
|
eventHeaderFromApi,
|
||||||
|
eventHeaderToApi,
|
||||||
|
type EventHeaderToggleKey,
|
||||||
|
} from "../../utils/formEventHeaderDisplay";
|
||||||
|
|
||||||
|
type Tab = "settings" | "avatars" | "links";
|
||||||
|
|
||||||
|
const DEFAULT_HEADER = eventHeaderFromApi(null);
|
||||||
|
|
||||||
|
export function AiAvatarManagePanel({
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
canWrite,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
eventName?: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<Tab>("settings");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [config, setConfig] = useState<AiAvatarConfig | null>(null);
|
||||||
|
const [avatars, setAvatars] = useState<AiAvatarTemplate[]>([]);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [editingAvatarId, setEditingAvatarId] = useState<string | null>(null);
|
||||||
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
const [avatarDraft, setAvatarDraft] = useState({
|
||||||
|
name: "Avatar",
|
||||||
|
enabled: true,
|
||||||
|
imageUrl: "" as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
accessMode: "open" as "open" | "login",
|
||||||
|
allowDownload: true,
|
||||||
|
eventHeader: DEFAULT_HEADER,
|
||||||
|
sharePlatforms: {
|
||||||
|
linkedin: false,
|
||||||
|
facebook: false,
|
||||||
|
instagram: false,
|
||||||
|
snapchat: false,
|
||||||
|
x: false,
|
||||||
|
whatsapp: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await aiAvatarApi.get(eventId);
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setError(res.error || "Failed to load AI Avatar");
|
||||||
|
setConfig(null);
|
||||||
|
setAvatars([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = res.data;
|
||||||
|
setConfig(data);
|
||||||
|
setAvatars(data.avatars || []);
|
||||||
|
setDraft({
|
||||||
|
accessMode: data.accessMode || "open",
|
||||||
|
allowDownload: data.allowDownload !== false,
|
||||||
|
eventHeader: eventHeaderFromApi(data.eventHeader as AiAvatarEventHeader | null),
|
||||||
|
sharePlatforms: {
|
||||||
|
linkedin: Boolean(data.sharePlatforms?.linkedin),
|
||||||
|
facebook: Boolean(data.sharePlatforms?.facebook),
|
||||||
|
instagram: Boolean(data.sharePlatforms?.instagram),
|
||||||
|
snapchat: Boolean(data.sharePlatforms?.snapchat),
|
||||||
|
x: Boolean(data.sharePlatforms?.x),
|
||||||
|
whatsapp: Boolean(data.sharePlatforms?.whatsapp),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const openAvatarEditor = (avatar: AiAvatarTemplate) => {
|
||||||
|
setEditingAvatarId(avatar._id);
|
||||||
|
setAvatarDraft({
|
||||||
|
name: avatar.name || "Avatar",
|
||||||
|
enabled: avatar.enabled !== false,
|
||||||
|
imageUrl: avatar.imageUrl || "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
if (!canWrite) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await aiAvatarApi.update(eventId, {
|
||||||
|
accessMode: draft.accessMode,
|
||||||
|
allowDownload: draft.allowDownload,
|
||||||
|
sharePlatforms: draft.sharePlatforms,
|
||||||
|
eventHeader: eventHeaderToApi(draft.eventHeader),
|
||||||
|
});
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setError(res.error || "Failed to save");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfig(res.data);
|
||||||
|
setAvatars(res.data.avatars || []);
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAvatar = async () => {
|
||||||
|
if (!canWrite || !editingAvatarId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await aiAvatarApi.updateAvatar(eventId, editingAvatarId, {
|
||||||
|
name: avatarDraft.name,
|
||||||
|
enabled: avatarDraft.enabled,
|
||||||
|
imageUrl: avatarDraft.imageUrl || null,
|
||||||
|
});
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setError(res.error || "Failed to save avatar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAvatars((prev) => prev.map((a) => (a._id === res.data!._id ? res.data! : a)));
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to save avatar");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAvatar = async () => {
|
||||||
|
if (!canWrite) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await aiAvatarApi.createAvatar(eventId, {
|
||||||
|
name: `Avatar ${avatars.length + 1}`,
|
||||||
|
});
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setError(res.error || "Failed to create avatar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAvatars((prev) => [...prev, res.data!]);
|
||||||
|
openAvatarEditor(res.data);
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to create avatar");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAvatar = async (avatarId: string) => {
|
||||||
|
if (!canWrite) return;
|
||||||
|
if (!window.confirm("Delete this avatar?")) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await aiAvatarApi.deleteAvatar(eventId, avatarId);
|
||||||
|
setAvatars((prev) => prev.filter((a) => a._id !== avatarId));
|
||||||
|
if (editingAvatarId === avatarId) setEditingAvatarId(null);
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to delete");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadAvatarImage = async (file: File | null) => {
|
||||||
|
if (!file || !canWrite) return;
|
||||||
|
setUploadingImage(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await uploadMediaFile(file, { eventId, module: "ai-avatar/avatars" });
|
||||||
|
const url = r.url || "";
|
||||||
|
if (!url) throw new Error("Upload succeeded but no URL returned");
|
||||||
|
setAvatarDraft((d) => ({ ...d, imageUrl: url }));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Avatar upload failed");
|
||||||
|
} finally {
|
||||||
|
setUploadingImage(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
if (!config?.uploadToken) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(aiAvatarVisitorUrl(config.uploadToken));
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabBtn = (id: Tab, label: string) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTab(id);
|
||||||
|
if (id !== "avatars") setEditingAvatarId(null);
|
||||||
|
}}
|
||||||
|
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 ctaLabel =
|
||||||
|
tab === "avatars" && editingAvatarId ? "Save avatar" : tab === "settings" ? "Save settings" : "Save";
|
||||||
|
const showCta = tab === "settings" || (tab === "avatars" && Boolean(editingAvatarId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelShell
|
||||||
|
onClose={onClose}
|
||||||
|
title="AI Avatar"
|
||||||
|
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home"}
|
||||||
|
description="Configure avatars and visitor AI swap link"
|
||||||
|
completedCount={0}
|
||||||
|
totalSections={0}
|
||||||
|
ctaLabel={ctaLabel}
|
||||||
|
showProgress={false}
|
||||||
|
hidePrimaryCta={!showCta}
|
||||||
|
ctaDisabled={!canWrite || saving || loading}
|
||||||
|
onCtaClick={() => {
|
||||||
|
if (tab === "avatars" && editingAvatarId) void saveAvatar();
|
||||||
|
else void saveSettings();
|
||||||
|
}}
|
||||||
|
panelWidth="min(720px,100vw)"
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", borderBottom: "1px solid var(--border)", padding: "0 12px" }}>
|
||||||
|
{tabBtn("settings", "Settings")}
|
||||||
|
{tabBtn("avatars", "Avatars")}
|
||||||
|
{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 === "links" && config ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-card)",
|
||||||
|
padding: "12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<Link2 size={16} color="var(--primary)" />
|
||||||
|
<strong style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)" }}>
|
||||||
|
Visitor upload URL
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||||
|
Guests pick an avatar, upload a selfie, then download or share
|
||||||
|
</p>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
background: "var(--muted-bg, #f5f5f5)",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.uploadToken
|
||||||
|
? aiAvatarVisitorUrl(config.uploadToken)
|
||||||
|
: "Enable AI Avatar to generate link"}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!config.uploadToken}
|
||||||
|
onClick={() => void copyLink()}
|
||||||
|
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: config.uploadToken ? "pointer" : "not-allowed",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
opacity: config.uploadToken ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void aiAvatarApi.update(eventId, { regenerateTokens: true }).then((res) => {
|
||||||
|
if (res.data) {
|
||||||
|
setConfig(res.data);
|
||||||
|
onChanged?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Regenerate token
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && tab === "settings" ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||||
|
<Field>
|
||||||
|
<Label text="Access mode" />
|
||||||
|
<select
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={draft.accessMode}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, accessMode: e.target.value as "open" | "login" }))}
|
||||||
|
style={SS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
|
<option value="open">Open (anyone with link)</option>
|
||||||
|
<option value="login">Login required</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Event header on visitor page" />
|
||||||
|
<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={`aa-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>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Allow visitors to download their photo" />
|
||||||
|
<RadioGroup
|
||||||
|
name="aa-allow-download"
|
||||||
|
value={draft.allowDownload ? "Yes" : "No"}
|
||||||
|
onChange={(v) => setDraft((d) => ({ ...d, allowDownload: v === "Yes" }))}
|
||||||
|
options={[
|
||||||
|
{ label: "Yes", value: "Yes" },
|
||||||
|
{ label: "No", value: "No" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Share on social platforms" />
|
||||||
|
<HelperText text="Shown after upload when at least one platform is enabled." />
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["linkedin", "LinkedIn"],
|
||||||
|
["facebook", "Facebook"],
|
||||||
|
["instagram", "Instagram"],
|
||||||
|
["snapchat", "Snapchat"],
|
||||||
|
["x", "X (Twitter)"],
|
||||||
|
["whatsapp", "WhatsApp"],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!canWrite}
|
||||||
|
checked={draft.sharePlatforms[key]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
sharePlatforms: { ...d.sharePlatforms, [key]: e.target.checked },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && tab === "avatars" ? (
|
||||||
|
editingAvatarId ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingAvatarId(null)}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--primary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back to avatars
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Avatar name" />
|
||||||
|
<input
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={avatarDraft.name}
|
||||||
|
onChange={(e) => setAvatarDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
|
style={IS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Enabled for visitors" />
|
||||||
|
<RadioGroup
|
||||||
|
name="aa-avatar-enabled"
|
||||||
|
value={avatarDraft.enabled ? "Yes" : "No"}
|
||||||
|
onChange={(v) => setAvatarDraft((d) => ({ ...d, enabled: v === "Yes" }))}
|
||||||
|
options={[
|
||||||
|
{ label: "Yes", value: "Yes" },
|
||||||
|
{ label: "No", value: "No" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Avatar image" />
|
||||||
|
<HelperText text="Upload the template image used for AI head swap (face from selfie is placed onto this image)." />
|
||||||
|
{avatarDraft.imageUrl ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "var(--muted-bg, #f5f5f5)",
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={avatarDraft.imageUrl}
|
||||||
|
alt={avatarDraft.name}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
disabled={!canWrite || uploadingImage}
|
||||||
|
onChange={(e) => void uploadAvatarImage(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
{uploadingImage ? (
|
||||||
|
<p style={{ margin: "6px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||||
|
Uploading…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
||||||
|
{avatars.length} avatar{avatars.length === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
{canWrite ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void addAvatar()}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--primary)",
|
||||||
|
color: "var(--primary-foreground, #fff)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add avatar
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!avatars.length ? (
|
||||||
|
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--muted)" }}>
|
||||||
|
No avatars yet. Add an image template for visitors to choose.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{avatars.map((avatar) => (
|
||||||
|
<div
|
||||||
|
key={avatar._id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
padding: 10,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-card)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "var(--muted-bg, #f5f5f5)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatar.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatar.imageUrl}
|
||||||
|
alt={avatar.name}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatar.name}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||||
|
{avatar.enabled !== false ? "Enabled" : "Disabled"}
|
||||||
|
{!avatar.imageUrl ? " · No image" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openAvatarEditor(avatar)}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{canWrite ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Delete"
|
||||||
|
onClick={() => void deleteAvatar(avatar._id)}
|
||||||
|
style={{
|
||||||
|
padding: 6,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
color: "var(--destructive)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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, Plus, Share2, ShieldCheck, Store, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
import { BarChart2, CalendarDays, Check, FileText, Globe, Image, Images, Link2, Mail, Palette, Plus, Share2, ShieldCheck, Sparkles, Store, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||||
import { 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";
|
||||||
|
|
@ -12,6 +12,7 @@ import { eventTicketsApi } from "../../../services/api/eventTickets";
|
||||||
import { campaignsApi } from "../../../services/api/campaigns";
|
import { campaignsApi } from "../../../services/api/campaigns";
|
||||||
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
|
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
|
||||||
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
|
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
|
||||||
|
import { VisitorThemeSettingsPanel } from "../panels/VisitorThemeSettingsPanel";
|
||||||
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
||||||
import { CreateEmailCampaignPanel } from "../panels/CreateEmailCampaignPanel";
|
import { CreateEmailCampaignPanel } from "../panels/CreateEmailCampaignPanel";
|
||||||
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
||||||
|
|
@ -20,9 +21,17 @@ import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
||||||
import { EventBadgesPanel } from "./EventBadgesPanel";
|
import { EventBadgesPanel } from "./EventBadgesPanel";
|
||||||
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||||
import { PhotoWallManagePanel } from "./PhotoWallManagePanel";
|
import { PhotoWallManagePanel } from "./PhotoWallManagePanel";
|
||||||
|
import { SocialShareManagePanel } from "./SocialShareManagePanel";
|
||||||
|
import { AiAvatarManagePanel } from "./AiAvatarManagePanel";
|
||||||
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 { socialShareApi } from "../../../services/api/socialShare";
|
||||||
|
import { aiAvatarApi } from "../../../services/api/aiAvatar";
|
||||||
import { photoWallUploadUrl, photoWallPresentUrl } from "../../utils/photoWallUrls";
|
import { photoWallUploadUrl, photoWallPresentUrl } from "../../utils/photoWallUrls";
|
||||||
|
import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls";
|
||||||
|
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
|
||||||
|
import { aiAvatarVisitorUrl } from "../../utils/aiAvatarUrls";
|
||||||
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
import { EventEmailCampaignsPanel } from "./EventEmailCampaignsPanel";
|
||||||
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
import { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
||||||
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
||||||
|
|
@ -36,6 +45,7 @@ import {
|
||||||
formsApi,
|
formsApi,
|
||||||
staticMicrositeApi,
|
staticMicrositeApi,
|
||||||
staticMicrositePublicUrl,
|
staticMicrositePublicUrl,
|
||||||
|
visitorThemeApi,
|
||||||
exhibitorsApi,
|
exhibitorsApi,
|
||||||
type ApiExhibitor,
|
type ApiExhibitor,
|
||||||
} from "../../../services/api";
|
} from "../../../services/api";
|
||||||
|
|
@ -236,6 +246,10 @@ export function EventHomeModules() {
|
||||||
const canReadDocuments = canModule("content_management", "read");
|
const canReadDocuments = canModule("content_management", "read");
|
||||||
const canReadPhotoWall = canModule("photo_wall", "read");
|
const canReadPhotoWall = canModule("photo_wall", "read");
|
||||||
const canWritePhotoWall = canModule("photo_wall", "write");
|
const canWritePhotoWall = canModule("photo_wall", "write");
|
||||||
|
const canReadSocialShare = canModule("social_share", "read");
|
||||||
|
const canWriteSocialShare = canModule("social_share", "write");
|
||||||
|
const canReadAiAvatar = canModule("ai_avatar", "read");
|
||||||
|
const canWriteAiAvatar = canModule("ai_avatar", "write");
|
||||||
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
const canReadEmailCampaigns = canModule("email_outreach_campaigns", "read");
|
||||||
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
const canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
||||||
const canReadExhibitors = canModule("exhibitors", "read");
|
const canReadExhibitors = canModule("exhibitors", "read");
|
||||||
|
|
@ -275,15 +289,21 @@ export function EventHomeModules() {
|
||||||
const [staticMicrositeOpen, setStaticMicrositeOpen] = useState(false);
|
const [staticMicrositeOpen, setStaticMicrositeOpen] = useState(false);
|
||||||
const [staticMicrositeMeta, setStaticMicrositeMeta] = useState<string | null>(null);
|
const [staticMicrositeMeta, setStaticMicrositeMeta] = useState<string | null>(null);
|
||||||
const [staticMicrositeLoading, setStaticMicrositeLoading] = useState(false);
|
const [staticMicrositeLoading, setStaticMicrositeLoading] = useState(false);
|
||||||
|
const [visitorThemeOpen, setVisitorThemeOpen] = useState(false);
|
||||||
|
const [visitorThemeMeta, setVisitorThemeMeta] = useState<string | null>(null);
|
||||||
|
const [visitorThemeLoading, setVisitorThemeLoading] = useState(false);
|
||||||
const [agendaCounts, setAgendaCounts] = useState({ tracks: 0, sessions: 0 });
|
const [agendaCounts, setAgendaCounts] = useState({ tracks: 0, sessions: 0 });
|
||||||
const [agendaLoading, setAgendaLoading] = useState(false);
|
const [agendaLoading, setAgendaLoading] = useState(false);
|
||||||
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
||||||
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||||
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
||||||
const [pictureWallEnabled, setPictureWallEnabled] = useState(false);
|
|
||||||
const [pictureWallLoading, setPictureWallLoading] = useState(false);
|
|
||||||
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
const [showDocumentsPanel, setShowDocumentsPanel] = useState(false);
|
||||||
const [showEventPhotoSearchPanel, setShowEventPhotoSearchPanel] = useState(false);
|
const [showEventPhotoSearchPanel, setShowEventPhotoSearchPanel] = useState(false);
|
||||||
|
const [photoSearchEnabled, setPhotoSearchEnabled] = useState(false);
|
||||||
|
const [photoSearchLoading, setPhotoSearchLoading] = useState(false);
|
||||||
|
const [photoSearchCount, setPhotoSearchCount] = useState(0);
|
||||||
|
const [photoSearchToken, setPhotoSearchToken] = useState<string | null>(null);
|
||||||
|
const [copiedPhotoSearchLink, setCopiedPhotoSearchLink] = useState(false);
|
||||||
const [showPhotoWallPanel, setShowPhotoWallPanel] = useState(false);
|
const [showPhotoWallPanel, setShowPhotoWallPanel] = useState(false);
|
||||||
const [photoWallEnabled, setPhotoWallEnabled] = useState(false);
|
const [photoWallEnabled, setPhotoWallEnabled] = useState(false);
|
||||||
const [photoWallLoading, setPhotoWallLoading] = useState(false);
|
const [photoWallLoading, setPhotoWallLoading] = useState(false);
|
||||||
|
|
@ -291,6 +311,18 @@ export function EventHomeModules() {
|
||||||
const [photoWallUploadToken, setPhotoWallUploadToken] = useState<string | null>(null);
|
const [photoWallUploadToken, setPhotoWallUploadToken] = useState<string | null>(null);
|
||||||
const [photoWallPresentToken, setPhotoWallPresentToken] = useState<string | null>(null);
|
const [photoWallPresentToken, setPhotoWallPresentToken] = useState<string | null>(null);
|
||||||
const [copiedPhotoWallLink, setCopiedPhotoWallLink] = useState<"upload" | "present" | null>(null);
|
const [copiedPhotoWallLink, setCopiedPhotoWallLink] = useState<"upload" | "present" | null>(null);
|
||||||
|
const [showSocialSharePanel, setShowSocialSharePanel] = useState(false);
|
||||||
|
const [socialShareEnabled, setSocialShareEnabled] = useState(false);
|
||||||
|
const [socialShareLoading, setSocialShareLoading] = useState(false);
|
||||||
|
const [socialShareFrameCount, setSocialShareFrameCount] = useState(0);
|
||||||
|
const [socialShareToken, setSocialShareToken] = useState<string | null>(null);
|
||||||
|
const [copiedSocialShareLink, setCopiedSocialShareLink] = useState(false);
|
||||||
|
const [showAiAvatarPanel, setShowAiAvatarPanel] = useState(false);
|
||||||
|
const [aiAvatarEnabled, setAiAvatarEnabled] = useState(false);
|
||||||
|
const [aiAvatarLoading, setAiAvatarLoading] = useState(false);
|
||||||
|
const [aiAvatarCount, setAiAvatarCount] = useState(0);
|
||||||
|
const [aiAvatarToken, setAiAvatarToken] = useState<string | null>(null);
|
||||||
|
const [copiedAiAvatarLink, setCopiedAiAvatarLink] = 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);
|
||||||
|
|
@ -328,7 +360,6 @@ export function EventHomeModules() {
|
||||||
setEventName(res.data?.name ?? null);
|
setEventName(res.data?.name ?? null);
|
||||||
setEventSettings((res.data?.settings as Record<string, unknown> | undefined) ?? {});
|
setEventSettings((res.data?.settings as Record<string, unknown> | undefined) ?? {});
|
||||||
setWebCheckInEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
|
setWebCheckInEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
|
||||||
setPictureWallEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.pictureWallEnabled));
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|
@ -497,6 +528,30 @@ export function EventHomeModules() {
|
||||||
void loadStaticMicrositeMeta();
|
void loadStaticMicrositeMeta();
|
||||||
}, [loadStaticMicrositeMeta]);
|
}, [loadStaticMicrositeMeta]);
|
||||||
|
|
||||||
|
const loadVisitorThemeMeta = useCallback(async () => {
|
||||||
|
if (!selectedEventId) {
|
||||||
|
setVisitorThemeMeta(null);
|
||||||
|
setVisitorThemeLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVisitorThemeLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await visitorThemeApi.get(selectedEventId);
|
||||||
|
const mode = res.data?.theme;
|
||||||
|
if (mode === "gradient") setVisitorThemeMeta("Gradient");
|
||||||
|
else if (mode === "background") setVisitorThemeMeta("Background");
|
||||||
|
else setVisitorThemeMeta("Simple");
|
||||||
|
} catch {
|
||||||
|
setVisitorThemeMeta("Simple");
|
||||||
|
} finally {
|
||||||
|
setVisitorThemeLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedEventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadVisitorThemeMeta();
|
||||||
|
}, [loadVisitorThemeMeta]);
|
||||||
|
|
||||||
const loadAgendaMeta = useCallback(async () => {
|
const loadAgendaMeta = useCallback(async () => {
|
||||||
if (!selectedEventId) {
|
if (!selectedEventId) {
|
||||||
setAgendaCounts({ tracks: 0, sessions: 0 });
|
setAgendaCounts({ tracks: 0, sessions: 0 });
|
||||||
|
|
@ -597,6 +652,90 @@ export function EventHomeModules() {
|
||||||
void loadPhotoWallMeta();
|
void loadPhotoWallMeta();
|
||||||
}, [loadPhotoWallMeta]);
|
}, [loadPhotoWallMeta]);
|
||||||
|
|
||||||
|
const loadSocialShareMeta = useCallback(async () => {
|
||||||
|
if (!selectedEventId || !canReadSocialShare) {
|
||||||
|
setSocialShareEnabled(false);
|
||||||
|
setSocialShareFrameCount(0);
|
||||||
|
setSocialShareToken(null);
|
||||||
|
setSocialShareLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSocialShareLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await socialShareApi.get(selectedEventId);
|
||||||
|
const data = res.data;
|
||||||
|
setSocialShareEnabled(Boolean(data?.enabled));
|
||||||
|
setSocialShareFrameCount(data?.frameCount ?? data?.frames?.length ?? 0);
|
||||||
|
setSocialShareToken(data?.uploadToken ?? null);
|
||||||
|
} catch {
|
||||||
|
setSocialShareEnabled(false);
|
||||||
|
setSocialShareFrameCount(0);
|
||||||
|
setSocialShareToken(null);
|
||||||
|
} finally {
|
||||||
|
setSocialShareLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedEventId, canReadSocialShare]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSocialShareMeta();
|
||||||
|
}, [loadSocialShareMeta]);
|
||||||
|
|
||||||
|
const loadAiAvatarMeta = useCallback(async () => {
|
||||||
|
if (!selectedEventId || !canReadAiAvatar) {
|
||||||
|
setAiAvatarEnabled(false);
|
||||||
|
setAiAvatarCount(0);
|
||||||
|
setAiAvatarToken(null);
|
||||||
|
setAiAvatarLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAiAvatarLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await aiAvatarApi.get(selectedEventId);
|
||||||
|
const data = res.data;
|
||||||
|
setAiAvatarEnabled(Boolean(data?.enabled));
|
||||||
|
setAiAvatarCount(data?.avatarCount ?? data?.avatars?.length ?? 0);
|
||||||
|
setAiAvatarToken(data?.uploadToken ?? null);
|
||||||
|
} catch {
|
||||||
|
setAiAvatarEnabled(false);
|
||||||
|
setAiAvatarCount(0);
|
||||||
|
setAiAvatarToken(null);
|
||||||
|
} finally {
|
||||||
|
setAiAvatarLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedEventId, canReadAiAvatar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAiAvatarMeta();
|
||||||
|
}, [loadAiAvatarMeta]);
|
||||||
|
|
||||||
|
const loadPhotoSearchMeta = useCallback(async () => {
|
||||||
|
if (!selectedEventId) {
|
||||||
|
setPhotoSearchEnabled(false);
|
||||||
|
setPhotoSearchCount(0);
|
||||||
|
setPhotoSearchToken(null);
|
||||||
|
setPhotoSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhotoSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await eventPhotoSearchApi.get(selectedEventId);
|
||||||
|
const data = res.data;
|
||||||
|
setPhotoSearchEnabled(Boolean(data?.enabled));
|
||||||
|
setPhotoSearchCount(data?.photoCount ?? 0);
|
||||||
|
setPhotoSearchToken(data?.searchToken ?? null);
|
||||||
|
} catch {
|
||||||
|
setPhotoSearchEnabled(false);
|
||||||
|
setPhotoSearchCount(0);
|
||||||
|
setPhotoSearchToken(null);
|
||||||
|
} finally {
|
||||||
|
setPhotoSearchLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedEventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPhotoSearchMeta();
|
||||||
|
}, [loadPhotoSearchMeta]);
|
||||||
|
|
||||||
const handleBadgeSuccess = () => {
|
const handleBadgeSuccess = () => {
|
||||||
setShowAddBadge(false);
|
setShowAddBadge(false);
|
||||||
setDuplicateBadgeBatchId(null);
|
setDuplicateBadgeBatchId(null);
|
||||||
|
|
@ -664,6 +803,11 @@ export function EventHomeModules() {
|
||||||
: rewardRulesCount === 0
|
: rewardRulesCount === 0
|
||||||
? "No rules configured"
|
? "No rules configured"
|
||||||
: `${rewardRulesCount} rule${rewardRulesCount === 1 ? "" : "s"} configured`;
|
: `${rewardRulesCount} rule${rewardRulesCount === 1 ? "" : "s"} configured`;
|
||||||
|
const visitorThemeCardMeta = !selectedEventId
|
||||||
|
? "Select an event to manage visitor theme"
|
||||||
|
: visitorThemeLoading
|
||||||
|
? "Loading…"
|
||||||
|
: visitorThemeMeta || "Simple";
|
||||||
const micrositeMeta = !selectedEventId
|
const micrositeMeta = !selectedEventId
|
||||||
? "Select an event to manage microsite"
|
? "Select an event to manage microsite"
|
||||||
: staticMicrositeLoading
|
: staticMicrositeLoading
|
||||||
|
|
@ -691,13 +835,6 @@ export function EventHomeModules() {
|
||||||
: webCheckInEnabled
|
: webCheckInEnabled
|
||||||
? "Enabled"
|
? "Enabled"
|
||||||
: "Disabled";
|
: "Disabled";
|
||||||
const pictureWallMeta = !selectedEventId
|
|
||||||
? "Select an event to configure picture wall"
|
|
||||||
: pictureWallLoading
|
|
||||||
? "Saving…"
|
|
||||||
: pictureWallEnabled
|
|
||||||
? "Enabled"
|
|
||||||
: "Disabled";
|
|
||||||
const photoWallMeta = !selectedEventId
|
const photoWallMeta = !selectedEventId
|
||||||
? "Select an event to configure photo wall"
|
? "Select an event to configure photo wall"
|
||||||
: photoWallLoading
|
: photoWallLoading
|
||||||
|
|
@ -705,6 +842,27 @@ export function EventHomeModules() {
|
||||||
: photoWallEnabled
|
: photoWallEnabled
|
||||||
? `Enabled · ${photoWallCount} photo${photoWallCount === 1 ? "" : "s"}`
|
? `Enabled · ${photoWallCount} photo${photoWallCount === 1 ? "" : "s"}`
|
||||||
: "Disabled";
|
: "Disabled";
|
||||||
|
const socialShareMeta = !selectedEventId
|
||||||
|
? "Select an event to configure social share"
|
||||||
|
: socialShareLoading
|
||||||
|
? "Loading…"
|
||||||
|
: socialShareEnabled
|
||||||
|
? `Enabled · ${socialShareFrameCount} frame${socialShareFrameCount === 1 ? "" : "s"}`
|
||||||
|
: "Disabled";
|
||||||
|
const aiAvatarMeta = !selectedEventId
|
||||||
|
? "Select an event to configure AI avatar"
|
||||||
|
: aiAvatarLoading
|
||||||
|
? "Loading…"
|
||||||
|
: aiAvatarEnabled
|
||||||
|
? `Enabled · ${aiAvatarCount} avatar${aiAvatarCount === 1 ? "" : "s"}`
|
||||||
|
: "Disabled";
|
||||||
|
const photoSearchMeta = !selectedEventId
|
||||||
|
? "Select an event to configure photo search"
|
||||||
|
: photoSearchLoading
|
||||||
|
? "Loading…"
|
||||||
|
: photoSearchEnabled
|
||||||
|
? `Enabled · ${photoSearchCount} photo${photoSearchCount === 1 ? "" : "s"}`
|
||||||
|
: "Disabled";
|
||||||
|
|
||||||
const handleWebCheckInToggle = async (enabled: boolean) => {
|
const handleWebCheckInToggle = async (enabled: boolean) => {
|
||||||
if (!selectedEventId) return;
|
if (!selectedEventId) return;
|
||||||
|
|
@ -721,21 +879,6 @@ export function EventHomeModules() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePictureWallToggle = async (enabled: boolean) => {
|
|
||||||
if (!selectedEventId) return;
|
|
||||||
setPictureWallLoading(true);
|
|
||||||
try {
|
|
||||||
const nextSettings = { ...(eventSettings || {}), pictureWallEnabled: enabled };
|
|
||||||
const res = await eventsApi.update(selectedEventId, { settings: nextSettings });
|
|
||||||
if (!res.error) {
|
|
||||||
setEventSettings(nextSettings);
|
|
||||||
setPictureWallEnabled(enabled);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setPictureWallLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePhotoWallToggle = async (enabled: boolean) => {
|
const handlePhotoWallToggle = async (enabled: boolean) => {
|
||||||
if (!selectedEventId || !canWritePhotoWall) return;
|
if (!selectedEventId || !canWritePhotoWall) return;
|
||||||
setPhotoWallLoading(true);
|
setPhotoWallLoading(true);
|
||||||
|
|
@ -752,6 +895,85 @@ export function EventHomeModules() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSocialShareToggle = async (enabled: boolean) => {
|
||||||
|
if (!selectedEventId || !canWriteSocialShare) return;
|
||||||
|
setSocialShareLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await socialShareApi.update(selectedEventId, { enabled });
|
||||||
|
if (!res.error && res.data) {
|
||||||
|
setSocialShareEnabled(Boolean(res.data.enabled));
|
||||||
|
setSocialShareFrameCount(res.data.frameCount ?? res.data.frames?.length ?? 0);
|
||||||
|
setSocialShareToken(res.data.uploadToken ?? null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSocialShareLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copySocialShareLink = async () => {
|
||||||
|
if (!socialShareToken) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(socialShareVisitorUrl(socialShareToken));
|
||||||
|
setCopiedSocialShareLink(true);
|
||||||
|
window.setTimeout(() => setCopiedSocialShareLink(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAiAvatarToggle = async (enabled: boolean) => {
|
||||||
|
if (!selectedEventId || !canWriteAiAvatar) return;
|
||||||
|
setAiAvatarLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await aiAvatarApi.update(selectedEventId, { enabled });
|
||||||
|
if (!res.error && res.data) {
|
||||||
|
setAiAvatarEnabled(Boolean(res.data.enabled));
|
||||||
|
setAiAvatarCount(res.data.avatarCount ?? res.data.avatars?.length ?? 0);
|
||||||
|
setAiAvatarToken(res.data.uploadToken ?? null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setAiAvatarLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAiAvatarLink = async () => {
|
||||||
|
if (!aiAvatarToken) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(aiAvatarVisitorUrl(aiAvatarToken));
|
||||||
|
setCopiedAiAvatarLink(true);
|
||||||
|
window.setTimeout(() => setCopiedAiAvatarLink(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhotoSearchToggle = async (enabled: boolean) => {
|
||||||
|
if (!selectedEventId) return;
|
||||||
|
setPhotoSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await eventPhotoSearchApi.update(selectedEventId, { enabled });
|
||||||
|
if (!res.error && res.data) {
|
||||||
|
setPhotoSearchEnabled(Boolean(res.data.enabled));
|
||||||
|
setPhotoSearchCount(res.data.photoCount ?? 0);
|
||||||
|
setPhotoSearchToken(res.data.searchToken ?? null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPhotoSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyPhotoSearchLink = async () => {
|
||||||
|
if (!photoSearchToken) return;
|
||||||
|
const url = photoSearchVisitorUrl(photoSearchToken);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopiedPhotoSearchLink(true);
|
||||||
|
window.setTimeout(() => setCopiedPhotoSearchLink(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const copyPhotoWallLink = async (kind: "upload" | "present") => {
|
const copyPhotoWallLink = async (kind: "upload" | "present") => {
|
||||||
const url =
|
const url =
|
||||||
kind === "upload" && photoWallUploadToken
|
kind === "upload" && photoWallUploadToken
|
||||||
|
|
@ -1159,6 +1381,26 @@ export function EventHomeModules() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ModuleCard
|
||||||
|
icon={<Palette size={18} />}
|
||||||
|
title="Visitor Theme"
|
||||||
|
description="Colors and background for visitor event, session, quiz, and photo pages"
|
||||||
|
meta={visitorThemeCardMeta}
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(true),
|
||||||
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setVisitorThemeOpen(true)}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{canReadMicrosite ? (
|
{canReadMicrosite ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Globe size={18} />}
|
icon={<Globe size={18} />}
|
||||||
|
|
@ -1267,108 +1509,57 @@ export function EventHomeModules() {
|
||||||
) : null}
|
) : null}
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Image size={18} />}
|
icon={<Image size={18} />}
|
||||||
title="Picture Wall"
|
title="Event Photo Search"
|
||||||
meta={pictureWallMeta}
|
meta={photoSearchMeta}
|
||||||
description="Enable a public gallery where guests can add and view event photos"
|
description="Upload event photos and share visitor Find Photos"
|
||||||
headerAction={
|
headerAction={
|
||||||
pictureWallEnabled ? (
|
photoSearchEnabled && photoSearchToken ? (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 }}>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Copy public picture wall link"
|
title="Copy visitor search link"
|
||||||
|
aria-label="Copy visitor search link"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled}
|
||||||
style={{
|
style={photoSearchHeaderButtonStyle}
|
||||||
...formLinkCopyButtonStyle(false),
|
onClick={() => void copyPhotoSearchLink()}
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!selectedEventId) return;
|
|
||||||
const url = `https://web.eventbuzz.live/event/${selectedEventId}/picturewall/upload`;
|
|
||||||
void navigator.clipboard.writeText(url);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Link2 size={12} />
|
{copiedPhotoSearchLink ? <Check size={12} /> : <Link2 size={12} />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="Share picture wall"
|
|
||||||
disabled={actionsDisabled}
|
|
||||||
style={{
|
|
||||||
...formHeaderIconButtonStyle(),
|
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!selectedEventId) return;
|
|
||||||
const url = `https://web.eventbuzz.live/event/${selectedEventId}/picturewall`;
|
|
||||||
if (navigator.share) {
|
|
||||||
void navigator.share({ title: 'Event picture wall', url });
|
|
||||||
} else {
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Share2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
pictureWallEnabled ? (
|
<>
|
||||||
|
{photoSearchEnabled ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled || pictureWallLoading}
|
disabled={actionsDisabled || photoSearchLoading}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(),
|
...moduleButtonStyle(),
|
||||||
opacity: actionsDisabled || pictureWallLoading ? 0.55 : 1,
|
opacity: actionsDisabled || photoSearchLoading ? 0.55 : 1,
|
||||||
cursor: actionsDisabled || pictureWallLoading ? "not-allowed" : "pointer",
|
cursor: actionsDisabled || photoSearchLoading ? "not-allowed" : "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() => void handlePictureWallToggle(false)}
|
onClick={() => void handlePhotoSearchToggle(false)}
|
||||||
>
|
>
|
||||||
Disable
|
Disable
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled || pictureWallLoading}
|
disabled={actionsDisabled || photoSearchLoading}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(true),
|
...moduleButtonStyle(true),
|
||||||
opacity: actionsDisabled || pictureWallLoading ? 0.55 : 1,
|
opacity: actionsDisabled || photoSearchLoading ? 0.55 : 1,
|
||||||
cursor: actionsDisabled || pictureWallLoading ? "not-allowed" : "pointer",
|
cursor: actionsDisabled || photoSearchLoading ? "not-allowed" : "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() => void handlePictureWallToggle(true)}
|
onClick={() => void handlePhotoSearchToggle(true)}
|
||||||
>
|
>
|
||||||
Enable
|
Enable
|
||||||
</button>
|
</button>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ModuleCard
|
|
||||||
icon={<Image size={18} />}
|
|
||||||
title="Event Photo Search"
|
|
||||||
description="Upload event photos and search by image"
|
|
||||||
headerAction={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="Open photo search page"
|
|
||||||
aria-label="Open photo search page"
|
|
||||||
disabled={actionsDisabled}
|
|
||||||
style={photoSearchHeaderButtonStyle}
|
|
||||||
onClick={() => {
|
|
||||||
if (!selectedEventId) return;
|
|
||||||
window.location.href = `https://web.eventbuzz.live/${selectedEventId}/searchphoto`;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Link2 size={12} />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
actions={
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(true),
|
...moduleButtonStyle(photoSearchEnabled),
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
}}
|
}}
|
||||||
|
|
@ -1376,8 +1567,147 @@ export function EventHomeModules() {
|
||||||
>
|
>
|
||||||
Manage
|
Manage
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{canReadSocialShare ? (
|
||||||
|
<ModuleCard
|
||||||
|
icon={<Share2 size={18} />}
|
||||||
|
title="Social Share"
|
||||||
|
meta={socialShareMeta}
|
||||||
|
description="Multi-frame photo create, download, and share"
|
||||||
|
headerAction={
|
||||||
|
socialShareEnabled && socialShareToken ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Copy visitor link"
|
||||||
|
onClick={() => void copySocialShareLink()}
|
||||||
|
style={formHeaderIconButtonStyle()}
|
||||||
|
>
|
||||||
|
{copiedSocialShareLink ? <Check size={14} /> : <Link2 size={14} />}
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{socialShareEnabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled || socialShareLoading || !canWriteSocialShare}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(),
|
||||||
|
opacity: actionsDisabled || socialShareLoading || !canWriteSocialShare ? 0.55 : 1,
|
||||||
|
cursor:
|
||||||
|
actionsDisabled || socialShareLoading || !canWriteSocialShare
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => void handleSocialShareToggle(false)}
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled || socialShareLoading || !canWriteSocialShare}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(true),
|
||||||
|
opacity: actionsDisabled || socialShareLoading || !canWriteSocialShare ? 0.55 : 1,
|
||||||
|
cursor:
|
||||||
|
actionsDisabled || socialShareLoading || !canWriteSocialShare
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => void handleSocialShareToggle(true)}
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(socialShareEnabled),
|
||||||
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setShowSocialSharePanel(true)}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canReadAiAvatar ? (
|
||||||
|
<ModuleCard
|
||||||
|
icon={<Sparkles size={18} />}
|
||||||
|
title="AI Avatar"
|
||||||
|
meta={aiAvatarMeta}
|
||||||
|
description="AI head-swap avatars from visitor selfies"
|
||||||
|
headerAction={
|
||||||
|
aiAvatarEnabled && aiAvatarToken ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Copy visitor link"
|
||||||
|
onClick={() => void copyAiAvatarLink()}
|
||||||
|
style={formHeaderIconButtonStyle()}
|
||||||
|
>
|
||||||
|
{copiedAiAvatarLink ? <Check size={14} /> : <Link2 size={14} />}
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{aiAvatarEnabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled || aiAvatarLoading || !canWriteAiAvatar}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(),
|
||||||
|
opacity: actionsDisabled || aiAvatarLoading || !canWriteAiAvatar ? 0.55 : 1,
|
||||||
|
cursor:
|
||||||
|
actionsDisabled || aiAvatarLoading || !canWriteAiAvatar
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => void handleAiAvatarToggle(false)}
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled || aiAvatarLoading || !canWriteAiAvatar}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(true),
|
||||||
|
opacity: actionsDisabled || aiAvatarLoading || !canWriteAiAvatar ? 0.55 : 1,
|
||||||
|
cursor:
|
||||||
|
actionsDisabled || aiAvatarLoading || !canWriteAiAvatar
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => void handleAiAvatarToggle(true)}
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(aiAvatarEnabled),
|
||||||
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setShowAiAvatarPanel(true)}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{canReadPhotoWall ? (
|
{canReadPhotoWall ? (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Images size={18} />}
|
icon={<Images size={18} />}
|
||||||
|
|
@ -1636,6 +1966,14 @@ export function EventHomeModules() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{visitorThemeOpen && selectedEventId ? (
|
||||||
|
<VisitorThemeSettingsPanel
|
||||||
|
eventId={selectedEventId}
|
||||||
|
onClose={() => setVisitorThemeOpen(false)}
|
||||||
|
onSaved={() => void loadVisitorThemeMeta()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{staticMicrositeOpen && selectedEventId ? (
|
{staticMicrositeOpen && selectedEventId ? (
|
||||||
<ManageStaticMicrositePanel
|
<ManageStaticMicrositePanel
|
||||||
eventId={selectedEventId}
|
eventId={selectedEventId}
|
||||||
|
|
@ -1658,6 +1996,7 @@ export function EventHomeModules() {
|
||||||
eventId={selectedEventId}
|
eventId={selectedEventId}
|
||||||
eventName={eventName ?? undefined}
|
eventName={eventName ?? undefined}
|
||||||
onClose={() => setShowEventPhotoSearchPanel(false)}
|
onClose={() => setShowEventPhotoSearchPanel(false)}
|
||||||
|
onChanged={() => void loadPhotoSearchMeta()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -1671,6 +2010,26 @@ export function EventHomeModules() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{showSocialSharePanel && selectedEventId ? (
|
||||||
|
<SocialShareManagePanel
|
||||||
|
eventId={selectedEventId}
|
||||||
|
eventName={eventName ?? undefined}
|
||||||
|
canWrite={canWriteSocialShare}
|
||||||
|
onClose={() => setShowSocialSharePanel(false)}
|
||||||
|
onChanged={() => void loadSocialShareMeta()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showAiAvatarPanel && selectedEventId ? (
|
||||||
|
<AiAvatarManagePanel
|
||||||
|
eventId={selectedEventId}
|
||||||
|
eventName={eventName ?? undefined}
|
||||||
|
canWrite={canWriteAiAvatar}
|
||||||
|
onClose={() => setShowAiAvatarPanel(false)}
|
||||||
|
onChanged={() => void loadAiAvatarMeta()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showEmailCampaignsPanel && selectedEventId ? (
|
{showEmailCampaignsPanel && selectedEventId ? (
|
||||||
<EventEmailCampaignsPanel
|
<EventEmailCampaignsPanel
|
||||||
eventId={selectedEventId}
|
eventId={selectedEventId}
|
||||||
|
|
|
||||||
|
|
@ -674,7 +674,7 @@ export function PhotoWallManagePanel({
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Share on social platforms" />
|
<Label text="Share on social platforms" />
|
||||||
<HelperText text="Shown after upload. Shares the photo as an image file with a separate caption (device share sheet on mobile; on desktop saves the image and copies the caption)." />
|
<HelperText text="Shown after upload when at least one platform is enabled. On phones, guests get a single Share button that opens the iOS/Android share sheet so they can pick the app. On desktop, platform buttons are shown." />
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" }}>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,890 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Check, Copy, Image, Link2, Trash2, Type } from "lucide-react";
|
||||||
|
import { PanelShell, Label, Field, Grid, IS, SS, focusIn, focusOut, HelperText, RadioGroup } from "../panels/PanelShell";
|
||||||
|
import {
|
||||||
|
socialShareApi,
|
||||||
|
uploadMediaFile,
|
||||||
|
type SocialShareConfig,
|
||||||
|
type SocialShareEventHeader,
|
||||||
|
type SocialShareFrame,
|
||||||
|
} from "../../../services/api";
|
||||||
|
import { socialShareVisitorUrl } from "../../utils/socialShareUrls";
|
||||||
|
import {
|
||||||
|
EVENT_HEADER_TOGGLE_LABELS,
|
||||||
|
eventHeaderFromApi,
|
||||||
|
eventHeaderToApi,
|
||||||
|
type EventHeaderToggleKey,
|
||||||
|
} from "../../utils/formEventHeaderDisplay";
|
||||||
|
import { PhotoWallFrameDesigner } from "./PhotoWallFrameDesigner";
|
||||||
|
import { PhotoWallFrameInspector } from "./PhotoWallFrameInspector";
|
||||||
|
import {
|
||||||
|
defaultCustomTextElement,
|
||||||
|
defaultFrameImageLayout,
|
||||||
|
defaultPhotoElement,
|
||||||
|
normalizeFrameImageLayout,
|
||||||
|
normalizeFrameLayout,
|
||||||
|
type PhotoWallFrameElement,
|
||||||
|
type PhotoWallFrameImageLayout,
|
||||||
|
} from "./photoWallFrameLayout";
|
||||||
|
|
||||||
|
type Tab = "settings" | "frames" | "links";
|
||||||
|
|
||||||
|
const DEFAULT_HEADER = eventHeaderFromApi(null);
|
||||||
|
|
||||||
|
export function SocialShareManagePanel({
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
canWrite,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
eventName?: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<Tab>("settings");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [config, setConfig] = useState<SocialShareConfig | null>(null);
|
||||||
|
const [frames, setFrames] = useState<SocialShareFrame[]>([]);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [editingFrameId, setEditingFrameId] = useState<string | null>(null);
|
||||||
|
const [selectedElId, setSelectedElId] = useState<string | null>(null);
|
||||||
|
const [uploadingFrame, setUploadingFrame] = useState(false);
|
||||||
|
const [frameDraft, setFrameDraft] = useState({
|
||||||
|
name: "Frame",
|
||||||
|
enabled: true,
|
||||||
|
frameImageUrl: "" as string,
|
||||||
|
frameImageLayout: defaultFrameImageLayout() as PhotoWallFrameImageLayout,
|
||||||
|
frameCanvasWidth: 1080,
|
||||||
|
frameCanvasHeight: 1080,
|
||||||
|
frameLayout: [] as PhotoWallFrameElement[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
accessMode: "open" as "open" | "login",
|
||||||
|
requireUploadCrop: false,
|
||||||
|
maxPhotoWidth: 1080,
|
||||||
|
maxPhotoHeight: 1080,
|
||||||
|
allowDownload: true,
|
||||||
|
eventHeader: DEFAULT_HEADER,
|
||||||
|
sharePlatforms: {
|
||||||
|
linkedin: false,
|
||||||
|
facebook: false,
|
||||||
|
instagram: false,
|
||||||
|
snapchat: false,
|
||||||
|
x: false,
|
||||||
|
whatsapp: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await socialShareApi.get(eventId);
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setError(res.error || "Failed to load social share");
|
||||||
|
setConfig(null);
|
||||||
|
setFrames([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = res.data;
|
||||||
|
setConfig(data);
|
||||||
|
setFrames(data.frames || []);
|
||||||
|
setDraft({
|
||||||
|
accessMode: data.accessMode || "open",
|
||||||
|
requireUploadCrop: Boolean(data.requireUploadCrop),
|
||||||
|
maxPhotoWidth: data.maxPhotoWidth ?? 1080,
|
||||||
|
maxPhotoHeight: data.maxPhotoHeight ?? 1080,
|
||||||
|
allowDownload: data.allowDownload !== false,
|
||||||
|
eventHeader: eventHeaderFromApi(data.eventHeader as SocialShareEventHeader | null),
|
||||||
|
sharePlatforms: {
|
||||||
|
linkedin: Boolean(data.sharePlatforms?.linkedin),
|
||||||
|
facebook: Boolean(data.sharePlatforms?.facebook),
|
||||||
|
instagram: Boolean(data.sharePlatforms?.instagram),
|
||||||
|
snapchat: Boolean(data.sharePlatforms?.snapchat),
|
||||||
|
x: Boolean(data.sharePlatforms?.x),
|
||||||
|
whatsapp: Boolean(data.sharePlatforms?.whatsapp),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const openFrameEditor = (frame: SocialShareFrame) => {
|
||||||
|
setEditingFrameId(frame._id);
|
||||||
|
setSelectedElId(null);
|
||||||
|
setFrameDraft({
|
||||||
|
name: frame.name || "Frame",
|
||||||
|
enabled: frame.enabled !== false,
|
||||||
|
frameImageUrl: frame.frameImageUrl || "",
|
||||||
|
frameImageLayout: normalizeFrameImageLayout(frame.frameImageLayout || defaultFrameImageLayout()),
|
||||||
|
frameCanvasWidth: frame.frameCanvasWidth ?? 1080,
|
||||||
|
frameCanvasHeight: frame.frameCanvasHeight ?? 1080,
|
||||||
|
frameLayout: normalizeFrameLayout(
|
||||||
|
frame.frameLayout,
|
||||||
|
frame.frameCanvasWidth ?? 1080,
|
||||||
|
frame.frameCanvasHeight ?? 1080,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
if (!canWrite) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await socialShareApi.update(eventId, {
|
||||||
|
accessMode: draft.accessMode,
|
||||||
|
requireUploadCrop: draft.requireUploadCrop,
|
||||||
|
maxPhotoWidth: draft.maxPhotoWidth,
|
||||||
|
maxPhotoHeight: draft.maxPhotoHeight,
|
||||||
|
allowDownload: draft.allowDownload,
|
||||||
|
sharePlatforms: draft.sharePlatforms,
|
||||||
|
eventHeader: eventHeaderToApi(draft.eventHeader),
|
||||||
|
});
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setError(res.error || "Failed to save");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfig(res.data);
|
||||||
|
setFrames(res.data.frames || []);
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to save");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveFrame = async () => {
|
||||||
|
if (!canWrite || !editingFrameId) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await socialShareApi.updateFrame(eventId, editingFrameId, {
|
||||||
|
name: frameDraft.name,
|
||||||
|
enabled: frameDraft.enabled,
|
||||||
|
frameImageUrl: frameDraft.frameImageUrl || null,
|
||||||
|
frameImageLayout: frameDraft.frameImageLayout,
|
||||||
|
frameCanvasWidth: frameDraft.frameCanvasWidth,
|
||||||
|
frameCanvasHeight: frameDraft.frameCanvasHeight,
|
||||||
|
frameLayout: frameDraft.frameLayout,
|
||||||
|
});
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setError(res.error || "Failed to save frame");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFrames((prev) => prev.map((f) => (f._id === res.data!._id ? res.data! : f)));
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to save frame");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFrame = async () => {
|
||||||
|
if (!canWrite) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await socialShareApi.createFrame(eventId, {
|
||||||
|
name: `Frame ${frames.length + 1}`,
|
||||||
|
frameLayout: [defaultPhotoElement(1080, 1080)],
|
||||||
|
});
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setError(res.error || "Failed to create frame");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFrames((prev) => [...prev, res.data!]);
|
||||||
|
openFrameEditor(res.data);
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to create frame");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFrame = async (frameId: string) => {
|
||||||
|
if (!canWrite) return;
|
||||||
|
if (!window.confirm("Delete this frame?")) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await socialShareApi.deleteFrame(eventId, frameId);
|
||||||
|
setFrames((prev) => prev.filter((f) => f._id !== frameId));
|
||||||
|
if (editingFrameId === frameId) setEditingFrameId(null);
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to delete");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFrameImage = async (file: File | null) => {
|
||||||
|
if (!file || !canWrite) return;
|
||||||
|
setUploadingFrame(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await uploadMediaFile(file, { eventId, module: "social-share/frames" });
|
||||||
|
const url = r.url || "";
|
||||||
|
if (!url) throw new Error("Upload succeeded but no URL returned");
|
||||||
|
setFrameDraft((d) => ({ ...d, frameImageUrl: url }));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Frame upload failed");
|
||||||
|
} finally {
|
||||||
|
setUploadingFrame(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFrameElement = (el: PhotoWallFrameElement) => {
|
||||||
|
setFrameDraft((d) => ({ ...d, frameLayout: [...d.frameLayout, el] }));
|
||||||
|
setSelectedElId(el.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
if (!config?.uploadToken) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(socialShareVisitorUrl(config.uploadToken));
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabBtn = (id: Tab, label: string) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTab(id);
|
||||||
|
if (id !== "frames") setEditingFrameId(null);
|
||||||
|
}}
|
||||||
|
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 ctaLabel =
|
||||||
|
tab === "frames" && editingFrameId ? "Save frame" : tab === "settings" ? "Save settings" : "Save";
|
||||||
|
const showCta = tab === "settings" || (tab === "frames" && Boolean(editingFrameId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelShell
|
||||||
|
onClose={onClose}
|
||||||
|
title="Social Share"
|
||||||
|
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home"}
|
||||||
|
description="Configure frames and visitor share link"
|
||||||
|
completedCount={0}
|
||||||
|
totalSections={0}
|
||||||
|
ctaLabel={ctaLabel}
|
||||||
|
showProgress={false}
|
||||||
|
hidePrimaryCta={!showCta}
|
||||||
|
ctaDisabled={!canWrite || saving || loading}
|
||||||
|
onCtaClick={() => {
|
||||||
|
if (tab === "frames" && editingFrameId) void saveFrame();
|
||||||
|
else void saveSettings();
|
||||||
|
}}
|
||||||
|
panelWidth={tab === "frames" && editingFrameId ? "min(960px,100vw)" : "min(720px,100vw)"}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", borderBottom: "1px solid var(--border)", padding: "0 12px" }}>
|
||||||
|
{tabBtn("settings", "Settings")}
|
||||||
|
{tabBtn("frames", "Frames")}
|
||||||
|
{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 === "links" && config ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-card)",
|
||||||
|
padding: "12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<Link2 size={16} color="var(--primary)" />
|
||||||
|
<strong style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)" }}>
|
||||||
|
Visitor upload URL
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||||
|
Guests pick a frame, upload a photo, then download or share
|
||||||
|
</p>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
background: "var(--muted-bg, #f5f5f5)",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.uploadToken
|
||||||
|
? socialShareVisitorUrl(config.uploadToken)
|
||||||
|
: "Enable social share to generate link"}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!config.uploadToken}
|
||||||
|
onClick={() => void copyLink()}
|
||||||
|
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: config.uploadToken ? "pointer" : "not-allowed",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
opacity: config.uploadToken ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{canWrite ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void socialShareApi.update(eventId, { regenerateTokens: true }).then((res) => {
|
||||||
|
if (res.data) {
|
||||||
|
setConfig(res.data);
|
||||||
|
onChanged?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Regenerate token
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && tab === "settings" ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||||
|
<Field>
|
||||||
|
<Label text="Access mode" />
|
||||||
|
<select
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={draft.accessMode}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, accessMode: e.target.value as "open" | "login" }))}
|
||||||
|
style={SS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
|
<option value="open">Open (anyone with link)</option>
|
||||||
|
<option value="login">Login required</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Event header on visitor page" />
|
||||||
|
<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={`ss-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>
|
||||||
|
|
||||||
|
<Grid cols={2}>
|
||||||
|
<Field>
|
||||||
|
<Label text="Max photo width (px)" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={80}
|
||||||
|
max={1200}
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={draft.maxPhotoWidth}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, maxPhotoWidth: Number(e.target.value) || 1080 }))}
|
||||||
|
style={IS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Max photo height (px)" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={80}
|
||||||
|
max={1200}
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={draft.maxPhotoHeight}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, maxPhotoHeight: Number(e.target.value) || 1080 }))}
|
||||||
|
style={IS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Require crop to match ratio" />
|
||||||
|
<HelperText text="If the photo aspect ratio does not match max width ÷ height, visitors must crop before upload." />
|
||||||
|
<RadioGroup
|
||||||
|
name="ss-require-crop"
|
||||||
|
value={draft.requireUploadCrop ? "Yes" : "No"}
|
||||||
|
onChange={(v) => setDraft((d) => ({ ...d, requireUploadCrop: v === "Yes" }))}
|
||||||
|
options={[
|
||||||
|
{ label: "Yes", value: "Yes" },
|
||||||
|
{ label: "No", value: "No" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Allow visitors to download their photo" />
|
||||||
|
<RadioGroup
|
||||||
|
name="ss-allow-download"
|
||||||
|
value={draft.allowDownload ? "Yes" : "No"}
|
||||||
|
onChange={(v) => setDraft((d) => ({ ...d, allowDownload: v === "Yes" }))}
|
||||||
|
options={[
|
||||||
|
{ label: "Yes", value: "Yes" },
|
||||||
|
{ label: "No", value: "No" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Share on social platforms" />
|
||||||
|
<HelperText text="Shown after upload when at least one platform is enabled." />
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["linkedin", "LinkedIn"],
|
||||||
|
["facebook", "Facebook"],
|
||||||
|
["instagram", "Instagram"],
|
||||||
|
["snapchat", "Snapchat"],
|
||||||
|
["x", "X (Twitter)"],
|
||||||
|
["whatsapp", "WhatsApp"],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!canWrite}
|
||||||
|
checked={draft.sharePlatforms[key]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
sharePlatforms: { ...d.sharePlatforms, [key]: e.target.checked },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && tab === "frames" ? (
|
||||||
|
editingFrameId ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingFrameId(null)}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--primary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Back to frames
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Frame name" />
|
||||||
|
<input
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={frameDraft.name}
|
||||||
|
onChange={(e) => setFrameDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
|
style={IS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Enabled for visitors" />
|
||||||
|
<RadioGroup
|
||||||
|
name="ss-frame-enabled"
|
||||||
|
value={frameDraft.enabled ? "Yes" : "No"}
|
||||||
|
onChange={(v) => setFrameDraft((d) => ({ ...d, enabled: v === "Yes" }))}
|
||||||
|
options={[
|
||||||
|
{ label: "Yes", value: "Yes" },
|
||||||
|
{ label: "No", value: "No" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Grid cols={2}>
|
||||||
|
<Field>
|
||||||
|
<Label text="Canvas width (px)" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={200}
|
||||||
|
max={4096}
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={frameDraft.frameCanvasWidth}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFrameDraft((d) => ({ ...d, frameCanvasWidth: Number(e.target.value) || 1080 }))
|
||||||
|
}
|
||||||
|
style={IS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Canvas height (px)" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={200}
|
||||||
|
max={4096}
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={frameDraft.frameCanvasHeight}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFrameDraft((d) => ({ ...d, frameCanvasHeight: Number(e.target.value) || 1080 }))
|
||||||
|
}
|
||||||
|
style={IS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Frame image" />
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
cursor: canWrite ? "pointer" : "not-allowed",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploadingFrame ? "Uploading…" : frameDraft.frameImageUrl ? "Replace frame" : "Upload frame"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
hidden
|
||||||
|
disabled={!canWrite || uploadingFrame}
|
||||||
|
onChange={(e) => void uploadFrameImage(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{frameDraft.frameImageUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => setFrameDraft((d) => ({ ...d, frameImageUrl: "" }))}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() =>
|
||||||
|
addFrameElement(
|
||||||
|
defaultPhotoElement(frameDraft.frameCanvasWidth, frameDraft.frameCanvasHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
cursor: canWrite ? "pointer" : "not-allowed",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image size={14} /> Photo slot
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() =>
|
||||||
|
addFrameElement(
|
||||||
|
defaultCustomTextElement(frameDraft.frameCanvasWidth, frameDraft.frameCanvasHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
cursor: canWrite ? "pointer" : "not-allowed",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Type size={14} /> Custom text
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "minmax(0,1fr) minmax(200px,260px)",
|
||||||
|
gap: 16,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PhotoWallFrameDesigner
|
||||||
|
canvasWidth={frameDraft.frameCanvasWidth}
|
||||||
|
canvasHeight={frameDraft.frameCanvasHeight}
|
||||||
|
frameImageUrl={frameDraft.frameImageUrl}
|
||||||
|
frameImageLayout={frameDraft.frameImageLayout}
|
||||||
|
layoutElements={frameDraft.frameLayout}
|
||||||
|
selectedId={selectedElId}
|
||||||
|
onSelect={setSelectedElId}
|
||||||
|
onLayoutChange={(next) => setFrameDraft((d) => ({ ...d, frameLayout: next }))}
|
||||||
|
onFrameImageLayoutChange={(next) =>
|
||||||
|
setFrameDraft((d) => ({ ...d, frameImageLayout: next }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<PhotoWallFrameInspector
|
||||||
|
element={frameDraft.frameLayout.find((e) => e.id === selectedElId) || null}
|
||||||
|
disabled={!canWrite}
|
||||||
|
canvasWidth={frameDraft.frameCanvasWidth}
|
||||||
|
canvasHeight={frameDraft.frameCanvasHeight}
|
||||||
|
onChange={(patch) => {
|
||||||
|
if (!selectedElId) return;
|
||||||
|
setFrameDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
frameLayout: d.frameLayout.map((el) =>
|
||||||
|
el.id === selectedElId ? { ...el, ...patch } : el,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedElId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canWrite}
|
||||||
|
onClick={() => {
|
||||||
|
setFrameDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
frameLayout: d.frameLayout.filter((el) => el.id !== selectedElId),
|
||||||
|
}));
|
||||||
|
setSelectedElId(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--destructive)",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--destructive)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove selected layer
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
||||||
|
<HelperText text="Visitors choose one of these frames before uploading their photo." />
|
||||||
|
{canWrite ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void addFrame()}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--primary)",
|
||||||
|
color: "var(--primary-foreground)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add frame
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{frames.length === 0 ? (
|
||||||
|
<p style={{ margin: 0, color: "var(--muted)", fontSize: "var(--text-sm)" }}>
|
||||||
|
No frames yet. Add one to get started.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
frames.map((frame) => (
|
||||||
|
<div
|
||||||
|
key={frame._id}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-card)",
|
||||||
|
padding: "12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "var(--muted-bg, #f5f5f5)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{frame.frameImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={frame.frameImageUrl}
|
||||||
|
alt=""
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<strong style={{ fontSize: "var(--text-sm)" }}>{frame.name || "Frame"}</strong>
|
||||||
|
<p style={{ margin: "4px 0 0", fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||||
|
{frame.enabled !== false ? "Enabled" : "Disabled"} · {frame.frameCanvasWidth}×
|
||||||
|
{frame.frameCanvasHeight}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openFrameEditor(frame)}
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{canWrite ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void deleteFrame(frame._id)}
|
||||||
|
aria-label="Delete frame"
|
||||||
|
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",
|
||||||
|
color: "var(--destructive)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</PanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,106 +1,474 @@
|
||||||
import React from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { X } from "lucide-react";
|
import { Check, Copy, Link2 } from "lucide-react";
|
||||||
|
import { PanelShell, Label, Field, Grid, SS, focusIn, focusOut, HelperText, RadioGroup } from "./PanelShell";
|
||||||
import { EventPhotoSearchContent } from "./EventPhotoSearchContent";
|
import { EventPhotoSearchContent } from "./EventPhotoSearchContent";
|
||||||
|
import {
|
||||||
|
eventPhotoSearchApi,
|
||||||
|
eventsApi,
|
||||||
|
type EventPhotoSearchConfig,
|
||||||
|
type EventPhotoSearchEventHeader,
|
||||||
|
} from "../../../services/api";
|
||||||
|
import { photoSearchVisitorUrl } from "../../utils/photoSearchUrls";
|
||||||
|
import {
|
||||||
|
EVENT_HEADER_TOGGLE_LABELS,
|
||||||
|
eventHeaderFromApi,
|
||||||
|
eventHeaderToApi,
|
||||||
|
type EventHeaderToggleKey,
|
||||||
|
} from "../../utils/formEventHeaderDisplay";
|
||||||
|
|
||||||
|
type Tab = "photos" | "settings" | "links";
|
||||||
|
|
||||||
|
const DEFAULT_HEADER = eventHeaderFromApi(null);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
|
canWrite?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onChanged?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EventPhotoSearchPanel({ open, eventId, eventName, onClose }: Props) {
|
export function EventPhotoSearchPanel({
|
||||||
|
open,
|
||||||
|
eventId,
|
||||||
|
eventName,
|
||||||
|
canWrite = true,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: Props) {
|
||||||
|
const [tab, setTab] = useState<Tab>("photos");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [config, setConfig] = useState<EventPhotoSearchConfig | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [copiedJoinCode, setCopiedJoinCode] = useState(false);
|
||||||
|
const [joinCode, setJoinCode] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
accessMode: "open" as "open" | "login",
|
||||||
|
allowDownload: true,
|
||||||
|
eventHeader: DEFAULT_HEADER,
|
||||||
|
sharePlatforms: {
|
||||||
|
linkedin: false,
|
||||||
|
facebook: false,
|
||||||
|
instagram: false,
|
||||||
|
snapchat: false,
|
||||||
|
x: false,
|
||||||
|
whatsapp: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [res, eventRes] = await Promise.all([
|
||||||
|
eventPhotoSearchApi.get(eventId),
|
||||||
|
eventsApi.get(eventId),
|
||||||
|
]);
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
setError(res.error || "Failed to load photo search settings");
|
||||||
|
setConfig(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = res.data;
|
||||||
|
setConfig(data);
|
||||||
|
setJoinCode(eventRes.data?.joinCode ? String(eventRes.data.joinCode) : null);
|
||||||
|
setDraft({
|
||||||
|
accessMode: data.accessMode || "open",
|
||||||
|
allowDownload: data.allowDownload !== false,
|
||||||
|
eventHeader: eventHeaderFromApi(data.eventHeader as EventPhotoSearchEventHeader | null),
|
||||||
|
sharePlatforms: {
|
||||||
|
linkedin: Boolean(data.sharePlatforms?.linkedin),
|
||||||
|
facebook: Boolean(data.sharePlatforms?.facebook),
|
||||||
|
instagram: Boolean(data.sharePlatforms?.instagram),
|
||||||
|
snapchat: Boolean(data.sharePlatforms?.snapchat),
|
||||||
|
x: Boolean(data.sharePlatforms?.x),
|
||||||
|
whatsapp: Boolean(data.sharePlatforms?.whatsapp),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load");
|
||||||
|
setConfig(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTab("photos");
|
||||||
|
void load();
|
||||||
|
}, [open, load]);
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
if (!canWrite) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await eventPhotoSearchApi.update(eventId, {
|
||||||
|
accessMode: draft.accessMode,
|
||||||
|
allowDownload: draft.allowDownload,
|
||||||
|
sharePlatforms: draft.sharePlatforms,
|
||||||
|
eventHeader: eventHeaderToApi(draft.eventHeader),
|
||||||
|
});
|
||||||
|
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 copyLink = async () => {
|
||||||
|
if (!config?.searchToken) return;
|
||||||
|
const url = photoSearchVisitorUrl(config.searchToken);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyJoinCode = async () => {
|
||||||
|
if (!joinCode) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(joinCode);
|
||||||
|
setCopiedJoinCode(true);
|
||||||
|
window.setTimeout(() => setCopiedJoinCode(false), 1500);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const photographerUploaderDownloadUrl =
|
||||||
|
import.meta.env.VITE_PHOTOGRAPHER_UPLOADER_URL || "";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
// const displayPhotos = searchResults !== null ? searchResults : photos;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<PanelShell
|
||||||
<div
|
onClose={onClose}
|
||||||
onClick={onClose}
|
title="Event Photo Search"
|
||||||
role="presentation"
|
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home"}
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }}
|
description="Upload photos, configure visitor Find Photos, and share links"
|
||||||
/>
|
completedCount={0}
|
||||||
|
totalSections={0}
|
||||||
<div
|
ctaLabel="Save settings"
|
||||||
role="dialog"
|
showProgress={false}
|
||||||
aria-modal="true"
|
hidePrimaryCta={tab !== "settings"}
|
||||||
aria-labelledby="event-photo-search-title"
|
ctaDisabled={!canWrite || saving || loading}
|
||||||
style={{
|
onCtaClick={() => void saveSettings()}
|
||||||
position: 'fixed',
|
panelWidth="min(820px,100vw)"
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
height: '100%',
|
|
||||||
width: 'min(820px, 100vw)',
|
|
||||||
background: 'var(--card)',
|
|
||||||
zIndex: 201,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
boxShadow: '-4px 0 40px rgba(0,0,0,0.2)',
|
|
||||||
animation: 'eventPhotoSlideIn 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<style>{`@keyframes eventPhotoSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }`}</style>
|
<div style={{ display: "flex", borderBottom: "1px solid var(--border)", padding: "0 12px" }}>
|
||||||
|
{tabBtn("photos", "Photos")}
|
||||||
{/* Header */}
|
{tabBtn("settings", "Settings")}
|
||||||
<div
|
{tabBtn("links", "Links")}
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 12,
|
|
||||||
padding: '16px 18px',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<p style={{ fontSize: 'var(--text-xs)', fontFamily: 'var(--font-noto-sans)', color: 'var(--muted)', margin: '0 0 4px' }}>
|
|
||||||
Event Photo Search
|
|
||||||
</p>
|
|
||||||
<h2
|
|
||||||
id="event-photo-search-title"
|
|
||||||
style={{
|
|
||||||
fontFamily: 'var(--font-inter)',
|
|
||||||
fontSize: 'var(--text-lg)',
|
|
||||||
fontWeight: 'var(--font-weight-medium)',
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
margin: 0,
|
|
||||||
lineHeight: 1.3,
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{eventName || 'Photos'}
|
|
||||||
</h2>
|
|
||||||
<p style={{ margin: '6px 0 0', fontSize: 'var(--text-xs)', color: 'var(--muted)', fontFamily: 'var(--font-noto-sans)' }}>
|
|
||||||
Upload event photos
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === "photos" ? (
|
||||||
|
<EventPhotoSearchContent active={open && tab === "photos"} eventId={eventId} mode="full" />
|
||||||
|
) : (
|
||||||
|
<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 === "links" && config ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-card)",
|
||||||
|
padding: "12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<Link2 size={16} color="var(--primary)" />
|
||||||
|
<strong style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)" }}>
|
||||||
|
Visitor search URL
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||||
|
Share so guests can upload a selfie and find matching event photos
|
||||||
|
</p>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
background: "var(--muted-bg, #f5f5f5)",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.searchToken
|
||||||
|
? photoSearchVisitorUrl(config.searchToken)
|
||||||
|
: "Enable photo search to generate link"}
|
||||||
|
</code>
|
||||||
|
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
disabled={!config.searchToken}
|
||||||
aria-label="Close"
|
onClick={() => void copyLink()}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
display: "inline-flex",
|
||||||
width: 36,
|
alignItems: "center",
|
||||||
height: 36,
|
gap: 6,
|
||||||
display: 'flex',
|
padding: "8px 12px",
|
||||||
alignItems: 'center',
|
borderRadius: "var(--radius-button)",
|
||||||
justifyContent: 'center',
|
border: "1px solid var(--border)",
|
||||||
borderRadius: 'var(--radius-button)',
|
background: "var(--card)",
|
||||||
border: '1px solid var(--border)',
|
cursor: config.searchToken ? "pointer" : "not-allowed",
|
||||||
background: 'var(--card)',
|
fontSize: "var(--text-sm)",
|
||||||
color: 'var(--muted)',
|
opacity: config.searchToken ? 1 : 0.55,
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EventPhotoSearchContent active={open} eventId={eventId} mode="full" />
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-card)",
|
||||||
|
padding: "12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<Link2 size={16} color="var(--primary)" />
|
||||||
|
<strong style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-inter)" }}>
|
||||||
|
Photographer uploader
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--muted)" }}>
|
||||||
|
Give this code to photographers using the EventBuzz Photo Uploader app
|
||||||
|
</p>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--foreground)",
|
||||||
|
background: "var(--muted-bg, #f5f5f5)",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{joinCode || "No join code on this event"}
|
||||||
|
</code>
|
||||||
|
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!joinCode}
|
||||||
|
onClick={() => void copyJoinCode()}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
cursor: joinCode ? "pointer" : "not-allowed",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
opacity: joinCode ? 1 : 0.55,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copiedJoinCode ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copiedJoinCode ? "Copied" : "Copy join code"}
|
||||||
|
</button>
|
||||||
|
{photographerUploaderDownloadUrl ? (
|
||||||
|
<a
|
||||||
|
href={photographerUploaderDownloadUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download Windows app
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: "var(--text-xs)", color: "var(--muted)", alignSelf: "center" }}>
|
||||||
|
Windows installer link coming soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canWrite ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void eventPhotoSearchApi.update(eventId, { regenerateTokens: true }).then((res) => {
|
||||||
|
if (res.data) {
|
||||||
|
setConfig(res.data);
|
||||||
|
onChanged?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Regenerate token
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && tab === "settings" ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "14px" }}>
|
||||||
|
<Field>
|
||||||
|
<Label text="Access mode" />
|
||||||
|
<select
|
||||||
|
disabled={!canWrite}
|
||||||
|
value={draft.accessMode}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, accessMode: e.target.value as "open" | "login" }))}
|
||||||
|
style={SS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
|
<option value="open">Open (anyone with link)</option>
|
||||||
|
<option value="login">Login required</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Event header on visitor page" />
|
||||||
|
<HelperText text="Choose which event details appear above Find Photos. Same options as public forms." />
|
||||||
|
<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={`ps-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>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Allow visitors to download matched photos" />
|
||||||
|
<RadioGroup
|
||||||
|
name="ps-allow-download"
|
||||||
|
value={draft.allowDownload ? "Yes" : "No"}
|
||||||
|
onChange={(v) => setDraft((d) => ({ ...d, allowDownload: v === "Yes" }))}
|
||||||
|
options={[
|
||||||
|
{ label: "Yes", value: "Yes" },
|
||||||
|
{ label: "No", value: "No" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Share on social platforms" />
|
||||||
|
<HelperText text="Shown on each match when at least one platform is enabled. On phones, guests get a single Share button that opens the OS share sheet." />
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" }}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["linkedin", "LinkedIn"],
|
||||||
|
["facebook", "Facebook"],
|
||||||
|
["instagram", "Instagram"],
|
||||||
|
["snapchat", "Snapchat"],
|
||||||
|
["x", "X (Twitter)"],
|
||||||
|
["whatsapp", "WhatsApp"],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: "var(--text-sm)" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!canWrite}
|
||||||
|
checked={draft.sharePlatforms[key]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
sharePlatforms: { ...d.sharePlatforms, [key]: e.target.checked },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PanelShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,839 @@
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { uploadMediaFile, visitorThemeApi } from "../../../services/api";
|
||||||
|
import type {
|
||||||
|
VisitorThemeBackgroundRepeat,
|
||||||
|
VisitorThemeBackgroundSize,
|
||||||
|
VisitorThemeConfig,
|
||||||
|
VisitorThemeMode,
|
||||||
|
} from "../../../services/api/types";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
HelperText,
|
||||||
|
Label,
|
||||||
|
PanelShell,
|
||||||
|
SelectWrap,
|
||||||
|
SS,
|
||||||
|
focusIn,
|
||||||
|
focusOut,
|
||||||
|
} from "./PanelShell";
|
||||||
|
|
||||||
|
const THEME_OPTIONS: { value: VisitorThemeMode; label: string }[] = [
|
||||||
|
{ value: "simple", label: "Simple" },
|
||||||
|
{ value: "gradient", label: "Gradient" },
|
||||||
|
{ value: "background", label: "Background image" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FONT_OPTIONS = ["Open Sans", "Montserrat", "Roboto", "Poppins", "Raleway"] as const;
|
||||||
|
|
||||||
|
const BG_SIZE_OPTIONS: { value: VisitorThemeBackgroundSize; label: string }[] = [
|
||||||
|
{ value: "cover", label: "Cover" },
|
||||||
|
{ value: "contain", label: "Contain" },
|
||||||
|
{ value: "auto", label: "Auto" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BG_REPEAT_OPTIONS: { value: VisitorThemeBackgroundRepeat; label: string }[] = [
|
||||||
|
{ value: "no", label: "No repeat" },
|
||||||
|
{ value: "repeat", label: "Repeat" },
|
||||||
|
{ value: "repeat-x", label: "Repeat X" },
|
||||||
|
{ value: "repeat-y", label: "Repeat Y" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_THEME: VisitorThemeConfig = {
|
||||||
|
theme: "simple",
|
||||||
|
color: "rgba(21, 25, 55, 1)",
|
||||||
|
font: "Roboto",
|
||||||
|
primaryColor: "rgba(48, 63, 159, 1)",
|
||||||
|
secondaryColor: "rgba(76, 175, 80, 1)",
|
||||||
|
headerColor: "rgba(255, 255, 255, 0.9)",
|
||||||
|
buttonColor: "rgba(44, 53, 122, 1)",
|
||||||
|
textColor: "rgba(255, 255, 255, 1)",
|
||||||
|
textHighlightColor: "",
|
||||||
|
cardColor: "rgba(255, 255, 255, 0.65)",
|
||||||
|
cardTextColor: "rgba(255, 255, 255, 1)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_GRADIENT = {
|
||||||
|
color1: "rgba(30, 114, 239, 1)",
|
||||||
|
color2: "rgba(239, 141, 29, 1)",
|
||||||
|
};
|
||||||
|
|
||||||
|
type RgbaParts = { r: number; g: number; b: number; a: number };
|
||||||
|
|
||||||
|
function clampByte(n: number): number {
|
||||||
|
return Math.max(0, Math.min(255, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAlpha(a: number): string {
|
||||||
|
if (!Number.isFinite(a)) return "1";
|
||||||
|
const clamped = Math.max(0, Math.min(1, a));
|
||||||
|
if (clamped === 1) return "1";
|
||||||
|
if (clamped === 0) return "0";
|
||||||
|
return String(parseFloat(clamped.toFixed(3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRgbaString(r: number, g: number, b: number, a = 1): string {
|
||||||
|
return `rgba(${clampByte(r)}, ${clampByte(g)}, ${clampByte(b)}, ${formatAlpha(a)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHex(input: string): RgbaParts | null {
|
||||||
|
const s = input.trim();
|
||||||
|
const m = s.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||||
|
if (!m) return null;
|
||||||
|
let h = m[1];
|
||||||
|
if (h.length === 3 || h.length === 4) {
|
||||||
|
h = h
|
||||||
|
.split("")
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
r: parseInt(h.slice(0, 2), 16),
|
||||||
|
g: parseInt(h.slice(2, 4), 16),
|
||||||
|
b: parseInt(h.slice(4, 6), 16),
|
||||||
|
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRgbFunc(input: string): RgbaParts | null {
|
||||||
|
const s = input.trim();
|
||||||
|
const m = s.match(
|
||||||
|
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
|
||||||
|
);
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
r: Number(m[1]),
|
||||||
|
g: Number(m[2]),
|
||||||
|
b: Number(m[3]),
|
||||||
|
a: m[4] != null ? Number(m[4]) : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColor(input?: string | null): RgbaParts | null {
|
||||||
|
if (input == null) return null;
|
||||||
|
const s = String(input).trim();
|
||||||
|
if (!s) return null;
|
||||||
|
return parseHex(s) || parseRgbFunc(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToRgba(input?: string | null, fallback = ""): string {
|
||||||
|
if (input == null) return fallback;
|
||||||
|
const s = String(input).trim();
|
||||||
|
if (!s) return fallback;
|
||||||
|
const parsed = parseColor(s);
|
||||||
|
if (!parsed) return s;
|
||||||
|
return toRgbaString(parsed.r, parsed.g, parsed.b, parsed.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHexSwatch(input?: string | null, fallback = "#151937"): string {
|
||||||
|
const parsed = parseColor(input) || parseColor(fallback);
|
||||||
|
if (!parsed) return fallback;
|
||||||
|
const hex = (n: number) => clampByte(n).toString(16).padStart(2, "0");
|
||||||
|
return `#${hex(parsed.r)}${hex(parsed.g)}${hex(parsed.b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTheme(raw?: VisitorThemeConfig | null): VisitorThemeConfig {
|
||||||
|
const modeRaw = raw?.theme != null ? String(raw.theme).trim().toLowerCase() : "simple";
|
||||||
|
const theme: VisitorThemeMode =
|
||||||
|
modeRaw === "gradient" ? "gradient" : modeRaw === "background" ? "background" : "simple";
|
||||||
|
|
||||||
|
const fonts = new Set<string>(FONT_OPTIONS);
|
||||||
|
const fontRaw = raw?.font != null ? String(raw.font).trim() : "";
|
||||||
|
const font = fontRaw && fonts.has(fontRaw) ? fontRaw : DEFAULT_THEME.font!;
|
||||||
|
|
||||||
|
const next: VisitorThemeConfig = {
|
||||||
|
theme,
|
||||||
|
color: colorToRgba(raw?.color, DEFAULT_THEME.color),
|
||||||
|
font,
|
||||||
|
primaryColor: colorToRgba(raw?.primaryColor),
|
||||||
|
secondaryColor: colorToRgba(raw?.secondaryColor),
|
||||||
|
headerColor: colorToRgba(raw?.headerColor),
|
||||||
|
buttonColor: colorToRgba(raw?.buttonColor),
|
||||||
|
textColor: colorToRgba(raw?.textColor),
|
||||||
|
textHighlightColor: colorToRgba(raw?.textHighlightColor),
|
||||||
|
cardColor: colorToRgba(raw?.cardColor),
|
||||||
|
cardTextColor: colorToRgba(raw?.cardTextColor),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (theme === "gradient") {
|
||||||
|
next.gradient = {
|
||||||
|
color1: colorToRgba(raw?.gradient?.color1, DEFAULT_GRADIENT.color1),
|
||||||
|
color2: colorToRgba(raw?.gradient?.color2, DEFAULT_GRADIENT.color2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (theme === "background") {
|
||||||
|
const sizeRaw = String(raw?.background?.size || "").trim().toLowerCase();
|
||||||
|
const repeatRaw = String(raw?.background?.repeat || "").trim().toLowerCase();
|
||||||
|
const sizeOk = new Set(BG_SIZE_OPTIONS.map((o) => o.value));
|
||||||
|
const repeatOk = new Set(BG_REPEAT_OPTIONS.map((o) => o.value));
|
||||||
|
next.background = {
|
||||||
|
url: String(raw?.background?.url || "").trim(),
|
||||||
|
size: sizeOk.has(sizeRaw as VisitorThemeBackgroundSize)
|
||||||
|
? (sizeRaw as VisitorThemeBackgroundSize)
|
||||||
|
: "cover",
|
||||||
|
repeat: repeatOk.has(repeatRaw as VisitorThemeBackgroundRepeat)
|
||||||
|
? (repeatRaw as VisitorThemeBackgroundRepeat)
|
||||||
|
: "no",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadForSave(theme: VisitorThemeConfig): VisitorThemeConfig {
|
||||||
|
const normalized = normalizeTheme(theme);
|
||||||
|
if (normalized.theme !== "gradient") delete normalized.gradient;
|
||||||
|
if (normalized.theme !== "background") delete normalized.background;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PALETTE_FIELDS: {
|
||||||
|
key: keyof Pick<
|
||||||
|
VisitorThemeConfig,
|
||||||
|
| "primaryColor"
|
||||||
|
| "secondaryColor"
|
||||||
|
| "headerColor"
|
||||||
|
| "buttonColor"
|
||||||
|
| "textColor"
|
||||||
|
| "textHighlightColor"
|
||||||
|
| "cardColor"
|
||||||
|
| "cardTextColor"
|
||||||
|
>;
|
||||||
|
label: string;
|
||||||
|
help: string;
|
||||||
|
allowEmpty?: boolean;
|
||||||
|
}[] = [
|
||||||
|
{ key: "primaryColor", label: "Primary color", help: "Main brand / accent surface." },
|
||||||
|
{ key: "secondaryColor", label: "Secondary color", help: "Secondary accent." },
|
||||||
|
{ key: "headerColor", label: "Header color", help: "Top bar background." },
|
||||||
|
{ key: "buttonColor", label: "Button color", help: "Primary CTA buttons." },
|
||||||
|
{ key: "textColor", label: "Text color", help: "Body / default text on the page." },
|
||||||
|
{ key: "textHighlightColor", label: "Text highlight", help: "Optional highlighted labels.", allowEmpty: true },
|
||||||
|
{ key: "cardColor", label: "Card color", help: "Card / panel background." },
|
||||||
|
{ key: "cardTextColor", label: "Card text color", help: "Text on cards." },
|
||||||
|
];
|
||||||
|
|
||||||
|
function RgbaColorField({
|
||||||
|
label,
|
||||||
|
help,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required,
|
||||||
|
allowEmpty,
|
||||||
|
swatchFallback = "#151937",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
help: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (next: string) => void;
|
||||||
|
required?: boolean;
|
||||||
|
allowEmpty?: boolean;
|
||||||
|
swatchFallback?: string;
|
||||||
|
}) {
|
||||||
|
const parsed = parseColor(value);
|
||||||
|
const alphaPct = parsed ? Math.round(parsed.a * 100) : 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<Label text={label} required={required} />
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={toHexSwatch(value, swatchFallback)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const hex = parseHex(e.target.value);
|
||||||
|
if (!hex) return;
|
||||||
|
onChange(toRgbaString(hex.r, hex.g, hex.b, parsed?.a ?? 1));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 36,
|
||||||
|
padding: 0,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder="rgba(0, 0, 0, 1)"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const next = colorToRgba(value, allowEmpty ? "" : swatchFallback === "#151937" ? DEFAULT_THEME.color : colorToRgba(swatchFallback));
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: "1 1 160px",
|
||||||
|
minWidth: 0,
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
background: "var(--input-background)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--card-foreground)",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={focusIn}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alpha
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={alphaPct}
|
||||||
|
onChange={(e) => {
|
||||||
|
const a = Number(e.target.value) / 100;
|
||||||
|
const base = parsed || parseColor(swatchFallback) || { r: 21, g: 25, b: 55, a: 1 };
|
||||||
|
onChange(toRgbaString(base.r, base.g, base.b, a));
|
||||||
|
}}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
aria-label={`${label} alpha`}
|
||||||
|
/>
|
||||||
|
<span style={{ width: 32, textAlign: "right" }}>{alphaPct}%</span>
|
||||||
|
</label>
|
||||||
|
{allowEmpty && value ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<HelperText text={help} />
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewPageStyle(theme: VisitorThemeConfig): React.CSSProperties {
|
||||||
|
const font = theme.font || "Roboto";
|
||||||
|
const base: React.CSSProperties = {
|
||||||
|
borderRadius: "var(--radius-card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
overflow: "hidden",
|
||||||
|
minHeight: 168,
|
||||||
|
fontFamily: `${font}, system-ui, sans-serif`,
|
||||||
|
color: theme.textColor || "rgba(255, 255, 255, 1)",
|
||||||
|
position: "relative",
|
||||||
|
};
|
||||||
|
if (theme.theme === "gradient") {
|
||||||
|
const c1 = theme.gradient?.color1 || DEFAULT_GRADIENT.color1;
|
||||||
|
const c2 = theme.gradient?.color2 || DEFAULT_GRADIENT.color2;
|
||||||
|
return { ...base, background: `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)` };
|
||||||
|
}
|
||||||
|
if (theme.theme === "background") {
|
||||||
|
const url = theme.background?.url || "";
|
||||||
|
const size = theme.background?.size || "cover";
|
||||||
|
const repeat = theme.background?.repeat === "no" ? "no-repeat" : theme.background?.repeat || "no-repeat";
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
backgroundColor: theme.color || DEFAULT_THEME.color,
|
||||||
|
backgroundImage: url ? `url(${url})` : undefined,
|
||||||
|
backgroundSize: size,
|
||||||
|
backgroundRepeat: repeat,
|
||||||
|
backgroundPosition: "center",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...base, background: theme.color || DEFAULT_THEME.color };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VisitorThemeSettingsPanel({
|
||||||
|
eventId,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved?: () => void;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [theme, setTheme] = useState<VisitorThemeConfig>(DEFAULT_THEME);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
visitorThemeApi
|
||||||
|
.get(eventId)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTheme(normalizeTheme(res.data));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Could not load visitor theme.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
const patchTheme = (partial: Partial<VisitorThemeConfig>) => {
|
||||||
|
setTheme((prev) => {
|
||||||
|
const nextMode = partial.theme ?? prev.theme;
|
||||||
|
const next: VisitorThemeConfig = { ...prev, ...partial, theme: nextMode };
|
||||||
|
if (partial.theme && partial.theme !== prev.theme) {
|
||||||
|
if (nextMode === "gradient" && !next.gradient) {
|
||||||
|
next.gradient = { ...DEFAULT_GRADIENT };
|
||||||
|
}
|
||||||
|
if (nextMode === "background" && !next.background) {
|
||||||
|
next.background = { url: "", size: "cover", repeat: "no" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalizeTheme(next);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadBackground = async (file: File | null) => {
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await uploadMediaFile(file, { eventId, module: "visitor-theme/backgrounds" });
|
||||||
|
const url = r.url || "";
|
||||||
|
if (!url) throw new Error("Upload succeeded but no URL returned");
|
||||||
|
patchTheme({
|
||||||
|
theme: "background",
|
||||||
|
background: {
|
||||||
|
url,
|
||||||
|
size: theme.background?.size || "cover",
|
||||||
|
repeat: theme.background?.repeat || "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Background upload failed.");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const payload = payloadForSave(theme);
|
||||||
|
const res = await visitorThemeApi.update(eventId, payload);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
setTheme(normalizeTheme(res.data ?? payload));
|
||||||
|
onSaved?.();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Could not save visitor theme.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelShell
|
||||||
|
title="Visitor Theme"
|
||||||
|
breadcrumb="Event Home"
|
||||||
|
description="Colors and background for visitor event, session, quiz, and photo pages."
|
||||||
|
completedCount={1}
|
||||||
|
totalSections={1}
|
||||||
|
showProgress={false}
|
||||||
|
ctaLabel={saving ? "Saving…" : "Save"}
|
||||||
|
ctaDisabled={loading || saving}
|
||||||
|
onCtaClick={() => void save()}
|
||||||
|
onClose={onClose}
|
||||||
|
panelWidth="min(560px, 100vw)"
|
||||||
|
>
|
||||||
|
<div style={{ padding: "20px" }}>
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>
|
||||||
|
Loading…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 8px",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--card-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</p>
|
||||||
|
<div style={previewPageStyle(theme)}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: theme.headerColor || "rgba(255, 255, 255, 0.9)",
|
||||||
|
color: theme.cardTextColor || "#333333",
|
||||||
|
padding: "10px 12px",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Event header
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 12 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: theme.cardColor || "rgba(255, 255, 255, 0.65)",
|
||||||
|
color: theme.cardTextColor || "#333333",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: "0 0 8px", fontSize: 13, fontWeight: 600 }}>Session card</p>
|
||||||
|
<p style={{ margin: "0 0 10px", fontSize: 12, opacity: 0.85 }}>
|
||||||
|
Visitor pages use this palette.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "6px 12px",
|
||||||
|
background: theme.buttonColor || theme.primaryColor || "#1e72ef",
|
||||||
|
color: theme.textColor || "#ffffff",
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
cursor: "default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Theme" required />
|
||||||
|
<SelectWrap>
|
||||||
|
<select
|
||||||
|
value={theme.theme}
|
||||||
|
onChange={(e) => patchTheme({ theme: e.target.value as VisitorThemeMode })}
|
||||||
|
style={SS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectWrap>
|
||||||
|
<HelperText text="Simple uses a solid page color. Gradient uses two colors. Background uses an image." />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Label text="Font" required />
|
||||||
|
<SelectWrap>
|
||||||
|
<select
|
||||||
|
value={theme.font || "Roboto"}
|
||||||
|
onChange={(e) => patchTheme({ font: e.target.value })}
|
||||||
|
style={SS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
|
{FONT_OPTIONS.map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectWrap>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{theme.theme === "simple" ? (
|
||||||
|
<RgbaColorField
|
||||||
|
label="Page color"
|
||||||
|
help="Solid background color for visitor pages."
|
||||||
|
required
|
||||||
|
value={theme.color}
|
||||||
|
onChange={(next) => patchTheme({ color: next })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{theme.theme === "gradient" ? (
|
||||||
|
<>
|
||||||
|
<RgbaColorField
|
||||||
|
label="Gradient color 1"
|
||||||
|
help="Start of the page gradient."
|
||||||
|
required
|
||||||
|
value={theme.gradient?.color1 || DEFAULT_GRADIENT.color1}
|
||||||
|
onChange={(next) =>
|
||||||
|
patchTheme({
|
||||||
|
gradient: {
|
||||||
|
color1: next,
|
||||||
|
color2: theme.gradient?.color2 || DEFAULT_GRADIENT.color2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RgbaColorField
|
||||||
|
label="Gradient color 2"
|
||||||
|
help="End of the page gradient."
|
||||||
|
required
|
||||||
|
value={theme.gradient?.color2 || DEFAULT_GRADIENT.color2}
|
||||||
|
onChange={(next) =>
|
||||||
|
patchTheme({
|
||||||
|
gradient: {
|
||||||
|
color1: theme.gradient?.color1 || DEFAULT_GRADIENT.color1,
|
||||||
|
color2: next,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
swatchFallback="#ef8d1d"
|
||||||
|
/>
|
||||||
|
<RgbaColorField
|
||||||
|
label="Fallback color"
|
||||||
|
help="Used if the gradient cannot be applied."
|
||||||
|
required
|
||||||
|
value={theme.color}
|
||||||
|
onChange={(next) => patchTheme({ color: next })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{theme.theme === "background" ? (
|
||||||
|
<>
|
||||||
|
<Field>
|
||||||
|
<Label text="Background image" />
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={theme.background?.url || ""}
|
||||||
|
placeholder="https://…"
|
||||||
|
onChange={(e) =>
|
||||||
|
patchTheme({
|
||||||
|
background: {
|
||||||
|
url: e.target.value,
|
||||||
|
size: theme.background?.size || "cover",
|
||||||
|
repeat: theme.background?.repeat || "no",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
background: "var(--input-background)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--card-foreground)",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
/>
|
||||||
|
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0] ?? null;
|
||||||
|
e.target.value = "";
|
||||||
|
void uploadBackground(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--card-foreground)",
|
||||||
|
cursor: uploading ? "not-allowed" : "pointer",
|
||||||
|
opacity: uploading ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading…" : "Upload image"}
|
||||||
|
</button>
|
||||||
|
{theme.background?.url ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
patchTheme({
|
||||||
|
background: {
|
||||||
|
url: "",
|
||||||
|
size: theme.background?.size || "cover",
|
||||||
|
repeat: theme.background?.repeat || "no",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--card)",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
padding: "6px 12px",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HelperText text="Upload an image or paste a URL." />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Background size" />
|
||||||
|
<SelectWrap>
|
||||||
|
<select
|
||||||
|
value={theme.background?.size || "cover"}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchTheme({
|
||||||
|
background: {
|
||||||
|
url: theme.background?.url || "",
|
||||||
|
size: e.target.value as VisitorThemeBackgroundSize,
|
||||||
|
repeat: theme.background?.repeat || "no",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={SS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
|
{BG_SIZE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectWrap>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label text="Background repeat" />
|
||||||
|
<SelectWrap>
|
||||||
|
<select
|
||||||
|
value={theme.background?.repeat || "no"}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchTheme({
|
||||||
|
background: {
|
||||||
|
url: theme.background?.url || "",
|
||||||
|
size: theme.background?.size || "cover",
|
||||||
|
repeat: e.target.value as VisitorThemeBackgroundRepeat,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={SS}
|
||||||
|
onFocus={focusIn}
|
||||||
|
onBlur={focusOut}
|
||||||
|
>
|
||||||
|
{BG_REPEAT_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</SelectWrap>
|
||||||
|
</Field>
|
||||||
|
<RgbaColorField
|
||||||
|
label="Fallback color"
|
||||||
|
help="Shown behind the image or if the image fails to load."
|
||||||
|
required
|
||||||
|
value={theme.color}
|
||||||
|
onChange={(next) => patchTheme({ color: next })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
paddingTop: "12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: "0 0 4px",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--card-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Palette
|
||||||
|
</p>
|
||||||
|
<HelperText text="All colors are saved as rgba." />
|
||||||
|
</div>
|
||||||
|
{PALETTE_FIELDS.map((field) => (
|
||||||
|
<RgbaColorField
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
help={field.help}
|
||||||
|
allowEmpty={field.allowEmpty}
|
||||||
|
value={String(theme[field.key] ?? "")}
|
||||||
|
onChange={(next) => patchTheme({ [field.key]: next })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
fontFamily: "var(--font-noto-sans)",
|
||||||
|
color: "var(--destructive)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
type Event,
|
type Event,
|
||||||
} from "../../../services/api";
|
} from "../../../services/api";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { WhatsAppReceptionTab } from "./WhatsAppReceptionTab";
|
||||||
|
|
||||||
/** Public concierge API base (must end with /api). Falls back to local concierge in dev. */
|
/** Public concierge API base (must end with /api). Falls back to local concierge in dev. */
|
||||||
const CONCIERGE_API_BASE = (
|
const CONCIERGE_API_BASE = (
|
||||||
|
|
@ -90,7 +91,7 @@ export function WhatsAppChatbotsPage() {
|
||||||
const [bots, setBots] = useState<ConciergeBotRecord[]>([]);
|
const [bots, setBots] = useState<ConciergeBotRecord[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [bot, setBot] = useState<ConciergeBotRecord | null>(null);
|
const [bot, setBot] = useState<ConciergeBotRecord | null>(null);
|
||||||
const [tab, setTab] = useState<"settings" | "venue-faq" | "knowledge" | "preview">("settings");
|
const [tab, setTab] = useState<"settings" | "reception" | "venue-faq" | "knowledge" | "preview">("settings");
|
||||||
const [sources, setSources] = useState<KnowledgeSourceRecord[]>([]);
|
const [sources, setSources] = useState<KnowledgeSourceRecord[]>([]);
|
||||||
const [chunks, setChunks] = useState<ContextChunkRecord[]>([]);
|
const [chunks, setChunks] = useState<ContextChunkRecord[]>([]);
|
||||||
const [previewSourceId, setPreviewSourceId] = useState<string | null>(null);
|
const [previewSourceId, setPreviewSourceId] = useState<string | null>(null);
|
||||||
|
|
@ -105,6 +106,8 @@ export function WhatsAppChatbotsPage() {
|
||||||
const [urlName, setUrlName] = useState("");
|
const [urlName, setUrlName] = useState("");
|
||||||
const [urlCrawlScope, setUrlCrawlScope] = useState<"auto" | "domain" | "path">("auto");
|
const [urlCrawlScope, setUrlCrawlScope] = useState<"auto" | "domain" | "path">("auto");
|
||||||
const [listError, setListError] = useState<string | null>(null);
|
const [listError, setListError] = useState<string | null>(null);
|
||||||
|
const [pendingEventId, setPendingEventId] = useState("");
|
||||||
|
const [eventChangeOpen, setEventChangeOpen] = useState(false);
|
||||||
|
|
||||||
const listOrgId = portfolioListOrganizationId(user);
|
const listOrgId = portfolioListOrganizationId(user);
|
||||||
|
|
||||||
|
|
@ -128,7 +131,9 @@ export function WhatsAppChatbotsPage() {
|
||||||
|
|
||||||
const loadBot = useCallback(async (id: string) => {
|
const loadBot = useCallback(async (id: string) => {
|
||||||
const res = await conciergeBotsApi.get(id);
|
const res = await conciergeBotsApi.get(id);
|
||||||
setBot(res.data?.bot || null);
|
const next = res.data?.bot || null;
|
||||||
|
setBot(next);
|
||||||
|
setPendingEventId(next ? eventIdOf(next) : "");
|
||||||
const src = await conciergeBotsApi.listSources(id);
|
const src = await conciergeBotsApi.listSources(id);
|
||||||
setSources(src.data?.sources || []);
|
setSources(src.data?.sources || []);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -153,12 +158,19 @@ export function WhatsAppChatbotsPage() {
|
||||||
else setBot(null);
|
else setBot(null);
|
||||||
}, [selectedId, loadBot]);
|
}, [selectedId, loadBot]);
|
||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = async (opts?: { confirmEventChange?: boolean }) => {
|
||||||
if (!bot) return;
|
if (!bot) return;
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
|
const currentEventId = eventIdOf(bot);
|
||||||
|
const nextEventId = pendingEventId || currentEventId;
|
||||||
|
if (nextEventId && nextEventId !== currentEventId && !opts?.confirmEventChange) {
|
||||||
|
setEventChangeOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const res = await conciergeBotsApi.update(bot._id, {
|
const res = await conciergeBotsApi.update(bot._id, {
|
||||||
name: bot.name,
|
name: bot.name,
|
||||||
wabaPhoneNumberId: bot.wabaPhoneNumberId,
|
wabaPhoneNumberId: bot.wabaPhoneNumberId,
|
||||||
|
eventId: nextEventId || undefined,
|
||||||
enabled: Boolean(bot.enabled),
|
enabled: Boolean(bot.enabled),
|
||||||
greeting: bot.greeting,
|
greeting: bot.greeting,
|
||||||
registration: bot.registration,
|
registration: bot.registration,
|
||||||
|
|
@ -186,10 +198,16 @@ export function WhatsAppChatbotsPage() {
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
|
setEventChangeOpen(false);
|
||||||
setMsg(res.error);
|
setMsg(res.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMsg("Settings saved");
|
setEventChangeOpen(false);
|
||||||
|
setMsg(
|
||||||
|
res.data?.eventChanged
|
||||||
|
? "Event updated. Previous knowledge was removed. Event programme, food, and places are re-syncing for the new event."
|
||||||
|
: "Settings saved",
|
||||||
|
);
|
||||||
await loadBot(bot._id);
|
await loadBot(bot._id);
|
||||||
await loadBots();
|
await loadBots();
|
||||||
};
|
};
|
||||||
|
|
@ -430,6 +448,7 @@ export function WhatsAppChatbotsPage() {
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
|
||||||
{([
|
{([
|
||||||
["settings", "Settings"],
|
["settings", "Settings"],
|
||||||
|
["reception", "Reception"],
|
||||||
["venue-faq", "Bot FAQ"],
|
["venue-faq", "Bot FAQ"],
|
||||||
["knowledge", "Knowledge"],
|
["knowledge", "Knowledge"],
|
||||||
["preview", "Preview"],
|
["preview", "Preview"],
|
||||||
|
|
@ -451,6 +470,34 @@ export function WhatsAppChatbotsPage() {
|
||||||
Display name
|
Display name
|
||||||
<input style={IS} value={bot.name || ""} onChange={(e) => setBot({ ...bot, name: e.target.value })} />
|
<input style={IS} value={bot.name || ""} onChange={(e) => setBot({ ...bot, name: e.target.value })} />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Event
|
||||||
|
<select
|
||||||
|
style={IS}
|
||||||
|
value={pendingEventId || eventIdOf(bot)}
|
||||||
|
onChange={(e) => setPendingEventId(e.target.value)}
|
||||||
|
>
|
||||||
|
{!(events || []).some((ev) => ev._id === (pendingEventId || eventIdOf(bot))) && (
|
||||||
|
<option value={pendingEventId || eventIdOf(bot)}>{eventName(bot)}</option>
|
||||||
|
)}
|
||||||
|
{events
|
||||||
|
.filter((ev) => ev.recordKind !== "activity")
|
||||||
|
.map((ev) => {
|
||||||
|
const takenByOther = bots.some(
|
||||||
|
(b) => b._id !== bot._id && eventIdOf(b) === ev._id,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<option key={ev._id} value={ev._id} disabled={takenByOther}>
|
||||||
|
{ev.name}
|
||||||
|
{takenByOther ? " (already has a chatbot)" : ""}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--muted-foreground)" }}>
|
||||||
|
Changing the event removes current knowledge and re-syncs programme, food, and places for the new event.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
WhatsApp phone number ID
|
WhatsApp phone number ID
|
||||||
<input
|
<input
|
||||||
|
|
@ -605,6 +652,14 @@ export function WhatsAppChatbotsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "reception" && (
|
||||||
|
<WhatsAppReceptionTab
|
||||||
|
bot={bot}
|
||||||
|
eventId={eventIdOf(bot)}
|
||||||
|
onSaved={() => loadBot(bot._id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === "venue-faq" && (
|
{tab === "venue-faq" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
<p style={{ margin: 0, fontSize: 13, color: "var(--muted-foreground)" }}>
|
<p style={{ margin: 0, fontSize: 13, color: "var(--muted-foreground)" }}>
|
||||||
|
|
@ -1017,6 +1072,49 @@ export function WhatsAppChatbotsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{eventChangeOpen && bot && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.4)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 110,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ background: "var(--card)", padding: 24, borderRadius: 12, width: 480, maxWidth: "90vw" }}>
|
||||||
|
<h2 style={{ marginTop: 0, fontSize: 18 }}>Change event?</h2>
|
||||||
|
<p style={{ margin: "0 0 12px", fontSize: 14 }}>
|
||||||
|
Move this chatbot from <strong>{eventName(bot)}</strong> to{" "}
|
||||||
|
<strong>{events.find((ev) => ev._id === pendingEventId)?.name || "the selected event"}</strong>?
|
||||||
|
</p>
|
||||||
|
<ul style={{ margin: "0 0 16px", paddingLeft: 18, fontSize: 13, lineHeight: 1.5 }}>
|
||||||
|
<li>Event programme, food, and tourist places knowledge will be removed and re-synced for the new event.</li>
|
||||||
|
<li>Uploaded PDFs and website URLs for the current event will be removed. You can add new sources after the change.</li>
|
||||||
|
<li>Reception visitors will need to identify again for the new event.</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ margin: "0 0 16px", fontSize: 12, color: "var(--muted-foreground)" }}>This cannot be undone.</p>
|
||||||
|
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={btnSecondary}
|
||||||
|
onClick={() => {
|
||||||
|
setEventChangeOpen(false);
|
||||||
|
setPendingEventId(eventIdOf(bot));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" style={btnPrimary} onClick={() => void saveSettings({ confirmEventChange: true })}>
|
||||||
|
Change event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Plus, Trash2, Download, Settings } from "lucide-react";
|
||||||
|
import {
|
||||||
|
conciergeBotsApi,
|
||||||
|
uploadMediaFile,
|
||||||
|
whatsappTemplatesApi,
|
||||||
|
type ConciergeBotRecord,
|
||||||
|
type ConciergeBotReception,
|
||||||
|
type ConciergeReceptionDrip,
|
||||||
|
type ConciergeReceptionInfo,
|
||||||
|
} from "../../../services/api";
|
||||||
|
import type { WhatsAppTemplateRecord } from "../../../services/api/types";
|
||||||
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
|
|
||||||
|
const IS: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
background: "var(--input-background)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
color: "var(--card-foreground)",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnPrimary: React.CSSProperties = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
background: "var(--primary)",
|
||||||
|
color: "var(--primary-foreground)",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnSecondary: React.CSSProperties = {
|
||||||
|
...btnPrimary,
|
||||||
|
background: "var(--secondary)",
|
||||||
|
color: "var(--card-foreground)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_WELCOME = "Hi {firstName}! Welcome to {eventName}.";
|
||||||
|
const DEFAULT_BADGE =
|
||||||
|
"We couldn't match your WhatsApp to a registration. Please send a clear photo of your badge QR code.";
|
||||||
|
const DEFAULT_STAFF =
|
||||||
|
"We still couldn't identify you. Please see the reception desk for help. You can also send another badge photo.";
|
||||||
|
|
||||||
|
function emptyDrip(): ConciergeReceptionDrip {
|
||||||
|
return {
|
||||||
|
trigger: "after_first_inbound",
|
||||||
|
delayMinutes: 15,
|
||||||
|
sendAt: "",
|
||||||
|
body: "",
|
||||||
|
mediaType: "none",
|
||||||
|
mediaUrl: "",
|
||||||
|
templateId: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function receptionFromBot(bot: ConciergeBotRecord): ConciergeBotReception {
|
||||||
|
const r = bot.reception || {};
|
||||||
|
return {
|
||||||
|
enabled: Boolean(r.enabled),
|
||||||
|
prefillText: r.prefillText || "Hi",
|
||||||
|
welcomeMessage: r.welcomeMessage || DEFAULT_WELCOME,
|
||||||
|
unmatchedBadgePrompt: r.unmatchedBadgePrompt || DEFAULT_BADGE,
|
||||||
|
staffHandoffMessage: r.staffHandoffMessage || DEFAULT_STAFF,
|
||||||
|
dripAudience: r.dripAudience === "all_inbound" ? "all_inbound" : "matched",
|
||||||
|
drips: Array.isArray(r.drips) && r.drips.length ? r.drips.map((d) => ({ ...emptyDrip(), ...d })) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WhatsAppReceptionTab({
|
||||||
|
bot,
|
||||||
|
eventId,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
bot: ConciergeBotRecord;
|
||||||
|
eventId: string;
|
||||||
|
onSaved: () => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
const { setActiveNav, setSettingsInitialSection } = useAppNav();
|
||||||
|
const [reception, setReception] = useState<ConciergeBotReception>(() => receptionFromBot(bot));
|
||||||
|
const [info, setInfo] = useState<ConciergeReceptionInfo | null>(null);
|
||||||
|
const [templates, setTemplates] = useState<WhatsAppTemplateRecord[]>([]);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setReception(receptionFromBot(bot));
|
||||||
|
}, [bot._id, bot.updatedAt]);
|
||||||
|
|
||||||
|
const loadInfo = useCallback(async () => {
|
||||||
|
const res = await conciergeBotsApi.getReception(bot._id);
|
||||||
|
if (!res.error && res.data) setInfo(res.data);
|
||||||
|
}, [bot._id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInfo();
|
||||||
|
whatsappTemplatesApi.list({ status: "APPROVED" }).then((r) => {
|
||||||
|
if (!r.error) setTemplates(r.data?.templates || []);
|
||||||
|
});
|
||||||
|
}, [loadInfo]);
|
||||||
|
|
||||||
|
const patch = (partial: Partial<ConciergeBotReception>) => setReception((prev) => ({ ...prev, ...partial }));
|
||||||
|
|
||||||
|
const patchDrip = (index: number, partial: Partial<ConciergeReceptionDrip>) => {
|
||||||
|
const drips = [...(reception.drips || [])];
|
||||||
|
drips[index] = { ...drips[index], ...partial };
|
||||||
|
patch({ drips });
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setMsg(null);
|
||||||
|
const res = await conciergeBotsApi.update(bot._id, {
|
||||||
|
reception: {
|
||||||
|
...reception,
|
||||||
|
drips: (reception.drips || []).map((d) => ({
|
||||||
|
...d,
|
||||||
|
templateId: d.templateId || null,
|
||||||
|
sendAt: d.sendAt || null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
if (res.error) {
|
||||||
|
setMsg(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMsg("Reception settings saved");
|
||||||
|
await onSaved();
|
||||||
|
await loadInfo();
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadDripMedia = async (index: number, file: File) => {
|
||||||
|
const up = await uploadMediaFile(file, { eventId: eventId || "portfolio", module: "whatsapp/reception-drip" });
|
||||||
|
if (up.error || !up.url) {
|
||||||
|
setMsg(up.error || "Upload failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mime = String(file.type || "").toLowerCase();
|
||||||
|
const mediaType = mime.startsWith("video/") ? "video" : "image";
|
||||||
|
patchDrip(index, { mediaUrl: up.url, mediaType });
|
||||||
|
};
|
||||||
|
|
||||||
|
const goSettings = () => {
|
||||||
|
setSettingsInitialSection("whatsapp");
|
||||||
|
setActiveNav("settings");
|
||||||
|
};
|
||||||
|
|
||||||
|
const outside24h = (d: ConciergeReceptionDrip) => {
|
||||||
|
if (d.trigger === "absolute") return true;
|
||||||
|
return Number(d.delayMinutes || 0) > 24 * 60;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16, maxWidth: 720 }}>
|
||||||
|
<section style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
|
||||||
|
<h3 style={{ margin: "0 0 10px", fontSize: 14 }}>Desk / reception</h3>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(reception.enabled)}
|
||||||
|
onChange={(e) => patch({ enabled: e.target.checked })}
|
||||||
|
disabled={!info?.canSendMessages}
|
||||||
|
/>
|
||||||
|
Enable reception check-in
|
||||||
|
</label>
|
||||||
|
{!info?.canSendMessages && (
|
||||||
|
<p style={{ fontSize: 12, color: "var(--muted-foreground)", margin: "0 0 10px" }}>
|
||||||
|
WhatsApp Cloud is not ready to send. Configure WABA ID, token, and phone number ID in Organisation
|
||||||
|
settings.
|
||||||
|
<button type="button" style={{ ...btnSecondary, marginLeft: 8 }} onClick={goSettings}>
|
||||||
|
<Settings size={14} /> WhatsApp settings
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{info?.resolveError && info.canSendMessages && (
|
||||||
|
<p style={{ fontSize: 12, color: "var(--destructive)", margin: "0 0 8px" }}>{info.resolveError}</p>
|
||||||
|
)}
|
||||||
|
{info?.displayPhone && (
|
||||||
|
<p style={{ fontSize: 13, margin: "0 0 8px" }}>
|
||||||
|
Desk number (from organisation settings): <strong>{info.displayPhone}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{info?.qrPngBase64 && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<img
|
||||||
|
alt="Reception WhatsApp QR"
|
||||||
|
src={`data:image/png;base64,${info.qrPngBase64}`}
|
||||||
|
style={{ width: 180, height: 180, background: "#fff", borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<a href={info.waMeUrl} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
|
||||||
|
Open wa.me link
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`data:image/png;base64,${info.qrPngBase64}`}
|
||||||
|
download="reception-whatsapp-qr.png"
|
||||||
|
style={{ ...btnSecondary, textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
<Download size={14} /> Download QR
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
Prefill message
|
||||||
|
<input
|
||||||
|
style={IS}
|
||||||
|
value={reception.prefillText || ""}
|
||||||
|
onChange={(e) => patch({ prefillText: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
|
||||||
|
Visitors scan the QR and send this text (default Hi). WhatsApp Cloud credentials stay in Organisation
|
||||||
|
settings.
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
Welcome message
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, minHeight: 72 }}
|
||||||
|
value={reception.welcomeMessage || ""}
|
||||||
|
onChange={(e) => patch({ welcomeMessage: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p style={{ fontSize: 11, color: "var(--muted-foreground)", margin: "4px 0 10px" }}>
|
||||||
|
Placeholders: {"{firstName}"}, {"{profileName}"}, {"{eventName}"}
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
If not matched — ask for badge photo
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, minHeight: 64 }}
|
||||||
|
value={reception.unmatchedBadgePrompt || ""}
|
||||||
|
onChange={(e) => patch({ unmatchedBadgePrompt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={{ marginTop: 8, display: "block" }}>
|
||||||
|
After badge fails — see reception
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, minHeight: 64 }}
|
||||||
|
value={reception.staffHandoffMessage || ""}
|
||||||
|
onChange={(e) => patch({ staffHandoffMessage: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ padding: 12, border: "1px solid var(--border)", borderRadius: 8 }}>
|
||||||
|
<h3 style={{ margin: "0 0 10px", fontSize: 14 }}>Drip messages</h3>
|
||||||
|
<label style={{ display: "block", marginBottom: 10 }}>
|
||||||
|
Send drips to
|
||||||
|
<select
|
||||||
|
style={IS}
|
||||||
|
value={reception.dripAudience || "matched"}
|
||||||
|
onChange={(e) => patch({ dripAudience: e.target.value as "matched" | "all_inbound" })}
|
||||||
|
>
|
||||||
|
<option value="matched">Matched participants only</option>
|
||||||
|
<option value="all_inbound">Everyone who messaged (including unidentified)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{(reception.drips || []).map((drip, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{ border: "1px solid var(--border)", borderRadius: 8, padding: 10, marginBottom: 10 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>Message {i + 1}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ ...btnSecondary, color: "var(--destructive)" }}
|
||||||
|
onClick={() => patch({ drips: (reception.drips || []).filter((_, j) => j !== i) })}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
When
|
||||||
|
<select
|
||||||
|
style={IS}
|
||||||
|
value={drip.trigger || "after_first_inbound"}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchDrip(i, { trigger: e.target.value as ConciergeReceptionDrip["trigger"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="after_first_inbound">Minutes after first inbound</option>
|
||||||
|
<option value="after_match">Minutes after we identify them</option>
|
||||||
|
<option value="absolute">Fixed date and time</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{drip.trigger === "absolute" ? (
|
||||||
|
<label>
|
||||||
|
Send at
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
style={IS}
|
||||||
|
value={drip.sendAt ? String(drip.sendAt).slice(0, 16) : ""}
|
||||||
|
onChange={(e) => patchDrip(i, { sendAt: e.target.value ? new Date(e.target.value).toISOString() : "" })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label>
|
||||||
|
Delay (minutes)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
style={IS}
|
||||||
|
value={drip.delayMinutes ?? 0}
|
||||||
|
onChange={(e) => patchDrip(i, { delayMinutes: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
Body
|
||||||
|
<textarea
|
||||||
|
style={{ ...IS, minHeight: 64 }}
|
||||||
|
value={drip.body || ""}
|
||||||
|
onChange={(e) => patchDrip(i, { body: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Media
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void uploadDripMedia(i, f);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{drip.mediaUrl && (
|
||||||
|
<p style={{ fontSize: 11, margin: "4px 0" }}>
|
||||||
|
{drip.mediaType}: {drip.mediaUrl}
|
||||||
|
<button type="button" style={btnSecondary} onClick={() => patchDrip(i, { mediaUrl: "", mediaType: "none" })}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{outside24h(drip) && (
|
||||||
|
<p style={{ fontSize: 12, color: "var(--destructive)", margin: "6px 0" }}>
|
||||||
|
This send may fall outside WhatsApp’s 24-hour window. Pick an approved template.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
Template (if outside 24h)
|
||||||
|
<select
|
||||||
|
style={IS}
|
||||||
|
value={drip.templateId || ""}
|
||||||
|
onChange={(e) => patchDrip(i, { templateId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t._id} value={t._id}>
|
||||||
|
{t.name} ({t.language})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" style={btnSecondary} onClick={() => patch({ drips: [...(reception.drips || []), emptyDrip()] })}>
|
||||||
|
<Plus size={14} /> Add drip message
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{msg && <p style={{ fontSize: 13, margin: 0 }}>{msg}</p>}
|
||||||
|
<button type="button" style={btnPrimary} disabled={saving} onClick={() => void save()}>
|
||||||
|
{saving ? "Saving…" : "Save reception"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/** Participant SPA host — never marketing apex (eventbuzz.live). */
|
||||||
|
function eventbuzzPublicBase(): string {
|
||||||
|
const fromEnv =
|
||||||
|
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === "string" &&
|
||||||
|
import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) ||
|
||||||
|
"";
|
||||||
|
const raw = (fromEnv || (import.meta.env.DEV ? "http://localhost:5174" : "https://join.eventbuzz.live")).replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
if (u.hostname === "eventbuzz.live" || u.hostname === "www.eventbuzz.live") {
|
||||||
|
u.hostname = "join.eventbuzz.live";
|
||||||
|
return u.origin;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* keep raw */
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiAvatarVisitorUrl(uploadToken: string): string {
|
||||||
|
const base = eventbuzzPublicBase();
|
||||||
|
return `${base}/ai-avatar/${encodeURIComponent(uploadToken)}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/** Participant SPA host — never marketing apex (eventbuzz.live). */
|
||||||
|
function eventbuzzPublicBase(): string {
|
||||||
|
const fromEnv =
|
||||||
|
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === "string" &&
|
||||||
|
import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) ||
|
||||||
|
"";
|
||||||
|
const raw = (fromEnv || (import.meta.env.DEV ? "http://localhost:5174" : "https://join.eventbuzz.live")).replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
if (u.hostname === "eventbuzz.live" || u.hostname === "www.eventbuzz.live") {
|
||||||
|
u.hostname = "join.eventbuzz.live";
|
||||||
|
return u.origin;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* keep raw */
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function photoSearchVisitorUrl(searchToken: string): string {
|
||||||
|
const base = eventbuzzPublicBase();
|
||||||
|
return `${base}/photo-search/${encodeURIComponent(searchToken)}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/** Participant SPA host — never marketing apex (eventbuzz.live). */
|
||||||
|
function eventbuzzPublicBase(): string {
|
||||||
|
const fromEnv =
|
||||||
|
(typeof import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL === "string" &&
|
||||||
|
import.meta.env.VITE_EVENTBUZZ_PUBLIC_URL.trim()) ||
|
||||||
|
"";
|
||||||
|
const raw = (fromEnv || (import.meta.env.DEV ? "http://localhost:5174" : "https://join.eventbuzz.live")).replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
if (u.hostname === "eventbuzz.live" || u.hostname === "www.eventbuzz.live") {
|
||||||
|
u.hostname = "join.eventbuzz.live";
|
||||||
|
return u.origin;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* keep raw */
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function socialShareVisitorUrl(uploadToken: string): string {
|
||||||
|
const base = eventbuzzPublicBase();
|
||||||
|
return `${base}/social-share/${encodeURIComponent(uploadToken)}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { api, fetchWithAuth } from './client';
|
||||||
|
|
||||||
|
export type AiAvatarAccessMode = 'open' | 'login';
|
||||||
|
|
||||||
|
export interface AiAvatarEventHeader {
|
||||||
|
showLogo?: boolean;
|
||||||
|
showBanner?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
|
showDateRange?: boolean;
|
||||||
|
showEventType?: boolean;
|
||||||
|
showLocationType?: boolean;
|
||||||
|
showVenue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAvatarTemplate {
|
||||||
|
_id: string;
|
||||||
|
aiAvatar: string;
|
||||||
|
event: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
imageUrl: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAvatarConfig {
|
||||||
|
_id: string;
|
||||||
|
event: string;
|
||||||
|
organization: string;
|
||||||
|
enabled: boolean;
|
||||||
|
accessMode: AiAvatarAccessMode;
|
||||||
|
allowDownload: boolean;
|
||||||
|
sharePlatforms?: {
|
||||||
|
linkedin?: boolean;
|
||||||
|
facebook?: boolean;
|
||||||
|
instagram?: boolean;
|
||||||
|
snapchat?: boolean;
|
||||||
|
x?: boolean;
|
||||||
|
whatsapp?: boolean;
|
||||||
|
};
|
||||||
|
eventHeader: AiAvatarEventHeader;
|
||||||
|
uploadToken: string | null;
|
||||||
|
avatars: AiAvatarTemplate[];
|
||||||
|
avatarCount: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiAvatarUpdatePayload = Partial<{
|
||||||
|
enabled: boolean;
|
||||||
|
accessMode: AiAvatarAccessMode;
|
||||||
|
allowDownload: boolean;
|
||||||
|
sharePlatforms: {
|
||||||
|
linkedin: boolean;
|
||||||
|
facebook: boolean;
|
||||||
|
instagram: boolean;
|
||||||
|
snapchat: boolean;
|
||||||
|
x: boolean;
|
||||||
|
whatsapp: boolean;
|
||||||
|
};
|
||||||
|
eventHeader: AiAvatarEventHeader;
|
||||||
|
regenerateTokens: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AiAvatarTemplatePayload = Partial<{
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
imageUrl: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const aiAvatarApi = {
|
||||||
|
get(eventId: string) {
|
||||||
|
return api.get<AiAvatarConfig>(`/events/${eventId}/ai-avatar`);
|
||||||
|
},
|
||||||
|
|
||||||
|
update(eventId: string, payload: AiAvatarUpdatePayload) {
|
||||||
|
return api.patch<AiAvatarConfig>(`/events/${eventId}/ai-avatar`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureTokens(eventId: string) {
|
||||||
|
return api.post<AiAvatarConfig>(`/events/${eventId}/ai-avatar/ensure-tokens`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
listAvatars(eventId: string) {
|
||||||
|
return api.get<{ avatars: AiAvatarTemplate[] }>(`/events/${eventId}/ai-avatar/avatars`);
|
||||||
|
},
|
||||||
|
|
||||||
|
createAvatar(eventId: string, payload: AiAvatarTemplatePayload = {}) {
|
||||||
|
return api.post<AiAvatarTemplate>(`/events/${eventId}/ai-avatar/avatars`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAvatar(eventId: string, avatarId: string, payload: AiAvatarTemplatePayload) {
|
||||||
|
return api.patch<AiAvatarTemplate>(
|
||||||
|
`/events/${eventId}/ai-avatar/avatars/${avatarId}`,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAvatar(eventId: string, avatarId: string) {
|
||||||
|
return fetchWithAuth(`/events/${eventId}/ai-avatar/avatars/${avatarId}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -12,6 +12,37 @@ export interface ConciergeBotRegistration {
|
||||||
allowGuestSkip?: boolean;
|
allowGuestSkip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConciergeReceptionDrip {
|
||||||
|
trigger?: "after_first_inbound" | "after_match" | "absolute";
|
||||||
|
delayMinutes?: number;
|
||||||
|
sendAt?: string | null;
|
||||||
|
body?: string;
|
||||||
|
mediaType?: "none" | "image" | "video";
|
||||||
|
mediaUrl?: string;
|
||||||
|
templateId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConciergeBotReception {
|
||||||
|
enabled?: boolean;
|
||||||
|
prefillText?: string;
|
||||||
|
welcomeMessage?: string;
|
||||||
|
unmatchedBadgePrompt?: string;
|
||||||
|
staffHandoffMessage?: string;
|
||||||
|
dripAudience?: "matched" | "all_inbound";
|
||||||
|
drips?: ConciergeReceptionDrip[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConciergeReceptionInfo {
|
||||||
|
displayPhone: string;
|
||||||
|
displayDigits: string;
|
||||||
|
waMeUrl: string;
|
||||||
|
qrPngBase64: string;
|
||||||
|
canSendMessages: boolean;
|
||||||
|
graphAccess: boolean;
|
||||||
|
resolveError: string | null;
|
||||||
|
prefillText: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConciergeBotKnowledge {
|
export interface ConciergeBotKnowledge {
|
||||||
includeEventDetails?: boolean;
|
includeEventDetails?: boolean;
|
||||||
includeFoodRecommendations?: boolean;
|
includeFoodRecommendations?: boolean;
|
||||||
|
|
@ -79,6 +110,7 @@ export interface ConciergeBotRecord {
|
||||||
greeting?: ConciergeBotGreeting;
|
greeting?: ConciergeBotGreeting;
|
||||||
registration?: ConciergeBotRegistration;
|
registration?: ConciergeBotRegistration;
|
||||||
knowledge?: ConciergeBotKnowledge;
|
knowledge?: ConciergeBotKnowledge;
|
||||||
|
reception?: ConciergeBotReception;
|
||||||
faqs?: ConciergeFaqItem[];
|
faqs?: ConciergeFaqItem[];
|
||||||
lastKnowledgeClearedAt?: string;
|
lastKnowledgeClearedAt?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|
@ -164,9 +196,15 @@ export const conciergeBotsApi = {
|
||||||
knowledge: ConciergeBotKnowledge;
|
knowledge: ConciergeBotKnowledge;
|
||||||
faqs: ConciergeFaqItem[];
|
faqs: ConciergeFaqItem[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
reception: ConciergeBotReception;
|
||||||
|
eventId: string;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
return api.put<{ bot: ConciergeBotRecord }>(`/concierge-bots/${botId}`, payload);
|
return api.put<{ bot: ConciergeBotRecord; eventChanged?: boolean }>(`/concierge-bots/${botId}`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
getReception(botId: string) {
|
||||||
|
return api.get<ConciergeReceptionInfo>(`/concierge-bots/${botId}/reception`);
|
||||||
},
|
},
|
||||||
|
|
||||||
remove(botId: string) {
|
remove(botId: string) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export type EventPhotoSearchAccessMode = 'open' | 'login';
|
||||||
|
|
||||||
|
export interface EventPhotoSearchEventHeader {
|
||||||
|
showLogo?: boolean;
|
||||||
|
showBanner?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
|
showDateRange?: boolean;
|
||||||
|
showEventType?: boolean;
|
||||||
|
showLocationType?: boolean;
|
||||||
|
showVenue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventPhotoSearchConfig {
|
||||||
|
_id: string;
|
||||||
|
event: string;
|
||||||
|
organization: string;
|
||||||
|
enabled: boolean;
|
||||||
|
accessMode: EventPhotoSearchAccessMode;
|
||||||
|
allowDownload: boolean;
|
||||||
|
sharePlatforms?: {
|
||||||
|
linkedin?: boolean;
|
||||||
|
facebook?: boolean;
|
||||||
|
instagram?: boolean;
|
||||||
|
snapchat?: boolean;
|
||||||
|
x?: boolean;
|
||||||
|
whatsapp?: boolean;
|
||||||
|
};
|
||||||
|
eventHeader: EventPhotoSearchEventHeader;
|
||||||
|
searchToken: string | null;
|
||||||
|
photoCount: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventPhotoSearchUpdatePayload = Partial<{
|
||||||
|
enabled: boolean;
|
||||||
|
accessMode: EventPhotoSearchAccessMode;
|
||||||
|
allowDownload: boolean;
|
||||||
|
sharePlatforms: {
|
||||||
|
linkedin: boolean;
|
||||||
|
facebook: boolean;
|
||||||
|
instagram: boolean;
|
||||||
|
snapchat: boolean;
|
||||||
|
x: boolean;
|
||||||
|
whatsapp: boolean;
|
||||||
|
};
|
||||||
|
eventHeader: EventPhotoSearchEventHeader;
|
||||||
|
regenerateTokens: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const eventPhotoSearchApi = {
|
||||||
|
get(eventId: string) {
|
||||||
|
return api.get<EventPhotoSearchConfig>(`/events/${eventId}/photo-search`);
|
||||||
|
},
|
||||||
|
|
||||||
|
update(eventId: string, payload: EventPhotoSearchUpdatePayload) {
|
||||||
|
return api.patch<EventPhotoSearchConfig>(`/events/${eventId}/photo-search`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureTokens(eventId: string) {
|
||||||
|
return api.post<EventPhotoSearchConfig>(`/events/${eventId}/photo-search/ensure-tokens`, {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -13,6 +13,31 @@ export { rewardPointsApi, type LeaderboardRow, type CreateRewardPointPayload } f
|
||||||
export { participantsApi, type ParticipantWithEvent } from './participants';
|
export { participantsApi, type ParticipantWithEvent } from './participants';
|
||||||
export { participantDocumentsApi, type ParticipantDocumentRow } from './participantDocuments';
|
export { participantDocumentsApi, type ParticipantDocumentRow } from './participantDocuments';
|
||||||
export { eventPhotoApi, type EventPhotoRow } from './eventPhoto';
|
export { eventPhotoApi, type EventPhotoRow } from './eventPhoto';
|
||||||
|
export {
|
||||||
|
eventPhotoSearchApi,
|
||||||
|
type EventPhotoSearchConfig,
|
||||||
|
type EventPhotoSearchUpdatePayload,
|
||||||
|
type EventPhotoSearchAccessMode,
|
||||||
|
type EventPhotoSearchEventHeader,
|
||||||
|
} from './eventPhotoSearch';
|
||||||
|
export {
|
||||||
|
socialShareApi,
|
||||||
|
type SocialShareConfig,
|
||||||
|
type SocialShareFrame,
|
||||||
|
type SocialShareUpdatePayload,
|
||||||
|
type SocialShareFramePayload,
|
||||||
|
type SocialShareAccessMode,
|
||||||
|
type SocialShareEventHeader,
|
||||||
|
} from './socialShare';
|
||||||
|
export {
|
||||||
|
aiAvatarApi,
|
||||||
|
type AiAvatarConfig,
|
||||||
|
type AiAvatarTemplate,
|
||||||
|
type AiAvatarUpdatePayload,
|
||||||
|
type AiAvatarTemplatePayload,
|
||||||
|
type AiAvatarAccessMode,
|
||||||
|
type AiAvatarEventHeader,
|
||||||
|
} from './aiAvatar';
|
||||||
export {
|
export {
|
||||||
photoWallApi,
|
photoWallApi,
|
||||||
type PhotoWallConfig,
|
type PhotoWallConfig,
|
||||||
|
|
@ -88,6 +113,7 @@ export {
|
||||||
type WebsiteSectionType,
|
type WebsiteSectionType,
|
||||||
} from './pages';
|
} from './pages';
|
||||||
export { websiteSettingsApi, type WebsiteSettings } from './websiteSettings';
|
export { websiteSettingsApi, type WebsiteSettings } from './websiteSettings';
|
||||||
|
export { visitorThemeApi } from './visitorTheme';
|
||||||
export {
|
export {
|
||||||
eventMicrositesApi,
|
eventMicrositesApi,
|
||||||
type MicrositeCreatePayload,
|
type MicrositeCreatePayload,
|
||||||
|
|
@ -100,6 +126,9 @@ export { whatsappTemplatesApi } from './whatsappTemplates';
|
||||||
export {
|
export {
|
||||||
conciergeBotsApi,
|
conciergeBotsApi,
|
||||||
type ConciergeBotRecord,
|
type ConciergeBotRecord,
|
||||||
|
type ConciergeBotReception,
|
||||||
|
type ConciergeReceptionDrip,
|
||||||
|
type ConciergeReceptionInfo,
|
||||||
type ConciergeFaqCategory,
|
type ConciergeFaqCategory,
|
||||||
type ConciergeFaqItem,
|
type ConciergeFaqItem,
|
||||||
type KnowledgeSourceRecord,
|
type KnowledgeSourceRecord,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { api, fetchWithAuth } from './client';
|
||||||
|
import type { PhotoWallFrameElement, PhotoWallFrameImageLayout } from './photoWall';
|
||||||
|
|
||||||
|
export type SocialShareAccessMode = 'open' | 'login';
|
||||||
|
|
||||||
|
export interface SocialShareEventHeader {
|
||||||
|
showLogo?: boolean;
|
||||||
|
showBanner?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
|
showDateRange?: boolean;
|
||||||
|
showEventType?: boolean;
|
||||||
|
showLocationType?: boolean;
|
||||||
|
showVenue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialShareFrame {
|
||||||
|
_id: string;
|
||||||
|
socialShare: string;
|
||||||
|
event: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
frameImageUrl: string | null;
|
||||||
|
frameImageLayout: PhotoWallFrameImageLayout;
|
||||||
|
frameCanvasWidth: number;
|
||||||
|
frameCanvasHeight: number;
|
||||||
|
frameLayout: PhotoWallFrameElement[];
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialShareConfig {
|
||||||
|
_id: string;
|
||||||
|
event: string;
|
||||||
|
organization: string;
|
||||||
|
enabled: boolean;
|
||||||
|
accessMode: SocialShareAccessMode;
|
||||||
|
requireUploadCrop: boolean;
|
||||||
|
maxPhotoWidth: number;
|
||||||
|
maxPhotoHeight: number;
|
||||||
|
allowDownload: boolean;
|
||||||
|
sharePlatforms?: {
|
||||||
|
linkedin?: boolean;
|
||||||
|
facebook?: boolean;
|
||||||
|
instagram?: boolean;
|
||||||
|
snapchat?: boolean;
|
||||||
|
x?: boolean;
|
||||||
|
whatsapp?: boolean;
|
||||||
|
};
|
||||||
|
eventHeader: SocialShareEventHeader;
|
||||||
|
uploadToken: string | null;
|
||||||
|
frames: SocialShareFrame[];
|
||||||
|
frameCount: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SocialShareUpdatePayload = Partial<{
|
||||||
|
enabled: boolean;
|
||||||
|
accessMode: SocialShareAccessMode;
|
||||||
|
requireUploadCrop: boolean;
|
||||||
|
maxPhotoWidth: number;
|
||||||
|
maxPhotoHeight: number;
|
||||||
|
allowDownload: boolean;
|
||||||
|
sharePlatforms: {
|
||||||
|
linkedin: boolean;
|
||||||
|
facebook: boolean;
|
||||||
|
instagram: boolean;
|
||||||
|
snapchat: boolean;
|
||||||
|
x: boolean;
|
||||||
|
whatsapp: boolean;
|
||||||
|
};
|
||||||
|
eventHeader: SocialShareEventHeader;
|
||||||
|
regenerateTokens: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type SocialShareFramePayload = Partial<{
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
frameImageUrl: string | null;
|
||||||
|
frameImageLayout: PhotoWallFrameImageLayout;
|
||||||
|
frameCanvasWidth: number;
|
||||||
|
frameCanvasHeight: number;
|
||||||
|
frameLayout: PhotoWallFrameElement[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const socialShareApi = {
|
||||||
|
get(eventId: string) {
|
||||||
|
return api.get<SocialShareConfig>(`/events/${eventId}/social-share`);
|
||||||
|
},
|
||||||
|
|
||||||
|
update(eventId: string, payload: SocialShareUpdatePayload) {
|
||||||
|
return api.patch<SocialShareConfig>(`/events/${eventId}/social-share`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureTokens(eventId: string) {
|
||||||
|
return api.post<SocialShareConfig>(`/events/${eventId}/social-share/ensure-tokens`, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
listFrames(eventId: string) {
|
||||||
|
return api.get<{ frames: SocialShareFrame[] }>(`/events/${eventId}/social-share/frames`);
|
||||||
|
},
|
||||||
|
|
||||||
|
createFrame(eventId: string, payload: SocialShareFramePayload = {}) {
|
||||||
|
return api.post<SocialShareFrame>(`/events/${eventId}/social-share/frames`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateFrame(eventId: string, frameId: string, payload: SocialShareFramePayload) {
|
||||||
|
return api.patch<SocialShareFrame>(`/events/${eventId}/social-share/frames/${frameId}`, payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFrame(eventId: string, frameId: string) {
|
||||||
|
return fetchWithAuth(`/events/${eventId}/social-share/frames/${frameId}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -306,6 +306,36 @@ export interface WebsiteThemeConfig {
|
||||||
textHighlightColor?: string | null;
|
textHighlightColor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Visitor-app theme (event / session / quiz / photo pages). Colors are rgba(). */
|
||||||
|
export type VisitorThemeMode = 'simple' | 'gradient' | 'background';
|
||||||
|
|
||||||
|
export type VisitorThemeBackgroundSize = 'cover' | 'contain' | 'auto';
|
||||||
|
|
||||||
|
export type VisitorThemeBackgroundRepeat = 'no' | 'repeat' | 'repeat-x' | 'repeat-y';
|
||||||
|
|
||||||
|
export interface VisitorThemeConfig {
|
||||||
|
theme: VisitorThemeMode;
|
||||||
|
color: string;
|
||||||
|
font?: string | null;
|
||||||
|
primaryColor?: string | null;
|
||||||
|
secondaryColor?: string | null;
|
||||||
|
headerColor?: string | null;
|
||||||
|
buttonColor?: string | null;
|
||||||
|
textColor?: string | null;
|
||||||
|
textHighlightColor?: string | null;
|
||||||
|
cardColor?: string | null;
|
||||||
|
cardTextColor?: string | null;
|
||||||
|
background?: {
|
||||||
|
url: string;
|
||||||
|
size: VisitorThemeBackgroundSize | string;
|
||||||
|
repeat: VisitorThemeBackgroundRepeat | string;
|
||||||
|
} | null;
|
||||||
|
gradient?: {
|
||||||
|
color1: string;
|
||||||
|
color2: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventWebsiteSettings {
|
export interface EventWebsiteSettings {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { api } from './client';
|
||||||
|
import type { VisitorThemeConfig } from './types';
|
||||||
|
|
||||||
|
export const visitorThemeApi = {
|
||||||
|
get(eventId: string) {
|
||||||
|
return api.get<VisitorThemeConfig>(`/events/${eventId}/visitor-theme`);
|
||||||
|
},
|
||||||
|
|
||||||
|
update(eventId: string, payload: VisitorThemeConfig) {
|
||||||
|
return api.put<VisitorThemeConfig>(`/events/${eventId}/visitor-theme`, payload);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,8 @@ interface ImportMetaEnv {
|
||||||
/** Concierge public API base for website embed (ends with /api). */
|
/** Concierge public API base for website embed (ends with /api). */
|
||||||
readonly VITE_CONCIERGE_URL?: string;
|
readonly VITE_CONCIERGE_URL?: string;
|
||||||
readonly VITE_GOOGLE_CLIENT_ID?: string;
|
readonly VITE_GOOGLE_CLIENT_ID?: string;
|
||||||
|
/** Optional Windows installer URL for photographer photo uploader. */
|
||||||
|
readonly VITE_PHOTOGRAPHER_UPLOADER_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