Changes
This commit is contained in:
parent
db7fc16ec2
commit
49b8e94706
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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) }); }
|
||||
|
|
|
|||
|
|
@ -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()}`;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue