Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
3d9fc028c6 Autosave: 20260607-230953 2026-06-07 23:09:46 +00:00
5 changed files with 1120 additions and 149 deletions

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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 AL 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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<div className="min-h-screen bg-[#07111f] text-white">
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('World Cup 2026 Friends Betting')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your WC 2026 Friends Betting app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
<header className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-6 lg:px-8">
<Link href="/" className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#22c55e] text-[#07111f] shadow-lg shadow-emerald-500/30">
<BaseIcon path={mdiTrophyOutline} size={25} />
</span>
<span>
<span className="block text-sm font-black uppercase tracking-[0.22em] text-emerald-200">WC 2026</span>
<span className="block text-lg font-black leading-none">Friends Betting</span>
</span>
</Link>
</div>
<nav className="flex items-center gap-3">
<Link href="/login" className="hidden rounded-full px-4 py-2 text-sm font-bold text-slate-200 transition hover:bg-white/10 sm:inline-flex">
Login
</Link>
<Link href="/login" className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-2.5 text-sm font-black text-[#07111f] shadow-xl shadow-black/20 transition hover:-translate-y-0.5 hover:bg-emerald-100">
<BaseIcon path={mdiLogin} size={18} />
Admin interface
</Link>
</nav>
</header>
<main>
<section className="relative isolate overflow-hidden">
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_15%_20%,rgba(34,197,94,0.35),transparent_30%),radial-gradient(circle_at_85%_15%,rgba(250,204,21,0.22),transparent_28%),linear-gradient(135deg,#07111f_0%,#0f2b46_55%,#06251b_100%)]" />
<div className="absolute left-1/2 top-14 -z-10 h-72 w-72 -translate-x-1/2 rounded-full bg-emerald-400/20 blur-3xl" />
<div className="mx-auto grid min-h-[calc(100vh-96px)] max-w-7xl items-center gap-12 px-6 py-14 lg:grid-cols-[1.05fr_0.95fr] lg:px-8">
<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">
<span className="h-2 w-2 rounded-full bg-[#facc15]" />
Interactive predictor for your friend group
</div>
<h1 className="max-w-4xl text-5xl font-black tracking-tight sm:text-6xl lg:text-7xl">
From first whistle to champion pick.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
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.
</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<Link href="/world-cup-predictor" className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#22c55e] px-7 py-4 text-base font-black text-[#07111f] shadow-2xl shadow-emerald-500/25 transition hover:-translate-y-1 hover:bg-[#86efac]">
<BaseIcon path={mdiSoccer} size={22} />
Start predicting
</Link>
<Link href="/login" className="inline-flex items-center justify-center gap-2 rounded-2xl border border-white/15 bg-white/10 px-7 py-4 text-base font-black text-white backdrop-blur transition hover:-translate-y-1 hover:bg-white/15">
Login / Admin
</Link>
</div>
<div className="mt-10 grid max-w-2xl grid-cols-3 gap-3">
{[
['12', 'Groups'],
['72', 'Group games'],
['31', 'Knockouts'],
].map(([value, label]) => (
<div key={label} className="rounded-3xl border border-white/10 bg-white/10 p-4 backdrop-blur">
<p className="text-3xl font-black text-[#facc15]">{value}</p>
<p className="mt-1 text-sm font-bold text-slate-300">{label}</p>
</div>
))}
</div>
</div>
<div className="relative">
<div className="absolute -inset-4 rounded-[2rem] bg-gradient-to-br from-emerald-400/25 to-yellow-300/20 blur-2xl" />
<div className="relative overflow-hidden rounded-[2rem] border border-white/15 bg-white/10 p-5 shadow-2xl backdrop-blur-xl">
<div className="rounded-[1.5rem] bg-[#07111f]/90 p-5">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<p className="text-xs font-black uppercase tracking-[0.25em] text-emerald-300">Live bracket preview</p>
<h2 className="mt-1 text-2xl font-black">Your route to glory</h2>
</div>
<span className="rounded-full bg-[#facc15] px-3 py-1 text-xs font-black text-[#07111f]">MVP</span>
</div>
<div className="mt-5 space-y-4">
{['Groups AL completed', 'Top teams qualify', 'Round of 32 unlocked', 'Champion prediction saved'].map((item, index) => (
<div key={item} className="flex items-center gap-4 rounded-2xl bg-white/8 p-4">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-emerald-400/20 text-sm font-black text-emerald-200">0{index + 1}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-gradient-to-r from-[#22c55e] to-[#facc15]" style={{ width: `${100 - index * 18}%` }} />
</div>
<span className="w-36 text-right text-sm font-bold text-slate-200">{item}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</section>
<section className="bg-[#f8fafc] px-6 py-20 text-slate-950 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="max-w-2xl">
<p className="text-sm font-black uppercase tracking-[0.24em] text-emerald-600">First playable slice</p>
<h2 className="mt-3 text-4xl font-black tracking-tight">A real workflow, not just a landing page.</h2>
<p className="mt-4 text-lg leading-8 text-slate-600">
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.
</p>
</div>
<div className="mt-10 grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{featureCards.map((feature) => (
<article key={feature.title} className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-xl shadow-slate-200/60">
<div className="mb-5 flex h-13 w-13 items-center justify-center rounded-2xl bg-emerald-50 text-emerald-700">
<BaseIcon path={feature.icon} size={28} />
</div>
<h3 className="text-xl font-black">{feature.title}</h3>
<p className="mt-3 text-sm leading-6 text-slate-600">{feature.text}</p>
</article>
))}
</div>
</div>
</section>
</main>
<footer className="border-t border-white/10 bg-[#07111f] px-6 py-8 text-sm text-slate-400 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p>© 2026 WC 2026 Friends Betting. Built for friendly prediction pools.</p>
<div className="flex gap-4">
<Link className="hover:text-white" href="/privacy-policy/">Privacy Policy</Link>
<Link className="hover:text-white" href="/login">Login</Link>
</div>
</div>
</footer>
</div>
);
}
@ -163,4 +166,3 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -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 AL 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 3rds</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 73104, 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;