This commit is contained in:
Ravi Gulhane 2026-10-13 16:57:48 +05:30
parent db7fc16ec2
commit 49b8e94706
19 changed files with 1371 additions and 374 deletions

View File

@ -47,6 +47,15 @@ export function PhotoWallPresentChrome({
spotlightSources,
onSpotlightSourceChange,
onAllSpotlightSourcesChange,
spotlightLists,
selectedSpotlightListId = null,
onSpotlightListChange,
onSpotlightReset,
sourcesViewSelected = true,
spotlightQuizzes,
selectedSpotlightQuizId = null,
onSpotlightQuizChange,
pageTitle = "",
}: {
event?: PhotoWallPublicEvent | null;
eventHeader?: PhotoWallEventHeaderDisplay | null;
@ -57,6 +66,15 @@ export function PhotoWallPresentChrome({
spotlightSources?: Record<SpotlightSource, boolean>;
onSpotlightSourceChange?: (source: SpotlightSource, enabled: boolean) => void;
onAllSpotlightSourcesChange?: (enabled: boolean) => void;
spotlightLists?: { id: string; name: string }[];
selectedSpotlightListId?: string | null;
onSpotlightListChange?: (id: string | null) => void;
onSpotlightReset?: () => void;
sourcesViewSelected?: boolean;
spotlightQuizzes?: { id: string; name: string }[];
selectedSpotlightQuizId?: string | null;
onSpotlightQuizChange?: (id: string) => void;
pageTitle?: string;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [menuMounted, setMenuMounted] = useState(false);
@ -122,7 +140,8 @@ export function PhotoWallPresentChrome({
return (
<>
<header className="sticky top-0 z-[70] flex min-h-[64px] w-full items-center justify-between gap-4 bg-[var(--theme-header-color)] px-4 py-3 text-[var(--theme-card-text-color)] shadow-xs sm:px-8">
<header className="sticky top-0 z-[70] flex w-full flex-col bg-[var(--theme-header-color)] text-[var(--theme-card-text-color)] shadow-xs">
<div className="flex min-h-[64px] w-full items-center justify-between gap-4 px-4 py-3 sm:px-8">
<div className="flex min-w-0 flex-1 flex-col items-start justify-start gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div className="flex min-w-0 shrink-0 items-center">
{flags.showLogo ? (
@ -173,7 +192,6 @@ export function PhotoWallPresentChrome({
</div>
) : null}
</div>
<button
type="button"
onClick={() => setMenuOpen(true)}
@ -185,6 +203,12 @@ export function PhotoWallPresentChrome({
<Menu className="size-5" strokeWidth={2} aria-hidden />
Menu
</button>
</div>
{pageTitle.trim() ? (
<h2 className="w-full px-6 pb-3 text-center text-2xl font-semibold leading-tight sm:text-3xl">
{pageTitle.trim()}
</h2>
) : null}
</header>
{flags.showBanner ? (
@ -223,7 +247,7 @@ export function PhotoWallPresentChrome({
<X className="size-5" strokeWidth={2} />
</button>
</div>
<nav className="flex flex-col p-2">
<nav className="flex min-h-0 flex-1 flex-col overflow-y-auto p-2">
<button
type="button"
onClick={runFullscreen}
@ -240,6 +264,19 @@ export function PhotoWallPresentChrome({
<RefreshCw className="size-5 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} />
Replay
</button>
{onSpotlightReset ? (
<button
type="button"
onClick={() => {
closeMenu();
onSpotlightReset();
}}
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left text-sm font-semibold transition hover:bg-[var(--theme-button-color)]/10"
>
<X className="size-5 shrink-0 text-[var(--theme-button-color)]" strokeWidth={2} />
Reset
</button>
) : null}
{spotlightSources && onSpotlightSourceChange ? (
<div className="mt-2 border-t border-[var(--theme-card-text-color)]/10 px-4 pt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide opacity-70">Sources</p>
@ -258,7 +295,8 @@ export function PhotoWallPresentChrome({
["quiz", "Quiz"],
["photoWall", "Photo Wall"],
] as Array<[SpotlightSource, string]>).map(([source, label]) => (
<label key={source} className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-sm font-medium transition hover:bg-[var(--theme-button-color)]/10">
<div key={source}>
<label className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-sm font-medium transition hover:bg-[var(--theme-button-color)]/10">
<input
type="checkbox"
checked={spotlightSources[source]}
@ -267,6 +305,50 @@ export function PhotoWallPresentChrome({
/>
{label}
</label>
{source === "quiz" && spotlightSources.quiz && spotlightQuizzes && spotlightQuizzes.length > 0 && onSpotlightQuizChange ? (
<div className="mb-1 ml-7 flex flex-col">
{spotlightQuizzes.map((quiz) => (
<label key={quiz.id} className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-1.5 text-sm font-medium transition hover:bg-[var(--theme-button-color)]/10">
<input
type="radio"
name="spotlight-quiz"
checked={selectedSpotlightQuizId === quiz.id}
onChange={() => onSpotlightQuizChange(quiz.id)}
className="size-4 accent-[var(--theme-button-color)]"
/>
{quiz.name}
</label>
))}
</div>
) : null}
</div>
))}
</div>
) : null}
{spotlightLists && spotlightLists.length > 0 && onSpotlightListChange ? (
<div className="mt-2 border-t border-[var(--theme-card-text-color)]/10 px-4 pt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide opacity-70">Lists</p>
<label className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-sm font-semibold transition hover:bg-[var(--theme-button-color)]/10">
<input
type="radio"
name="spotlight-list"
checked={selectedSpotlightListId == null && sourcesViewSelected}
onChange={() => onSpotlightListChange(null)}
className="size-4 accent-[var(--theme-button-color)]"
/>
Selected Source
</label>
{spotlightLists.map((list) => (
<label key={list.id} className="flex cursor-pointer items-center gap-3 rounded-lg px-2 py-2 text-sm font-medium transition hover:bg-[var(--theme-button-color)]/10">
<input
type="radio"
name="spotlight-list"
checked={selectedSpotlightListId === list.id}
onChange={() => onSpotlightListChange(list.id)}
className="size-4 accent-[var(--theme-button-color)]"
/>
{list.name}
</label>
))}
</div>
) : null}

View File

@ -5,7 +5,6 @@ import {
type PresentPhoto,
} from "../../lib/photoWallMarquee";
import { MarqueeRow } from "./MarqueeRow";
import type { SpotlightFrame } from "./SpotlightPhotoFrame";
type MarqueePhotoWallProps = {
rows: PresentPhoto[][];
@ -20,10 +19,10 @@ type MarqueePhotoWallProps = {
borderRadius: number | string;
aspectRatio: string;
showNames: boolean;
showMessage?: boolean;
paused?: boolean;
/** Start endless scroll only after every row is filled to photosPerRow. */
marqueeActive?: boolean;
spotlightFrame?: SpotlightFrame;
};
function useViewportWidth() {
@ -51,9 +50,9 @@ export function MarqueePhotoWall({
borderRadius,
aspectRatio,
showNames,
showMessage = false,
paused = false,
marqueeActive = false,
spotlightFrame,
}: MarqueePhotoWallProps) {
const viewportWidth = useViewportWidth();
const available =
@ -84,10 +83,10 @@ export function MarqueePhotoWall({
borderRadius={borderRadius}
aspectRatio={aspectRatio}
showNames={showNames}
showMessage={showMessage}
speed={44 + rowIndex * 6}
paused={paused}
marqueeActive={marqueeActive}
spotlightFrame={spotlightFrame}
/>
))}
</div>

View File

@ -1,7 +1,6 @@
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[];
@ -12,12 +11,12 @@ type MarqueeRowProps = {
borderRadius: number | string;
aspectRatio: string;
showNames: boolean;
showMessage?: boolean;
/** px per second */
speed?: number;
paused?: boolean;
/** When false, show a static row (fill phase). When true, endless GSAP loop. */
marqueeActive?: boolean;
spotlightFrame?: SpotlightFrame;
};
function PhotoCard({
@ -27,7 +26,7 @@ function PhotoCard({
borderRadius,
aspectRatio,
showNames,
spotlightFrame,
showMessage = false,
}: {
photo: PresentPhoto;
cardWidth: number;
@ -35,7 +34,7 @@ function PhotoCard({
borderRadius: number | string;
aspectRatio: string;
showNames: boolean;
spotlightFrame?: SpotlightFrame;
showMessage?: boolean;
}) {
return (
<div
@ -51,16 +50,12 @@ function PhotoCard({
borderRadius,
}}
>
{spotlightFrame ? (
<SpotlightPhotoFrame photo={photo} frame={spotlightFrame} borderRadius={borderRadius} />
) : (
<img
src={photo.url}
alt={photo.displayName || "Photo Wall"}
className="h-full w-full object-cover"
draggable={false}
/>
)}
</div>
{showNames && photo.displayName ? (
<p
@ -70,6 +65,11 @@ function PhotoCard({
{photo.displayName}
</p>
) : null}
{showMessage && photo.message ? (
<p className="mt-0.5 line-clamp-2 w-full px-1 text-center text-[11px] leading-snug text-[var(--theme-card-text-color)]/75 sm:text-xs">
{photo.message}
</p>
) : null}
</div>
);
}
@ -93,10 +93,10 @@ export function MarqueeRow({
borderRadius,
aspectRatio,
showNames,
showMessage = false,
speed = 48,
paused = false,
marqueeActive = false,
spotlightFrame,
}: MarqueeRowProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
@ -219,7 +219,7 @@ export function MarqueeRow({
borderRadius={borderRadius}
aspectRatio={aspectRatio}
showNames={showNames}
spotlightFrame={spotlightFrame}
showMessage={showMessage}
/>
))}
</div>

View File

@ -1,14 +1,11 @@
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;
};
/**
@ -20,7 +17,6 @@ export function PhotoSpotlight({
borderRadius,
holdMs = 2400,
onComplete,
spotlightFrame,
}: PhotoSpotlightProps) {
const rootRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
@ -110,15 +106,13 @@ export function PhotoSpotlight({
<div
ref={frameRef}
className="w-full overflow-hidden ring-2 ring-white/35"
style={{ borderRadius, aspectRatio: spotlightFrame ? `${spotlightFrame.frameCanvasWidth || 1080} / ${spotlightFrame.frameCanvasHeight || 1080}` : "1 / 1" }}
style={{ borderRadius, aspectRatio: "1 / 1" }}
>
{spotlightFrame ? <SpotlightPhotoFrame photo={photo} frame={spotlightFrame} borderRadius={borderRadius} /> : (
<img
src={photo.url}
alt={photo.displayName || "New photo"}
className="h-full w-full object-cover"
/>
)}
</div>
<div className="mt-5 flex w-full flex-col items-center px-1 text-center">

View File

@ -1,85 +0,0 @@
import type { PhotoWallPresentData } from '../../lib/api';
import type { PresentPhoto } from '../../lib/photoWallMarquee';
export type SpotlightFrame = Pick<PhotoWallPresentData, 'frameCanvasWidth' | 'frameCanvasHeight'> & {
frameImageUrl?: string | null;
frameImageLayout?: { x: number; y: number; w: number; h: number };
frameLayout?: Array<Record<string, unknown>>;
};
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<string, unknown>) => ({
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 (
<div className="relative h-full w-full overflow-hidden" style={{ aspectRatio: `${width} / ${height}`, borderRadius, containerType: 'inline-size' }}>
<img
src={photo.url}
alt={photo.displayName || 'Spotlight photo'}
className={photoSlot ? 'absolute object-cover' : 'absolute inset-0 h-full w-full object-cover'}
style={photoSlot ? positionedStyle(photoSlot) : undefined}
draggable={false}
/>
{frame.frameImageUrl ? (
<img
src={frame.frameImageUrl}
alt=""
className="pointer-events-none absolute object-fill"
style={{ left: `${imageLayout.x}%`, top: `${imageLayout.y}%`, width: `${imageLayout.w}%`, height: `${imageLayout.h}%` }}
draggable={false}
/>
) : 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 <img key={String(element.id || index)} src={String(element.imageUrl)} alt="" className="pointer-events-none object-contain" style={positionedStyle(element)} />;
}
if (!isText || !text) return null;
const style = positionedStyle(element);
return (
<span
key={String(element.id || index)}
className="pointer-events-none flex items-center overflow-hidden px-1"
style={{
...style,
color: String(element.color || '#ffffff'),
fontSize: `${(Number(element.fontSizePx || 36) / width) * 100}cqi`,
fontWeight: element.fontWeight === 'bold' ? 700 : 400,
fontFamily: String(element.fontFamily || 'inherit'),
textAlign: String(element.textAlign || 'left') as 'left' | 'center' | 'right',
justifyContent: element.textAlign === 'center' ? 'center' : element.textAlign === 'right' ? 'flex-end' : 'flex-start',
whiteSpace: 'pre-wrap',
lineHeight: 1.1,
}}
>
{element.field === 'name' ? `${String(element.prefix || '')}${text}${String(element.postfix || '')}` : text}
</span>
);
})}
</div>
);
}

View File

@ -9,6 +9,7 @@ import {
getPublicVisitorTheme,
getSocialShareMeta,
} from "../lib/api";
import { getGameQuiz, getMatchUp } from "../lib/games";
import { applyEventTheme } from "../theme/eventTheme";
import { readCachedVisitorTheme, writeCachedVisitorTheme } from "../theme/visitorThemeCache";
import { readStoredVisitorContext } from "../types/visitorContext";
@ -19,7 +20,9 @@ type PublicTokenSource =
| "social-share"
| "photo-wall"
| "photo-wall-present"
| "spotlight-present";
| "spotlight-present"
| "match-up"
| "game-quiz";
type ThemeLookup =
| { kind: "none" }
@ -48,6 +51,8 @@ function resolveThemeLookup(pathname: string, searchParams: URLSearchParams): Th
{ re: /^\/photo-wall-present\/([^/]+)/, source: "photo-wall-present" },
{ re: /^\/spotlight-present\/([^/]+)/, source: "spotlight-present" },
{ re: /^\/photo-wall\/([^/]+)/, source: "photo-wall" },
{ re: /^\/match-up\/([^/]+)/, source: "match-up" },
{ re: /^\/game-quiz\/([^/]+)/, source: "game-quiz" },
];
for (const { re, source } of tokenRoutes) {
const match = pathname.match(re);
@ -66,6 +71,8 @@ async function eventIdFromPublicToken(source: PublicTokenSource, token: string):
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;
if (source === "match-up") return (await getMatchUp(token)).eventId || "";
if (source === "game-quiz") return (await getGameQuiz(token)).eventId || "";
return (await getPhotoWallPresent(token)).eventId;
}

View File

@ -181,9 +181,48 @@ body {
pointer-events: none;
}
@keyframes matchup-rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.matchup-rise { animation: matchup-rise 0.45s ease; }
@media (prefers-reduced-motion: reduce) {
.matchup-rise { animation: none; }
}
.game-page { min-height: 100dvh; background: #9dd9ee center / cover fixed; color: #102333; font-family: Inter, system-ui, sans-serif; display: flex; justify-content: center; }
.game-page--match { background-color: #83cfee; }
.game-page--quiz { background-color: #8fd2ed; }
.game-page--quiz { background: transparent; font-family: var(--theme-body-font, Inter, system-ui, sans-serif); }
.game-page--quiz .game-viewport { background: transparent; box-shadow: none; }
.game-page--quiz .reference-header { height: auto; margin: 16px 16px 0; border-radius: 8px; color: var(--theme-card-text-color, #333333); }
.game-page--quiz .game-brand strong,
.game-page--quiz .reference-icon { color: var(--theme-card-text-color, #333333); }
.quiz-stage { position: relative; min-height: 0; padding-bottom: 24px; background-position: center; background-size: cover; background-repeat: no-repeat; }
.game-page--quiz .game-start--quiz { min-height: 0; margin: 0; padding: 28px 22px 24px; border-radius: 8px; box-shadow: 0 4px 16px rgba(16, 35, 51, 0.12); background-color: var(--theme-card-color, #ffffff); background-image: none; color: var(--theme-card-text-color, #333333); }
.game-page--quiz .game-start h1,
.game-page--quiz .game-start h2,
.game-page--quiz .game-start p,
.game-page--quiz .quiz-card-reference,
.game-page--quiz .quiz-card-reference h1,
.game-page--quiz .quiz-result-reference,
.game-page--quiz .quiz-result-reference h1,
.game-page--quiz .quiz-result-reference h2,
.game-page--quiz .result-message,
.game-page--quiz .share-result,
.game-page--quiz .quiz-progress-reference { color: var(--theme-card-text-color, #333333); }
.game-page--quiz .game-start button { background: var(--theme-button-color, #0878c8); color: var(--theme-button-text-color, #fff); }
.game-page--quiz .quiz-card-reference,
.game-page--quiz .quiz-result-reference { margin: 0; border-radius: 8px; box-shadow: 0 4px 16px rgba(16, 35, 51, 0.12); background-color: var(--theme-card-color, #ffffff); background-image: none; }
.game-page--quiz .quiz-progress-reference { padding: 0 0 12px; }
.game-page--quiz .quiz-progress-reference div { background: color-mix(in srgb, var(--theme-card-text-color, #333333) 16%, transparent); }
.game-page--quiz .quiz-progress-reference i { background: var(--theme-button-color, #0878b9); }
.game-page--quiz .quiz-options-reference button { border-radius: 8px; background: color-mix(in srgb, var(--theme-card-text-color, #333333) 8%, var(--theme-card-color, #ffffff)); color: var(--theme-card-text-color, #333333); }
.game-page--quiz .quiz-options-reference button.is-selected { background: var(--theme-button-color, #0878b9); color: var(--theme-button-text-color, #ffffff); }
.game-page--quiz .quiz-options-reference button.is-correct { background: #c5ebd0; color: var(--theme-card-text-color, #333333); border: 1px solid #2e9b54; }
.game-page--quiz .quiz-options-reference button.is-wrong { background: #f3c6cb; color: var(--theme-card-text-color, #333333); border: 1px solid #cf3447; }
.game-page--quiz .leaderboard b { color: var(--theme-button-color, #0878b9); }
.game-page--quiz .leaderboard { border-top-color: color-mix(in srgb, var(--theme-card-text-color, #333333) 16%, transparent); }
.game-page--quiz .leaderboard li { border-bottom-color: color-mix(in srgb, var(--theme-card-text-color, #333333) 16%, transparent); }
.game-backdrop { position: fixed; inset: 0; background: linear-gradient(180deg, rgba(255,255,255,.02), rgba(13,112,161,.26)); pointer-events: none; }
.game-viewport { position: relative; width: min(750px, 100%); min-height: 100dvh; background: rgba(218,244,250,.25); box-shadow: 0 0 0 1px rgba(17,74,113,.08); }
.reference-header { height: 92px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 14px 22px; color: #102333; }

View File

@ -7,7 +7,7 @@ import type {
VisitorJoinType,
} from "../types/visitorContext";
function getBaseUrl(): string {
export function getBaseUrl(): string {
const raw = import.meta.env.VITE_API_BASE_URL;
if (typeof raw !== "string" || !raw.trim()) return "";
return raw.replace(/\/$/, "");
@ -1064,10 +1064,38 @@ export type PhotoWallPresentData = {
export type SpotlightSource = "socialShare" | "aiAvatar" | "quiz" | "photoWall";
export type SpotlightSources = Record<SpotlightSource, boolean>;
export type SpotlightWinner = {
rank: number;
displayName: string;
photoUrl: string;
score: number;
total: number;
correct: number;
timeSpent: number;
};
export type SpotlightQuiz = {
id: string;
name: string;
winners: SpotlightWinner[];
};
export type SpotlightListEntry = {
participantId: string;
displayName: string;
photoUrl: string;
line: string;
};
export type SpotlightList = {
id: string;
name: string;
entries: SpotlightListEntry[];
};
export type SpotlightPresentData = PhotoWallPresentData & {
sources: SpotlightSources;
frameImageUrl?: string | null;
frameLayout?: Array<Record<string, unknown>>;
winners?: SpotlightWinner[];
winnersPublishedAt?: string | null;
quizzes?: SpotlightQuiz[];
activityTitles?: Partial<Record<SpotlightSource, string>>;
lists?: SpotlightList[];
photos: Array<PhotoWallPresentData["photos"][number] & { source: SpotlightSource }>;
};
@ -1085,7 +1113,7 @@ export async function uploadPhotoWallSelfie(
uploadToken: string,
file: File,
formData?: Record<string, unknown>,
options?: { displayName?: string },
options?: { displayName?: string; participantId?: string },
): Promise<{
awaitingApproval: boolean;
previewUrl: string | null;
@ -1106,6 +1134,9 @@ export async function uploadPhotoWallSelfie(
if (options?.displayName?.trim()) {
fd.append("displayName", options.displayName.trim());
}
if (options?.participantId?.trim()) {
fd.append("participantId", options.participantId.trim());
}
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
@ -1241,6 +1272,7 @@ export async function uploadSocialSharePhoto(
file: File,
frameId: string,
displayName: string,
participantId?: string,
): Promise<{
previewUrl: string | null;
fileUrl: string | null;
@ -1253,6 +1285,7 @@ export async function uploadSocialSharePhoto(
fd.append("file", file, file.name);
fd.append("frameId", frameId);
fd.append("displayName", displayName.trim());
if (participantId?.trim()) fd.append("participantId", participantId.trim());
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;
@ -1318,6 +1351,7 @@ export async function uploadAiAvatarPhoto(
file: File,
avatarId: string,
displayName: string,
participantId?: string,
): Promise<{
previewUrl: string | null;
fileUrl: string | null;
@ -1329,6 +1363,7 @@ export async function uploadAiAvatarPhoto(
fd.append("file", file, file.name);
fd.append("avatarId", avatarId);
fd.append("displayName", displayName.trim());
if (participantId?.trim()) fd.append("participantId", participantId.trim());
const headers: Record<string, string> = {};
const token = getAttendeeToken();
if (token) headers.Authorization = `Bearer ${token}`;

View File

@ -1,24 +1,45 @@
const API_BASE = (import.meta.env.VITE_API_URL || 'http://localhost:3002/api').replace(/\/$/, '');
function apiBase(): string {
const configured = import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_API_URL || 'http://localhost:3001';
const base = String(configured).trim().replace(/\/$/, '');
return base.endsWith('/api') ? base : `${base}/api`;
}
async function call<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } });
const body = await response.json();
if (!response.ok || !body.success) throw new Error(body.error?.message || 'Game unavailable');
const response = await fetch(`${apiBase()}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) } });
const body = await response.json().catch(() => null);
if (!response.ok || !body?.success) throw new Error(body?.error?.message || 'Game unavailable');
return body.data as T;
}
export interface PublicMatchUpItem { _id: string; text: string; imageUrl?: string; }
export interface PublicMatchUpGroup { _id: string; title: string; description?: string; items: PublicMatchUpItem[]; }
export interface PublicMatchUp { name: string; backgroundImage: string; gridSize: 3 | 4 | 5; groups: PublicMatchUpGroup[]; publicToken: string; }
export interface PublicQuizQuestion { _id: string; question: string; options: string[]; }
export interface PublicQuiz { name: string; backgroundImage: string; questions: PublicQuizQuestion[]; publicToken: string; }
export interface QuizAnswerFeedback { correct: boolean; correctIndex: number; points: number; }
export interface QuizResult { displayName: string; score: number; total: number; correct: number; timeSpent: number; }
export interface PublicMatchUp {
name: string;
backgroundImage: string;
backgroundSize?: "cover" | "contain" | "auto";
backgroundRepeat?: "no" | "repeat" | "repeat-x" | "repeat-y";
backgroundWidth?: string | number;
gridSize: 3 | 4 | 5;
attempts?: number;
groups: PublicMatchUpGroup[];
publicToken: string;
eventId?: string;
accessMode?: "open" | "login";
eventName?: string;
event?: import('./api').PhotoWallPublicEvent | null;
eventHeader?: import('./api').PhotoWallEventHeaderDisplay | null;
}
export interface PublicQuizOption { text: string; imageUrl?: string; }
export interface PublicQuizQuestion { _id: string; question: string; imageUrl?: string; type?: "single" | "multiple"; points?: number; timeLimit?: number; options: Array<string | PublicQuizOption>; }
export interface PublicQuizSettings { showResults?: boolean; timeRestricted?: boolean; timeLimit?: number; displayMode?: string; }
export interface PublicQuiz { name: string; backgroundImage: string; settings?: PublicQuizSettings; questions: PublicQuizQuestion[]; publicToken: string; eventId?: string; accessMode?: "open" | "login"; eventName?: string; event?: import('./api').PhotoWallPublicEvent | null; eventHeader?: import('./api').PhotoWallEventHeaderDisplay | null; }
export interface QuizAnswerFeedback { correct: boolean | null; correctIndex: number; correctIndexes?: number[]; points: number; }
export interface QuizResult { displayName: string; score: number; total: number; correct: number; questionCount?: number; timeSpent: number; }
export interface QuizLeaderboardEntry extends QuizResult { _id: string; }
export function getMatchUp(token: string) { return call<PublicMatchUp>(`/public/games/match-up/${encodeURIComponent(token)}`); }
export function guessMatchUp(token: string, itemIds: string[]) { return call<{ correct: boolean; group: { _id: string; title: string; description: string; itemIds: string[] } | null }>(`/public/games/match-up/${encodeURIComponent(token)}/guess`, { method: 'POST', body: JSON.stringify({ itemIds }) }); }
export function completeMatchUp(token: string, groups: string[][]) { return call<{ completed: boolean; groups: number }>(`/public/games/match-up/${encodeURIComponent(token)}/complete`, { method: 'POST', body: JSON.stringify({ groups }) }); }
export function guessMatchUp(token: string, itemIds: string[], participantId?: string) { return call<{ correct: boolean; group: { _id: string; title: string; description: string; itemIds: string[] } | null }>(`/public/games/match-up/${encodeURIComponent(token)}/guess`, { method: 'POST', body: JSON.stringify({ itemIds, participantId }) }); }
export function completeMatchUp(token: string, groups: string[][], participantId?: string) { return call<{ completed: boolean; groups: number }>(`/public/games/match-up/${encodeURIComponent(token)}/complete`, { method: 'POST', body: JSON.stringify({ groups, participantId }) }); }
export function getGameQuiz(token: string) { return call<PublicQuiz>(`/public/games/quiz/${encodeURIComponent(token)}`); }
export function answerGameQuiz(token: string, questionIndex: number, answerIndex: number) { return call<QuizAnswerFeedback>(`/public/games/quiz/${encodeURIComponent(token)}/answer`, { method: 'POST', body: JSON.stringify({ questionIndex, answerIndex }) }); }
export function submitGameQuiz(token: string, payload: { displayName: string; answers: number[]; timeSpent: number }) { return call<{ result: QuizResult; leaderboard: QuizLeaderboardEntry[] }>(`/public/games/quiz/${encodeURIComponent(token)}/submit`, { method: 'POST', body: JSON.stringify(payload) }); }
export function answerGameQuiz(token: string, questionIndex: number, answer: number | number[], participantId?: string) { return call<QuizAnswerFeedback>(`/public/games/quiz/${encodeURIComponent(token)}/answer`, { method: 'POST', body: JSON.stringify(Array.isArray(answer) ? { questionIndex, answerIndexes: answer, participantId } : { questionIndex, answerIndex: answer, participantId }) }); }
export function submitGameQuiz(token: string, payload: { displayName: string; answers: Array<number | number[]>; timeSpent: number; participantId?: string }) { return call<{ result: QuizResult; leaderboard: QuizLeaderboardEntry[] }>(`/public/games/quiz/${encodeURIComponent(token)}/submit`, { method: 'POST', body: JSON.stringify(payload) }); }

View File

@ -0,0 +1,66 @@
const COOKIE_NAME = "eb_engagement_participants";
const MAX_AGE_SECONDS = 60 * 60 * 24 * 400;
function readMap(): Record<string, string> {
if (typeof document === "undefined") return {};
const parts = document.cookie ? document.cookie.split("; ") : [];
const raw = parts.find((part) => part.startsWith(`${COOKIE_NAME}=`));
if (!raw) return {};
try {
const parsed = JSON.parse(decodeURIComponent(raw.slice(COOKIE_NAME.length + 1))) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [eventId, participantId] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof participantId === "string" && eventId && participantId.trim()) {
out[eventId] = participantId.trim();
}
}
return out;
} catch {
return {};
}
}
function cookieScope(): string {
if (typeof window === "undefined") return "";
const host = window.location.hostname.toLowerCase();
const shared = host === "eventbuzz.live" || host.endsWith(".eventbuzz.live");
const domain = shared ? "; Domain=.eventbuzz.live" : "";
const secure = window.location.protocol === "https:" ? "; Secure" : "";
return `${domain}${secure}`;
}
function writeMap(map: Record<string, string>): void {
if (typeof document === "undefined") return;
const value = encodeURIComponent(JSON.stringify(map));
const scope = cookieScope();
document.cookie = `${COOKIE_NAME}=${value}; Path=/; Max-Age=${MAX_AGE_SECONDS}; SameSite=Lax${scope}`;
if (scope.includes("Domain=")) {
document.cookie = `${COOKIE_NAME}=; Path=/; Max-Age=0; SameSite=Lax`;
}
}
export function readParticipantId(eventId: string): string {
if (!eventId) return "";
return readMap()[eventId] || "";
}
export function clearParticipantId(eventId: string): void {
if (!eventId) return;
const map = readMap();
delete map[eventId];
writeMap(map);
}
export function engagementRegisterUrl(eventId: string, returnTo: string): string {
const fromEnv =
(typeof import.meta.env.VITE_ENGAGEMENT_PUBLIC_URL === "string" &&
import.meta.env.VITE_ENGAGEMENT_PUBLIC_URL.trim()) ||
"";
const base = (fromEnv || (import.meta.env.DEV ? "http://localhost:5175" : "https://engage.eventbuzz.live")).replace(
/\/+$/,
"",
);
const q = new URLSearchParams({ returnTo });
return `${base}/${encodeURIComponent(eventId)}/register?${q.toString()}`;
}

View File

@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { getBaseUrl } from "./api";
import { clearParticipantId, engagementRegisterUrl, readParticipantId } from "./participantCookie";
async function profileHasPhoto(eventId: string, participantId: string): Promise<boolean> {
const base = getBaseUrl();
const url = `${base}/api/public/engagement/${encodeURIComponent(eventId)}/participants/${encodeURIComponent(participantId)}`;
try {
const res = await fetch(url);
if (!res.ok) return false;
const body = (await res.json()) as { data?: { participant?: { photoUrl?: string } } };
return Boolean(String(body?.data?.participant?.photoUrl || "").trim());
} catch {
return false;
}
}
/** Profile required unless the activity is Open. A face photo on the shared cookie continues the activity. */
export function useActivityProfileGate(
eventId: string | undefined,
accessMode: string | undefined,
ready: boolean,
) {
const [allowed, setAllowed] = useState(false);
const [participantId, setParticipantId] = useState("");
useEffect(() => {
if (!ready) return;
if (!eventId || accessMode === "open") {
setParticipantId(eventId ? readParticipantId(eventId) : "");
setAllowed(true);
return;
}
let cancelled = false;
void (async () => {
const id = readParticipantId(eventId);
const hasPhoto = id ? await profileHasPhoto(eventId, id) : false;
if (cancelled) return;
if (id && hasPhoto) {
setParticipantId(id);
setAllowed(true);
return;
}
if (id) clearParticipantId(eventId);
window.location.assign(engagementRegisterUrl(eventId, window.location.href));
})();
return () => {
cancelled = true;
};
}, [eventId, accessMode, ready]);
return { allowed, participantId };
}

View File

@ -21,13 +21,11 @@ import { PublicEventChrome } from "../components/PublicEventChrome";
import { SharePlatformIcon } from "../components/SharePlatformIcon";
import {
getAiAvatarMeta,
getVisitorLoginPreview,
uploadAiAvatarPhoto,
type AiAvatarMeta,
type AiAvatarPublicTemplate,
type PhotoWallSharePlatforms,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
@ -37,11 +35,7 @@ import {
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
import { useActivityProfileGate } from "../lib/useActivityProfileGate";
function AiAvatarLayout({
children,
@ -177,7 +171,7 @@ export function AiAvatarPage() {
};
}, [done?.fileUrl]);
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
const profileGate = useActivityProfileGate(meta?.eventId, meta?.accessMode, Boolean(meta) && !loading);
const enabledSharePlatforms = useMemo(
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
@ -245,7 +239,13 @@ export function AiAvatarPage() {
setErr(null);
setActionMsg(null);
try {
const result = await uploadAiAvatarPhoto(uploadToken, file, selectedAvatar._id, displayName.trim());
const result = await uploadAiAvatarPhoto(
uploadToken,
file,
selectedAvatar._id,
displayName.trim(),
profileGate.participantId,
);
setShareFile(null);
setDone({
previewUrl: result.previewUrl,
@ -350,24 +350,6 @@ export function AiAvatarPage() {
}
};
const goLogin = async () => {
if (!meta?.eventId) return;
try {
const preview = await getVisitorLoginPreview(meta.eventId);
const ctx: EbVisitorContext = {
type: "event",
eventId: meta.eventId,
event: preview,
fetchedAt: new Date().toISOString(),
};
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
setPostAuthReturnPath(`/ai-avatar/${uploadToken}`);
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
} catch {
setErr("Could not open login");
}
};
if (!uploadToken) {
return (
<AiAvatarLayout>
@ -376,7 +358,7 @@ export function AiAvatarPage() {
);
}
if (loading) {
if (loading || (meta && !profileGate.allowed)) {
return (
<AiAvatarLayout>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
@ -510,19 +492,7 @@ export function AiAvatarPage() {
return (
<AiAvatarLayout meta={meta}>
<section className="w-full card-bg rounded-lg shadow-md p-6">
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">AI Avatar</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to create your AI avatar.</p>
<button
type="button"
onClick={() => void goLogin()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white"
>
Sign in to continue
</button>
</div>
) : !selectedAvatar ? (
{!selectedAvatar ? (
<div className="space-y-4">
<h2 className="mb-1 text-center text-[22px] font-semibold text-[var(--theme-card-text-color)]">Choose an avatar</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Pick an avatar, then upload your selfie.</p>

View File

@ -1,19 +1,224 @@
import { useEffect, useState } from 'react';
import { CheckCircle2, ChevronLeft, Share2, Trophy, XCircle } from 'lucide-react';
import eventbuzzLogo from '../assets/eventbuzz-logo.png';
import { answerGameQuiz, getGameQuiz, submitGameQuiz, type PublicQuiz, type QuizLeaderboardEntry, type QuizResult } from '../lib/games';
import { getAttendeeParticipant } from '../lib/attendeeAuth';
import { useEffect, useState, type ReactNode } from "react";
import { CheckCircle2, Share2, XCircle } from "lucide-react";
import eventbuzzLogo from "../assets/eventbuzz-logo.png";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { PublicEventChrome } from "../components/PublicEventChrome";
import { getBaseUrl } from "../lib/api";
import { answerGameQuiz, getGameQuiz, submitGameQuiz, type PublicQuiz, type PublicQuizOption, type QuizLeaderboardEntry, type QuizResult } from "../lib/games";
import { useActivityProfileGate } from "../lib/useActivityProfileGate";
function QuizLayout({ children, game }: { children: ReactNode; game?: PublicQuiz | null }) {
const quizBackground = game?.backgroundImage?.trim()
? { backgroundImage: `url(${game.backgroundImage.trim()})` }
: undefined;
return (
<div className="game-page game-page--quiz">
<MobileShell className="relative z-10 bg-transparent">
{game?.event ? (
<PublicEventChrome
event={game.event}
eventHeader={game.eventHeader}
eventNameFallback={game.eventName}
eventId={game.eventId}
/>
) : (
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
)}
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
<div className="quiz-stage flex w-full flex-col px-4" style={quizBackground}>{children}</div>
</main>
</MobileShell>
</div>
);
}
function optionText(option: string | PublicQuizOption) {
return typeof option === "string" ? option : option.text;
}
function optionImage(option: string | PublicQuizOption) {
return typeof option === "string" ? "" : option.imageUrl || "";
}
async function engagementDisplayName(eventId: string, participantId: string) {
const base = getBaseUrl();
const url = `${base}/api/public/engagement/${encodeURIComponent(eventId)}/participants/${encodeURIComponent(participantId)}`;
const res = await fetch(url);
if (!res.ok) return "";
const body = (await res.json()) as { data?: { participant?: { displayName?: string; firstName?: string; lastName?: string } } };
const participant = body?.data?.participant;
return String(participant?.displayName || [participant?.firstName, participant?.lastName].filter(Boolean).join(" ") || "").trim();
}
export function GameQuizPage() {
const token = new URLSearchParams(window.location.search).get('token') || window.location.pathname.split('/').pop() || '';
const participant = getAttendeeParticipant();
const [game, setGame] = useState<PublicQuiz | null>(null); const [index, setIndex] = useState(0); const [answers, setAnswers] = useState<number[]>([]); const [feedback, setFeedback] = useState<(boolean | null)[]>([]); const [selected, setSelected] = useState<number | null>(null); const [correctAnswer, setCorrectAnswer] = useState<number | null>(null); const [result, setResult] = useState<QuizResult | null>(null); const [leaderboard, setLeaderboard] = useState<QuizLeaderboardEntry[]>([]); const [name, setName] = useState(participant?.displayName || [participant?.firstName, participant?.lastName].filter(Boolean).join(' ')); const [started, setStarted] = useState(false); const [startedAt, setStartedAt] = useState(0); const [error, setError] = useState(''); const [loading, setLoading] = useState(true);
useEffect(() => { void getGameQuiz(token).then(setGame).catch((err) => setError(err.message)).finally(() => setLoading(false)); }, [token]);
const submit = async (finalAnswers: number[]) => { if (!game) return; const displayName = name.trim() || window.prompt('Enter a display name for the leaderboard')?.trim() || 'Anonymous'; try { const data = await submitGameQuiz(token, { displayName, answers: finalAnswers, timeSpent: Math.round((Date.now() - startedAt) / 1000) }); setResult(data.result); setLeaderboard(data.leaderboard); } catch (err) { setError(err instanceof Error ? err.message : 'Unable to submit quiz'); } };
const choose = async (answerIndex: number) => { if (!game || selected !== null || !started) return; setSelected(answerIndex); try { const response = await answerGameQuiz(token, index, answerIndex); const nextAnswers = [...answers]; nextAnswers[index] = answerIndex; const nextFeedback = [...feedback]; nextFeedback[index] = response.correct; setCorrectAnswer(response.correctIndex); setAnswers(nextAnswers); setFeedback(nextFeedback); window.setTimeout(() => { if (index + 1 >= game.questions.length) { setStarted(false); void submit(nextAnswers); } else { setIndex((current) => current + 1); setSelected(null); setCorrectAnswer(null); } }, 900); } catch (err) { setSelected(null); setError(err instanceof Error ? err.message : 'Unable to check answer'); } };
const shareResult = async () => { if (!result) return; const text = `${game?.name || 'Quiz'}: I scored ${result.score} points (${result.correct}/${result.total / 5} correct).`; if (navigator.share) await navigator.share({ title: game?.name, text, url: window.location.href }); else await navigator.clipboard?.writeText(text); };
if (loading) return <main className="game-page game-page--quiz"><div className="game-loading">Loading game...</div></main>;
if (error || !game) return <main className="game-page game-page--quiz"><div className="game-error"><h1>Quiz unavailable</h1><p>{error || 'Quiz is not available.'}</p></div></main>;
const background = game.backgroundImage ? { backgroundImage: `url(${game.backgroundImage})` } : undefined; const question = game.questions[index];
return <main className="game-page game-page--quiz" style={background}><div className="game-backdrop" /><section className="game-viewport"><header className="reference-header"><button className="reference-icon" aria-label="Go back" title="Go back" onClick={() => window.history.back()}><ChevronLeft size={25} /></button><div className="game-brand"><img src={eventbuzzLogo} alt="EventBuzz" /><strong>{game.name}</strong></div><button className="reference-icon" aria-label="Leaderboard" title="Leaderboard"><Trophy size={20} /></button></header>{!started && !result ? <section className="game-start game-start--quiz"><img src={eventbuzzLogo} alt="EventBuzz" /><h1>{game.name}</h1><h2>HOW TO PLAY</h2><p>Answer every question.<br />Choose the best answer.<br />How much can you score?</p><input value={name} onChange={(event) => setName(event.target.value)} placeholder="Display name" aria-label="Display name" /><button onClick={() => { setStarted(true); setStartedAt(Date.now()); }}>START QUIZ</button></section> : result ? <section className="quiz-result-reference"><p className="result-message">{result.correct >= Math.ceil(result.total / 5 * 0.7) ? 'Great effort!' : 'Strong effort! Aim for more next time'}</p><h1>You got {result.correct} out of {result.total / 5} correct</h1><div className="result-strip">{feedback.map((answer, answerIndex) => <span className={answer ? 'is-correct' : 'is-wrong'} key={answerIndex}>{answerIndex + 1}</span>)}</div><strong className="result-score">+{result.score}</strong><button className="share-result" onClick={() => void shareResult()}><Share2 size={17} />Share result</button><h2>Leaderboard</h2><ol className="leaderboard">{leaderboard.map((entry, entryIndex) => <li key={entry._id || `${entry.displayName}-${entryIndex}`}><span><b>{entryIndex + 1}</b>{entry.displayName}</span><strong>{entry.score}</strong></li>)}</ol><p className="next-trivia">Next quiz is coming soon. See you then!</p></section> : <><div className="quiz-progress-reference"><span>Question {index + 1} of {game.questions.length}</span><div><i style={{ width: `${((index + 1) / game.questions.length) * 100}%` }} /></div></div><section className="quiz-card-reference"><h1>{question.question}</h1><div className="quiz-options-reference">{question.options.map((option, optionIndex) => { const isChosen = selected === optionIndex; const isCorrect = selected !== null && optionIndex === correctAnswer; const isWrong = selected !== null && !feedback[index] && isChosen; return <button key={optionIndex} className={`${isChosen ? 'is-selected' : ''} ${isCorrect ? 'is-correct' : ''} ${isWrong ? 'is-wrong' : ''}`} onClick={() => void choose(optionIndex)} disabled={selected !== null}>{option}{isCorrect ? <CheckCircle2 size={18} /> : null}{isWrong ? <XCircle size={18} /> : null}</button>; })}</div></section></>}</section></main>;
const token = new URLSearchParams(window.location.search).get("token") || window.location.pathname.split("/").pop() || "";
const [game, setGame] = useState<PublicQuiz | null>(null);
const [index, setIndex] = useState(0);
const [answers, setAnswers] = useState<Array<number | number[]>>([]);
const [feedback, setFeedback] = useState<(boolean | null)[]>([]);
const [selected, setSelected] = useState<number[]>([]);
const [correctIndexes, setCorrectIndexes] = useState<number[]>([]);
const [result, setResult] = useState<QuizResult | null>(null);
const [leaderboard, setLeaderboard] = useState<QuizLeaderboardEntry[]>([]);
const [name, setName] = useState("");
const [started, setStarted] = useState(false);
const [startedAt, setStartedAt] = useState(0);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
useEffect(() => {
void getGameQuiz(token).then(setGame).catch((err) => setError(err.message)).finally(() => setLoading(false));
}, [token]);
const profileGate = useActivityProfileGate(game?.eventId, game?.accessMode || "login", Boolean(game));
const profileRequired = Boolean(game) && (game?.accessMode || "login") !== "open";
const playing = started || (profileRequired && profileGate.allowed);
useEffect(() => {
if (!playing || startedAt) return;
setStartedAt(Date.now());
}, [playing, startedAt]);
useEffect(() => {
if (!profileRequired || !profileGate.allowed || !game?.eventId || !profileGate.participantId) return;
let cancelled = false;
void engagementDisplayName(game.eventId, profileGate.participantId)
.then((label) => {
if (!cancelled && label) setName(label);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [profileRequired, profileGate.allowed, profileGate.participantId, game?.eventId]);
const submit = async (finalAnswers: Array<number | number[]>) => {
if (!game) return;
const displayName = name.trim() || (profileRequired ? "Guest" : window.prompt("Enter a display name for the leaderboard")?.trim() || "Anonymous");
try {
const data = await submitGameQuiz(token, { displayName, answers: finalAnswers, timeSpent: Math.round((Date.now() - startedAt) / 1000), participantId: profileGate.participantId });
setResult(data.result);
setLeaderboard(data.leaderboard);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to submit quiz");
}
};
const commitAnswer = async (answer: number | number[]) => {
if (!game || checking || !playing) return;
const question = game.questions[index];
const chosen = Array.isArray(answer) ? answer : [answer];
if (!chosen.length) return;
setChecking(true);
setSelected(chosen);
try {
const response = await answerGameQuiz(token, index, answer, profileGate.participantId);
const nextAnswers = [...answers];
nextAnswers[index] = answer;
const nextFeedback = [...feedback];
nextFeedback[index] = response.correct;
setCorrectIndexes(response.correctIndexes || (response.correctIndex >= 0 ? [response.correctIndex] : []));
setAnswers(nextAnswers);
setFeedback(nextFeedback);
window.setTimeout(() => {
if (index + 1 >= game.questions.length) {
setStarted(false);
void submit(nextAnswers);
} else {
setIndex((current) => current + 1);
setSelected([]);
setCorrectIndexes([]);
}
setChecking(false);
}, question && response.correct === null ? 250 : 900);
} catch (err) {
setSelected([]);
setChecking(false);
setError(err instanceof Error ? err.message : "Unable to check answer");
}
};
if (loading || (game && !profileGate.allowed)) return <QuizLayout><div className="game-loading">Loading game...</div></QuizLayout>;
if (error || !game) return <QuizLayout><div className="game-error"><h1>Quiz unavailable</h1><p>{error || "Quiz is not available."}</p></div></QuizLayout>;
const question = game.questions[index];
const multiple = question?.type === "multiple";
const reveal = selected.length > 0 && correctIndexes.length > 0;
const questionCount = result?.questionCount || game.questions.length;
return (
<QuizLayout game={game}>
{!playing && !result ? (
<section className="game-start game-start--quiz card-bg">
<img src={eventbuzzLogo} alt="EventBuzz" />
<h1>{game.name}</h1>
<h2>HOW TO PLAY</h2>
<p>Answer every question.<br />Choose the best answer.<br />How much can you score?</p>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Display name" aria-label="Display name" />
<button className="theme-btn-bg" onClick={() => { setStarted(true); setStartedAt(Date.now()); }}>START QUIZ</button>
</section>
) : result ? (
<section className="quiz-result-reference card-bg">
<p className="result-message">{result.correct >= Math.ceil(questionCount * 0.7) ? "Great effort!" : "Strong effort! Aim for more next time"}</p>
<h1>You got {result.correct} out of {questionCount} correct</h1>
<div className="result-strip">{feedback.map((answer, answerIndex) => <span className={answer ? "is-correct" : "is-wrong"} key={answerIndex}>{answerIndex + 1}</span>)}</div>
{game.settings?.showResults !== false ? <strong className="result-score">+{result.score}</strong> : null}
<button className="share-result" onClick={() => void shareResult(game.name, result)}><Share2 size={17} />Share result</button>
{game.settings?.showResults !== false ? (
<>
<h2>Leaderboard</h2>
<ol className="leaderboard">{leaderboard.map((entry, entryIndex) => <li key={entry._id || `${entry.displayName}-${entryIndex}`}><span><b>{entryIndex + 1}</b>{entry.displayName}</span><strong>{entry.score}</strong></li>)}</ol>
</>
) : null}
</section>
) : question ? (
<>
<section className="quiz-card-reference card-bg">
<div className="quiz-progress-reference"><span>Question {index + 1} of {game.questions.length}</span><div><i style={{ width: `${((index + 1) / game.questions.length) * 100}%` }} /></div></div>
{question.imageUrl ? <img src={question.imageUrl} alt="" style={{ width: "100%", maxHeight: 180, objectFit: "cover", marginBottom: 16 }} /> : null}
<h1>{question.question}</h1>
<div className="quiz-options-reference">
{question.options.map((option, optionIndex) => {
const isChosen = selected.includes(optionIndex);
const isCorrect = reveal && correctIndexes.includes(optionIndex);
const isWrong = reveal && feedback[index] === false && isChosen && !isCorrect;
return (
<button
key={optionIndex}
className={`${isChosen ? "is-selected" : ""} ${isCorrect ? "is-correct" : ""} ${isWrong ? "is-wrong" : ""}`}
onClick={() => {
if (checking || reveal) return;
if (multiple) setSelected((current) => current.includes(optionIndex) ? current.filter((item) => item !== optionIndex) : [...current, optionIndex]);
else void commitAnswer(optionIndex);
}}
disabled={checking || reveal}
>
<span>
{optionImage(option) ? <img src={optionImage(option)} alt="" style={{ width: 42, height: 42, objectFit: "cover", marginRight: 8 }} /> : null}
{optionText(option)}
</span>
{isCorrect ? <CheckCircle2 size={18} /> : null}
{isWrong ? <XCircle size={18} /> : null}
</button>
);
})}
</div>
{multiple && !reveal ? (
<button type="button" className="theme-btn-bg" disabled={!selected.length || checking} onClick={() => void commitAnswer(selected)} style={{ width: "100%", marginTop: 16, border: 0, borderRadius: 4, padding: 14, fontWeight: 800, cursor: "pointer" }}>
Submit answer
</button>
) : null}
</section>
</>
) : null}
</QuizLayout>
);
}
async function shareResult(name: string, result: QuizResult) {
const text = `${name || "Quiz"}: I scored ${result.score} points (${result.correct}/${result.questionCount || result.correct} correct).`;
if (navigator.share) await navigator.share({ title: name, text, url: window.location.href });
else await navigator.clipboard?.writeText(text);
}

View File

@ -1,45 +1,426 @@
import { useEffect, useMemo, useState } from 'react';
import { BarChart3, ChevronLeft, CircleHelp, Heart, RefreshCw, Trophy, X } from 'lucide-react';
import eventbuzzLogo from '../assets/eventbuzz-logo.png';
import { getMatchUp, guessMatchUp, type PublicMatchUp, type PublicMatchUpItem } from '../lib/games';
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { CircleHelp, Heart, RefreshCw, X } from "lucide-react";
import { flushSync } from "react-dom";
import { useParams } from "react-router-dom";
import gsap from "gsap";
import { HeaderMobile } from "../components/HeaderMobile";
import { MobileShell } from "../components/MobileShell";
import { PublicEventChrome } from "../components/PublicEventChrome";
import { getMatchUp, guessMatchUp, type PublicMatchUp, type PublicMatchUpItem } from "../lib/games";
import { useActivityProfileGate } from "../lib/useActivityProfileGate";
type Reveal = { _id: string; title: string; description: string; itemIds: string[] };
function shuffled<T>(items: T[]) { return [...items].sort(() => Math.random() - 0.5); }
function shuffled<T>(items: T[]) {
return [...items].sort(() => Math.random() - 0.5);
}
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function measureTiles(root: ParentNode) {
const map = new Map<string, DOMRect>();
root.querySelectorAll<HTMLElement>("[data-tile-id]").forEach((el) => {
const id = el.dataset.tileId;
if (id && !map.has(id)) map.set(id, el.getBoundingClientRect());
});
return map;
}
function flipCommit(root: HTMLElement | null, apply: () => void, duration = 0.62) {
if (!root || prefersReducedMotion()) {
flushSync(apply);
return Promise.resolve();
}
const before = measureTiles(root);
flushSync(apply);
const nodes: HTMLElement[] = [];
root.querySelectorAll<HTMLElement>("[data-tile-id]").forEach((el) => {
const from = before.get(el.dataset.tileId || "");
if (!from) return;
const to = el.getBoundingClientRect();
if (to.width < 1 || to.height < 1) return;
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const sx = to.width > 0 ? from.width / to.width : 1;
const sy = to.height > 0 ? from.height / to.height : 1;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5 && Math.abs(sx - 1) < 0.02 && Math.abs(sy - 1) < 0.02) return;
gsap.set(el, { x: dx, y: dy, scaleX: sx, scaleY: sy, transformOrigin: "center center", zIndex: 5 });
nodes.push(el);
});
if (!nodes.length) return Promise.resolve();
return new Promise<void>((resolve) => {
gsap.to(nodes, {
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
duration,
ease: "power2.inOut",
overwrite: true,
onComplete: () => {
gsap.set(nodes, { clearProps: "transform,zIndex" });
resolve();
},
});
});
}
function mergeFormedGroup(root: HTMLElement | null) {
if (!root || prefersReducedMotion()) return Promise.resolve();
const tiles = root.querySelector<HTMLElement>("[data-merge-tiles]");
const title = root.querySelector<HTMLElement>("[data-merge-title]");
const background = root.querySelector<HTMLElement>("[data-merge-bg]");
if (!tiles || !title || !background) return Promise.resolve();
return new Promise<void>((resolve) => {
gsap.timeline({ onComplete: resolve })
.to(background, { opacity: 1, duration: 0.38, ease: "power1.out" }, 0)
.to(tiles, { opacity: 0, scale: 0.96, duration: 0.32, ease: "power1.in" }, 0.06)
.fromTo(title, { opacity: 0, y: 8 }, { opacity: 1, y: 0, duration: 0.36, ease: "power2.out" }, 0.12);
});
}
function shakeTiles(root: HTMLElement | null, ids: string[]) {
if (!root || prefersReducedMotion()) return Promise.resolve();
const nodes = ids
.map((id) => root.querySelector<HTMLElement>(`[data-tile-id="${CSS.escape(id)}"]`))
.filter((node): node is HTMLElement => Boolean(node));
if (!nodes.length) return Promise.resolve();
return new Promise<void>((resolve) => {
gsap.to(nodes, {
keyframes: [
{ x: -8, duration: 0.07 },
{ x: 7, duration: 0.07 },
{ x: -5, duration: 0.06 },
{ x: 4, duration: 0.06 },
{ x: 0, duration: 0.06 },
],
ease: "power1.inOut",
onComplete: () => {
gsap.set(nodes, { clearProps: "transform" });
resolve();
},
});
});
}
function matchUpBackgroundWidth(value: string | number | undefined) {
const raw = String(value ?? "").trim().toLowerCase();
const px = raw.match(/^(\d+(?:\.\d+)?)\s*px$/);
if (px) {
const n = Math.round(Number(px[1]));
if (Number.isFinite(n) && n >= 1) return `${Math.min(4000, n)}px`;
}
const percent = raw.match(/^(\d+(?:\.\d+)?)\s*%?$/);
const n = percent ? Math.round(Number(percent[1])) : NaN;
return `${Number.isFinite(n) ? Math.min(100, Math.max(1, n)) : 100}%`;
}
function MatchUpLayout({
children,
game,
}: {
children: ReactNode;
game?: PublicMatchUp | null;
}) {
const backgroundUrl = game?.backgroundImage?.trim() || "";
const backgroundWidth = matchUpBackgroundWidth(game?.backgroundWidth);
const backgroundSize = game?.backgroundSize === "contain" || game?.backgroundSize === "auto" ? game.backgroundSize : "cover";
const backgroundRepeat = game?.backgroundRepeat === "repeat" || game?.backgroundRepeat === "repeat-x" || game?.backgroundRepeat === "repeat-y"
? game.backgroundRepeat
: "no-repeat";
return (
<>
{backgroundUrl ? (
<div
aria-hidden
className="pointer-events-none fixed inset-y-0 left-1/2 z-0 -translate-x-1/2 bg-transparent bg-center"
style={{
width: backgroundWidth,
backgroundImage: `url(${backgroundUrl})`,
backgroundSize,
backgroundRepeat,
}}
/>
) : null}
<MobileShell className="relative z-10 bg-transparent">
{game?.event ? (
<PublicEventChrome
event={game.event}
eventHeader={game.eventHeader}
eventNameFallback={game.eventName}
eventId={game.eventId}
/>
) : (
<HeaderMobile showRewardsAndLogout={false} showBack={false} />
)}
<main className="relative z-0 flex w-full max-w-none flex-1 flex-col pb-8 pt-6">
<div className="flex w-full max-w-none flex-col px-4">{children}</div>
</main>
</MobileShell>
</>
);
}
export function MatchUpPage() {
const token = new URLSearchParams(window.location.search).get('token') || window.location.pathname.split('/').pop() || '';
const [game, setGame] = useState<PublicMatchUp | null>(null); const [items, setItems] = useState<PublicMatchUpItem[]>([]); const [selected, setSelected] = useState<string[]>([]); const [reveals, setReveals] = useState<Reveal[]>([]); const [lives, setLives] = useState(5); const [message, setMessage] = useState(''); const [helpOpen, setHelpOpen] = useState(false); const [started, setStarted] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState('');
useEffect(() => { void getMatchUp(token).then((data) => { setGame(data); setItems(shuffled(data.groups.flatMap((group) => group.items))); }).catch((err) => setError(err.message)).finally(() => setLoading(false)); }, [token]);
const activeItems = useMemo(() => items.filter((item) => !reveals.some((group) => group.itemIds.includes(item._id))), [items, reveals]);
const finished = Boolean(game && reveals.length === game.groups.length); const failed = lives <= 0; const background = game?.backgroundImage ? { backgroundImage: `url(${game.backgroundImage})` } : undefined;
const submit = async () => { if (!game || selected.length !== game.gridSize || loading || failed) return; setMessage('Checking your guess...'); try { const result = await guessMatchUp(token, selected); if (result.correct && result.group) { setReveals((current) => [...current, result.group as Reveal]); setSelected([]); setMessage('Connection found'); } else { setLives((current) => Math.max(0, current - 1)); setSelected([]); setMessage('That group does not match'); } } catch (err) { setMessage(err instanceof Error ? err.message : 'Unable to check guess'); } };
if (loading) return <main className="game-page game-page--match"><div className="game-loading">Loading game...</div></main>;
if (error || !game) return <main className="game-page game-page--match"><div className="game-error"><h1>Game unavailable</h1><p>{error || 'Match Up is not available.'}</p></div></main>;
return <main className="game-page game-page--match" style={background}><div className="game-backdrop" /><section className="game-viewport"><header className="reference-header"><button className="reference-icon" aria-label="Go back" title="Go back" onClick={() => window.history.back()}><ChevronLeft size={25} /></button><div className="game-brand"><img src={eventbuzzLogo} alt="EventBuzz" /><strong>{game.name}</strong></div><div className="reference-actions"><button className="reference-icon" aria-label="Game progress" title="Game progress"><BarChart3 size={19} /></button><button className="reference-icon" aria-label="Leaderboard" title="Leaderboard"><Trophy size={19} /></button><button className="reference-icon" aria-label="How to play" title="How to play" onClick={() => setHelpOpen(true)}><CircleHelp size={20} /></button></div></header>{!started ? <section className="game-start game-start--match"><img src={eventbuzzLogo} alt="EventBuzz" /><h1>{game.name}</h1><span className="metric-badge">Metric {game.gridSize} x {game.gridSize}</span><h2>HOW TO PLAY</h2><p>Find groups of related tiles.<br />Choose {game.gridSize} tiles for each connection.<br />How many can you solve?</p><button onClick={() => setStarted(true)}>START</button></section> : <><div className="game-subheader"><button className="subheader-back" aria-label="Go back" title="Go back" onClick={() => window.history.back()}><ChevronLeft size={18} /></button><span>Today</span><span /></div>{helpOpen ? <div className="game-dialog-backdrop" role="presentation" onClick={() => setHelpOpen(false)}><section className="game-dialog" role="dialog" aria-modal="true" aria-labelledby="match-help-title" onClick={(event) => event.stopPropagation()}><div className="dialog-title"><h2 id="match-help-title">How to play?</h2><CircleHelp size={20} /></div><p><strong>1</strong> Pick {game.gridSize} tiles that share a common connection.</p><p><strong>2</strong> Tap submit to check your guess.</p><p><strong>3</strong> Find every connection before you run out of hearts.</p><button className="dialog-close" onClick={() => setHelpOpen(false)}>Got it</button></section></div> : null}<div className="match-content"><div className="match-toolbar"><div className="heart-count" aria-label={`${lives} hearts remaining`}><Heart size={22} fill="currentColor" /><strong>{lives}</strong></div><span className="metric-inline">{game.gridSize} x {game.gridSize}</span><button className="game-tool" onClick={() => setItems(shuffled(items))} disabled={finished || failed}><RefreshCw size={16} />Shuffle</button><button className="game-tool" onClick={() => setSelected([])} disabled={!selected.length}><X size={17} />Clear</button></div>{reveals.map((group) => <article className="match-reveal" key={group._id}><strong>{group.title}</strong><span>{group.description}</span></article>)}{finished || failed ? <section className="match-finish"><div className="finish-mark">{finished ? 'OK' : '!'}</div><h1>{finished ? 'Nice work.' : 'Out of hearts.'}</h1><p>{finished ? 'You found every hidden connection.' : 'Try the puzzle again and find the connections.'}</p><button onClick={() => window.location.reload()}>Play again</button></section> : <><div
className="match-board"
style={{
gridTemplateColumns: `repeat(${game.gridSize}, minmax(0, 1fr))`,
}}
>
{activeItems.map((item) => (
const { token = "" } = useParams<{ token: string }>();
const [game, setGame] = useState<PublicMatchUp | null>(null);
const [items, setItems] = useState<PublicMatchUpItem[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [reveals, setReveals] = useState<Reveal[]>([]);
const [lives, setLives] = useState(5);
const [message, setMessage] = useState("");
const [helpOpen, setHelpOpen] = useState(false);
const [started, setStarted] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [pending, setPending] = useState<Reveal | null>(null);
const [locked, setLocked] = useState(false);
const boardRef = useRef<HTMLDivElement>(null);
const shuffleIconRef = useRef<SVGSVGElement>(null);
const busy = useRef(false);
useEffect(() => {
void getMatchUp(token)
.then((data) => {
setGame(data);
setLives(Math.min(20, Math.max(1, Number(data.attempts) || 5)));
setItems(shuffled(data.groups.flatMap((group) => group.items)));
})
.catch((err) => setError(err instanceof Error ? err.message : "Match Up is not available."))
.finally(() => setLoading(false));
}, [token]);
const profileGate = useActivityProfileGate(game?.eventId, game?.accessMode || "login", Boolean(game));
const activeItems = useMemo(
() => items.filter((item) => !reveals.some((group) => group.itemIds.includes(item._id)) && !pending?.itemIds.includes(item._id)),
[items, reveals, pending],
);
const finished = Boolean(game && reveals.length === game.groups.length);
const failed = lives <= 0;
const pendingItems = pending
? pending.itemIds.map((id) => items.find((item) => item._id === id)).filter((item): item is PublicMatchUpItem => Boolean(item))
: [];
const shuffleBoard = () => {
if (!game || busy.current || finished || failed) return;
busy.current = true;
setLocked(true);
const hidden = new Set(reveals.flatMap((group) => group.itemIds));
const solved = items.filter((item) => hidden.has(item._id));
const visible = shuffled(items.filter((item) => !hidden.has(item._id)));
if (shuffleIconRef.current) {
gsap.to(shuffleIconRef.current, { rotate: "+=180", duration: 0.55, ease: "power2.inOut" });
}
void flipCommit(boardRef.current, () => setItems([...solved, ...visible]), 0.55).finally(() => {
busy.current = false;
setLocked(false);
});
};
const submit = async () => {
if (!game || selected.length !== game.gridSize || busy.current || failed) return;
const guessIds = selected;
busy.current = true;
setLocked(true);
setMessage("");
try {
const result = await guessMatchUp(token, guessIds, profileGate.participantId);
if (result.correct && result.group) {
const group = result.group as Reveal;
await flipCommit(boardRef.current, () => {
setPending(group);
setSelected([]);
});
await mergeFormedGroup(boardRef.current);
flushSync(() => {
setReveals((current) => [...current, group]);
setPending(null);
});
setMessage("Connection found");
} else {
await shakeTiles(boardRef.current, guessIds);
setLives((current) => Math.max(0, current - 1));
setSelected([]);
setMessage("That group does not match");
}
} catch (err) {
setMessage(err instanceof Error ? err.message : "Unable to check guess");
} finally {
busy.current = false;
setLocked(false);
}
};
if (loading || (game && !profileGate.allowed)) {
return (
<MatchUpLayout>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
</MatchUpLayout>
);
}
if (error || !game) {
return (
<MatchUpLayout>
<section className="w-full card-bg rounded-lg p-6 text-center shadow-md">
<h1 className="text-[22px] font-semibold text-[var(--theme-card-text-color)]">Game unavailable</h1>
<p className="mt-2 text-sm text-[var(--theme-card-text-color)]/80">{error || "Match Up is not available."}</p>
</section>
</MatchUpLayout>
);
}
return (
<MatchUpLayout game={game}>
{!started ? (
<section className="w-full card-bg rounded-lg p-6 text-center shadow-md">
<h1 className="text-[22px] font-semibold text-[var(--theme-card-text-color)]">{game.name}</h1>
<p className="mt-2 text-sm font-semibold text-[var(--theme-button-color)]">{game.gridSize} × {game.gridSize}</p>
<h2 className="mt-6 text-sm font-semibold uppercase tracking-wide text-[var(--theme-card-text-color)]">How to play</h2>
<p className="mt-2 text-sm leading-6 text-[var(--theme-card-text-color)]/80">
Find groups of related tiles.
<br />
Choose {game.gridSize} tiles for each connection.
<br />
How many can you solve?
</p>
<button
type="button"
className="mt-6 inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold"
onClick={() => setStarted(true)}
>
Start
</button>
</section>
) : (
<section className="w-full card-bg rounded-lg p-4 shadow-md">
<h1 className="mb-3 text-center text-[18px] font-semibold leading-6 text-[var(--theme-card-text-color)]">{game.name}</h1>
<div className="mb-3 flex items-center gap-2 text-[var(--theme-card-text-color)]">
<div className="mr-auto inline-flex items-center gap-1 text-[#d92945]" aria-label={`${lives} hearts remaining`}>
<Heart size={18} fill="currentColor" />
<strong>{lives}</strong>
</div>
<span className="text-xs font-semibold">{game.gridSize} × {game.gridSize}</span>
<button type="button" className="inline-flex items-center gap-1 text-xs font-semibold disabled:opacity-40" onClick={shuffleBoard} disabled={finished || failed || locked}>
<RefreshCw ref={shuffleIconRef} size={14} /> Shuffle
</button>
<button type="button" className="inline-flex items-center gap-1 text-xs font-semibold disabled:opacity-40" onClick={() => setSelected([])} disabled={locked || !selected.length}>
<X size={14} /> Clear
</button>
<button type="button" className="inline-flex items-center" aria-label="How to play" onClick={() => setHelpOpen(true)}>
<CircleHelp size={16} />
</button>
</div>
<div ref={boardRef}>
{reveals.map((group) => (
<article key={group._id} className="mb-2 flex min-h-[76px] flex-col items-center justify-center rounded-md bg-[var(--theme-button-color)] px-3 py-2 text-center text-[var(--theme-button-text-color)]">
<strong className="block text-sm uppercase">{group.title || "Connection"}</strong>
{group.description ? <span className="text-xs">{group.description}</span> : null}
</article>
))}
{pending ? (
<article className="relative z-10 mb-2 min-h-[76px]">
<div data-merge-bg className="absolute inset-0 rounded-md bg-[var(--theme-button-color)] opacity-0" />
<div data-merge-title className="relative flex min-h-[76px] flex-col items-center justify-center px-3 py-2 text-center text-[var(--theme-button-text-color)] opacity-0">
<strong className="block text-sm uppercase">{pending.title || "Connection"}</strong>
{pending.description ? <span className="text-xs">{pending.description}</span> : null}
</div>
<div
data-merge-tiles
className="absolute inset-0 grid gap-2"
style={{ gridTemplateColumns: `repeat(${Math.max(pendingItems.length, 1)}, minmax(0, 1fr))` }}
>
{pendingItems.map((item) => (
<div
key={item._id}
data-tile-id={item._id}
className="theme-btn-bg flex min-h-0 items-center justify-center rounded-md px-1.5 py-2 text-center text-xs font-semibold shadow-sm"
>
<span>{item.text}</span>
</div>
))}
</div>
</article>
) : null}
{finished || failed ? null : (
<div
className="grid gap-2"
style={{ gridTemplateColumns: `repeat(${game.gridSize}, minmax(0, 1fr))` }}
>
{activeItems.map((item) => {
const isSelected = selected.includes(item._id);
return (
<button
key={item._id}
className={`match-tile ${
selected.includes(item._id) ? 'is-selected' : ''
}`}
type="button"
data-tile-id={item._id}
disabled={locked}
className={`flex min-h-[76px] items-center justify-center rounded-md px-1.5 py-2 text-center text-xs font-semibold shadow-sm disabled:opacity-100 ${isSelected ? "theme-btn-bg" : "bg-white text-[var(--theme-card-text-color)]"}`}
onClick={() =>
setSelected((current) =>
current.includes(item._id)
? current.filter((id) => id !== item._id)
: current.length >= game.gridSize
? current
: [...current, item._id]
: [...current, item._id],
)
}
>
{item.imageUrl ? <img src={item.imageUrl} alt="" /> : null}
<span>{item.text}</span>
</button>
))}
</div><button className="match-submit" disabled={selected.length !== game.gridSize} onClick={() => void submit()}>SUBMIT GUESS</button></>}{message ? <p className={`game-feedback ${message.includes('not') ? 'is-error' : ''}`}>{message}</p> : null}</div></>}</section></main>;
);
})}
</div>
)}
</div>
{finished || failed ? (
<div className="matchup-rise py-8 text-center">
<h2 className="text-[22px] font-semibold text-[var(--theme-card-text-color)]">{finished ? "Nice work." : "Out of hearts."}</h2>
<p className="mt-2 text-sm text-[var(--theme-card-text-color)]/80">
{finished ? "You found every hidden connection." : "Try the puzzle again and find the connections."}
</p>
<button type="button" className="mt-6 inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold" onClick={() => window.location.reload()}>
Play again
</button>
</div>
) : (
<button
type="button"
className="mt-4 inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold disabled:opacity-50"
disabled={locked || selected.length !== game.gridSize}
onClick={() => void submit()}
>
Submit guess
</button>
)}
{message ? (
<p className={`mt-3 text-center text-xs font-semibold ${message.includes("not") ? "text-red-600" : "text-[var(--theme-card-text-color)]/80"}`}>
{message}
</p>
) : null}
</section>
)}
{helpOpen ? (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/40 p-5" role="presentation" onClick={() => setHelpOpen(false)}>
<section className="w-full max-w-sm card-bg rounded-lg p-5 shadow-xl" role="dialog" aria-modal="true" aria-labelledby="match-help-title" onClick={(event) => event.stopPropagation()}>
<h2 id="match-help-title" className="text-lg font-semibold text-[var(--theme-card-text-color)]">How to play?</h2>
<p className="mt-3 text-sm text-[var(--theme-card-text-color)]/80">Pick {game.gridSize} tiles that share a common connection.</p>
<p className="mt-2 text-sm text-[var(--theme-card-text-color)]/80">Tap submit to check your guess.</p>
<p className="mt-2 text-sm text-[var(--theme-card-text-color)]/80">Find every connection before you run out of hearts.</p>
<button type="button" className="mt-5 inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold" onClick={() => setHelpOpen(false)}>
Got it
</button>
</section>
</div>
) : null}
</MatchUpLayout>
);
}

View File

@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { getPhotoWallPresent, getSpotlightPresent, type PhotoWallPresentData, type SpotlightPresentData, type SpotlightSource, type SpotlightSources } from "../lib/api";
import { getPhotoWallPresent, getSpotlightPresent, type PhotoWallPresentData, type SpotlightListEntry, type SpotlightPresentData, type SpotlightQuiz, type SpotlightSource, type SpotlightSources, type SpotlightWinner } from "../lib/api";
import { PhotoWallPresentChrome } from "../components/PhotoWallPresentChrome";
import { MarqueePhotoWall } from "../components/photoWall/MarqueePhotoWall";
import { PhotoSpotlight } from "../components/photoWall/PhotoSpotlight";
import type { SpotlightFrame } from "../components/photoWall/SpotlightPhotoFrame";
import {
emptyRows,
isMarqueeReady,
@ -34,17 +33,78 @@ function presentShapeStyles(
return { borderRadius, aspectRatio, shape: normalized };
}
function initialPortrait(name: string): string {
const letter = (name.trim().charAt(0) || "?").toUpperCase().replace(/[^0-9A-Z]/g, "?");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><rect width="640" height="640" fill="#27272a"/><text x="320" y="390" text-anchor="middle" font-size="280" font-family="Arial,sans-serif" fill="#fafafa">${letter}</text></svg>`;
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
}
function winnerToPhoto(winner: SpotlightWinner): PresentPhoto {
return {
_id: `winner:${winner.rank}:${winner.displayName}`,
url: winner.photoUrl || initialPortrait(winner.displayName),
displayName: winner.displayName,
message: `#${winner.rank} · ${winner.score}/${winner.total}`,
};
}
function spotlightLineup(next: PhotoWallPresentData, winners: SpotlightWinner[]): PresentPhoto[] {
const photos = oldestFirst(
(next.photos || []).filter((photo) => {
if (!photo._id) return false;
const source = "source" in photo ? photo.source : undefined;
return source !== "quiz";
}),
);
return [...winners.map(winnerToPhoto), ...photos];
}
function entryToPhoto(entry: SpotlightListEntry): PresentPhoto {
return {
_id: entry.participantId,
url: entry.photoUrl || initialPortrait(entry.displayName),
displayName: entry.displayName,
message: entry.line?.trim() || null,
};
}
function oldestFirst(photos: PresentPhoto[]): PresentPhoto[] {
return [...photos].reverse();
}
const ACTIVITY_TITLE_FALLBACK: Record<SpotlightSource, string> = {
socialShare: "Social Share",
aiAvatar: "AI Avatar",
quiz: "Quiz",
photoWall: "Photo Wall",
};
function singleActivityTitle(
sources: SpotlightSources,
titles?: Partial<Record<SpotlightSource, string>>,
quizName?: string,
): string {
const selected = (Object.keys(ACTIVITY_TITLE_FALLBACK) as SpotlightSource[]).filter((key) => sources[key]);
if (selected.length !== 1) return "";
const key = selected[0];
if (key === "quiz") return quizName?.trim() || titles?.quiz?.trim() || ACTIVITY_TITLE_FALLBACK.quiz;
return titles?.[key]?.trim() || ACTIVITY_TITLE_FALLBACK[key];
}
function presentQuizzes(next: PhotoWallPresentData | null | undefined): SpotlightQuiz[] {
return ((next as SpotlightPresentData | null)?.quizzes || []);
}
function quizWinners(next: PhotoWallPresentData | null | undefined, quizId: string | null): SpotlightWinner[] {
const quizzes = presentQuizzes(next);
const selected = quizzes.find((quiz) => quiz.id === quizId) || quizzes[0];
return selected?.winners || [];
}
export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?: boolean }) {
const { presentToken } = useParams<{ presentToken: string }>();
const [data, setData] = useState<(PhotoWallPresentData & {
sources?: SpotlightSources;
frameImageUrl?: string | null;
frameImageLayout?: { x: number; y: number; w: number; h: number };
frameLayout?: Array<Record<string, unknown>>;
}) | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@ -55,6 +115,13 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
const [spotlightQueue, setSpotlightQueue] = useState<PresentPhoto[]>([]);
const [activeSpotlight, setActiveSpotlight] = useState<PresentPhoto | null>(null);
const [selectedSources, setSelectedSources] = useState<SpotlightSources>({ socialShare: true, aiAvatar: true, quiz: true, photoWall: true });
const [selectedQuizId, setSelectedQuizId] = useState<string | null>(null);
const [selectedListId, setSelectedListId] = useState<string | null>(null);
const [sourcesView, setSourcesView] = useState(true);
const [listWallPhotos, setListWallPhotos] = useState<PresentPhoto[]>([]);
const [listRowPhotos, setListRowPhotos] = useState<PresentPhoto[][]>([]);
const [listQueue, setListQueue] = useState<PresentPhoto[]>([]);
const [listSpotlight, setListSpotlight] = useState<PresentPhoto | null>(null);
const sourcePreferencesInitialized = useRef(false);
const selectedSourceQuery = (Object.keys(selectedSources) as SpotlightSource[]).filter((source) => selectedSources[source]).join(",");
@ -67,13 +134,60 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
const seededRef = useRef(false);
/** Once every row reaches photosPerRow, keep marquee on. */
const marqueeStartedRef = useRef(false);
const listMarqueeStartedRef = useRef(false);
const listWallPhotosRef = useRef<PresentPhoto[]>([]);
const listQueueRef = useRef<PresentPhoto[]>([]);
const listSpotlightRef = useRef<PresentPhoto | null>(null);
const listEntriesRef = useRef<Map<string, PresentPhoto>>(new Map());
const selectedListIdRef = useRef<string | null>(null);
const selectedQuizIdRef = useRef<string | null>(null);
const dataRef = useRef(data);
const wallGenerationRef = useRef(0);
wallPhotosRef.current = wallPhotos;
activeSpotlightRef.current = activeSpotlight;
spotlightQueueRef.current = spotlightQueue;
listWallPhotosRef.current = listWallPhotos;
listQueueRef.current = listQueue;
listSpotlightRef.current = listSpotlight;
selectedListIdRef.current = selectedListId;
selectedQuizIdRef.current = selectedQuizId;
dataRef.current = data;
totalRowsRef.current = Math.max(1, data?.totalRows ?? 3);
photosPerRowRef.current = Math.max(1, Math.min(12, data?.photosPerRow || 4));
const playList = useCallback((entries: SpotlightListEntry[]) => {
const lineup = entries.map(entryToPhoto).filter((photo) => Boolean(photo._id));
listEntriesRef.current = new Map(lineup.map((photo) => [photo._id, photo]));
listMarqueeStartedRef.current = false;
listSpotlightRef.current = null;
listWallPhotosRef.current = [];
listQueueRef.current = lineup;
setListWallPhotos([]);
setListRowPhotos(emptyRows(totalRowsRef.current));
setListSpotlight(null);
setListQueue(lineup);
}, []);
const mergeList = useCallback((entries: SpotlightListEntry[]) => {
const photos = entries.map(entryToPhoto).filter((photo) => Boolean(photo._id));
const byId = new Map(photos.map((photo) => [photo._id, photo]));
listEntriesRef.current = byId;
const known = new Set<string>([
...listWallPhotosRef.current.map((photo) => photo._id),
...listQueueRef.current.map((photo) => photo._id),
...(listSpotlightRef.current ? [listSpotlightRef.current._id] : []),
]);
const fresh = photos.filter((photo) => !known.has(photo._id));
const apply = (photo: PresentPhoto) => byId.get(photo._id) ?? photo;
const keep = (photo: PresentPhoto) => byId.has(photo._id);
listQueueRef.current = [...listQueueRef.current.filter(keep).map(apply), ...fresh];
listWallPhotosRef.current = listWallPhotosRef.current.filter(keep).map(apply);
setListQueue(listQueueRef.current);
setListWallPhotos(listWallPhotosRef.current);
setListRowPhotos((rows) => rows.map((row) => row.filter(keep).map(apply)));
}, []);
const seedWall = useCallback((photos: PresentPhoto[], rows: number, perRow: number) => {
const ordered = oldestFirst(photos.filter((p) => Boolean(p._id)));
setWallPhotos(ordered);
@ -83,17 +197,58 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
marqueeStartedRef.current = false;
}, []);
const playThrough = useCallback((photos: PresentPhoto[]) => {
marqueeStartedRef.current = false;
activeSpotlightRef.current = null;
wallPhotosRef.current = [];
spotlightQueueRef.current = photos;
setWallPhotos([]);
setRowPhotos(emptyRows(totalRowsRef.current));
setActiveSpotlight(null);
setSpotlightQueue(photos);
}, []);
const refresh = useCallback(async () => {
if (!presentToken) return;
const generation = wallGenerationRef.current;
try {
const next = spotlightMode
? await getSpotlightPresent(presentToken, selectedSourceQuery.split(",").filter(Boolean) as SpotlightSource[])
: await getPhotoWallPresent(presentToken);
if (generation !== wallGenerationRef.current) return;
setData(next);
dataRef.current = next;
setErr(null);
if (spotlightMode && selectedListIdRef.current) {
const list = ((next as SpotlightPresentData).lists || []).find((item) => item.id === selectedListIdRef.current);
if (!list) {
selectedListIdRef.current = null;
setSelectedListId(null);
} else {
mergeList(list.entries);
}
}
let includeQuiz = spotlightMode && selectedSourceQuery.split(",").includes("quiz");
if (spotlightMode && !sourcePreferencesInitialized.current && "sources" in next && next.sources) {
sourcePreferencesInitialized.current = true;
setSelectedSources({ ...(next as SpotlightPresentData).sources });
const sources = { ...(next as SpotlightPresentData).sources };
setSelectedSources(sources);
includeQuiz = Boolean(sources.quiz);
const savedQuery = (Object.keys(sources) as SpotlightSource[]).filter((source) => sources[source]).join(",");
if (savedQuery !== selectedSourceQuery) return;
}
const quizzes = presentQuizzes(next);
const currentQuizId = selectedQuizIdRef.current;
const nextQuizId = currentQuizId && quizzes.some((quiz) => quiz.id === currentQuizId) ? currentQuizId : (quizzes[0]?.id || null);
if (nextQuizId !== currentQuizId) {
selectedQuizIdRef.current = nextQuizId;
setSelectedQuizId(nextQuizId);
}
const lineupWinners = includeQuiz ? quizWinners(next, nextQuizId) : [];
if (spotlightMode && seededRef.current && currentQuizId && nextQuizId !== currentQuizId) {
playThrough(spotlightLineup(next, lineupWinners));
return;
}
const rows = Math.max(1, next.totalRows ?? 3);
@ -102,7 +257,22 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
if (!seededRef.current) {
seededRef.current = true;
seedWall(incoming, rows, perRow);
if (spotlightMode) playThrough(spotlightLineup(next, lineupWinners));
else seedWall(incoming, rows, perRow);
return;
}
if (spotlightMode) {
const known = new Set<string>([
...wallPhotosRef.current.map((photo) => photo._id),
...spotlightQueueRef.current.map((photo) => photo._id),
...(activeSpotlightRef.current ? [activeSpotlightRef.current._id] : []),
]);
const fresh = spotlightLineup(next, lineupWinners).filter((photo) => photo._id && !known.has(photo._id));
if (fresh.length) {
spotlightQueueRef.current = [...spotlightQueueRef.current, ...fresh];
setSpotlightQueue(spotlightQueueRef.current);
}
return;
}
@ -121,7 +291,7 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
} finally {
setLoading(false);
}
}, [presentToken, seedWall, spotlightMode, selectedSourceQuery]);
}, [mergeList, playThrough, presentToken, seedWall, spotlightMode, selectedSourceQuery]);
useEffect(() => {
seededRef.current = false;
@ -139,15 +309,46 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
return () => window.clearInterval(id);
}, [refresh]);
const changeSource = useCallback((source: SpotlightSource, enabled: boolean) => {
const clearWall = useCallback(() => {
wallGenerationRef.current += 1;
marqueeStartedRef.current = false;
activeSpotlightRef.current = null;
wallPhotosRef.current = [];
spotlightQueueRef.current = [];
setWallPhotos([]);
setRowPhotos(emptyRows(totalRowsRef.current));
setActiveSpotlight(null);
setSpotlightQueue([]);
seededRef.current = false;
setSelectedSources((current) => ({ ...current, [source]: enabled }));
}, []);
const changeSource = useCallback((source: SpotlightSource, enabled: boolean) => {
clearWall();
setSourcesView(true);
setSelectedSources((current) => ({ ...current, [source]: enabled }));
}, [clearWall]);
const changeAllSources = useCallback((enabled: boolean) => {
seededRef.current = false;
clearWall();
if (enabled) setSourcesView(true);
setSelectedSources({ socialShare: enabled, aiAvatar: enabled, quiz: enabled, photoWall: enabled });
}, []);
}, [clearWall]);
const resetSpotlight = useCallback(() => {
clearWall();
selectedListIdRef.current = null;
setSelectedListId(null);
setSourcesView(false);
listMarqueeStartedRef.current = false;
listSpotlightRef.current = null;
listWallPhotosRef.current = [];
listQueueRef.current = [];
setListWallPhotos([]);
setListRowPhotos(emptyRows(totalRowsRef.current));
setListSpotlight(null);
setListQueue([]);
setSelectedSources({ socialShare: false, aiAvatar: false, quiz: false, photoWall: false });
}, [clearWall]);
useEffect(() => {
const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement));
@ -168,19 +369,34 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
}
}, []);
const chooseList = useCallback((id: string | null) => {
selectedListIdRef.current = id;
setSelectedListId(id);
setSourcesView(id == null);
if (!id) return;
const list = ((dataRef.current as SpotlightPresentData | null)?.lists || []).find((item) => item.id === id);
playList(list?.entries || []);
}, [playList]);
const chooseQuiz = useCallback((id: string) => {
selectedQuizIdRef.current = id;
setSelectedQuizId(id);
const next = dataRef.current;
if (!spotlightMode || !next) return;
playThrough(spotlightLineup(next, quizWinners(next, id)));
}, [playThrough, spotlightMode]);
const handleReplay = useCallback(() => {
const lineup = oldestFirst((data?.photos || []).filter((p) => Boolean(p._id)));
if (selectedListIdRef.current) {
const list = ((dataRef.current as SpotlightPresentData | null)?.lists || []).find((item) => item.id === selectedListIdRef.current);
playList(list?.entries || []);
return;
}
if (!data) return;
const lineup = spotlightLineup(data, spotlightMode && selectedSources.quiz ? quizWinners(data, selectedQuizIdRef.current) : []);
if (lineup.length === 0) return;
const rows = Math.max(1, data?.totalRows ?? 3);
marqueeStartedRef.current = false;
activeSpotlightRef.current = null;
wallPhotosRef.current = [];
spotlightQueueRef.current = lineup;
setWallPhotos([]);
setRowPhotos(emptyRows(rows));
setActiveSpotlight(null);
setSpotlightQueue(lineup);
}, [data]);
playThrough(lineup);
}, [data, playList, playThrough, selectedSources.quiz, spotlightMode]);
useEffect(() => {
if (!seededRef.current) return;
@ -189,10 +405,42 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
useEffect(() => {
if (activeSpotlight || spotlightQueue.length === 0) return;
setActiveSpotlight(spotlightQueue[0]);
setSpotlightQueue((q) => q.slice(1));
const next = spotlightQueue[0];
const rest = spotlightQueue.slice(1);
activeSpotlightRef.current = next;
spotlightQueueRef.current = rest;
setActiveSpotlight(next);
setSpotlightQueue(rest);
}, [activeSpotlight, spotlightQueue]);
useEffect(() => {
if (!selectedListId || listSpotlight || listQueue.length === 0) return;
const next = listQueue[0];
listQueueRef.current = listQueue.slice(1);
listSpotlightRef.current = next;
setListSpotlight(next);
setListQueue(listQueueRef.current);
}, [selectedListId, listSpotlight, listQueue]);
const onListSpotlightComplete = useCallback(() => {
const current = listSpotlightRef.current;
listSpotlightRef.current = null;
if (!current) {
setListSpotlight(null);
return;
}
const latest = listEntriesRef.current.get(current._id);
setListSpotlight(null);
if (!latest) return;
const perRow = photosPerRowRef.current;
const rows = totalRowsRef.current;
setListWallPhotos((prev) => (prev.some((photo) => photo._id === latest._id) ? prev : [...prev, latest]));
setListRowPhotos((prevRows) => {
const lanes = prevRows.length === rows ? prevRows : emptyRows(rows);
return placePhotoInRows(lanes, latest, perRow);
});
}, []);
const onSpotlightComplete = useCallback(() => {
const current = activeSpotlightRef.current;
if (!current) {
@ -237,7 +485,7 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
const marginLeft = Math.max(0, data.presentMarginLeftPx ?? 0);
const marginRight = Math.max(0, data.presentMarginRightPx ?? 0);
const showNames = Boolean(data.showUploaderNameOnWall);
const frameEnabled = Boolean(data.frameEnabled);
const frameEnabled = spotlightMode ? false : Boolean(data.frameEnabled);
const frameW = data.frameCanvasWidth || 1080;
const frameH = data.frameCanvasHeight || 1080;
const photosPerRow = Math.max(1, Math.min(12, data.photosPerRow || 4));
@ -256,15 +504,20 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
marqueeStartedRef.current = true;
}
const marqueeActive = marqueeStartedRef.current;
const spotlightFrame: SpotlightFrame | undefined = spotlightMode && data.frameEnabled
? {
frameCanvasWidth: data.frameCanvasWidth,
frameCanvasHeight: data.frameCanvasHeight,
frameImageUrl: data.frameImageUrl,
frameImageLayout: data.frameImageLayout,
frameLayout: data.frameLayout,
const presentLists = spotlightMode ? ((data as SpotlightPresentData).lists || []) : [];
const activeList = presentLists.find((list) => list.id === selectedListId) ?? null;
const listDisplayRows = listRowPhotos.length === totalRows ? listRowPhotos : emptyRows(totalRows);
if (activeList && isMarqueeReady(listDisplayRows, photosPerRow)) {
listMarqueeStartedRef.current = true;
}
: undefined;
const listMarqueeActive = listMarqueeStartedRef.current;
const presentQuizList = spotlightMode ? presentQuizzes(data) : [];
const selectedQuiz = presentQuizList.find((quiz) => quiz.id === selectedQuizId) || presentQuizList[0] || null;
const pageTitle = !spotlightMode
? ""
: activeList
? activeList.name.trim()
: singleActivityTitle(selectedSources, (data as SpotlightPresentData).activityTitles, selectedQuiz?.name);
return (
<div ref={rootRef} className="eb-gradient-auth relative min-h-dvh overflow-x-hidden text-white">
@ -278,9 +531,53 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
spotlightSources={spotlightMode ? selectedSources : undefined}
onSpotlightSourceChange={spotlightMode ? changeSource : undefined}
onAllSpotlightSourcesChange={spotlightMode ? changeAllSources : undefined}
spotlightLists={spotlightMode ? presentLists : undefined}
selectedSpotlightListId={activeList?.id ?? null}
onSpotlightListChange={spotlightMode ? chooseList : undefined}
onSpotlightReset={spotlightMode ? resetSpotlight : undefined}
sourcesViewSelected={sourcesView}
spotlightQuizzes={spotlightMode && selectedSources.quiz ? presentQuizList : undefined}
selectedSpotlightQuizId={selectedQuiz?.id ?? null}
onSpotlightQuizChange={spotlightMode ? chooseQuiz : undefined}
pageTitle={pageTitle}
/>
{wallPhotos.length === 0 && !activeSpotlight && spotlightQueue.length === 0 ? (
{activeList ? (
listWallPhotos.length === 0 && !listSpotlight && listQueue.length === 0 ? (
<div className="flex min-h-[60dvh] items-center justify-center px-6 text-center text-white/80">
This list is empty.
</div>
) : (
<>
<MarqueePhotoWall
rows={listDisplayRows}
marqueeDirection={data.marqueeDirection}
photosPerRow={photosPerRow}
maxPhotoWidth={maxW}
maxPhotoHeight={maxH}
gapPx={gap}
marginTop={marginTop}
marginLeft={marginLeft}
marginRight={marginRight}
borderRadius={borderRadius}
aspectRatio={aspectRatio}
showNames
showMessage
paused={Boolean(listSpotlight)}
marqueeActive={listMarqueeActive}
/>
<PhotoSpotlight
photo={listSpotlight}
borderRadius={borderRadius}
holdMs={2000}
onComplete={onListSpotlightComplete}
/>
</>
)
) : null}
<div className={activeList ? "hidden" : undefined}>
{spotlightMode && !Object.values(selectedSources).some(Boolean) ? null : wallPhotos.length === 0 && !activeSpotlight && spotlightQueue.length === 0 ? (
<div className="flex min-h-[60dvh] items-center justify-center px-6 text-center text-white/80">
Waiting for photos…
</div>
@ -297,10 +594,10 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
marginRight={marginRight}
borderRadius={borderRadius}
aspectRatio={aspectRatio}
showNames={showNames}
showNames={spotlightMode || showNames}
showMessage={spotlightMode}
paused={Boolean(activeSpotlight)}
marqueeActive={marqueeActive}
spotlightFrame={spotlightFrame}
/>
)}
@ -309,8 +606,8 @@ export function PhotoWallPresentPage({ spotlightMode = false }: { spotlightMode?
borderRadius={borderRadius}
holdMs={2000}
onComplete={onSpotlightComplete}
spotlightFrame={spotlightFrame}
/>
</div>
</div>
);
}

View File

@ -22,13 +22,11 @@ import { PublicEventChrome } from "../components/PublicEventChrome";
import { SharePlatformIcon } from "../components/SharePlatformIcon";
import {
getPhotoWallUploadMeta,
getVisitorLoginPreview,
uploadPhotoWallSelfie,
type PhotoWallSharePlatforms,
type PhotoWallPublicFormField,
type PhotoWallUploadMeta,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
@ -38,11 +36,7 @@ import {
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
import { useActivityProfileGate } from "../lib/useActivityProfileGate";
function fieldKey(f: PhotoWallPublicFormField): string {
return String(f.name || f.label || "").trim();
@ -151,7 +145,7 @@ export function PhotoWallUploadPage() {
[meta],
);
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
const profileGate = useActivityProfileGate(meta?.eventId, meta?.accessMode, Boolean(meta) && !loading);
const enabledSharePlatforms = useMemo(
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
@ -252,6 +246,7 @@ export function PhotoWallUploadPage() {
const localFile = file;
const result = await uploadPhotoWallSelfie(uploadToken, file, values, {
displayName: meta.askUploaderName ? uploaderName.trim() : undefined,
participantId: profileGate.participantId,
});
// Keep local bytes immediately so Share works even if the API file proxy fails (CORS/network).
setShareFile(localFile);
@ -355,24 +350,6 @@ export function PhotoWallUploadPage() {
}
};
const goLogin = async () => {
if (!meta?.eventId) return;
try {
const preview = await getVisitorLoginPreview(meta.eventId);
const ctx: EbVisitorContext = {
type: "event",
eventId: meta.eventId,
event: preview,
fetchedAt: new Date().toISOString(),
};
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
setPostAuthReturnPath(`/photo-wall/${uploadToken}`);
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
} catch {
setErr("Could not open login");
}
};
if (!uploadToken) {
return (
<PhotoWallLayout>
@ -381,7 +358,7 @@ export function PhotoWallUploadPage() {
);
}
if (loading) {
if (loading || (meta && !profileGate.allowed)) {
return (
<PhotoWallLayout>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
@ -541,18 +518,6 @@ export function PhotoWallUploadPage() {
{meta?.form?.description || "Add a selfie to the event photo wall."}
</p>
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to upload your photo for this event.</p>
<button
type="button"
onClick={() => void goLogin()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-[var(--theme-button-color)] px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Sign in to continue
</button>
</div>
) : (
<form onSubmit={(e) => void onSubmit(e)} className="space-y-4">
<div>
{previewUrl ? (
@ -635,7 +600,6 @@ export function PhotoWallUploadPage() {
{submitting ? "Uploading…" : "Upload"}
</button>
</form>
)}
</section>
{cropSession && meta ? (

View File

@ -20,13 +20,11 @@ import { PublicEventChrome } from "../components/PublicEventChrome";
import { SharePlatformIcon } from "../components/SharePlatformIcon";
import {
getSocialShareMeta,
getVisitorLoginPreview,
uploadSocialSharePhoto,
type PhotoWallSharePlatforms,
type SocialShareMeta,
type SocialSharePublicFrame,
} from "../lib/api";
import { getAttendeeParticipant, getAttendeeToken } from "../lib/attendeeAuth";
import { getImageNaturalSize, ratiosMatch } from "../lib/cropImage";
import {
canUseNativeShareSheet,
@ -36,11 +34,7 @@ import {
sharePhotoWallImage,
shareViaNativeSheet,
} from "../lib/photoWallShare";
import {
EB_VISITOR_CONTEXT_KEY,
setPostAuthReturnPath,
type EbVisitorContext,
} from "../types/visitorContext";
import { useActivityProfileGate } from "../lib/useActivityProfileGate";
function SocialShareLayout({
children,
@ -160,7 +154,7 @@ export function SocialSharePage() {
};
}, [done?.fileUrl]);
const loggedIn = Boolean(getAttendeeToken() && getAttendeeParticipant());
const profileGate = useActivityProfileGate(meta?.eventId, meta?.accessMode, Boolean(meta) && !loading);
const enabledSharePlatforms = useMemo(
() => SHARE_LABELS.filter((item) => Boolean(meta?.sharePlatforms?.[item.key])),
[meta?.sharePlatforms],
@ -236,7 +230,13 @@ export function SocialSharePage() {
setErr(null);
setActionMsg(null);
try {
const result = await uploadSocialSharePhoto(uploadToken, file, selectedFrame._id, displayName.trim());
const result = await uploadSocialSharePhoto(
uploadToken,
file,
selectedFrame._id,
displayName.trim(),
profileGate.participantId,
);
setShareFile(null);
setResultImageReady(false);
setDone({
@ -343,24 +343,6 @@ export function SocialSharePage() {
}
};
const goLogin = async () => {
if (!meta?.eventId) return;
try {
const preview = await getVisitorLoginPreview(meta.eventId);
const ctx: EbVisitorContext = {
type: "event",
eventId: meta.eventId,
event: preview,
fetchedAt: new Date().toISOString(),
};
localStorage.setItem(EB_VISITOR_CONTEXT_KEY, JSON.stringify(ctx));
setPostAuthReturnPath(`/social-share/${uploadToken}`);
window.location.assign(`/login?type=event&id=${encodeURIComponent(meta.eventId)}`);
} catch {
setErr("Could not open login");
}
};
if (!uploadToken) {
return (
<SocialShareLayout>
@ -369,7 +351,7 @@ export function SocialSharePage() {
);
}
if (loading) {
if (loading || (meta && !profileGate.allowed)) {
return (
<SocialShareLayout>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Loading…</p>
@ -492,19 +474,7 @@ export function SocialSharePage() {
return (
<SocialShareLayout meta={meta}>
<section className="w-full card-bg rounded-lg shadow-md p-6">
{meta?.accessMode === "login" && !loggedIn ? (
<div className="space-y-3">
<h2 className="mb-1 text-center text-[22px] font-semibold">Social Share</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Sign in to create and share your photo.</p>
<button
type="button"
onClick={() => void goLogin()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg theme-btn-bg px-4 text-sm font-semibold text-white hover:bg-[#b71c1c]"
>
Sign in to continue
</button>
</div>
) : !selectedFrame ? (
{!selectedFrame ? (
<div className="space-y-4">
<h2 className="mb-1 text-center text-[22px] font-semibold">Choose a frame</h2>
<p className="text-center text-sm text-[var(--theme-card-text-color)]/80">Pick a frame, then upload your photo.</p>

View File

@ -159,10 +159,6 @@ export function SpotlightPresentPage() {
const aspectRatio =
shape === "circle"
? "1 / 1"
: data.frameEnabled
? `${data.frameCanvasWidth || 1080} / ${
data.frameCanvasHeight || 1080
}`
: `${maxWidth} / ${maxHeight}`;
const maxDisplayWidth =

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

@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_API_URL?: string;
readonly VITE_FIREBASE_API_KEY?: string;
readonly VITE_FIREBASE_DATABASE_URL?: string;
}