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:
Ravi Gulhane 2026-08-17 09:50:53 +05:30
parent ab57e3f0a8
commit 57f73cb6c2
19 changed files with 4283 additions and 216 deletions

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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" }}>
{(
[

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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)}`;
}

View File

@ -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)}`;
}

View File

@ -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)}`;
}

View File

@ -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' });
},
};

View File

@ -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) {

View File

@ -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`, {});
},
};

View File

@ -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,

View File

@ -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' });
},
};

View File

@ -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;

View File

@ -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);
},
};

2
src/vite-env.d.ts vendored
View File

@ -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) */