Skip to content

Commit 19542e5

Browse files
B2JK-Industryclaude
andcommitted
feat(ui): PR-P G-07 + G-08 — /games/ai hub + GameHero × 9 evergreen pages
G-07 — `/games/ai` parent route (NEW). Pre-PR-P: only `app/games/ai/[id]/page.tsx` existed; visiting bare `/games/ai` returned the generic 404 (now PR-P G-03 root not-found, but still a dead end for kids who guess the URL). Server component listing 0-3 currently-live AI envelopes via `listActiveAiGames()`. Each card shows building glyph + AI badge + PL-tagged title + tagline (lang="pl" so the bitmap-fed audio + auto-translate know it's Polish under non-pl locales) + ⏱ live countdown. Empty state graceful: rotation hint + cron timing copy ("First game lands at 09:00 UTC") so the player knows when the next envelope is due. Added `dict.aiHub` × 4 langs. G-08 — GameHero primitive × 9 evergreen pages. Per-game pages each had a bespoke header — emoji size, back link spacing, body paragraph length all drifted. New players had no consistent "what am I playing for?" surface: rules / duration / max XP / age band weren't surfaced together. `components/game-hero.tsx` (NEW): emoji + localizedTitle (via E-03 helper, falls back to PL canonical) + game.description (lang="pl") + 3 chips (⏱ duration / ⚡ "Up to N W" per locale / age hint). Integrated into all 9 evergreen pages (energy-dash, power-flip, stock-tap, budget-balance, finance-quiz, math-sprint, memory-match, currency-rush, word-scramble). Existing back link kept; existing inline H1 + body removed (GameHero owns that surface now). Each page also gained a `notFound()` guard if `getGame(id)` returns null — defensive against future game-id renames in lib/games.ts. The legacy `t.headerTitle` / `t.headerBody` dict keys are no longer read but kept in lib/locales/ for backwards compat with any external consumer; cleanup is a Pass-11 candidate. Validation: - pnpm typecheck → 0 errors Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 85ae293 commit 19542e5

15 files changed

Lines changed: 230 additions & 39 deletions

File tree

app/games/ai/page.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import Link from "next/link";
2+
import { listActiveAiGames } from "@/lib/ai-pipeline/publish";
3+
import { dictFor } from "@/lib/i18n";
4+
import { getLang } from "@/lib/i18n-server";
5+
import { LiveCountdown } from "@/components/live-countdown";
6+
7+
/* G-07 — `/games/ai` parent route.
8+
*
9+
* Pre-PR-P: only `app/games/ai/[id]/page.tsx` existed; visiting the
10+
* bare `/games/ai` returned the generic 404 (now PR-P G-03 root
11+
* not-found, but still a dead-end for kids who guess the URL).
12+
*
13+
* This server component lists 0-3 currently-live AI envelopes via
14+
* the same `listActiveAiGames()` reader the dashboard preview uses.
15+
* Empty state is graceful: rotation hint + cron timing copy so the
16+
* player knows when the next game lands instead of seeing a blank.
17+
*/
18+
export const dynamic = "force-dynamic";
19+
20+
export default async function AiGamesHub() {
21+
const lang = await getLang();
22+
const t = dictFor(lang).aiHub;
23+
const active = await listActiveAiGames();
24+
25+
return (
26+
<main className="flex flex-col gap-6 max-w-4xl mx-auto animate-slide-up">
27+
<header className="flex flex-col gap-2">
28+
<h1 className="t-h2 text-[var(--accent)]">{t.title}</h1>
29+
<p className="text-[var(--ink-muted)]">{t.body}</p>
30+
</header>
31+
32+
{active.length === 0 ? (
33+
<div className="card p-6 flex flex-col items-center gap-2 text-center">
34+
<span className="text-3xl" aria-hidden>
35+
🤖
36+
</span>
37+
<p className="font-semibold">{t.emptyTitle}</p>
38+
<p className="text-[var(--ink-muted)]">{t.emptyBody}</p>
39+
<p className="t-caption text-[var(--ink-subtle)] mt-2">
40+
{t.rotationHint}
41+
</p>
42+
</div>
43+
) : (
44+
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
45+
{active.map((envelope) => (
46+
<li key={envelope.id}>
47+
<Link
48+
href={`/games/ai/${envelope.id}`}
49+
className="card card--interactive p-5 flex flex-col gap-2 h-full"
50+
>
51+
<div className="flex items-baseline gap-2">
52+
<span className="text-3xl" aria-hidden>
53+
{envelope.buildingGlyph}
54+
</span>
55+
<span className="chip text-[10px] border-[var(--accent)] text-[var(--accent)]">
56+
AI
57+
</span>
58+
</div>
59+
<h2
60+
className="font-semibold tracking-tight text-base"
61+
lang="pl"
62+
>
63+
{envelope.title}
64+
</h2>
65+
<p
66+
className="text-sm text-[var(--ink-muted)] line-clamp-2"
67+
lang="pl"
68+
>
69+
{envelope.tagline}
70+
</p>
71+
<span className="t-caption text-[var(--ink-muted)] mt-auto">
72+
{" "}
73+
<LiveCountdown
74+
validUntil={envelope.validUntil}
75+
svg={false}
76+
color="var(--ink-muted)"
77+
/>
78+
</span>
79+
</Link>
80+
</li>
81+
))}
82+
</ul>
83+
)}
84+
</main>
85+
);
86+
}

app/games/budget-balance/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import Link from "next/link";
2-
import { redirect } from "next/navigation";
2+
import { notFound, redirect } from "next/navigation";
33
import { getSession } from "@/lib/session";
44
import { BudgetBalanceClient } from "@/components/games/budget-balance-client";
55
import { budgetScenariosFor } from "@/lib/content/budget-balance";
66
import { dictFor } from "@/lib/i18n";
77
import { getLang } from "@/lib/i18n-server";
8+
import { getGame } from "@/lib/games";
9+
import { GameHero } from "@/components/game-hero";
810

911
export const dynamic = "force-dynamic";
1012

@@ -13,7 +15,8 @@ export default async function BudgetBalancePage() {
1315
if (!session) redirect("/login?next=/games/budget-balance");
1416
const lang = await getLang();
1517
const dict = dictFor(lang);
16-
const t = dict.budget;
18+
const gameMeta = getGame("budget-balance");
19+
if (!gameMeta) notFound();
1720
const pool = budgetScenariosFor(lang);
1821
// Server component evaluated per-request (force-dynamic): random scenario selection
1922
// is intentional — each page load gets a fresh scenario from the pool.
@@ -25,9 +28,8 @@ export default async function BudgetBalancePage() {
2528
<Link href="/games" className="text-sm text-[var(--ink-muted)] hover:underline">
2629
{dict.games.back}
2730
</Link>
28-
<h1 className="text-3xl font-bold">{t.headerTitle}</h1>
29-
<p className="text-[var(--ink-muted)]">{t.headerBody}</p>
3031
</header>
32+
<GameHero game={gameMeta} lang={lang} dict={dict} />
3133
<BudgetBalanceClient scenario={pick} dict={dict} />
3234
</div>
3335
);

app/games/currency-rush/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import Link from "next/link";
2-
import { redirect } from "next/navigation";
2+
import { notFound, redirect } from "next/navigation";
33
import { getSession } from "@/lib/session";
44
import { CurrencyRushClient } from "@/components/games/currency-rush-client";
55
import { dictFor } from "@/lib/i18n";
66
import { getLang } from "@/lib/i18n-server";
7+
import { getGame } from "@/lib/games";
8+
import { GameHero } from "@/components/game-hero";
79

810
export const dynamic = "force-dynamic";
911

@@ -12,16 +14,16 @@ export default async function CurrencyRushPage() {
1214
if (!session) redirect("/login?next=/games/currency-rush");
1315
const lang = await getLang();
1416
const dict = dictFor(lang);
15-
const t = dict.currency;
17+
const gameMeta = getGame("currency-rush");
18+
if (!gameMeta) notFound();
1619
return (
1720
<div className="flex flex-col gap-6">
1821
<header className="flex flex-col gap-2">
1922
<Link href="/games" className="text-sm text-[var(--ink-muted)] hover:underline">
2023
{dict.games.back}
2124
</Link>
22-
<h1 className="text-3xl font-bold">{t.headerTitle}</h1>
23-
<p className="text-[var(--ink-muted)]">{t.headerBody}</p>
2425
</header>
26+
<GameHero game={gameMeta} lang={lang} dict={dict} />
2527
<CurrencyRushClient dict={dict} />
2628
</div>
2729
);

app/games/energy-dash/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import Link from "next/link";
2-
import { redirect } from "next/navigation";
2+
import { notFound, redirect } from "next/navigation";
33
import { getSession } from "@/lib/session";
44
import { EnergyDashClient } from "@/components/games/energy-dash-client";
55
import { dictFor } from "@/lib/i18n";
66
import { getLang } from "@/lib/i18n-server";
7+
import { getGame } from "@/lib/games";
8+
import { GameHero } from "@/components/game-hero";
79

810
export const dynamic = "force-dynamic";
911

@@ -12,16 +14,16 @@ export default async function EnergyDashPage() {
1214
if (!session) redirect("/login?next=/games/energy-dash");
1315
const lang = await getLang();
1416
const dict = dictFor(lang);
15-
const t = dict.energy;
17+
const gameMeta = getGame("energy-dash");
18+
if (!gameMeta) notFound();
1619
return (
1720
<div className="flex flex-col gap-6">
1821
<header className="flex flex-col gap-2">
1922
<Link href="/games" className="text-sm text-[var(--ink-muted)] hover:underline">
2023
{dict.games.back}
2124
</Link>
22-
<h1 className="text-3xl font-bold">{t.headerTitle}</h1>
23-
<p className="text-[var(--ink-muted)]">{t.headerBody}</p>
2425
</header>
26+
<GameHero game={gameMeta} lang={lang} dict={dict} />
2527
<EnergyDashClient dict={dict} />
2628
</div>
2729
);

app/games/finance-quiz/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Link from "next/link";
2+
import { notFound } from "next/navigation";
23
import { getSession } from "@/lib/session";
34
import { FinanceQuizClient } from "@/components/games/finance-quiz-client";
45
import {
@@ -9,6 +10,8 @@ import {
910
import { shuffle } from "@/lib/shuffle";
1011
import { dictFor, type Lang } from "@/lib/i18n";
1112
import { getLang } from "@/lib/i18n-server";
13+
import { getGame } from "@/lib/games";
14+
import { GameHero } from "@/components/game-hero";
1215

1316
function pickRound(lang: Lang): QuizQuestion[] {
1417
return shuffle(financeQuestionsFor(lang))
@@ -56,6 +59,8 @@ export default async function FinanceQuizPage() {
5659
const lang = await getLang();
5760
const dict = dictFor(lang);
5861
const t = dict.finance;
62+
const gameMeta = getGame("finance-quiz");
63+
if (!gameMeta) notFound();
5964
const round = pickRound(lang);
6065
const anonymous = !session;
6166
const demo = DEMO_BANNER[lang];
@@ -65,11 +70,8 @@ export default async function FinanceQuizPage() {
6570
<Link href="/games" className="text-sm text-[var(--ink-muted)] hover:underline">
6671
{dict.games.back}
6772
</Link>
68-
<h1 className="text-3xl font-bold">{t.headerTitle}</h1>
69-
<p className="text-[var(--ink-muted)]">
70-
{t.headerBody.replace("{n}", String(QUESTIONS_PER_ROUND))}
71-
</p>
7273
</header>
74+
{gameMeta && <GameHero game={gameMeta} lang={lang} dict={dict} />}
7375
{anonymous && (
7476
<aside
7577
className="card flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 p-4"

app/games/math-sprint/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import Link from "next/link";
2-
import { redirect } from "next/navigation";
2+
import { notFound, redirect } from "next/navigation";
33
import { getSession } from "@/lib/session";
44
import { MathSprintClient } from "@/components/games/math-sprint-client";
55
import { dictFor } from "@/lib/i18n";
66
import { getLang } from "@/lib/i18n-server";
7+
import { getGame } from "@/lib/games";
8+
import { GameHero } from "@/components/game-hero";
79

810
export const dynamic = "force-dynamic";
911

@@ -12,16 +14,16 @@ export default async function MathSprintPage() {
1214
if (!session) redirect("/login?next=/games/math-sprint");
1315
const lang = await getLang();
1416
const dict = dictFor(lang);
15-
const t = dict.math;
17+
const gameMeta = getGame("math-sprint");
18+
if (!gameMeta) notFound();
1619
return (
1720
<div className="flex flex-col gap-6">
1821
<header className="flex flex-col gap-2">
1922
<Link href="/games" className="text-sm text-[var(--ink-muted)] hover:underline">
2023
{dict.games.back}
2124
</Link>
22-
<h1 className="text-3xl font-bold">{t.headerTitle}</h1>
23-
<p className="text-[var(--ink-muted)]">{t.headerBody}</p>
2425
</header>
26+
<GameHero game={gameMeta} lang={lang} dict={dict} />
2527
<MathSprintClient dict={dict} />
2628
</div>
2729
);

app/games/memory-match/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import Link from "next/link";
2-
import { redirect } from "next/navigation";
2+
import { notFound, redirect } from "next/navigation";
33
import { getSession } from "@/lib/session";
44
import { MemoryMatchClient } from "@/components/games/memory-match-client";
55
import { memoryPairsFor, PAIRS_PER_ROUND } from "@/lib/content/memory-pairs";
66
import { sample } from "@/lib/shuffle";
77
import { dictFor, type Lang } from "@/lib/i18n";
88
import { getLang } from "@/lib/i18n-server";
9+
import { getGame } from "@/lib/games";
10+
import { GameHero } from "@/components/game-hero";
911

1012
export const dynamic = "force-dynamic";
1113

@@ -18,16 +20,16 @@ export default async function MemoryMatchPage() {
1820
if (!session) redirect("/login?next=/games/memory-match");
1921
const lang = await getLang();
2022
const dict = dictFor(lang);
21-
const t = dict.memory;
23+
const gameMeta = getGame("memory-match");
24+
if (!gameMeta) notFound();
2225
return (
2326
<div className="flex flex-col gap-6">
2427
<header className="flex flex-col gap-2">
2528
<Link href="/games" className="text-sm text-[var(--ink-muted)] hover:underline">
2629
{dict.games.back}
2730
</Link>
28-
<h1 className="text-3xl font-bold">{t.headerTitle}</h1>
29-
<p className="text-[var(--ink-muted)]">{t.headerBody}</p>
3031
</header>
32+
<GameHero game={gameMeta} lang={lang} dict={dict} />
3133
<MemoryMatchClient pairs={pickRound(lang)} dict={dict} />
3234
</div>
3335
);

app/games/power-flip/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import Link from "next/link";
2-
import { redirect } from "next/navigation";
2+
import { notFound, redirect } from "next/navigation";
33
import { getSession } from "@/lib/session";
44
import { PowerFlipClient } from "@/components/games/power-flip-client";
55
import { powerRoundsFor, type PowerRound } from "@/lib/content/power-flip";
66
import { shuffle } from "@/lib/shuffle";
77
import { dictFor, type Lang } from "@/lib/i18n";
88
import { getLang } from "@/lib/i18n-server";
9+
import { getGame } from "@/lib/games";
10+
import { GameHero } from "@/components/game-hero";
911

1012
export const dynamic = "force-dynamic";
1113

@@ -28,16 +30,16 @@ export default async function PowerFlipPage() {
2830
if (!session) redirect("/login?next=/games/power-flip");
2931
const lang = await getLang();
3032
const dict = dictFor(lang);
31-
const t = dict.power;
33+
const gameMeta = getGame("power-flip");
34+
if (!gameMeta) notFound();
3235
return (
3336
<div className="flex flex-col gap-6">
3437
<header className="flex flex-col gap-2">
3538
<Link href="/games" className="text-sm text-[var(--ink-muted)] hover:underline">
3639
{dict.games.back}
3740
</Link>
38-
<h1 className="text-3xl font-bold">{t.headerTitle}</h1>
39-
<p className="text-[var(--ink-muted)]">{t.headerBody}</p>
4041
</header>
42+
<GameHero game={gameMeta} lang={lang} dict={dict} />
4143
<PowerFlipClient rounds={pickRound(lang)} dict={dict} />
4244
</div>
4345
);

app/games/stock-tap/page.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import Link from "next/link";
2-
import { redirect } from "next/navigation";
2+
import { notFound, redirect } from "next/navigation";
33
import { getSession } from "@/lib/session";
44
import { StockTapClient } from "@/components/games/stock-tap-client";
55
import { dictFor } from "@/lib/i18n";
66
import { getLang } from "@/lib/i18n-server";
7+
import { getGame } from "@/lib/games";
8+
import { GameHero } from "@/components/game-hero";
79

810
export const dynamic = "force-dynamic";
911

@@ -12,19 +14,16 @@ export default async function StockTapPage() {
1214
if (!session) redirect("/login?next=/games/stock-tap");
1315
const lang = await getLang();
1416
const dict = dictFor(lang);
15-
const t = dict.stock;
16-
const body = t.headerBody
17-
.replace("{buy}", t.buy)
18-
.replace("{sell}", t.sell);
17+
const gameMeta = getGame("stock-tap");
18+
if (!gameMeta) notFound();
1919
return (
2020
<div className="flex flex-col gap-6">
2121
<header className="flex flex-col gap-2">
2222
<Link href="/games" className="text-sm text-[var(--ink-muted)] hover:underline">
2323
{dict.games.back}
2424
</Link>
25-
<h1 className="text-3xl font-bold">{t.headerTitle}</h1>
26-
<p className="text-[var(--ink-muted)]">{body}</p>
2725
</header>
26+
<GameHero game={gameMeta} lang={lang} dict={dict} />
2827
<StockTapClient dict={dict} />
2928
</div>
3029
);

app/games/word-scramble/page.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Link from "next/link";
2-
import { redirect } from "next/navigation";
2+
import { notFound, redirect } from "next/navigation";
33
import { getSession } from "@/lib/session";
44
import { WordScrambleClient } from "@/components/games/word-scramble-client";
55
import {
@@ -9,6 +9,8 @@ import {
99
import { sample } from "@/lib/shuffle";
1010
import { dictFor, type Lang } from "@/lib/i18n";
1111
import { getLang } from "@/lib/i18n-server";
12+
import { getGame } from "@/lib/games";
13+
import { GameHero } from "@/components/game-hero";
1214

1315
export const dynamic = "force-dynamic";
1416

@@ -21,16 +23,16 @@ export default async function WordScramblePage() {
2123
if (!session) redirect("/login?next=/games/word-scramble");
2224
const lang = await getLang();
2325
const dict = dictFor(lang);
24-
const t = dict.word;
26+
const gameMeta = getGame("word-scramble");
27+
if (!gameMeta) notFound();
2528
return (
2629
<div className="flex flex-col gap-6">
2730
<header className="flex flex-col gap-2">
2831
<Link href="/games" className="text-sm text-[var(--ink-muted)] hover:underline">
2932
{dict.games.back}
3033
</Link>
31-
<h1 className="text-3xl font-bold">{t.headerTitle}</h1>
32-
<p className="text-[var(--ink-muted)]">{t.headerBody}</p>
3334
</header>
35+
<GameHero game={gameMeta} lang={lang} dict={dict} />
3436
<WordScrambleClient words={pickRound(lang)} dict={dict} />
3537
</div>
3638
);

0 commit comments

Comments
 (0)