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/finder/constants.py b/finder/constants.py index 9aee415..604646a 100644 --- a/finder/constants.py +++ b/finder/constants.py @@ -117,6 +117,13 @@ PROPERTY_TYPE_MAP = { "House Boat": "Other", "Barn": "Other", "Serviced Apartments": "Flats/Maisonettes", + # Space-separated variants (from home.co.uk underscore/hyphen normalization) + "Semi Detached": "Semi-Detached", + "Semi Detached Bungalow": "Semi-Detached", + "End Of Terrace": "Terraced", + "End Terrace": "Terraced", + "Block Of Apartments": "Flats/Maisonettes", + "Farm / Barn": "Other", # Lowercase variants (from home.co.uk / Rightmove APIs) "house": "Detached", "bungalow": "Other", diff --git a/finder/homecouk.py b/finder/homecouk.py index bace56d..51e3940 100644 --- a/finder/homecouk.py +++ b/finder/homecouk.py @@ -26,7 +26,7 @@ from metrics import ( homecouk_requests_total, ) from spatial import PostcodeSpatialIndex -from transform import validate_floor_area +from transform import normalize_postcode, normalize_sub_type, validate_floor_area log = logging.getLogger("homecouk") @@ -359,11 +359,11 @@ def transform_property( "Number of bedrooms & living rooms": bedrooms + bathrooms, "lon": lng, "lat": lat, - "Postcode": postcode, + "Postcode": normalize_postcode(postcode), "Address per Property Register": address, "Leasehold/Freehold": parse_tenure(prop), "Property type": map_property_type(listing_type), - "Property sub-type": listing_type.title() if listing_type else "Unknown", + "Property sub-type": normalize_sub_type(listing_type), "price": int(price), "price_frequency": "" if channel == "BUY" else "monthly", "Price qualifier": price_qualifier, diff --git a/finder/openrent.py b/finder/openrent.py index f08a3cd..8be4779 100644 --- a/finder/openrent.py +++ b/finder/openrent.py @@ -46,7 +46,7 @@ from metrics import ( openrent_requests_total, ) from spatial import PostcodeSpatialIndex -from transform import validate_floor_area +from transform import normalize_postcode, normalize_sub_type, validate_floor_area log = logging.getLogger("openrent") @@ -781,14 +781,14 @@ def transform_property( "Number of bedrooms & living rooms": bedrooms, "lon": lng, "lat": lat, - "Postcode": postcode, + "Postcode": normalize_postcode(postcode), "Address per Property Register": address, # OpenRent is a rental-only platform — tenure (Freehold/Leasehold) is a # property ownership concept that doesn't apply to rental listings. The # landlord's tenure is not shown on OpenRent listing pages. "Leasehold/Freehold": None, "Property type": map_property_type(property_type), - "Property sub-type": property_type or "Unknown", + "Property sub-type": normalize_sub_type(property_type), "price": int(price), "price_frequency": frequency, "Price qualifier": "", diff --git a/finder/scraper.py b/finder/scraper.py index 78db671..a06c354 100644 --- a/finder/scraper.py +++ b/finder/scraper.py @@ -338,7 +338,25 @@ def _load_checkpoint( if rpath.exists(): try: with open(rpath) as f: - loaded_results[source][channel.upper()] = json.load(f) + raw = json.load(f) + # Deduplicate by ID — concurrent workers (e.g. hk_worker's + # ThreadPoolExecutor) can cause in-flight outcodes to have + # results saved before their progress index is recorded. + # On resume those outcodes get re-scraped, duplicating results. + seen_ids: set[str] = set() + deduped: list[dict] = [] + for p in raw: + pid = p.get("id") + if pid not in seen_ids: + seen_ids.add(pid) + deduped.append(p) + if len(deduped) < len(raw): + log.info( + "Checkpoint %s/%s: deduped %d → %d (removed %d dupes)", + source, channel, len(raw), len(deduped), + len(raw) - len(deduped), + ) + loaded_results[source][channel.upper()] = deduped except Exception: log.warning( "Checkpoint results for %s/%s corrupt, restarting %s", diff --git a/finder/storage.py b/finder/storage.py index 487ee34..30ee9d1 100644 --- a/finder/storage.py +++ b/finder/storage.py @@ -5,7 +5,7 @@ from pathlib import Path import polars as pl from constants import MAX_BEDROOMS, MAX_RENT_MONTHLY, MIN_RENT_MONTHLY -from transform import map_property_type, normalize_price +from transform import map_property_type, normalize_postcode, normalize_price log = logging.getLogger("rightmove") @@ -132,7 +132,7 @@ def write_parquet(properties: list[dict], path: Path, channel: str) -> None: ], "lon": [p["lon"] for p in properties], "lat": [p["lat"] for p in properties], - "Postcode": [p["Postcode"] for p in properties], + "Postcode": [normalize_postcode(p["Postcode"]) for p in properties], "Address per Property Register": [ p["Address per Property Register"] for p in properties ], diff --git a/finder/transform.py b/finder/transform.py index 301e0e6..94ec195 100644 --- a/finder/transform.py +++ b/finder/transform.py @@ -7,21 +7,24 @@ from spatial import PostcodeSpatialIndex log = logging.getLogger("rightmove") -# Maximum plausible floor area for a residential property listing (sqm). -# ~21,500 sq ft — covers even the largest UK mansions. +# Floor area bounds (sqm). Values outside this range are almost certainly +# data errors: sub-5 sqm catches garbled extractions (e.g., 0.1 sqm for a +# detached house), and >2000 sqm (~21,500 sq ft) exceeds even the largest +# UK mansions. +MIN_FLOOR_AREA_SQM = 5.0 MAX_FLOOR_AREA_SQM = 2000.0 def validate_floor_area(sqm: float | None) -> float | None: """Validate a floor area value. Returns None for nonsensical values. - Rejects zero/negative values and anything above MAX_FLOOR_AREA_SQM, + Rejects values below MIN_FLOOR_AREA_SQM and above MAX_FLOOR_AREA_SQM, which catches parsing errors where prices or other large numbers are mistakenly extracted as floor area from free-text descriptions or DOM text. """ if sqm is None: return None - if sqm <= 0 or sqm > MAX_FLOOR_AREA_SQM: + if sqm < MIN_FLOOR_AREA_SQM or sqm > MAX_FLOOR_AREA_SQM: return None return sqm @@ -42,6 +45,25 @@ def parse_display_size(display_size: str | None) -> float | None: return None +def normalize_sub_type(sub_type: str | None) -> str: + """Normalize property sub-type for consistent storage. + + Fixes delimiter inconsistencies (underscores/hyphens → spaces) from + home.co.uk and truncates Zoopla description fragments that were + accidentally captured as sub-types. + """ + if not sub_type: + return "Unknown" + cleaned = sub_type.replace("_", " ").strip() + # Description fragments captured as sub-types are much longer than any + # real property type name (longest canonical is ~25 chars) + if len(cleaned) > 40: + return "Unknown" + # Collapse multiple spaces + cleaned = re.sub(r"\s+", " ", cleaned) + return cleaned.title() + + def map_property_type(sub_type: str | None) -> str: """Map propertySubType to canonical type.""" if not sub_type: @@ -51,6 +73,15 @@ def map_property_type(sub_type: str | None) -> str: return canonical # Try title-case variant (e.g., "country house" → "Country House") canonical = PROPERTY_TYPE_MAP.get(sub_type.title()) + if canonical: + return canonical + # Try lowercase variant (e.g., "Townhouse" → "townhouse") + canonical = PROPERTY_TYPE_MAP.get(sub_type.lower()) + if canonical: + return canonical + # Normalize delimiters (underscores/hyphens → spaces) and try again + normalized = re.sub(r"[-_]+", " ", sub_type).strip().title() + canonical = PROPERTY_TYPE_MAP.get(normalized) if canonical: return canonical # Keyword fallback for compound types not in the map @@ -103,12 +134,13 @@ def fix_coords(lat: float, lng: float) -> tuple[float, float]: def normalize_postcode(postcode: str) -> str: - """Ensure UK postcode has a space before the 3-char incode. - E.g., 'SW1A1AA' → 'SW1A 1AA', 'E1 4AB' unchanged.""" - postcode = postcode.strip().upper() - if " " in postcode or len(postcode) < 5: - return postcode - return postcode[:-3] + " " + postcode[-3:] + """Ensure UK postcode has exactly one space before the 3-char incode. + E.g., 'SW1A1AA' → 'SW1A 1AA', 'N4 2HA' → 'N4 2HA', 'E1 4AB' unchanged.""" + # Strip all whitespace then re-insert the single canonical space + compact = re.sub(r"\s+", "", postcode).upper() + if len(compact) < 5: + return compact + return compact[:-3] + " " + compact[-3:] def normalize_price(amount: int, frequency: str) -> int: @@ -187,7 +219,7 @@ def transform_property( "Address per Property Register": prop.get("displayAddress", ""), "Leasehold/Freehold": extract_tenure(prop.get("tenure")), "Property type": map_property_type(sub_type), - "Property sub-type": sub_type or "Unknown", + "Property sub-type": normalize_sub_type(sub_type), "price": price, "price_frequency": frequency, "Price qualifier": price_qualifier, diff --git a/finder/zoopla.py b/finder/zoopla.py index f610704..60c2c5e 100644 --- a/finder/zoopla.py +++ b/finder/zoopla.py @@ -29,7 +29,7 @@ import time from constants import DELAY_BETWEEN_PAGES, MAX_BEDROOMS, PROPERTY_TYPE_MAP, ZOOPLA_BASE from metrics import zoopla_errors_total, zoopla_pages_scraped, zoopla_properties_scraped from spatial import PostcodeSpatialIndex -from transform import validate_floor_area +from transform import normalize_sub_type, validate_floor_area log = logging.getLogger("zoopla") @@ -666,16 +666,25 @@ def _map_property_type(raw_type: str | None) -> str: return canonical # Title-case match (handles regex-extracted lowercase like "town house" → "Town House") canonical = PROPERTY_TYPE_MAP.get(raw_type.title()) + if canonical: + return canonical + # Lowercase match (e.g., "Townhouse" → "townhouse") + canonical = PROPERTY_TYPE_MAP.get(raw_type.lower()) + if canonical: + return canonical + # Normalize delimiters (underscores/hyphens → spaces) and try again + normalized = re.sub(r"[-_]+", " ", raw_type).strip().title() + canonical = PROPERTY_TYPE_MAP.get(normalized) if canonical: return canonical # Keyword fallback lower = raw_type.lower() if "flat" in lower or "apartment" in lower or "maisonette" in lower or "studio" in lower or "penthouse" in lower: return "Flats/Maisonettes" - if "detached" in lower and "semi" not in lower: - return "Detached" - if "semi" in lower: + if "semi" in lower and "detach" in lower: return "Semi-Detached" + if "detach" in lower: + return "Detached" if "terrace" in lower or "mews" in lower: return "Terraced" if "house" in lower: @@ -792,7 +801,7 @@ def transform_property( "Address per Property Register": address, "Leasehold/Freehold": raw.get("tenure") or None, "Property type": _map_property_type(raw.get("property_type")), - "Property sub-type": raw.get("property_type") or "", + "Property sub-type": normalize_sub_type(raw.get("property_type")), "price": int(price), "price_frequency": frequency, "Price qualifier": "", 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/App.tsx b/frontend/src/App.tsx index 4c05ed9..0560e9d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -139,8 +139,9 @@ export default function App() { window.history.replaceState({}, '', newUrl); trackEvent('Purchase'); setShowLicenseSuccess(true); - refreshAuth(); } + // Always refresh auth on startup to pick up server-side subscription changes + refreshAuth().catch(() => {}); }, []); // eslint-disable-line react-hooks/exhaustive-deps const savedSearches = useSavedSearches(user?.id ?? null); @@ -419,6 +420,8 @@ export default function App() { isPropertySaved={user ? savedProperties.isPropertySaved : undefined} getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined} deferTutorial={showLicenseSuccess} + onSaveSearch={user ? savedSearches.saveSearch : undefined} + savingSearch={savedSearches.saving} /> )} {showAuthModal && ( 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')}