diff --git a/apps/web/__tests__/MatchVenueFooter.test.tsx b/apps/web/__tests__/MatchVenueFooter.test.tsx new file mode 100644 index 00000000..d4701984 --- /dev/null +++ b/apps/web/__tests__/MatchVenueFooter.test.tsx @@ -0,0 +1,156 @@ +/** + * MatchVenueFooter unit tests. + * + * Coverage: + * - Renders date + time + gold info icon. + * - Uses venue timezone on first render (pre-hydration), then swaps + * to the user's timezone after `useEffect` resolves. + * - Renders an accessible name on the wrapper. + * - Click with an overlay router fires `overlay.open("match", ...)` + * and calls `preventDefault`. Click without an overlay falls + * through to a real navigation (we just assert it didn't crash + * and the default wasn't prevented). + * - With no `hostCity`, falls back to UTC and still renders. + */ + +// @vitest-environment jsdom + +import React from "react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; + +import { MatchVenueFooter } from "../components/bracket/MatchVenueFooter"; +import { OverlayProvider, useOverlay } from "../components/overlay/OverlayProvider"; +import { hostCityById } from "../lib/host-cities"; + +const MEXICO_CITY = hostCityById("mexico_city")!; +const KICKOFF = "2026-06-11T19:00:00Z"; + +describe("MatchVenueFooter", () => { + beforeEach(() => { + // Pin the user's IANA timezone to Pacific/Auckland so the + // post-hydration swap is deterministic across machines. + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ + locale: "en-NZ", + timeZone: "Pacific/Auckland", + calendar: "gregory", + numberingSystem: "latn", + } as unknown as Intl.ResolvedDateTimeFormatOptions); + }); + + it("renders a tap-target with an accessible name", async () => { + await act(async () => { + render( + , + ); + }); + const link = screen.getByRole("link"); + expect(link.getAttribute("aria-label")).toMatch(/Mexico vs South Africa/); + expect(link.getAttribute("aria-label")).toMatch(/kicks off/); + }); + + it("includes the gold info icon as an aria-hidden SVG", async () => { + await act(async () => { + render( + , + ); + }); + const link = screen.getByRole("link"); + const svg = link.querySelector("svg"); + expect(svg).not.toBeNull(); + expect(svg!.getAttribute("aria-hidden")).toBe("true"); + }); + + it("renders the kickoff date in the resolved (user) timezone after hydration", async () => { + await act(async () => { + render( + , + ); + }); + // 2026-06-11T19:00 UTC = 2026-06-12 07:00 Pacific/Auckland. + // Assert the rendered date corresponds to NZ-side of the + // dateline, not the venue's Mexico-side. + const text = screen.getByRole("link").textContent ?? ""; + expect(text).toMatch(/Fri/); // 12 Jun 2026 is a Friday in Auckland + }); + + it("falls back to UTC when hostCity is absent", async () => { + await act(async () => { + render( + , + ); + }); + // Without a hostCity, the SSR/initial timezone is UTC; after + // hydration we still swap to the user TZ (mocked Auckland). The + // link should still render without crashing. + expect(screen.getByRole("link")).toBeDefined(); + }); + + it("opens the overlay router on plain click", async () => { + let api: ReturnType | null = null; + const Capture = (): React.ReactElement => { + api = useOverlay(); + return <>; + }; + await act(async () => { + render( + + + + , + ); + }); + fireEvent.click(screen.getByRole("link"), { button: 0 }); + expect(api!.stack).toHaveLength(1); + expect(api!.stack[0]!.kind).toBe("match"); + expect(api!.stack[0]!.params.id).toBe("1"); + }); + + it("falls back to a real link href when no overlay provider is mounted", async () => { + await act(async () => { + render( + , + ); + }); + // No overlay router available, so the component should still + // render an that a browser would follow on click. + expect(screen.getByRole("link").getAttribute("href")).toBe( + "/match/42/preview", + ); + }); +}); diff --git a/apps/web/__tests__/host-cities.test.ts b/apps/web/__tests__/host-cities.test.ts new file mode 100644 index 00000000..080b77bd --- /dev/null +++ b/apps/web/__tests__/host-cities.test.ts @@ -0,0 +1,70 @@ +/** + * Smoke tests for the host-city lookup module. + * + * Coverage: + * - `hostCityById` resolves a known id to the expected record. + * - `hostCityById` returns `undefined` for an unknown id (defensive). + * - `hostCityByMatchNumber` walks fixtures.json โ†’ host_city_id and + * surfaces the rich HostCity. + * - `kickoffIsoByMatchNumber` returns the canonical kickoff for a + * fixture by match_number. + * + * The data file is loaded statically at import time, so these tests + * also act as a guard against accidental schema drift in + * `data/fifa-wc-2026/host-cities.json` or `fixtures.json`. + */ + +import { describe, expect, it } from "vitest"; + +import { + hostCityById, + hostCityByMatchNumber, + kickoffIsoByMatchNumber, + allHostCities, +} from "../lib/host-cities"; + +describe("hostCityById", () => { + it("resolves a known id to the full record", () => { + const c = hostCityById("mexico_city"); + expect(c).toBeDefined(); + expect(c?.city).toBe("Mexico City"); + expect(c?.country).toBe("MX"); + expect(c?.stadium).toBe("Estadio Azteca"); + expect(c?.stadium_tournament_name).toBe("Estadio Banorte"); + expect(c?.timezone).toBe("America/Mexico_City"); + expect(typeof c?.capacity).toBe("number"); + expect(c?.capacity).toBeGreaterThan(0); + }); + + it("returns undefined for an unknown id", () => { + expect(hostCityById("atlantis")).toBeUndefined(); + expect(hostCityById(undefined)).toBeUndefined(); + expect(hostCityById(null)).toBeUndefined(); + expect(hostCityById("")).toBeUndefined(); + }); + + it("covers every FIFA-2026 host city", () => { + expect(allHostCities().length).toBe(16); + }); +}); + +describe("hostCityByMatchNumber", () => { + it("resolves match 1 (MEX vs RSA) to Mexico City", () => { + const c = hostCityByMatchNumber(1); + expect(c?.id).toBe("mexico_city"); + }); + + it("returns undefined for an out-of-range match number", () => { + expect(hostCityByMatchNumber(999)).toBeUndefined(); + }); +}); + +describe("kickoffIsoByMatchNumber", () => { + it("returns the canonical kickoff for match 1", () => { + expect(kickoffIsoByMatchNumber(1)).toBe("2026-06-11T19:00:00Z"); + }); + + it("returns undefined for an out-of-range match number", () => { + expect(kickoffIsoByMatchNumber(999)).toBeUndefined(); + }); +}); diff --git a/apps/web/app/api/v1/syndicates/[slug]/manage-owner/route.ts b/apps/web/app/api/v1/syndicates/[slug]/manage-owner/route.ts index 811e4af0..71c65b24 100644 --- a/apps/web/app/api/v1/syndicates/[slug]/manage-owner/route.ts +++ b/apps/web/app/api/v1/syndicates/[slug]/manage-owner/route.ts @@ -18,6 +18,7 @@ import { z } from "zod"; import { getSessionFromRequest } from "@/lib/auth/session"; import { isSuperAdmin } from "@/lib/auth/super-admin"; import { getPersistence } from "@/lib/syndicate/persistence"; +import { parseAllowedCountries } from "@/lib/syndicate/country-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -122,6 +123,11 @@ export async function GET(req: NextRequest, props: { params: Promise<{ slug: str size_band: row.size_band, branding_primary_colour: row.branding_primary_colour, branding_accent_colour: row.branding_accent_colour, + // Tim 2026-06-06: surface the country allow-list so the + // manage page can render a 'lock entries' editor. Returned + // as an array of bare dial codes ("64", "61", ...); empty + // array means "no restriction". + allowed_phone_countries: parseAllowedCountries(row.allowed_phone_countries), created_at: row.created_at, }, }); @@ -130,6 +136,17 @@ export async function GET(req: NextRequest, props: { params: Promise<{ slug: str const PatchSchema = z.object({ name: z.string().min(3).max(80).optional(), topic: z.string().max(280).nullable().optional(), + /** + * SEC-POOL-11 / Tim 2026-06-06: country allow-list edit. Each entry + * is a bare E.164 dial code (1โ€“3 digits, no "+"). Empty array = + * no restriction (anyone with a verified phone can join). Capped + * at 10 entries so the bracket-join UI doesn't render a wall of + * flags. + */ + allowed_phone_countries: z + .array(z.string().regex(/^\d{1,3}$/)) + .max(10) + .optional(), }).strict(); export async function PATCH(req: NextRequest, props: { params: Promise<{ slug: string }> }): Promise { @@ -160,6 +177,7 @@ export async function PATCH(req: NextRequest, props: { params: Promise<{ slug: s slug: updated.slug, name: updated.name, topic: updated.topic, + allowed_phone_countries: parseAllowedCountries(updated.allowed_phone_countries), }, }); } diff --git a/apps/web/app/home.css b/apps/web/app/home.css index d71ebe01..f0f46898 100644 --- a/apps/web/app/home.css +++ b/apps/web/app/home.css @@ -74,22 +74,22 @@ * legibility. */ opacity: 0.78; filter: saturate(1); - /* Tim 2026-06-05: soft fade-to-transparent on the bottom edge so - * the banner doesn't bump up against the lede paragraph with a - * hard horizontal line where the player photos end. The dark - * overlay above still keeps the headline readable. */ + /* Tim 2026-06-05 (rev 2): fade the bottom 20% of the banner to + * transparent so the lede paragraph that sits under the banner + * blends in instead of butting against a hard horizontal line + * where the player photos end. Clean linear ramp: opaque to + * 80%, fully transparent at the bottom. The dark overlay above + * still keeps the headline readable through the upper 80%. */ -webkit-mask-image: linear-gradient( 180deg, #000 0%, - #000 68%, - rgba(0, 0, 0, 0.4) 88%, + #000 80%, transparent 100% ); mask-image: linear-gradient( 180deg, #000 0%, - #000 68%, - rgba(0, 0, 0, 0.4) 88%, + #000 80%, transparent 100% ); } diff --git a/apps/web/app/manage/syndicates/[slug]/ManageClient.tsx b/apps/web/app/manage/syndicates/[slug]/ManageClient.tsx index b11f93b7..d705a435 100644 --- a/apps/web/app/manage/syndicates/[slug]/ManageClient.tsx +++ b/apps/web/app/manage/syndicates/[slug]/ManageClient.tsx @@ -12,24 +12,31 @@ interface SyndicateData { share_guid: string; topic: string | null; size_band: string; + /** Bare E.164 dial codes ("64", "61", ...). Empty array means + * "no country restriction". Added Tim 2026-06-06 so the manage + * page can edit the list post-creation. */ + allowed_phone_countries: readonly string[]; created_at: number; } type Phase = "requesting" | "verifying" | "managing"; const COUNTRY_CODES = [ - { iso: "NZ", dial: "+64", name: "New Zealand" }, - { iso: "AU", dial: "+61", name: "Australia" }, - { iso: "GB", dial: "+44", name: "United Kingdom" }, - { iso: "US", dial: "+1", name: "United States" }, - { iso: "IE", dial: "+353", name: "Ireland" }, - { iso: "ZA", dial: "+27", name: "South Africa" }, - { iso: "IN", dial: "+91", name: "India" }, - { iso: "BR", dial: "+55", name: "Brazil" }, - { iso: "DE", dial: "+49", name: "Germany" }, - { iso: "FR", dial: "+33", name: "France" }, + { iso: "NZ", dial: "+64", name: "New Zealand", flag: "๐Ÿ‡ณ๐Ÿ‡ฟ" }, + { iso: "AU", dial: "+61", name: "Australia", flag: "๐Ÿ‡ฆ๐Ÿ‡บ" }, + { iso: "GB", dial: "+44", name: "United Kingdom", flag: "๐Ÿ‡ฌ๐Ÿ‡ง" }, + { iso: "US", dial: "+1", name: "United States", flag: "๐Ÿ‡บ๐Ÿ‡ธ" }, + { iso: "IE", dial: "+353", name: "Ireland", flag: "๐Ÿ‡ฎ๐Ÿ‡ช" }, + { iso: "ZA", dial: "+27", name: "South Africa", flag: "๐Ÿ‡ฟ๐Ÿ‡ฆ" }, + { iso: "IN", dial: "+91", name: "India", flag: "๐Ÿ‡ฎ๐Ÿ‡ณ" }, + { iso: "BR", dial: "+55", name: "Brazil", flag: "๐Ÿ‡ง๐Ÿ‡ท" }, + { iso: "DE", dial: "+49", name: "Germany", flag: "๐Ÿ‡ฉ๐Ÿ‡ช" }, + { iso: "FR", dial: "+33", name: "France", flag: "๐Ÿ‡ซ๐Ÿ‡ท" }, ] as const; +/** Cap mirrors the server's MAX_ALLOWED_COUNTRIES (10). */ +const MAX_ALLOWED_COUNTRIES = 10; + export function ManageClient({ slug, prefilledPhone, @@ -57,6 +64,14 @@ export function ManageClient({ // Edit state const [editName, setEditName] = useState(""); const [editTopic, setEditTopic] = useState(""); + // Country lock editor. We keep the current allow-list as a separate + // `editAllowedCountries` array so the user can toggle / add / remove + // without round-tripping to the server. `editCountriesLocked` mirrors + // the SyndicateForm pattern: a checkbox toggle, distinct from the + // array being empty, so the user can clear the list (-> 'no + // restriction') AND toggle the lock off without ambiguity. + const [editAllowedCountries, setEditAllowedCountries] = useState([]); + const [editCountriesLocked, setEditCountriesLocked] = useState(false); const [saving, setSaving] = useState(false); const [saveMsg, setSaveMsg] = useState(""); const [copied, setCopied] = useState(false); @@ -224,6 +239,9 @@ export function ManageClient({ setSyndicate(syn); setEditName(syn.name); setEditTopic(syn.topic ?? ""); + const initialCountries = (syn.allowed_phone_countries ?? []).slice(); + setEditAllowedCountries(initialCountries); + setEditCountriesLocked(initialCountries.length > 0); setPhase("managing"); } catch { setError("Network error. Please try again."); @@ -237,6 +255,11 @@ export function ManageClient({ setSaving(true); setSaveMsg(""); try { + // When the lock toggle is OFF, send an empty array to clear + // the restriction server-side. When it's ON, send whatever + // codes the user has chipped in (the server enforces a 10-code + // cap and a dial-code regex on each entry). + const allowedToSend = editCountriesLocked ? editAllowedCountries : []; const res = await fetch(`/api/v1/syndicates/${encodeURIComponent(slug)}/manage-owner`, { method: "PATCH", headers: { @@ -246,12 +269,33 @@ export function ManageClient({ body: JSON.stringify({ name: editName.trim() || undefined, topic: editTopic.trim() || null, + allowed_phone_countries: allowedToSend, }), }); if (res.ok) { - const body = await res.json() as { syndicate?: { name: string; topic: string | null } }; + const body = await res.json() as { + syndicate?: { + name: string; + topic: string | null; + allowed_phone_countries?: readonly string[]; + }; + }; if (body.syndicate) { - setSyndicate((prev) => prev ? { ...prev, name: body.syndicate!.name, topic: body.syndicate!.topic } : prev); + const nextCountries = body.syndicate.allowed_phone_countries ?? []; + setSyndicate((prev) => + prev + ? { + ...prev, + name: body.syndicate!.name, + topic: body.syndicate!.topic, + allowed_phone_countries: nextCountries, + } + : prev, + ); + // Re-sync the editor in case the server normalised our list + // (deduped, dropped invalid entries, etc). + setEditAllowedCountries(nextCountries.slice()); + setEditCountriesLocked(nextCountries.length > 0); } setSaveMsg("Saved."); setTimeout(() => setSaveMsg(""), 2000); @@ -472,6 +516,127 @@ export function ManageClient({ onChange={(e) => setEditTopic(e.target.value)} /> + + {/* Country lock editor. Mirrors the create form's picker + * (apps/web/app/syndicates/new/SyndicateForm.tsx). Toggle + * controls whether the lock is on at all; when off, the + * server clears `allowed_phone_countries` so the join + * page stops showing the " residents only" banner. + * Tim 2026-06-06. */} +
+ + Lock entries by country + + + {editCountriesLocked && ( +
+
+ {editAllowedCountries.length === 0 ? ( + No countries selected. Add one to keep the lock active. + ) : ( + editAllowedCountries.map((dial) => { + const country = COUNTRY_CODES.find((c) => c.dial === `+${dial}`); + return ( + + + {country?.name ?? `+${dial}`} + +{dial} + + + ); + }) + )} +
+ {editAllowedCountries.length < MAX_ALLOWED_COUNTRIES && ( +
+ +
+ )} + {editAllowedCountries.length >= MAX_ALLOWED_COUNTRIES && ( + + Maximum {MAX_ALLOWED_COUNTRIES} countries per pool. Remove one to add another. + + )} +
+ )} +
+
-
- - {showScores && ( -
- - - -
- )} -
+ {kickoffIso && ( + + )} ); } diff --git a/apps/web/components/bracket/MatchVenueFooter.tsx b/apps/web/components/bracket/MatchVenueFooter.tsx new file mode 100644 index 00000000..7871fb61 --- /dev/null +++ b/apps/web/components/bracket/MatchVenueFooter.tsx @@ -0,0 +1,160 @@ +/** + * MatchVenueFooter, the neutral charcoal lozenge that sits at the + * bottom of each match row in place of the old "Add score" toggle. + * + * Renders: weekday + date + middle-dot + user-local kickoff time + + * short timezone abbreviation in parens + a small gold info icon. + * + * Whole lozenge is a single tap target. When the bracket's overlay + * router is mounted (the common case inside `/world-cup-2026`), the + * click opens the existing `MatchOverlay` bottom sheet for this + * match. Outside the bracket shell (tests, standalone match-preview + * page) the underlying `` falls through to a regular navigation + * to `/match/{id}/preview`. + * + * SSR / hydration: the first render uses the venue's IANA timezone + * so the server output is deterministic. After mount, a `useEffect` + * reads the user's resolved timezone (`Intl.DateTimeFormat() + * .resolvedOptions().timeZone`) and re-renders with that. The DOM + * structure is identical pre/post hydration, only the formatted + * text changes, which React tolerates. + */ + +"use client"; + +import { useEffect, useState, type MouseEvent } from "react"; + +import { useOptionalOverlay } from "@/components/overlay/OverlayProvider"; + +import type { HostCity } from "@/lib/host-cities"; + +export interface MatchVenueFooterProps { + readonly matchId: string; + readonly homeName: string; + readonly awayName: string; + /** ISO-8601 kickoff time in UTC, e.g. "2026-06-11T19:00:00Z". */ + readonly kickoffIso: string; + /** Resolved host-city record. When absent (defensive), the lozenge + * falls back to UTC formatting. */ + readonly hostCity?: HostCity; +} + +interface FormattedKickoff { + readonly dateLabel: string; + readonly timeLabel: string; +} + +function formatKickoff( + iso: string, + timezone: string, + locale?: string, +): FormattedKickoff { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) { + return { dateLabel: "TBD", timeLabel: "TBD" }; + } + // `undefined` locale lets Intl resolve to the runtime locale; the + // explicit `locale` argument is for the SSR pre-hydration pass + // where we want a stable, deterministic output. + const loc = locale; + const dateLabel = new Intl.DateTimeFormat(loc, { + weekday: "short", + day: "numeric", + month: "short", + timeZone: timezone, + }).format(d); + const timeLabel = new Intl.DateTimeFormat(loc, { + hour: "numeric", + minute: "2-digit", + timeZone: timezone, + timeZoneName: "short", + }).format(d); + return { dateLabel, timeLabel }; +} + +function resolveUserTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } catch { + return "UTC"; + } +} + +export function MatchVenueFooter(props: MatchVenueFooterProps) { + const { matchId, homeName, awayName, kickoffIso, hostCity } = props; + const overlay = useOptionalOverlay(); + + // SSR / first-render pass: use the venue timezone (deterministic + // across server + client). After mount, switch to the user's + // resolved timezone via Intl. The venue fallback also covers the + // rare case where Intl can't resolve a user timezone. + const ssrTimezone = hostCity?.timezone ?? "UTC"; + const ssrLocale = "en-US"; + const [timezone, setTimezone] = useState(ssrTimezone); + const [locale, setLocale] = useState(ssrLocale); + + useEffect(() => { + setTimezone(resolveUserTimezone()); + // `undefined` here = use the runtime-resolved locale. + setLocale(undefined); + }, []); + + const { dateLabel, timeLabel } = formatKickoff(kickoffIso, timezone, locale); + const ariaLabel = + `View match details for ${homeName} vs ${awayName}, ` + + `kicks off ${dateLabel}, ${timeLabel}`; + + const onClick = (e: MouseEvent): void => { + if (!overlay) return; // let the navigate normally + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) { + return; // user wants a new tab / etc., honour it + } + e.preventDefault(); + overlay.open("match", { id: matchId }); + }; + + return ( + + + {dateLabel} + + {timeLabel} + + + + ); +} + +interface InfoIconProps { + readonly className?: string; +} + +function InfoIcon(props: InfoIconProps) { + return ( + + ); +} diff --git a/apps/web/components/join/JoinFlowClient.tsx b/apps/web/components/join/JoinFlowClient.tsx index d244e44c..5213e5fd 100644 --- a/apps/web/components/join/JoinFlowClient.tsx +++ b/apps/web/components/join/JoinFlowClient.tsx @@ -251,6 +251,17 @@ export function JoinFlowClient({ slug, initialName }: JoinFlowClientProps): JSX. if (verifiedUser) setUser(verifiedUser); const fresh = verifiedUser ?? (await fetchInboundUser()); if (fresh) setUser(fresh); + // Tell `useUser` to re-probe the inbound session so the global + // ProfileCompletionGate flips to its "authenticated, missing + // display_name" state and shows the @handle picker overlay. + // Without this dispatch, useUser only learns about Supabase auth + // events, the `tnm_session` cookie set by inbound-login goes + // unnoticed, and the OnboardingStep's poll loop sits on + // "Setting up your profileโ€ฆ" indefinitely because no UI ever + // surfaces to let the user pick a name. Tim 2026-06-06. + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("tnm:auth-changed")); + } const status = await fetchMembershipStatus(slug); routeSignedIn(status); }, diff --git a/apps/web/components/overlay/MatchOverlay.tsx b/apps/web/components/overlay/MatchOverlay.tsx index 9d5b1619..a160fc70 100644 --- a/apps/web/components/overlay/MatchOverlay.tsx +++ b/apps/web/components/overlay/MatchOverlay.tsx @@ -1,9 +1,23 @@ /** * MatchOverlay, bottom-sheet card with the compact match-preview view. * - * Renders a slimmed-down version of the match-preview surface: kickoff - * label + venue, both team flags, and quick links to each team's - * overlay (replaces self). + * Layout (top โ†’ bottom): + * - Stage chip: "Group A ยท Match 1" or "Round of 32 ยท Match 73", + * small gold-bordered lozenge. + * - Two team side-cards (unchanged): both flags, names, codes, + * tappable into the team overlay. + * - When block: full date, large user-local kickoff time, smaller + * venue-local kickoff time. When the user's timezone matches the + * venue timezone, collapses to a single line. + * - Where block: city + host-country flag + country name, real + * stadium name, FIFA tournament name in quotes + formatted + * stadium capacity. + * + * The When block formats date/time client-side via `Intl.DateTimeFormat` + * using the runtime-resolved locale + timezone. On first paint + * (SSR / pre-hydration) we render both lines in the venue timezone so + * the markup is deterministic; a `useEffect` swaps to the user's + * resolved timezone after mount. * * Tim 2026-06-05: the "Full page โ†’" header CTA and the "Open full * preview (Predict / H2H / Form / Lineup / Stats) โ†’" footer CTA were @@ -14,12 +28,13 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { loadFixtures2026 } from "@tournamental/bracket-engine"; import { TeamFlag } from "@/components/bracket/TeamFlag"; import { enrichTournamentTeams, type CanonicalTeamsFile } from "@/lib/bracket/enrich"; +import { hostCityById, type HostCity } from "@/lib/host-cities"; import canonicalTeamsRaw from "@/../../data/fifa-wc-2026/teams.json"; import { @@ -69,8 +84,15 @@ export function MatchOverlay(props: MatchOverlayProps) { const homeName = home?.name ?? match.homeCode ?? "TBD"; const awayName = away?.name ?? match.awayCode ?? "TBD"; - const kickoff = new Date(match.kickoffUtc); - const kickoffLabel = formatKickoff(kickoff); + const hostCity = hostCityById(match.hostCityId); + const stageChipText = `${match.stageLabel} ยท Match ${match.matchNo}`; + const someTbd = !match.homeCode || !match.awayCode; + const bothTbd = !match.homeCode && !match.awayCode; + const tbdNote = someTbd + ? bothTbd + ? "Teams shown once the previous stage closes" + : "Opponent shown once the previous stage closes" + : null; return (
+ {tbdNote && ( +

({tbdNote})

+ )}
- {match.stageLabel} - - {match.venue && {match.venue}} + {stageChipText}
@@ -105,6 +128,12 @@ export function MatchOverlay(props: MatchOverlayProps) { onOpenTeam={(code) => overlay.replace("team", { code })} />
+ + {match.kickoffUtc && ( + + )} + + {hostCity && }
); @@ -131,10 +160,19 @@ function SideCard(props: SideCardProps) { ); } + // Inline a CSS variable carrying the team's flag URL so the + // `.vt-match-overlay-side[data-team-bg]::before` pseudo can paint a + // blurred full-bleed flag behind the circular chip + name. Same + // pattern the bracket's `.km-team` cells use. + const bgStyle: CSSProperties = { + ["--vt-side-bg" as string]: `url(/flags/${code}.svg)`, + }; return (