Skip to content

Commit 1dc4924

Browse files
B2JK-Industryclaude
andcommitted
feat(ui): PR-P G-03 + G-27 — root error boundaries, sunset CityScene
Naming note: prior commit 0d266a6 was tagged "PR-O Pass-9" (F-01..F-05). The Pass-10 spec (`_fe-fix-prompt-pass10-v2.md`) also calls itself "PR-O" but to keep git history unambiguous I'm tagging this Sprint G work as PR-P. Same chain, just shifted one letter. G-03 — root error boundaries (4 files, 3 NEW + dict keys): - `app/error.tsx` (NEW) — root segment fallback. Client component (Next.js requirement). Reads document.documentElement.lang on mount to pick the locale; falls back to DEFAULT_LANG. - `app/not-found.tsx` (NEW) — root 404 server component using `getLang()`. Replaces the generic Vercel 404 outside /miasto. - `app/games/[id]/not-found.tsx` (NEW) — segment-specific 404 with copy referencing GAMES.length + a CTA back to the games hub. - `app/global-error.tsx` already shipped in R-07 (PL-only). Left in place — multi-lang for catastrophic fallback is overkill and the layout failure path is well covered as-is. Added `errors.*` keys (title / body / retry / back / notFound*) to lib/locales/{pl,uk,cs,en}.ts so all four locales render localized error + not-found copy. G-27 — CityScene landing → use HeroBackdrop sunset: User feedback after PR-O Pass-9 (screenshot at 0:10): the anonymous landing's CityScene preview still rendered the legacy night-sky inline (sky gradient + moon + stars + cobbles ground + 5 streetlight posts). PR-L gave it a peach `--sc-sky` token via `data-mood="sunset"` but the SVG still drew its own moon + stars + ground over the top, so the surface read as "different product than /miasto". Added `backdrop?: "default" | "sunset"` prop on `<CityScene>`. When `sunset`, the inline sky/moon/stars/back-silhouettes/ground/road/ lampposts are skipped and `<HeroBackdrop>` (shared with `<CitySkylineHero>` and `<WattCityClient>`) renders instead. The AI zone slots + BUILDING_PLAN evergreen buildings layer on top unchanged. Lit-on-play mask wired off BUILDING_PLAN powered states: each lamp slice covers `1800/6 = 300` viewBox px; a slice's lamp lights up when the building whose center sits in it has any plays. Same mechanic as /miasto (where it's keyed off SLOT_MAP occupancy). Set `backdrop="sunset"` on: - `app/page.tsx` (anonymous landing — the surface in the screenshot) - `components/dashboard.tsx` (logged-in dashboard preview, for consistency with the new hero above it) Default `backdrop="default"` keeps /games full-size hero on the existing night palette — that surface still has its own retint chain in globals.css and we don't want to disturb the F-02 solid-buildings work. Validation: - pnpm typecheck → 0 errors Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0d266a6 commit 1dc4924

11 files changed

Lines changed: 1273 additions & 94 deletions

File tree

app/error.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import Link from "next/link";
5+
import { dictFor, DEFAULT_LANG, type Lang } from "@/lib/i18n";
6+
7+
/* G-03 — Root segment error boundary.
8+
*
9+
* Mirror of `app/miasto/error.tsx` (R-07) but at the root segment so
10+
* exceptions outside `/miasto` (auth flows, /games, /leaderboard,
11+
* /loans, etc.) no longer fall through to the browser-default error
12+
* page. Next.js mounts this Client Component when ANY server or
13+
* client component below `app/` throws and no nearer error.tsx
14+
* catches it.
15+
*
16+
* i18n note: Client Components can't use `getLang()` (server-only).
17+
* We read `<html lang="...">` once on mount; if the value isn't a
18+
* supported locale, fall back to DEFAULT_LANG. The dict copy lives
19+
* in `lib/locales/{pl,uk,cs,en}.ts` `errors.*` so all four locales
20+
* see localized text instead of the legacy hardcoded PL.
21+
*/
22+
export default function RootError({
23+
error,
24+
reset,
25+
}: {
26+
error: Error & { digest?: string };
27+
reset: () => void;
28+
}) {
29+
const [lang, setLang] = useState<Lang>(DEFAULT_LANG);
30+
31+
useEffect(() => {
32+
if (typeof document !== "undefined") {
33+
const docLang = document.documentElement.lang as Lang;
34+
if (["pl", "uk", "cs", "en"].includes(docLang)) setLang(docLang);
35+
}
36+
if (typeof console !== "undefined") {
37+
console.error("[root] segment error:", error);
38+
}
39+
}, [error]);
40+
41+
const t = dictFor(lang).errors;
42+
43+
return (
44+
<main className="flex flex-col gap-4 max-w-xl mx-auto py-12 animate-slide-up">
45+
<span aria-hidden className="text-5xl text-center">
46+
⚠️
47+
</span>
48+
<h1 className="t-h2 text-[var(--accent)] text-center">{t.title}</h1>
49+
<p className="t-body text-[var(--ink-muted)] text-center">{t.body}</p>
50+
{error.digest && (
51+
<p className="t-caption text-[var(--ink-subtle)] text-center font-mono">
52+
ref: {error.digest}
53+
</p>
54+
)}
55+
<div className="flex flex-wrap items-center justify-center gap-3">
56+
<button type="button" className="btn btn-primary" onClick={reset}>
57+
{t.retry}
58+
</button>
59+
<Link href="/" className="btn btn-secondary">
60+
{t.back}
61+
</Link>
62+
</div>
63+
</main>
64+
);
65+
}

app/games/[id]/not-found.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Link from "next/link";
2+
import { GAMES } from "@/lib/games";
3+
import { dictFor } from "@/lib/i18n";
4+
import { getLang } from "@/lib/i18n-server";
5+
6+
/* G-03 — Segment-specific 404 for /games/<unknown>. Replaces the
7+
* fallback root `app/not-found.tsx` for this segment with copy that
8+
* names the actual game count + points the player at the games hub
9+
* (Next.js 16 picks the deepest matching not-found.tsx). */
10+
export default async function GameNotFound() {
11+
const lang = await getLang();
12+
const t = dictFor(lang).errors;
13+
return (
14+
<main className="flex flex-col gap-4 max-w-xl mx-auto py-12 text-center animate-slide-up">
15+
<span aria-hidden className="text-5xl">
16+
🎮
17+
</span>
18+
<h1 className="t-h2 text-[var(--accent)]">{t.notFoundGameTitle}</h1>
19+
<p className="text-[var(--ink-muted)]">
20+
{t.notFoundGameBody.replace("{n}", String(GAMES.length))}
21+
</p>
22+
<div className="flex flex-wrap items-center justify-center gap-3">
23+
<Link href="/games" className="btn btn-primary">
24+
{t.notFoundGameAll}
25+
</Link>
26+
<Link href="/" className="btn btn-secondary">
27+
{t.notFoundCta}
28+
</Link>
29+
</div>
30+
</main>
31+
);
32+
}

app/not-found.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Link from "next/link";
2+
import { dictFor } from "@/lib/i18n";
3+
import { getLang } from "@/lib/i18n-server";
4+
5+
/* G-03 — Root 404. Server Component so it renders the player's
6+
* locale via the same `xp_lang` cookie path as the rest of the app.
7+
* Without this file Next.js fell back to the generic Vercel
8+
* "404 NOT_FOUND" page, breaking the brand chrome + nav. */
9+
export default async function NotFound() {
10+
const lang = await getLang();
11+
const t = dictFor(lang).errors;
12+
return (
13+
<main className="flex flex-col gap-4 max-w-xl mx-auto py-12 text-center animate-slide-up">
14+
<span aria-hidden className="text-5xl">
15+
🔍
16+
</span>
17+
<h1 className="t-h2 text-[var(--accent)]">{t.notFoundTitle}</h1>
18+
<p className="text-[var(--ink-muted)]">{t.notFoundBody}</p>
19+
<div className="flex flex-wrap items-center justify-center gap-3">
20+
<Link href="/" className="btn btn-primary">
21+
{t.notFoundCta}
22+
</Link>
23+
<Link href="/games" className="btn btn-secondary">
24+
{t.notFoundGames}
25+
</Link>
26+
</div>
27+
</main>
28+
);
29+
}

app/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,12 @@ export default async function Home() {
237237
<section className="flex flex-col gap-4">
238238
<h2 className="section-heading">{t.scenesTitle}</h2>
239239
<p className="t-body-lg text-[var(--ink-muted)] max-w-xl -mt-2">{t.scenesBody}</p>
240-
<CityScene interactive={false} compact aiGames={cityAiGames} />
240+
<CityScene
241+
interactive={false}
242+
compact
243+
backdrop="sunset"
244+
aiGames={cityAiGames}
245+
/>
241246
</section>
242247
<ComingSoonBanner lang={lang} />
243248
</div>

0 commit comments

Comments
 (0)