i18n(th): batch 2 — map (mapUi + mapPage) + AGENTS note

This commit is contained in:
SkateSpots 2026-10-06 13:35:20 +07:00
parent 39572998a0
commit b8c306125c
15 changed files with 591 additions and 18 deletions

View file

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

10
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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) => {

View file

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

View file

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

View file

@ -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';
}
/**

View file

@ -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<LanguageContextType | undefined>(undefined);
export const translations: Record<Language, Translations> = { en, es };
export const translations: Record<Language, Translations> = { en, es, th };
/** Get a nested value from an object using dot notation (`nav.home`). */
export function getNestedValue(obj: Translations, path: string): string | undefined {

297
src/i18n/th/batch1.ts Normal file
View file

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

201
src/i18n/th/batch2.ts Normal file
View file

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

17
src/i18n/th/index.ts Normal file
View file

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

View file

@ -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);
}

View file

@ -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. */

View file

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

View file

@ -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))',