Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
072ee89
docs(spec): design consistent card size and richer title pages (spec …
kris1027 Oct 12, 2026
880b97f
feat(ui): step the catalog grid to six columns, keep seasons wide
kris1027 Oct 12, 2026
7b4209d
feat(tmdb): read title extras and the new movie and show detail fields
kris1027 Oct 12, 2026
f644e54
feat(catalog): pick the visitor's region from the Vercel country header
kris1027 Oct 12, 2026
f5a3779
feat(legal): add YouTube, the region sentence and the JustWatch credit
kris1027 Oct 12, 2026
1249468
feat(movies): add trailer, age rating, crew, where to watch, more lik…
kris1027 Oct 12, 2026
fbe7b95
feat(shows): bring the show page to parity with the new title sections
kris1027 Oct 12, 2026
3a6bd8b
test(catalog): cover the title extras, region, crew, facts and traile…
kris1027 Oct 12, 2026
f0d65ec
docs(scope): tick the card size and title page build milestones
kris1027 Oct 12, 2026
c3f4559
docs(spec): add the card size and title page verify steps
kris1027 Oct 12, 2026
34434a4
feat(catalog): draw the cast as a carousel row of media cards
kris1027 Oct 12, 2026
2dbf086
docs(spec): record the card size and title page verify results
kris1027 Oct 12, 2026
b26c070
test(catalog): cover the visitor region read and where to watch states
kris1027 Oct 12, 2026
104b345
test(catalog): cover the show age chip and title page section order
kris1027 Oct 12, 2026
a49029f
docs(scope): tick the card size and title page test milestone
kris1027 Oct 12, 2026
3545fd2
docs(scope): tick the card size and title page build milestone
kris1027 Oct 12, 2026
5722ef9
chore(sync): reconcile docs for card size and title pages
kris1027 Oct 12, 2026
9f0090c
docs(catalog): say why the title extras slot bodies are exported
kris1027 Oct 12, 2026
900914b
refactor(tmdb): share one extras request between movies and shows
kris1027 Oct 12, 2026
8137ee2
refactor(catalog): build every title page URL with one titleHref
kris1027 Oct 12, 2026
ab6113c
refactor(catalog): share the title section error line and drop the un…
kris1027 Oct 12, 2026
28e1a44
refactor(tmdb): drop unnamed companies, crew and creators in one place
kris1027 Oct 12, 2026
48e0a10
refactor(movies): rename the hero meta row apart from the Facts panel
kris1027 Oct 12, 2026
5d24d57
refactor(catalog): name the titled ref the trailer slot takes
kris1027 Oct 12, 2026
a095031
docs(spec): amend spec 0022 with the cast row, network names and erro…
kris1027 Oct 12, 2026
c5e61d2
fix(format): drop the trailing .0 from compact dollars on Node 22
kris1027 Oct 12, 2026
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
41 changes: 28 additions & 13 deletions app/movies/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -107,6 +121,7 @@ async function MovieDetail({
}

const { movie } = result;
const title = { mediaType: "movie", id } as const;

return (
<article className="flex flex-col gap-10 md:gap-14">
Expand All @@ -130,33 +145,33 @@ async function MovieDetail({
/>
</Suspense>
}
actions={<TrailerSlot {...title} title={movie.title} />}
ageRating={<AgeRatingSlot {...title} />}
/>

<section
aria-labelledby="overview-heading"
className="flex flex-col gap-3"
>
<h2
id="overview-heading"
className="text-xl leading-tight font-bold text-foreground md:text-2xl"
>
<h2 id="overview-heading" className={SECTION_HEADING_CLASS}>
Overview
</h2>
<MovieOverview
overview={movie.overview}
language={movie.overviewLanguage}
/>
<MovieCrew movie={movie} />
</section>

<section aria-labelledby="cast-heading" className="flex flex-col gap-4">
<h2
id="cast-heading"
className="text-xl leading-tight font-bold text-foreground md:text-2xl"
>
Cast
</h2>
<WhereToWatchSection {...title} />

<CastSection>
<CastRow cast={movie.cast} />
</section>
</CastSection>

<MoreLikeThisSection {...title} />

<MovieFacts movie={movie} />
</article>
);
}
11 changes: 11 additions & 0 deletions app/privacy/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PrivacyPage />);
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(<PrivacyPage />);

Expand Down
10 changes: 10 additions & 0 deletions app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</p>
<p>
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.
</p>
</LegalSection>

<LegalSection title="Why we use it">
Expand Down Expand Up @@ -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.
</p>
<p>
A trailer plays in YouTube&rsquo;s privacy enhanced player, which
loads only after you press Trailer. From then on YouTube&rsquo;s own
privacy policy applies to that player.
</p>
</LegalSection>

<LegalSection title="Who processes your data">
Expand Down
16 changes: 16 additions & 0 deletions app/showcase/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,22 @@ export default function ShowcasePage() {
<li>
<PosterCardSkeleton />
</li>
<li>
<PosterCard
title="A second poster"
posterUrl={LIGHT_POSTER}
href="/shows"
badge={<TmdbRatingBadge value={8.2} />}
/>
</li>
<li>
<PosterCard
title="A sixth card, filling the row"
posterUrl={null}
href="/shows"
badge={<TmdbRatingBadge value={5.8} />}
/>
</li>
</PosterGrid>
</Section>

Expand Down
39 changes: 28 additions & 11 deletions app/shows/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -112,6 +125,7 @@ async function ShowDetail({
}

const { show } = result;
const title = { mediaType: "tv", id } as const;

return (
<article className="flex flex-col gap-10 md:gap-14">
Expand All @@ -126,6 +140,7 @@ async function ShowDetail({
airSpan={formatAirSpan(show)}
status={show.status}
genres={show.genres}
ageRating={<AgeRatingSlot {...title} />}
/>
}
tmdbRating={show.tmdbRating}
Expand All @@ -143,27 +158,29 @@ async function ShowDetail({
</Suspense>
</>
}
actions={<TrailerSlot {...title} title={show.name} />}
/>

<section
aria-labelledby="overview-heading"
className="flex flex-col gap-3"
>
<h2 id="overview-heading" className={SECTION_HEADING}>
<h2 id="overview-heading" className={SECTION_HEADING_CLASS}>
Overview
</h2>
<MovieOverview
overview={show.overview}
language={show.overviewLanguage}
/>
<ShowCrew show={show} />
</section>

<section
aria-labelledby="seasons-heading"
className="flex flex-col gap-4"
>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<h2 id="seasons-heading" className={SECTION_HEADING}>
<h2 id="seasons-heading" className={SECTION_HEADING_CLASS}>
Seasons
</h2>
<Suspense fallback={null}>
Expand All @@ -178,21 +195,21 @@ async function ShowDetail({
/>
</section>

<section aria-labelledby="cast-heading" className="flex flex-col gap-4">
<h2 id="cast-heading" className={SECTION_HEADING}>
Cast
</h2>
<WhereToWatchSection {...title} />

<CastSection>
<Suspense fallback={<CastRowSkeleton />}>
<ShowCast id={id} />
</Suspense>
</section>
</CastSection>

<MoreLikeThisSection {...title} />

<ShowFacts show={show} />
</article>
);
}

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. */
Expand Down
107 changes: 107 additions & 0 deletions app/title-pages.test.ts
Original file line number Diff line number Diff line change
@@ -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, [
"<MovieHero",
'id="overview-heading"',
"<MovieCrew",
"<WhereToWatchSection",
"<CastSection",
"<MoreLikeThisSection",
"<MovieFacts",
]);
expect(ascending(order)).toBe(true);
});

it("puts the trailer and age chip in the movie hero", () => {
const hero = MOVIE_PAGE.slice(
MOVIE_PAGE.indexOf("<MovieHero"),
MOVIE_PAGE.indexOf('id="overview-heading"'),
);
expect(hero).toContain("<TrailerSlot");
expect(hero).toContain("<AgeRatingSlot");
});

it("reads overview, crew, seasons, where to watch, cast, more like this, facts on a show", () => {
const order = offsets(SHOW_PAGE, [
"<DetailHero",
'id="overview-heading"',
"<ShowCrew",
'id="seasons-heading"',
"<SeasonGrid",
"<WhereToWatchSection",
"<CastSection",
"<MoreLikeThisSection",
"<ShowFacts",
]);
expect(ascending(order)).toBe(true);
});

it("puts the trailer and age chip in the show hero", () => {
const hero = SHOW_PAGE.slice(
SHOW_PAGE.indexOf("<DetailHero"),
SHOW_PAGE.indexOf('id="overview-heading"'),
);
expect(hero).toContain("<TrailerSlot");
expect(hero).toContain("<AgeRatingSlot");
});
});

describe("the metadata path stays region and extras free (AC-10)", () => {
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"/);
}
});
});
Loading
Loading