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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+
- {getPageTitle('Starter Page')} + {getPageTitle('World Cup 2026 Friends Betting')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - - - -
-
-
-
-
-

© 2026 {title}. All rights reserved

- - Privacy Policy +
+ + + + + + WC 2026 + Friends Betting + -
+ + +
+
+
+
+ +
+
+
+ + 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]) => ( +
+

{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}

+
+ ))} +
+
+
+
+ +
+
+

© 2026 WC 2026 Friends Betting. Built for friendly prediction pools.

+
+ Privacy Policy + Login +
+
+
); } @@ -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 }) => ( + +); + +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) => ( +