Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions apps/web/__tests__/MatchVenueFooter.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <a> 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 <a> with an accessible name", async () => {
await act(async () => {
render(
<MatchVenueFooter
matchId="1"
homeName="Mexico"
awayName="South Africa"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>,
);
});
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(
<MatchVenueFooter
matchId="1"
homeName="Mexico"
awayName="South Africa"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>,
);
});
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(
<MatchVenueFooter
matchId="1"
homeName="Mexico"
awayName="South Africa"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>,
);
});
// 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(
<MatchVenueFooter
matchId="1"
homeName="A"
awayName="B"
kickoffIso={KICKOFF}
/>,
);
});
// 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<typeof useOverlay> | null = null;
const Capture = (): React.ReactElement => {
api = useOverlay();
return <></>;
};
await act(async () => {
render(
<OverlayProvider>
<Capture />
<MatchVenueFooter
matchId="1"
homeName="Mexico"
awayName="South Africa"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>
</OverlayProvider>,
);
});
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(
<MatchVenueFooter
matchId="42"
homeName="A"
awayName="B"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>,
);
});
// No overlay router available, so the component should still
// render an <a href> that a browser would follow on click.
expect(screen.getByRole("link").getAttribute("href")).toBe(
"/match/42/preview",
);
});
});
70 changes: 70 additions & 0 deletions apps/web/__tests__/host-cities.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
18 changes: 18 additions & 0 deletions apps/web/app/api/v1/syndicates/[slug]/manage-owner/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
},
});
Expand All @@ -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<Response> {
Expand Down Expand Up @@ -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),
},
});
}
16 changes: 8 additions & 8 deletions apps/web/app/home.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%
);
}
Expand Down
Loading
Loading