|
| 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