Add event games pages

This commit is contained in:
Tanuja Pansare 2026-10-01 17:57:47 +05:30
parent e36e4b94e4
commit db7fc16ec2
6 changed files with 181 additions and 0 deletions

View File

@ -26,6 +26,8 @@ import { AiAvatarPage } from "./pages/AiAvatarPage";
import { CodeEntryPage } from "./pages/CodeEntryPage"; import { CodeEntryPage } from "./pages/CodeEntryPage";
import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage"; import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage";
import { TermsOfServicePage } from "./pages/TermsOfServicePage"; import { TermsOfServicePage } from "./pages/TermsOfServicePage";
import { MatchUpPage } from "./pages/MatchUpPage";
import { GameQuizPage } from "./pages/GameQuizPage";
import { useEventTheme } from "./hooks/useEventTheme"; import { useEventTheme } from "./hooks/useEventTheme";
import { ThemeGate } from "./components/ThemeGate"; import { ThemeGate } from "./components/ThemeGate";
@ -57,6 +59,8 @@ export default function App() {
<Route path="/photo-search/:searchToken" element={<PhotoSearchPage />} /> <Route path="/photo-search/:searchToken" element={<PhotoSearchPage />} />
<Route path="/social-share/:uploadToken" element={<SocialSharePage />} /> <Route path="/social-share/:uploadToken" element={<SocialSharePage />} />
<Route path="/ai-avatar/:uploadToken" element={<AiAvatarPage />} /> <Route path="/ai-avatar/:uploadToken" element={<AiAvatarPage />} />
<Route path="/match-up/:token" element={<MatchUpPage />} />
<Route path="/game-quiz/:token" element={<GameQuizPage />} />
<Route path="/questions" element={<Questions />} /> <Route path="/questions" element={<Questions />} />
<Route path="/ask" element={<Navigate to="/questions" replace />} /> <Route path="/ask" element={<Navigate to="/questions" replace />} />
<Route path="/feedback" element={<FeedbackPage />} /> <Route path="/feedback" element={<FeedbackPage />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -180,3 +180,92 @@ body {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: 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-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; }
.game-brand { display: grid; justify-items: center; gap: 3px; min-width: 130px; }
.game-brand img { width: 112px; height: 30px; object-fit: contain; }
.game-brand strong { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #fff; font-size: 13px; font-weight: 900; text-transform: uppercase; }
.reference-icon, .subheader-back { border: 0; background: transparent; color: inherit; display: inline-flex; align-items: center; justify-content: center; padding: 5px; cursor: pointer; }
.reference-actions { display: flex; justify-content: flex-end; gap: 10px; }
.game-logo { display: grid; justify-items: center; line-height: .82; transform: rotate(-3deg); color: #0871c9; text-shadow: 1px 1px 0 white, -1px -1px 0 white; }
.game-logo span { font-size: 11px; font-weight: 900; letter-spacing: .18em; }
.game-logo strong { font-size: 29px; font-style: italic; letter-spacing: -.05em; }
.game-logo small { color: #e12d3e; font-size: 9px; font-weight: 900; letter-spacing: .2em; transform: rotate(3deg); }
.game-subheader { height: 42px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 0 18px; background: rgba(255,255,255,.82); font-size: 14px; font-weight: 800; }
.game-subheader .subheader-back { justify-self: start; }
.match-content { padding: 17px 22px 28px; }
.metric-inline { margin-left: 6px; color: #23465e; font-size: 12px; font-weight: 900; }
.match-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.heart-count { margin-right: auto; color: #d92945; display: inline-flex; align-items: center; gap: 4px; font-size: 18px; }
.game-tool { border: 0; background: transparent; color: #23465e; display: inline-flex; align-items: center; gap: 5px; padding: 7px 3px; font-size: 13px; cursor: pointer; }
.game-tool:disabled { opacity: .45; cursor: default; }
.match-board { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; max-height: 56dvh; overflow-y: auto; padding: 2px; }
.match-tile { min-height: 76px; border: 0; border-radius: 3px; background: rgba(247,252,253,.94); color: #16384d; padding: 9px 7px; display: grid; place-items: center; gap: 5px; text-align: center; font-size: 13px; font-weight: 800; cursor: pointer; box-shadow: 0 1px 1px rgba(20,71,95,.14); transition: background .16s ease, transform .16s ease; }
.match-tile:hover { transform: translateY(-1px); }
.match-tile.is-selected { background: #f5a45b; color: #fff; }
.match-tile img { width: 32px; height: 32px; border-radius: 3px; object-fit: cover; }
.match-submit { width: 100%; margin-top: 15px; border: 0; border-radius: 4px; background: #0878b9; color: #fff; padding: 14px; font-size: 13px; font-weight: 900; letter-spacing: .04em; cursor: pointer; }
.match-submit:disabled { background: #8baebb; cursor: default; }
.match-reveal { display: grid; gap: 3px; margin-bottom: 8px; padding: 12px 14px; border-radius: 3px; background: #ed9150; color: #fff; }
.match-reveal strong { font-size: 14px; text-transform: uppercase; }
.match-reveal span { font-size: 12px; }
.game-feedback { min-height: 18px; margin: 8px 0 0; text-align: center; color: #176b87; font-size: 12px; font-weight: 700; }
.game-feedback.is-error { color: #b52d42; }
.game-start { min-height: calc(100dvh - 92px); box-sizing: border-box; display: flex; flex-direction: column; align-items: center; text-align: center; padding: 110px 28px 40px; color: #102333; }
.game-start img { width: min(260px, 72%); height: auto; object-fit: contain; margin-bottom: 26px; }
.game-start h1 { margin: 0 0 16px; font-size: 31px; font-weight: 900; text-transform: uppercase; }
.game-start h2 { margin: 28px 0 12px; font-size: 17px; letter-spacing: .04em; }
.game-start p { margin: 0; font-size: 16px; line-height: 1.45; }
.game-start button { width: min(365px, 100%); margin-top: 34px; border: 0; border-radius: 4px; background: #0878c8; color: #fff; padding: 15px; font-size: 15px; font-weight: 900; cursor: pointer; }
.game-start--match { background: linear-gradient(180deg, rgba(19,118,194,.05), rgba(9,145,235,.72)); }
.game-start--quiz { background: linear-gradient(180deg, rgba(255,255,255,.08), rgba(45,164,225,.34)); }
.game-start--quiz img { width: min(300px, 78%); }
.game-start--quiz input { width: min(365px, 100%); box-sizing: border-box; margin-top: 24px; border: 1px solid #a6c4cf; padding: 12px; background: rgba(255,255,255,.9); color: #102333; font: inherit; }
.game-start--quiz button { margin-top: 14px; }
.metric-badge { display: inline-flex; align-items: center; border-radius: 3px; background: #fff; color: #0878b9; padding: 7px 13px; font-size: 15px; font-weight: 900; }
.game-dialog-backdrop { position: fixed; inset: 0; z-index: 5; display: grid; place-items: center; padding: 20px; background: rgba(14,58,79,.42); }
.game-dialog { width: min(420px, 100%); padding: 24px; background: #fff; box-shadow: 0 12px 36px rgba(16,58,78,.25); }
.dialog-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.dialog-title h2 { margin: 0; font-size: 22px; }
.game-dialog p { display: flex; gap: 12px; margin: 14px 0; color: #3d5661; font-size: 14px; line-height: 1.45; }
.game-dialog p strong { color: #1b9c6b; }
.dialog-close, .match-finish button, .quiz-start button { border: 0; background: #0878b9; color: white; padding: 11px 17px; font-weight: 800; cursor: pointer; }
.match-finish, .game-error, .game-loading { padding: 76px 24px; text-align: center; }
.finish-mark { width: 50px; height: 50px; margin: 0 auto 14px; display: grid; place-items: center; border-radius: 50%; background: #0878b9; color: #fff; font-size: 28px; font-weight: 900; }
.match-finish h1, .game-error h1 { margin: 0 0 8px; font-size: 28px; }
.match-finish p, .game-error p, .game-loading { color: #3d5661; }
.quiz-start { margin: 18vh 22px 0; padding: 28px 25px; background: rgba(255,255,255,.93); }
.quiz-start h1 { margin: 0 0 10px; font-size: 28px; }
.quiz-start p { margin: 0 0 22px; color: #526875; }
.quiz-start input { width: 100%; box-sizing: border-box; margin-bottom: 13px; border: 1px solid #b9cbd0; padding: 12px; font: inherit; }
.quiz-progress-reference { padding: 13px 22px 0; color: #23465e; font-size: 12px; font-weight: 800; }
.quiz-progress-reference div { height: 4px; margin-top: 8px; background: rgba(255,255,255,.7); }
.quiz-progress-reference i { display: block; height: 100%; background: #0878b9; transition: width .25s ease; }
.quiz-card-reference { margin: 28px 22px 30px; padding: 23px 21px 18px; background: rgba(255,255,255,.94); }
.quiz-card-reference h1 { margin: 0 0 20px; font-size: 21px; line-height: 1.18; }
.quiz-options-reference { display: grid; gap: 7px; }
.quiz-options-reference button { display: flex; align-items: center; justify-content: space-between; width: 100%; border: 0; background: #e8edf0; color: #263d48; padding: 14px 12px; text-align: left; font: inherit; cursor: pointer; }
.quiz-options-reference button.is-selected { background: #c6e6f3; }
.quiz-options-reference button.is-correct { background: #c5ebd0; border: 1px solid #2e9b54; }
.quiz-options-reference button.is-wrong { background: #f3c6cb; border: 1px solid #cf3447; }
.quiz-result-reference { margin: 29vh 29px 0; padding: 23px 24px 18px; background: rgba(255,255,255,.95); }
.result-message { margin: 0 0 7px; color: #6c777c; font-size: 14px; }
.quiz-result-reference h1 { margin: 0 0 17px; font-size: 22px; }
.result-strip { display: flex; gap: 4px; flex-wrap: wrap; }
.result-strip span { width: 35px; height: 35px; display: grid; place-items: center; color: #fff; font-size: 20px; font-weight: 900; }
.result-strip .is-correct { background: #218434; }
.result-strip .is-wrong { background: #c41226; }
.result-score { display: block; margin-top: 13px; font-size: 26px; }
.share-result { border: 0; background: transparent; display: inline-flex; align-items: center; gap: 6px; padding: 8px 0; color: #23465e; font-weight: 700; cursor: pointer; }
.quiz-result-reference h2 { margin: 20px 0 8px; font-size: 17px; }
.leaderboard { margin: 0; padding: 0; list-style: none; border-top: 1px solid #d6e0e2; }
.leaderboard li { display: flex; justify-content: space-between; padding: 9px 0; border-bottom: 1px solid #d6e0e2; }
.leaderboard li span { display: flex; gap: 10px; }
.leaderboard b { color: #0878b9; }
.next-trivia { margin: 18px 0 0; color: #526875; font-size: 13px; }
@media (max-width: 600px) { .reference-header { height: 77px; padding: 10px 13px; } .game-brand img { width: 95px; } .game-brand strong { max-width: 120px; font-size: 11px; } .match-content { padding: 14px 12px 24px; } .match-board { gap: 5px; } .match-tile { min-height: 69px; padding: 7px 4px; font-size: 11px; } .match-tile img { width: 27px; height: 27px; } .game-start { min-height: calc(100dvh - 77px); padding-top: 86px; } .game-start h1 { font-size: 26px; } .quiz-card-reference { margin-inline: 12px; } .quiz-progress-reference { padding-inline: 12px; } .quiz-result-reference { margin-inline: 12px; } }

24
src/lib/games.ts Normal file
View File

@ -0,0 +1,24 @@
const API_BASE = (import.meta.env.VITE_API_URL || 'http://localhost:3002/api').replace(/\/$/, '');
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');
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 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 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) }); }

View File

@ -0,0 +1,19 @@
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';
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>;
}

45
src/pages/MatchUpPage.tsx Normal file
View File

@ -0,0 +1,45 @@
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';
type Reveal = { _id: string; title: string; description: string; itemIds: string[] };
function shuffled<T>(items: T[]) { return [...items].sort(() => Math.random() - 0.5); }
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) => (
<button
key={item._id}
className={`match-tile ${
selected.includes(item._id) ? 'is-selected' : ''
}`}
onClick={() =>
setSelected((current) =>
current.includes(item._id)
? current.filter((id) => id !== item._id)
: current.length >= game.gridSize
? current
: [...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>;
}