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.