diff --git a/CLAUDE.md b/CLAUDE.md index 1b4fe25..5ac66af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,6 +276,55 @@ Every UI element must use the correct token from this table. Do not invent new p - [ ] Sidebars, dropdowns, and popups are readable in both modes - [ ] HomePage and DataSourcesPage adapt correctly +## Internationalization (i18n) — MANDATORY + +All user-visible text in the frontend MUST be translated. The build will fail if any language file is missing keys. Supported languages: English, French, German, Hungarian, Chinese. + +### Architecture + +``` +frontend/src/i18n/ + index.ts # i18next init, language detection, SUPPORTED_LANGUAGES + i18next.d.ts # Module augmentation — makes t() type-safe + server.ts # ts() for server-derived values, re-exports tsDesc() + descriptions.ts # Feature description translations (separate from locale files) + locales/ + en.ts # English (source of truth, as const) + fr.ts / de.ts / hu.ts / zh.ts # Each typed as Translations = DeepStringify +``` + +**Three translation mechanisms:** +1. **`t('key')`** — UI strings (buttons, labels, headings). Type-safe: `t('typo')` is a compile error. +2. **`ts(value)`** — Server-derived values (feature names, group names, enum values, POI categories). Looks up `server.${value}` in the locale file, falls back to English. +3. **`tsDesc(featureName, englishFallback)`** — Feature descriptions. English comes from the server (single source of truth); other languages from `descriptions.ts`. Keyed by feature name, not description text. + +### Adding a new UI string + +1. Add the key to `en.ts` in the appropriate section +2. The build will immediately fail for all other locale files — add translations to each +3. Use `t('section.keyName')` in the component + +### Adding a new language + +1. Create `locales/xx.ts` importing `Translations` from `./en` — TypeScript enforces every key exists +2. Add a `xx` section to `descriptions.ts` for feature descriptions +3. Register in `index.ts`: import, add to `SUPPORTED_LANGUAGES` (with flag emoji) and `resources` + +### Translating server-derived values (feature names, POI categories, etc.) + +When a new feature is added in `features.rs`: +- Its name should be added to the `server` section of **all** locale files (keyed by the English name) +- Its description should be added to `descriptions.ts` for each non-English language +- English descriptions come from the server — do NOT duplicate them in `en.ts` + +### Rules + +- **Every `bg-*`, `text-*` class still needs `dark:` counterpart** (i18n doesn't change the design system) +- **URLs always contain English feature names** — `ts()` only wraps display, never data keys or URL params +- **Never use dynamic key construction with `t()`** — it breaks TypeScript checking. Use `ts()` for runtime lookups or `tDynamic()` from `index.ts` +- **`useTranslatedModes()`** hook provides translated travel mode labels — don't use `MODE_LABELS` for display +- **Format utilities** (`formatRelativeTime`, `formatDuration`, `summarizeParams`) are already i18n-aware — they import `i18n` directly since they're not React components + ## Coding Preferences - **No backwards compatibility, no silent fallbacks**: Never add fallback codepaths for old data formats, legacy URL parameters, or alternate field names. Never silently swallow errors — always error loudly (return an error, panic, or at minimum log). If something is wrong, the code should fail visibly. One canonical name per field, one format per API, one way to do things. Specific patterns: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0477818..b0b734a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,10 +18,12 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.0", "@types/supercluster": "^7.1.3", + "i18next": "^26.0.3", "maplibre-gl": "^4.0.0", "pocketbase": "^0.26.8", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^17.0.2", "react-joyride": "^2.9.3", "react-map-gl": "^7.1.0", "supercluster": "^8.0.1" @@ -1662,6 +1664,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -9007,6 +9018,15 @@ "node": ">=12" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-webpack-plugin": { "version": "5.6.6", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.6.tgz", @@ -9171,6 +9191,37 @@ "node": ">=10.18" } }, + "node_modules/i18next": { + "version": "26.0.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.3.tgz", + "integrity": "sha512-1571kXINxHKY7LksWp8wP+zP0YqHSSpl/OW0Y0owFEf2H3s8gCAffWaZivcz14rMkOvn3R/psiQxVsR9t2Nafg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -11920,6 +11971,33 @@ "is-lite": "^0.8.2" } }, + "node_modules/react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-innertext": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz", @@ -14101,7 +14179,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14300,6 +14378,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -14343,6 +14430,15 @@ "node": ">= 0.8" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index bee4eff..9d04b54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,10 +23,12 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slider": "^1.1.0", "@types/supercluster": "^7.1.3", + "i18next": "^26.0.3", "maplibre-gl": "^4.0.0", "pocketbase": "^0.26.8", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^17.0.2", "react-joyride": "^2.9.3", "react-map-gl": "^7.1.0", "supercluster": "^8.0.1" diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 39847ee..91d5184 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import type { AuthUser } from '../../hooks/useAuth'; import type { SavedSearch } from '../../hooks/useSavedSearches'; import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties'; @@ -13,6 +14,7 @@ import { BookmarkIcon } from '../ui/icons/BookmarkIcon'; import { HouseIcon } from '../ui/icons/HouseIcon'; import { TrashIcon } from '../ui/icons/TrashIcon'; import { CloseIcon } from '../ui/icons/CloseIcon'; +import { useLicense } from '../../hooks/useLicense'; function PageLayout({ children }: { children: React.ReactNode }) { return ( @@ -35,6 +37,7 @@ function DeleteDialog({ onCancel: () => void; onConfirm: () => void; }) { + const { t } = useTranslation(); return (
@@ -57,13 +60,13 @@ function DeleteDialog({ onClick={onCancel} className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700" > - Cancel + {t('common.cancel')}
@@ -72,6 +75,7 @@ function DeleteDialog({ } function NotesInput({ value, onSave }: { value: string; onSave: (notes: string) => void }) { + const { t } = useTranslation(); const [text, setText] = useState(value); const textareaRef = useRef(null); const timerRef = useRef | null>(null); @@ -115,7 +119,7 @@ function NotesInput({ value, onSave }: { value: string; onSave: (notes: string) value={text} onChange={handleChange} onBlur={handleBlur} - placeholder="Jot down your thoughts..." + placeholder={t('savedPage.notesPlaceholder')} rows={1} className="w-full resize-none overflow-hidden rounded border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 px-3 py-1.5 text-sm text-warm-700 dark:text-warm-300 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400" /> @@ -130,17 +134,21 @@ function formatPropertyPrice(data: SavedPropertyData): string | null { return null; } -function formatPropertyDetails(data: SavedPropertyData): string { +function formatPropertyDetails( + data: SavedPropertyData, + t: { (key: 'savedPage.bed'): string; (key: 'savedPage.epc'): string } +): string { const parts: string[] = []; if (data.propertySubType) parts.push(data.propertySubType); else if (data.propertyType) parts.push(data.propertyType); - if (data.bedrooms) parts.push(`${data.bedrooms} bed`); + if (data.bedrooms) parts.push(`${data.bedrooms} ${t('savedPage.bed')}`); if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}m²`); - if (data.energyRating) parts.push(`EPC ${data.energyRating}`); + if (data.energyRating) parts.push(`${t('savedPage.epc')} ${data.energyRating}`); return parts.join(' · '); } function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) { + const { t } = useTranslation(); const [editing, setEditing] = useState(false); const [text, setText] = useState(value); const inputRef = useRef(null); @@ -186,7 +194,7 @@ function EditableName({ value, onSave }: { value: string; onSave: (name: string)

setEditing(true)} className="font-medium text-navy-950 dark:text-warm-100 truncate cursor-pointer hover:text-teal-600 dark:hover:text-teal-400 border-b border-dotted border-transparent hover:border-warm-400 dark:hover:border-warm-500" - title="Click to rename" + title={t('savedPage.clickToRename')} > {value}

@@ -208,6 +216,7 @@ function SavedSearchesTab({ onUpdateName: (id: string, name: string) => void; onOpen: (params: string) => void; }) { + const { t } = useTranslation(); const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [copiedId, setCopiedId] = useState(null); const [sharingId, setSharingId] = useState(null); @@ -254,10 +263,10 @@ function SavedSearchesTab({

- No saved searches yet + {t('savedPage.noSavedSearches')}

- Save your filters and map view so you can pick up exactly where you left off. + {t('savedPage.noSavedSearchesDesc')}

); @@ -309,7 +318,7 @@ function SavedSearchesTab({ onClick={() => onOpen(search.params)} className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700" > - Open + {t('common.open')}