diff --git a/AGENTS.md b/AGENTS.md index 5db20f8..59ac5b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -824,6 +824,12 @@ session with **no other memory of this project**. So: ⚠️ **Android `standard` mode is best-effort** (Doze/OEM battery managers defer it); `fast` needs a sticky notification. Never the only path. (Approach + tradeoffs came from the Numo/Linky/BTCPay/Cordn review this session.) + **Categories PUSHED (2026-10-01, supersedes the line above):** `dms`, + `trades`, `crew` (invite 36229 / join-request 31969), and `social` (zap 9735 / + reply-mention 1 / comment 1111) via `CATEGORY_BY_KIND`. **Reactions/reposts + are NOT pushed** (too noisy) and **newsletter has no push channel** — those + stay in-app. The **master switch off tears down push + the native poller** + (`toggleEnabled(false)` calls `push.disable()` + `background.disable()`). - **Cordn coordinator support (client-side, 2026-09-29):** Cordn is an **MLS delivery coordinator** exposed as a **ContextVM server** over Nostr relays (E2EE; it sees ciphertext + *metadata* — who/when/which group). SkateSpots is @@ -1609,6 +1615,34 @@ The credits should read (keep this shape when editing): **Nostr** (protocol) · · **Zapstore** · **ContextVM**. (`useShakespeare.ts`, `proxy.shakespeare.diy` and similar are internal endpoints/hook names, not credits — leave them.) +## Internationalisation (EN / ES-Castellano) — decisions (2026-10-01) + +- 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 + each), `t()` with dot-notation + `{{param}}` interpolation, English fallback and + a console warning for a missing key. Toggle in the header (`LanguageToggle`) and + Settings → Language; persisted in `localStorage` `app-language`. +- **First-visit auto-detect:** `detectLanguage()` in `LanguageContext.tsx` starts + Spanish-device visitors in `es` (Castellano), everyone else in English. A stored + choice always wins. +- **Translation policy (owner decision 2026-10-01):** Castellano; **only skate + TRICK names stay English** (kickflip, ollie, nollie, heelflip, 50-50, …). + Everything else — UI chrome, descriptions, buttons, errors — translates. Gear + words DO translate (decks → tablas, wheels → ruedas); trucks/bearings are common + as-is. Keep the do-not-translate list limited to tricks. +- **The Sasquatch replies in the app language:** `SasquatchGuide` appends a + `languageDirective` to the system prompt (both the first streaming pass and the + tool-result synthesis pass) when `language === 'es'`. +- **Keep `en.ts` and `es.ts` key-for-key in sync** — the fallback + warn makes + drift silent otherwise. +- **User-generated content is never translated** (spot / listing / DM / crew text + is whatever the author wrote). +- ⚠️ Coverage is **partial**: the framework + ~33 namespaces exist, but most files + are not yet wired to `t()`. Continue extracting area by area (Map/spots → + Marketplace/trades → Lab/crews → Settings/legal). Untranslated text falls back + to English, so it degrades gracefully. + ## Nostr Protocol Integration This project comes with custom hooks for querying and publishing events on the Nostr network. diff --git a/package.json b/package.json index 5f4eff4..f85b9e3 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "check:cvm": "bun build cvm-server/server.ts --target=bun --outfile=/tmp/cvm-check.js", "check:ai": "bun build ai-proxy/server.ts --target=bun --outfile=/tmp/ai-check.js", "check:push": "bun build push-service/server.ts --target=bun --outfile=/tmp/push-check.js", - "test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", + "check:i18n": "node scripts/check-i18n.mjs", + "test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'", "deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup", "android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs", "android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs new file mode 100644 index 0000000..76ee386 --- /dev/null +++ b/scripts/check-i18n.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * i18n guard — fails the build on translation drift. + * + * Because `t('some.key')` takes a plain string, TypeScript can't catch a typo or + * a key that only exists in one language. This script is the safety net: + * + * 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. + * + * Translations are loaded with esbuild (already a dependency) so the TypeScript + * source files can be imported directly without a build step. + */ +import { readFileSync, readdirSync, statSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join, extname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; + +const ROOT = new URL('..', import.meta.url).pathname; +const SRC = join(ROOT, 'src'); + +/** Flatten a nested translations object into a Set of dotted leaf keys. */ +function flatten(obj, prefix = '', out = new Set()) { + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object') { + flatten(value, path, out); + } else if (typeof value === 'string') { + out.add(path); + } + } + return out; +} + +/** Load a TS translation module by bundling it to a temp ESM file. */ +async function loadTranslations(file) { + const result = await build({ + entryPoints: [file], + bundle: true, + format: 'esm', + platform: 'node', + write: false, + logLevel: 'silent', + }); + const dir = mkdtempSync(join(tmpdir(), 'i18n-check-')); + const tmp = join(dir, 'mod.mjs'); + writeFileSync(tmp, result.outputFiles[0].text); + try { + const mod = await import(pathToFileURL(tmp).href); + return mod.default; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Recursively list source files under a directory. */ +function walk(dir) { + const files = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry === 'i18n' || entry === 'test') continue; + files.push(...walk(full)); + } else if (['.ts', '.tsx'].includes(extname(entry))) { + files.push(full); + } + } + return files; +} + +/** Collect every `t('some.key')` usage across the source. */ +function collectUsedKeys(files) { + const used = new Map(); // key -> [files] + const re = /(? 200) console.error(` …and ${problems.length - 200} more`); + process.exit(1); + } + + console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used`); +} + +main().catch((err) => { + console.error('i18n check crashed:', err); + process.exit(1); +}); diff --git a/src/components/AddPhotoToSpot.tsx b/src/components/AddPhotoToSpot.tsx index 286bb45..4b5e1e1 100644 --- a/src/components/AddPhotoToSpot.tsx +++ b/src/components/AddPhotoToSpot.tsx @@ -19,6 +19,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { PHOTO_CONTRIBUTION_KIND } from '@/hooks/useSpotContributions'; import { SKATE_SPOT_KIND } from '@/lib/types'; import { LoginArea } from '@/components/auth/LoginArea'; +import { useTranslation } from '@/contexts/language'; interface AddPhotoToSpotProps { spotPubkey: string; @@ -27,6 +28,7 @@ interface AddPhotoToSpotProps { } export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPhotoToSpotProps) { + const { t } = useTranslation(); const { user } = useCurrentUser(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); @@ -51,14 +53,14 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho if (url) { setUploadedImage({ url, mimeType }); toast({ - title: 'Photo uploaded', - description: 'Add a caption and submit to contribute!', + title: t('spotUi.photoUploaded'), + description: t('spotUi.photoUploadedDesc'), }); } } catch (error) { toast({ - title: 'Upload failed', - description: error instanceof Error ? error.message : 'Failed to upload photo', + title: t('spotUi.uploadFailed'), + description: error instanceof Error ? error.message : t('spotUi.failedToUploadPhoto'), variant: 'destructive', }); } @@ -67,8 +69,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho const handleSubmit = async () => { if (!uploadedImage) { toast({ - title: 'No photo', - description: 'Please upload a photo first.', + title: t('spotUi.noPhoto'), + description: t('spotUi.uploadPhotoFirst'), variant: 'destructive', }); return; @@ -93,8 +95,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho }); toast({ - title: 'Photo contributed!', - description: 'Your photo has been added to this spot.', + title: t('spotUi.photoContributed'), + description: t('spotUi.photoContributedDesc'), }); // Reset and close @@ -106,8 +108,8 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho queryClient.invalidateQueries({ queryKey: ['spot-photos', spotPubkey, spotIdentifier] }); } catch (error) { toast({ - title: 'Failed to contribute', - description: error instanceof Error ? error.message : 'Something went wrong', + title: t('spotUi.failedToContribute'), + description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'), variant: 'destructive', }); } @@ -124,23 +126,23 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho - Add Photo to Spot + {t('spotUi.addPhotoToSpot')} - Contribute a photo to help others discover this spot. + {t('spotUi.addPhotoDesc')} {!user ? (
-

Log in to contribute photos

+

{t('spotUi.logInToContributePhotos')}

) : ( @@ -150,7 +152,7 @@ export function AddPhotoToSpot({ spotPubkey, spotIdentifier, spotTitle }: AddPho
Uploaded