@ -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 < string , string [ ] > = {
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 < Record < string , Team > > ( ( 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 < Record < string , Score > > ( ( acc , match ) = > {
acc [ match . id ] = { home : '' , away : '' } ;
return acc ;
} , { } ) ;
const emptyKnockoutScores = BRACKET_MATCHES . reduce < Record < number , Score > > ( ( 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 } ) = > (
< div className = { ` rounded-2xl border px-4 py-3 ${ active ? 'border-emerald-400 bg-emerald-50 text-emerald-950' : 'border-white/20 bg-white/10 text-white/80' } ` } >
< p className = "text-xs font-semibold uppercase tracking-[0.2em]" > Step { number } < / p >
< p className = "mt-1 text-sm font-bold" > { label } < / p >
< / div >
) ;
const ScoreInput = ( { label , value , onChange } : { label : string ; value : string ; onChange : ( value : string ) = > void } ) = > (
< label className = "flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-200" >
< span className = "sr-only" > { label } < / span >
< input
aria - label = { label }
className = "h-10 w-14 rounded-xl border border-slate-200 bg-white text-center text-base font-black text-slate-900 shadow-sm outline-none transition focus:border-emerald-400 focus:ring-2 focus:ring-emerald-200 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
min = "0"
onChange = { ( event ) = > onChange ( event . target . value ) }
placeholder = "0"
type = "number"
value = { value }
/ >
< / label >
) ;
const WorldCupPredictor = ( ) = > {
const { currentUser } = useAppSelector ( ( state ) = > state . auth ) ;
const [ groupScores , setGroupScores ] = useState < Record < string , Score > > ( emptyGroupScores ) ;
const [ knockoutScores , setKnockoutScores ] = useState < Record < number , Score > > ( emptyKnockoutScores ) ;
const [ answers , setAnswers ] = useState < Record < number , string > > ( { } ) ;
const [ saveMessage , setSaveMessage ] = useState ( '' ) ;
const [ exportMessage , setExportMessage ] = useState ( '' ) ;
const [ savedSubmission , setSavedSubmission ] = useState < SavedSubmission | null > ( null ) ;
useEffect ( ( ) = > {
const stored = window . localStorage . getItem ( 'wc2026-prediction-draft' ) ;
if ( ! stored ) return ;
try {
const parsed = JSON . parse ( stored ) as {
groupScores? : Record < string , Score > ;
knockoutScores? : Record < string , Score > ;
answers? : Record < number , string > ;
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 < Record < string , Standing [ ] > > ( ( 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 < Record < string , Standing > > ( ( 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 < Record < number , ResolvedKnockoutMatch > > ( ( ) = > {
const cache = new Map < number , ResolvedKnockoutMatch > ( ) ;
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 < Record < number , ResolvedKnockoutMatch > > ( ( 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 < Record < string , Score > > ( ( acc , match ) = > {
acc [ match . id ] = {
home : String ( randomInt ( 0 , 4 ) ) ,
away : String ( randomInt ( 0 , 4 ) ) ,
} ;
return acc ;
} , { } )
) ;
setKnockoutScores (
BRACKET_MATCHES . reduce < Record < number , Score > > ( ( 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 < Record < number , string > > ( ( 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 (
< >
< Head >
< title > { getPageTitle ( 'World Cup 2026 Predictor' ) } < / title >
< / Head >
< SectionMain >
< section className = "overflow-hidden rounded-[2rem] bg-slate-950 text-white shadow-2xl shadow-emerald-950/30" >
< div className = "relative p-6 sm:p-8 lg:p-10" >
< div className = "absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.35),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(250,204,21,0.25),_transparent_30%)]" / >
< div className = "relative grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-end" >
< div >
< div className = "mb-6 inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-bold text-emerald-100 backdrop-blur" >
< BaseIcon path = { mdiSoccer } size = { 18 } / >
Friends league predictor MVP
< / div >
< h1 className = "max-w-4xl text-4xl font-black tracking-tight sm:text-5xl lg:text-6xl" >
Build your World Cup 2026 bracket from group guesses to champion .
< / h1 >
< p className = "mt-5 max-w-2xl text-base leading-7 text-slate-300 sm:text-lg" >
Enter all group - stage scorelines , let the FIFA - style bracket fill in , then play every knockout round until your winner is locked in .
< / p >
< div className = "mt-6 flex flex-col gap-3 sm:flex-row" >
< BaseButton
className = "rounded-2xl bg-emerald-500 px-5 py-3 text-sm font-black text-white hover:bg-emerald-600"
color = "success"
label = "Fill random results"
onClick = { fillRandomResults }
/ >
< div className = "rounded-2xl border border-white/15 bg-white/5 px-4 py-3 text-sm font-semibold text-slate-300" >
Temporary dev helper for testing the full prediction flow .
< / div >
< / div >
< / div >
< div className = "grid gap-3 sm:grid-cols-3 lg:grid-cols-1" >
< WorkflowStep active label = { ` ${ groupProgress } / ${ GROUP_MATCHES . length } group scores ` } number = "01" / >
< WorkflowStep active = { groupStageComplete } label = { ` ${ groupStageComplete ? 32 : 0 } /32 knockout seeds ` } number = "02" / >
< WorkflowStep active = { Boolean ( champion ) } label = { champion ? ` ${ champion . name } champion ` : ` ${ knockoutProgress } /32 knockout scores ` } number = "03" / >
< / div >
< / div >
< / div >
< / section >
< div className = "mt-6 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]" >
< CardBox className = "border-0 bg-white/95 shadow-xl shadow-slate-200/70 dark:bg-slate-900/90 dark:shadow-black/20" >
< div className = "mb-5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" >
< div >
< p className = "text-sm font-bold uppercase tracking-[0.18em] text-emerald-600" > Group stage < / p >
< h2 className = "text-2xl font-black text-slate-950 dark:text-white" > Groups A – L score sheet < / h2 >
< / div >
< span className = "rounded-full bg-slate-100 px-4 py-2 text-sm font-bold text-slate-700 dark:bg-slate-800 dark:text-slate-200" >
{ Math . round ( ( groupProgress / GROUP_MATCHES . length ) * 100 ) } % complete
< / span >
< / div >
< div className = "grid gap-4 lg:grid-cols-2" >
{ GROUP_NAMES . map ( ( group ) = > (
< div key = { group } className = "rounded-3xl border border-slate-100 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40" >
< div className = "mb-3 flex items-center justify-between" >
< h3 className = "text-lg font-black text-slate-950 dark:text-white" > Group { group } < / h3 >
< span className = "text-xs font-bold uppercase tracking-widest text-slate-400" > Top 2 + best 3 rds < / span >
< / div >
< div className = "space-y-3" >
{ GROUP_MATCHES . filter ( ( match ) = > match . group === group ) . map ( ( match ) = > (
< div key = { match . id } className = "grid grid-cols-[1fr_auto_1fr] items-center gap-3 rounded-2xl bg-white p-3 shadow-sm dark:bg-slate-900" >
< p className = "truncate text-sm font-bold text-slate-700 dark:text-slate-200" > { TEAMS_BY_ID [ match . homeId ] . name } < / p >
< div className = "flex items-center gap-1" >
< ScoreInput label = { ` ${ TEAMS_BY_ID [ match . homeId ] . name } goals ` } value = { groupScores [ match . id ] . home } onChange = { ( value ) = > updateGroupScore ( match . id , 'home' , value ) } / >
< span className = "text-slate-400" > - < / span >
< ScoreInput label = { ` ${ TEAMS_BY_ID [ match . awayId ] . name } goals ` } value = { groupScores [ match . id ] . away } onChange = { ( value ) = > updateGroupScore ( match . id , 'away' , value ) } / >
< / div >
< p className = "truncate text-right text-sm font-bold text-slate-700 dark:text-slate-200" > { TEAMS_BY_ID [ match . awayId ] . name } < / p >
< / div >
) ) }
< / div >
< div className = "mt-4 rounded-2xl bg-slate-900 px-4 py-3 text-xs font-semibold text-slate-300 dark:bg-slate-950" >
When the group closes , the top two teams and the best third - placed sides feed the official knockout bracket .
< / div >
< / div >
) ) }
< / div >
< / CardBox >
< CardBox className = "border-0 bg-slate-950 text-white shadow-xl shadow-slate-200/70 dark:bg-slate-900 dark:shadow-black/20" >
< div className = "mb-5 flex items-center gap-3" >
< div className = "rounded-2xl bg-emerald-500/15 p-3 text-emerald-300" >
< BaseIcon path = { mdiTrophyOutline } size = { 24 } / >
< / div >
< div >
< p className = "text-sm font-bold uppercase tracking-[0.18em] text-emerald-300" > Knockout stage < / p >
< h2 className = "text-2xl font-black text-white" > Open FIFA - style bracket < / h2 >
< / div >
< / div >
< p className = "text-sm leading-6 text-slate-300" >
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 .
< / p >
< div className = "mt-5 grid gap-3 sm:grid-cols-3" >
< div className = "rounded-2xl bg-white/5 p-4 text-sm font-bold text-slate-200" > Rounds : 6 < / div >
< div className = "rounded-2xl bg-white/5 p-4 text-sm font-bold text-slate-200" > Matches : 32 < / div >
< div className = "rounded-2xl bg-white/5 p-4 text-sm font-bold text-slate-200" > Open now < / div >
< / div >
< div className = "mt-5 rounded-3xl bg-white/5 p-4 ring-1 ring-white/10" >
< div className = "flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between" >
< div >
< p className = "text-sm font-bold uppercase tracking-[0.18em] text-emerald-300" > Best third - placed teams < / p >
< h3 className = "text-lg font-black text-white" > Projected ranking across all 12 groups < / h3 >
< / div >
< span className = "rounded-full bg-emerald-500/15 px-3 py-1 text-xs font-black uppercase tracking-widest text-emerald-200" >
Top 8 qualify
< / span >
< / div >
< p className = "mt-2 text-xs font-semibold leading-5 text-slate-300" >
{ 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.' }
< / p >
< div className = "mt-4 space-y-2" >
{ thirdPlaceStandings . map ( ( standing , index ) = > {
const qualified = index < 8 ;
return (
< div
key = { standing . team . id }
className = { ` flex flex-col gap-2 rounded-2xl px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between ${
qualified ? 'bg-emerald-500/15 text-emerald-50' : 'bg-white/5 text-slate-300'
} ` }
>
< div className = "flex items-center gap-3 font-black" >
< span className = { ` flex h-7 w-7 items-center justify-center rounded-full text-xs font-black ${ qualified ? 'bg-emerald-400 text-emerald-950' : 'bg-white/10 text-white/70' } ` } >
{ index + 1 }
< / span >
< span className = "truncate" > { standing . team . name } < / span >
< span className = { ` rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-widest ${ qualified ? 'bg-emerald-400/20 text-emerald-100' : 'bg-white/10 text-slate-300' } ` } >
Group { standing . team . group }
< / span >
< / div >
< div className = "flex items-center gap-3 text-xs font-black uppercase tracking-widest sm:justify-end" >
< span > { standing . points } pts < / span >
< span > { standing . goalDifference > 0 ? ` + ${ standing . goalDifference } ` : standing . goalDifference } GD < / span >
< span > { standing . goalsFor } GF < / span >
< / div >
< / div >
) ;
} ) }
< / div >
< / div >
< / CardBox >
< / div >
< CardBox className = "mt-6 border-0 bg-white shadow-xl shadow-slate-200/70 dark:bg-slate-900 dark:shadow-black/20" >
< div className = "mb-5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" >
< div >
< p className = "text-sm font-bold uppercase tracking-[0.18em] text-emerald-600" > Knockout bracket < / p >
< h2 className = "text-2xl font-black text-slate-950 dark:text-white" > Matches 73 – 104 , exactly as FIFA lays them out < / h2 >
< / div >
< span className = "rounded-full bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200" >
{ knockoutProgress } / { BRACKET_MATCHES . length } scores entered
< / span >
< / div >
< div className = "grid gap-4 xl:grid-cols-6" >
{ BRACKET_ROUNDS . map ( ( round ) = > (
< div key = { round . title } className = "space-y-3" >
< h3 className = "rounded-2xl bg-slate-100 px-4 py-3 text-sm font-black text-slate-800 dark:bg-slate-800 dark:text-white" >
{ round . title }
< / h3 >
{ 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 (
< div key = { matchId } className = "rounded-2xl border border-slate-100 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950/40" >
< div className = "mb-2 flex items-center justify-between gap-2 text-xs font-black uppercase tracking-widest text-slate-400" >
< span > Match { matchId } < / span >
< span > { result ? . definition . round ? ? round . title } < / span >
< / div >
< div className = "space-y-2" >
< div className = { ` flex items-center justify-between gap-2 ${ winnerIsHome ? 'text-emerald-700 dark:text-emerald-300' : 'text-slate-700 dark:text-slate-200' } ` } >
< span className = "truncate text-sm font-black" > { result ? . home . label ? ? 'TBD' } < / span >
< ScoreInput label = { ` ${ result ? . home . label ? ? 'Home' } knockout goals ` } value = { score . home } onChange = { ( value ) = > updateKnockoutScore ( matchId , 'home' , value ) } / >
< / div >
< div className = { ` flex items-center justify-between gap-2 ${ winnerIsAway ? 'text-emerald-700 dark:text-emerald-300' : 'text-slate-700 dark:text-slate-200' } ` } >
< span className = "truncate text-sm font-black" > { result ? . away . label ? ? 'TBD' } < / span >
< ScoreInput label = { ` ${ result ? . away . label ? ? 'Away' } knockout goals ` } value = { score . away } onChange = { ( value ) = > updateKnockoutScore ( matchId , 'away' , value ) } / >
< / div >
< / div >
{ result ? . tied && (
< p className = "mt-2 rounded-xl bg-red-50 px-3 py-2 text-xs font-bold text-red-600 dark:bg-red-500/10 dark:text-red-200" > Pick a winner — knockout ties are not valid . < / p >
) }
{ result ? . winner && (
< p className = "mt-2 flex items-center gap-1 text-xs font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-300" >
< BaseIcon path = { mdiCheckCircle } size = { 14 } / >
{ result . definition . round === 'Bronze final' ? ` ${ result . winner . name } takes third place ` : ` ${ result . winner . name } advances ` }
< / p >
) }
{ ! result ? . winner && result ? . definition . round === 'Bronze final' && (
< p className = "mt-2 text-xs font-bold text-slate-500 dark:text-slate-400" > Bronze medal match is shown directly in the bracket . < / p >
) }
< / div >
) ;
} ) }
< / div >
) ) }
< / div >
< / CardBox >
< div className = "mt-6 grid gap-6 lg:grid-cols-[1fr_0.8fr]" >
< CardBox className = "border-0 bg-white shadow-xl shadow-slate-200/70 dark:bg-slate-900 dark:shadow-black/20" >
< div className = "mb-5 flex items-center gap-3" >
< div className = "rounded-2xl bg-blue-100 p-3 text-blue-700 dark:bg-blue-500/20 dark:text-blue-200" >
< BaseIcon path = { mdiHelpCircleOutline } size = { 24 } / >
< / div >
< div >
< p className = "text-sm font-bold uppercase tracking-[0.18em] text-blue-600" > Open questions < / p >
< h2 className = "text-2xl font-black text-slate-950 dark:text-white" > Answer at least 5 < / h2 >
< / div >
< / div >
< div className = "space-y-4" >
{ OPEN_QUESTIONS . map ( ( question , index ) = > (
< label key = { question } className = "block" >
< span className = "mb-2 block text-sm font-black text-slate-700 dark:text-slate-200" >
{ index + 1 } . { question }
< / span >
< textarea
className = "min-h-[88px] w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-medium text-slate-800 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100 dark:border-slate-700 dark:bg-slate-950 dark:text-white"
onChange = { ( event ) = > setAnswers ( ( current ) = > ( { . . . current , [ index ] : event . target . value } ) ) }
placeholder = "Type your prediction..."
value = { answers [ index ] ? ? '' }
/ >
< / label >
) ) }
< / div >
< / CardBox >
< CardBox className = "border-0 bg-white shadow-xl shadow-slate-200/70 dark:bg-slate-900 dark:shadow-black/20" >
< p className = "text-sm font-bold uppercase tracking-[0.18em] text-emerald-600" > Submission < / p >
< h2 className = "mt-1 text-2xl font-black text-slate-950 dark:text-white" > Ready for the friend pool < / h2 >
< div className = "mt-5 space-y-3 text-sm font-bold text-slate-600 dark:text-slate-300" >
< div className = "flex justify-between rounded-2xl bg-slate-50 p-4 dark:bg-slate-950/40" >
< span > Player < / span >
< span > { currentUser ? . firstName || currentUser ? . email || 'Signed-in friend' } < / span >
< / div >
< div className = "flex justify-between rounded-2xl bg-slate-50 p-4 dark:bg-slate-950/40" >
< span > Group scores < / span >
< span > { groupProgress } / { GROUP_MATCHES . length } < / span >
< / div >
< div className = "flex justify-between rounded-2xl bg-slate-50 p-4 dark:bg-slate-950/40" >
< span > Knockout scores < / span >
< span > { knockoutProgress } / { BRACKET_MATCHES . length } < / span >
< / div >
< div className = "flex justify-between rounded-2xl bg-slate-50 p-4 dark:bg-slate-950/40" >
< span > Open answers < / span >
< span > { answeredQuestions } / { OPEN_QUESTIONS . length } < / span >
< / div >
< / div >
< BaseButton
className = "mt-6 w-full rounded-2xl bg-emerald-500 py-4 text-base font-black text-white hover:bg-emerald-600"
color = "success"
icon = { mdiContentSaveOutline }
label = "Save prediction draft"
onClick = { saveDraft }
/ >
{ saveMessage && (
< div className = { ` mt-4 rounded-2xl px-4 py-3 text-sm font-bold ${ savedSubmission ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200' : 'bg-orange-50 text-orange-700 dark:bg-orange-500/10 dark:text-orange-200' } ` } >
{ saveMessage }
< / div >
) }
< BaseButton
className = "mt-4 w-full rounded-2xl px-4 py-4 text-base font-black"
color = "info"
label = "Export for Google Sheets (TSV)"
onClick = { exportForGoogleSheets }
/ >
{ exportMessage && (
< div className = "mt-4 rounded-2xl bg-blue-50 px-4 py-3 text-sm font-bold text-blue-700 dark:bg-blue-500/10 dark:text-blue-200" >
{ exportMessage }
< / div >
) }
{ savedSubmission && (
< div className = "mt-5 rounded-3xl border border-emerald-100 bg-emerald-50 p-5 dark:border-emerald-500/20 dark:bg-emerald-500/10" >
< p className = "text-xs font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-200" > Saved detail < / p >
< h3 className = "mt-2 text-xl font-black text-slate-950 dark:text-white" > Champion : { savedSubmission . champion } < / h3 >
< p className = "mt-2 text-sm font-semibold text-slate-600 dark:text-slate-300" >
Bronze : { savedSubmission . bronzeWinner }
< / p >
< p className = "mt-2 text-sm font-semibold text-slate-600 dark:text-slate-300" >
Saved { new Date ( savedSubmission . savedAt ) . toLocaleString ( ) } with { savedSubmission . answeredQuestions } open - question answers .
< / p >
< / div >
) }
< / CardBox >
< / div >
< / SectionMain >
< / >
) ;
} ;
WorldCupPredictor . getLayout = function getLayout ( page : ReactElement ) {
return < LayoutAuthenticated > { page } < / LayoutAuthenticated > ;
} ;
export default WorldCupPredictor ;