feat(game): explain a miss, share a round, and keep the phone header in reach

Below 3 points the result dialog says whether the guess had the right
district or province, located server-side from the boundaries. The
reveal links the spot on OpenStreetMap, and a Share button builds a
squares line with the region's URL. Region pages and the root carry
Open Graph metadata for link previews.

An expired round is worded as expired rather than as a failed save. A
skipped round gets a fresh session id so the unawaited delete cannot
kill the next round. Below sm the theme switch collapses to one cycling
button and the tally hides, so the mute control stays on a 360px
screen. The panorama viewer loads on demand, taking three.js out of
the first load.
This commit is contained in:
tiennm99 committed 2026-09-20 23:02:05 +07:00
1 parent 3314da1358
commit e649259518
14 files changed
+368 -55

No files matched your search

+10
View File
@@ -32,6 +32,16 @@ KV_REST_API_TOKEN=
# Do not set it empty -- src/lib/upstash.js treats that as unset on purpose.
# KEY_PREFIX=vngeoguessr:
# --- Debug API (optional) ---------------------------------------------------
# In Vercel production /api/debug/* returns 404 unless the request carries this
# value as the `x-debug-key` header or the `vng_debug` cookie. Leave unset to
# keep the debug API closed there. Off production it is always open.
# DEBUG_ACCESS_KEY=
# --- Link previews (optional) -----------------------------------------------
# Absolute origin for Open Graph URLs. Defaults to Vercel's production URL.
# NEXT_PUBLIC_SITE_URL=https://example.com
# --- Map tiles (optional) ---------------------------------------------------
# Read at BUILD time, not runtime. Unset, all Leaflet maps fall back to the OSM
# public tile server: fine for dev and e2e, but its policy forbids commercial
+1 -1
View File
@@ -4,7 +4,7 @@ A GeoGuessr clone focused on Vietnamese locations with accurate boundary detecti
## 🎮 Features
- **Region Tree**: Whole country, 5 provinces, 61 districts — including Da Lat (Lam Dong) and Duc Hoa (Long An)
- **Region Tree**: Whole country, 9 provinces, 75 districts — including Da Lat (Lam Dong) and Duc Hoa (Long An)
- **360° Street View**: Mapillary panoramic images with PhotoSphere viewer
- **Anti-Cheat Security**: Server-side session management prevents cheating
- **Distance-Based Scoring**: 0-5 point system based on accuracy
+10
View File
@@ -56,6 +56,16 @@ reads, with placeholders only. The sections below explain each one.
**Mapillary (required)**:
- `MAPILLARY_ACCESS_TOKEN` - Mapillary API token for image fetching
**Debug API (optional)**:
- `DEBUG_ACCESS_KEY` - On Vercel production the `/api/debug/*` routes return
404 unless the request carries this value as the `x-debug-key` header or the
`vng_debug` cookie. Unset, they are closed in production. Off production
(`VERCEL_ENV` not `production`) they are always open
**Link previews (optional)**:
- `NEXT_PUBLIC_SITE_URL` - Absolute origin for Open Graph URLs. Defaults to
Vercel's production URL, then `http://localhost:3000`
**Map tiles (optional)**:
- `NEXT_PUBLIC_GEOAPIFY_KEY` - Geoapify API key. When set (at build time), all
Leaflet maps serve tiles from Geoapify, whose free tier permits commercial
+65 -5
View File
@@ -1,7 +1,7 @@
# Game Features
## Location Coverage
- **Three-level region tree**: Vietnam → five provinces → 61 districts and towns,
- **Three-level region tree**: Vietnam → nine provinces → 75 districts and towns,
generated into `src/data/regions/` and traversed through `src/lib/regions.js`
- **Play at any level**: the whole country, one province, or one district
- **Pre-2025-merger boundaries**: Da Lat sits under Lam Dong, Duc Hoa under Long An
@@ -27,7 +27,9 @@
telling the player a region they can see has no coverage
- **The player identity behind that is a cookie and nothing more**: `vng_pid` is
an httpOnly UUID the server mints, holds no personal data, is never shown to
the player, and is never joined to their username or scores. It is not the
the player, and is never joined to their username or scores. Its one other
use is a HyperLogLog of distinct players per day (see Play statistics), which
can be counted but never read back. It is not the
username, deliberately — that lives in localStorage, is renameable, and is
shared by anyone who types it
- **Panoramas only**: non-panoramic images are filtered out when the index is
@@ -52,13 +54,35 @@
## Anti-Cheat Security
- **Redis Session Management**: target coordinates stored server-side in Redis
- **Debug API closed in production**: `/api/debug/pano` and
`/api/debug/region-coverage` return panorama coordinates, which is the answer
to any live round. On Vercel production they answer only a request carrying
`DEBUG_ACCESS_KEY` as the `x-debug-key` header or the `vng_debug` cookie
(`src/lib/debug-access.js`); unset, they are closed. Local, test and preview
deployments keep them open. The `/debug` pages still render in production
but their data calls fail without the key
- **Validated input**: the username rule lives once in `src/lib/username.js`
(2-20 characters of letters, digits, `-`, `_`, any script) and is enforced by
the name prompt and by `/api/guess`; a colon is excluded because the distance
boards pack `username:distance:timestamp` into one member. Guess coordinates
must be finite numbers, checked before the session is consumed so a malformed
submit costs nothing. A `sessionId` that is not a UUID is replaced on
`/api/new-game` and rejected on `/api/skip`; only server-minted ids reach the
keyspace
- **Server-resolved region**: the district a panorama sits in is decided at
session creation and never sent to the client; a `regionCode` in the guess
request body is ignored
- **UUID Session IDs**: unique session identifiers via `crypto.randomUUID()`
- **30-minute Expiry**: automatic Redis session cleanup
- **Single-use sessions**: the session is claimed with an atomic `DEL` before any
score is written, so a replayed or concurrent submit scores exactly once
score is written, so a replayed or concurrent submit scores exactly once. The
failure carries a `reason` (`session-expired`, `session-consumed`,
`invalid-guess`, `invalid-username`, `invalid-request`) and the result dialog
words each one differently, so an expired round is not reported as a failed
write
- **Skipped rounds get a fresh id**: the skip request deletes the old session
without being awaited, so the next round never reuses that id -- a late
delete used to land after the new round's write and kill it
- **Server-side Calculations**: all distance and scoring computed server-side
using Turf.js
@@ -77,6 +101,14 @@ A guess is graded on absolute precision, so a point means the same thing on
every board and a country round is only won by pinning the street. The ladder
is shown on the result dialog.
Below 3 points the dialog adds one display-only line the ladder cannot say:
"Right district", "Right province, wrong district" or "Wrong province". The
server locates the guess against the generated boundaries
(`src/lib/region-locate.js`, server-only) and returns it as `hit` and
`guessedRegion` on `gameResult`. It changes no score. After the reveal the
dialog also links the answer's coordinates to OpenStreetMap -- only after the
guess, never before.
The headline score and the leaderboards agree: every board above the panorama's
district is credited the same points for the round (`submitRoundScore` in
`src/lib/leaderboard.js`). Each level's added points are returned as `points`
@@ -120,10 +152,38 @@ ladder stay on the boards as they were recorded.
into Lam Dong's and Duc Hoa's into Long An's by a one-shot copy script
(applied and verified 2026-09-01, then removed; it survives in git history).
No score was reset
- **Redis Sorted Sets**: persistent leaderboard data using ZADD/ZRANGE
- **Top 200 Entries**: automatic trimming per leaderboard
- **Redis Sorted Sets**: persistent leaderboard data using ZINCRBY/ZADD/ZRANGE
- **Score boards are never trimmed; the top 200 is a serving window**: a score
board holds one member per name, so it grows with the player count. It used
to be trimmed to 200, which deleted the running total of anyone below the
cut -- their next round restarted from zero, and once 200th place held more
than one round's points nobody new could ever get on. Distance boards gain a
member every round and are still trimmed to 200. Scores are added with one
atomic ZINCRBY, so two rounds under one name finishing together both count
- **Real-time Ranking**: rank calculated with ZREVRANK/ZRANK
- **Persistent Storage**: no expiration on leaderboard data
The `leaderboard:city:` / `distance:city:` key prefix is kept deliberately —
renaming it would orphan every score already recorded under it.
## Sharing
- **Share button on the result dialog**: builds three lines -- the picked
region, the score as five squares with the distance, and the region's
`/game/{slug}` URL (`src/lib/share.js`) -- and hands them to the platform
share sheet, or the clipboard where there is none. Never coordinates, the
panorama id or the resolved district: the same panorama can be dealt again
- **Link previews**: the root layout and every region page carry Open Graph
and Twitter card metadata, so a shared link unfurls with the region's name.
`metadataBase` comes from `NEXT_PUBLIC_SITE_URL`, else Vercel's production
URL, else localhost
## Play statistics
- **Two keys per UTC day** (`src/lib/stats.js`): `stats:{day}` is a hash of
round counts keyed `{pickedLevel}:{score}`; `stats:players:{day}` is a
HyperLogLog of `vng_pid` values. Together they give rounds/day, distinct
players, rounds per player, the zero-score share per level, and a return
rate from the union of several days against their sum. Both expire after 90
days. Nothing per player is stored; the HyperLogLog only counts
- **Cost**: two Redis commands per round plus an occasional EXPIRE. A failed
write is logged and never fails the guess
- **Reading them**: `npm run stats [days]` prints the last N days
+1 -1
View File
@@ -127,7 +127,7 @@ points added at every level are the same number.
- **Score Leaderboards**: Accumulated scoring system with single entry per user
- **Distance Leaderboards**: Best distance records with multiple entries per user allowed
- **Redis Sorted Sets**: Persistent storage using ZADD/ZRANGE operations
- **Top 200 Limit**: Automatic trimming per leaderboard to maintain top performers only
- **Top 200 Window**: Score boards serve their top 200 but keep every total; distance boards are trimmed to 200
- **Score Accumulation**: New scores added to existing totals in score leaderboards
- **Distance Records**: Each game creates new timestamped distance record entry
- **Real-time Ranking**: Dynamic rank calculation using ZREVRANK/ZRANK for all leaderboard types
+14 -9
View File
@@ -47,7 +47,6 @@ Next.js 16 App Router structure:
- `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
- `debug/DebugNav.js` - Segmented peer navigation shown on every debug page
- `debug/bbox/page.js` - Bbox visualization and live Mapillary probing
- `debug/coverage/page.js` - Panorama coverage map, per region
- `debug/coverage/CoverageMap.js` - Leaflet layer for that page
@@ -56,9 +55,8 @@ Next.js 16 App Router structure:
- `guess/route.js` - Processes guess submissions, scores, and fans out
- `leaderboard/route.js` - Leaderboard data management with Redis
- `skip/route.js` - Skip current round functionality
- `debug/mapillary/route.js` - Mapillary API debugging and testing
- `debug/pano/route.js` - Resolve one panorama id to an image
- `debug/region-coverage/route.js` - A region's outline and panorama points
- `debug/pano/route.js` - Resolve one panorama id to an image (closed in production without the debug key)
- `debug/region-coverage/route.js` - A region's outline and panorama points (same gate)
#### React Components (`src/app/components/`)
- `AppBackground.js` - The key art (`public/bg.png`) on one fixed layer under
@@ -128,7 +126,8 @@ Neon Postgres, which is what the app queries at runtime.
- `pano-db.js` - **Server-side only.** Neon HTTP adapter behind pano-index.js
- `region-request.js` - Resolves and validates a region code from a request
- `game.js` - Scoring ladder, distance, formatting
- `username.js` - Player name in localStorage, plus the random-name generator
- `username.js` - Player name in localStorage, the random-name generator, and
`validateUsername`, the one rule the name prompt and `/api/guess` share
- `last-region.js` - Last-played region in localStorage (the home page's
"Continue in ..." row)
- `leaderboard.js` - Leaderboard operations, including the district to province
@@ -139,6 +138,12 @@ Neon Postgres, which is what the app queries at runtime.
- `pano-history.js` - **Server-side only.** The last 50 panoramas a player was
shown, in Redis with a rolling 3-day expiry
- `session.js` - Redis-based session management with 30-min expiry
- `stats.js` - **Server-side only.** Daily round counts and distinct-player
HyperLogLog in Redis, 90-day TTL; read by `scripts/stats.mjs`
- `region-locate.js` - **Server-side only.** Which region a map point falls in,
from the generated boundaries; feeds the result dialog's region-hit line
- `debug-access.js` - The production gate on `/api/debug/*`
- `share.js` - Client-safe share text for a round and the share-sheet call
- `upstash.js` - Upstash Redis REST client adapter with multi-tenant key prefix
- `theme.js`, `use-count-up.js` - Theme persistence and a count-up hook
- `audio.js` - **Client-side only.** The audio context, its first-gesture
@@ -153,15 +158,15 @@ Each carries a header comment with its flags and its cost.
- `assign-pano-districts.mjs` - Clips and partitions panoramas by district
- `seed-pano-db.mjs` - Validates the artifacts and uploads them to Neon
- `build-check.mjs` - Production build into `.next-check`
- `stats.mjs` - Print the daily play statistics from Redis (`npm run stats`)
- `lib/assign-districts.mjs` - District assignment shared by the two pano scripts
- `lib/pano-schema.mjs` - Panorama table DDL shared by the seed and the tests
## Tests (`tests/`)
Vitest, mostly one file per `src/lib/` module, plus a route test for
`new-game`, `guess`, and `debug/region-coverage`. The `skip`, `leaderboard`,
`debug/mapillary`, and `debug/pano` routes have no dedicated test file; their
underlying `src/lib/` logic (`leaderboard.js`, `mapillary.js`) is still
covered. `fake-upstash-redis.js`, `mock-upstash.js`, `redis-harness.js` and
`new-game`, `guess`, `skip`, and `debug/region-coverage`. The `leaderboard`
and `debug/pano` routes have no dedicated test file; their underlying
`src/lib/` logic (`leaderboard.js`, `mapillary.js`) is still covered. `fake-upstash-redis.js`, `mock-upstash.js`, `redis-harness.js` and
`wait-for-srh.js` are the shared harness that lets the same files run against
either the in-memory fake or a real Redis. `fake-neon.js`, `mock-neon.js` and
`pano-fixtures.js` are the equivalent for the panorama store: PGlite behind the
+6 -5
View File
@@ -12,9 +12,9 @@
- **Mapillary Graph API**: one call per round -- lookup by image id, ~230ms.
Its `/images?bbox=` search is deliberately off the game path: it returns HTTP
500 in every dense district, because it counts images inside the box before
applying the limit. See the header of `src/lib/mapillary.js`.
`api/debug/mapillary/route.js` keeps the search as a diagnostic, and will
reproduce those 500s
applying the limit. See the header of `src/lib/mapillary.js`. The debug
route that used to exercise that search was removed: it was an open proxy
spending the API token on a call the game never makes
- **Leaflet**: Interactive mapping library for guess placement
- **Map tiles**: `src/lib/map-tiles.js` picks the provider at build time —
Geoapify (`NEXT_PUBLIC_GEOAPIFY_KEY` set; free tier permits commercial use)
@@ -48,10 +48,11 @@
- **Credential Flexibility**: Accepts `UPSTASH_REDIS_REST_URL`+`UPSTASH_REDIS_REST_TOKEN` (vanilla Upstash) or `KV_REST_API_URL`+`KV_REST_API_TOKEN` (Vercel Marketplace aliases)
- **Multi-tenant Key Prefix**: All physical keys carry `KEY_PREFIX` (default `vngeoguessr:`) to safely share Upstash DB with other Vercel projects. Prefix applied centrally in `src/lib/upstash.js`; callers use logical keys only.
- **Key Namespaces**: `session:{id}` (30-min TTL), `leaderboard:{scope}`,
`distance:{scope}` (no expiry). `{scope}` is `vietnam` for the country and
`distance:{scope}` (no expiry), `stats:{day}` and `stats:players:{day}`
(90-day TTL). `{scope}` is `vietnam` for the country and
`city:{regionCode}` for every province and district -- the `city:` segment is
kept so existing scores stay addressable
- **Sorted Sets**: Leaderboard ranking with automatic trimming (top 200)
- **Sorted Sets**: Leaderboard ranking; score boards untrimmed and served as a top-200 window, distance boards trimmed to 200. Hash and HyperLogLog for the daily statistics
- **UUID v4**: Session identifier generation via built-in `crypto.randomUUID()`
- **30-minute Session Expiry**: Automatic TTL-based cleanup
+46 -14
View File
@@ -2,8 +2,8 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { ArrowLeft, Beer } from 'lucide-react';
import PanoramaViewer from './PanoramaViewer';
import ThemeToggle from './ThemeToggle';
import SoundToggle from './SoundToggle';
import DonateQRModal from './DonateQRModal';
@@ -20,6 +20,18 @@ import { playSound } from '../../lib/audio';
// chose would make the reveal meaningless for a country round.
import { getRegion, isRegion } from '../../lib/regions';
// Loaded on demand like the Leaflet map: the viewer drags three.js in with
// it, the largest chunk in the app by far, and nothing on the game screen can
// render a panorama until a round has been fetched anyway.
const PanoramaViewer = dynamic(() => import('./PanoramaViewer'), {
ssr: false,
loading: () => (
<div className="w-full h-full flex items-center justify-center" role="status" aria-live="polite">
<p className="text-neutral-300">Loading panorama...</p>
</div>
),
});
/**
* The result sound for a score, following the scoring ladder in lib/game.js:
* 4-5 points is a guess within 100m and worth celebrating, 1-3 is a hit, 0 is
@@ -223,13 +235,14 @@ export default function GameClient({ region }) {
const data = await response.json();
if (data.success) {
return { ...data.gameResult, leaderboard: data.leaderboard };
} else {
console.error('Failed to submit game result:', data.error);
return null;
}
console.error('Failed to submit game result:', data.error);
// The server says why. An expired round and a write failure used to
// share one message, and only one of them is anything the player did.
return { failed: true, reason: data.reason ?? null };
} catch (error) {
console.error('Failed to submit game result:', error);
return null;
return { failed: true, reason: null };
}
};
@@ -291,7 +304,7 @@ export default function GameClient({ region }) {
try {
const submitted = await submitGameResult(guessCoordinates);
if (submitted) {
if (submitted && !submitted.failed) {
setSessionRounds((rounds) => rounds + 1);
setSessionPoints((points) => points + (submitted.score ?? 0));
setResult({
@@ -302,13 +315,16 @@ export default function GameClient({ region }) {
scoreLevels: submitted.levels ?? [],
distanceLevels: submitted.distanceLevels ?? [],
resolvedPath: submitted.region?.path ?? null,
// 'district' | 'province' | 'none': how much of the answer's region
// the guess shared. Display only.
hit: submitted.hit ?? 'none',
leaderboardMessage: submitted.leaderboard?.message ?? '',
});
if (mountedRef.current) playSound(resultSound(submitted.score ?? 0));
} else {
// The guess did not record. Say so instead of rendering a 99999m round,
// which reads as a real miss and is indistinguishable from one.
setResult({ failed: true });
setResult({ failed: true, reason: submitted?.reason ?? null });
if (mountedRef.current) playSound('error');
}
} catch (error) {
@@ -316,7 +332,7 @@ export default function GameClient({ region }) {
// recorded. One representation for both, so the screen cannot show a
// confident 99999m miss for a round the server never saw.
console.error('Error submitting guess:', error);
setResult({ failed: true });
setResult({ failed: true, reason: null });
if (mountedRef.current) playSound('error');
}
@@ -387,13 +403,15 @@ export default function GameClient({ region }) {
resetRoundState();
prefetchRef.current = null;
roundEpochRef.current += 1;
const currentSession = sessionId;
setSessionId(null);
// Skip is also the way out of the error panel; leaving the error up would
// suppress the spinner and read as a hang while the new round loads.
setLoadError(null);
setRoundLoading(true);
const loaded = await loadRound(region, currentSession, roundEpochRef.current);
// A fresh id, never the one just skipped: the DEL above is still in
// flight, and reusing the id let it land after the new round's write and
// delete a live session.
const loaded = await loadRound(region, null, roundEpochRef.current);
if (!loaded) setRoundLoading(false);
};
@@ -443,16 +461,20 @@ export default function GameClient({ region }) {
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-foreground hidden sm:inline">VNGeoGuessr</span>
<Badge variant="brand" className="text-xs">
{/* Truncates rather than pushing the controls off a 360px screen:
a long district name loses its tail, not the mute button. */}
<Badge variant="brand" className="max-w-[8rem] truncate text-xs sm:max-w-none" title={regionName}>
{regionName}
</Badge>
{/* This visit's tally; invisible until the first round lands so the
header opens no colder than it used to. */}
header opens no colder than it used to, and hidden on phones,
where the header has no spare width -- the result dialog carries
the same numbers. */}
{sessionRounds > 0 && (
<Badge
variant="secondary"
className="text-xs tabular-nums"
title={`${sessionPoints} headline points in ${sessionRounds} ${sessionRounds === 1 ? 'round' : 'rounds'} this visit — leaderboards grade each board on its own scale`}
className="hidden text-xs tabular-nums sm:inline-flex"
title={`${sessionPoints} points in ${sessionRounds} ${sessionRounds === 1 ? 'round' : 'rounds'} this visit`}
>
{sessionRounds} {sessionRounds === 1 ? 'round' : 'rounds'} · {sessionPoints} pts
</Badge>
@@ -460,7 +482,16 @@ export default function GameClient({ region }) {
</div>
<div className="flex items-center gap-2">
{/* Both toggles collapse to one cell below sm. Two full groups are
seven 44px cells, which with Back, the region badge and the beer
button is more than a 360px header holds; the overflow used to
clip the right-hand controls out of reach after the first round. */}
<span className="sm:hidden">
<ThemeToggle compact />
</span>
<span className="hidden sm:inline">
<ThemeToggle />
</span>
{/* Two variants, swapped by breakpoint rather than by a resize
listener: below sm the header has no room for a second pair of
44px cells beside ThemeToggle's three, so sound collapses to one
@@ -595,6 +626,7 @@ export default function GameClient({ region }) {
guessCoordinates={guessCoordinates}
username={username}
regionName={regionName}
regionCode={pickedRegion?.code ?? 'VN'}
onNextRound={handleNextRound}
onMenu={handleGoBack}
/>
+89 -15
View File
@@ -1,13 +1,41 @@
"use client";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ChevronDown } from 'lucide-react';
import { Check, ChevronDown, ExternalLink, Share2 } from 'lucide-react';
import { formatDistance, SCORE_BANDS } from '../../lib/game';
import { useCountUp } from '../../lib/use-count-up';
import { regionSlug } from '../../lib/regions';
import { buildShareText, shareText } from '../../lib/share';
import ResultMap, { MARKER_COLORS } from './ResultMap';
// Why a round was not recorded, in the player's terms. The server names the
// reason; without one, the honest default is that the write failed.
const FAILURE_COPY = {
'session-expired': {
title: 'This round expired',
body: 'Rounds last 30 minutes. Nothing was scored, so start a new one.',
},
'session-consumed': {
title: 'This round was already submitted',
body: 'Only the first guess counts. Nothing more was scored.',
},
default: {
title: 'Your guess could not be saved',
body: 'Nothing was scored. The round has ended, so start a new one to try again.',
},
};
// The one line the ladder cannot say: how much of the answer's region a miss
// still got right. Only worth showing when the ladder itself said little.
const HIT_COPY = {
district: 'Right district',
province: 'Right province, wrong district',
none: 'Wrong province',
};
// Background and text move together: the semantic tokens flip to lighter
// fills with dark text in dark mode, so a hardcoded text-white cannot ride
// along on the wrapper.
@@ -55,6 +83,7 @@ export default function RoundResultDialog({
guessCoordinates,
username,
regionName,
regionCode,
onNextRound,
onMenu,
}) {
@@ -64,6 +93,23 @@ export default function RoundResultDialog({
// rather than arriving already finished.
const shownScore = useCountUp(score, open);
// The share outcome ('shared' | 'copied' | 'failed'), remembered against
// the result it belongs to, so a new round's dialog opens with a fresh
// button without an effect resetting state on open.
const [shareOutcome, setShareOutcome] = useState({ result: null, state: null });
const shareState = shareOutcome.result === result ? shareOutcome.state : null;
const handleShare = async () => {
const url = `${window.location.origin}/game/${regionSlug(regionCode)}`;
const text = buildShareText(regionName, score, formatDistance(result.distance), url);
setShareOutcome({ result, state: await shareText(text) });
};
const failure = FAILURE_COPY[result?.reason] ?? FAILURE_COPY.default;
// A guess the ladder scored well already says where it landed; the region
// line earns its place under 3 points, where the ladder says only "beyond".
const hitLine = result && !result.failed && score < 3 && result.hit ? HIT_COPY[result.hit] : null;
const hasScoreLevels = (result?.scoreLevels?.length ?? 0) > 0;
const hasDistanceLevels = result?.distanceLevels?.some((entry) => entry.rank) ?? false;
const hasLeaderboardSection = hasScoreLevels || hasDistanceLevels;
@@ -110,12 +156,8 @@ export default function RoundResultDialog({
// score circle here would present a write failure as a real miss,
// and the player would have no way to tell the difference.
<div className="space-y-3 py-6 text-center" role="alert">
<p className="text-lg font-semibold text-foreground">
Your guess could not be saved
</p>
<p className="text-sm text-muted-foreground">
Nothing was scored. The round has ended, so start a new one to try again.
</p>
<p className="text-lg font-semibold text-foreground">{failure.title}</p>
<p className="text-sm text-muted-foreground">{failure.body}</p>
</div>
) : result ? (
<>
@@ -139,6 +181,11 @@ export default function RoundResultDialog({
<p className="text-muted-foreground text-sm animate-fade-in-up" style={{ animationDelay: '320ms' }}>
{scoreWording(score).message}
</p>
{hitLine && (
<p className="text-sm font-medium text-foreground animate-fade-in-up" style={{ animationDelay: '400ms' }}>
{hitLine}
</p>
)}
</div>
<div className="space-y-1">
@@ -177,6 +224,20 @@ export default function RoundResultDialog({
<p className="text-sm font-semibold text-foreground">
{result.resolvedPath.join(' › ')}
</p>
{/* The answer is out, so the spot can be explored. Only
here, after the guess: the same link before it would be
the answer. */}
{result.exactLocation && (
<a
href={`https://www.openstreetmap.org/?mlat=${result.exactLocation.lat}&mlon=${result.exactLocation.lng}#map=17/${result.exactLocation.lat}/${result.exactLocation.lng}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center gap-1 text-xs font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
Explore this spot on OpenStreetMap
<ExternalLink className="size-3" aria-hidden="true" />
</a>
)}
</div>
)}
@@ -230,14 +291,8 @@ export default function RoundResultDialog({
className="rounded-lg bg-brand-subtle p-2 text-brand-subtle-foreground"
>
<p className="font-semibold">{entry.name}</p>
<p
className="tabular-nums"
// Each board judges the round by its own
// regional ladder, so what it added can differ
// from the headline score -- by design.
title="Each board grades your distance on its own scale"
>
{entry.score === null ? 'Below top 200' : `Total: ${entry.score}`}
<p className="tabular-nums">
{`Total: ${entry.score}`}
{typeof entry.points === 'number' ? ` (+${entry.points})` : ''}
</p>
{entry.rank && (
@@ -284,6 +339,25 @@ export default function RoundResultDialog({
<Button onClick={onNextRound} size="lg" className="flex-[2]">
Next Round
</Button>
{result && !result.failed && (
<Button
onClick={handleShare}
variant="outline"
size="lg"
aria-label={shareState === 'copied' ? 'Result copied' : 'Share this result'}
title="Share this result"
className="px-3"
>
{shareState === 'copied' ? (
<Check className="size-4" aria-hidden="true" />
) : (
<Share2 className="size-4" aria-hidden="true" />
)}
<span className="sr-only sm:not-sr-only">
{shareState === 'copied' ? 'Copied' : shareState === 'failed' ? 'Retry' : 'Share'}
</span>
</Button>
)}
<Button onClick={onMenu} variant="ghost" className="flex-1">
Menu
</Button>
+33 -2
View File
@@ -19,7 +19,14 @@ const THEME_ICONS = {
system: Monitor,
};
export default function ThemeToggle({ className = '' }) {
/**
* The three-way theme switch, or one cycling button where the header has no
* room for three cells.
* @param {Object} props
* @param {string} [props.className]
* @param {boolean} [props.compact] True for the single cycling button.
*/
export default function ThemeToggle({ className = '', compact = false }) {
const [theme, setTheme] = useState(DEFAULT_THEME);
// The stored choice only exists on the client, so the first paint has to
// match the server's default and correct itself after mount.
@@ -43,6 +50,30 @@ export default function ThemeToggle({ className = '' }) {
setStoredTheme(choice);
};
const groupClass = `inline-flex h-11 items-center rounded-lg border border-border bg-card ${className}`;
if (compact) {
// One cell that steps light -> dark -> system. Shows the CURRENT theme's
// icon, so the button reads as a status as well as a control.
const index = THEMES.findIndex((option) => option.value === theme);
const current = THEMES[index === -1 ? 0 : index];
const next = THEMES[(index + 1) % THEMES.length];
const Icon = THEME_ICONS[current.value];
return (
<div role="group" aria-label="Colour theme" className={groupClass}>
<button
type="button"
aria-label={`Theme: ${current.label}. Switch to ${next.label}`}
title={`Theme: ${current.label}`}
onClick={() => handleSelect(next.value)}
className="flex h-11 w-11 items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Icon className="size-4" aria-hidden="true" />
</button>
</div>
);
}
return (
// A plain group of toggle buttons rather than an ARIA radiogroup: a
// radiogroup obliges roving tabindex and arrow-key navigation, and claiming
@@ -51,7 +82,7 @@ export default function ThemeToggle({ className = '' }) {
<div
role="group"
aria-label="Colour theme"
className={`inline-flex h-11 items-center rounded-lg border border-border bg-card ${className}`}
className={groupClass}
>
{THEMES.map((option) => {
const selected = mounted && theme === option.value;
+7 -2
View File
@@ -49,9 +49,14 @@ export async function generateMetadata({ params }) {
// reverses it, and the country is the same word on all 85.
const place = regionPath(code).slice(1).reverse().join(', ') || name;
const title = `${name} — VNGeoGuessr`;
const description = `Guess where you are in ${place}, from street view.`;
return {
title: `${name} — VNGeoGuessr`,
description: `Guess where you are in ${place}, from street view.`,
title,
description,
// The share text links here, so this is the page a chat unfurls.
openGraph: { title, description, url: `/game/${regionSlug(code)}` },
twitter: { title, description },
};
}
+25 -1
View File
@@ -18,9 +18,33 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
// Deployment URL for absolute Open Graph URLs. Vercel sets the production
// hostname; a build anywhere else falls back to localhost, which only affects
// link previews.
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL
?? (process.env.VERCEL_PROJECT_PRODUCTION_URL
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: 'http://localhost:3000');
const DESCRIPTION = "Guess where you are in Vietnam from real street view. Free, no account.";
export const metadata = {
metadataBase: new URL(SITE_URL),
title: "VNGeoGuessr",
description: "GeoGuessr for VietNam",
description: DESCRIPTION,
// What a shared link unfurls to in a chat. The per-region pages override the
// title and description with the region's name.
openGraph: {
type: "website",
siteName: "VNGeoGuessr",
title: "VNGeoGuessr",
description: DESCRIPTION,
},
twitter: {
card: "summary",
title: "VNGeoGuessr",
description: DESCRIPTION,
},
};
// light dark lets the OS preference drive native controls, scrollbars and form
+46
View File
@@ -0,0 +1,46 @@
// The text a player shares after a round. Client-safe: no storage, no network.
//
// The text names the region the player PICKED and the round's outcome, and
// links to that region's page. It never carries coordinates, the panorama id
// or the district the panorama resolved to: the round is over, but the same
// panorama can be dealt to the next player.
const MAX_POINTS = 5;
/**
* Build the share text for one round.
* @param {string} regionName Region the player picked, e.g. 'Ha Noi'.
* @param {number} score Points earned, 0-5.
* @param {string} distanceLabel Formatted distance, e.g. '82m'.
* @param {string} url Absolute URL of the region's game page.
* @returns {string}
*/
export function buildShareText(regionName, score, distanceLabel, url) {
const points = Math.min(Math.max(Math.trunc(score) || 0, 0), MAX_POINTS);
const squares = '🟩'.repeat(points) + '⬜'.repeat(MAX_POINTS - points);
return `VNGeoGuessr · ${regionName}\n${squares} ${points}/${MAX_POINTS} · ${distanceLabel} away\n${url}`;
}
/**
* Hand text to the platform share sheet, falling back to the clipboard.
* @param {string} text
* @returns {Promise<'shared'|'copied'|'failed'>} What actually happened.
*/
export async function shareText(text) {
if (typeof navigator === 'undefined') return 'failed';
if (typeof navigator.share === 'function') {
try {
await navigator.share({ text });
return 'shared';
} catch (error) {
// The player closed the sheet: nothing to fall back to, nothing went wrong.
if (error?.name === 'AbortError') return 'failed';
}
}
try {
await navigator.clipboard.writeText(text);
return 'copied';
} catch {
return 'failed';
}
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { buildShareText } from '../src/lib/share.js';
describe('buildShareText', () => {
it('draws the score as squares and links the region page', () => {
expect(buildShareText('Ha Noi', 4, '82m', 'https://example.test/game/hn')).toBe(
'VNGeoGuessr · Ha Noi\n🟩🟩🟩🟩⬜ 4/5 · 82m away\nhttps://example.test/game/hn'
);
});
it('clamps a score outside the ladder', () => {
expect(buildShareText('Vietnam', 9, '1.20km', 'u')).toContain('🟩🟩🟩🟩🟩 5/5');
expect(buildShareText('Vietnam', -1, '1.20km', 'u')).toContain('⬜⬜⬜⬜⬜ 0/5');
});
});