diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..3fb491164 Binary files /dev/null and b/.DS_Store differ diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..b1c3c3bf5 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "PowerShell(Add-Type -AssemblyName System.Drawing; $img = [System.Drawing.Image]::FromFile\\('c:\\\\Users\\\\shalo\\\\MatchLens\\\\Code\\\\client\\\\src\\\\assets\\\\ball.png'\\); \"$\\($img.Width\\)x$\\($img.Height\\)\")", + "Bash(chromium-cli --help)", + "Bash(npx --yes playwright --version)", + "Bash(curl -s http://localhost:5173/ -o /dev/null -w \"%{http_code}\\\\n\")", + "Bash(npx playwright *)", + "mcp__claude_ai_Figma__get_design_context", + "Bash(python3 -c ' *)", + "Bash(taskkill //PID 27472 //F)", + "Bash(git status *)", + "Bash(powershell.exe -NoProfile -Command \"\\(Get-Content 'Code/client/src/pages/Leaderboard.jsx' -Raw\\) -replace 'dash-mlx','primary' | Set-Content -NoNewline -Encoding utf8 'Code/client/src/pages/Leaderboard.jsx'\")" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..d63dcac3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +Code/CLAUDE.md +planning/frontend_audit.md +Code/CLAUDE.md +planning/frontend_audit.md diff --git a/Code/.gitignore b/Code/.gitignore new file mode 100644 index 000000000..7a70a85ec --- /dev/null +++ b/Code/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# vite build output (client vite.config.js builds to ../server/public) +server/public diff --git a/Code/client/.env.example b/Code/client/.env.example new file mode 100644 index 000000000..939b3716c --- /dev/null +++ b/Code/client/.env.example @@ -0,0 +1,13 @@ +# API origin. Leave unset for both local dev (the Vite proxy forwards /api) and +# production (the client build is served by Express itself, so same-origin). +# Only needed if the client and API are deployed to different origins. +# +# NOTE: VITE_* values are embedded into the public client bundle. Never secrets. +VITE_API_URL= + +# Cloudinary unsigned upload preset — used for real avatar uploads on the +# Profile page. No backend involvement: the browser uploads directly to +# Cloudinary using these two public values. Fill in your own account's +# cloud name and unsigned upload preset name. +VITE_CLOUDINARY_CLOUD_NAME= +VITE_CLOUDINARY_UPLOAD_PRESET= diff --git a/Code/client/index.html b/Code/client/index.html new file mode 100644 index 000000000..bc06331d0 --- /dev/null +++ b/Code/client/index.html @@ -0,0 +1,16 @@ + + + + + + MatchLens + + + + + + +
+ + + diff --git a/Code/client/postcss.config.js b/Code/client/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/Code/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/Code/client/public/favicon.png b/Code/client/public/favicon.png new file mode 100644 index 000000000..a6828db96 Binary files /dev/null and b/Code/client/public/favicon.png differ diff --git a/Code/client/src/App.jsx b/Code/client/src/App.jsx new file mode 100644 index 000000000..71b2e45ad --- /dev/null +++ b/Code/client/src/App.jsx @@ -0,0 +1,40 @@ +import { Routes, Route } from 'react-router-dom' +import RequireAuth from './components/RequireAuth' +import Sidebar from './components/Sidebar' +import Home from './pages/Home' +import Matches from './pages/Matches' +import MatchDetail from './pages/MatchDetail' +import Discover from './pages/Discover' +import TeamDetail from './pages/TeamDetail' +import PlayerDetail from './pages/PlayerDetail' +import Leaderboard from './pages/Leaderboard' +import Profile from './pages/Profile' +import Login from './pages/Login' +import Signup from './pages/Signup' +import NotFound from './pages/NotFound' + +export default function App() { + return ( + + } /> + } /> + + {/* Everything below the guard needs a live server session. RequireAuth + bounces anonymous visitors to /login before the layout renders. */} + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } /> + + ) +} diff --git a/Code/client/src/assets/ball-frame.svg b/Code/client/src/assets/ball-frame.svg new file mode 100644 index 000000000..342f521fb --- /dev/null +++ b/Code/client/src/assets/ball-frame.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Code/client/src/assets/ball.png b/Code/client/src/assets/ball.png new file mode 100644 index 000000000..a6fc8c6e4 Binary files /dev/null and b/Code/client/src/assets/ball.png differ diff --git a/Code/client/src/components/AuthFields.jsx b/Code/client/src/components/AuthFields.jsx new file mode 100644 index 000000000..89c717c76 --- /dev/null +++ b/Code/client/src/components/AuthFields.jsx @@ -0,0 +1,76 @@ +import { useState } from 'react' + +function FieldShell({ children }) { + return ( +
+ {children} +
+ ) +} + +export function TextField({ id, label, type = 'text', placeholder, value, onChange, autoComplete }) { + return ( +
+ + + + +
+ ) +} + +export function PasswordField({ id, label, placeholder, value, onChange, autoComplete }) { + const [visible, setVisible] = useState(false) + + return ( +
+ + + + + +
+ ) +} + +function EyeIcon({ revealed }) { + return ( + + ) +} diff --git a/Code/client/src/components/AuthLayout.jsx b/Code/client/src/components/AuthLayout.jsx new file mode 100644 index 000000000..3af245cd2 --- /dev/null +++ b/Code/client/src/components/AuthLayout.jsx @@ -0,0 +1,78 @@ +import { Link } from 'react-router-dom' +import { AUTH_ORIGIN } from '../config/api' +import GitHubMark from './GitHubMark' +import ballMark from '../assets/ball.png' +import ballFrame from '../assets/ball-frame.svg' + +const AUTH_URL = `${AUTH_ORIGIN}/auth` + +// Shared shell for /login and /signup — the two-column layout, brand marks, +// GitHub button and footer are pixel-identical between the two Figma frames; +// only the heading copy, form fields and spacing differ per page. +export default function AuthLayout({ + title, + subtitle, + paddingClassName, + gapClassName, + footerPrompt, + footerLinkLabel, + footerLinkTo, + children, +}) { + return ( +
+
+
+ +

+ MatchLens +

+
+ +
+
+

{title}

+

{subtitle}

+
+ + {children} + +
+
+

OR CONTINUE WITH

+
+
+ + + + Continue with GitHub + + +

+ {footerPrompt} {footerLinkLabel} +

+
+ +
+

Terms of Service  ·  Privacy Policy  ·  Help Center

+

v2.4.0-Stable  ·  Systems Operational

+
+
+ +
+
+ +

+ MatchLens +

+

Your ultimate arena companion

+
+
+
+ ) +} diff --git a/Code/client/src/components/Avatar.jsx b/Code/client/src/components/Avatar.jsx new file mode 100644 index 000000000..98321fdb3 --- /dev/null +++ b/Code/client/src/components/Avatar.jsx @@ -0,0 +1,33 @@ +import { getInitials, paletteFor } from '../utilities/monogram' + +// Drop-in replacement for the old `bg-white/10` avatar/photo blocks. Renders +// the real image when a URL is available (e.g. `profile_image_url`); +// otherwise falls back to a deterministic initials badge instead of a blank +// grey block — used for user avatars and player photos alike, since neither +// has a guaranteed image source. +export default function Avatar({ name, src, className = '', textClassName = 'text-[13px]' }) { + const safeName = name || '?' + + if (src) { + return ( +
+ ) + } + + const palette = paletteFor(safeName) + + return ( + + ) +} diff --git a/Code/client/src/components/CommentThread.jsx b/Code/client/src/components/CommentThread.jsx new file mode 100644 index 000000000..7bcc36bd4 --- /dev/null +++ b/Code/client/src/components/CommentThread.jsx @@ -0,0 +1,33 @@ +import Avatar from './Avatar' + +// Static placeholder pending real comment data + posting (Match Comments, +// #7). No Figma reference exists for this yet, so it's a minimal mock +// list matching the app's existing card styling — swap the mock array +// and wire the input up when that's ready. +const mockComments = [ + { id: 1, author: 'AlbicelesteArmy', text: 'What a finish from Álvarez, that lead feels safe now.' }, + { id: 2, author: 'LesBleusForever', text: "Mbappé's equalizer came out of nowhere, still anyone's game." }, + { id: 3, author: 'MessiFanatic', text: 'Midfield control has been all Argentina since the 60th minute.' }, +] + +export default function CommentThread() { + return ( +
+

💬 Match Comments

+
+ {mockComments.map((comment) => ( +
+ +
+

{comment.author}

+

{comment.text}

+
+
+ ))} +
+
+

Add a comment...

+
+
+ ) +} diff --git a/Code/client/src/components/Crest.jsx b/Code/client/src/components/Crest.jsx new file mode 100644 index 000000000..1400e13dd --- /dev/null +++ b/Code/client/src/components/Crest.jsx @@ -0,0 +1,21 @@ +import { paletteFor, shortLabel } from '../utilities/monogram' + +// Drop-in replacement for the old `bg-white/10` crest blocks — pass the same +// size/shape classes via `className`. No team, match, or player endpoint +// returns a crest/badge image, so this is the honest fallback: a +// deterministic initials badge, never an invented logo. +// `compact` hides the label for slots too small to render legible text. +export default function Crest({ label, compact = false, className = '', textClassName = 'text-[11px]' }) { + const safeLabel = label || '?' + const palette = paletteFor(safeLabel) + + return ( + + ) +} diff --git a/Code/client/src/components/GitHubMark.jsx b/Code/client/src/components/GitHubMark.jsx new file mode 100644 index 000000000..e14915cd8 --- /dev/null +++ b/Code/client/src/components/GitHubMark.jsx @@ -0,0 +1,14 @@ +// Inline rather than an icon package — it's the only icon the app needs, and +// currentColor lets it inherit the button's text colour on hover. +const GitHubMark = () => ( + +) + +export default GitHubMark diff --git a/Code/client/src/components/MatchCard.jsx b/Code/client/src/components/MatchCard.jsx new file mode 100644 index 000000000..e10be8c8b --- /dev/null +++ b/Code/client/src/components/MatchCard.jsx @@ -0,0 +1,44 @@ +import { Link } from 'react-router-dom' +import Crest from './Crest' + +function formatDate(dateString) { + return new Date(dateString).toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + }) +} + +export default function MatchCard({ match }) { + const { id, home, away, home_score, away_score, status, minute, date } = match + const isLive = status === 'LIVE' + + return ( + +
+

{formatDate(date)}

+

+ {isLive && minute != null ? `LIVE ${minute}'` : status} +

+
+
+
+ +

{home}

+
+
+

+ {home_score ?? '–'} - {away_score ?? '–'} +

+
+
+ +

{away}

+
+
+ + ) +} diff --git a/Code/client/src/components/PlayerCard.jsx b/Code/client/src/components/PlayerCard.jsx new file mode 100644 index 000000000..f8d0e4a4e --- /dev/null +++ b/Code/client/src/components/PlayerCard.jsx @@ -0,0 +1,26 @@ +import { Link } from 'react-router-dom' +import Avatar from './Avatar' + +export default function PlayerCard({ player }) { + const { id, name, team, position, goals, assists } = player + + return ( + + +
+

{team}

+

{name}

+

+ {position} • {assists} assists +

+
+
+

Goals:

+

{goals}

+
+ + ) +} diff --git a/Code/client/src/components/RequireAuth.jsx b/Code/client/src/components/RequireAuth.jsx new file mode 100644 index 000000000..8a61cbb6a --- /dev/null +++ b/Code/client/src/components/RequireAuth.jsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react' +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import AuthAPI from '../services/AuthAPI' + +// Route guard for everything behind sign-in. +// +// The server session is the source of truth for every sign-in path, so this +// always revalidates. localStorage is only a hint that lets the first paint +// happen immediately — trusting it outright left a stale entry rendering the +// app as logged-in while every request was actually unauthenticated. +export default function RequireAuth() { + const location = useLocation() + + // 'optimistic' paints the app straight away on the strength of the stored + // hint, then downgrades to 'anonymous' if the server disagrees. Without a + // hint there is nothing to paint, so block on the check instead of flashing + // the dashboard at someone who is about to be redirected. + const [status, setStatus] = useState(() => + localStorage.getItem('matchlens_user') ? 'optimistic' : 'checking' + ) + + useEffect(() => { + let active = true + + AuthAPI.getSession().then((user) => { + if (!active) return + + if (user) { + localStorage.setItem('matchlens_user', JSON.stringify(user)) + setStatus('authed') + } else { + localStorage.removeItem('matchlens_user') + setStatus('anonymous') + } + }) + + return () => { active = false } + }, []) + + if (status === 'checking') return null + + // `from` lets the login page send the user back where they were aiming. + if (status === 'anonymous') { + return + } + + return +} diff --git a/Code/client/src/components/Sidebar/index.jsx b/Code/client/src/components/Sidebar/index.jsx new file mode 100644 index 000000000..c8addb152 --- /dev/null +++ b/Code/client/src/components/Sidebar/index.jsx @@ -0,0 +1,212 @@ +import { useEffect, useState } from 'react' +import { NavLink, Outlet, useNavigate } from 'react-router-dom' +import { useSessionUser } from '../../hooks/useSessionUser' +import AuthAPI from '../../services/AuthAPI' +import Avatar from '../Avatar' +import Crest from '../Crest' +import Skeleton from '../Skeleton' +import ballMark from '../../assets/ball.png' + +const API_URL = import.meta.env.VITE_API_URL ?? '' + +const menuItems = [ + { label: 'Dashboard', to: '/', end: true, icon: DashboardIcon }, + { label: 'Live Football', to: '/matches', end: false, icon: LiveIcon }, + { label: 'Standings', to: '/leaderboard', end: false, icon: StandingsIcon }, + { label: 'Highlights', to: '/discover', end: false, icon: HighlightsIcon }, +] + +function DashboardIcon({ className }) { + return ( + + + + + + + ) +} + +function LiveIcon({ className }) { + return ( + + + + + ) +} + +function StandingsIcon({ className }) { + return ( + + + + ) +} + +function HighlightsIcon({ className }) { + return ( + + + + ) +} + +function SearchIcon({ className }) { + return ( + + + + + ) +} + +function SignOutIcon({ className }) { + return ( + + + + + ) +} + +export default function Sidebar() { + const navigate = useNavigate() + const user = useSessionUser() + const [followedTeams, setFollowedTeams] = useState([]) + const [followsLoading, setFollowsLoading] = useState(true) + const [followsError, setFollowsError] = useState(null) + + useEffect(() => { + if (!user?.id) { + setFollowsLoading(false) + return + } + setFollowsLoading(true) + setFollowsError(null) + fetch(`${API_URL}/api/follows/user/${user.id}`) + .then((res) => { + if (!res.ok) throw new Error('Failed to load followed teams') + return res.json() + }) + .then(setFollowedTeams) + .catch((err) => { + console.error('Failed to load followed teams', err) + setFollowsError('Could not load followed teams.') + }) + .finally(() => setFollowsLoading(false)) + }, [user?.id]) + + // AuthAPI.logout clears localStorage itself. The server also has to destroy + // the session — clearing only localStorage left the cookie alive, so the next + // visit picked the session back up and silently signed you in again. + const handleSignOut = async () => { + try { + await AuthAPI.logout() + } finally { + navigate('/login', { replace: true }) + } + } + + return ( +
+ + +
+ +
+
+ ) +} diff --git a/Code/client/src/components/Skeleton.jsx b/Code/client/src/components/Skeleton.jsx new file mode 100644 index 000000000..00b5763e4 --- /dev/null +++ b/Code/client/src/components/Skeleton.jsx @@ -0,0 +1,5 @@ +// Shared pulsing placeholder for loading states — pass sizing/shape classes +// via `className` the same way callers size any other block-level element. +export default function Skeleton({ className = '' }) { + return
+} diff --git a/Code/client/src/components/StandingsTable.jsx b/Code/client/src/components/StandingsTable.jsx new file mode 100644 index 000000000..6e9cd8db3 --- /dev/null +++ b/Code/client/src/components/StandingsTable.jsx @@ -0,0 +1,99 @@ +import { Link } from 'react-router-dom' +import Crest from './Crest' + +function LeaderboardTable({ rows }) { + return ( +
+
+

RANK

+

USERNAME

+

LOCATION / TITLE

+

POINTS

+

TOP TEAM

+

TREND

+
+ {rows.map((row) => { + const isTopThree = row.rank <= 3 + return ( +
+
+ {isTopThree && 🏅} +

{row.rank}

+
+

{row.username}

+
+

{row.location}

+

{row.title}

+
+

+ {row.points.toLocaleString()} +

+
+ +

{row.topTeam}

+
+
+ {row.trend === 'up' ? ( + + ) : ( + + )} +
+
+ ) + })} +
+ ) +} + +export default function StandingsTable({ teams, variant = 'teams', rows }) { + if (variant === 'leaderboard') { + return + } + + const sorted = [...teams].sort((a, b) => b.points - a.points) + + return ( +
+
+

RANK

+

TEAM

+

W

+

D

+

L

+

PTS

+
+ {sorted.map((team, index) => { + const rank = index + 1 + const isFirst = rank === 1 + return ( +
+

+ {rank} +

+ + +

{team.name}

+ +

{team.wins}

+

{team.draws}

+

{team.losses}

+

+ {team.points} +

+
+ ) + })} +
+ ) +} diff --git a/Code/client/src/components/StatBar.jsx b/Code/client/src/components/StatBar.jsx new file mode 100644 index 000000000..b77d0d0d4 --- /dev/null +++ b/Code/client/src/components/StatBar.jsx @@ -0,0 +1,19 @@ +export default function StatBar({ label, homeValue, awayValue }) { + const total = homeValue + awayValue + const homePercent = total > 0 ? (homeValue / total) * 100 : 50 + const awayPercent = 100 - homePercent + + return ( +
+
+

{homeValue}

+

{label}

+

{awayValue}

+
+
+
+
+
+
+ ) +} diff --git a/Code/client/src/components/TeamCard.jsx b/Code/client/src/components/TeamCard.jsx new file mode 100644 index 000000000..4872f997d --- /dev/null +++ b/Code/client/src/components/TeamCard.jsx @@ -0,0 +1,27 @@ +import { Link } from 'react-router-dom' +import Crest from './Crest' + +export default function TeamCard({ team, isFollowing, onToggleFollow }) { + return ( +
+ +
+ +
+
+

{team.name}

+

{team.region}

+
+ + +
+ ) +} diff --git a/Code/client/src/components/VideoPlayer.jsx b/Code/client/src/components/VideoPlayer.jsx new file mode 100644 index 000000000..039840cbb --- /dev/null +++ b/Code/client/src/components/VideoPlayer.jsx @@ -0,0 +1,15 @@ +// Static placeholder pending the real video embed (Video Highlights, #8). +// Swap the contents of the thumbnail block for a real player/embed — +// the surrounding title prop and layout can stay as-is. +export default function VideoPlayer({ title = 'Match Highlights' }) { + return ( +
+
+
+ +
+
+

{title}

+
+ ) +} diff --git a/Code/client/src/config/api.js b/Code/client/src/config/api.js new file mode 100644 index 000000000..897b08191 --- /dev/null +++ b/Code/client/src/config/api.js @@ -0,0 +1,16 @@ +// Where the client talks to the API. +// +// Two constants because the two route families reach the server differently. + +// /api/* is same-origin in both environments: the Vite proxy forwards it in +// dev, and Express serves the built client itself in prod. A relative path is +// therefore correct everywhere, and only a split deploy needs VITE_API_URL. +export const API_URL = import.meta.env.VITE_API_URL ?? '' + +// /auth/* is not proxied. The GitHub OAuth round trip has to come back to the +// API's own origin for the session cookie to be set there, so in dev these +// calls have to name that origin explicitly — which also makes them +// cross-origin, which is why AuthAPI sends credentials. In prod the client is +// served by the same Express process, so same-origin applies again. +export const AUTH_ORIGIN = + import.meta.env.VITE_API_URL ?? (import.meta.env.PROD ? '' : 'http://localhost:3000') diff --git a/Code/client/src/hooks/useSessionUser.js b/Code/client/src/hooks/useSessionUser.js new file mode 100644 index 000000000..b9f71a56c --- /dev/null +++ b/Code/client/src/hooks/useSessionUser.js @@ -0,0 +1,15 @@ +import { useState } from 'react' + +// RequireAuth stores the session user here once the guard resolves (see +// components/RequireAuth.jsx), so any page under it can read the id/ +// username/avatar for free instead of re-fetching /auth/login/success. +export function useSessionUser() { + const [user] = useState(() => { + try { + return JSON.parse(localStorage.getItem('matchlens_user')) + } catch { + return null + } + }) + return user +} diff --git a/Code/client/src/hooks/useTeamSearch.js b/Code/client/src/hooks/useTeamSearch.js new file mode 100644 index 000000000..1acfa4b12 --- /dev/null +++ b/Code/client/src/hooks/useTeamSearch.js @@ -0,0 +1,15 @@ +import { useState } from 'react' + +// Search (#6, owned by rijulpoudel). State is real so the search input +// and group tabs are interactive, but filteredTeams is a pass-through — +// implement the real search + group filtering below without touching +// Discover.jsx's layout. +export function useTeamSearch(teams) { + const [searchTerm, setSearchTerm] = useState('') + const [activeGroup, setActiveGroup] = useState('All') + + // TODO(rijulpoudel): real search + group filtering + const filteredTeams = teams + + return { searchTerm, setSearchTerm, activeGroup, setActiveGroup, filteredTeams } +} diff --git a/Code/client/src/index.css b/Code/client/src/index.css new file mode 100644 index 000000000..d81d06477 --- /dev/null +++ b/Code/client/src/index.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply font-sans bg-dashboard text-white; + } +} diff --git a/Code/client/src/main.jsx b/Code/client/src/main.jsx new file mode 100644 index 000000000..40bea91ac --- /dev/null +++ b/Code/client/src/main.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + +) diff --git a/Code/client/src/mocks/dashboardMocks.js b/Code/client/src/mocks/dashboardMocks.js new file mode 100644 index 000000000..2f31d1ddf --- /dev/null +++ b/Code/client/src/mocks/dashboardMocks.js @@ -0,0 +1,23 @@ +// The knockout bracket and per-match statistics below have no backing table +// or endpoint anywhere in Code/server — they're fixtures, not stand-ins for +// data that will later be fetched. + +export const knockoutBracket = { + quarterLeft: [ + { home: 'FRA', away: 'MAR' }, + { home: 'ESP', away: 'BEL' }, + ], + semiLeft: { home: 'FRA', away: 'ESP' }, + final: { home: 'FRA', away: 'GER', venue: 'Boston Arena' }, + semiRight: { home: 'GER', away: 'ENG' }, + quarterRight: [ + { home: 'GER', away: 'NOR' }, + { home: 'ENG', away: 'ARG' }, + ], +} + +export const matchStatistics = [ + { label: 'Shots on Target', homeValue: 7, awayValue: 3 }, + { label: 'Total Shots', homeValue: 12, awayValue: 7 }, + { label: 'Fouls', homeValue: 8, awayValue: 11 }, +] diff --git a/Code/client/src/mocks/leaderboard.js b/Code/client/src/mocks/leaderboard.js new file mode 100644 index 000000000..cf9cc72c7 --- /dev/null +++ b/Code/client/src/mocks/leaderboard.js @@ -0,0 +1,17 @@ +// Placeholder fixture for the Fan Leaderboard page, pending a real +// leaderboard endpoint. The existing /api/users only returns +// { id, username, email, points } — no location/title/team/trend +// concept at all, so this stays mocked. Rows straight from the Figma +// table (usernames, locations, titles, points, teams, trends). +export const leaderboard = [ + { rank: 1, username: 'TacticalPro_99', location: 'UK', title: 'Elite Scout', points: 24580, topTeam: 'Brazil', trend: 'up' }, + { rank: 2, username: 'GoalMachine', location: 'São Paulo, Brazil', title: 'Master Analyst', points: 22340, topTeam: 'Argentina', trend: 'up' }, + { rank: 3, username: 'FútbolGenius', location: 'Madrid, Spain', title: 'Rising Star', points: 21100, topTeam: 'Spain', trend: 'down' }, + { rank: 4, username: 'DerKaiser', location: 'Munich, Germany', title: 'Elite Scout', points: 19870, topTeam: 'Germany', trend: 'up' }, + { rank: 5, username: 'LesBleus_Fan', location: 'Paris, France', title: 'Veteran', points: 18450, topTeam: 'France', trend: 'up' }, + { rank: 6, username: 'AzzurriFaith', location: 'Milan, Italy', title: 'Veteran', points: 17200, topTeam: 'Italy', trend: 'down' }, + { rank: 7, username: 'SambaBoy', location: 'Rio, Brazil', title: 'Analyst', points: 16890, topTeam: 'Brazil', trend: 'up' }, + { rank: 8, username: 'OranjeHope', location: 'Amsterdam, Netherlands', title: 'Rising Star', points: 15340, topTeam: 'Netherlands', trend: 'down' }, + { rank: 9, username: 'ThreeLions', location: 'London, UK', title: 'Analyst', points: 14900, topTeam: 'England', trend: 'up' }, + { rank: 10, username: 'LaAlbiceleste', location: 'Buenos Aires', title: 'Scout', points: 14200, topTeam: 'Argentina', trend: 'up' }, +] diff --git a/Code/client/src/mocks/playerDetail.js b/Code/client/src/mocks/playerDetail.js new file mode 100644 index 000000000..e1a360270 --- /dev/null +++ b/Code/client/src/mocks/playerDetail.js @@ -0,0 +1,65 @@ +// Placeholder fixture for the Player Detail page, pending a real +// /players/:id detail endpoint. The existing /api/players only has +// { id, name, team, position, goals, assists } — nothing close to +// bio/season-stats/MatchLens-score/market-value/career-history/ +// attributes, so this page's data has to stay mocked. Owned by ticket +// #1 (shared), static layout only. getMockPlayerDetail overlays the +// requested :playerId onto it so the page still reflects the route +// param while the content stays static. + +const basePlayer = { + name: 'Martin Ødegaard', + position: 'Midfielder', + club: 'Arsenal FC', + clubNumber: 8, + nationalTeam: 'Norway National Team 🇳🇴 (2026 World Cup Squad)', + age: 25, + born: 'Drammen, Norway', + height: '178cm', + preferredFoot: 'Left', + seasonLabel: '2025/26 Season', + seasonStats: { + goals: 11, + assists: 14, + appearances: 32, + passAccuracy: '91%', + keyPasses: 3.8, + chancesCreated: 67, + }, + matchLensScore: 94.2, + scoreBlurb: '99th percentile for progressive passes among European midfielders.', + scoreBadge: 'Top 1% Creative Midfielders in Europe', + marketValue: '€95.0M', + marketValuePrevious: '€82M', + matchRating: 8.1, + careerHistory: [ + { season: '2025/26', club: 'Arsenal', apps: 32, goals: 11, assists: 14, rating: 8.1 }, + { season: '2024/25', club: 'Arsenal', apps: 35, goals: 8, assists: 12, rating: 7.9 }, + { season: '2023/24', club: 'Arsenal', apps: 28, goals: 7, assists: 10, rating: 7.8 }, + { season: '2022/23', club: 'Arsenal', apps: 36, goals: 15, assists: 8, rating: 8.3 }, + { season: '2021/22', club: 'Arsenal', apps: 33, goals: 7, assists: 4, rating: 7.2 }, + { season: '2019/20', club: 'Real Madrid', apps: 7, goals: 0, assists: 2, rating: 6.4 }, + ], + attributes: [ + { label: 'Vision', value: 96 }, + { label: 'Technique', value: 91 }, + { label: 'Passing', value: 92 }, + { label: 'Tactical IQ', value: 98 }, + { label: 'Dribbling', value: 88 }, + { label: 'Leadership', value: 85 }, + ], + aiNote: + 'Ødegaard is among the most progressive passers in world football. His ability to receive between the lines and play incisive through-balls makes him the creative heartbeat of Arsenal. In the 2026 World Cup, he will be the key orchestrator for Norway, tasked with unlocking defenses in a tough Group B alongside Spain and Brazil.', + groupLabel: 'Group B Standings', + groupStandings: [ + { name: 'Spain 🇪🇸', points: 6 }, + { name: 'Brazil 🇧🇷', points: 4 }, + { name: 'Norway 🇳🇴', points: 3 }, + { name: 'Morocco 🇲🇦', points: 1 }, + ], + internationalRecord: { caps: 62, goals: 9, assists: 18 }, +} + +export function getMockPlayerDetail(playerId) { + return { ...basePlayer, id: playerId } +} diff --git a/Code/client/src/mocks/teamDetail.js b/Code/client/src/mocks/teamDetail.js new file mode 100644 index 000000000..3f4708351 --- /dev/null +++ b/Code/client/src/mocks/teamDetail.js @@ -0,0 +1,44 @@ +// Placeholder fixture for the Team Detail page, pending a real +// /teams/:id detail endpoint. The existing /api/teams only has +// { name, league, played, wins, draws, losses, goals_scored, points } +// for 3 club teams — no badge/stadium/form/fixtures/roster concept at +// all, so this page's data has to stay mocked, same reasoning as the +// Match Detail page. getMockTeamDetail overlays the requested :teamId +// onto it so the page still reflects the route param. + +const baseTeam = { + name: 'Argentina', + tagline: 'FIFA World Cup 2026 · 3x Champions (1978, 1986, 2022)', + meta: '🏟 Multiple Venues (USA, Canada & Mexico) · 📍 South America · 👥 8.2M Followers', + isFollowing: true, + groupLabel: 'Group C Standings', + recentForm: [ + { result: 'W', score: '3-0' }, + { result: 'W', score: '2-1' }, + { result: 'W', score: '1-0' }, + { result: 'L', score: '0-1' }, + { result: 'W', score: '2-0' }, + ], + metrics: { goalDifference: '+7 GD', goalsScored: 10, goalsConceded: 3 }, + upcomingFixtures: [ + { date: 'Sat, 19 Jul', time: '18:00', homeTeam: 'Argentina', awayTeam: 'France', venue: 'MetLife Stadium, New Jersey' }, + { date: 'Wed, 23 Jul', time: '20:00', homeTeam: 'Winner QF1', awayTeam: 'Argentina', venue: 'AT&T Stadium, Dallas' }, + { date: 'Sun, 27 Jul', time: '18:00', homeTeam: 'Semi-Final TBD', awayTeam: 'Argentina', venue: 'MetLife Stadium' }, + ], + standings: [ + { rank: 1, name: 'Argentina', points: 9 }, + { rank: 2, name: 'Poland', points: 4 }, + { rank: 3, name: 'Saudi Arabia', points: 3 }, + ], + roster: [ + { id: 1, name: 'Lionel Messi', position: 'CAM', goals: 4, assists: 3 }, + { id: 2, name: 'Julián Álvarez', position: 'ST', goals: 3, assists: 1 }, + { id: 3, name: 'Enzo Fernández', position: 'CM', goals: 1, assists: 2 }, + { id: 4, name: 'Emiliano Martínez', position: 'GK', goals: 0, assists: 0 }, + { id: 5, name: 'Cristian Romero', position: 'CB', goals: 1, assists: 0 }, + ], +} + +export function getMockTeamDetail(teamId) { + return { ...baseTeam, id: teamId } +} diff --git a/Code/client/src/mocks/teams.js b/Code/client/src/mocks/teams.js new file mode 100644 index 000000000..d08c97383 --- /dev/null +++ b/Code/client/src/mocks/teams.js @@ -0,0 +1,19 @@ +// Placeholder fixture for a teams-directory endpoint that doesn't exist +// yet — the real /api/teams only covers 3 club teams in one league, not +// World Cup national teams grouped by confederation/group. Names, +// regions, and initial follow state come straight from the Figma frame; +// `group` isn't shown per-team there, just assigned round-robin so the +// Group A–F filter tabs have something to eventually filter against. +// `api_team_id` is a string (not `id`) to match the real followed_teams +// table's VARCHAR(50) column, since this list feeds Profile's real +// POST /api/follows call. +export const teams = [ + { api_team_id: '1', name: 'Argentina', region: 'South America', group: 'A', isFollowing: true }, + { api_team_id: '2', name: 'Brazil', region: 'South America', group: 'B', isFollowing: false }, + { api_team_id: '3', name: 'Germany', region: 'Europe', group: 'C', isFollowing: false }, + { api_team_id: '4', name: 'France', region: 'Europe', group: 'D', isFollowing: false }, + { api_team_id: '5', name: 'England', region: 'Europe', group: 'E', isFollowing: true }, + { api_team_id: '6', name: 'Spain', region: 'Europe', group: 'F', isFollowing: false }, + { api_team_id: '7', name: 'Netherlands', region: 'Europe', group: 'A', isFollowing: false }, + { api_team_id: '8', name: 'USA', region: 'North America', group: 'B', isFollowing: false }, +] diff --git a/Code/client/src/pages/Discover.jsx b/Code/client/src/pages/Discover.jsx new file mode 100644 index 000000000..fd6164bd5 --- /dev/null +++ b/Code/client/src/pages/Discover.jsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react' +import TeamCard from '../components/TeamCard' +import { useSessionUser } from '../hooks/useSessionUser' +import { useTeamSearch } from '../hooks/useTeamSearch' +import { teams } from '../mocks/teams' + +const API_URL = import.meta.env.VITE_API_URL ?? '' + +const TRENDING_TAGS = ['Lionel Messi', 'Kylian Mbappé', 'World Cup 2026', 'Argentina', 'France'] + +const GROUP_TABS = [ + { label: 'All', value: 'All' }, + { label: 'Group A', value: 'A' }, + { label: 'Group B', value: 'B' }, + { label: 'Group C', value: 'C' }, + { label: 'Group D', value: 'D' }, + { label: 'Group E', value: 'E' }, + { label: 'Group F', value: 'F' }, +] + +const FOOTER_COLUMNS = [ + { title: 'Explore', links: ['Live Matches', 'Standings', 'Highlights', 'Predictions'] }, + { title: 'Stats', links: ['Player Stats', 'Team Stats', 'Group Standings', 'Head-to-Head Nations'] }, + { title: 'Support', links: ['Help Center', 'Contact Us', 'FAQ', 'Feedback'] }, + { title: 'Legal', links: ['Terms of Service', 'Privacy Policy', 'Data Sources', 'API Access'] }, +] + +function SearchIcon({ className }) { + return ( + + + + + ) +} + +export default function Discover() { + const sessionUser = useSessionUser() + const userId = sessionUser?.id + + const { searchTerm, setSearchTerm, activeGroup, setActiveGroup, filteredTeams } = useTeamSearch(teams) + const [followedTeams, setFollowedTeams] = useState([]) + const [followsLoading, setFollowsLoading] = useState(true) + const [followsLoadError, setFollowsLoadError] = useState(null) + const [followError, setFollowError] = useState(null) + + useEffect(() => { + if (!userId) { + setFollowsLoading(false) + return + } + setFollowsLoading(true) + setFollowsLoadError(null) + fetch(`${API_URL}/api/follows/user/${userId}`) + .then((res) => { + if (!res.ok) throw new Error('Failed to load followed teams') + return res.json() + }) + .then(setFollowedTeams) + .catch((err) => { + console.error('Failed to load followed teams', err) + setFollowsLoadError('Could not load your followed teams — follow status may be out of date.') + }) + .finally(() => setFollowsLoading(false)) + }, [userId]) + + async function handleToggleFollow(team) { + setFollowError(null) + const existing = followedTeams.find((f) => String(f.api_team_id) === String(team.api_team_id)) + try { + if (existing) { + const res = await fetch(`${API_URL}/api/follows/${existing.followed_team_id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Unfollow failed') + setFollowedTeams((prev) => prev.filter((f) => f.followed_team_id !== existing.followed_team_id)) + } else { + const res = await fetch(`${API_URL}/api/follows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, api_team_id: team.api_team_id, team_name: team.name }), + }) + if (!res.ok) throw new Error('Follow failed') + const created = await res.json() + setFollowedTeams((prev) => [...prev, created]) + } + } catch (err) { + setFollowError('Could not update follow status. Please try again.') + } + } + + return ( +
+ {/* Featured banner */} +
+
+
+ + DISCOVER THE WORLD CUP + +

Explore the World Cup 2026

+

+ Find and follow national teams and players competing in the FIFA World Cup 2026. +

+
+
+ + {/* Search and trending */} +
+
+ + setSearchTerm(event.target.value)} + placeholder="Search teams, players, leagues..." + className="w-full bg-transparent text-[13px] font-medium text-white placeholder:text-secondary focus:outline-none" + /> +
+
+

Trending:

+ {TRENDING_TAGS.map((tag) => ( + + {tag} + + ))} +
+
+ + {/* Filter by group */} +
+

Filter by League

+
+ {GROUP_TABS.map((tab) => { + const isActive = tab.value === activeGroup + return ( + + ) + })} +
+
+ + {/* Team grid */} +
+
+

Top Featured Teams

+

SEE ALL

+
+ {followsLoadError &&

{followsLoadError}

} + {followError &&

{followError}

} +
+ {filteredTeams.map((team) => ( + String(f.api_team_id) === String(team.api_team_id))} + onToggleFollow={() => handleToggleFollow(team)} + /> + ))} +
+
+ + {/* Footer */} +
+
+ {FOOTER_COLUMNS.map((column) => ( +
+

{column.title}

+
+ {column.links.map((link) => ( +

+ {link} +

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

+ MatchLens +

+

© 2026 MatchLens Platform. All rights reserved.

+
+
+
+ ) +} diff --git a/Code/client/src/pages/Home.jsx b/Code/client/src/pages/Home.jsx new file mode 100644 index 000000000..583805f72 --- /dev/null +++ b/Code/client/src/pages/Home.jsx @@ -0,0 +1,337 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import MatchCard from '../components/MatchCard' +import PlayerCard from '../components/PlayerCard' +import StandingsTable from '../components/StandingsTable' +import StatBar from '../components/StatBar' +import Crest from '../components/Crest' +import Skeleton from '../components/Skeleton' +import { useSessionUser } from '../hooks/useSessionUser' +import { knockoutBracket, matchStatistics } from '../mocks/dashboardMocks' + +// Falls back to a relative path (same-origin) when unset, so requests +// still work via the Vite proxy in dev and the Express static server in +// prod without ever hardcoding a host. +const API_URL = import.meta.env.VITE_API_URL ?? '' + +function getCountdown(dateString, now) { + const diff = Math.max(0, new Date(dateString).getTime() - now) + return { + days: Math.floor(diff / 86400000), + hours: Math.floor((diff % 86400000) / 3600000), + minutes: Math.floor((diff % 3600000) / 60000), + seconds: Math.floor((diff % 60000) / 1000), + } +} + +function TimeUnit({ value, label }) { + return ( +
+

{String(value).padStart(2, '0')}

+

{label}

+
+ ) +} + +function BracketMatch({ home, away, highlight }) { + return ( +
+

{home}

+

{away}

+
+ ) +} + +async function fetchJson(url) { + const res = await fetch(url) + if (!res.ok) throw new Error(`Request to ${url} failed`) + return res.json() +} + +export default function Home() { + const sessionUser = useSessionUser() + const [matches, setMatches] = useState([]) + const [teams, setTeams] = useState([]) + const [topScorers, setTopScorers] = useState([]) + const [user, setUser] = useState(null) + const [now, setNow] = useState(Date.now()) + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + + Promise.all([ + fetchJson(`${API_URL}/api/matches`), + fetchJson(`${API_URL}/api/teams`), + fetchJson(`${API_URL}/api/players?sort=goals`), + ]) + .then(([matchesData, teamsData, playersData]) => { + if (cancelled) return + setMatches(matchesData) + setTeams(teamsData) + setTopScorers(playersData.slice(0, 3)) + }) + .catch((err) => { + if (cancelled) return + console.error('Failed to load dashboard data', err) + setError('Could not load dashboard data. Please try again.') + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (!sessionUser?.id) return + fetchJson(`${API_URL}/api/users/${sessionUser.id}`) + .then(setUser) + .catch((err) => console.error('Failed to load user', err)) + }, [sessionUser?.id]) + + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(interval) + }, []) + + const upcomingMatch = matches.find((m) => m.status === 'UPCOMING') + const liveMatch = matches.find((m) => m.status === 'LIVE' || m.status === 'HT') + const countdown = upcomingMatch ? getCountdown(upcomingMatch.date, now) : null + + if (error) { + return ( +
+

{error}

+
+ ) + } + + return ( +
+
+ {/* Hero */} + {loading ? ( + + ) : ( +
+
+
+
+ + {upcomingMatch ? 'UPCOMING MAJOR' : 'NO UPCOMING MATCH'} + + {upcomingMatch ? ( + <> +

+ {upcomingMatch.home} vs {upcomingMatch.away} +

+

+ {new Date(upcomingMatch.date).toLocaleDateString('en-GB', { + weekday: 'long', + day: '2-digit', + month: 'long', + year: 'numeric', + })}{' '} + • {upcomingMatch.venue} +

+ + ) : ( +

Check back soon for the next fixture.

+ )} +
+ {upcomingMatch && countdown && ( +
+ + : + + : + + : + +
+ )} +
+
+ )} + + {/* Match ticker */} +
+
+
+

Latest Match

+

Coming Match

+

Pre-season

+

Live Games

+
+

SEE ALL

+
+ {loading ? ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ) : matches.length === 0 ? ( +

No matches scheduled right now.

+ ) : ( +
+ {matches.map((match) => ( + + ))} +
+ )} +
+ + {/* Knockout bracket */} +
+

🏆 World Cup 2026 Knockout Bracket

+
+
+ {knockoutBracket.quarterLeft.map((m, i) => ( + + ))} +
+
+ +
+

Grand Final

+
+

+ {knockoutBracket.final.home} vs {knockoutBracket.final.away} +

+

{knockoutBracket.final.venue}

+
+
+ +
+
+ {knockoutBracket.quarterRight.map((m, i) => ( + + ))} +
+
+
+ + {/* Standings */} +
+
+

🔥 Standings

+

MATCHDAY 24

+
+ {loading ? ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ) : teams.length === 0 ? ( +

No standings available right now.

+ ) : ( + + )} +
+ + {/* Top scorers */} +
+

⚽ Top Scorers

+ {loading ? ( +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ ) : topScorers.length === 0 ? ( +

No player stats available right now.

+ ) : ( +
+ {topScorers.map((player) => ( + + ))} +
+ )} +
+
+ + {/* Live match widget */} + +
+ ) +} diff --git a/Code/client/src/pages/Leaderboard.jsx b/Code/client/src/pages/Leaderboard.jsx new file mode 100644 index 000000000..a17a8b2a8 --- /dev/null +++ b/Code/client/src/pages/Leaderboard.jsx @@ -0,0 +1,120 @@ +import StandingsTable from '../components/StandingsTable' +import { leaderboard } from '../mocks/leaderboard' + +const steps = [ + { title: '1. Predict', body: 'Submit score predictions on World Cup matches' }, + { title: '2. Earn', body: 'Correct predictions earn points. Following a team doubles points.' }, + { title: '3. Climb', body: 'Rise through global rankings and unlock legendary badges.' }, +] + +const recentTopEarners = [ + { username: 'TacticalPro_99', result: '+450 pts', match: 'on Brazil vs Serbia (2-0)' }, + { username: 'GoalMachine', result: '+380 pts', match: 'on Argentina vs France (2-1)' }, + { username: 'DerKaiser', result: '+300 pts', match: 'on Germany vs Japan (3-0)' }, +] + +const predictionHistory = [ + { matchup: 'ARG 3-1 MEX', result: '✅ +320 pts', won: true }, + { matchup: 'FRA vs GER', result: '❌ -200 pts', won: false }, + { matchup: 'ESP 2-0 MAR', result: '✅ +280 pts', won: true }, +] + +export default function Leaderboard() { + return ( +
+
+
+

Fan Leaderboard

+

+ 2026 FIFA World Cup Edition — Compete with fans worldwide +

+
+ + {/* Mechanics banner */} +
+

How To Earn Points & Climb

+
+ {steps.map((step) => ( +
+

{step.title}

+

{step.body}

+
+ ))} +
+
+ + {/* Your stats */} +
+
+

#47

+
+
+
+

Your Predictions Progress

+

8,230 pts

+
+
+
+
+

+ Top Earning Team: Arsenal +

+
+
+ + {/* Global leaderboard */} + +
+ + {/* Right sidebar */} + +
+ ) +} diff --git a/Code/client/src/pages/Login.jsx b/Code/client/src/pages/Login.jsx new file mode 100644 index 000000000..02e71bf30 --- /dev/null +++ b/Code/client/src/pages/Login.jsx @@ -0,0 +1,112 @@ +import { useState, useEffect } from 'react' +import { useNavigate, useSearchParams, useLocation } from 'react-router-dom' +import UsersAPI from '../services/UsersAPI' +import AuthLayout from '../components/AuthLayout' +import { TextField, PasswordField } from '../components/AuthFields' +import { validateLogin } from '../utilities/validateLogin' + +// The OAuth callback can only hand us a short code in the query string, so it +// redirects here with ?error= rather than failing silently. +const OAUTH_ERRORS = { + oauth_denied: 'Sign-in was cancelled.', + oauth_failed: 'Something went wrong signing in with GitHub. Please try again.', + account_conflict: 'That email is already registered to a different account. Try signing in with your password.' +} + +const Login = ({ title }) => { + const navigate = useNavigate() + const location = useLocation() + const [searchParams] = useSearchParams() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + // Where RequireAuth turned this visitor away from, so signing in resumes + // the page they actually asked for instead of always landing on the dashboard. + const destination = location.state?.from?.pathname ?? '/' + + useEffect(() => { + document.title = title + }, [title]) + + useEffect(() => { + const code = searchParams.get('error') + if (code) setError(OAUTH_ERRORS[code] ?? OAUTH_ERRORS.oauth_failed) + }, [searchParams]) + + const handleSignIn = async (event) => { + event.preventDefault() + + // Quick client-side check for instant feedback... + const validationError = validateLogin(email, password) + if (validationError) { + setError(validationError) + return + } + + // ...but the server has the final say on whether login succeeds. + try { + setLoading(true) + const user = await UsersAPI.login(email, password) + localStorage.setItem('matchlens_user', JSON.stringify(user)) + navigate(destination, { replace: true }) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + return ( + +
+
+ { setEmail(e.target.value); setError(null) }} + autoComplete="email" + /> + +
+ { setPassword(e.target.value); setError(null) }} + autoComplete="current-password" + /> +

Forgot Password?

+
+
+ +
+ { error &&

{error}

} + + +
+
+
+ ) +} + +export default Login diff --git a/Code/client/src/pages/MatchDetail.jsx b/Code/client/src/pages/MatchDetail.jsx new file mode 100644 index 000000000..f9cb07d61 --- /dev/null +++ b/Code/client/src/pages/MatchDetail.jsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import VideoPlayer from '../components/VideoPlayer' +import CommentThread from '../components/CommentThread' +import Crest from '../components/Crest' + +const API_URL = import.meta.env.VITE_API_URL ?? '' + +const TABS = ['Overview', 'Lineup', 'Stats', 'Comments', 'Highlights'] + +function formatDate(dateString) { + return new Date(dateString).toLocaleDateString('en-GB', { + weekday: 'long', + day: '2-digit', + month: 'long', + year: 'numeric', + }) +} + +function InProgress({ label }) { + return ( +
+

{label} isn't available from the API yet.

+

This section is in progress.

+
+ ) +} + +export default function MatchDetail() { + const { matchId } = useParams() + const [activeTab, setActiveTab] = useState('Overview') + const [match, setMatch] = useState(null) + const [loading, setLoading] = useState(true) + const [notFound, setNotFound] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + setLoading(true) + setNotFound(false) + setError(null) + fetch(`${API_URL}/api/matches/${matchId}`) + .then((res) => { + if (res.status === 404) { + setNotFound(true) + return null + } + if (!res.ok) throw new Error('Failed to load match') + return res.json() + }) + .then((data) => data && setMatch(data)) + .catch((err) => { + console.error('Failed to load match', err) + setError('Could not load this match. Please try again.') + }) + .finally(() => setLoading(false)) + }, [matchId]) + + if (loading) { + return ( +
+

Loading match…

+
+ ) + } + + if (notFound) { + return ( +
+

Match not found

+

This match doesn't exist or may have been removed.

+ + ← Back to Matches + +
+ ) + } + + if (error || !match) { + return ( +
+

{error ?? 'Something went wrong.'}

+
+ ) + } + + const isLiveOrHt = match.status === 'LIVE' || match.status === 'HT' + + return ( +
+
+ {/* Hero */} +
+
+
+
+

{formatDate(match.date)} • {match.venue}

+ {isLiveOrHt && ( + + {match.status === 'HT' ? 'HALF-TIME' : `LIVE ${match.minute}'`} + + )} +
+
+
+

{match.home}

+ +
+
+

+ {match.home_score ?? '–'} - {match.away_score ?? '–'} +

+
+
+ +

{match.away}

+
+
+
+
+ + {/* Tabs */} +
+ {TABS.map((tab) => { + const isActive = tab === activeTab + return ( + + ) + })} +
+ + {/* Tab content */} + {activeTab === 'Overview' && } + {activeTab === 'Lineup' && } + {activeTab === 'Stats' && } + {activeTab === 'Comments' && } + {activeTab === 'Highlights' && } +
+ + {/* Right sidebar */} + +
+ ) +} diff --git a/Code/client/src/pages/Matches.jsx b/Code/client/src/pages/Matches.jsx new file mode 100644 index 000000000..d9279ff3f --- /dev/null +++ b/Code/client/src/pages/Matches.jsx @@ -0,0 +1,182 @@ +import { useEffect, useMemo, useState } from 'react' +import MatchCard from '../components/MatchCard' + +const API_URL = import.meta.env.VITE_API_URL ?? '' + +// The live API only ever emits LIVE | HT | UPCOMING today. FT is a real +// status the schema supports but no fixture currently carries, so the +// Results tab is an honest empty state rather than a broken filter. +const TABS = [ + { label: 'Live', statuses: ['LIVE', 'HT'] }, + { label: 'Upcoming', statuses: ['UPCOMING'] }, + { label: 'Results', statuses: ['FT'] }, +] + +function SearchIcon({ className }) { + return ( + + + + + ) +} + +export default function Matches() { + const [matches, setMatches] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedDate, setSelectedDate] = useState('') + const [searchTerm, setSearchTerm] = useState('') + const [activeTab, setActiveTab] = useState('Live') + + useEffect(() => { + setLoading(true) + setError(null) + const query = selectedDate ? `?date=${selectedDate}` : '' + fetch(`${API_URL}/api/matches${query}`) + .then((res) => { + if (!res.ok) throw new Error('Failed to load matches') + return res.json() + }) + .then(setMatches) + .catch((err) => { + console.error('Failed to load matches', err) + setError('Could not load matches. Please try again.') + }) + .finally(() => setLoading(false)) + }, [selectedDate]) + + const activeStatuses = TABS.find((tab) => tab.label === activeTab).statuses + const filteredMatches = useMemo(() => { + const term = searchTerm.trim().toLowerCase() + return matches + .filter((match) => activeStatuses.includes(match.status)) + .filter((match) => + term === '' + ? true + : [match.home, match.away, match.venue].some((field) => field?.toLowerCase().includes(term)), + ) + }, [matches, activeStatuses, searchTerm]) + + const featuredLiveMatch = matches.find((match) => match.status === 'LIVE' || match.status === 'HT') + + return ( +
+
+
+

Matches

+

+ Live analytics and deep match insights across the FIFA World Cup 2026. +

+
+ +
+
+ {TABS.map((tab) => { + const isActive = tab.label === activeTab + return ( + + ) + })} +
+ +
+ setSelectedDate(event.target.value)} + className="rounded-lg border border-dash bg-dash-card px-3 py-2 text-[13px] text-white focus:outline-none" + /> + {selectedDate && ( + + )} +
+ + setSearchTerm(event.target.value)} + placeholder="Filter by team or venue..." + className="w-full bg-transparent text-[13px] text-white placeholder:text-secondary focus:outline-none" + /> +
+
+
+ + {loading &&

Loading matches…

} + {error &&

{error}

} + + {!loading && !error && filteredMatches.length === 0 && ( +

+ No {activeTab.toLowerCase()} matches{selectedDate ? ` on ${selectedDate}` : ''}. +

+ )} + +
+ {filteredMatches.map((match) => ( + + ))} +
+
+ + +
+ ) +} diff --git a/Code/client/src/pages/NotFound.jsx b/Code/client/src/pages/NotFound.jsx new file mode 100644 index 000000000..56d444e39 --- /dev/null +++ b/Code/client/src/pages/NotFound.jsx @@ -0,0 +1,16 @@ +import { Link } from 'react-router-dom' + +export default function NotFound() { + return ( +
+

404

+

Page not found

+

+ The page you're looking for doesn't exist or may have moved. +

+ + Back to Dashboard + +
+ ) +} diff --git a/Code/client/src/pages/PlayerDetail.jsx b/Code/client/src/pages/PlayerDetail.jsx new file mode 100644 index 000000000..01855abb8 --- /dev/null +++ b/Code/client/src/pages/PlayerDetail.jsx @@ -0,0 +1,208 @@ +import { useParams } from 'react-router-dom' +import Avatar from '../components/Avatar' +import { getMockPlayerDetail } from '../mocks/playerDetail' + +function StarRating({ rating }) { + const filled = Math.round(rating / 2) + return ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ★ + + ))} +
+ ) +} + +export default function PlayerDetail() { + const { playerId } = useParams() + const player = getMockPlayerDetail(playerId) + + const statBlocks = [ + { value: player.seasonStats.goals, label: 'Goals' }, + { value: player.seasonStats.assists, label: 'Assists' }, + { value: player.seasonStats.appearances, label: 'Appearances' }, + { value: player.seasonStats.passAccuracy, label: 'Pass Accuracy' }, + { value: player.seasonStats.keyPasses, label: 'Key Passes / 90' }, + { value: player.seasonStats.chancesCreated, label: 'Chances Created' }, + ] + + return ( +
+
+ {/* Hero */} +
+
+
+
+ +
+

{player.name}

+
+ + {player.position} + +

+ {player.club} #{player.clubNumber} · {player.nationalTeam} +

+
+

+ Age: {player.age} · Born: {player.born} · Height: {player.height} · Preferred Foot:{' '} + {player.preferredFoot} +

+
+
+ +
+
+ + {/* Performance overview */} +
+

Performance Overview ({player.seasonLabel})

+
+ {statBlocks.map((stat, index) => ( +
+
+

{stat.value}

+

{stat.label}

+
+ {index < statBlocks.length - 1 &&
} +
+ ))} +
+
+ + {/* Dual metrics */} +
+
+
+

{player.matchLensScore}

+
+
+

MatchLens Score

+

{player.scoreBlurb}

+ + {player.scoreBadge} + +
+
+
+

Market Value & Rating

+
+
+

ESTIMATED VALUE

+
+

{player.marketValue}

+

↑ from {player.marketValuePrevious}

+
+
+
+

MATCH RATING

+
+

{player.matchRating}

+ +
+
+
+
+
+ + {/* Career history */} +
+

Career History

+
+
+

SEASON

+

CLUB

+

APPS

+

GOALS

+

ASSISTS

+

RATING

+
+ {player.careerHistory.map((row) => ( +
+

{row.season}

+

{row.club}

+

{row.apps}

+

{row.goals}

+

{row.assists}

+

{row.rating}

+
+ ))} +
+
+ + {/* Attributes + AI note */} +
+
+

Key Attributes

+
+ {player.attributes.map((attr) => ( +
+
+

{attr.label}

+

{attr.value}

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

+ AI Tactical Analysis +

+

{player.aiNote}

+
+
+
+ + {/* Right sidebar */} + +
+ ) +} diff --git a/Code/client/src/pages/Profile.jsx b/Code/client/src/pages/Profile.jsx new file mode 100644 index 000000000..11dab7867 --- /dev/null +++ b/Code/client/src/pages/Profile.jsx @@ -0,0 +1,463 @@ +import { useEffect, useState } from 'react' +import { useSessionUser } from '../hooks/useSessionUser' +import { teams as followableTeams } from '../mocks/teams' +import Avatar from '../components/Avatar' +import Crest from '../components/Crest' +import Skeleton from '../components/Skeleton' + +const API_URL = import.meta.env.VITE_API_URL ?? '' +const CLOUDINARY_CLOUD_NAME = import.meta.env.VITE_CLOUDINARY_CLOUD_NAME +const CLOUDINARY_UPLOAD_PRESET = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET + +const DEFAULT_BIO = 'Tactical obsession. Data-driven insights. World Cup 2026 prediction specialist.' + +// Decorative only — no transactions table or trend data exists anywhere +// (points-beyond-display is GildardoOrea's territory, #10). +const balanceTrend = [40, 55, 35, 60, 30, 70, 50] +const recentTransactions = [ + { label: 'Argentina vs Mexico ✅', amount: '+320 pts', positive: true }, + { label: 'France vs Germany ❌', amount: '-200 pts', positive: false }, + { label: 'Brazil vs Serbia ✅', amount: '+450 pts', positive: true }, +] + +const connectedApps = [ + { name: 'Twitter/X', connected: true }, + { name: 'Discord', connected: false }, + { name: 'Spotify', connected: false }, +] + +function ToggleSwitch({ checked, onChange }) { + return ( + + ) +} + +export default function Profile() { + const sessionUser = useSessionUser() + const userId = sessionUser?.id + + const [user, setUser] = useState(null) + const [loadingUser, setLoadingUser] = useState(true) + const [userError, setUserError] = useState(null) + const [followedTeams, setFollowedTeams] = useState([]) + const [loadingFollows, setLoadingFollows] = useState(true) + const [followsLoadError, setFollowsLoadError] = useState(null) + const [followError, setFollowError] = useState(null) + const [showTeamPicker, setShowTeamPicker] = useState(false) + + const [isEditingProfile, setIsEditingProfile] = useState(false) + const [avatarUrl, setAvatarUrl] = useState(null) + const [uploading, setUploading] = useState(false) + const [uploadError, setUploadError] = useState(null) + const [bio, setBio] = useState(() => localStorage.getItem(`matchlens:bio:${userId}`) ?? DEFAULT_BIO) + const [bioDraft, setBioDraft] = useState(bio) + + const [notifPrefs, setNotifPrefs] = useState({ + matchAlerts: true, + transferNews: true, + worldCupUpdates: true, + leaderboardMilestones: false, + predictionResults: true, + }) + const [privacyPrefs, setPrivacyPrefs] = useState({ + profileVisibility: true, + dataSharing: false, + showPredictionHistory: true, + }) + + useEffect(() => { + if (!userId) return + + setLoadingUser(true) + setUserError(null) + fetch(`${API_URL}/api/users/${userId}`) + .then((res) => { + if (!res.ok) throw new Error('Failed to load user') + return res.json() + }) + .then((data) => { + setUser(data) + setAvatarUrl(data.profile_image_url) + }) + .catch((err) => { + console.error('Failed to load user', err) + setUserError('Could not load your profile.') + }) + .finally(() => setLoadingUser(false)) + + setLoadingFollows(true) + setFollowsLoadError(null) + fetch(`${API_URL}/api/follows/user/${userId}`) + .then((res) => { + if (!res.ok) throw new Error('Failed to load followed teams') + return res.json() + }) + .then(setFollowedTeams) + .catch((err) => { + console.error('Failed to load followed teams', err) + setFollowsLoadError('Could not load followed teams.') + }) + .finally(() => setLoadingFollows(false)) + }, [userId]) + + async function handleAvatarChange(event) { + const file = event.target.files?.[0] + if (!file) return + + if (!CLOUDINARY_CLOUD_NAME || !CLOUDINARY_UPLOAD_PRESET) { + setUploadError( + 'Cloudinary is not configured — set VITE_CLOUDINARY_CLOUD_NAME and VITE_CLOUDINARY_UPLOAD_PRESET in client/.env', + ) + return + } + + setUploading(true) + setUploadError(null) + try { + const formData = new FormData() + formData.append('file', file) + formData.append('upload_preset', CLOUDINARY_UPLOAD_PRESET) + + const uploadRes = await fetch( + `https://api.cloudinary.com/v1_1/${CLOUDINARY_CLOUD_NAME}/image/upload`, + { method: 'POST', body: formData }, + ) + if (!uploadRes.ok) throw new Error('Cloudinary upload failed') + const uploadData = await uploadRes.json() + setAvatarUrl(uploadData.secure_url) + + const patchRes = await fetch(`${API_URL}/api/users/${userId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile_image_url: uploadData.secure_url }), + }) + if (!patchRes.ok) throw new Error('Failed to save avatar') + const updatedUser = await patchRes.json() + setUser(updatedUser) + + // Keep the Sidebar's avatar (read from this hint) in sync without + // waiting for the next full session check. + localStorage.setItem('matchlens_user', JSON.stringify({ ...sessionUser, ...updatedUser })) + } catch (err) { + setUploadError('Upload failed. Please try again.') + } finally { + setUploading(false) + } + } + + function handleSaveBio() { + setBio(bioDraft) + localStorage.setItem(`matchlens:bio:${userId}`, bioDraft) + setIsEditingProfile(false) + } + + async function handleFollowTeam(team) { + setFollowError(null) + try { + const res = await fetch(`${API_URL}/api/follows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, api_team_id: team.api_team_id, team_name: team.name }), + }) + if (!res.ok) throw new Error('Follow failed') + const created = await res.json() + setFollowedTeams((prev) => [...prev, created]) + setShowTeamPicker(false) + } catch (err) { + setFollowError('Could not follow team. Please try again.') + } + } + + async function handleUnfollowTeam(followedTeamId) { + setFollowError(null) + try { + const res = await fetch(`${API_URL}/api/follows/${followedTeamId}`, { method: 'DELETE' }) + if (!res.ok) throw new Error('Unfollow failed') + setFollowedTeams((prev) => prev.filter((team) => team.followed_team_id !== followedTeamId)) + } catch (err) { + setFollowError('Could not unfollow team. Please try again.') + } + } + + const pickableTeams = followableTeams.filter( + (team) => !followedTeams.some((followed) => String(followed.api_team_id) === String(team.api_team_id)), + ) + + return ( +
+
+

Profile & Account Settings

+

+ Manage your predictive avatar, tracked national teams, and account safety. +

+
+ +
+
+ {/* Profile header */} +
+
+ {loadingUser ? ( +
+ +
+ + +
+
+ ) : userError ? ( +

{userError}

+ ) : ( +
+ +
+

{user?.username}

+

@{user?.username}

+
+
+ )} + +
+ + {isEditingProfile && ( +
+
+

Avatar

+ + {uploading &&

Uploading…

} + {uploadError &&

{uploadError}

} +
+
+

Bio

+