Files
arah2/web/src/i18n/strings.ts
T

278 lines
13 KiB
TypeScript

/*
* strings.ts — the Mini App shell's copy tokens.css (UI-SPEC §Copywriting Contract).
* -------------------------------------------------------------------------------
* Every user-facing string the shell renders lives here and NOWHERE else —
* `web/src/ui/*`, `web/src/route/*` and `web/src/main.ts` import from this
* module, and the copy gate (web/test/copy-gate.test.mjs COPY1..COPY7) keeps
* it verbatim against the approved UI-SPEC table in en + ms. Register labels
* (`BOOT-01 //`, `BOOT-02 //`, `GEO // LOCATION`, `SYS // POSITION`, `ROUTE`,
* `ROUTE // ROUTER`, `GUIDE`) are instrument vocabulary: identical in both
* locales by
* design. Tone is clinical + honest (D-01): plain statements of what is
* stored and what is not.
*
* Locales: English default ("en") + Bahasa Melayu ("ms") — Phase 2 D-05
* precedent, same pickLocale ms-prefix rule as bot/src/i18n/strings.ts.
*/
export interface ShellStrings {
bootValidatingRegister: string;
bootValidatingHeadline: string;
bootValidatingBody: string;
bootAcquiringRegister: string;
bootAcquiringHeadline: string;
bootAcquiringBody: string;
deniedRegister: string;
ctaTryAgain: string;
ctaAskViaChat: string;
ctaLocationSettings: string;
deniedHeadline: string;
deniedBody: string;
deniedHint: string;
lostRegister: string;
lostHeadline: string;
lostBody: string;
lostHint: string;
chatFallbackWaiting: string;
stripValidating: string;
stripAcquiring: string;
stripLive: string;
stripSignalLost: string;
stripLocationOff: string;
stripAwaitingPin: string;
attribution: string;
ariaRecenter: string;
destinationHeadline: string;
destinationUnavailableHeadline: string;
destinationUnavailableBody: string;
addToHomeScreen: string;
homeScreenAlreadyAdded: string;
routeRegister: string;
routeDiffFastest: string;
/** Template — {min}/{n} placeholders are replaced in code (instrument formats, not locale strings). */
routeDiffSlower: string;
routePendingRegister: string;
routePendingHeadline: string;
routePendingBody: string;
routePendingHint: string;
ctaSearchAgain: string;
ariaRouteAlternatives: string;
/** Template — {n}/{min}/{km} placeholders are replaced in code. */
ariaRouteChip: string;
/* — Phase 6 guidance chrome (06-UI-SPEC §Copywriting Contract; D-06/D-09/D-14).
Every guidance word lives here in en + ms — never inline (the copy gates).
Register labels (GUIDE, GUIDE // NEXT/REROUTING/…, GUIDE // T-nn) are
instrument vocabulary composed in code from guideRegister. Tone: clinical,
honest — the privacy promise stated once, at the exact moment it executes. */
/** The banner/card register base word — composed with the state word in code. */
guideRegister: string;
/** Fallback street line for an unnamed OSM segment (D-06 street slot). */
guideStreetUnnamed: string;
/** REROUTING calm-state line — no blame, no apology (D-04). */
guideReroutingLine: string;
/** RESUMING projected-state line until the first live fix (D-06/D-11). */
guideResumingLine: string;
/** NO REROUTE banner line — router-pending degradation (D-03). */
guideNoRerouteLine: string;
/** NO REROUTE card state line — the longer honest copy (D-03). */
guideNoRerouteBody: string;
/** Banner toggle word, collapsed state (real-word affordance, no chevron). */
ctaBannerList: string;
/** Banner toggle word, expanded state. */
ctaBannerClose: string;
/** The guidance card's ONLY action — opens the abandon confirm card (D-09). */
ctaEndTrip: string;
/** The abandon-route confirm card (D-09/PX-03) — the one destructive guard. */
guideConfirmHeadline: string;
/** The privacy clause stated at the exact moment it executes (D-14/PRIV-01). */
guideConfirmBody: string;
/** Confirm primary CTA — deliberately NOT red (deliberate, not an error). */
ctaConfirmAbandon: string;
/** Confirm dismiss — guidance continues. */
ctaConfirmContinue: string;
/** Trip-ended card (D-14) — ONE card for arrival and abandon. */
guideEndHeadline: string;
/** Trip-end deletion copy — the TTL law's promise, visible. */
guideEndBody: string;
/** The maneuver list region label (list = labelled region of real text rows). */
ariaManeuverList: string;
/** The banner button label (one toggle: aria-expanded + aria-controls). */
ariaBannerToggle: string;
/** The guidance card's progress bar label. */
ariaTripProgress: string;
/** The destination card's forward action: re-trigger routing (device-gate fix:
* SEARCH AGAIN's destination state was terminal-with-no-forward). */
ctaRoute: string;
/** The route button's aria label. */
ariaRouteButton: string;
/** The document title while a destination is resolved — Telegram's header
* mirrors it (device-gate feedback: "arah² → Routing to <name>"). */
titleRoute: string;
}
export type Locale = "en" | "ms";
export const strings: Record<Locale, ShellStrings> = {
en: {
bootValidatingRegister: "BOOT-01 // SESSION",
bootValidatingHeadline: "Validating session",
bootValidatingBody: "Confirming your Telegram identity. Takes a few seconds and stores nothing.",
bootAcquiringRegister: "BOOT-02 // LOCATION",
bootAcquiringHeadline: "Acquiring location",
bootAcquiringBody: "Finding where you are. Your position is never written to storage.",
deniedRegister: "GEO // LOCATION",
ctaTryAgain: "TRY AGAIN",
ctaAskViaChat: "ASK VIA CHAT",
ctaLocationSettings: "LOCATION SETTINGS",
deniedHeadline: "Location is switched off",
deniedBody: "araharah guides you from where you physically are. Without location there is no route to draw.",
deniedHint: "Grant location to Telegram, or send a pin in this chat: attach → Location.",
lostRegister: "SYS // POSITION",
lostHeadline: "Signal lost",
lostBody: "Showing your last known position — marked on the map. Reacquiring automatically.",
lostHint: "You can also send a fresh pin in this chat: attach → Location.",
chatFallbackWaiting: "Waiting for the pin you send in chat.",
stripValidating: "VALIDATING",
stripAcquiring: "ACQUIRING",
stripLive: "LIVE",
stripSignalLost: "SIGNAL LOST",
stripLocationOff: "LOCATION OFF",
stripAwaitingPin: "AWAITING PIN",
attribution: "© OpenStreetMap contributors",
ariaRecenter: "Recenter map",
destinationHeadline: "Destination",
destinationUnavailableHeadline: "Destination unavailable",
destinationUnavailableBody: "That place is not in this build. Search again from the bot.",
addToHomeScreen: "ADD TO HOME SCREEN",
homeScreenAlreadyAdded: "Already on your home screen",
routeRegister: "ROUTE",
routeDiffFastest: "Fastest route",
routeDiffSlower: "+{min} MIN VS ROUTE {n}",
routePendingRegister: "ROUTE // ROUTER",
routePendingHeadline: "Routing not wired up yet",
routePendingBody:
"Routes currently cover known KL landmarks only. The general router ships later — your coordinates were never sent anywhere.",
routePendingHint: "Send /cari in the chat and pick another landmark.",
ctaSearchAgain: "SEARCH AGAIN",
ariaRouteAlternatives: "Alternative routes",
ariaRouteChip: "Route {n}: {min} minutes, {km} kilometres",
guideRegister: "GUIDE",
guideStreetUnnamed: "Unnamed road",
guideReroutingLine: "Finding a new route from where you are.",
guideResumingLine: "Resuming from your last known progress.",
guideNoRerouteLine: "Rerouting unavailable on this server.",
guideNoRerouteBody: "No road graph here — follow the drawn route; recalculation is unavailable.",
ctaBannerList: "LIST",
ctaBannerClose: "CLOSE",
ctaEndTrip: "END TRIP",
guideConfirmHeadline: "Abandon route?",
guideConfirmBody: "Guidance stops and this trip's record is deleted. Nothing is stored.",
ctaConfirmAbandon: "YES, ABANDON",
ctaConfirmContinue: "CONTINUE",
guideEndHeadline: "Trip ended",
guideEndBody:
"Route guidance stopped and the trip record is deleted — nothing about this drive remains.",
ariaManeuverList: "Full maneuver list",
ariaBannerToggle: "Next maneuver",
ariaTripProgress: "Trip progress",
ctaRoute: "ROUTE",
ariaRouteButton: "Route to this destination",
titleRoute: "arah² → Routing to {name}",
},
ms: {
bootValidatingRegister: "BOOT-01 // SESSION",
bootValidatingHeadline: "Menyemak sesi",
bootValidatingBody: "Mengesahkan identiti Telegram anda. Hanya beberapa saat, tiada apa-apa disimpan.",
bootAcquiringRegister: "BOOT-02 // LOCATION",
bootAcquiringHeadline: "Mendapatkan lokasi",
bootAcquiringBody: "Mencari kedudukan anda. Lokasi anda tidak pernah disimpan.",
deniedRegister: "GEO // LOCATION",
ctaTryAgain: "CUBA LAGI",
ctaAskViaChat: "MINTA VIA CHAT",
ctaLocationSettings: "TETAPAN LOKASI",
deniedHeadline: "Lokasi dimatikan",
deniedBody: "araharah memandu dari kedudukan sebenar anda. Tanpa lokasi, tiada laluan boleh dilukis.",
deniedHint: "Benarkan lokasi untuk Telegram, atau hantar pin dalam chat ini: lampiran → Lokasi.",
lostRegister: "SYS // POSITION",
lostHeadline: "Isyarat hilang",
lostBody: "Menunjukkan kedudukan terakhir anda — ditanda pada peta. Sedang mencari semula.",
lostHint: "Anda juga boleh hantar pin baharu dalam chat ini: lampiran → Lokasi.",
chatFallbackWaiting: "Menanti pin yang anda hantar dalam chat.",
stripValidating: "MENYEMAK",
stripAcquiring: "MENDAPAT",
stripLive: "LANGSUNG",
stripSignalLost: "ISYARAT HILANG",
stripLocationOff: "LOKASI MATI",
stripAwaitingPin: "AWAITING PIN",
attribution: "© OpenStreetMap contributors",
ariaRecenter: "Tetapkan semula peta",
destinationHeadline: "Destinasi",
destinationUnavailableHeadline: "Destinasi tidak tersedia",
destinationUnavailableBody: "Tempat itu tiada dalam binaan ini. Cari semula dari bot.",
addToHomeScreen: "TAMBAH KE SKRIN UTAMA",
homeScreenAlreadyAdded: "Sudah di skrin utama anda",
routeRegister: "ROUTE",
routeDiffFastest: "Laluan terpantas",
routeDiffSlower: "+{min} MIN VS LALUAN {n}",
routePendingRegister: "ROUTE // ROUTER",
routePendingHeadline: "Penghalaan belum dipasang",
routePendingBody:
"Laluan buat masa ini meliputi mercu tanda KL yang diketahui sahaja. Penghala am akan datang kemudian — koordinat anda tidak pernah dihantar ke mana-mana.",
routePendingHint: "Hantar /cari dalam chat dan pilih mercu tanda lain.",
ctaSearchAgain: "CARI SEMULA",
ariaRouteAlternatives: "Laluan alternatif",
ariaRouteChip: "Laluan {n}: {min} minit, {km} kilometer",
guideRegister: "GUIDE",
guideStreetUnnamed: "Jalan tanpa nama",
guideReroutingLine: "Mencari laluan baharu dari kedudukan anda.",
guideResumingLine: "Sambung semula dari kemajuan terakhir anda.",
guideNoRerouteLine: "Pengiraan semula tiada pada pelayan ini.",
guideNoRerouteBody: "Tiada graf jalan di sini — ikut laluan yang dilukis; pengiraan semula tidak tersedia.",
ctaBannerList: "SENARAI",
ctaBannerClose: "TUTUP",
ctaEndTrip: "TAMAT PERJALANAN",
guideConfirmHeadline: "Tinggalkan laluan?",
guideConfirmBody: "Panduan berhenti dan rekod perjalanan ini dipadam. Tiada apa-apa disimpan.",
ctaConfirmAbandon: "YA, TINGGALKAN",
ctaConfirmContinue: "TERUSKAN",
guideEndHeadline: "Perjalanan tamat",
guideEndBody:
"Panduan laluan dihentikan dan rekod perjalanan dipadam — tiada apa-apa tentang pemanduan ini kekal.",
ariaManeuverList: "Senarai penuh manuver",
ariaBannerToggle: "Manuver seterusnya",
ariaTripProgress: "Kemajuan perjalanan",
ctaRoute: "LALUI",
ariaRouteButton: "Lalui ke destinasi ini",
titleRoute: "arah² → Perjalanan ke {name}",
},
};
/**
* D-05 fallback rule (verbatim from bot/src/i18n/strings.ts): a Telegram
* `language_code` that starts with the Malay tag selects "ms"; every other
* value (including null/undefined and unknown languages) falls back to en.
*/
export function pickLocale(telegramLanguageCode?: string | null): Locale {
const code = telegramLanguageCode ?? "";
return code === "ms" || code.startsWith("ms-") ? "ms" : "en";
}
/** Derive the locale from the RAW initData string (RESEARCH §Security V2 —
* the signed string, never initDataUnsafe). Telegram's wire format is
* user=<url-encoded JSON>&auth_date=...&query_id=...&signature=...&hash=...,
* so the first query component is `user`. A malformed or absent field
* falls back to "en" — never throws, never blocks boot. */
export function localeFromInitData(initData: string): Locale {
try {
const userField = initData.split("&")[0] ?? "";
if (!userField.startsWith("user=")) return "en";
const user = JSON.parse(decodeURIComponent(userField.slice(5))) as { language_code?: string };
return pickLocale(user.language_code);
} catch {
return "en";
}
}