import { FormEvent, useEffect, useMemo, useState } from 'react'; import type { AddMemoryRequest, ChallengeDefinition, CollectionItem, ContentResponse, ItemType, Match, MemoryMood, PassportState, Prediction, TriviaQuestion } from '@fan-passport/shared'; import { ApiClientError, api } from './api/client'; const USER_STORAGE_KEY = 'fan-passport-demo-user-id'; function errorMessage(error: unknown): string { if (error instanceof ApiClientError) { return error.message; } if (error instanceof Error) { return error.message; } return 'Something went wrong. Please try again.'; } function collectionKey(itemType: ItemType, contentId: string): string { return `${itemType}:${contentId}`; } function teamName(content: ContentResponse, teamId: string): string { return content.teams.find((team) => team.id === teamId)?.name ?? teamId; } function stadiumName(content: ContentResponse, stadiumId: string): string { return content.stadiums.find((stadium) => stadium.id === stadiumId)?.name ?? stadiumId; } function matchTitle(content: ContentResponse, match: Match): string { return `${teamName(content, match.homeTeamId)} vs ${teamName(content, match.awayTeamId)}`; } function formatDateTime(value: string): string { return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(new Date(value)); } function formatDate(value: string): string { return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(new Date(value)); } export default function App() { const [content, setContent] = useState(null); const [passport, setPassport] = useState(null); const [userId, setUserId] = useState(() => window.localStorage.getItem(USER_STORAGE_KEY)); const [loading, setLoading] = useState(true); const [busyAction, setBusyAction] = useState(null); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); async function loadInitialState(activeUserId: string | null) { setLoading(true); setError(null); try { const loadedContent = await api.getContent(); setContent(loadedContent); if (activeUserId) { const loadedPassport = await api.getPassport(activeUserId); setPassport(loadedPassport); } else { setPassport(null); } } catch (caught) { if (caught instanceof ApiClientError && caught.status === 404) { window.localStorage.removeItem(USER_STORAGE_KEY); setUserId(null); setPassport(null); setError('The saved demo passport no longer exists. Create a new one to continue.'); } else { setError(errorMessage(caught)); } } finally { setLoading(false); } } useEffect(() => { let cancelled = false; async function run() { setLoading(true); setError(null); try { const loadedContent = await api.getContent(); if (cancelled) return; setContent(loadedContent); if (userId) { const loadedPassport = await api.getPassport(userId); if (cancelled) return; setPassport(loadedPassport); } else { setPassport(null); } } catch (caught) { if (cancelled) return; if (caught instanceof ApiClientError && caught.status === 404) { window.localStorage.removeItem(USER_STORAGE_KEY); setUserId(null); setPassport(null); setError('The saved demo passport no longer exists. Create a new one to continue.'); } else { setError(errorMessage(caught)); } } finally { if (!cancelled) { setLoading(false); } } } run(); return () => { cancelled = true; }; }, [userId]); async function handleCreateUser(displayName: string, country: string) { setBusyAction('create-user'); setError(null); setNotice(null); try { const response = await api.createUser({ displayName, country }); window.localStorage.setItem(USER_STORAGE_KEY, response.user.id); setUserId(response.user.id); setPassport(response.passport); setNotice(`Passport created for ${response.user.displayName}. Start collecting!`); } catch (caught) { setError(errorMessage(caught)); } finally { setBusyAction(null); } } async function runAction(actionKey: string, action: () => Promise<{ passport: PassportState; message: string }>) { setBusyAction(actionKey); setError(null); setNotice(null); try { const response = await action(); setPassport(response.passport); setNotice(response.message); } catch (caught) { setError(errorMessage(caught)); } finally { setBusyAction(null); } } function handleCollect(item: CollectionItem) { if (!passport) return; runAction(`collect-${item.itemType}-${item.contentId}`, () => api.collectItem({ userId: passport.user.id, itemType: item.itemType, contentId: item.contentId }) ); } function handleAnswer(question: TriviaQuestion, selectedOptionId: string) { if (!passport) return; runAction(`trivia-${question.id}-${selectedOptionId}`, () => api.answerTrivia(question.id, { userId: passport.user.id, selectedOptionId }) ); } function handlePredict(matchId: string, predictedWinnerTeamId: string, confidence: 1 | 2 | 3 | 4 | 5) { if (!passport) return; runAction(`prediction-${matchId}-${predictedWinnerTeamId}`, () => api.submitPrediction({ userId: passport.user.id, matchId, predictedWinnerTeamId, confidence }) ); } function handleAddMemory(input: Omit) { if (!passport) return; runAction('memory-add', () => api.addMemory({ ...input, userId: passport.user.id }) ); } async function handleResetDemo() { setBusyAction('reset-demo'); setError(null); setNotice(null); try { await api.resetDemo(); window.localStorage.removeItem(USER_STORAGE_KEY); setUserId(null); setPassport(null); setNotice('Demo reset. Create a fresh passport to run the journey again.'); await loadInitialState(null); } catch (caught) { setError(errorMessage(caught)); } finally { setBusyAction(null); } } const contentLoaded = Boolean(content); return (
Skip to main content

FIFA World Cup 2026 demo

Fan Passport: The World Cup Journey

Collect teams, matches, stadiums, predictions, achievements, trivia, stickers, and memories in one daily fan loop.

{passport ? (
Level {passport.user.level} {passport.user.points} XP Rank #{passport.stats.rank}
) : (
Demo build {contentLoaded ? content?.challenges.length : '—'} challenges End-to-end
)}
{loading && !content ? : null} {error ? (
Unable to continue. {error}
) : null} {notice ? (
{notice}
) : null} {!loading && content && !passport ? ( ) : null} {content && passport ? ( ) : null}
); } function LoadingState() { return (
); } interface OnboardingFormProps { onCreate: (displayName: string, country: string) => Promise; busy: boolean; } function OnboardingForm({ onCreate, busy }: OnboardingFormProps) { const [displayName, setDisplayName] = useState(''); const [country, setCountry] = useState('Global'); async function handleSubmit(event: FormEvent) { event.preventDefault(); await onCreate(displayName, country); } return (

Start here

Start your passport

Create a local demo fan profile. The profile lives in the API memory store, so you can safely reset and replay the public demo.

setDisplayName(event.target.value)} required /> setCountry(event.target.value)} />
); } interface DashboardProps { content: ContentResponse; passport: PassportState; busyAction: string | null; onCollect: (item: CollectionItem) => void; onAnswer: (question: TriviaQuestion, selectedOptionId: string) => void; onPredict: (matchId: string, predictedWinnerTeamId: string, confidence: 1 | 2 | 3 | 4 | 5) => void; onAddMemory: (input: Omit) => void; } function PassportDashboard({ content, passport, busyAction, onCollect, onAnswer, onPredict, onAddMemory }: DashboardProps) { return ( <>

Daily passport

Welcome, {passport.user.displayName}

Your passport has {passport.stats.totalCollections} collection stamps, {passport.badges.length} badges, and{' '} {passport.stats.completedChallenges} completed challenges.

); } interface StatCardProps { label: string; value: number; hint: string; } function StatCard({ label, value, hint }: StatCardProps) { return (
{label} {value} {hint}
); } interface ChallengeGridProps { content: ContentResponse; passport: PassportState; } function ChallengeGrid({ content, passport }: ChallengeGridProps) { const definitionsById = useMemo( () => new Map(content.challenges.map((challenge) => [challenge.id, challenge])), [content.challenges] ); return (

Return every day

Challenges

{passport.stats.completedChallenges}/{content.challenges.length} complete
{passport.challengeProgress.map((progress) => { const challenge = definitionsById.get(progress.challengeId); if (!challenge) return null; const percent = Math.round((progress.current / progress.target) * 100); return (

{challenge.title}

{progress.complete ? 'Complete' : `${percent}%`}

{challenge.description}

{progress.current}/{progress.target} · {challenge.points} XP reward {progress.completedAt ? ` · completed ${formatDate(progress.completedAt)}` : ''}
); })}
); } interface CollectionBoardProps { content: ContentResponse; passport: PassportState; busyAction: string | null; onCollect: (item: CollectionItem) => void; } function CollectionBoard({ content, passport, busyAction, onCollect }: CollectionBoardProps) { const collected = new Set(passport.collections.map((entry) => collectionKey(entry.itemType, entry.contentId))); const sections: Array<{ type: ItemType; title: string; description: string }> = [ { type: 'team', title: 'Collect teams', description: 'Fill group pages by stamping teams into your passport.' }, { type: 'stadium', title: 'Collect stadiums', description: 'Tour the 2026 host-city stadiums.' }, { type: 'match', title: 'Log matches', description: 'Record fixtures you watched or want to remember.' }, { type: 'sticker', title: 'Build a virtual sticker collection', description: 'Recreate the sticker album habit digitally.' } ]; return (

Collections

Passport collection book

{passport.stats.totalCollections}/{content.collectionItems.length} collected
{sections.map((section) => { const items = content.collectionItems.filter((item) => item.itemType === section.type); return (

{section.title}

{section.description}

{items.map((item) => { const key = collectionKey(item.itemType, item.contentId); const isCollected = collected.has(key); const actionKey = `collect-${item.itemType}-${item.contentId}`; return (
{item.rarity}

{item.title}

{item.description}

); })}
); })}
); } interface TriviaPanelProps { content: ContentResponse; passport: PassportState; busyAction: string | null; onAnswer: (question: TriviaQuestion, selectedOptionId: string) => void; } function TriviaPanel({ content, passport, busyAction, onAnswer }: TriviaPanelProps) { const answered = new Map(passport.triviaAnswers.map((answer) => [answer.questionId, answer])); const unansweredCount = content.triviaQuestions.length - answered.size; return (

Daily habit

World Cup trivia

{unansweredCount} unanswered
{content.triviaQuestions.map((question) => { const answer = answered.get(question.id); return (
{question.category} · {question.points} XP

{question.question}

{answer ? (

{answer.correct ? 'Correct.' : 'Answered.'} {question.explanation}

) : null}
{question.options.map((option) => ( ))}
); })} {content.triviaQuestions.length === 0 ? ( ) : null}
); } interface PredictionsPanelProps { content: ContentResponse; passport: PassportState; busyAction: string | null; onPredict: (matchId: string, predictedWinnerTeamId: string, confidence: 1 | 2 | 3 | 4 | 5) => void; } function PredictionsPanel({ content, passport, busyAction, onPredict }: PredictionsPanelProps) { const predictionsByMatch = new Map(passport.predictions.map((prediction) => [prediction.matchId, prediction])); return (

Predict and compete

Predictions

{passport.stats.giantKillingsPredicted} giant-killing picks
{content.matches.map((match) => { const prediction = predictionsByMatch.get(match.id); const home = content.teams.find((team) => team.id === match.homeTeamId); const away = content.teams.find((team) => team.id === match.awayTeamId); const stadium = stadiumName(content, match.stadiumId); return (
Group {match.group} · {formatDateTime(match.kickoffISO)}

{matchTitle(content, match)}

{stadium}

{prediction ? :

No prediction yet.

}
{[home, away].filter(Boolean).map((team) => { const isUnderdog = match.underdogTeamId === team!.id; const label = isUnderdog ? `Predict ${team!.name} giant killing` : `Predict ${team!.name} win`; const actionKey = `prediction-${match.id}-${team!.id}`; return ( ); })}
); })}
); } function PredictionSummary({ content, prediction }: { content: ContentResponse; prediction: Prediction }) { const selectedTeam = teamName(content, prediction.predictedWinnerTeamId); return (

Picked {selectedTeam} · Confidence {prediction.confidence}/5 {prediction.giantKilling ? ' · giant killing' : ''}

); } interface MemoriesPanelProps { content: ContentResponse; passport: PassportState; busyAction: string | null; onAddMemory: (input: Omit) => void; } function MemoriesPanel({ content, passport, busyAction, onAddMemory }: MemoriesPanelProps) { const [title, setTitle] = useState(''); const [note, setNote] = useState(''); const [matchId, setMatchId] = useState(''); const [mood, setMood] = useState('joy'); function handleSubmit(event: FormEvent) { event.preventDefault(); onAddMemory({ title, note, matchId: matchId || undefined, mood }); setTitle(''); setNote(''); setMatchId(''); setMood('joy'); } return (

Tournament memories

Memory book

{passport.stats.memoriesCreated} saved
setTitle(event.target.value)} placeholder="Opening week goosebumps" required />