diff --git a/src/App.tsx b/src/App.tsx index 606f0d7..4370ce0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,8 @@ import { AiAvatarPage } from "./pages/AiAvatarPage"; import { CodeEntryPage } from "./pages/CodeEntryPage"; import { PrivacyPolicyPage } from "./pages/PrivacyPolicyPage"; import { TermsOfServicePage } from "./pages/TermsOfServicePage"; +import { MatchUpPage } from "./pages/MatchUpPage"; +import { GameQuizPage } from "./pages/GameQuizPage"; import { useEventTheme } from "./hooks/useEventTheme"; import { ThemeGate } from "./components/ThemeGate"; @@ -57,6 +59,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/assets/eventbuzz-logo.png b/src/assets/eventbuzz-logo.png new file mode 100644 index 0000000..7e11628 Binary files /dev/null and b/src/assets/eventbuzz-logo.png differ diff --git a/src/index.css b/src/index.css index a6293cc..37cf9cd 100644 --- a/src/index.css +++ b/src/index.css @@ -180,3 +180,92 @@ body { opacity: 0; 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; } } diff --git a/src/lib/games.ts b/src/lib/games.ts new file mode 100644 index 0000000..d6074d6 --- /dev/null +++ b/src/lib/games.ts @@ -0,0 +1,24 @@ +const API_BASE = (import.meta.env.VITE_API_URL || 'http://localhost:3002/api').replace(/\/$/, ''); + +async function call(path: string, init?: RequestInit): Promise { + 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(`/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(`/public/games/quiz/${encodeURIComponent(token)}`); } +export function answerGameQuiz(token: string, questionIndex: number, answerIndex: number) { return call(`/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) }); } diff --git a/src/pages/GameQuizPage.tsx b/src/pages/GameQuizPage.tsx new file mode 100644 index 0000000..c798e07 --- /dev/null +++ b/src/pages/GameQuizPage.tsx @@ -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(null); const [index, setIndex] = useState(0); const [answers, setAnswers] = useState([]); const [feedback, setFeedback] = useState<(boolean | null)[]>([]); const [selected, setSelected] = useState(null); const [correctAnswer, setCorrectAnswer] = useState(null); const [result, setResult] = useState(null); const [leaderboard, setLeaderboard] = useState([]); 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
Loading game...
; + if (error || !game) return

Quiz unavailable

{error || 'Quiz is not available.'}

; + const background = game.backgroundImage ? { backgroundImage: `url(${game.backgroundImage})` } : undefined; const question = game.questions[index]; + return
EventBuzz{game.name}
{!started && !result ?
EventBuzz

{game.name}

HOW TO PLAY

Answer every question.
Choose the best answer.
How much can you score?

setName(event.target.value)} placeholder="Display name" aria-label="Display name" />
: result ?

{result.correct >= Math.ceil(result.total / 5 * 0.7) ? 'Great effort!' : 'Strong effort! Aim for more next time'}

You got {result.correct} out of {result.total / 5} correct

{feedback.map((answer, answerIndex) => {answerIndex + 1})}
+{result.score}

Leaderboard

    {leaderboard.map((entry, entryIndex) =>
  1. {entryIndex + 1}{entry.displayName}{entry.score}
  2. )}

Next quiz is coming soon. See you then!

: <>
Question {index + 1} of {game.questions.length}

{question.question}

{question.options.map((option, optionIndex) => { const isChosen = selected === optionIndex; const isCorrect = selected !== null && optionIndex === correctAnswer; const isWrong = selected !== null && !feedback[index] && isChosen; return ; })}
}
; +} diff --git a/src/pages/MatchUpPage.tsx b/src/pages/MatchUpPage.tsx new file mode 100644 index 0000000..91b4c96 --- /dev/null +++ b/src/pages/MatchUpPage.tsx @@ -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(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(null); const [items, setItems] = useState([]); const [selected, setSelected] = useState([]); const [reveals, setReveals] = useState([]); 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
Loading game...
; + if (error || !game) return

Game unavailable

{error || 'Match Up is not available.'}

; + return
EventBuzz{game.name}
{!started ?
EventBuzz

{game.name}

Metric {game.gridSize} x {game.gridSize}

HOW TO PLAY

Find groups of related tiles.
Choose {game.gridSize} tiles for each connection.
How many can you solve?

: <>
Today
{helpOpen ?
setHelpOpen(false)}>
event.stopPropagation()}>

How to play?

1 Pick {game.gridSize} tiles that share a common connection.

2 Tap submit to check your guess.

3 Find every connection before you run out of hearts.

: null}
{lives}
{game.gridSize} x {game.gridSize}
{reveals.map((group) =>
{group.title}{group.description}
)}{finished || failed ?
{finished ? 'OK' : '!'}

{finished ? 'Nice work.' : 'Out of hearts.'}

{finished ? 'You found every hidden connection.' : 'Try the puzzle again and find the connections.'}

: <>
+ {activeItems.map((item) => ( + + ))} +
}{message ?

{message}

: null}
}
; +}