From b8c306125c728b2b60416faa29b41331fdda4aa0 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 6 Oct 2026 13:35:20 +0700 Subject: [PATCH] =?UTF-8?q?i18n(th):=20batch=202=20=E2=80=94=20map=20(mapU?= =?UTF-8?q?i=20+=20mapPage)=20+=20AGENTS=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 17 ++ package-lock.json | 10 + package.json | 3 +- scripts/check-i18n.mjs | 13 +- src/components/LanguageToggle.tsx | 1 + src/components/SasquatchGuide.tsx | 4 +- src/contexts/LanguageContext.tsx | 12 +- src/contexts/language.ts | 5 +- src/i18n/th/batch1.ts | 297 ++++++++++++++++++++++++++++++ src/i18n/th/batch2.ts | 201 ++++++++++++++++++++ src/i18n/th/index.ts | 17 ++ src/index.css | 8 +- src/lib/dateFnsI18n.ts | 14 +- src/main.tsx | 5 + tailwind.config.ts | 2 +- 15 files changed, 591 insertions(+), 18 deletions(-) create mode 100644 src/i18n/th/batch1.ts create mode 100644 src/i18n/th/batch2.ts create mode 100644 src/i18n/th/index.ts diff --git a/AGENTS.md b/AGENTS.md index f4217b4..6327001 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2141,6 +2141,23 @@ similar are internal endpoints/hook names, not credits — leave them.) ## Internationalisation (EN / ES-Castellano) — decisions (2026-10-01) +### Thai (ไทย) — third locale (2026-10-06) +- Added `th` alongside en/es on the **same framework**; core plumbing unchanged. + Translations live in **`src/i18n/th/`** as fragments composed in `index.ts` + (so parallel batches don't edit one giant file). Batch 1 = app shell + static + pages (`common`, `nav`, `chromeUi`, `desktopNav`, `navSheet`, `pwa`, `home`, + `about`, `faq`, `onboarding`); more namespaces land in later batches. +- **Partial by design:** a missing `th` key falls back to English. `check:i18n` + is now **3-way** — en/es stay key-for-key identical (unchanged), `th` may be + partial and is reported as **coverage %**; a `th` key absent from `en` fails. +- **Font:** `@fontsource/kanit` (Kanit) imported in `main.tsx` and added to the + CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have **no Thai + glyphs**, so without it Thai silently falls back to a system font. +- **Detection:** a Thai-device browser (`th*`) starts in `th` (`detectLanguage`). +- **The trick glossary (`src/lib/glossary.ts`) stays English.** +- **Do-not-translate is broader than tricks for Thai:** core skate vocabulary + stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr, + Lightning, Zap, Relay, sats) because Thai skaters use the English terms. - The app ships **English + Spanish (Castellano, Spain)**. The framework already exists: `src/contexts/language.ts` (context/types/hooks) + `LanguageContext.tsx` (provider), `src/i18n/en.ts` + `src/i18n/es.ts` (one default-export object diff --git a/package-lock.json b/package-lock.json index 07ad91d..71384de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@fontsource-variable/outfit": "^5.2.8", "@fontsource/barlow-condensed": "^5.2.8", "@fontsource/bebas-neue": "^5.2.7", + "@fontsource/kanit": "^5.3.0", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", "@modelcontextprotocol/sdk": "^1.29.0", @@ -1501,6 +1502,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/kanit": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/kanit/-/kanit-5.3.0.tgz", + "integrity": "sha512-XrVxf5NWb8QLAEbQS2GYir3AY0WbOLr5Q80fWurnoFWXyc7CWvioGNPC2dxRGZKocMUXbwGhuK+sJqWkRLAWNA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@getalby/lightning-tools": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz", diff --git a/package.json b/package.json index c0e3e0a..94d7375 100644 --- a/package.json +++ b/package.json @@ -40,13 +40,13 @@ "@fontsource-variable/outfit": "^5.2.8", "@fontsource/barlow-condensed": "^5.2.8", "@fontsource/bebas-neue": "^5.2.7", + "@fontsource/kanit": "^5.3.0", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", "@modelcontextprotocol/sdk": "^1.29.0", "@netlify/blobs": "^11.1.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", - "@scure/base": "^1.1.1", "@nostrify/nostrify": "^0.47.1", "@nostrify/react": "^0.2.17", "@nostrify/types": "^0.36.7", @@ -77,6 +77,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@scure/base": "^1.1.1", "@scure/bip32": "^2.0.1", "@scure/bip39": "1.6.0", "@std/encoding": "npm:@jsr/std__encoding@^0.224.3", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index 6a9fded..457a59d 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -8,6 +8,9 @@ * 1. Every `t('…')` key used anywhere in `src/` MUST exist in BOTH `en` and * `es` (a missing key otherwise degrades to the raw key + a console warning). * 2. `en` and `es` must be key-for-key identical, so neither language can drift. + * 3. Thai (`th`) may be PARTIAL while it's being translated — a gap falls back + * to English and is reported as coverage %, not a failure. A `th` key that + * doesn't exist in `en` IS drift and fails. * * Translations are loaded with esbuild (already a dependency) so the TypeScript * source files can be imported directly without a build step. @@ -98,6 +101,7 @@ function collectUsedKeys(files) { async function main() { const en = flatten(await loadTranslations(join(SRC, 'i18n/en.ts'))); const es = flatten(await loadTranslations(join(SRC, 'i18n/es.ts'))); + const th = flatten(await loadTranslations(join(SRC, 'i18n/th/index.ts'))); const problems = []; @@ -112,6 +116,13 @@ async function main() { if (!es.has(key)) problems.push(`used but missing in es: ${key} (${files[0]})`); } + // 3. Thai may be PARTIAL while it's being translated (missing keys fall back + // to English), so a gap is reported as coverage, not a failure. But a Thai + // key that doesn't exist in `en` IS drift → fail. + const missingTh = [...en].filter((k) => !th.has(k)).length; + for (const key of th) if (!en.has(key)) problems.push(`th has a key not in en: ${key}`); + const thCoverage = en.size ? Math.round(((en.size - missingTh) / en.size) * 100) : 100; + if (problems.length) { console.error(`\n✖ i18n check failed (${problems.length}):`); for (const p of problems.slice(0, 200)) console.error(` • ${p}`); @@ -119,7 +130,7 @@ async function main() { process.exit(1); } - console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used`); + console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used · th coverage ${thCoverage}% (${en.size - missingTh}/${en.size})`); } main().catch((err) => { diff --git a/src/components/LanguageToggle.tsx b/src/components/LanguageToggle.tsx index f49f555..d9604b9 100644 --- a/src/components/LanguageToggle.tsx +++ b/src/components/LanguageToggle.tsx @@ -19,6 +19,7 @@ interface LanguageOption { const languages: LanguageOption[] = [ { code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' }, { code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' }, + { code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' }, ]; interface LanguageToggleProps { diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 2cb2bab..7dc5354 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -1113,7 +1113,9 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { // the app's current language, keeping skate trick names in English. const languageDirective = language === 'es' ? '\n\nAlways reply in Spanish (Castellano). Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and never translate them.' - : ''; + : language === 'th' + ? '\n\nAlways reply in Thai (ไทย) with a friendly, casual skate tone. Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and core skate vocabulary (spot, park, bowl, DIY, vert, crew, line) in English — never translate those.' + : ''; const systemPrompt = (personal.available && isPro ? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}` : SYSTEM_PROMPT) + languageDirective; diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx index a6dfec2..4123146 100644 --- a/src/contexts/LanguageContext.tsx +++ b/src/contexts/LanguageContext.tsx @@ -15,14 +15,16 @@ interface LanguageProviderProps { /** * Pick the starting language from the visitor's browser on their FIRST visit. - * A device set to Spanish (`es-ES`, `es-MX`, …) lands in Castellano; anything - * else falls back to English. Once the user taps the language toggle their - * choice is stored and always wins. + * A device set to Thai (`th`, `th-TH`) lands in Thai; Spanish (`es-*`) in + * Castellano; anything else falls back to English. Once the user taps the + * language toggle their choice is stored and always wins. */ function detectLanguage(): Language { if (typeof navigator === 'undefined') return 'en'; - const tags = [navigator.language, ...(navigator.languages ?? [])]; - return tags.some((tag) => tag?.toLowerCase().startsWith('es')) ? 'es' : 'en'; + const tags = [navigator.language, ...(navigator.languages ?? [])].map((t) => t?.toLowerCase() ?? ''); + if (tags.some((t) => t.startsWith('th'))) return 'th'; + if (tags.some((t) => t.startsWith('es'))) return 'es'; + return 'en'; } /** diff --git a/src/contexts/language.ts b/src/contexts/language.ts index 166cb2d..59186a0 100644 --- a/src/contexts/language.ts +++ b/src/contexts/language.ts @@ -1,6 +1,7 @@ import { createContext, useContext } from 'react'; import en from '@/i18n/en'; import es from '@/i18n/es'; +import th from '@/i18n/th'; /** * Language context + types + hooks. Split from the `LanguageProvider` component @@ -8,7 +9,7 @@ import es from '@/i18n/es'; * `react-refresh/only-export-components` rule. */ -export type Language = 'en' | 'es'; +export type Language = 'en' | 'es' | 'th'; type TranslationKey = string; type TranslationValue = string | { [key: string]: TranslationValue }; @@ -30,7 +31,7 @@ export interface LanguageContextType { export const LanguageContext = createContext(undefined); -export const translations: Record = { en, es }; +export const translations: Record = { en, es, th }; /** Get a nested value from an object using dot notation (`nav.home`). */ export function getNestedValue(obj: Translations, path: string): string | undefined { diff --git a/src/i18n/th/batch1.ts b/src/i18n/th/batch1.ts new file mode 100644 index 0000000..c48eeae --- /dev/null +++ b/src/i18n/th/batch1.ts @@ -0,0 +1,297 @@ +/** + * Thai (ไทย) — batch 1: app shell + static pages. + * + * Policy (same as Spanish): skate terminology stays English (spot, park, bowl, + * DIY, vert, Street/Transition/Plaza, Zap, Crew, Stash, The Lab, Nostr, + * Lightning, Bitcoin, Relay, sats). Everything else is Thai. + * + * Keys MUST match the `en` namespaces exactly; missing keys fall back to English + * (the `check:i18n` script reports Thai coverage). + */ +const thBatch1 = { + common: { + loading: 'กำลังโหลด...', + error: 'ผิดพลาด', + save: 'บันทึก', + cancel: 'ยกเลิก', + delete: 'ลบ', + edit: 'แก้ไข', + add: 'เพิ่ม', + remove: 'เอาออก', + create: 'สร้าง', + submit: 'ส่ง', + close: 'ปิด', + back: 'ย้อนกลับ', + next: 'ถัดไป', + done: 'เสร็จ', + search: 'ค้นหา', + filter: 'ตัวกรอง', + sort: 'เรียงลำดับ', + share: 'แชร์', + more: 'เพิ่มเติม', + copy: 'คัดลอก', + copied: 'คัดลอกแล้ว!', + viewAll: 'ดูทั้งหมด', + seeMore: 'ดูเพิ่มเติม', + showMore: 'แสดงเพิ่มเติม', + showLess: 'แสดงน้อยลง', + noResults: 'ไม่พบผลลัพธ์', + tryAgain: 'ลองอีกครั้ง', + learnMore: 'เรียนรู้เพิ่มเติม', + getStarted: 'เริ่มต้น', + yes: 'ใช่', + no: 'ไม่', + or: 'หรือ', + and: 'และ', + free: 'ฟรี', + new: 'ใหม่', + popular: 'ยอดนิยม', + featured: 'แนะนำ', + verified: 'ยืนยันแล้ว', + sats: 'sats', + clear: 'ล้าง', + clearFilters: 'ล้างตัวกรอง', + clearAll: 'ล้างทั้งหมด', + }, + nav: { + home: 'หน้าแรก', + map: 'Spots', + lab: 'The Lab', + events: 'อีเวนต์', + crews: 'Crews', + wallet: 'กระเป๋า', + settings: 'ตั้งค่า', + more: 'เพิ่มเติม', + rewards: 'รางวัล', + stats: 'สถิติชุมชน', + about: 'เกี่ยวกับ', + food: 'อาหาร', + marketplace: 'มาร์เก็ตเพลส', + mints: 'Mints', + appShop: 'ร้านค้าแอป', + diy: 'DIY', + trades: 'มาร์เก็ตเพลส', + stash: 'Stash', + collections: 'คอลเลกชัน', + contacts: 'รายชื่อ', + messages: 'ข้อความ', + skaters: 'นักสเก็ต', + glossary: 'คำศัพท์', + riderCard: 'Rider Card', + supporters: 'ผู้สนับสนุน ⚡', + newsletter: 'จดหมายข่าว', + viewProfile: 'ดูโปรไฟล์', + leaderboards: 'ลีดเดอร์บอร์ด', + }, + chromeUi: { + skipToContent: 'ข้ามไปที่เนื้อหา', + menu: 'เมนู', + inBadge: 'เข้า', + loggedIn: 'เข้าสู่ระบบแล้ว', + profile: 'โปรไฟล์', + logIn: 'เข้าสู่ระบบ', + logOut: 'ออกจากระบบ', + notLoggedIn: 'ยังไม่ได้เข้าสู่ระบบ', + loggedOutPrompt: 'เข้าสู่ระบบเพื่อเพิ่มสปอต ให้ Zap นักสเก็ต และบันทึกรายการโปรด', + accounts: 'บัญชี', + addAnotherAccount: 'เพิ่มบัญชี', + setStatus: 'ตั้งสถานะ', + setYourStatus: 'ตั้งสถานะของคุณ', + status: 'สถานะ', + addSpot: 'เพิ่มสปอต', + myData: 'ข้อมูลของฉัน', + myStash: 'Stash ของฉัน', + myCollections: 'คอลเลกชันของฉัน', + viewMyProfile: 'ดูโปรไฟล์ของฉัน', + customizeNavBar: 'ปรับแต่งแถบนำทาง', + ecash: 'Ecash', + madeWith: 'สร้างด้วย', + forSkateCommunity: 'เพื่อชุมชนสเก็ตบอร์ด', + beta: 'Beta', + license: 'สัญญาอนุญาต', + privacy: 'ความเป็นส่วนตัว', + collaborators: 'ผู้ร่วมงานและเพื่อน', + notSponsoredBy: 'ไม่ได้รับการสนับสนุนจาก', + errorTitle: 'โหลดไม่สำเร็จ', + errorDescription: 'คำขอล้มเหลว ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง', + tryAgain: 'ลองอีกครั้ง', + weatherOn: 'เปิดสภาพอากาศ', + weatherOff: 'ปิดสภาพอากาศ', + trustFilter: 'ตัวกรองความน่าเชื่อถือ', + filterByTrustLevel: 'กรองตามระดับความน่าเชื่อถือ', + trust: { + all: 'ผู้ใช้ทั้งหมด', + allDesc: 'แสดงเนื้อหาจากทุกคน', + verified: 'ยืนยันแล้ว', + verifiedDesc: 'คุณติดตามหรือยืนยันแล้ว (trust ≥90)', + trusted: 'น่าเชื่อถือ', + trustedDesc: 'เพื่อนของเพื่อนหรือยืนยัน NIP-05 (trust ≥60)', + following: 'เฉพาะที่ติดตาม', + followingDesc: 'แสดงเฉพาะเนื้อหาจากคนที่คุณติดตาม', + followingShort: 'กำลังติดตาม', + }, + sort: { + recent: 'ล่าสุด', + recentDesc: 'ใหม่สุดก่อน', + trust: 'ความน่าเชื่อถือสูงสุด', + trustDesc: 'ครีเอเตอร์ที่น่าเชื่อถือที่สุด', + proximity: 'ใกล้ฉันที่สุด', + proximityDesc: 'ตำแหน่งที่ใกล้ที่สุด', + popular: 'ยอดนิยม', + popularDesc: 'คอมเมนต์/รีแอ็กชันมากที่สุด', + zaps: 'ถูก Zap มากที่สุด', + zapsDesc: 'มูลค่าที่ได้รับสูงสุด', + }, + spotType: { + street: 'Street', + transition: 'Transition', + park: 'Park', + plaza: 'Plaza', + diy: 'DIY', + bowl: 'Bowl', + vert: 'Vert', + indoor: 'Indoor', + skatepark: 'Skatepark', + miniramp: 'Mini Ramp', + }, + }, + desktopNav: { + title: 'แถบนำทางเดสก์ท็อป', + desc: 'เลือกว่าแท็บใดจะแสดงบนแถบบนและลำดับ แท็บที่ซ่อนยังอยู่ในเมนู “เพิ่มเติม”', + fixed: 'คงที่', + shownInTopBar: 'แสดงบนแถบบน ({{count}})', + noExtraTabs: 'ไม่มีแท็บเพิ่มเติม — ทุกอย่างอยู่ในเมนู “เพิ่มเติม” เพิ่มด้านล่างได้เลย', + inMoreMenu: 'อยู่ในเมนู “เพิ่มเติม” ({{count}})', + moveUp: 'เลื่อน {{name}} ขึ้น', + moveDown: 'เลื่อน {{name}} ลง', + hide: 'ซ่อน {{name}}', + moveToMore: 'ย้ายไปเมนูเพิ่มเติม', + show: 'แสดง', + showInTopBar: 'แสดง {{name}} บนแถบบน', + showInTopBarTitle: 'แสดงบนแถบบน', + reset: 'รีเซ็ตเป็นค่าเริ่มต้น', + }, + navSheet: { + title: 'ปรับแต่งแถบของคุณ', + desc: 'เลือก {{count}} แท็บที่คุณใช้บ่อย ที่เหลือจะอยู่ในเมนู', + preview: 'ตัวอย่าง', + empty: 'ว่าง', + order: 'ลำดับ ({{done}}/{{total}})', + available: 'แท็บที่ใช้ได้', + full: 'แถบเต็ม — เอาออกหนึ่งอันเพื่อสลับ', + moveUp: 'เลื่อน {{name}} ขึ้น', + moveDown: 'เลื่อน {{name}} ลง', + login: 'เข้าสู่ระบบ', + profile: 'โปรไฟล์', + reset: 'รีเซ็ต', + done: 'เสร็จ', + }, + pwa: { + installTitle: 'ติดตั้ง SkateSpots', + installSubtitle: 'รับประสบการณ์แอปเต็มรูปแบบ', + addToHomeScreen: 'เพิ่มไปยังหน้าจอหลัก', + installApp: 'ติดตั้งแอป', + maybeLater: 'ไว้ทีหลัง', + gotIt: 'เข้าใจแล้ว!', + benefits: { + fast: 'เร็วสุดขีด', + offline: 'ใช้แบบออฟไลน์ได้', + fullAccess: 'เข้าถึงเต็มรูปแบบ', + }, + iosStep1: 'แตะปุ่มแชร์', + iosStep1Hint: 'ที่ด้านล่างของ Safari', + iosStep2: 'เลื่อนแล้วแตะ “เพิ่มไปยังหน้าจอหลัก”', + iosStep2Hint: 'ในเมนูแชร์', + iosStep3: 'แตะ “เพิ่ม” เพื่อติดตั้ง', + iosStep3Hint: 'แอปจะปรากฏบนหน้าจอหลักของคุณ', + androidStep1: 'แตะปุ่มเมนู', + androidStep1Hint: '(สามจุด) ในเบราว์เซอร์', + androidStep2: 'เลือก “เพิ่มไปยังหน้าจอหลัก”', + androidStep2Hint: 'หรือ “ติดตั้งแอป”', + androidStep3: 'ยืนยันการติดตั้ง', + androidStep3Hint: 'แอปจะปรากฏบนหน้าจอหลักของคุณ', + tagline: 'ฟรี • ใช้แบบออฟไลน์ได้ • เร็วและเชื่อถือได้', + }, + home: { + tagline: 'ขับเคลื่อนโดยชุมชน • บน Relays', + title: 'SKATE SPOTS', + subtitle: 'ค้นหาสปอต • นัดเจอกัน • เรียนทริก • ซัพพอร์ตท้องถิ่น', + description: 'การสเก็ตจริง ๆ เกิดนอกจอ — เครื่องมือนี้ช่วยให้คุณเจอมัน', + betaWarning: 'BETA - อยู่ระหว่างพัฒนา • ใช้งานด้วยความระมัดระวัง', + pwaHint: 'Progressive Web App - ติดตั้งบนอุปกรณ์ของคุณ', + exploreMap: 'สำรวจแผนที่', + addSpot: 'เพิ่มสปอต', + globalSpots: 'สปอตทั่วโลก', + openCommunity: 'ชุมชนเปิด', + lightningZaps: 'Lightning Zaps', + featuredSpots: 'สปอตแนะนำ', + featuredSpotsDesc: 'สปอตสเก็ตที่ชุมชนยืนยันแล้วจากทั่วโลก', + upcomingEvents: 'อีเวนต์ที่กำลังจะมา', + upcomingEventsDesc: 'การแข่ง มีตติ้ง และเซสชันที่กำลังจะเกิดขึ้น', + noSpotsYet: 'ยังไม่พบสปอต', + noSpotsHint: 'เป็นคนแรกที่เพิ่มสปอตสเก็ต!', + noEventsYet: 'ยังไม่มีอีเวนต์ที่กำลังจะมา', + noEventsHint: 'กลับมาดูใหม่หรือสร้างขึ้นเอง!', + theLab: 'The Lab', + theLabDesc: 'เรียนทริก หาโค้ช และอัปสกิลการเล่นของคุณ', + coaches: 'โค้ช', + coachesDesc: 'เรียนจากนักสเก็ตที่มีประสบการณ์ช่วยให้คุณพัฒนา', + filmers: 'ช่างถ่าย', + filmersDesc: 'เชื่อมต่อกับช่างถ่ายเพื่อเก็บคลิปที่ดีที่สุด', + findCoach: 'หาโค้ช', + becomeCoach: 'เป็นโค้ช', + findFilmer: 'หาช่างถ่าย', + becomeFilmer: 'เป็นช่างถ่าย', + viewAllSpots: 'ดูสปอตทั้งหมด', + viewAllEvents: 'ดูอีเวนต์ทั้งหมด', + createEvent: 'สร้างอีเวนต์', + readyToShare: 'พร้อมแชร์แล้วหรือยัง?', + readyToShareDesc: 'ช่วยให้ชุมชนสเก็ตเติบโตด้วยการเพิ่มสปอตโปรดของคุณ ทุกตำแหน่งที่แชร์ช่วยให้นักสเก็ตคนอื่นค้นพบที่ใหม่ ๆ', + }, + about: { + title: 'เกี่ยวกับ SkateSpots', + subtitle: 'ชุมชนสเก็ตบอร์ดแบบกระจายศูนย์', + whatIs: 'SkateSpots คืออะไร?', + whatIsDesc: 'SkateSpots คือแพลตฟอร์มที่ขับเคลื่อนโดยชุมชนสำหรับค้นหาสถานที่สเก็ตทั่วโลก สร้างบน Nostr ให้นักสเก็ตเป็นเจ้าของข้อมูลของตัวเองอย่างแท้จริง', + features: 'ฟีเจอร์', + featuresDesc: 'ค้นหาสปอต สร้างอีเวนต์ เชื่อมต่อกับโค้ช และสนับสนุนร้านสเก็ตท้องถิ่น — ครบในที่เดียว', + nostr: 'สร้างบน Nostr', + nostrDesc: 'Nostr คือโปรโตคอลเปิดที่เรียบง่าย ทำให้การสื่อสารทั่วโลกต้านทานการเซ็นเซอร์ได้จริง บัญชีและข้อมูลเป็นของคุณ', + openSource: 'โอเพนซอร์ส', + openSourceDesc: 'SkateSpots เป็นโอเพนซอร์สเต็มรูปแบบ ร่วมพัฒนา ฟอร์ก หรือรันอินสแตนซ์ของคุณเองได้', + lightning: 'การจ่ายด้วย Lightning', + lightningDesc: 'Zap นักสเก็ต โค้ช และครีเอเตอร์ที่คุณชอบด้วยการจ่าย Bitcoin Lightning', + }, + faq: { + title: 'คำถามที่พบบ่อย', + subtitle: 'ทุกอย่างที่คุณต้องรู้เกี่ยวกับ SkateSpots', + whatIsNostr: 'Nostr คืออะไร?', + whatIsNostrAnswer: 'Nostr คือโปรโตคอลเปิดสำหรับโซเชียลแบบกระจายศูนย์ ต่างจากแพลตฟอร์มทั่วไป คุณเป็นเจ้าของข้อมูลและตัวตนของคุณ', + howToStart: 'เริ่มต้นอย่างไร?', + howToStartAnswer: 'กด “สมัคร” เพื่อสร้างบัญชี Nostr ใหม่ หรือ “เข้าสู่ระบบ” ถ้ามีอยู่แล้ว ใช้ส่วนขยายเบราว์เซอร์ แอปมือถือ หรือคีย์ลับก็ได้', + whatAreZaps: 'Zaps คืออะไร?', + whatAreZapsAnswer: 'Zaps คือทิป Bitcoin Lightning ที่ส่งให้ผู้ใช้คนอื่น เป็นวิธีแสดงความชื่นชมเนื้อหาดี ๆ หรือสนับสนุนครีเอเตอร์', + howToAddSpot: 'เพิ่มสปอตอย่างไร?', + howToAddSpotAnswer: 'เข้าสู่ระบบ แล้วกด “เพิ่มสปอต” กรอกรายละเอียด ทั้งตำแหน่ง รูป และคำอธิบาย', + isItFree: 'SkateSpots ฟรีไหม?', + isItFreeAnswer: 'ฟรี! SkateSpots ใช้งานฟรีทั้งหมด จะส่ง Zaps (ทิป Bitcoin) ให้ผู้ใช้คนอื่นก็ได้', + canIEditSpot: 'แก้ไขหรือลบสปอตของฉันได้ไหม?', + canIEditSpotAnswer: 'ได้ คุณแก้ไขสปอตที่สร้างได้ เนื่องจากธรรมชาติกระจายศูนย์ของ Nostr คำขอลบจะถูกส่งไป แต่บาง relay อาจเก็บข้อมูลไว้', + }, + onboarding: { + welcome: 'ยินดีต้อนรับสู่ SkateSpots!', + welcomeDesc: 'มาตั้งค่ากันในไม่กี่ขั้นตอน', + step1Title: 'สร้างบัญชีของคุณ', + step1Desc: 'กำลังสร้างคีย์ Nostr ของคุณ เหมือนรหัสผ่านที่คุณควบคุมเอง', + step2Title: 'สำรองคีย์ของคุณ', + step2Desc: 'เก็บวลีกู้คืนไว้ในที่ปลอดภัย นี่เป็นวิธีเดียวที่จะกู้บัญชีคืนได้!', + step3Title: 'ตั้งค่าโปรไฟล์ของคุณ', + step3Desc: 'บอกชุมชนเกี่ยวกับตัวคุณ', + step4Title: 'พร้อมแล้ว!', + step4Desc: 'เริ่มสำรวจสปอต อีเวนต์ และเชื่อมต่อกับนักสเก็ต', + skipForNow: 'ข้ามไปก่อน', + continueToApp: 'ไปที่แอป', + }, +}; + +export default thBatch1; diff --git a/src/i18n/th/batch2.ts b/src/i18n/th/batch2.ts new file mode 100644 index 0000000..c39e13d --- /dev/null +++ b/src/i18n/th/batch2.ts @@ -0,0 +1,201 @@ +/** + * Thai (ไทย) — batch 2: the map (mapUi + mapPage). + * Skate terms stay English (Spots, Shops, Gear, Crews, Stash, Tour, Crew). + */ +const thBatch2 = { + mapUi: { + mapFooter: { + links: 'ลิงก์', + hide: 'ซ่อนแถบลิงก์แผนที่', + }, + findingSpots: 'กำลังหาสปอต', + loadingSpots: 'กำลังโหลดสปอต', + loadingMap: 'กำลังโหลดแผนที่...', + mapViewAria: 'มุมมองแผนที่', + listViewAria: 'มุมมองรายการ', + layers: 'เลเยอร์', + mapLayers: 'เลเยอร์แผนที่', + showOnMap: 'แสดงบนแผนที่', + showResults: 'แสดงผลลัพธ์', + quickActions: 'ทางลัด', + spots: 'Spots', + shops: 'Shops', + gear: 'Gear', + tours: 'Tours', + events: 'อีเวนต์', + crews: 'Crews', + spotType: 'ประเภทสปอต', + sortBy: 'เรียงตาม', + trustLevel: 'ระดับความน่าเชื่อถือ', + weather: 'สภาพอากาศ', + buySellGear: 'ซื้อ/ขาย Gear', + stash: 'Stash', + more: 'เพิ่มเติม', + newHere: 'ใหม่ที่นี่?', + welcomeDesc: 'ค้นพบสปอตสเก็ต อีเวนต์ และชุมชน', + getOnZapstore: 'รับบน Zapstore', + dismiss: 'ปิด', + addedBy: 'เพิ่มโดย', + directions: 'เส้นทาง', + viewDetails: 'ดูรายละเอียด', + viewDetailsArrow: 'ดูรายละเอียด →', + spotDetails: 'รายละเอียดสปอต', + skateShop: 'Skate Shop', + skateSpot: 'Skate Spot', + youAreHere: 'คุณอยู่ที่นี่', + conditions: 'สภาพ', + weatherScoreSuffix: '{{score}}/100 สภาพอากาศ', + wind: 'ลม', + humidity: 'ความชื้น', + airQuality: 'คุณภาพอากาศ', + threeDayForecast: 'พยากรณ์ 3 วัน', + gearForSale: 'Gear ที่ขาย ({{count}})', + viewItems: 'ดูสินค้า →', + viewAllItems: 'ดูทั้งหมด {{count}} สินค้า →', + viewGear: 'ดู Gear →', + localTour: 'Local Tour', + meetAt: 'นัดที่: {{place}}', + hours: '{{count}} ชั่วโมง', + level: 'ระดับ {{level}}', + viewTour: 'ดูทัวร์ →', + upcomingEvent: 'อีเวนต์ที่กำลังจะมา', + viewEvent: 'ดูอีเวนต์ →', + skateCrew: 'Skate Crew', + members: 'สมาชิก {{count}} คน', + since: 'ตั้งแต่ {{year}}', + viewCrew: 'ดู Crew →', + acceptsBitcoin: 'รับ Bitcoin', + boosted: 'Boosted', + website: 'เว็บไซต์', + offlineSavedSpots: 'ออฟไลน์ · แสดงสปอตที่บันทึกไว้', + cantReachRelays: 'เชื่อมต่อ relay ไม่ได้', + relayOnlineNoAnswer: 'คุณออนไลน์อยู่ แต่ relay ของชุมชนยังไม่ตอบสนอง', + youreOffline: 'คุณออฟไลน์อยู่', + savedOnDevice: 'สปอตที่คุณเคยเห็นถูกบันทึกไว้ในเครื่องนี้และจะแสดงที่นี่', + noSpotsMatchTitle: 'ไม่มีสปอตที่ตรง', + noSpotsMatchDesc: 'ลองค้นหาอย่างอื่นหรือล้างตัวกรอง', + noSpotsMappedYet: 'ยังไม่มีสปอตบนแผนที่', + noSpotsMappedDesc: 'เป็นคนแรกที่แมปสปอตของคุณ ใช้เวลาไม่กี่นาที มันเป็นของคุณ และนักสเก็ตคนต่อไปจะขอบคุณ', + addFirstSpot: 'เพิ่มสปอตแรก', + findSpotsNearMe: 'หาสปอตใกล้ฉัน', + locating: 'กำลังระบุตำแหน่ง…', + refiningLocation: 'กำลังปรับตำแหน่ง…', + refiningLocationDesc: 'อิงจากสปอตล่าสุดของคุณ — กำลังขอพิกัด GPS ใหม่', + locatingYou: 'กำลังหาตำแหน่งคุณ…', + locatingDesc: 'กำลังขอพิกัด GPS ครั้งแรกอาจใช้เวลาถึงหนึ่งนาที', + foundYou: 'เจอตำแหน่งคุณแล้ว', + foundYouDesc: 'จัดแผนที่ให้อยู่ที่ตำแหน่งของคุณ', + locationPermissionNeeded: 'ต้องอนุญาตตำแหน่ง', + permissionNeededDesc: 'อนุญาตตำแหน่งให้ SkateSpots ที่ ตั้งค่าเครื่อง → แอป → SkateSpots → สิทธิ์ แล้วกด "ใกล้ฉัน" อีกครั้ง', + locationTimedOut: 'หาตำแหน่งหมดเวลา', + timeoutNativeDesc: 'หาพิกัดไม่ทันเวลา ถ้าอยู่ในอาคาร GPS มองไม่เห็นดาวเทียม — เปิด Network Location (ตั้งค่า → ตำแหน่ง) หรือออกไปข้างนอกแล้วลองใหม่', + timeoutWebDesc: 'หาตำแหน่งไม่ทันเวลา ตรวจสัญญาณแล้วลองใหม่', + noLocationProvider: 'ไม่มีตัวให้ตำแหน่ง', + noProviderNativeDesc: 'โทรศัพท์ไม่ให้ตำแหน่ง บน ROM ที่เน้นความเป็นส่วนตัว (เช่น GrapheneOS) เปิด Network Location ใน ตั้งค่า → ตำแหน่ง หรือตรวจว่า GPS เปิดและลองกลางแจ้ง', + noProviderWebDesc: 'ไม่มีผู้ให้บริการตำแหน่ง เปิดบริการตำแหน่งแล้วลองใหม่', + locationUnavailable: 'ตำแหน่งใช้ไม่ได้', + unavailableNativeDesc: 'เปิด ตำแหน่ง/GPS ในตั้งค่าโทรศัพท์ อนุญาตตำแหน่งให้ SkateSpots แล้วลองใหม่', + unavailableWebDesc: 'ระบุตำแหน่งไม่ได้ ตรวจว่าบริการตำแหน่งเปิดอยู่', + locationBlocked: 'ตำแหน่งถูกบล็อก', + locationBlockedDesc: 'เบราว์เซอร์บล็อกการเข้าถึงตำแหน่งสำหรับไซต์นี้ วิธีเปิดใหม่:', + locationPlatformAndroid: 'Android Chrome', + locationPlatformIos: 'iOS Safari', + locationPlatformDesktop: 'Desktop Chrome', + locationPlatformFirefox: 'Firefox', + locationStepsAndroid: 'แตะไอคอนกุญแจบนแถบที่อยู่ → การตั้งค่าไซต์ → ตำแหน่ง → อนุญาต', + locationStepsIos: 'แอปตั้งค่า → Safari → ตำแหน่ง → อนุญาต (หรือแตะปุ่ม "AA" บนแถบที่อยู่ของ Safari)', + locationStepsDesktop: 'คลิกไอคอนกุญแจ → การตั้งค่าไซต์ → ตำแหน่ง → อนุญาต', + locationStepsFirefox: 'คลิกไอคอนกุญแจ → การเชื่อมต่อปลอดภัย → ข้อมูลเพิ่มเติม → สิทธิ์ → การเข้าถึงตำแหน่ง → อนุญาต', + gotIt: 'เข้าใจแล้ว', + addSpot: 'เพิ่มสปอต', + addShop: 'เพิ่มร้าน', + addEvent: 'เพิ่มอีเวนต์', + sellGear: 'ขาย Gear', + offerTour: 'เสนอทัวร์', + closeAddMenu: 'ปิดเมนูเพิ่ม', + addMenuAria: 'เพิ่มสปอต ร้าน อีเวนต์ Gear หรือทัวร์', + localChat: 'แชตท้องถิ่น', + localChatDesc: 'แชตสดตามจุดกลางแผนที่ เลือกความกว้างของช่องได้', + btcMapShowing: 'แสดง {{count}} สถานที่ที่รับ sats ของคุณใกล้ ๆ', + btcMapShow: 'แสดงสถานที่ที่รับ sats ของคุณ (ผ่าน BTC Map)', + yourPrivateStash: 'Stash ส่วนตัวของคุณ', + stashIntro: 'ติดตาม Gear ตั้งราคา และหาเทรดกับนักสเก็ตคนอื่น', + encryptionRequired: 'ต้องเข้ารหัส', + encryptionRequiredDesc: 'ใช้ตัวเซ็นที่รองรับ NIP-44 (Alby, nos2x, Amber)', + myStash: 'Stash ของฉัน', + encrypted: 'เข้ารหัสแล้ว', + itemsCount: '{{count}} รายการ', + items: 'รายการ', + wishlist: 'Wishlist', + types: 'ประเภท', + stashEmpty: 'Stash ของคุณว่างเปล่า', + addFirstItem: 'เพิ่มรายการแรก', + wishlistCount: 'Wishlist ({{count}})', + myGearCount: 'Gear ของฉัน ({{count}})', + openFullStash: 'เปิด Stash เต็ม', + addItem: 'เพิ่มรายการ', + searchSpotsPlaceholder: 'ค้นหาสปอต...', + selected: 'เลือกแล้ว', + noSpotsFound: 'ไม่พบสปอต ลองค้นหาอย่างอื่น', + addingSpotsHint: 'การเพิ่มสปอตช่วยให้คนอื่นเจอคุณเมื่อดูตำแหน่งนั้น', + report: 'รายงาน', + spotSingular: '1 สปอต', + spotPlural: '{{count}} สปอต', + view: 'ดู', + myCollections: 'คอลเลกชันของฉัน', + collectionsSubtitle: 'จัดสปอตโปรดเป็นลิสต์ของคุณเอง', + newCollection: 'คอลเลกชันใหม่', + loginToCreate: 'เข้าสู่ระบบเพื่อสร้างคอลเลกชัน', + loginToCreateDesc: 'บันทึกสปอตโปรดเป็นลิสต์ เช่น "ทริปบาร์เซโลนา 2024" หรือ "Best Rails in LA"', + noCollections: 'ยังไม่มีคอลเลกชัน', + noCollectionsDesc: 'สร้างคอลเลกชันแรกเพื่อเริ่มจัดสปอต', + createFirstCollection: 'สร้างคอลเลกชันแรก', + createNewCollection: 'สร้างคอลเลกชันใหม่', + createCollectionDesc: 'ตั้งชื่อคอลเลกชันและคำอธิบาย (ไม่บังคับ)', + collectionName: 'ชื่อคอลเลกชัน', + collectionNamePlaceholder: 'ทริปบาร์เซโลนา 2024', + descriptionOptional: 'คำอธิบาย (ไม่บังคับ)', + descriptionPlaceholder: 'สปอตโปรดจากทริปบาร์เซโลนาของฉัน...', + creating: 'กำลังสร้าง...', + createCollection: 'สร้างคอลเลกชัน', + editCollection: 'แก้ไขคอลเลกชัน', + saving: 'กำลังบันทึก...', + saveChanges: 'บันทึกการเปลี่ยนแปลง', + deleteCollectionTitle: 'ลบคอลเลกชัน?', + deleteCollectionDesc: 'การลบ "{{title}}" จะถาวร ตัวสปอตจะไม่ได้รับผลกระทบ', + deleting: 'กำลังลบ...', + collectionCreated: 'สร้างคอลเลกชันแล้ว!', + collectionCreatedDesc: '"{{title}}" พร้อมใส่สปอตแล้ว', + collectionCreateFailed: 'สร้างคอลเลกชันไม่สำเร็จ', + collectionUpdated: 'อัปเดตคอลเลกชันแล้ว!', + changesSaved: 'บันทึกการเปลี่ยนแปลงแล้ว', + collectionUpdateFailed: 'อัปเดตคอลเลกชันไม่สำเร็จ', + collectionDeleted: 'ลบคอลเลกชันแล้ว', + collectionDeletedDesc: 'ลบ "{{title}}" แล้ว', + collectionDeleteFailed: 'ลบคอลเลกชันไม่สำเร็จ', + somethingWentWrong: 'มีบางอย่างผิดพลาด', + }, + mapPage: { + title: 'ร้านและสปอต', + spots: 'Spots', + shops: 'Shops', + filters: 'ตัวกรอง', + listView: 'รายการ', + mapView: 'แผนที่', + nearMe: 'ใกล้ฉัน', + searchPlaceholder: 'ค้นหาสปอต...', + noSpotsInArea: 'ไม่มีสปอตในพื้นที่นี้', + zoomIn: 'ซูมเข้า', + zoomOut: 'ซูมออก', + myLocation: 'ตำแหน่งของฉัน', + locationsFound: 'พบ {{count}} ตำแหน่ง', + spotsOnly: 'เฉพาะ Spots', + shopsOnly: 'เฉพาะ Shops', + spotsAndShops: 'Spots & Shops', + noSpotsMatch: 'ไม่มีสปอตที่ตรงกับตัวกรอง', + noSpotsYet: 'ยังไม่มีการเพิ่มสปอต', + addFirstSpot: 'เพิ่มสปอตแรก', + }, +}; + +export default thBatch2; diff --git a/src/i18n/th/index.ts b/src/i18n/th/index.ts new file mode 100644 index 0000000..112ca65 --- /dev/null +++ b/src/i18n/th/index.ts @@ -0,0 +1,17 @@ +import batch1 from './batch1'; +import batch2 from './batch2'; + +/** + * Thai (ไทย) translations — composed from fragments so parallel translation + * batches never touch one giant file (same pattern as `i18n/batch3|batch4`). + * + * Partial by design: any key not yet translated falls back to English, and + * `scripts/check-i18n.mjs` reports the coverage. Add new fragments here as the + * remaining namespaces land. + */ +const th = { + ...batch1, + ...batch2, +}; + +export default th; diff --git a/src/index.css b/src/index.css index 4715cd7..2ce8f1c 100644 --- a/src/index.css +++ b/src/index.css @@ -149,18 +149,18 @@ /* ── Display font utilities ───────────────────────────────────────────────── */ /* Bebas Neue — used for all major headlines */ .font-display { - font-family: 'Bebas Neue', 'Impact', 'Arial Black', sans-serif; + font-family: 'Bebas Neue', 'Kanit', 'Impact', 'Arial Black', sans-serif; letter-spacing: 0.04em; } /* Barlow Condensed — used for subheadings, labels, nav */ .font-condensed { - font-family: 'Barlow Condensed', 'Arial Narrow', sans-serif; + font-family: 'Barlow Condensed', 'Kanit', 'Arial Narrow', sans-serif; } .neon-header { animation: neon-glow 2s ease-in-out infinite; - font-family: 'Bebas Neue', 'Impact', 'Arial Black', sans-serif; + font-family: 'Bebas Neue', 'Kanit', 'Impact', 'Arial Black', sans-serif; letter-spacing: 0.06em; } @@ -714,7 +714,7 @@ select { position: relative; overflow: hidden; flex-shrink: 0; - font-family: 'Inter Variable', 'Inter', system-ui, sans-serif; + font-family: 'Inter Variable', 'Inter', 'Kanit', system-ui, sans-serif; box-shadow: 0 8px 40px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); } diff --git a/src/lib/dateFnsI18n.ts b/src/lib/dateFnsI18n.ts index 094e702..4590eb1 100644 --- a/src/lib/dateFnsI18n.ts +++ b/src/lib/dateFnsI18n.ts @@ -19,7 +19,7 @@ import { formatDistance as _formatDistance, format as _format, } from 'date-fns'; -import { enUS, es as esLocale } from 'date-fns/locale'; +import { enUS, es as esLocale, th as thLocale } from 'date-fns/locale'; import type { Locale } from 'date-fns'; import type { Language } from '@/contexts/language'; @@ -28,8 +28,16 @@ let activeCode = 'en-US'; /** Called by LanguageProvider (during render) so children format in-language. */ export function setDateLocale(language: Language): void { - activeLocale = language === 'es' ? esLocale : enUS; - activeCode = language === 'es' ? 'es-ES' : 'en-US'; + if (language === 'es') { + activeLocale = esLocale; + activeCode = 'es-ES'; + } else if (language === 'th') { + activeLocale = thLocale; + activeCode = 'th-TH'; + } else { + activeLocale = enUS; + activeCode = 'en-US'; + } } /** The active date-fns locale. */ diff --git a/src/main.tsx b/src/main.tsx index bc27b52..1afdd2c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -34,6 +34,11 @@ import '@fontsource/bebas-neue'; import '@fontsource/barlow-condensed/400.css'; import '@fontsource/barlow-condensed/600.css'; import '@fontsource/barlow-condensed/700.css'; +// Kanit — Thai glyphs (Latin/Thai stack fallback; the other fonts have no Thai) +import '@fontsource/kanit/400.css'; +import '@fontsource/kanit/500.css'; +import '@fontsource/kanit/600.css'; +import '@fontsource/kanit/700.css'; // Leaflet CSS is loaded via CDN in index.html diff --git a/tailwind.config.ts b/tailwind.config.ts index 80f18d2..cb44f2b 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -28,7 +28,7 @@ export default { }, extend: { fontFamily: { - sans: ['Outfit Variable', 'Outfit', 'system-ui', 'sans-serif'], + sans: ['Outfit Variable', 'Outfit', 'Kanit', 'system-ui', 'sans-serif'], }, colors: { border: 'hsl(var(--border))',