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
65 changes: 61 additions & 4 deletions app/games/[id]/page.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { type ReactElement, Suspense } from "react";
import { createElement, Fragment, type ReactElement, Suspense } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";

import { GameDetailSkeleton } from "@/components/game/game-detail-skeleton";
import { GameFacts } from "@/components/game/game-facts";
import { GameHero } from "@/components/game/game-hero";
import { GameMoreLikeThisSection } from "@/components/game/game-more-like-this";
import { GameOverview } from "@/components/game/game-overview";
import { ScreenshotGallery } from "@/components/game/screenshot-gallery";
import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot";

/**
* covers: spec 0025, AC-1, AC-6, AC-7, AC-8, AC-9, AC-10
* covers: spec 0025, AC-1, AC-6, AC-7, AC-8, AC-9, AC-10; spec 0031, AC-1,
* AC-11
*
* Until G10 every game page outcome is `noindex`, with no canonical or share
* card. `loadGame` is replaced, so the metadata and the streamed body branch
Expand Down Expand Up @@ -139,13 +144,30 @@ describe("the streamed body", () => {
platforms: [{ id: 6, name: "PC (Microsoft Windows)" }],
communityRating: 9.3,
communityRatingCount: 5501,
storyline: null,
artUrl: "https://images.igdb.com/igdb/image/upload/t_1080p/ar1.jpg",
screenshotUrls: [],
trailer: { key: "dQw4w9WgXcQ", name: "Launch Trailer" },
similarGameIds: [1887],
developers: ["CD Projekt RED"],
publishers: [],
gameModes: [],
playerPerspectives: [],
themes: [],
franchises: [],
series: [],
gameEngines: [],
platformReleases: [],
};

/** The article's children: the hero first, then the sections in order. */
const sectionsOf = (article: Element) => article.props.children as Element[];

it("hands the found game's facts to the hero and nothing it does not show (AC-1)", async () => {
loadGame.mockResolvedValue({ kind: "found", game: FOUND });

const article = await bodyFor("1942");
const hero = article.props.children as Element;
const hero = sectionsOf(article)[0];

expect(loadGame).toHaveBeenCalledWith(1942);
expect(hero.type).toBe(GameHero);
Expand All @@ -159,14 +181,49 @@ describe("the streamed body", () => {
platforms: FOUND.platforms,
communityRating: FOUND.communityRating,
communityRatingCount: FOUND.communityRatingCount,
artUrl: FOUND.artUrl,
trailer: FOUND.trailer,
});
});

it("orders the sections under the hero as spec 0031 lists them (AC-1)", async () => {
loadGame.mockResolvedValue({ kind: "found", game: FOUND });

const types = sectionsOf(await bodyFor("1942")).map((child) => child.type);

expect(types).toEqual([
GameHero,
GameOverview,
ScreenshotGallery,
GameMoreLikeThisSection,
GameFacts,
]);
});

it("renders the hero alone for a game IGDB has nothing more on (AC-1, AC-11)", async () => {
const BARE = {
...FOUND,
summary: null,
artUrl: null,
trailer: null,
similarGameIds: [],
developers: [],
};
loadGame.mockResolvedValue({ kind: "found", game: BARE });

const [hero, ...sections] = sectionsOf(await bodyFor("94975"));

expect(hero.props).toMatchObject({ artUrl: null, trailer: null });
expect(
renderToStaticMarkup(createElement(Fragment, null, ...sections)),
).toBe("");
});

it("puts the status control in the hero's tracking slot, in its own null fallback boundary (spec 0026, AC-1)", async () => {
loadGame.mockResolvedValue({ kind: "found", game: FOUND });

const article = await bodyFor("1942");
const hero = article.props.children as Element;
const hero = sectionsOf(article)[0];
const tracking = hero.props.tracking as Element;
const slot = tracking.props.children as Element;

Expand Down
37 changes: 35 additions & 2 deletions app/games/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { notFound } from "next/navigation";
import { Suspense } from "react";

import { GameDetailSkeleton } from "@/components/game/game-detail-skeleton";
import { GameFacts } from "@/components/game/game-facts";
import { GameHero } from "@/components/game/game-hero";
import { GameMoreLikeThisSection } from "@/components/game/game-more-like-this";
import { GameOverview } from "@/components/game/game-overview";
import { ScreenshotGallery } from "@/components/game/screenshot-gallery";
import { RetryLink } from "@/components/retry-link";
import { StatePanel } from "@/components/state-panel";
import { GameTrackingSlot } from "@/components/tracking/game-tracking-slot";
Expand Down Expand Up @@ -63,8 +67,12 @@ export async function generateMetadata({
*
* Nothing here reads a cookie, a header, the session or the visitor's region
* (AC-9). The status control reads the session in its own Suspense boundary
* through the hero's `tracking` slot (spec 0026, AC-1), and G9 adds the
* sections under the hero.
* through the hero's `tracking` slot (spec 0026, AC-1).
*
* Spec 0031 adds the backdrop and Trailer button to the hero, then Overview,
* Screenshots, More like this and Facts, in that order (AC-1). All but More
* like this come from the one `getGame` read; that section streams its own
* batch read in its own boundary (AC-8).
*/
export default function GamePage({ params }: PageProps<"/games/[id]">) {
return (
Expand Down Expand Up @@ -113,6 +121,8 @@ async function GameDetail({
platforms={game.platforms}
communityRating={game.communityRating}
communityRatingCount={game.communityRatingCount}
artUrl={game.artUrl}
trailer={game.trailer}
tracking={
<Suspense fallback={null}>
<GameTrackingSlot
Expand All @@ -123,6 +133,29 @@ async function GameDetail({
</Suspense>
}
/>
<GameOverview
summary={game.summary}
storyline={game.storyline}
developers={game.developers}
publishers={game.publishers}
/>
<ScreenshotGallery
name={game.name}
screenshotUrls={game.screenshotUrls}
/>
<GameMoreLikeThisSection
gameId={id}
similarGameIds={game.similarGameIds}
/>
<GameFacts
gameModes={game.gameModes}
playerPerspectives={game.playerPerspectives}
themes={game.themes}
franchises={game.franchises}
series={game.series}
gameEngines={game.gameEngines}
platformReleases={game.platformReleases}
/>
</article>
);
}
80 changes: 80 additions & 0 deletions app/showcase/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import {
ListEmptyPanel,
ListErrorPanel,
} from "@/components/catalog/list-panels";
import { GameFacts } from "@/components/game/game-facts";
import { GameOverview } from "@/components/game/game-overview";
import { ScreenshotGallery } from "@/components/game/screenshot-gallery";
import { GlassPill } from "@/components/glass-pill";
import { IconKey } from "@/components/layout/icon-key";
import { LibraryNavView } from "@/components/layout/library-nav";
Expand Down Expand Up @@ -102,6 +105,56 @@ const LIGHT_POSTER =
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="300"><rect width="200" height="300" fill="#C8B89A"/></svg>',
);

/**
* Flat 16:9 stand ins for IGDB screenshots, so the showcase needs no network.
* Shades of SVG's default fill, so no colour value lives outside globals.css.
*/
const SCREENSHOTS = [0.2, 0.4, 0.6, 0.8, 1].map(
(opacity) =>
"data:image/svg+xml;utf8," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080"><rect width="1920" height="1080" opacity="${opacity}"/></svg>`,
),
);

/** Every Facts entry, with a quarter date and a TBA, for the showcase (AC-16). */
const GAME_FACTS: React.ComponentProps<typeof GameFacts> = {
gameModes: [
{ id: 1, name: "Single player" },
{ id: 2, name: "Multiplayer" },
],
playerPerspectives: [{ id: 2, name: "Third person" }],
themes: [{ id: 1, name: "Action" }],
franchises: [{ id: 452, name: "The Witcher" }],
series: [{ id: 62, name: "The Witcher" }],
gameEngines: [
{ id: 24, name: "REDengine" },
{ id: 25, name: "REDengine 3" },
],
platformReleases: [
{
platformId: 6,
platform: "PC (Microsoft Windows)",
release: { precision: "day", date: "2015-05-19" },
},
{
platformId: 508,
platform: "Nintendo Switch 2",
release: { precision: "quarter", year: 2027, quarter: 1 },
},
{
platformId: 169,
platform: "Xbox Series X|S",
release: { precision: "month", date: "2027-06-01" },
},
{
platformId: 900,
platform: "PlayStation 6",
release: { precision: "tba" },
},
],
};

function Section({
title,
note,
Expand Down Expand Up @@ -465,6 +518,33 @@ export default function ShowcasePage() {
<GameLibraryCardDemo poster={LIGHT_POSTER} />
</Section>

<Section
title="Game page sections"
note="Under the game hero (spec 0031): the Overview with IGDB's storyline under Story and the company lines, the same Overview with no storyline, the Screenshots row whose thumbnails open one dialog (Previous, Next, the arrow keys, focus back on the thumbnail), and Facts with a quarter date and IGDB's own TBA sorted last. Every part is left out when IGDB has nothing."
>
<div className="flex flex-col gap-10">
<GameOverview
summary="A story driven open world RPG set in a dark fantasy universe."
storyline={
"The witcher Geralt searches for Ciri.\n\nThe Wild Hunt is close behind."
}
developers={["CD Projekt RED"]}
publishers={["CD Projekt", "WB Games"]}
/>
<GameOverview
summary="A summary with no storyline below it."
storyline={null}
developers={["A studio"]}
publishers={[]}
/>
<ScreenshotGallery
name="A showcase game"
screenshotUrls={SCREENSHOTS}
/>
<GameFacts {...GAME_FACTS} />
</div>
</Section>

<Section
title="Carousel row"
note="A title page row (Cast, More like this): the header and arrows are prerendered, the cards stream behind skeletons, and a failed or empty list keeps a card's height so nothing shifts. The arrows show from md and are disabled at each end. The landing sections reuse the error and empty lines."
Expand Down
24 changes: 18 additions & 6 deletions components/catalog/title-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ 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.
* "Written by" for a movie, "Created by" for a show, and "Developed by" and
* "Published by" for a game (spec 0031, AC-5). Each line only when it has a
* name, and nothing at all when none does.
*/
function CrewLines({
lines,
Expand Down Expand Up @@ -70,9 +71,10 @@ function AgeRatingChip({ rating, region }: { rating: string; region: Region }) {
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.
* The Facts panel at the end of a title page (spec 0022, AC-16; a game's
* since spec 0031, AC-9): a list of labelled values, each only when the
* catalog, TMDB or IGDB, has one. Nothing is shown as Unknown or 0; with no
* facts at all the panel is left out.
*/
function FactsPanel({ facts }: { facts: Array<Fact | null> }) {
const shown = facts.filter((fact): fact is Fact => fact !== null);
Expand Down Expand Up @@ -182,4 +184,14 @@ function NetworkList({ networks }: { networks: Company[] }) {
);
}

export { AgeRatingChip, MovieCrew, MovieFacts, ShowCrew, ShowFacts };
export {
AgeRatingChip,
CrewLines,
type Fact,
FactsPanel,
MovieCrew,
MovieFacts,
ShowCrew,
ShowFacts,
textFact,
};
27 changes: 25 additions & 2 deletions components/catalog/title-extras.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
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 type { TaggedCatalogPage } from "@/lib/catalog-list-read";
import { getVisitorRegion } from "@/lib/region";
import {
getMovieExtras,
Expand Down Expand Up @@ -198,12 +199,32 @@ async function MoreLikeThisContent(props: TitleRef) {
}
if (extras.recommendations.paged.results.length === 0) return null;

return (
<MoreLikeThisRow
result={extras.recommendations}
returnPath={titleHref(props.mediaType, props.id)}
/>
);
}

/**
* The filled row, shared with the game page (spec 0031, AC-8) so a game's
* similar games and a title's recommendations keep one card size, cap and
* eager count.
*/
function MoreLikeThisRow({
result,
returnPath,
}: {
result: TaggedCatalogPage;
returnPath: string;
}) {
return (
<MoreLikeThisFrame>
<ul className="flex gap-3 md:gap-4">
<CatalogListCards
result={extras.recommendations}
returnPath={titleHref(props.mediaType, props.id)}
result={result}
returnPath={returnPath}
eagerCount={0}
limit={ROW_SIZE}
sizes={ROW_POSTER_SIZES}
Expand Down Expand Up @@ -240,6 +261,8 @@ export {
AgeRatingSlot,
AgeRatingSlotContent,
MoreLikeThisContent,
MoreLikeThisFrame,
MoreLikeThisRow,
MoreLikeThisSection,
TrailerSlot,
TrailerSlotContent,
Expand Down
7 changes: 6 additions & 1 deletion components/catalog/title-section.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { RetryLink } from "@/components/retry-link";
import type { CatalogSource } from "@/lib/catalog/media-type";

/**
* The h2 look every title page section shares: Overview, Seasons, Where to
Expand All @@ -16,18 +17,22 @@ export const SECTION_HEADING_CLASS =
*
* @param what What failed to load, as it reads after "Couldn't load".
* @param href The title page, which the Retry reloads in full.
* @param source The catalog that failed: TMDB, or IGDB on a game page
* (spec 0031, AC-8).
*/
export function SectionErrorLine({
what,
href,
source = "TMDB",
}: {
what: string;
href: string;
source?: CatalogSource;
}) {
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<p className="text-[15px] text-muted-foreground">
Couldn't load {what} from TMDB.
Couldn't load {what} from {source}.
</p>
<RetryLink href={href} />
</div>
Expand Down
Loading
Loading