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 { 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 { useAppNav } from "../../contexts/AppNavContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
|
@ -12,6 +12,7 @@ import { eventTicketsApi } from "../../../services/api/eventTickets";
|
|||
import { campaignsApi } from "../../../services/api/campaigns";
|
||||
import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards/rewardRulesSettings";
|
||||
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
|
||||
import { VisitorThemeSettingsPanel } from "../panels/VisitorThemeSettingsPanel";
|
||||
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
||||
import { CreateEmailCampaignPanel } from "../panels/CreateEmailCampaignPanel";
|
||||
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
||||
|
|
@ -20,9 +21,17 @@ import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
|||
import { EventBadgesPanel } from "./EventBadgesPanel";
|
||||
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||
import { PhotoWallManagePanel } from "./PhotoWallManagePanel";
|
||||
import { SocialShareManagePanel } from "./SocialShareManagePanel";
|
||||
import { AiAvatarManagePanel } from "./AiAvatarManagePanel";
|
||||
import { EventPhotoSearchPanel } from "../panels/EventPhotoSearchPanel";
|
||||
import { photoWallApi } from "../../../services/api/photoWall";
|
||||
import { eventPhotoSearchApi } from "../../../services/api/eventPhotoSearch";
|
||||
import { socialShareApi } from "../../../services/api/socialShare";
|
||||
import { aiAvatarApi } from "../../../services/api/aiAvatar";
|
||||
import { 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 { EventExhibitorsPanel } from "./EventExhibitorsPanel";
|
||||
import { AddExhibitorPanel } from "../panels/AddExhibitorPanel";
|
||||
|
|
@ -36,6 +45,7 @@ import {
|
|||
formsApi,
|
||||
staticMicrositeApi,
|
||||
staticMicrositePublicUrl,
|
||||
visitorThemeApi,
|
||||
exhibitorsApi,
|
||||
type ApiExhibitor,
|
||||
} from "../../../services/api";
|
||||
|
|
@ -236,6 +246,10 @@ export function EventHomeModules() {
|
|||
const canReadDocuments = canModule("content_management", "read");
|
||||
const canReadPhotoWall = canModule("photo_wall", "read");
|
||||
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 canWriteEmailCampaigns = canModule("email_outreach_campaigns", "write");
|
||||
const canReadExhibitors = canModule("exhibitors", "read");
|
||||
|
|
@ -275,15 +289,21 @@ export function EventHomeModules() {
|
|||
const [staticMicrositeOpen, setStaticMicrositeOpen] = useState(false);
|
||||
const [staticMicrositeMeta, setStaticMicrositeMeta] = useState<string | null>(null);
|
||||
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 [agendaLoading, setAgendaLoading] = useState(false);
|
||||
const [eventSettings, setEventSettings] = useState<Record<string, unknown>>({});
|
||||
const [webCheckInEnabled, setWebCheckInEnabled] = useState(false);
|
||||
const [webCheckInLoading, setWebCheckInLoading] = useState(false);
|
||||
const [pictureWallEnabled, setPictureWallEnabled] = useState(false);
|
||||
const [pictureWallLoading, setPictureWallLoading] = useState(false);
|
||||
const [showDocumentsPanel, setShowDocumentsPanel] = 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 [photoWallEnabled, setPhotoWallEnabled] = useState(false);
|
||||
const [photoWallLoading, setPhotoWallLoading] = useState(false);
|
||||
|
|
@ -291,6 +311,18 @@ export function EventHomeModules() {
|
|||
const [photoWallUploadToken, setPhotoWallUploadToken] = useState<string | null>(null);
|
||||
const [photoWallPresentToken, setPhotoWallPresentToken] = useState<string | 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 [emailCampaignLoading, setEmailCampaignLoading] = useState(false);
|
||||
const [showCreateEmailCampaign, setShowCreateEmailCampaign] = useState(false);
|
||||
|
|
@ -328,7 +360,6 @@ export function EventHomeModules() {
|
|||
setEventName(res.data?.name ?? null);
|
||||
setEventSettings((res.data?.settings as Record<string, unknown> | undefined) ?? {});
|
||||
setWebCheckInEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.webCheckInEnabled));
|
||||
setPictureWallEnabled(Boolean((res.data?.settings as Record<string, unknown> | undefined)?.pictureWallEnabled));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
|
@ -497,6 +528,30 @@ export function EventHomeModules() {
|
|||
void 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 () => {
|
||||
if (!selectedEventId) {
|
||||
setAgendaCounts({ tracks: 0, sessions: 0 });
|
||||
|
|
@ -597,6 +652,90 @@ export function EventHomeModules() {
|
|||
void 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 = () => {
|
||||
setShowAddBadge(false);
|
||||
setDuplicateBadgeBatchId(null);
|
||||
|
|
@ -664,6 +803,11 @@ export function EventHomeModules() {
|
|||
: rewardRulesCount === 0
|
||||
? "No rules configured"
|
||||
: `${rewardRulesCount} rule${rewardRulesCount === 1 ? "" : "s"} configured`;
|
||||
const visitorThemeCardMeta = !selectedEventId
|
||||
? "Select an event to manage visitor theme"
|
||||
: visitorThemeLoading
|
||||
? "Loading…"
|
||||
: visitorThemeMeta || "Simple";
|
||||
const micrositeMeta = !selectedEventId
|
||||
? "Select an event to manage microsite"
|
||||
: staticMicrositeLoading
|
||||
|
|
@ -691,13 +835,6 @@ export function EventHomeModules() {
|
|||
: webCheckInEnabled
|
||||
? "Enabled"
|
||||
: "Disabled";
|
||||
const pictureWallMeta = !selectedEventId
|
||||
? "Select an event to configure picture wall"
|
||||
: pictureWallLoading
|
||||
? "Saving…"
|
||||
: pictureWallEnabled
|
||||
? "Enabled"
|
||||
: "Disabled";
|
||||
const photoWallMeta = !selectedEventId
|
||||
? "Select an event to configure photo wall"
|
||||
: photoWallLoading
|
||||
|
|
@ -705,6 +842,27 @@ export function EventHomeModules() {
|
|||
: photoWallEnabled
|
||||
? `Enabled · ${photoWallCount} photo${photoWallCount === 1 ? "" : "s"}`
|
||||
: "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) => {
|
||||
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) => {
|
||||
if (!selectedEventId || !canWritePhotoWall) return;
|
||||
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 url =
|
||||
kind === "upload" && photoWallUploadToken
|
||||
|
|
@ -1159,6 +1381,26 @@ export function EventHomeModules() {
|
|||
}
|
||||
/>
|
||||
) : 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 ? (
|
||||
<ModuleCard
|
||||
icon={<Globe size={18} />}
|
||||
|
|
@ -1267,117 +1509,205 @@ export function EventHomeModules() {
|
|||
) : null}
|
||||
<ModuleCard
|
||||
icon={<Image size={18} />}
|
||||
title="Picture Wall"
|
||||
meta={pictureWallMeta}
|
||||
description="Enable a public gallery where guests can add and view event photos"
|
||||
title="Event Photo Search"
|
||||
meta={photoSearchMeta}
|
||||
description="Upload event photos and share visitor Find Photos"
|
||||
headerAction={
|
||||
pictureWallEnabled ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
title="Copy public picture wall link"
|
||||
disabled={actionsDisabled}
|
||||
style={{
|
||||
...formLinkCopyButtonStyle(false),
|
||||
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} />
|
||||
</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>
|
||||
photoSearchEnabled && photoSearchToken ? (
|
||||
<button
|
||||
type="button"
|
||||
title="Copy visitor search link"
|
||||
aria-label="Copy visitor search link"
|
||||
disabled={actionsDisabled}
|
||||
style={photoSearchHeaderButtonStyle}
|
||||
onClick={() => void copyPhotoSearchLink()}
|
||||
>
|
||||
{copiedPhotoSearchLink ? <Check size={12} /> : <Link2 size={12} />}
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
pictureWallEnabled ? (
|
||||
<>
|
||||
{photoSearchEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || photoSearchLoading}
|
||||
style={{
|
||||
...moduleButtonStyle(),
|
||||
opacity: actionsDisabled || photoSearchLoading ? 0.55 : 1,
|
||||
cursor: actionsDisabled || photoSearchLoading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => void handlePhotoSearchToggle(false)}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || photoSearchLoading}
|
||||
style={{
|
||||
...moduleButtonStyle(true),
|
||||
opacity: actionsDisabled || photoSearchLoading ? 0.55 : 1,
|
||||
cursor: actionsDisabled || photoSearchLoading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => void handlePhotoSearchToggle(true)}
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || pictureWallLoading}
|
||||
disabled={actionsDisabled}
|
||||
style={{
|
||||
...moduleButtonStyle(),
|
||||
opacity: actionsDisabled || pictureWallLoading ? 0.55 : 1,
|
||||
cursor: actionsDisabled || pictureWallLoading ? "not-allowed" : "pointer",
|
||||
...moduleButtonStyle(photoSearchEnabled),
|
||||
opacity: actionsDisabled ? 0.55 : 1,
|
||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => void handlePictureWallToggle(false)}
|
||||
onClick={() => setShowEventPhotoSearchPanel(true)}
|
||||
>
|
||||
Disable
|
||||
Manage
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled || pictureWallLoading}
|
||||
style={{
|
||||
...moduleButtonStyle(true),
|
||||
opacity: actionsDisabled || pictureWallLoading ? 0.55 : 1,
|
||||
cursor: actionsDisabled || pictureWallLoading ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => void handlePictureWallToggle(true)}
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
)
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{canReadSocialShare ? (
|
||||
<ModuleCard
|
||||
icon={<Image size={18} />}
|
||||
title="Event Photo Search"
|
||||
description="Upload event photos and search by image"
|
||||
icon={<Share2 size={18} />}
|
||||
title="Social Share"
|
||||
meta={socialShareMeta}
|
||||
description="Multi-frame photo create, download, and share"
|
||||
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>
|
||||
socialShareEnabled && socialShareToken ? (
|
||||
<button
|
||||
type="button"
|
||||
title="Copy visitor link"
|
||||
onClick={() => void copySocialShareLink()}
|
||||
style={formHeaderIconButtonStyle()}
|
||||
>
|
||||
{copiedSocialShareLink ? <Check size={14} /> : <Link2 size={14} />}
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionsDisabled}
|
||||
style={{
|
||||
...moduleButtonStyle(true),
|
||||
opacity: actionsDisabled ? 0.55 : 1,
|
||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => setShowEventPhotoSearchPanel(true)}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
<>
|
||||
{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 ? (
|
||||
<ModuleCard
|
||||
icon={<Images size={18} />}
|
||||
|
|
@ -1636,6 +1966,14 @@ export function EventHomeModules() {
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{visitorThemeOpen && selectedEventId ? (
|
||||
<VisitorThemeSettingsPanel
|
||||
eventId={selectedEventId}
|
||||
onClose={() => setVisitorThemeOpen(false)}
|
||||
onSaved={() => void loadVisitorThemeMeta()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{staticMicrositeOpen && selectedEventId ? (
|
||||
<ManageStaticMicrositePanel
|
||||
eventId={selectedEventId}
|
||||
|
|
@ -1658,6 +1996,7 @@ export function EventHomeModules() {
|
|||
eventId={selectedEventId}
|
||||
eventName={eventName ?? undefined}
|
||||
onClose={() => setShowEventPhotoSearchPanel(false)}
|
||||
onChanged={() => void loadPhotoSearchMeta()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1671,6 +2010,26 @@ export function EventHomeModules() {
|
|||
/>
|
||||
) : 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 ? (
|
||||
<EventEmailCampaignsPanel
|
||||
eventId={selectedEventId}
|
||||
|
|
|
|||
|
|
@ -674,7 +674,7 @@ export function PhotoWallManagePanel({
|
|||
|
||||
<Field>
|
||||
<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" }}>
|
||||
{(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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 { X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "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 {
|
||||
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 = {
|
||||
open: boolean;
|
||||
eventId: string;
|
||||
eventName?: string;
|
||||
canWrite?: boolean;
|
||||
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;
|
||||
|
||||
// const displayPhotos = searchResults !== null ? searchResults : photos;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200 }}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="event-photo-search-title"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
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>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 36,
|
||||
height: 36,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 'var(--radius-button)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--card)',
|
||||
color: 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EventPhotoSearchContent active={open} eventId={eventId} mode="full" />
|
||||
<PanelShell
|
||||
onClose={onClose}
|
||||
title="Event Photo Search"
|
||||
breadcrumb={eventName ? `Event Home · ${eventName}` : "Event Home"}
|
||||
description="Upload photos, configure visitor Find Photos, and share links"
|
||||
completedCount={0}
|
||||
totalSections={0}
|
||||
ctaLabel="Save settings"
|
||||
showProgress={false}
|
||||
hidePrimaryCta={tab !== "settings"}
|
||||
ctaDisabled={!canWrite || saving || loading}
|
||||
onCtaClick={() => void saveSettings()}
|
||||
panelWidth="min(820px,100vw)"
|
||||
>
|
||||
<div style={{ display: "flex", borderBottom: "1px solid var(--border)", padding: "0 12px" }}>
|
||||
{tabBtn("photos", "Photos")}
|
||||
{tabBtn("settings", "Settings")}
|
||||
{tabBtn("links", "Links")}
|
||||
</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
|
||||
type="button"
|
||||
disabled={!config.searchToken}
|
||||
onClick={() => void copyLink()}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "8px 12px",
|
||||
borderRadius: "var(--radius-button)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--card)",
|
||||
cursor: config.searchToken ? "pointer" : "not-allowed",
|
||||
fontSize: "var(--text-sm)",
|
||||
opacity: config.searchToken ? 1 : 0.55,
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</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,
|
||||
} from "../../../services/api";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { WhatsAppReceptionTab } from "./WhatsAppReceptionTab";
|
||||
|
||||
/** Public concierge API base (must end with /api). Falls back to local concierge in dev. */
|
||||
const CONCIERGE_API_BASE = (
|
||||
|
|
@ -90,7 +91,7 @@ export function WhatsAppChatbotsPage() {
|
|||
const [bots, setBots] = useState<ConciergeBotRecord[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | 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 [chunks, setChunks] = useState<ContextChunkRecord[]>([]);
|
||||
const [previewSourceId, setPreviewSourceId] = useState<string | null>(null);
|
||||
|
|
@ -105,6 +106,8 @@ export function WhatsAppChatbotsPage() {
|
|||
const [urlName, setUrlName] = useState("");
|
||||
const [urlCrawlScope, setUrlCrawlScope] = useState<"auto" | "domain" | "path">("auto");
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [pendingEventId, setPendingEventId] = useState("");
|
||||
const [eventChangeOpen, setEventChangeOpen] = useState(false);
|
||||
|
||||
const listOrgId = portfolioListOrganizationId(user);
|
||||
|
||||
|
|
@ -128,7 +131,9 @@ export function WhatsAppChatbotsPage() {
|
|||
|
||||
const loadBot = useCallback(async (id: string) => {
|
||||
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);
|
||||
setSources(src.data?.sources || []);
|
||||
}, []);
|
||||
|
|
@ -153,12 +158,19 @@ export function WhatsAppChatbotsPage() {
|
|||
else setBot(null);
|
||||
}, [selectedId, loadBot]);
|
||||
|
||||
const saveSettings = async () => {
|
||||
const saveSettings = async (opts?: { confirmEventChange?: boolean }) => {
|
||||
if (!bot) return;
|
||||
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, {
|
||||
name: bot.name,
|
||||
wabaPhoneNumberId: bot.wabaPhoneNumberId,
|
||||
eventId: nextEventId || undefined,
|
||||
enabled: Boolean(bot.enabled),
|
||||
greeting: bot.greeting,
|
||||
registration: bot.registration,
|
||||
|
|
@ -186,10 +198,16 @@ export function WhatsAppChatbotsPage() {
|
|||
})),
|
||||
});
|
||||
if (res.error) {
|
||||
setEventChangeOpen(false);
|
||||
setMsg(res.error);
|
||||
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 loadBots();
|
||||
};
|
||||
|
|
@ -430,6 +448,7 @@ export function WhatsAppChatbotsPage() {
|
|||
<div style={{ display: "flex", gap: 8, marginBottom: 16, flexWrap: "wrap" }}>
|
||||
{([
|
||||
["settings", "Settings"],
|
||||
["reception", "Reception"],
|
||||
["venue-faq", "Bot FAQ"],
|
||||
["knowledge", "Knowledge"],
|
||||
["preview", "Preview"],
|
||||
|
|
@ -451,6 +470,34 @@ export function WhatsAppChatbotsPage() {
|
|||
Display name
|
||||
<input style={IS} value={bot.name || ""} onChange={(e) => setBot({ ...bot, name: e.target.value })} />
|
||||
</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>
|
||||
WhatsApp phone number ID
|
||||
<input
|
||||
|
|
@ -605,6 +652,14 @@ export function WhatsAppChatbotsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{tab === "reception" && (
|
||||
<WhatsAppReceptionTab
|
||||
bot={bot}
|
||||
eventId={eventIdOf(bot)}
|
||||
onSaved={() => loadBot(bot._id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "venue-faq" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: "var(--muted-foreground)" }}>
|
||||
|
|
@ -1017,6 +1072,49 @@ export function WhatsAppChatbotsPage() {
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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 {
|
||||
includeEventDetails?: boolean;
|
||||
includeFoodRecommendations?: boolean;
|
||||
|
|
@ -79,6 +110,7 @@ export interface ConciergeBotRecord {
|
|||
greeting?: ConciergeBotGreeting;
|
||||
registration?: ConciergeBotRegistration;
|
||||
knowledge?: ConciergeBotKnowledge;
|
||||
reception?: ConciergeBotReception;
|
||||
faqs?: ConciergeFaqItem[];
|
||||
lastKnowledgeClearedAt?: string;
|
||||
createdAt?: string;
|
||||
|
|
@ -164,9 +196,15 @@ export const conciergeBotsApi = {
|
|||
knowledge: ConciergeBotKnowledge;
|
||||
faqs: ConciergeFaqItem[];
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 { participantDocumentsApi, type ParticipantDocumentRow } from './participantDocuments';
|
||||
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 {
|
||||
photoWallApi,
|
||||
type PhotoWallConfig,
|
||||
|
|
@ -88,6 +113,7 @@ export {
|
|||
type WebsiteSectionType,
|
||||
} from './pages';
|
||||
export { websiteSettingsApi, type WebsiteSettings } from './websiteSettings';
|
||||
export { visitorThemeApi } from './visitorTheme';
|
||||
export {
|
||||
eventMicrositesApi,
|
||||
type MicrositeCreatePayload,
|
||||
|
|
@ -100,6 +126,9 @@ export { whatsappTemplatesApi } from './whatsappTemplates';
|
|||
export {
|
||||
conciergeBotsApi,
|
||||
type ConciergeBotRecord,
|
||||
type ConciergeBotReception,
|
||||
type ConciergeReceptionDrip,
|
||||
type ConciergeReceptionInfo,
|
||||
type ConciergeFaqCategory,
|
||||
type ConciergeFaqItem,
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
enabled?: boolean;
|
||||
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). */
|
||||
readonly VITE_CONCIERGE_URL?: 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) */
|
||||
|
|
|
|||
Loading…
Reference in New Issue