diff --git a/AGENTS.md b/AGENTS.md index 1ce5337..e843f2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -300,6 +300,7 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke - `user_show_state` is `user_id`, `show_id`, `tracked_at` and the timestamps, nothing more: the spec 0020 contract migration (`20261010120000_show_tracking_contract.sql`) dropped the legacy status columns, the `tv_status`, `status_source` and `show_hold` enums, the old views and every status and hold function. `user_tracked_shows` is the one view. A show page control is a Plan to watch / Tracking toggle. - The navbar's SHOWS | MOVIES tabs filter every page (scope feature 22, [prompts/media-tabs-everywhere.md](prompts/media-tabs-everywhere.md)). `/watchlist`, `/upcoming`, `/watched` and `/search` take their media type from `?type=tv|movie`. A bare URL means `tv`, and an invalid value shows a "That page doesn't exist" panel, never a redirect. Each tab has its own reads, count and pages. The parsing and URL building live only in `lib/catalog/media-type.ts`, so use `typedHref` for any link to these pages and always write `type`. The client shell reads `useSearchParams` only on those four paths, inside its own Suspense boundary; reading it anywhere else takes `/shows`, `/movies` and the title pages out of their prerendered shells. - TMDB images render through `next/image`; `image.tmdb.org` is the one allowed remote pattern. +- The visitor's country (Where to watch, the age rating chip) comes only from `getVisitorRegion()` in `lib/region.ts`, which reads Vercel's `x-vercel-ip-country` header with a `US` fallback (spec 0022). Call it only inside a streamed Suspense boundary, never in a page shell, a cached scope or an SEO surface, and never store, log or send it to TMDB. - The navbar is transparent (no background, border or blur) and not sticky, so it scrolls away with the page; every control in it is 40px tall at every width, with the `hit-area` utilities keeping a 44px tap target. The footer is borderless. See [components/AGENTS.md](components/AGENTS.md). - Legal facts (operator handle and GitHub URL, contact email, processors, `LEGAL_LAST_UPDATED`) live only in `lib/legal/operator.ts`, and TMDB's notice only in `TMDB_ATTRIBUTION`; `lib/legal/boundary.test.ts` fails if either is pasted elsewhere. `/privacy` and `/terms` describe only what the code does, so a change that adds a processor, email provider or analytics updates `PROCESSORS`, the policy text and `LEGAL_LAST_UPDATED` in the same PR (spec 0017). `public/tmdb-logo.svg` is TMDB's official file, hash checked; never edit it. - Production runs from [docs/deploy.md](docs/deploy.md), the runbook of spec 0018: push a migration with `supabase db push` before merging the PR that adds it, and auth settings reach the cloud only through `supabase config push` (never the dashboard). Run both pushes yourself; from an agent shell the CLI applies them without stopping at the prompt. `vercel.json` pins functions to `fra1`, next to the Supabase region `eu-central-1`. The `/api/search` rate limit is a Vercel Firewall rule, recorded only in `docs/deploy.md`. diff --git a/app/movies/[id]/page.tsx b/app/movies/[id]/page.tsx index 9978a63..72619c8 100644 --- a/app/movies/[id]/page.tsx +++ b/app/movies/[id]/page.tsx @@ -3,7 +3,15 @@ import { notFound } from "next/navigation"; import { Suspense } from "react"; import { JsonLd } from "@/components/catalog/json-ld"; -import { CastRow } from "@/components/movie/cast-row"; +import { MovieCrew, MovieFacts } from "@/components/catalog/title-details"; +import { + AgeRatingSlot, + MoreLikeThisSection, + TrailerSlot, + WhereToWatchSection, +} from "@/components/catalog/title-extras"; +import { SECTION_HEADING_CLASS } from "@/components/catalog/title-section"; +import { CastRow, CastSection } from "@/components/movie/cast-row"; import { MovieDetailSkeleton } from "@/components/movie/movie-detail-skeleton"; import { MovieHero } from "@/components/movie/movie-hero"; import { MovieOverview } from "@/components/movie/movie-overview"; @@ -71,6 +79,12 @@ export async function generateMetadata({ * scoped piece is the tracking slot from spec 0007, which lives in * `components/tracking/` and streams inside its own Suspense boundary, so the * catalog content never waits on the session. + * + * Spec 0022 adds the Trailer button and age chip to the hero, the crew under + * the overview, then Where to watch, Cast, More like this and Facts, in that + * order (AC-17). The trailer, chip, Where to watch and More like this stream + * from the extras read in their own boundaries; the chip and Where to watch + * read the visitor's region there, never in this shell (AC-10). */ export default function MoviePage({ params }: PageProps<"/movies/[id]">) { return ( @@ -107,6 +121,7 @@ async function MovieDetail({ } const { movie } = result; + const title = { mediaType: "movie", id } as const; return (
@@ -130,33 +145,33 @@ async function MovieDetail({ /> } + actions={} + ageRating={} />
-

+

Overview

+
-
-

- Cast -

+ + + -
+ + + + +
); } diff --git a/app/privacy/page.test.tsx b/app/privacy/page.test.tsx index f72d705..dd8716d 100644 --- a/app/privacy/page.test.tsx +++ b/app/privacy/page.test.tsx @@ -86,6 +86,17 @@ describe("/privacy", () => { expectOperatorLinks(container); }); + it("names YouTube and says the region country is not stored (spec 0022, AC-19)", () => { + const { container } = render(); + const text = container.textContent ?? ""; + + expect(text).toContain("YouTube"); + expect(text).toContain("only after you press Trailer"); + expect(text).toMatch( + /approximate country Vercel derives from your\s+connection picks the region for where to watch and the age rating\. It\s+is not stored\./, + ); + }); + it("opens external links in a new tab, safely (AC-7)", () => { const { container } = render(); diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 092e720..445c21a 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -76,6 +76,11 @@ export default function PrivacyPage() { Nothing else. We collect no payment data, no contacts, and no location beyond what an IP address implies.

+

+ On a title page, the approximate country Vercel derives from your + connection picks the region for where to watch and the age rating. It + is not stored. +

@@ -110,6 +115,11 @@ export default function PrivacyPage() { so there is no consent banner. Fonts are served from BeStats itself, not loaded from another site.

+

+ A trailer plays in YouTube’s privacy enhanced player, which + loads only after you press Trailer. From then on YouTube’s own + privacy policy applies to that player. +

diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx index e1f964d..e68ba8b 100644 --- a/app/showcase/page.tsx +++ b/app/showcase/page.tsx @@ -388,6 +388,22 @@ export default function ShowcasePage() {
  • +
  • + } + /> +
  • +
  • + } + /> +
  • diff --git a/app/shows/[id]/page.tsx b/app/shows/[id]/page.tsx index ae1f24a..e39e226 100644 --- a/app/shows/[id]/page.tsx +++ b/app/shows/[id]/page.tsx @@ -4,7 +4,15 @@ import { Suspense } from "react"; import { DetailHero } from "@/components/catalog/detail-hero"; import { CastRowSkeleton } from "@/components/catalog/detail-skeletons"; import { JsonLd } from "@/components/catalog/json-ld"; -import { CastRow } from "@/components/movie/cast-row"; +import { ShowCrew, ShowFacts } from "@/components/catalog/title-details"; +import { + AgeRatingSlot, + MoreLikeThisSection, + TrailerSlot, + WhereToWatchSection, +} from "@/components/catalog/title-extras"; +import { SECTION_HEADING_CLASS } from "@/components/catalog/title-section"; +import { CastRow, CastSection } from "@/components/movie/cast-row"; import { MovieOverview } from "@/components/movie/movie-overview"; import { RetryLink } from "@/components/retry-link"; import { SeasonGrid } from "@/components/show/season-grid"; @@ -77,6 +85,11 @@ export async function generateMetadata({ * user's calculated ratings (spec 0012) stream from `components/tracking/` * beside the Seasons heading and on each season card, and the hero's tracking * pill (spec 0020) and progress line (spec 0013) stream into its tracking place. + * + * Spec 0022 adds the Trailer button and age chip to the hero, the creators + * under the overview, then Where to watch, Cast, More like this and Facts + * after the seasons (AC-17), each extras section in its own boundary, as on + * the movie page. */ export default function ShowPage({ params }: PageProps<"/shows/[id]">) { return ( @@ -112,6 +125,7 @@ async function ShowDetail({ } const { show } = result; + const title = { mediaType: "tv", id } as const; return (
    @@ -126,6 +140,7 @@ async function ShowDetail({ airSpan={formatAirSpan(show)} status={show.status} genres={show.genres} + ageRating={} /> } tmdbRating={show.tmdbRating} @@ -143,19 +158,21 @@ async function ShowDetail({ } + actions={} />
    -

    +

    Overview

    +
    -

    +

    Seasons

    @@ -178,21 +195,21 @@ async function ShowDetail({ />
    -
    -

    - Cast -

    + + + }> -
    + + + + +
    ); } -const SECTION_HEADING = - "text-xl leading-tight font-bold text-foreground md:text-2xl"; - const NO_CAST_MESSAGE = "TMDB lists no cast for this show."; /** Failures a retry can fix; the rest are cached as settled answers. */ diff --git a/app/title-pages.test.ts b/app/title-pages.test.ts new file mode 100644 index 0000000..c315b19 --- /dev/null +++ b/app/title-pages.test.ts @@ -0,0 +1,107 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +/** + * covers: spec 0022, AC-10, AC-17 + * + * Two properties of the title page modules that rendering cannot pin cheaply: + * the order the sections are written in, and that the metadata path never + * reaches the region or the extras. A grep, in the shape of + * `app/layout-purity.test.ts`, because both are about what a module names. + * The rendered order and the prerendered shells are checked live by + * `/check verify` and the `pnpm build` route table. + */ +const MOVIE_PAGE = readFileSync("app/movies/[id]/page.tsx", "utf8"); +const SHOW_PAGE = readFileSync("app/shows/[id]/page.tsx", "utf8"); + +/** Each marker's offset in the source, failing loudly on a missing one. */ +function offsets(source: string, markers: string[]): number[] { + return markers.map((marker) => { + const at = source.indexOf(marker); + expect(at, `${marker} is missing`).toBeGreaterThanOrEqual(0); + return at; + }); +} + +/** True when the numbers only ever go up. */ +function ascending(values: number[]): boolean { + return values.every( + (value, index) => index === 0 || value > values[index - 1], + ); +} + +/** The body of `generateMetadata`, up to the first top level closing brace. */ +function metadataBody(source: string): string { + const start = source.indexOf("export async function generateMetadata"); + expect(start).toBeGreaterThanOrEqual(0); + const end = source.indexOf("\n}\n", start); + return source.slice(start, end); +} + +describe("title page section order (AC-17)", () => { + it("reads overview, crew, where to watch, cast, more like this, facts on a movie", () => { + const order = offsets(MOVIE_PAGE, [ + " { + const hero = MOVIE_PAGE.slice( + MOVIE_PAGE.indexOf(" { + const order = offsets(SHOW_PAGE, [ + " { + const hero = SHOW_PAGE.slice( + SHOW_PAGE.indexOf(" { + it.each([ + ["the movie page", MOVIE_PAGE], + ["the show page", SHOW_PAGE], + ])( + "generateMetadata on %s names no region, rating or extras", + (_, source) => { + expect(metadataBody(source)).not.toMatch( + /getVisitorRegion|Extras|ageRating|AgeRating|watchProviders|headers\(/, + ); + }, + ); + + it("keeps the region out of the shell: only the extras slots read it", () => { + for (const source of [MOVIE_PAGE, SHOW_PAGE]) { + expect(source).not.toMatch(/getVisitorRegion|from "@\/lib\/region"/); + } + }); +}); diff --git a/components/AGENTS.md b/components/AGENTS.md index 84e4c8b..9455b8a 100644 --- a/components/AGENTS.md +++ b/components/AGENTS.md @@ -34,7 +34,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline` - **The page background** (`layout/page-background.tsx`, rendered first in `` by the root layout) is fixed at `-z-10`, `aria-hidden` and `pointer-events-none`; its teal gradient (`page-gradient`) and vignette (`page-vignette`) are in the prerendered HTML and are the first paint. `PageBackgroundCanvas` imports `lib/film-scene/start.ts` when the browser is idle, which draws the film strips and bokeh with hand written WebGL2 (no Three.js) in a worker through `OffscreenCanvas`, or on the main thread where a worker cannot draw. Every colour it uses is read from `globals.css` tokens at mount. It stops in a hidden tab, draws one still frame under reduced motion, and falls back to the gradient and vignette alone without WebGL2 or after a lost context. Every fragment is capped so `text-muted-foreground` keeps 4.5:1 over its brightest pixel, read from that token at mount. Nothing in the shell may paint an opaque full page background over it. - **A dead end is a bug.** `StatePanel`'s `error` and `signed-out` variants cannot render without an action. The discriminated union makes it a type error and the component throws if the type is bypassed. - **Touch targets** are 44px on mobile and 36px on desktop, the `touch` and `sm` sizes in `buttonVariants`. A control drawn smaller than 44px on mobile carries `hit-area` instead (root AGENTS.md section 3). One accepted exception: the footer's `kris1027` credit link has a 36px tap area, because 44px would mean taking taps from the legal links above it or widening the gap and moving the line. -- Posters render through `next/image` with `fill` inside `aspect-[2/3]`, using the `POSTER_SIZES` string exported by `poster-card.tsx` so it stays matched to the grid's column counts (2 below `md`, 4 from it, in a grid capped at 1200px and centred, so a 20 title page is 5 full rows; prompts/grid-tabs-footer-legend.md). +- Posters render through `next/image` with `fill` inside `aspect-[2/3]`, using the `sizes` of the `PosterGrid` preset it sits in. `catalog` (every title grid) steps 2, 3, 4, 5 and 6 columns so a card stays near the home row card's 180px; `wide` (the season grid only) keeps 2 and 4 (spec 0022). Each preset's columns, `POSTER_GRID_SIZES` and `POSTER_GRID_EAGER` in `poster-grid.tsx` change together, and `poster-grid.test.tsx` recomputes the sizes. `POSTER_SIZES` in `poster-card.tsx` is the `catalog` value. The grid is capped at 1200px and centred. - A missing poster renders the fallback tile at an identical footprint, so the grid never shifts. Never invent a placeholder image or placeholder metadata. ## Layout @@ -43,6 +43,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline` - `components/layout/` holds the app shell pieces. The navbar ships only its signed out form; the signed in variant arrives with feature 6 and must not be built by reading a client supplied identity. - Everything else at the top level is a content piece: `glass-pill`, `rating-badges`, `poster-card`, `poster-grid`, `state-panel`, `skeleton`, `pagination-links`, `retry-link`. - `components/movie/` holds the movie page pieces (`MovieHero`, `MovieOverview`, `CastRow`, `MovieDetailSkeleton`), all Server Components. Feature 10 should reuse their structure for TV rather than design a second page (spec 0006). +- `components/catalog/` holds the title page sections both `/movies/{id}` and `/shows/{id}` share (spec 0022): `title-details.tsx` (crew and Facts, from the detail read), and `title-extras.tsx`, `where-to-watch.tsx` and `trailer-button.tsx`, fed by the separate extras read. The extras sections stream in their own Suspense boundary and read the visitor's region there through `getVisitorRegion()` (`lib/region.ts`), never in the shell. The trailer iframe (`youtube-nocookie`) mounts only while its dialog is open. - `components/library/` holds the three private library pages, `/watchlist`, `/upcoming` and `/watched` (spec 0020). `LibrarySection` reads one tab per request; show cards are Server Components (`watchlist-show-card.tsx` streams its season read per card, `show-cards.tsx` holds the Upcoming and Watched cards), and the client pieces, `library-grid.tsx` (movies), `mark-next-watched-button.tsx` and `missing-show-card.tsx`, sit outside the shell, so the shell's client boundary list above is unchanged. `ids.ts` is the one home of the focus ids the server cards and those client controls share. Show tracking writes from any surface go through `components/tracking/use-show-tracking.ts`, which owns the Stop tracking Undo. - `components/layout/site-footer.tsx` renders under every route from the root layout and must read no request state (`app/layout-purity.test.ts`). It is flat (no glass, no border) and carries no email address. Its credit line links `OPERATOR_HANDLE` to `OPERATOR_GITHUB_URL`, both from `lib/legal/operator.ts`. `components/legal/` holds the reading column and helpers `/privacy` and `/terms` share; use `LEGAL_LINK_CLASS` for any link in legal text (spec 0017). - The card marks are explained once, by `layout/icon-key.tsx`, the footer's first row on every route; there is no per page legend. A new mark adds its entry there, and a new badge must reuse `GlassPill` rather than re-derive it. diff --git a/components/catalog/catalog-list-page.test.tsx b/components/catalog/catalog-list-page.test.tsx index 07c62b2..58a2bed 100644 --- a/components/catalog/catalog-list-page.test.tsx +++ b/components/catalog/catalog-list-page.test.tsx @@ -200,13 +200,13 @@ describe("the list page grid (AC-4, AC-9)", () => { ); }); - it("loads the first 4 posters eagerly and the rest lazily (AC-7)", async () => { + it("loads the first 6 posters eagerly and the rest lazily (AC-7; spec 0022, AC-5)", async () => { read.mockResolvedValue(tvPage(8, 30)); await renderGrid(); - expect(screen.getByText("Show 4")).toHaveAttribute("data-priority", "true"); - expect(screen.getByText("Show 5")).toHaveAttribute( + expect(screen.getByText("Show 6")).toHaveAttribute("data-priority", "true"); + expect(screen.getByText("Show 7")).toHaveAttribute( "data-priority", "false", ); diff --git a/components/catalog/catalog-list-page.tsx b/components/catalog/catalog-list-page.tsx index 8c862f1..e15992e 100644 --- a/components/catalog/catalog-list-page.tsx +++ b/components/catalog/catalog-list-page.tsx @@ -4,7 +4,7 @@ import { Suspense } from "react"; import { CatalogListCards } from "@/components/catalog/catalog-list-cards"; import { PaginationLinks } from "@/components/pagination-links"; -import { PosterGrid } from "@/components/poster-grid"; +import { POSTER_GRID_EAGER, PosterGrid } from "@/components/poster-grid"; import { RetryLink } from "@/components/retry-link"; import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton"; import { StatePanel } from "@/components/state-panel"; @@ -21,13 +21,6 @@ import { TmdbError } from "@/lib/tmdb"; /** TMDB's list page size, which the skeleton mirrors. */ const PAGE_SIZE = 20; -/** - * Cards whose posters load eagerly: one full row at the widest grid (4 - * columns from `md`), so the first row is never lazy at any width (spec 0021, - * AC-7, keeping spec 0006, AC-1). - */ -const EAGER_POSTERS = 4; - const LANDING_LABELS = { tv: "Shows", movie: "Movies" } as const; type SearchParams = Promise>; @@ -124,7 +117,7 @@ async function ListGrid({ diff --git a/components/catalog/detail-hero.tsx b/components/catalog/detail-hero.tsx index 1d47305..4074ebe 100644 --- a/components/catalog/detail-hero.tsx +++ b/components/catalog/detail-hero.tsx @@ -28,6 +28,12 @@ type DetailHeroProps = { * This component reads no session itself, so it stays a plain catalog piece. */ tracking?: React.ReactNode; + /** + * Public actions beside the tracking row: the Trailer button (spec 0022, + * AC-11), streamed in its own boundary with no fallback, so it reserves no + * height and appears only when the title has a trailer. + */ + actions?: React.ReactNode; }; /** @@ -54,6 +60,7 @@ function DetailHero({ tmdbRating, tmdbVoteCount, tracking, + actions, }: DetailHeroProps) { return (
    @@ -128,6 +135,7 @@ function DetailHero({ {meta} {tracking} + {actions}
    diff --git a/components/catalog/detail-skeletons.tsx b/components/catalog/detail-skeletons.tsx index 9336e93..b934a3f 100644 --- a/components/catalog/detail-skeletons.tsx +++ b/components/catalog/detail-skeletons.tsx @@ -1,6 +1,7 @@ import { cn } from "cn"; -import { Skeleton } from "@/components/skeleton"; +import { ROW_ITEM_CLASS } from "@/components/catalog/carousel-row-states"; +import { PosterCardSkeleton, Skeleton } from "@/components/skeleton"; /** * The `DetailHero` while TMDB answers, at its footprint (spec 0006, AC-11; @@ -103,36 +104,36 @@ const OVERVIEW_LINES = [ ]; /** - * One row of cast cards at `CastRow`'s card widths, without a heading. The - * caption reserves two lines each for the name and the character, the most - * `CastRow` wraps them to, and `pb-3` matches the row's own padding. + * One row of cast cards at `CastRow`'s row card width, without a heading: + * the 2:3 frame, the name line and the character line, as on a media card. + * Inside the cast carousel it is the cards' fallback; on a page skeleton, + * `CastRowSkeletonFrame` stands in for the carousel's scroll region. */ function CastRowSkeleton() { return ( -
    - {Array.from({ length: 8 }, (_, index) => ( -
    - -
    -
    - -
    -
    - -
    -
    -
    + + ); +} + +/** The cast row skeleton clipped and padded like the carousel's region. */ +function CastRowSkeletonFrame() { + return ( +
    +
    ); } export { CastRowSkeleton, + CastRowSkeletonFrame, DetailHeroSkeleton, OverviewSkeleton, SectionHeadingSkeleton, diff --git a/components/catalog/title-details.test.tsx b/components/catalog/title-details.test.tsx new file mode 100644 index 0000000..51157e4 --- /dev/null +++ b/components/catalog/title-details.test.tsx @@ -0,0 +1,166 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { Movie, TvShow } from "@/lib/tmdb"; + +import { + AgeRatingChip, + MovieCrew, + MovieFacts, + ShowCrew, + ShowFacts, +} from "./title-details"; + +/** + * covers: spec 0022, AC-13, AC-15, AC-16 + * + * The crew lines, the age chip and the Facts panel: each value only when + * TMDB has one, never Unknown or 0, and the panel gone when nothing is left. + */ + +const MOVIE = { + title: "Fight Club", + originalTitle: "Fight Club", + originalLanguage: "en", + status: "Released", + budget: 63_000_000, + revenue: 100_853_753, + productionCompanies: [ + { id: 1, name: "Fox 2000 Pictures", logoUrl: null }, + { id: 2, name: "Regency Enterprises", logoUrl: null }, + ], + directors: ["David Fincher"], + writers: ["Jim Uhls"], +} as Movie; + +const SHOW = { + status: "Ended", + type: "Scripted", + originalLanguage: "en", + networks: [ + { id: 174, name: "AMC", logoUrl: "https://image.tmdb.org/t/p/w154/a.png" }, + { id: 2, name: "No Logo TV", logoUrl: null }, + ], + productionCompanies: [], + firstAirDate: "2008-01-20", + lastAirDate: "2013-09-29", + createdBy: ["Vince Gilligan"], +} as unknown as TvShow; + +/** The panel's facts as `label: value` pairs, in order. */ +function facts(): string[] { + return Array.from(document.querySelectorAll("dl > div")).map( + (row) => + `${row.querySelector("dt")?.textContent}: ${row.querySelector("dd")?.textContent}`, + ); +} + +describe("crew lines (AC-15)", () => { + it("shows Directed by and Written by for a movie", () => { + render(); + + expect(screen.getByText("Directed by")).toBeVisible(); + expect(screen.getByText("A, B")).toBeVisible(); + expect(screen.getByText("Written by")).toBeVisible(); + }); + + it("leaves out a line with no names, and renders nothing with none", () => { + const { container, rerender } = render( + , + ); + expect(screen.queryByText("Written by")).toBeNull(); + + rerender(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows Created by for a show", () => { + render(); + + expect(screen.getByText("Created by")).toBeVisible(); + expect(screen.getByText("Vince Gilligan")).toBeVisible(); + }); +}); + +describe("AgeRatingChip (AC-9, AC-13)", () => { + it("shows the rating and names the country", () => { + render( + , + ); + + expect(screen.getByText("16")).toBeVisible(); + expect(screen.getByText(/Age rating in Germany/)).toBeInTheDocument(); + }); +}); + +describe("MovieFacts (AC-16)", () => { + it("lists each fact TMDB has, money in compact US dollars", () => { + render(); + + expect(screen.getByRole("heading", { name: "Facts" })).toBeVisible(); + expect(facts()).toEqual([ + "Status: Released", + "Original language: English", + "Budget: $63M", + "Revenue: $100.9M", + "Production companies: Fox 2000 Pictures, Regency Enterprises", + ]); + }); + + it("shows the original title only when it differs", () => { + render(); + + expect(facts()).toContain("Original title: Le Fight Club"); + }); + + it("never shows Unknown or 0 for a missing value", () => { + render( + , + ); + + expect(facts()).toEqual(["Original language: English"]); + expect(document.body.textContent).not.toMatch(/Unknown|\$0/); + }); +}); + +describe("ShowFacts (AC-16)", () => { + it("lists status, type, language, networks and both air dates", () => { + render(); + + expect(facts()).toEqual([ + "Status: Ended", + "Type: Scripted", + "Original language: English", + "Networks: AMCNo Logo TV", + "First aired: Jan 20, 2008", + "Last aired: Sep 29, 2013", + ]); + }); + + it("leaves out an empty status and missing air dates", () => { + render( + , + ); + + expect(facts()).toEqual(["Original language: English"]); + }); +}); diff --git a/components/catalog/title-details.tsx b/components/catalog/title-details.tsx new file mode 100644 index 0000000..9cf9006 --- /dev/null +++ b/components/catalog/title-details.tsx @@ -0,0 +1,185 @@ +import Image from "next/image"; +import type * as React from "react"; + +import { GlassPill } from "@/components/glass-pill"; +import type { Region } from "@/lib/catalog/region"; +import { formatAirDate, formatUsdCompact, languageName } from "@/lib/format"; +import type { Company, Movie, TvShow } from "@/lib/tmdb"; + +import { SECTION_HEADING_CLASS } from "./title-section"; + +/** + * The key crew under the overview (spec 0022, AC-15): "Directed by" and + * "Written by" for a movie, "Created by" for a show. Each line only when it + * has a name, and nothing at all when none does. + */ +function CrewLines({ + lines, +}: { + lines: ReadonlyArray<{ label: string; names: string[] }>; +}) { + const shown = lines.filter((line) => line.names.length > 0); + if (shown.length === 0) return null; + + return ( +
    + {shown.map((line) => ( +
    +
    {line.label}
    +
    + {line.names.join(", ")} +
    +
    + ))} +
    + ); +} + +/** A movie's crew lines, from the crew `getMovie` already read. */ +function MovieCrew({ movie }: { movie: Pick }) { + return ( + + ); +} + +/** A show's crew line, from `created_by` on `getTvShow`. */ +function ShowCrew({ show }: { show: Pick }) { + return ; +} + +/** + * The hero's age rating chip for the visitor's country (spec 0022, AC-13). + * The country is named for assistive technology and on hover, since a bare + * "16" means little without it (AC-9). Rendered only when the country has a + * rating; another country's is never borrowed. + */ +function AgeRatingChip({ rating, region }: { rating: string; region: Region }) { + return ( + + Age rating in {region.name}: + {rating} + + ); +} + +type Fact = { label: string; value: React.ReactNode }; + +/** + * The Facts panel at the end of a title page (spec 0022, AC-16): a list of + * labelled values, each only when TMDB has one. Nothing is shown as Unknown + * or 0; with no facts at all the panel is left out. + */ +function FactsPanel({ facts }: { facts: Array }) { + const shown = facts.filter((fact): fact is Fact => fact !== null); + if (shown.length === 0) return null; + + return ( +
    +

    + Facts +

    +
    + {shown.map((fact) => ( +
    +
    {fact.label}
    +
    + {fact.value} +
    +
    + ))} +
    +
    + ); +} + +/** A fact with a text value, or null when there is no value. */ +function textFact(label: string, value: string | null): Fact | null { + return value === null || value === "" ? null : { label, value }; +} + +/** Company names joined, or null when TMDB lists none. */ +function companiesFact(label: string, companies: Company[]): Fact | null { + return companies.length === 0 + ? null + : { label, value: companies.map((company) => company.name).join(", ") }; +} + +/** A movie's facts, all from `getMovie` (AC-16). */ +function MovieFacts({ movie }: { movie: Movie }) { + return ( + + ); +} + +/** A show's facts, all from `getTvShow` (AC-16). */ +function ShowFacts({ show }: { show: TvShow }) { + return ( + , + }, + companiesFact("Production companies", show.productionCompanies), + textFact("First aired", formatAirDate(show.firstAirDate)), + textFact("Last aired", formatAirDate(show.lastAirDate)), + ]} + /> + ); +} + +/** + * A show's networks: each `w154` logo on a mid grey tile, followed by the + * name. TMDB's network logos come both white and dark, so no single tile + * keeps every one readable, and the name is what always reads; the logo is + * decoration beside it (`alt=""`). With no logo, the name alone. + */ +function NetworkList({ networks }: { networks: Company[] }) { + return ( +
      + {networks.map((network) => ( +
    • + {network.logoUrl === null ? null : ( + + + + )} + {network.name} +
    • + ))} +
    + ); +} + +export { AgeRatingChip, MovieCrew, MovieFacts, ShowCrew, ShowFacts }; diff --git a/components/catalog/title-extras.test.tsx b/components/catalog/title-extras.test.tsx new file mode 100644 index 0000000..1002621 --- /dev/null +++ b/components/catalog/title-extras.test.tsx @@ -0,0 +1,286 @@ +import { render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * covers: spec 0022, AC-9, AC-11, AC-12, AC-13, AC-14, AC-18 + * + * The extras slots on their own: the region picks the entry after the read, + * an empty or missing value leaves its piece out, and a `TmdbError` (a 404 + * included) stays inside the slot that asked, as a one line error with a + * Retry to the title page. The reads, the region and the cards are replaced; + * `TmdbError` is the real one. + */ +const readMovie = vi.fn(); +const readTv = vi.fn(); +const region = vi.fn(); + +vi.mock("@/lib/tmdb", async () => ({ + TmdbError: (await import("@/lib/tmdb/errors")).TmdbError, + getMovieExtras: (...args: unknown[]) => readMovie(...args), + getTvExtras: (...args: unknown[]) => readTv(...args), +})); +vi.mock("@/lib/region", () => ({ getVisitorRegion: () => region() })); +vi.mock("@/components/catalog/list-cards", () => ({ + MovieListCard: ({ + movie, + returnPath, + }: { + movie: { title: string }; + returnPath: string; + }) => {movie.title}, + ShowListCard: ({ show }: { show: { name: string } }) => ( + {show.name} + ), +})); + +const { TmdbError } = await import("@/lib/tmdb/errors"); +const { JUSTWATCH_ATTRIBUTION } = await import("@/lib/legal/operator"); +const { + AgeRatingSlotContent, + MoreLikeThisContent, + TrailerSlotContent, + WhereToWatchContent, +} = await import("./title-extras"); + +const POLAND = { code: "PL", name: "Poland" }; +const MOVIE = { mediaType: "movie", id: 550 } as const; + +function provider(id: number, name: string) { + return { id, name, logoUrl: `https://image.tmdb.org/t/p/w92/${id}.png` }; +} + +function extras(overrides: Record = {}) { + return { + trailer: { key: "dfeUzm6KF4g", name: "Official Trailer" }, + recommendations: { + mediaType: "movie", + paged: { + page: 1, + totalPages: 2, + totalResults: 40, + results: Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + title: `Movie ${index + 1}`, + })), + }, + }, + watchProviders: { + PL: { + link: "https://www.themoviedb.org/movie/550/watch?locale=PL", + stream: [provider(8, "Netflix")], + free: [], + rentOrBuy: [ + provider(10, "Amazon Video"), + { ...provider(11, "No Logo"), logoUrl: null }, + ], + }, + US: { + link: "https://www.themoviedb.org/movie/550/watch?locale=US", + stream: [provider(9, "Hulu")], + free: [], + rentOrBuy: [], + }, + }, + ageRatings: { PL: "18", US: "R" }, + ...overrides, + }; +} + +beforeEach(() => { + // The row's scroller measures itself; jsdom has no ResizeObserver. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + readMovie.mockReset(); + readTv.mockReset(); + region.mockReset(); +}); + +describe("TrailerSlotContent (AC-11)", () => { + it("renders the Trailer button for a title with a trailer", async () => { + readMovie.mockResolvedValue(extras()); + + render(await TrailerSlotContent({ ...MOVIE, title: "Fight Club" })); + + expect(readMovie).toHaveBeenCalledWith(550); + expect(screen.getByRole("button", { name: "Trailer" })).toBeVisible(); + // The player does not exist until the dialog opens. + expect(document.querySelector("iframe")).toBeNull(); + }); + + it("renders nothing with no trailer, or when the read failed (AC-18)", async () => { + readMovie.mockResolvedValue(extras({ trailer: null })); + expect(await TrailerSlotContent({ ...MOVIE, title: "X" })).toBeNull(); + + readMovie.mockRejectedValue(new TmdbError("timeout", "/movie/550", "x")); + expect(await TrailerSlotContent({ ...MOVIE, title: "X" })).toBeNull(); + }); + + it("reads a show's extras for a show", async () => { + readTv.mockResolvedValue(extras({ trailer: null })); + + await TrailerSlotContent({ mediaType: "tv", id: 1396, title: "X" }); + + expect(readTv).toHaveBeenCalledWith(1396); + expect(readMovie).not.toHaveBeenCalled(); + }); + + it("rethrows anything that is not a TMDB failure", async () => { + readMovie.mockRejectedValue(new Error("bug")); + + await expect(TrailerSlotContent({ ...MOVIE, title: "X" })).rejects.toThrow( + "bug", + ); + }); +}); + +describe("AgeRatingSlotContent (AC-9, AC-13)", () => { + it("shows the visitor's country rating, naming the country", async () => { + readMovie.mockResolvedValue(extras()); + region.mockResolvedValue(POLAND); + + render(<>{await AgeRatingSlotContent(MOVIE)}); + + expect(screen.getByText("18")).toBeVisible(); + expect(screen.getByText(/Age rating in Poland/)).toBeInTheDocument(); + }); + + it("never borrows another country's rating", async () => { + readMovie.mockResolvedValue(extras({ ageRatings: { US: "R" } })); + region.mockResolvedValue(POLAND); + + expect(await AgeRatingSlotContent(MOVIE)).toBeNull(); + }); + + it("renders nothing when the read failed (AC-18)", async () => { + readMovie.mockRejectedValue(new TmdbError("not_found", "/movie/550", "x")); + region.mockResolvedValue(POLAND); + + expect(await AgeRatingSlotContent(MOVIE)).toBeNull(); + }); +}); + +describe("WhereToWatchContent (AC-9, AC-12, AC-18)", () => { + it("names the country and lists only its non empty groups, in order", async () => { + readMovie.mockResolvedValue(extras()); + region.mockResolvedValue(POLAND); + + render(await WhereToWatchContent(MOVIE)); + + expect( + screen.getByRole("heading", { name: "Where to watch in Poland" }), + ).toBeVisible(); + const groups = screen + .getAllByRole("list") + .map((list) => list.getAttribute("aria-label")); + expect(groups).toEqual(["Stream", "Rent or buy"]); + expect( + within(screen.getByRole("list", { name: "Stream" })).getByRole("img", { + name: "Netflix", + }), + ).toBeVisible(); + expect(screen.queryByRole("img", { name: "Hulu" })).toBeNull(); + // A provider with no logo shows its name. + expect(screen.getByText("No Logo")).toBeVisible(); + }); + + it("links once to TMDB's page for the country, with the JustWatch credit", async () => { + readMovie.mockResolvedValue(extras()); + region.mockResolvedValue(POLAND); + + render(await WhereToWatchContent(MOVIE)); + + const link = screen.getByRole("link", { name: /All options on TMDB/ }); + expect(link).toHaveAttribute( + "href", + "https://www.themoviedb.org/movie/550/watch?locale=PL", + ); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(screen.getByText(JUSTWATCH_ATTRIBUTION)).toBeVisible(); + }); + + it("says plainly when the country has no providers", async () => { + readMovie.mockResolvedValue(extras({ watchProviders: {} })); + region.mockResolvedValue(POLAND); + + render(await WhereToWatchContent(MOVIE)); + + expect( + screen.getByText("Not streaming in Poland right now."), + ).toBeVisible(); + expect(screen.getByText(JUSTWATCH_ATTRIBUTION)).toBeVisible(); + expect(screen.queryByRole("link")).toBeNull(); + }); + + it("keeps a failure to one line with a Retry to the page (AC-18)", async () => { + readMovie.mockRejectedValue(new TmdbError("not_found", "/movie/550", "x")); + region.mockResolvedValue(POLAND); + + render(await WhereToWatchContent(MOVIE)); + + expect( + screen.getByText("Couldn't load where to watch from TMDB."), + ).toBeVisible(); + expect(screen.getByRole("link", { name: "Try again" })).toHaveAttribute( + "href", + "/movies/550", + ); + }); +}); + +describe("MoreLikeThisContent (AC-14, AC-18)", () => { + it("shows up to 12 recommendations, returning to the title page", async () => { + readMovie.mockResolvedValue(extras()); + + render(<>{await MoreLikeThisContent(MOVIE)}); + + expect( + screen.getByRole("heading", { name: "More like this" }), + ).toBeVisible(); + expect(screen.getByText("Movie 12")).toHaveAttribute( + "data-return", + "/movies/550", + ); + expect(screen.queryByText("Movie 13")).toBeNull(); + expect(screen.queryByRole("link", { name: /See all/ })).toBeNull(); + }); + + it("leaves the whole section out with no recommendations", async () => { + readMovie.mockResolvedValue( + extras({ + recommendations: { + mediaType: "movie", + paged: { page: 1, results: [], totalPages: 0, totalResults: 0 }, + }, + }), + ); + + expect(await MoreLikeThisContent(MOVIE)).toBeNull(); + }); + + it("keeps its heading with a one line error and Retry on failure", async () => { + readTv.mockRejectedValue(new TmdbError("upstream", "/tv/1396", "x")); + + render(<>{await MoreLikeThisContent({ mediaType: "tv", id: 1396 })}); + + expect( + screen.getByRole("heading", { name: "More like this" }), + ).toBeVisible(); + expect( + screen.getByText("Couldn't load recommendations from TMDB."), + ).toBeVisible(); + expect(screen.getByRole("link", { name: "Try again" })).toHaveAttribute( + "href", + "/shows/1396", + ); + }); +}); diff --git a/components/catalog/title-extras.tsx b/components/catalog/title-extras.tsx new file mode 100644 index 0000000..8d20450 --- /dev/null +++ b/components/catalog/title-extras.tsx @@ -0,0 +1,248 @@ +import type * as React from "react"; +import { cache, Suspense } from "react"; + +import { + CarouselRowSkeleton, + ROW_ITEM_CLASS, + ROW_POSTER_SIZES, + ROW_SIZE, +} from "@/components/catalog/carousel-row-states"; +import { CarouselScroller } from "@/components/catalog/carousel-scroller"; +import { CatalogListCards } from "@/components/catalog/catalog-list-cards"; +import { type MediaType, titleHref } from "@/lib/catalog/media-type"; +import { getVisitorRegion } from "@/lib/region"; +import { + getMovieExtras, + getTvExtras, + type MovieExtras, + TmdbError, + type TvExtras, +} from "@/lib/tmdb"; + +import { AgeRatingChip } from "./title-details"; +import { SECTION_HEADING_CLASS, SectionErrorLine } from "./title-section"; +import { TrailerButton } from "./trailer-button"; +import { + WhereToWatch, + WhereToWatchError, + WhereToWatchSkeleton, +} from "./where-to-watch"; + +/** + * The title page sections fed by the extras read (spec 0022, AC-11 to + * AC-14, AC-18), shared by the movie and show pages. + * + * Each slot is its own async Server Component inside its own Suspense + * boundary and calls `readExtras` itself. The calls share one read: the + * cached `getMovieExtras` or `getTvExtras` underneath, and React `cache()` + * on top, so one render asks once. The visitor's region is read only in the + * slots that need it, outside the cache and inside their boundaries, so the + * title pages keep their prerendered shells (AC-9, AC-10). + */ + +/** Which extras read a slot makes, and the page a Retry reloads. */ +type TitleRef = { mediaType: MediaType; id: number }; + +/** A `TitleRef` with the title's name, which the trailer dialog is labelled by. */ +type NamedTitleRef = TitleRef & { title: string }; + +/** + * The title's extras, or null when TMDB failed, a 404 included (AC-18). A + * `TmdbError` stays inside the slot that asked; anything else is a bug and + * is rethrown. + */ +const readExtras = cache( + async ( + mediaType: TitleRef["mediaType"], + id: number, + ): Promise => { + try { + return mediaType === "movie" + ? await getMovieExtras(id) + : await getTvExtras(id); + } catch (error) { + if (!(error instanceof TmdbError)) throw error; + return null; + } + }, +); + +/** + * The hero's Trailer button (AC-11), or nothing: while the extras load, + * when they failed, and when the title has no usable trailer. + */ +function TrailerSlot(props: NamedTitleRef) { + return ( + + + + ); +} + +/** + * The trailer slot's body. Exported apart from its Suspense boundary so the + * tests can await it as the async Server Component it is; pages render + * `TrailerSlot`. + */ +async function TrailerSlotContent({ mediaType, id, title }: NamedTitleRef) { + const trailer = (await readExtras(mediaType, id))?.trailer ?? null; + if (trailer === null) return null; + return ( + + ); +} + +/** + * The hero's age rating chip for the visitor's country (AC-13), or nothing: + * while loading, on failure, and when that country has no rating. + */ +function AgeRatingSlot(props: TitleRef) { + return ( + + + + ); +} + +/** + * The age rating slot's body. Exported apart from its Suspense boundary for + * the tests, like `TrailerSlotContent`; pages render `AgeRatingSlot`. It + * reads the region here, never above the boundary (AC-9). + */ +async function AgeRatingSlotContent({ mediaType, id }: TitleRef) { + const [extras, region] = await Promise.all([ + readExtras(mediaType, id), + getVisitorRegion(), + ]); + const rating = extras?.ageRatings[region.code]; + if (rating === undefined) return null; + return ; +} + +/** Where to watch in the visitor's country, with its own states (AC-12, AC-18). */ +function WhereToWatchSection(props: TitleRef) { + return ( + }> + + + ); +} + +/** + * Where to watch's body. Exported apart from its Suspense boundary for the + * tests, like `TrailerSlotContent`; pages render `WhereToWatchSection`. It + * reads the region here, never above the boundary (AC-9). + */ +async function WhereToWatchContent(props: TitleRef) { + const [extras, region] = await Promise.all([ + readExtras(props.mediaType, props.id), + getVisitorRegion(), + ]); + if (extras === null) { + return ( + + ); + } + return ( + + ); +} + +/** + * More like this (AC-14): up to 12 of TMDB's recommendations in the home + * row card, through the same `CatalogListCards` as a landing row, so each + * card carries the visitor's tracked, planned or watched status. No See all, + * since TMDB's recommendations have no list page here. With none, the whole + * section is left out; on failure it keeps its heading with a one line + * error and Retry. + */ +function MoreLikeThisSection(props: TitleRef) { + return ( + + + + } + > + + + ); +} + +/** + * More like this's body. Exported apart from its Suspense boundary for the + * tests, like `TrailerSlotContent`; pages render `MoreLikeThisSection`. + */ +async function MoreLikeThisContent(props: TitleRef) { + const extras = await readExtras(props.mediaType, props.id); + if (extras === null) { + return ( + + + + ); + } + if (extras.recommendations.paged.results.length === 0) return null; + + return ( + +
      + +
    +
    + ); +} + +/** The section, its heading and the row's arrow scroller, in every state. */ +function MoreLikeThisFrame({ children }: { children: React.ReactNode }) { + return ( +
    + + More like this + + } + seeAll={null} + > + {children} + +
    + ); +} + +export { + AgeRatingSlot, + AgeRatingSlotContent, + MoreLikeThisContent, + MoreLikeThisSection, + TrailerSlot, + TrailerSlotContent, + WhereToWatchContent, + WhereToWatchSection, +}; diff --git a/components/catalog/title-section.tsx b/components/catalog/title-section.tsx new file mode 100644 index 0000000..3fba6ec --- /dev/null +++ b/components/catalog/title-section.tsx @@ -0,0 +1,35 @@ +import { RetryLink } from "@/components/retry-link"; + +/** + * The h2 look every title page section shares: Overview, Seasons, Where to + * watch, Cast, More like this and Facts (spec 0006, spec 0009, spec 0022). + * One constant, so a new section cannot drift from the old ones. + */ +export const SECTION_HEADING_CLASS = + "text-xl leading-tight font-bold text-foreground md:text-2xl"; + +/** + * The one line a title page section shows when the extras read failed, with + * a full page Retry (spec 0022, AC-18). It names no cause: a 404, a timeout + * and a bad response all land here, and "didn't respond" would be untrue + * for the first and the last. + * + * @param what What failed to load, as it reads after "Couldn't load". + * @param href The title page, which the Retry reloads in full. + */ +export function SectionErrorLine({ + what, + href, +}: { + what: string; + href: string; +}) { + return ( +
    +

    + Couldn't load {what} from TMDB. +

    + +
    + ); +} diff --git a/components/catalog/trailer-button.test.tsx b/components/catalog/trailer-button.test.tsx new file mode 100644 index 0000000..3e3d37a --- /dev/null +++ b/components/catalog/trailer-button.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { TrailerButton } from "./trailer-button"; + +/** + * covers: spec 0022, AC-11, AC-20 + * + * Against the real Base UI dialog: no YouTube iframe exists until the + * visitor presses Trailer, the embed is the privacy enhanced one with the + * attributes the spec fixes, focus opens on Close (so Escape still reaches + * the dialog rather than the cross origin player) and returns to the button, + * and closing removes the player. + */ +function renderButton() { + render( + , + ); + return screen.getByRole("button", { name: "Trailer" }); +} + +describe("TrailerButton", () => { + it("loads nothing from YouTube until it is pressed", () => { + renderButton(); + + expect(document.querySelector("iframe")).toBeNull(); + }); + + it("opens the privacy enhanced player from the keyboard, focus on Close", async () => { + const user = userEvent.setup(); + const trigger = renderButton(); + + trigger.focus(); + await user.keyboard("{Enter}"); + + const dialog = await screen.findByRole("dialog", { + name: "Fight Club trailer", + }); + const iframe = dialog.querySelector("iframe"); + expect(iframe).toHaveAttribute( + "src", + "https://www.youtube-nocookie.com/embed/dfeUzm6KF4g?autoplay=1&rel=0&playsinline=1", + ); + expect(iframe).toHaveAttribute( + "title", + "Fight Club: Official Trailer (YouTube video player)", + ); + expect(iframe).toHaveAttribute( + "allow", + "autoplay; encrypted-media; picture-in-picture; fullscreen", + ); + expect(iframe).toHaveAttribute( + "referrerpolicy", + "strict-origin-when-cross-origin", + ); + await waitFor(() => { + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Close" }), + ); + }); + }); + + it("closes on Escape, removes the player and returns focus", async () => { + const user = userEvent.setup(); + const trigger = renderButton(); + + await user.click(trigger); + await screen.findByRole("dialog"); + await user.keyboard("{Escape}"); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).toBeNull(); + }); + expect(document.querySelector("iframe")).toBeNull(); + await waitFor(() => { + expect(document.activeElement).toBe(trigger); + }); + }); +}); diff --git a/components/catalog/trailer-button.tsx b/components/catalog/trailer-button.tsx new file mode 100644 index 0000000..28382f4 --- /dev/null +++ b/components/catalog/trailer-button.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { PlayIcon, XIcon } from "lucide-react"; +import { useRef } from "react"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +/** + * The hero's Trailer button and its player dialog (spec 0022, AC-11). + * + * The trigger is base-ui's `Dialog.Trigger`, so focus returns to it when the + * dialog closes. The YouTube iframe exists only while the dialog is open: the + * popup unmounts when closed, so no request reaches YouTube until the visitor + * presses Trailer, and closing it stops playback. The privacy enhanced + * `youtube-nocookie.com` host is the one YouTube joined `PROCESSORS` with + * (AC-19). Full screen below `md`, a 16:9 panel from it. + * + * Focus opens on the Close button rather than the player: once focus is + * inside the cross origin iframe, Escape goes to YouTube and never reaches + * the dialog, so a keyboard user could not close it. + * + * @param videoKey Already checked against `[A-Za-z0-9_-]{6,20}` by the + * normalizer, so it is safe in the embed path. + * @param videoName TMDB's name for the video, used in the iframe's title. + * @param title The movie or show title, so the dialog names what it plays. + */ +function TrailerButton({ + videoKey, + videoName, + title, +}: { + videoKey: string; + videoName: string; + title: string; +}) { + const closeRef = useRef(null); + + return ( + + + + + + {title} trailer + +
    +