feat(game): put the region in the URL path

/game?region=TPHCM becomes /game/tphcm, backed by an app/game/[region]
dynamic segment. The region is a property of the page, so it belongs in
the path: the URL is now shareable and honest, an unknown region is a
real 404 instead of silently rendering as "Vietnam", and the page no
longer needs useSearchParams or its Suspense boundary.

It also makes per-region traffic visible. Vercel's Pages dimension strips
query parameters on every plan, so ?region= was invisible; one row per
region falls out of correct routing.

Slugs are lowercase, and regionSlug/regionFromSlug in lib/regions.js own
the conversion. Three call sites build these URLs -- the picker,
generateStaticParams, and the legacy redirect -- and a casing mismatch
between any two would split one region across two rows, which is the
whole point of the move.

An unusual casing renders rather than redirecting to the canonical form.
A redirect on a prerendered route gets cached as that route's response:
on a case-insensitive filesystem the ISR cache folds /game/DNA onto
/game/dna, and a cached redirect that has lost its Location header then
answers the canonical URL with a 307 to nowhere for the life of the
process. Reproduced on Windows against next start; no redirect on the
route means no such mechanism anywhere.

Legacy ?region= and ?location= redirect from app/game/page.js rather
than next.config.mjs, because a config redirect forwards the source
query to the destination and would park ?region= on /game/tphcm
permanently. The legacy value is encodeURIComponent'd: it reaches a
Location header unvalidated, where a raw CRLF makes Node throw a 500
instead of the honest 404, and a raw ../ would be normalised onto
another path.

A region-less /game now plays the country rather than defaulting to
TPHCM, which was never a stated default -- just where the old query
chain happened to end.

API routes keep their query params. They are fetch calls, not
navigations, so a path segment buys nothing there.

Also repairs four specs that were already failing on main: three
asserted that the landing page shows no username prompt, which the
landing-prompt change contradicted, and one expected the build sha and
its copy button to be a single element, which splitting them
contradicted.
This commit is contained in:
tiennm99 committed 2026-09-06 16:37:43 +07:00
1 parent 90ebed728a
commit 711ef863a6
14 files changed
+409 -71

No files matched your search

+3 -2
View File
@@ -98,8 +98,9 @@ The UI has a Playwright smoke lane: `npm run test:e2e` runs `tests/e2e/*.spec.js
in Chromium against a dev server it starts (or reuses) itself. Every `/api/*`
call and the panorama image are stubbed at the browser boundary
(`tests/e2e/helpers.js`), so it needs no Redis, Neon, Mapillary, or `.env`.
It covers the region picker, the username modal, and one full round to the
reveal. Anything beyond those flows (real panoramas, real scoring round-trips)
It covers the region picker, the username modal, one full round to the
reveal, and the `/game/{slug}` routing contract (legacy `?region=` redirects,
unknown-region 404s). Anything beyond those flows (real panoramas, real scoring round-trips)
is still manual: inform the user when work is complete. Do NOT start
development servers - user handles manual testing themselves.
+20
View File
@@ -23,6 +23,26 @@
- Regions with no usable panoramas are listed but disabled, with the reason
shown. See the Coverage note in [project-overview.md](project-overview.md)
#### The game URL
The region is a lowercase path segment: `/game/tphcm`, `/game/vn`,
`/game/hn-badinh`. Codes are uppercase inside the app (they key the region
tree); `regionSlug` / `regionFromSlug` in `src/lib/regions.js` own the
conversion, and are the only place the URL casing convention lives.
- An unknown region is a 404. A real region with no imagery is not — it renders
and shows the coverage message, which is a better answer than a 404
- An uppercase URL plays rather than redirecting. There is deliberately no
canonical-casing redirect: a redirect on a prerendered route gets cached as
that route's response, and nothing in the app generates an uppercase link
- `/game` with no region plays the whole country
- `?region=` and `?location=` still work; they redirect to `/game/{slug}` and
are kept for links and bookmarks already in the wild
- **API routes deliberately keep their query params.** `/api/new-game?region=`,
`/api/leaderboard?region=` and the debug routes are unchanged and should stay
that way — they are fetch calls, not navigations, so there is nothing for a
path segment to buy there
### 3. Session Creation
- Server generates unique UUID v4 session ID
- Server stores the exact target location **and the district the panorama sits
+10 -3
View File
@@ -4,7 +4,9 @@
- `CLAUDE.md` - Project instructions and guidelines for Claude Code
- `components.json` - shadcn/ui configuration
- `package.json` - Dependencies and scripts
- `next.config.mjs` - Next.js configuration
- `next.config.mjs` - Next.js configuration. Deliberately has no `redirects()`:
a config redirect forwards the source query string to the destination, so the
legacy `/game?region=` links are redirected from `src/app/game/page.js` instead
- `eslint.config.mjs` - ESLint configuration
- `postcss.config.mjs` - PostCSS configuration
- `jsconfig.json` - JavaScript project configuration
@@ -26,7 +28,12 @@ Next.js 16 App Router structure:
- `favicon.ico` - Site favicon
#### Game Pages
- `game/page.js` - Main game interface
- `game/[region]/page.js` - The game screen for one region (`/game/tphcm`).
Server Component: validates the slug, prerenders one page per region,
404s an unknown one
- `game/[region]/not-found.js` - The 404 for an unknown region code
- `game/page.js` - Redirects the legacy `?region=` / `?location=` links to
`/game/{slug}`; a region-less `/game` goes to the country round
- `credits/page.js` - Data sources, licenses, and open-source credits
- `debug/page.js` - Debug hub: lists every debug tool as a peer
- `debug/layout.js` - Shared shell for all debug pages: app bar, DebugNav, theme
@@ -84,7 +91,7 @@ the shadcn CLI when a screen needs them, rather than keeping unused ones around.
Both directories are build output. Do not hand-edit; see *Rebuilding the
generated region data* in [development.md](development.md).
- `regions/index.js` - The 67-node tree: code, name, parent, level, children,
- `regions/index.js` - The 85-node tree: code, name, parent, level, children,
center, bbox, and coverage flags
- `regions/counts.js` - Per-region panorama and cell tallies. **The one
panorama-derived file a client component may import** - it carries counts