Skip to content

Commit 4946e06

Browse files
mnindrazakaclaude
andauthored
docs: PRD for indonesian/english language switch with geo-based default (#18)
Claude-Session: https://claude.ai/code/session_01BCVhXLtT4kUNjxBtWnF82n Co-authored-by: Claude <noreply@anthropic.com>
1 parent aca78d3 commit 4946e06

1 file changed

Lines changed: 324 additions & 0 deletions

File tree

docs/PRD-i18n-language-switch.md

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
# PRD — Indonesian / English Language Switch (i18n)
2+
3+
| | |
4+
|---|---|
5+
| **Status** | Draft |
6+
| **Author** | Gatherloop Team |
7+
| **Last updated** | 2026-07-16 |
8+
| **Parent doc** | [PRD.md](./PRD.md) — §2 Non-Goals ("Multi-language support") is hereby superseded |
9+
| **Target platform** | Web (static site, deployed on GitHub Pages) |
10+
| **Languages** | Bahasa Indonesia (default) + English |
11+
12+
---
13+
14+
## 1. Background
15+
16+
The site is currently Indonesian-only (`lang="id"`, all copy hardcoded in
17+
components and data files). The v1 PRD explicitly deferred multi-language
18+
support. Two things changed:
19+
20+
1. Gatherloop gets visitors from outside Indonesia — tourists and expats in
21+
East Java who find the cafe via Google Maps — and they currently land on a
22+
page they cannot read.
23+
2. The site is shared internationally (board game community links,
24+
Instagram), where an English version makes the cafe accessible.
25+
26+
This feature adds an **English version of every page** and a **visible
27+
language switch**, with the site **defaulting to English when opened from
28+
outside Indonesia** and to Indonesian otherwise.
29+
30+
### 1.1 Constraint: no server, no IP geolocation
31+
32+
The site is static (GitHub Pages) — there is no server to read the visitor's
33+
IP or `Accept-Language` header. "Opened outside Indonesia" is therefore
34+
**approximated client-side** using signals the browser already exposes:
35+
36+
1. **Browser language** (`navigator.languages`): any entry starting with
37+
`id` → Indonesian.
38+
2. **Timezone** (`Intl.DateTimeFormat().resolvedOptions().timeZone`): one of
39+
`Asia/Jakarta`, `Asia/Pontianak`, `Asia/Makassar`, `Asia/Jayapura`
40+
Indonesian (covers Indonesian devices set to English).
41+
3. Otherwise → English.
42+
43+
*Alternative considered and rejected:* a free IP-geolocation API (e.g.
44+
ipapi.co). Adds an external runtime dependency, a privacy concern, latency
45+
before the page can settle on a language, and rate limits — while browser
46+
language + timezone already answer the actual question ("can this visitor
47+
read Indonesian?") more directly than geography does.
48+
49+
## 2. Goals
50+
51+
1. Every page (`/`, `/games`, `/menu`) is available in full English.
52+
2. Visitors outside Indonesia (per §1.1 heuristic) get English by default,
53+
without any interaction; visitors in Indonesia keep seeing Indonesian.
54+
3. A visible ID/EN switch on every page lets anyone override the default;
55+
the choice is remembered across visits.
56+
4. Both language versions are static, crawlable, and correctly annotated for
57+
SEO (`hreflang`, `og:locale`, sitemap) — no client-side translation.
58+
5. Content maintenance stays simple: all UI copy lives in per-language
59+
dictionary files; adding a string means adding it to both dictionaries.
60+
61+
### Non-Goals (out of scope)
62+
63+
- Translating **images** (harga main poster, menu images) — they remain
64+
Indonesian; English pages show the same images. Producing English image
65+
variants is an owner/content task that can slot in later with no code
66+
change (see §9).
67+
- Translating **game titles** (they are proper names) or the underlying
68+
data values in `games.json` (tags get display-label translations, not data
69+
changes — see §5.4).
70+
- Languages beyond Indonesian and English.
71+
- Machine/auto-translation at build or runtime.
72+
- Region-specific content differences (prices, menu) — only the language
73+
changes.
74+
75+
## 3. Target Users
76+
77+
- **Non-Indonesian visitors** (tourists/expats via Google Maps or shared
78+
links): must be able to read price info, FAQ, and game collection without
79+
hunting for a language toggle.
80+
- **Indonesian majority** (80%+ of traffic, mobile): must notice **no
81+
change** — same URLs, same content, no redirect flicker.
82+
- **Mixed groups / Indonesian devices set to English**: the timezone signal
83+
keeps them on Indonesian; the switch lets them opt into English.
84+
85+
Primary device remains **smartphone** (360px baseline, Instagram in-app
86+
browser included).
87+
88+
## 4. URL Structure & Routing
89+
90+
Path-prefix strategy using Astro's built-in i18n routing:
91+
92+
| Indonesian (default) | English |
93+
|---|---|
94+
| `/` | `/en/` |
95+
| `/games` | `/en/games` |
96+
| `/menu` | `/en/menu` |
97+
98+
- Indonesian keeps its **current unprefixed URLs** (`prefixDefaultLocale:
99+
false`) — no existing link, QR code, or Google index entry breaks.
100+
- English lives under `/en/` — both versions are real prebuilt pages, so
101+
they work without JavaScript and are independently indexable/shareable.
102+
- All URL handling goes through a locale-aware helper built on the existing
103+
`withBase` (`src/utils/url.ts`), so GitHub Pages base-path behavior (parent
104+
PRD §6) is preserved.
105+
- `/games` query-string filters (`?tag=…&pemain=…`) keep their existing
106+
Indonesian parameter names **in both languages** — they are a shared API
107+
surface for already-shared links, not visible UI copy.
108+
109+
## 5. Functional Requirements
110+
111+
### 5.1 Translation dictionaries
112+
113+
- New module `src/i18n/`:
114+
- `Locale` type: `"id" | "en"`; constants for default locale and locale list.
115+
- `id.ts` and `en.ts` dictionaries with an identical, typed key set — a
116+
missing key in either language is a **build-time type error**.
117+
- `t(locale)` accessor used by all components; components never hardcode
118+
user-facing copy after migration.
119+
- Localizable **data** (FAQ) moves from flat Indonesian JSON to a per-language
120+
shape (e.g. `{ "id": [...], "en": [...] }` in `src/data/faq.json`), typed so
121+
both languages are required.
122+
- `src/consts.ts` strings that are copy (`SITE_TITLE`, `SITE_DESCRIPTION`)
123+
become per-locale; identity/data constants (`SITE_NAME`, `ADDRESS`, URLs)
124+
stay as-is.
125+
- `src/utils/format.ts` (`menit`/`pemain`) becomes locale-aware
126+
(`min`/`players` in English).
127+
128+
### 5.2 Language switch UI
129+
130+
- A compact, always-visible toggle — two options **ID | EN** — placed at the
131+
top of every page (above the hero on `/`, next to the back link on
132+
subpages) and repeated in the footer.
133+
- Switching navigates to the **same page** in the other language
134+
(`/games``/en/games`), preserving the query string on `/games` so active
135+
filters survive the switch.
136+
- The switch is plain `<a>` links (works without JS); a small script
137+
additionally records the choice (§5.3) on click.
138+
- Current language is visually marked (`aria-current="true"`); tap target
139+
≥44px; labeled for screen readers ("Ganti bahasa" / "Change language").
140+
141+
### 5.3 Default-language detection & persistence
142+
143+
Decision order on page load (inline `<head>` script, runs before paint):
144+
145+
1. **Saved preference** (`localStorage["gatherloop-lang"]`) — set whenever the
146+
user uses the switch. Always wins.
147+
2. **Explicit URL** — a visitor who opens an `/en/…` link stays on English
148+
(and vice versa); a shared link is an explicit choice, never redirected.
149+
3. **First visit on a default-locale (Indonesian) URL with no saved
150+
preference** — apply the §1.1 heuristic; if it resolves to English,
151+
`location.replace()` to the `/en/` equivalent of the current path
152+
(query string preserved).
153+
154+
Rules:
155+
156+
- Redirect uses `location.replace()` — no extra history entry, back button
157+
stays sane.
158+
- The heuristic can only redirect **id → en** on first visit; it never
159+
redirects away from `/en/` (rule 2 covers that direction), so a loop is
160+
impossible.
161+
- The detection script is tiny (<500 bytes), inlined in `<head>` before CSS
162+
so a redirected visitor never sees an Indonesian flash.
163+
- No-JS visitors and crawlers simply get the page they requested; `hreflang`
164+
annotations (§5.5) tell search engines about the alternative, so no
165+
cloaking/SEO penalty.
166+
- The saved preference is a single locale string in `localStorage` — no
167+
cookies, no consent banner needed.
168+
169+
### 5.4 Page-by-page content scope
170+
171+
| Surface | Indonesian source today | English treatment |
172+
|---|---|---|
173+
| Layout meta (title, description, OG) | `consts.ts` | Translated per locale; `lang` and `og:locale` follow the page locale (`id` / `id_ID`, `en` / `en_US`) |
174+
| Hero tagline | `Hero.astro` | Translated |
175+
| Harga Main section | `HargaMain.astro` + poster image | Heading/alt translated ("Play Rates"); **image stays Indonesian** with an English caption line "Prices shown in the poster apply to everyone." (exact copy TBD, §8) |
176+
| Info links + address | `InfoLinks.astro` | Labels translated; address unchanged (proper noun) |
177+
| FAQ | `faq.json` | Fully translated Q&A (draft by team, confirmed by owner, §8) |
178+
| Footer | `Footer.astro` | Translated |
179+
| Games page + cards | `games.astro`, `GameCard.astro`, `format.ts` | Translated (title, back link, `menit``min`, `pemain``players`) |
180+
| Game filters | `GameFilters.astro` | All labels, placeholders, result count, empty state translated |
181+
| Game **tags** | `games.json` (`adu strategi`, …) | Data values unchanged; a tag→label map in the dictionaries renders English chip labels (`adu strategi` → "strategy"). URLs keep the canonical Indonesian tag values in both languages (§4) |
182+
| Menu page | `menu.astro` + menu images | Title/alt translated; **images stay Indonesian** (see Non-Goals) |
183+
| Back link | `BackLink.astro` | "Kembali" → "Back" |
184+
185+
### 5.5 SEO
186+
187+
- Every page emits `hreflang` alternates for `id`, `en`, and `x-default`
188+
(`x-default` → Indonesian, the site's home market default).
189+
- `og:locale` per page + `og:locale:alternate`.
190+
- Sitemap includes both language versions (`@astrojs/sitemap` i18n support).
191+
- Canonical URL is each page's own URL (no cross-language canonicals).
192+
193+
### 5.6 Mockup — landing page top, mobile
194+
195+
```
196+
┌─────────────────────────────┐
197+
│ [ID | EN] │ ← switch, top-right
198+
│ ╔═════════════════════════╗ │
199+
│ ║ [ HERO IMAGE ] ║ │
200+
│ ╚═════════════════════════╝ │
201+
│ (o) Gatherloop │
202+
│ │
203+
│ A Fun Hangout Spot for │ ← /en/ shown
204+
│ Playing Board Games │
205+
│ Hundreds of games, great │
206+
│ coffee, and new friends… │
207+
│ ── Play Rates ─────────── │
208+
│ ... │
209+
│ ┌─────────────────────────┐ │
210+
│ │ 🎲 Game Collection › │ │
211+
│ └─────────────────────────┘ │
212+
└─────────────────────────────┘
213+
```
214+
215+
Subpages: `‹ Back [ID | EN]` on one row above the page title.
216+
217+
## 6. Non-Functional Requirements
218+
219+
| Area | Requirement |
220+
|---|---|
221+
| Tech | Astro built-in i18n routing (no i18n library, no framework). Detection script is inline vanilla JS, <500 bytes, zero dependencies. |
222+
| Performance | Both language versions fully prerendered; the only new JS is the detection snippet and the switch's preference-saving handler. Lighthouse targets from parent PRD (≥90 mobile) hold for `/en/` pages. |
223+
| No flash / no loop | Redirect (if any) fires before first paint; redirect is one-directional (id→en, first visit only) making loops structurally impossible. |
224+
| Type safety | Dictionary key parity and FAQ locale completeness enforced by TypeScript at build time — an untranslated string fails `astro build`. |
225+
| Accessibility | Switch is native links with `aria-current` and accessible name; `<html lang>` correct per page so screen readers pick the right voice. |
226+
| Base path | All locale-aware URLs respect the Astro `base` (parent PRD §6), including the redirect target computation. |
227+
| Browser support | Evergreen + Android WebView (Instagram in-app). `Intl.DateTimeFormat().resolvedOptions().timeZone` is supported there; if unavailable, detection degrades to browser-language only. `localStorage` access wrapped in try/catch (private mode). |
228+
| Analytics-free | No tracking added; preference stored locally only. |
229+
230+
## 7. Implementation Phases
231+
232+
Each phase is one small, independently reviewable PR. Every PR leaves the
233+
deployed site working and shippable. Phases must land in order (each builds
234+
on the previous), except Phases 3 and 4, which are independent of each other
235+
and can be parallelized after Phase 2.
236+
237+
### Phase 1 — i18n foundation (no visible change)
238+
**PR: `feat: i18n foundation with typed translation dictionaries`**
239+
- Enable Astro i18n routing: `defaultLocale: "id"`, `locales: ["id", "en"]`,
240+
`prefixDefaultLocale: false`.
241+
- Create `src/i18n/` with the `Locale` type, `id.ts` + `en.ts` dictionaries
242+
(typed key parity per §5.1), and the `t(locale)` helper.
243+
- Locale-aware URL helper (`localizePath(locale, path)`) on top of `withBase`.
244+
- `Layout.astro` accepts a `locale` prop driving `<html lang>`, `og:locale`,
245+
and per-locale title/description from restructured `consts.ts`.
246+
- Migrate **`format.ts` and `BackLink.astro`** to the dictionary as the
247+
proof-of-mechanism (Indonesian output unchanged).
248+
- **Done when:** `astro build` output is byte-equivalent for visitors
249+
(Indonesian everywhere, same URLs); removing a dictionary key from one
250+
language fails the build.
251+
252+
### Phase 2 — English landing page
253+
**PR: `feat: english landing page at /en`**
254+
- Extract all landing copy (Hero, HargaMain, InfoLinks, Faq, Footer) into the
255+
dictionaries; restructure `faq.json` to the per-language shape (§5.1) with
256+
draft English translations.
257+
- Add `/en/index.astro` rendering the same components with `locale="en"`;
258+
internal links on it point at `/en/…` targets (games/menu may 404 until
259+
Phases 3–4 land — acceptable, mirrors parent PRD Phase 4 precedent).
260+
- Harga Main caption line for English (§5.4).
261+
- **Done when:** `/en/` renders the fully English landing page; `/` is
262+
unchanged; both build statically.
263+
264+
### Phase 3 — English games page
265+
**PR: `feat: english games page at /en/games`**
266+
- Extract `games.astro`, `GameCard.astro`, and all `GameFilters.astro` UI
267+
strings (labels, placeholders, chips, result count, empty state) into the
268+
dictionaries; add the tag→English-label map (§5.4).
269+
- Add `/en/games` reusing the same components/data with `locale="en"`;
270+
filter query parameters stay canonical (§4).
271+
- **Done when:** `/en/games` filters work identically to `/games` with fully
272+
English UI; a filtered URL works in both languages.
273+
274+
### Phase 4 — English menu page
275+
**PR: `feat: english menu page at /en/menu`**
276+
- Extract `menu.astro` strings; add `/en/menu` (Indonesian menu images, per
277+
Non-Goals).
278+
- **Done when:** `/en/menu` renders with English title/alt text and back link.
279+
280+
### Phase 5 — Language switch UI
281+
**PR: `feat: language switch on all pages`**
282+
- `LanguageSwitch.astro` component per §5.2: ID | EN links to the equivalent
283+
page in the other locale, query string preserved on `/games`,
284+
`aria-current` on the active language.
285+
- Placed on all six pages (landing top-right, subpage header row, footer).
286+
- Click handler saves the choice to `localStorage` (§5.3 rule 1 data source;
287+
detection itself comes in Phase 6).
288+
- **Done when:** every page can round-trip id↔en to the equivalent page,
289+
filters intact, ≥44px tap targets at 360px.
290+
291+
### Phase 6 — Auto-detection, persistence & SEO polish
292+
**PR: `feat: default to english outside indonesia + hreflang`**
293+
- Inline `<head>` detection script implementing the §5.3 decision order and
294+
the §1.1 language/timezone heuristic, with `location.replace()`, base-path
295+
awareness, and try/catch around `localStorage`.
296+
- `hreflang` alternates (+ `x-default`), `og:locale:alternate`, and
297+
dual-language sitemap entries (§5.5).
298+
- Verification pass: no redirect flash on a throttled connection; no loop
299+
with storage disabled; Instagram in-app browser; Lighthouse ≥90 on `/en/`.
300+
- **Done when:** a browser with English language + non-Indonesian timezone
301+
opening `/` lands on `/en/` before first paint; an Indonesian device sees
302+
no redirect; the choice made via the switch sticks across visits.
303+
304+
## 8. Open Questions (owner input needed)
305+
306+
1. **English copy review** — tagline, FAQ answers, and Harga Main caption
307+
will ship as team drafts; owner should review tone (casual, matching the
308+
Indonesian "nongkrong" voice).
309+
2. **Tag display labels** — confirm the English label per tag
310+
(e.g. `adu cepat` → "speed", `kerja sama tim` → "co-op").
311+
3. **English image variants** — are English versions of the harga main
312+
poster and menu planned? (Not blocking; slots already exist — replacing
313+
files per locale can be a follow-up.)
314+
4. **`x-default`** — PRD proposes Indonesian (home market). Confirm, or
315+
switch to English if international discoverability matters more.
316+
317+
## 9. Future Enhancements (post-launch backlog)
318+
319+
- Per-locale images (English harga main poster / menu) once produced.
320+
- English game tag values surfaced in shareable URLs (`?tag=strategy`)
321+
with bidirectional aliasing.
322+
- Additional locales if warranted (the dictionary structure generalizes).
323+
- A one-time dismissible banner ("View this page in English?") as a softer
324+
alternative to auto-redirect, if redirect behavior confuses users.

0 commit comments

Comments
 (0)