;
+ onSpotlightSourceChange?: (source: SpotlightSource, enabled: boolean) => void;
+ onAllSpotlightSourcesChange?: (enabled: boolean) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [menuMounted, setMenuMounted] = useState(false);
@@ -200,7 +206,7 @@ export function PhotoWallPresentChrome({
id={panelId}
role="dialog"
aria-modal="true"
- aria-label="Photo wall menu"
+ aria-label={spotlightSources ? "Spotlight menu" : "Photo wall menu"}
className={`absolute inset-y-0 right-0 flex w-[min(320px,92vw)] flex-col bg-[var(--theme-header-color)] text-[var(--theme-card-text-color)] shadow-2xl transition-transform ease-out ${
menuVisible ? "translate-x-0" : "translate-x-full"
}`}
@@ -234,6 +240,36 @@ export function PhotoWallPresentChrome({
Replay
+ {spotlightSources && onSpotlightSourceChange ? (
+
+
Sources
+
+ {([
+ ["socialShare", "Social Share"],
+ ["aiAvatar", "AI Avatar"],
+ ["quiz", "Quiz"],
+ ["photoWall", "Photo Wall"],
+ ] as Array<[SpotlightSource, string]>).map(([source, label]) => (
+
+ ))}
+
+ ) : null}
diff --git a/src/components/photoWall/MarqueePhotoWall.tsx b/src/components/photoWall/MarqueePhotoWall.tsx
index a0a68d4..c0a9b10 100644
--- a/src/components/photoWall/MarqueePhotoWall.tsx
+++ b/src/components/photoWall/MarqueePhotoWall.tsx
@@ -5,6 +5,7 @@ import {
type PresentPhoto,
} from "../../lib/photoWallMarquee";
import { MarqueeRow } from "./MarqueeRow";
+import type { SpotlightFrame } from "./SpotlightPhotoFrame";
type MarqueePhotoWallProps = {
rows: PresentPhoto[][];
@@ -22,6 +23,7 @@ type MarqueePhotoWallProps = {
paused?: boolean;
/** Start endless scroll only after every row is filled to photosPerRow. */
marqueeActive?: boolean;
+ spotlightFrame?: SpotlightFrame;
};
function useViewportWidth() {
@@ -51,6 +53,7 @@ export function MarqueePhotoWall({
showNames,
paused = false,
marqueeActive = false,
+ spotlightFrame,
}: MarqueePhotoWallProps) {
const viewportWidth = useViewportWidth();
const available =
@@ -84,6 +87,7 @@ export function MarqueePhotoWall({
speed={44 + rowIndex * 6}
paused={paused}
marqueeActive={marqueeActive}
+ spotlightFrame={spotlightFrame}
/>
))}
diff --git a/src/components/photoWall/MarqueeRow.tsx b/src/components/photoWall/MarqueeRow.tsx
index c81b52e..84f1164 100644
--- a/src/components/photoWall/MarqueeRow.tsx
+++ b/src/components/photoWall/MarqueeRow.tsx
@@ -1,6 +1,7 @@
import { useLayoutEffect, useRef } from "react";
import gsap from "gsap";
import type { PresentPhoto } from "../../lib/photoWallMarquee";
+import { SpotlightPhotoFrame, type SpotlightFrame } from "./SpotlightPhotoFrame";
type MarqueeRowProps = {
photos: PresentPhoto[];
@@ -16,6 +17,7 @@ type MarqueeRowProps = {
paused?: boolean;
/** When false, show a static row (fill phase). When true, endless GSAP loop. */
marqueeActive?: boolean;
+ spotlightFrame?: SpotlightFrame;
};
function PhotoCard({
@@ -25,6 +27,7 @@ function PhotoCard({
borderRadius,
aspectRatio,
showNames,
+ spotlightFrame,
}: {
photo: PresentPhoto;
cardWidth: number;
@@ -32,6 +35,7 @@ function PhotoCard({
borderRadius: number | string;
aspectRatio: string;
showNames: boolean;
+ spotlightFrame?: SpotlightFrame;
}) {
return (
-

+ {spotlightFrame ? (
+
+ ) : (
+

+ )}
{showNames && photo.displayName ? (
(null);
const trackRef = useRef(null);
@@ -210,6 +219,7 @@ export function MarqueeRow({
borderRadius={borderRadius}
aspectRatio={aspectRatio}
showNames={showNames}
+ spotlightFrame={spotlightFrame}
/>
))}
diff --git a/src/components/photoWall/PhotoSpotlight.tsx b/src/components/photoWall/PhotoSpotlight.tsx
index 05f17ed..b5847a1 100644
--- a/src/components/photoWall/PhotoSpotlight.tsx
+++ b/src/components/photoWall/PhotoSpotlight.tsx
@@ -1,12 +1,14 @@
import { useEffect, useRef } from "react";
import gsap from "gsap";
import type { PresentPhoto } from "../../lib/photoWallMarquee";
+import { SpotlightPhotoFrame, type SpotlightFrame } from "./SpotlightPhotoFrame";
type PhotoSpotlightProps = {
photo: PresentPhoto | null;
borderRadius: number | string;
holdMs?: number;
onComplete: () => void;
+ spotlightFrame?: SpotlightFrame;
};
/**
@@ -18,6 +20,7 @@ export function PhotoSpotlight({
borderRadius,
holdMs = 2400,
onComplete,
+ spotlightFrame,
}: PhotoSpotlightProps) {
const rootRef = useRef(null);
const cardRef = useRef(null);
@@ -107,13 +110,15 @@ export function PhotoSpotlight({
-

+ {spotlightFrame ?
: (
+

+ )}
diff --git a/src/components/photoWall/SpotlightPhotoFrame.tsx b/src/components/photoWall/SpotlightPhotoFrame.tsx
new file mode 100644
index 0000000..6f1c646
--- /dev/null
+++ b/src/components/photoWall/SpotlightPhotoFrame.tsx
@@ -0,0 +1,85 @@
+import type { PhotoWallPresentData } from '../../lib/api';
+import type { PresentPhoto } from '../../lib/photoWallMarquee';
+
+export type SpotlightFrame = Pick
& {
+ frameImageUrl?: string | null;
+ frameImageLayout?: { x: number; y: number; w: number; h: number };
+ frameLayout?: Array>;
+};
+
+function toPxPercent(value: unknown, size: number): string {
+ return `${(Number(value || 0) / Math.max(1, size)) * 100}%`;
+}
+
+export function SpotlightPhotoFrame({
+ photo,
+ frame,
+ borderRadius,
+}: {
+ photo: PresentPhoto;
+ frame: SpotlightFrame;
+ borderRadius: number | string;
+}) {
+ const width = frame.frameCanvasWidth || 1080;
+ const height = frame.frameCanvasHeight || 1080;
+ const layout = frame.frameLayout || [];
+ const photoSlot = layout.find((element) => element.field === 'photo');
+ const imageLayout = frame.frameImageLayout || { x: 0, y: 0, w: 100, h: 100 };
+ const positionedStyle = (element: Record) => ({
+ position: 'absolute' as const,
+ left: toPxPercent(element.x, width),
+ top: toPxPercent(element.y, height),
+ width: toPxPercent(element.w ?? width, width),
+ height: toPxPercent(element.h ?? height, height),
+ });
+
+ return (
+
+

+ {frame.frameImageUrl ? (
+

+ ) : null}
+ {layout.map((element, index) => {
+ if (element.field === 'photo') return null;
+ const isText = element.kind === 'custom_text' || element.field === 'name' || element.field === 'form';
+ const text = element.field === 'name' ? photo.displayName || '' : String(element.text || '');
+ if (element.kind === 'custom_image' && element.imageUrl) {
+ return
})
;
+ }
+ if (!isText || !text) return null;
+ const style = positionedStyle(element);
+ return (
+
+ {element.field === 'name' ? `${String(element.prefix || '')}${text}${String(element.postfix || '')}` : text}
+
+ );
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/src/hooks/useEventTheme.ts b/src/hooks/useEventTheme.ts
index cbf9ba2..fe35a53 100644
--- a/src/hooks/useEventTheme.ts
+++ b/src/hooks/useEventTheme.ts
@@ -4,6 +4,7 @@ import {
getAiAvatarMeta,
getPhotoSearchMeta,
getPhotoWallPresent,
+ getSpotlightPresent,
getPhotoWallUploadMeta,
getPublicVisitorTheme,
getSocialShareMeta,
@@ -17,7 +18,8 @@ type PublicTokenSource =
| "photo-search"
| "social-share"
| "photo-wall"
- | "photo-wall-present";
+ | "photo-wall-present"
+ | "spotlight-present";
type ThemeLookup =
| { kind: "none" }
@@ -44,6 +46,7 @@ function resolveThemeLookup(pathname: string, searchParams: URLSearchParams): Th
{ re: /^\/photo-search\/([^/]+)/, source: "photo-search" },
{ re: /^\/social-share\/([^/]+)/, source: "social-share" },
{ re: /^\/photo-wall-present\/([^/]+)/, source: "photo-wall-present" },
+ { re: /^\/spotlight-present\/([^/]+)/, source: "spotlight-present" },
{ re: /^\/photo-wall\/([^/]+)/, source: "photo-wall" },
];
for (const { re, source } of tokenRoutes) {
@@ -62,6 +65,7 @@ async function eventIdFromPublicToken(source: PublicTokenSource, token: string):
if (source === "photo-search") return (await getPhotoSearchMeta(token)).eventId;
if (source === "social-share") return (await getSocialShareMeta(token)).eventId;
if (source === "photo-wall") return (await getPhotoWallUploadMeta(token)).eventId;
+ if (source === "spotlight-present") return (await getSpotlightPresent(token)).eventId;
return (await getPhotoWallPresent(token)).eventId;
}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 4e34f21..4d1038c 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -1062,6 +1062,15 @@ export type PhotoWallPresentData = {
}>;
};
+export type SpotlightSource = "socialShare" | "aiAvatar" | "quiz" | "photoWall";
+export type SpotlightSources = Record;
+export type SpotlightPresentData = PhotoWallPresentData & {
+ sources: SpotlightSources;
+ frameImageUrl?: string | null;
+ frameLayout?: Array>;
+ photos: Array;
+};
+
export async function getPhotoWallUploadMeta(uploadToken: string): Promise {
const base = getBaseUrl();
const url = `${base}/api/public/photo-wall/upload/${encodeURIComponent(uploadToken)}`;
@@ -1145,6 +1154,20 @@ export async function getPhotoWallPresent(presentToken: string): Promise {
+ const base = getBaseUrl();
+ const query = sources ? `?sources=${encodeURIComponent(sources.join(","))}` : "";
+ const url = `${base}/api/public/spotlight/present/${encodeURIComponent(presentToken)}${query}`;
+ const res = await fetch(url);
+ const body = await parseJson(res);
+ if (!res.ok) throw new Error(messageFromBody(body));
+ if (!body || typeof body !== "object" || !("data" in body)) throw new Error("Invalid response");
+ return (body as { data: SpotlightPresentData }).data;
+}
+
export type PhotoSearchMeta = {
eventId: string;
eventName: string;
@@ -1217,6 +1240,7 @@ export async function uploadSocialSharePhoto(
uploadToken: string,
file: File,
frameId: string,
+ displayName: string,
): Promise<{
previewUrl: string | null;
fileUrl: string | null;
@@ -1228,6 +1252,7 @@ export async function uploadSocialSharePhoto(
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("frameId", frameId);
+ fd.append("displayName", displayName.trim());
const headers: Record = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
@@ -1292,6 +1317,7 @@ export async function uploadAiAvatarPhoto(
uploadToken: string,
file: File,
avatarId: string,
+ displayName: string,
): Promise<{
previewUrl: string | null;
fileUrl: string | null;
@@ -1302,6 +1328,7 @@ export async function uploadAiAvatarPhoto(
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("avatarId", avatarId);
+ fd.append("displayName", displayName.trim());
const headers: Record = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
diff --git a/src/pages/AiAvatarPage.tsx b/src/pages/AiAvatarPage.tsx
index 9847338..7c21ec3 100644
--- a/src/pages/AiAvatarPage.tsx
+++ b/src/pages/AiAvatarPage.tsx
@@ -109,6 +109,7 @@ export function AiAvatarPage() {
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [selectedAvatar, setSelectedAvatar] = useState(null);
+ const [displayName, setDisplayName] = useState("");
const [file, setFile] = useState(null);
const [previewUrl, setPreviewUrl] = useState(null);
const [submitting, setSubmitting] = useState(false);
@@ -244,7 +245,7 @@ export function AiAvatarPage() {
setErr(null);
setActionMsg(null);
try {
- const result = await uploadAiAvatarPhoto(uploadToken, file, selectedAvatar._id);
+ const result = await uploadAiAvatarPhoto(uploadToken, file, selectedAvatar._id, displayName.trim());
setShareFile(null);
setDone({
previewUrl: result.previewUrl,
@@ -590,9 +591,22 @@ export function AiAvatarPage() {
hasFile={Boolean(file)}
onPick={(selected) => void handleFileChange(selected)}
/>
+