diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 72935e6..fcbd9b9 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index c5b7937..f368789 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -8,6 +8,14 @@ const menuAside: MenuAsideItem[] = [
label: 'Dashboard',
},
+ {
+ href: '/world-cup-predictor',
+ label: 'World Cup Predictor',
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ icon: 'mdiTrophyOutline' in icon ? icon['mdiTrophyOutline' as keyof typeof icon] : icon.mdiTrophy ?? icon.mdiTable,
+ },
+
{
href: '/users/users-list',
label: 'Users',
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 871cfb1..2aeb8ed 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,161 +1,164 @@
-
-import React, { useEffect, useState } from 'react';
+import { mdiChartTimelineVariant, mdiClipboardCheckOutline, mdiGoogleSpreadsheet, mdiLogin, mdiSoccer, mdiTrophyOutline } from '@mdi/js';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
-import BaseButton from '../components/BaseButton';
-import CardBox from '../components/CardBox';
-import SectionFullScreen from '../components/SectionFullScreen';
+import React from 'react';
+import BaseIcon from '../components/BaseIcon';
import LayoutGuest from '../layouts/Guest';
-import BaseDivider from '../components/BaseDivider';
-import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
+const featureCards = [
+ {
+ icon: mdiSoccer,
+ title: 'Predict every group game',
+ text: 'Friends enter scorelines for Groups A–L and the app turns them into live projected tables.',
+ },
+ {
+ icon: mdiChartTimelineVariant,
+ title: 'Auto-build the bracket',
+ text: 'Qualifiers flow into the knockout tree so each winner unlocks the next round.',
+ },
+ {
+ icon: mdiClipboardCheckOutline,
+ title: 'Finish with tie-breakers',
+ text: 'Open questions capture the fun bets: Golden Boot, surprise package, bold predictions, and more.',
+ },
+ {
+ icon: mdiGoogleSpreadsheet,
+ title: 'Prepared for Sheets sync',
+ text: 'The MVP saves a complete draft now; Google Sheets push can be connected when credentials are ready.',
+ },
+];
export default function Starter() {
- const [illustrationImage, setIllustrationImage] = useState({
- src: undefined,
- photographer: undefined,
- photographer_url: undefined,
- })
- const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
- const [contentType, setContentType] = useState('video');
- const [contentPosition, setContentPosition] = useState('right');
- const textColor = useAppSelector((state) => state.style.linkColor);
-
- const title = 'WC 2026 Friends Betting'
-
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
-
- const imageBlock = (image) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
-
return (
-
+
-
{getPageTitle('Starter Page')}
+
{getPageTitle('World Cup 2026 Friends Betting')}
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
+
+
+
+
+
+
+ WC 2026
+ Friends Betting
+
-
+
+
+ Login
+
+
+
+ Admin interface
+
+
+
+
+
+
+
+
+
+
+
+
+ Interactive predictor for your friend group
+
+
+ From first whistle to champion pick.
+
+
+ A modern World Cup 2026 betting pool app where friends predict every group result, watch qualifiers auto-calculate, complete the knockout bracket, and answer bonus questions.
+
+
+
+
+ Start predicting
+
+
+ Login / Admin
+
+
+
+ {[
+ ['12', 'Groups'],
+ ['72', 'Group games'],
+ ['31', 'Knockouts'],
+ ].map(([value, label]) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
Live bracket preview
+
Your route to glory
+
+
MVP
+
+
+ {['Groups A–L completed', 'Top teams qualify', 'Round of 32 unlocked', 'Champion prediction saved'].map((item, index) => (
+
+
0{index + 1}
+
+
{item}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
First playable slice
+
A real workflow, not just a landing page.
+
+ The first iteration gives signed-in users the full prediction journey in one polished interface. Existing admin CRUD remains available for managing tournaments, teams, matches, submissions, and questions.
+
+
+
+ {featureCards.map((feature) => (
+
+
+
+
+ {feature.title}
+ {feature.text}
+
+ ))}
+
+
+
+
+
+
);
}
@@ -163,4 +166,3 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) {
return
{page} ;
};
-
diff --git a/frontend/src/pages/world-cup-predictor.tsx b/frontend/src/pages/world-cup-predictor.tsx
new file mode 100644
index 0000000..f36a3df
--- /dev/null
+++ b/frontend/src/pages/world-cup-predictor.tsx
@@ -0,0 +1,963 @@
+import { mdiCheckCircle, mdiContentSaveOutline, mdiHelpCircleOutline, mdiPodiumGold, mdiSoccer, mdiTrophyOutline } from '@mdi/js';
+import Head from 'next/head';
+import React, { ReactElement, useEffect, useMemo, useState } from 'react';
+import BaseButton from '../components/BaseButton';
+import BaseIcon from '../components/BaseIcon';
+import CardBox from '../components/CardBox';
+import SectionMain from '../components/SectionMain';
+import LayoutAuthenticated from '../layouts/Authenticated';
+import { getPageTitle } from '../config';
+import { useAppSelector } from '../stores/hooks';
+
+type Team = {
+ id: string;
+ name: string;
+ group: string;
+};
+
+type Score = {
+ home: string;
+ away: string;
+};
+
+type Standing = {
+ team: Team;
+ played: number;
+ points: number;
+ goalDifference: number;
+ goalsFor: number;
+};
+
+type SavedSubmission = {
+ savedAt: string;
+ champion: string;
+ bronzeWinner: string;
+ answeredQuestions: number;
+};
+
+type GroupSlot = {
+ kind: 'group';
+ group: string;
+ position: 'winner' | 'runner-up';
+};
+
+type ThirdSlot = {
+ kind: 'third';
+ groups: string[];
+};
+
+type WinnerSlot = {
+ kind: 'winner';
+ matchId: number;
+};
+
+type LoserSlot = {
+ kind: 'loser';
+ matchId: number;
+};
+
+type BracketSlot = GroupSlot | ThirdSlot | WinnerSlot | LoserSlot;
+
+type KnockoutMatchDefinition = {
+ id: number;
+ round: 'Round of 32' | 'Round of 16' | 'Quarter-final' | 'Semi-final' | 'Bronze final' | 'Final';
+ home: BracketSlot;
+ away: BracketSlot;
+};
+
+type ResolvedSlot = {
+ label: string;
+ team: Team | null;
+};
+
+type ResolvedKnockoutMatch = {
+ definition: KnockoutMatchDefinition;
+ home: ResolvedSlot;
+ away: ResolvedSlot;
+ winner: Team | null;
+ loser: Team | null;
+ tied: boolean;
+ score: Score;
+};
+
+const GROUPS: Record
= {
+ A: ['Mexico', 'South Africa', 'Korea Republic', 'Czechia'],
+ B: ['Canada', 'Bosnia and Herzegovina', 'Qatar', 'Switzerland'],
+ C: ['Haiti', 'Scotland', 'Brazil', 'Morocco'],
+ D: ['USA', 'Paraguay', 'Australia', 'Türkiye'],
+ E: ['Côte d\'Ivoire', 'Ecuador', 'Germany', 'Curaçao'],
+ F: ['Netherlands', 'Japan', 'Sweden', 'Tunisia'],
+ G: ['IR Iran', 'New Zealand', 'Belgium', 'Egypt'],
+ H: ['Saudi Arabia', 'Uruguay', 'Spain', 'Cabo Verde'],
+ I: ['France', 'Senegal', 'Iraq', 'Norway'],
+ J: ['Argentina', 'Algeria', 'Austria', 'Jordan'],
+ K: ['Portugal', 'Congo DR', 'Uzbekistan', 'Colombia'],
+ L: ['Ghana', 'Panama', 'England', 'Croatia'],
+};
+
+const GROUP_NAMES = Object.keys(GROUPS);
+const OPEN_QUESTIONS = [
+ 'Who will be the surprise team of the tournament?',
+ 'Who will win the Golden Boot?',
+ 'Which goalkeeper will be the penalty hero?',
+ 'Which favorite will exit earlier than expected?',
+ 'Which young player will become a star?',
+ 'What will be the biggest scoreline?',
+ 'What is your boldest World Cup 2026 prediction?',
+];
+
+const TEAM_LIST: Team[] = GROUP_NAMES.flatMap((group) =>
+ GROUPS[group].map((name, index) => ({
+ id: `${group}${index + 1}`,
+ name,
+ group,
+ }))
+);
+
+const TEAMS_BY_ID = TEAM_LIST.reduce>((acc, team) => {
+ acc[team.id] = team;
+ return acc;
+}, {});
+
+const GROUP_MATCHES = GROUP_NAMES.flatMap((group) => {
+ const teamIds = GROUPS[group].map((_, index) => `${group}${index + 1}`);
+
+ return [
+ [teamIds[0], teamIds[1]],
+ [teamIds[2], teamIds[3]],
+ [teamIds[0], teamIds[2]],
+ [teamIds[1], teamIds[3]],
+ [teamIds[0], teamIds[3]],
+ [teamIds[1], teamIds[2]],
+ ].map(([homeId, awayId], index) => ({
+ id: `${group}-${index + 1}`,
+ group,
+ homeId,
+ awayId,
+ }));
+});
+
+const BRACKET_MATCHES: KnockoutMatchDefinition[] = [
+ { id: 73, round: 'Round of 32', home: { kind: 'group', group: 'A', position: 'runner-up' }, away: { kind: 'group', group: 'B', position: 'runner-up' } },
+ { id: 74, round: 'Round of 32', home: { kind: 'group', group: 'E', position: 'winner' }, away: { kind: 'third', groups: ['A', 'B', 'C', 'D', 'F'] } },
+ { id: 75, round: 'Round of 32', home: { kind: 'group', group: 'F', position: 'winner' }, away: { kind: 'group', group: 'C', position: 'runner-up' } },
+ { id: 76, round: 'Round of 32', home: { kind: 'group', group: 'C', position: 'winner' }, away: { kind: 'group', group: 'F', position: 'runner-up' } },
+ { id: 77, round: 'Round of 32', home: { kind: 'group', group: 'I', position: 'winner' }, away: { kind: 'third', groups: ['C', 'D', 'F', 'G', 'H'] } },
+ { id: 78, round: 'Round of 32', home: { kind: 'group', group: 'E', position: 'runner-up' }, away: { kind: 'group', group: 'I', position: 'runner-up' } },
+ { id: 79, round: 'Round of 32', home: { kind: 'group', group: 'A', position: 'winner' }, away: { kind: 'third', groups: ['C', 'E', 'F', 'H', 'I'] } },
+ { id: 80, round: 'Round of 32', home: { kind: 'group', group: 'L', position: 'winner' }, away: { kind: 'third', groups: ['E', 'H', 'I', 'J', 'K'] } },
+ { id: 81, round: 'Round of 32', home: { kind: 'group', group: 'D', position: 'winner' }, away: { kind: 'third', groups: ['B', 'E', 'F', 'I', 'J'] } },
+ { id: 82, round: 'Round of 32', home: { kind: 'group', group: 'G', position: 'winner' }, away: { kind: 'third', groups: ['A', 'E', 'H', 'I', 'J'] } },
+ { id: 83, round: 'Round of 32', home: { kind: 'group', group: 'K', position: 'runner-up' }, away: { kind: 'group', group: 'L', position: 'runner-up' } },
+ { id: 84, round: 'Round of 32', home: { kind: 'group', group: 'H', position: 'winner' }, away: { kind: 'group', group: 'J', position: 'runner-up' } },
+ { id: 85, round: 'Round of 32', home: { kind: 'group', group: 'B', position: 'winner' }, away: { kind: 'third', groups: ['E', 'F', 'G', 'I', 'J'] } },
+ { id: 86, round: 'Round of 32', home: { kind: 'group', group: 'J', position: 'winner' }, away: { kind: 'group', group: 'H', position: 'runner-up' } },
+ { id: 87, round: 'Round of 32', home: { kind: 'group', group: 'K', position: 'winner' }, away: { kind: 'third', groups: ['D', 'E', 'I', 'J', 'L'] } },
+ { id: 88, round: 'Round of 32', home: { kind: 'group', group: 'D', position: 'runner-up' }, away: { kind: 'group', group: 'G', position: 'runner-up' } },
+ { id: 89, round: 'Round of 16', home: { kind: 'winner', matchId: 73 }, away: { kind: 'winner', matchId: 74 } },
+ { id: 90, round: 'Round of 16', home: { kind: 'winner', matchId: 75 }, away: { kind: 'winner', matchId: 76 } },
+ { id: 91, round: 'Round of 16', home: { kind: 'winner', matchId: 77 }, away: { kind: 'winner', matchId: 78 } },
+ { id: 92, round: 'Round of 16', home: { kind: 'winner', matchId: 79 }, away: { kind: 'winner', matchId: 80 } },
+ { id: 93, round: 'Round of 16', home: { kind: 'winner', matchId: 83 }, away: { kind: 'winner', matchId: 84 } },
+ { id: 94, round: 'Round of 16', home: { kind: 'winner', matchId: 81 }, away: { kind: 'winner', matchId: 82 } },
+ { id: 95, round: 'Round of 16', home: { kind: 'winner', matchId: 86 }, away: { kind: 'winner', matchId: 88 } },
+ { id: 96, round: 'Round of 16', home: { kind: 'winner', matchId: 85 }, away: { kind: 'winner', matchId: 87 } },
+ { id: 97, round: 'Quarter-final', home: { kind: 'winner', matchId: 89 }, away: { kind: 'winner', matchId: 90 } },
+ { id: 98, round: 'Quarter-final', home: { kind: 'winner', matchId: 93 }, away: { kind: 'winner', matchId: 94 } },
+ { id: 99, round: 'Quarter-final', home: { kind: 'winner', matchId: 91 }, away: { kind: 'winner', matchId: 92 } },
+ { id: 100, round: 'Quarter-final', home: { kind: 'winner', matchId: 95 }, away: { kind: 'winner', matchId: 96 } },
+ { id: 101, round: 'Semi-final', home: { kind: 'winner', matchId: 97 }, away: { kind: 'winner', matchId: 98 } },
+ { id: 102, round: 'Semi-final', home: { kind: 'winner', matchId: 99 }, away: { kind: 'winner', matchId: 100 } },
+ { id: 103, round: 'Bronze final', home: { kind: 'loser', matchId: 101 }, away: { kind: 'loser', matchId: 102 } },
+ { id: 104, round: 'Final', home: { kind: 'winner', matchId: 101 }, away: { kind: 'winner', matchId: 102 } },
+];
+
+const BRACKET_ROUNDS = [
+ { title: 'Round of 32', matchIds: BRACKET_MATCHES.slice(0, 16).map((match) => match.id) },
+ { title: 'Round of 16', matchIds: BRACKET_MATCHES.slice(16, 24).map((match) => match.id) },
+ { title: 'Quarter-final', matchIds: BRACKET_MATCHES.slice(24, 28).map((match) => match.id) },
+ { title: 'Semi-final', matchIds: BRACKET_MATCHES.slice(28, 30).map((match) => match.id) },
+ { title: 'Bronze final', matchIds: [103] },
+ { title: 'Final', matchIds: [104] },
+];
+
+const emptyGroupScores = GROUP_MATCHES.reduce>((acc, match) => {
+ acc[match.id] = { home: '', away: '' };
+ return acc;
+}, {});
+
+const emptyKnockoutScores = BRACKET_MATCHES.reduce>((acc, match) => {
+ acc[match.id] = { home: '', away: '' };
+ return acc;
+}, {});
+
+const isCompleteScore = (score?: Score) => Boolean(score && score.home !== '' && score.away !== '');
+const numericScore = (value: string) => Number.parseInt(value || '0', 10);
+const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
+
+const rankStandings = (standings: Standing[]) =>
+ [...standings].sort(
+ (a, b) =>
+ b.points - a.points ||
+ b.goalDifference - a.goalDifference ||
+ b.goalsFor - a.goalsFor ||
+ a.team.name.localeCompare(b.team.name)
+ );
+
+const bracketLabelForSlot = (slot: BracketSlot) => {
+ if (slot.kind === 'group') {
+ return slot.position === 'winner' ? `Winner Group ${slot.group}` : `Runner-up Group ${slot.group}`;
+ }
+
+ if (slot.kind === 'third') {
+ return `Best 3rd from ${slot.groups.join('/')}`;
+ }
+
+ if (slot.kind === 'winner') {
+ return `Winner Match ${slot.matchId}`;
+ }
+
+ return `Loser Match ${slot.matchId}`;
+};
+
+const WorkflowStep = ({ active, label, number }: { active: boolean; label: string; number: string }) => (
+
+
Step {number}
+
{label}
+
+);
+
+const ScoreInput = ({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) => (
+
+ {label}
+ onChange(event.target.value)}
+ placeholder="0"
+ type="number"
+ value={value}
+ />
+
+);
+
+const WorldCupPredictor = () => {
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const [groupScores, setGroupScores] = useState>(emptyGroupScores);
+ const [knockoutScores, setKnockoutScores] = useState>(emptyKnockoutScores);
+ const [answers, setAnswers] = useState>({});
+ const [saveMessage, setSaveMessage] = useState('');
+ const [exportMessage, setExportMessage] = useState('');
+ const [savedSubmission, setSavedSubmission] = useState(null);
+
+ useEffect(() => {
+ const stored = window.localStorage.getItem('wc2026-prediction-draft');
+
+ if (!stored) return;
+
+ try {
+ const parsed = JSON.parse(stored) as {
+ groupScores?: Record;
+ knockoutScores?: Record;
+ answers?: Record;
+ savedSubmission?: SavedSubmission;
+ };
+
+ setGroupScores({ ...emptyGroupScores, ...(parsed.groupScores ?? {}) });
+ setKnockoutScores({ ...emptyKnockoutScores, ...(parsed.knockoutScores ?? {}) });
+ setAnswers(parsed.answers ?? {});
+ setSavedSubmission(parsed.savedSubmission ?? null);
+ } catch (error) {
+ console.error('Could not restore World Cup prediction draft', error);
+ }
+ }, []);
+
+ const standingsByGroup = useMemo(() => {
+ return GROUP_NAMES.reduce>((acc, group) => {
+ const standings = GROUPS[group].map((name, index) => ({
+ team: {
+ id: `${group}${index + 1}`,
+ name,
+ group,
+ },
+ played: 0,
+ points: 0,
+ goalDifference: 0,
+ goalsFor: 0,
+ }));
+
+ const standingByTeam = standings.reduce>((lookup, standing) => {
+ lookup[standing.team.id] = standing;
+ return lookup;
+ }, {});
+
+ GROUP_MATCHES.filter((match) => match.group === group).forEach((match) => {
+ const score = groupScores[match.id];
+ if (!isCompleteScore(score)) return;
+
+ const homeGoals = numericScore(score.home);
+ const awayGoals = numericScore(score.away);
+ const home = standingByTeam[match.homeId];
+ const away = standingByTeam[match.awayId];
+
+ home.played += 1;
+ away.played += 1;
+ home.goalsFor += homeGoals;
+ away.goalsFor += awayGoals;
+ home.goalDifference += homeGoals - awayGoals;
+ away.goalDifference += awayGoals - homeGoals;
+
+ if (homeGoals > awayGoals) {
+ home.points += 3;
+ } else if (awayGoals > homeGoals) {
+ away.points += 3;
+ } else {
+ home.points += 1;
+ away.points += 1;
+ }
+ });
+
+ acc[group] = rankStandings(standings);
+ return acc;
+ }, {});
+ }, [groupScores]);
+
+ const groupStageComplete = GROUP_MATCHES.every((match) => isCompleteScore(groupScores[match.id]));
+ const groupProgress = GROUP_MATCHES.filter((match) => isCompleteScore(groupScores[match.id])).length;
+
+ const thirdPlaceStandings = useMemo(
+ () => rankStandings(GROUP_NAMES.map((group) => standingsByGroup[group][2]).filter(Boolean)),
+ [standingsByGroup]
+ );
+
+ const bestThirdTeams = thirdPlaceStandings.slice(0, 8);
+
+ const bracketResults = useMemo>(() => {
+ const cache = new Map();
+ const remainingThirdTeams = [...bestThirdTeams];
+
+ const resolveGroupTieWinner = (a: Team, b: Team) => {
+ const aStanding = standingsByGroup[a.group]?.find((s) => s.team.id === a.id);
+ const bStanding = standingsByGroup[b.group]?.find((s) => s.team.id === b.id);
+
+ // Mirror rankStandings ordering.
+ const cmp =
+ (bStanding?.points ?? 0) - (aStanding?.points ?? 0) ||
+ (bStanding?.goalDifference ?? 0) - (aStanding?.goalDifference ?? 0) ||
+ (bStanding?.goalsFor ?? 0) - (aStanding?.goalsFor ?? 0) ||
+ a.name.localeCompare(b.name);
+
+ // cmp < 0 => a is better
+ return cmp < 0 ? a : b;
+ };
+
+ const resolveTeamSlot = (slot: BracketSlot): ResolvedSlot => {
+ if (slot.kind === 'group') {
+ const standing = standingsByGroup[slot.group]?.[slot.position === 'winner' ? 0 : 1];
+ return standing ? { label: standing.team.name, team: standing.team } : { label: bracketLabelForSlot(slot), team: null };
+ }
+
+ if (slot.kind === 'third') {
+ const candidateStandingIndex = remainingThirdTeams.findIndex((standing) => slot.groups.includes(standing.team.group));
+ if (candidateStandingIndex >= 0) {
+ const [candidateStanding] = remainingThirdTeams.splice(candidateStandingIndex, 1);
+ return { label: candidateStanding.team.name, team: candidateStanding.team };
+ }
+
+ return { label: bracketLabelForSlot(slot), team: null };
+ }
+
+ const sourceMatch = cache.get(slot.matchId);
+ if (!sourceMatch) {
+ return { label: bracketLabelForSlot(slot), team: null };
+ }
+
+ // If the source match is tied, `winner/loser` intentionally stay null.
+ // For UX, we still need downstream slots to resolve to real team names.
+ if (sourceMatch.tied && sourceMatch.home.team && sourceMatch.away.team) {
+ const tiebreakWinner = resolveGroupTieWinner(sourceMatch.home.team, sourceMatch.away.team);
+ const tiebreakLoser = tiebreakWinner.id === sourceMatch.home.team.id ? sourceMatch.away.team : sourceMatch.home.team;
+
+ const team = slot.kind === 'winner' ? tiebreakWinner : tiebreakLoser;
+ return { label: team.name, team };
+ }
+
+ const team = slot.kind === 'winner' ? sourceMatch.winner : sourceMatch.loser;
+ return team ? { label: team.name, team } : { label: bracketLabelForSlot(slot), team: null };
+ };
+
+ BRACKET_MATCHES.forEach((definition) => {
+ const home = resolveTeamSlot(definition.home);
+ const away = resolveTeamSlot(definition.away);
+ const score = knockoutScores[definition.id] ?? { home: '', away: '' };
+ const tied = isCompleteScore(score) && score.home === score.away;
+
+ let winner: Team | null = null;
+ let loser: Team | null = null;
+
+ if (!tied && home.team && away.team && isCompleteScore(score)) {
+ const homeGoals = numericScore(score.home);
+ const awayGoals = numericScore(score.away);
+ winner = homeGoals > awayGoals ? home.team : away.team;
+ loser = homeGoals > awayGoals ? away.team : home.team;
+ }
+
+ cache.set(definition.id, {
+ definition,
+ home,
+ away,
+ winner,
+ loser,
+ tied,
+ score,
+ });
+ });
+
+ return Array.from(cache.entries()).reduce>((acc, [matchId, result]) => {
+ acc[matchId] = result;
+ return acc;
+ }, {});
+ }, [bestThirdTeams, groupStageComplete, knockoutScores, standingsByGroup]);
+
+ const knockoutProgress = BRACKET_MATCHES.filter((match) => isCompleteScore(knockoutScores[match.id])).length;
+ const champion = bracketResults[104]?.winner ?? null;
+ const bronzeWinner = bracketResults[103]?.winner ?? null;
+ const answeredQuestions = Object.values(answers).filter((answer) => answer.trim().length > 0).length;
+
+ const updateGroupScore = (matchId: string, side: keyof Score, value: string) => {
+ setSaveMessage('');
+ setGroupScores((current) => ({
+ ...current,
+ [matchId]: {
+ ...current[matchId],
+ [side]: value,
+ },
+ }));
+ };
+
+ const updateKnockoutScore = (matchId: number, side: keyof Score, value: string) => {
+ setSaveMessage('');
+ setKnockoutScores((current) => ({
+ ...current,
+ [matchId]: {
+ home: '',
+ away: '',
+ ...(current[matchId] ?? { home: '', away: '' }),
+ [side]: value,
+ },
+ }));
+ };
+
+ const saveDraft = () => {
+ if (!groupStageComplete) {
+ setSaveMessage('Finish all group-stage scores before saving your tournament prediction.');
+ return;
+ }
+
+ if (knockoutProgress < BRACKET_MATCHES.length) {
+ setSaveMessage('Complete every knockout score, including the bronze final, before saving.');
+ return;
+ }
+
+ if (Object.values(bracketResults).some((match) => match.tied)) {
+ setSaveMessage('Knockout ties are not valid. Resolve any tied matches before saving.');
+ return;
+ }
+
+ if (!champion || !bronzeWinner) {
+ setSaveMessage('Complete the knockout bracket to determine both the champion and bronze medalist.');
+ return;
+ }
+
+ if (answeredQuestions < 5) {
+ setSaveMessage('Answer at least 5 open questions before saving.');
+ return;
+ }
+
+ const summary = {
+ savedAt: new Date().toISOString(),
+ champion: champion.name,
+ bronzeWinner: bronzeWinner.name,
+ answeredQuestions,
+ };
+
+ window.localStorage.setItem(
+ 'wc2026-prediction-draft',
+ JSON.stringify({ groupScores, knockoutScores, answers, savedSubmission: summary })
+ );
+ setSavedSubmission(summary);
+ setSaveMessage('Prediction saved as a local draft. Google Sheets sync can be connected once sheet credentials are provided.');
+ };
+
+ const tsvEscape = (value: unknown) => {
+ const str = value === null || value === undefined ? '' : String(value);
+ return str.replace(/\t/g, ' ').replace(/\r?\n/g, ' ').trim();
+ };
+
+ const exportForGoogleSheets = async () => {
+ setExportMessage('');
+
+ const userId = currentUser?.id ?? '';
+ const firstName = currentUser?.firstName ?? '';
+ const lastName = currentUser?.lastName ?? '';
+ const email = currentUser?.email ?? '';
+
+ const groupStageCompleteValue = groupStageComplete ? 'true' : 'false';
+ const knockoutCompleteValue = knockoutProgress === BRACKET_MATCHES.length ? 'true' : 'false';
+ const answeredQuestionsValue = String(answeredQuestions);
+
+ const header: string[] = [
+ 'user_id',
+ 'first_name',
+ 'last_name',
+ 'email',
+ 'group_stage_complete',
+ 'knockout_complete',
+ 'answered_questions',
+ 'champion',
+ 'bronze_final_winner',
+ ];
+
+ const row: string[] = [
+ tsvEscape(userId),
+ tsvEscape(firstName),
+ tsvEscape(lastName),
+ tsvEscape(email),
+ groupStageCompleteValue,
+ knockoutCompleteValue,
+ answeredQuestionsValue,
+ tsvEscape(champion?.name ?? ''),
+ tsvEscape(bronzeWinner?.name ?? ''),
+ ];
+
+ // Group stage predictions
+ GROUP_MATCHES.forEach((match, index) => {
+ const score = groupScores[match.id] ?? { home: '', away: '' };
+ const homeTeam = TEAMS_BY_ID[match.homeId]?.name ?? '';
+ const awayTeam = TEAMS_BY_ID[match.awayId]?.name ?? '';
+
+ header.push(`g${index + 1}_match_id`);
+ header.push(`g${index + 1}_home_team`);
+ header.push(`g${index + 1}_away_team`);
+ header.push(`g${index + 1}_pred_home_goals`);
+ header.push(`g${index + 1}_pred_away_goals`);
+
+ row.push(tsvEscape(match.id));
+ row.push(tsvEscape(homeTeam));
+ row.push(tsvEscape(awayTeam));
+ row.push(tsvEscape(score.home));
+ row.push(tsvEscape(score.away));
+ });
+
+ // Knockout stage predictions
+ BRACKET_MATCHES.forEach((definition, index) => {
+ const result = bracketResults[definition.id];
+ const score = knockoutScores[definition.id] ?? { home: '', away: '' };
+
+ const homeTeam = result?.home.team?.name ?? result?.home.label ?? '';
+ const awayTeam = result?.away.team?.name ?? result?.away.label ?? '';
+ const winnerName = result?.winner?.name ?? '';
+ const tiedValue = result?.tied ? 'true' : 'false';
+
+ header.push(`k${index + 1}_match_id`);
+ header.push(`k${index + 1}_round`);
+ header.push(`k${index + 1}_home_team`);
+ header.push(`k${index + 1}_away_team`);
+ header.push(`k${index + 1}_pred_home_goals`);
+ header.push(`k${index + 1}_pred_away_goals`);
+ header.push(`k${index + 1}_pred_winner`);
+ header.push(`k${index + 1}_tied`);
+
+ row.push(tsvEscape(definition.id));
+ row.push(tsvEscape(definition.round));
+ row.push(tsvEscape(homeTeam));
+ row.push(tsvEscape(awayTeam));
+ row.push(tsvEscape(score.home));
+ row.push(tsvEscape(score.away));
+ row.push(tsvEscape(winnerName));
+ row.push(tsvEscape(tiedValue));
+ });
+
+ // Open question answers
+ OPEN_QUESTIONS.forEach((question, index) => {
+ header.push(`q${index + 1}_question`);
+ header.push(`q${index + 1}_answer`);
+
+ row.push(tsvEscape(question));
+ row.push(tsvEscape(answers[index] ?? ''));
+ });
+
+ const tsv = [header.map(tsvEscape).join('\t'), row.map(tsvEscape).join('\t')].join('\n');
+
+ const fileDate = new Date().toISOString().slice(0, 10);
+ const safeUser = (email || userId || 'user').replace(/[^a-z0-9]+/gi, '_');
+ const fileName = `wc2026-prediction_${safeUser}_${fileDate}.tsv`;
+
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(tsv);
+ }
+ setExportMessage('TSV copied to clipboard. If needed, you can also download the file below.');
+ } catch (e) {
+ setExportMessage('Clipboard blocked by your browser. Downloading TSV file instead.');
+ console.warn('Clipboard write failed:', e);
+ }
+
+ const blob = new Blob([tsv], { type: 'text/tab-separated-values;charset=utf-8' });
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = fileName;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ window.URL.revokeObjectURL(url);
+ };
+
+ const fillRandomResults = () => {
+ setSaveMessage('');
+ setSavedSubmission(null);
+
+ setGroupScores(
+ GROUP_MATCHES.reduce>((acc, match) => {
+ acc[match.id] = {
+ home: String(randomInt(0, 4)),
+ away: String(randomInt(0, 4)),
+ };
+ return acc;
+ }, {})
+ );
+
+ setKnockoutScores(
+ BRACKET_MATCHES.reduce>((acc, match) => {
+ const home = randomInt(0, 4);
+ let away = randomInt(0, 4);
+
+ if (home === away) {
+ away = (away + 1) % 5;
+ }
+
+ acc[match.id] = {
+ home: String(home),
+ away: String(away),
+ };
+ return acc;
+ }, {})
+ );
+
+ setAnswers(
+ OPEN_QUESTIONS.reduce>((acc, _question, index) => {
+ acc[index] = `Test answer ${index + 1}`;
+ return acc;
+ }, {})
+ );
+
+ setSaveMessage('Random test results filled for all matches and open questions. Review or save the draft when ready.');
+ };
+
+ return (
+ <>
+
+ {getPageTitle('World Cup 2026 Predictor')}
+
+
+
+
+
+
+
+
+
+ Friends league predictor MVP
+
+
+ Build your World Cup 2026 bracket from group guesses to champion.
+
+
+ Enter all group-stage scorelines, let the FIFA-style bracket fill in, then play every knockout round until your winner is locked in.
+
+
+
+
+ Temporary dev helper for testing the full prediction flow.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Group stage
+
Groups A–L score sheet
+
+
+ {Math.round((groupProgress / GROUP_MATCHES.length) * 100)}% complete
+
+
+
+
+ {GROUP_NAMES.map((group) => (
+
+
+
Group {group}
+ Top 2 + best 3rds
+
+
+ {GROUP_MATCHES.filter((match) => match.group === group).map((match) => (
+
+
{TEAMS_BY_ID[match.homeId].name}
+
+ updateGroupScore(match.id, 'home', value)} />
+ -
+ updateGroupScore(match.id, 'away', value)} />
+
+
{TEAMS_BY_ID[match.awayId].name}
+
+ ))}
+
+
+ When the group closes, the top two teams and the best third-placed sides feed the official knockout bracket.
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
Knockout stage
+
Open FIFA-style bracket
+
+
+
+ The bracket is open immediately and follows the published 2026 route: Round of 32, Round of 16, quarter-finals, semi-finals, bronze final, and final.
+
+
+
Rounds: 6
+
Matches: 32
+
Open now
+
+
+
+
+
+
Best third-placed teams
+
Projected ranking across all 12 groups
+
+
+ Top 8 qualify
+
+
+
+ {groupStageComplete
+ ? 'All group scores are in, so the top eight third-place finishers are final.'
+ : 'This ranking updates live as you enter scores. The top eight third-place finishers become final once all group matches are scored.'}
+
+
+ {thirdPlaceStandings.map((standing, index) => {
+ const qualified = index < 8;
+
+ return (
+
+
+
+ {index + 1}
+
+ {standing.team.name}
+
+ Group {standing.team.group}
+
+
+
+ {standing.points} pts
+ {standing.goalDifference > 0 ? `+${standing.goalDifference}` : standing.goalDifference} GD
+ {standing.goalsFor} GF
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
Knockout bracket
+
Matches 73–104, exactly as FIFA lays them out
+
+
+ {knockoutProgress}/{BRACKET_MATCHES.length} scores entered
+
+
+
+
+ {BRACKET_ROUNDS.map((round) => (
+
+
+ {round.title}
+
+ {round.matchIds.map((matchId) => {
+ const result = bracketResults[matchId];
+ const score = knockoutScores[matchId] ?? { home: '', away: '' };
+ const winnerIsHome = result?.winner?.id === result?.home.team?.id;
+ const winnerIsAway = result?.winner?.id === result?.away.team?.id;
+
+ return (
+
+
+ Match {matchId}
+ {result?.definition.round ?? round.title}
+
+
+
+ {result?.home.label ?? 'TBD'}
+ updateKnockoutScore(matchId, 'home', value)} />
+
+
+ {result?.away.label ?? 'TBD'}
+ updateKnockoutScore(matchId, 'away', value)} />
+
+
+ {result?.tied && (
+
Pick a winner — knockout ties are not valid.
+ )}
+ {result?.winner && (
+
+
+ {result.definition.round === 'Bronze final' ? `${result.winner.name} takes third place` : `${result.winner.name} advances`}
+
+ )}
+ {!result?.winner && result?.definition.round === 'Bronze final' && (
+
Bronze medal match is shown directly in the bracket.
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
Open questions
+
Answer at least 5
+
+
+
+ {OPEN_QUESTIONS.map((question, index) => (
+
+
+ {index + 1}. {question}
+
+
+ ))}
+
+
+
+
+ Submission
+ Ready for the friend pool
+
+
+ Player
+ {currentUser?.firstName || currentUser?.email || 'Signed-in friend'}
+
+
+ Group scores
+ {groupProgress}/{GROUP_MATCHES.length}
+
+
+ Knockout scores
+ {knockoutProgress}/{BRACKET_MATCHES.length}
+
+
+ Open answers
+ {answeredQuestions}/{OPEN_QUESTIONS.length}
+
+
+
+ {saveMessage && (
+
+ {saveMessage}
+
+ )}
+
+
+ {exportMessage && (
+
+ {exportMessage}
+
+ )}
+ {savedSubmission && (
+
+
Saved detail
+
Champion: {savedSubmission.champion}
+
+ Bronze: {savedSubmission.bronzeWinner}
+
+
+ Saved {new Date(savedSubmission.savedAt).toLocaleString()} with {savedSubmission.answeredQuestions} open-question answers.
+
+
+ )}
+
+
+
+ >
+ );
+};
+
+WorldCupPredictor.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};
+
+export default WorldCupPredictor;