Compare commits
7 commits
89a85e9a0c
...
b94cf17d75
| Author | SHA1 | Date | |
|---|---|---|---|
| b94cf17d75 | |||
| 3853b5dce7 | |||
| 23d128ff63 | |||
| a26494b028 | |||
| 96402228e3 | |||
| a7aaf5effa | |||
| c14d28f430 |
89 changed files with 6901 additions and 1148 deletions
49
CLAUDE.md
49
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<typeof en>
|
||||
```
|
||||
|
||||
**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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
98
frontend/package-lock.json
generated
98
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onCancel}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
|
|
@ -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')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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<HTMLTextAreaElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>(null);
|
||||
|
|
@ -186,7 +194,7 @@ function EditableName({ value, onSave }: { value: string; onSave: (name: string)
|
|||
<h3
|
||||
onClick={() => 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}
|
||||
</h3>
|
||||
|
|
@ -208,6 +216,7 @@ function SavedSearchesTab({
|
|||
onUpdateName: (id: string, name: string) => void;
|
||||
onOpen: (params: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
|
|
@ -254,10 +263,10 @@ function SavedSearchesTab({
|
|||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<BookmarkIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
|
||||
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
|
||||
No saved searches yet
|
||||
{t('savedPage.noSavedSearches')}
|
||||
</p>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-500">
|
||||
Save your filters and map view so you can pick up exactly where you left off.
|
||||
{t('savedPage.noSavedSearchesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleShare(search.params, search.id)}
|
||||
|
|
@ -319,9 +328,9 @@ function SavedSearchesTab({
|
|||
{sharingId === search.id ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : copiedId === search.id ? (
|
||||
'Copied!'
|
||||
t('common.copied')
|
||||
) : (
|
||||
'Share'
|
||||
t('common.share')
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -339,8 +348,8 @@ function SavedSearchesTab({
|
|||
|
||||
{deleteConfirmId && (
|
||||
<DeleteDialog
|
||||
title="Delete search"
|
||||
message="Are you sure you want to delete this saved search? This cannot be undone."
|
||||
title={t('savedPage.deleteSearch')}
|
||||
message={t('savedPage.deleteSearchConfirm')}
|
||||
onCancel={() => setDeleteConfirmId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
|
|
@ -362,6 +371,7 @@ function SavedPropertiesTab({
|
|||
onUpdateNotes: (id: string, notes: string) => void;
|
||||
onOpen: (postcode: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
|
|
@ -383,10 +393,10 @@ function SavedPropertiesTab({
|
|||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<HouseIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
|
||||
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
|
||||
No saved properties yet
|
||||
{t('savedPage.noSavedProperties')}
|
||||
</p>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-500">
|
||||
Bookmark properties as you explore and build your shortlist without losing track.
|
||||
{t('savedPage.noSavedPropertiesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -397,7 +407,7 @@ function SavedPropertiesTab({
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{properties.map((prop) => {
|
||||
const price = formatPropertyPrice(prop.data);
|
||||
const details = formatPropertyDetails(prop.data);
|
||||
const details = formatPropertyDetails(prop.data, t);
|
||||
return (
|
||||
<div
|
||||
key={prop.id}
|
||||
|
|
@ -429,7 +439,7 @@ function SavedPropertiesTab({
|
|||
onClick={() => onOpen(prop.postcode)}
|
||||
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
|
||||
>
|
||||
Open postcode
|
||||
{t('savedPage.openPostcode')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(prop.id)}
|
||||
|
|
@ -446,7 +456,7 @@ function SavedPropertiesTab({
|
|||
rel="noopener noreferrer"
|
||||
className="mt-2 block text-center px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
View listing →
|
||||
{t('savedPage.viewListing')} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -457,8 +467,8 @@ function SavedPropertiesTab({
|
|||
|
||||
{deleteConfirmId && (
|
||||
<DeleteDialog
|
||||
title="Delete property"
|
||||
message="Are you sure you want to delete this saved property? This cannot be undone."
|
||||
title={t('savedPage.deleteProperty')}
|
||||
message={t('savedPage.deletePropertyConfirm')}
|
||||
onCancel={() => setDeleteConfirmId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
|
|
@ -492,6 +502,7 @@ export function SavedPage({
|
|||
onUpdatePropertyNotes: (id: string, notes: string) => void;
|
||||
onOpenProperty: (postcode: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>(
|
||||
window.location.hash === '#properties' ? 'properties' : 'searches'
|
||||
);
|
||||
|
|
@ -507,7 +518,7 @@ export function SavedPage({
|
|||
<PageLayout>
|
||||
<div className="flex border-b border-warm-200 dark:border-warm-700 mb-6">
|
||||
<button className={tabClass('searches')} onClick={() => setActiveTab('searches')}>
|
||||
Searches
|
||||
{t('savedPage.searches')}
|
||||
{searches.length > 0 && (
|
||||
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
|
||||
{searches.length}
|
||||
|
|
@ -515,7 +526,7 @@ export function SavedPage({
|
|||
)}
|
||||
</button>
|
||||
<button className={tabClass('properties')} onClick={() => setActiveTab('properties')}>
|
||||
Properties
|
||||
{t('common.properties')}
|
||||
{savedProperties.length > 0 && (
|
||||
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
|
||||
{savedProperties.length}
|
||||
|
|
@ -563,6 +574,7 @@ function InviteTable({
|
|||
loading: boolean;
|
||||
title: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = (url: string, code: string) => {
|
||||
|
|
@ -583,18 +595,18 @@ function InviteTable({
|
|||
</div>
|
||||
) : invites.length === 0 ? (
|
||||
<p className="px-5 py-6 text-sm text-warm-500 dark:text-warm-400 text-center">
|
||||
No invites generated yet
|
||||
{t('invitesPage.noInvitesYet')}
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full table-fixed text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Link</th>
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">{t('invitesPage.link')}</th>
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">
|
||||
Status
|
||||
{t('invitesPage.status')}
|
||||
</th>
|
||||
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">
|
||||
Created
|
||||
{t('invitesPage.created')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -612,7 +624,7 @@ function InviteTable({
|
|||
<button
|
||||
onClick={() => handleCopy(inv.url, inv.code)}
|
||||
className="shrink-0 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Copy invite link"
|
||||
title={t('invitesPage.copyInviteLink')}
|
||||
>
|
||||
{copiedCode === inv.code ? (
|
||||
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||
|
|
@ -630,7 +642,7 @@ function InviteTable({
|
|||
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{inv.used ? 'Redeemed' : 'Pending'}
|
||||
{inv.used ? t('invitesPage.redeemed') : t('invitesPage.pending')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-warm-500 dark:text-warm-400 text-xs">
|
||||
|
|
@ -646,6 +658,7 @@ function InviteTable({
|
|||
}
|
||||
|
||||
export function InvitesPage({ user }: { user: AuthUser }) {
|
||||
const { t } = useTranslation();
|
||||
const [creatingInvite, setCreatingInvite] = useState<Record<string, boolean>>({});
|
||||
const [inviteUrl, setInviteUrl] = useState<Record<string, string>>({});
|
||||
const [inviteError, setInviteError] = useState<Record<string, string>>({});
|
||||
|
|
@ -722,7 +735,7 @@ export function InvitesPage({ user }: { user: AuthUser }) {
|
|||
<div className="max-w-lg mx-auto">
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Invite links are available for licensed users.
|
||||
{t('invitesPage.inviteLinksLicensed')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -741,7 +754,7 @@ export function InvitesPage({ user }: { user: AuthUser }) {
|
|||
{(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (
|
||||
<div key={type} className="px-5 py-4">
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||
{type === 'admin' ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
|
||||
{type === 'admin' ? t('invitesPage.inviteAdminLabel') : t('invitesPage.inviteReferralLabel')}
|
||||
</p>
|
||||
{inviteUrl[type] ? (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -760,7 +773,7 @@ export function InvitesPage({ user }: { user: AuthUser }) {
|
|||
) : (
|
||||
<ClipboardIcon className="w-4 h-4" />
|
||||
)}
|
||||
{inviteCopied[type] ? 'Copied' : 'Copy'}
|
||||
{inviteCopied[type] ? t('common.copied') : t('common.copy')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -770,7 +783,7 @@ export function InvitesPage({ user }: { user: AuthUser }) {
|
|||
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
|
||||
>
|
||||
{creatingInvite[type] && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{type === 'admin' ? 'Generate free invite link' : 'Generate referral link'}
|
||||
{type === 'admin' ? t('invitesPage.generateFreeInvite') : t('invitesPage.generateReferralLink')}
|
||||
</button>
|
||||
)}
|
||||
{inviteError[type] && (
|
||||
|
|
@ -786,12 +799,12 @@ export function InvitesPage({ user }: { user: AuthUser }) {
|
|||
<InviteTable
|
||||
invites={adminInvites}
|
||||
loading={inviteHistoryLoading}
|
||||
title="Admin invites (100% off)"
|
||||
title={t('invitesPage.adminInvitesTitle')}
|
||||
/>
|
||||
<InviteTable
|
||||
invites={referralInvites}
|
||||
loading={inviteHistoryLoading}
|
||||
title="Referral invites (30% off)"
|
||||
title={t('invitesPage.referralInvitesTitle')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -799,7 +812,7 @@ export function InvitesPage({ user }: { user: AuthUser }) {
|
|||
<InviteTable
|
||||
invites={referralInvites}
|
||||
loading={inviteHistoryLoading}
|
||||
title="Your invite links"
|
||||
title={t('invitesPage.yourInviteLinks')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -814,8 +827,11 @@ export default function AccountPage({
|
|||
user: AuthUser;
|
||||
onRefreshAuth: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [newsletterSaving, setNewsletterSaving] = useState(false);
|
||||
const [newsletterError, setNewsletterError] = useState<string | null>(null);
|
||||
const { startCheckout, checkingOut, error: checkoutError } = useLicense();
|
||||
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
|
||||
|
||||
const badgeColor =
|
||||
user.subscription === 'licensed'
|
||||
|
|
@ -829,7 +845,7 @@ export default function AccountPage({
|
|||
{/* Email */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400">{t('accountPage.emailLabel')}</p>
|
||||
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -837,13 +853,27 @@ export default function AccountPage({
|
|||
{/* Subscription */}
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400">{t('accountPage.subscriptionLabel')}</p>
|
||||
<span
|
||||
className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}
|
||||
>
|
||||
{user.subscription === 'licensed' ? 'Full Access' : 'Inner London'}
|
||||
{user.subscription === 'licensed' ? t('userMenu.fullAccess') : t('userMenu.demo')}
|
||||
</span>
|
||||
</div>
|
||||
{!isLicensed && (
|
||||
<div className="flex flex-col items-end">
|
||||
<button
|
||||
onClick={() => startCheckout()}
|
||||
disabled={checkingOut}
|
||||
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{checkingOut ? t('accountPage.redirecting') : t('accountPage.upgrade')}
|
||||
</button>
|
||||
{checkoutError && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">{checkoutError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Newsletter */}
|
||||
|
|
@ -877,7 +907,7 @@ export default function AccountPage({
|
|||
className="w-4 h-4 accent-teal-600 rounded"
|
||||
/>
|
||||
<span className="text-navy-950 dark:text-warm-100 text-sm">
|
||||
Receive newsletter emails
|
||||
{t('accountPage.receiveNewsletter')}
|
||||
</span>
|
||||
{newsletterSaving && <SpinnerIcon className="w-4 h-4 animate-spin text-warm-400" />}
|
||||
</label>
|
||||
|
|
@ -889,7 +919,7 @@ export default function AccountPage({
|
|||
|
||||
{/* Support */}
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
||||
<a
|
||||
href="mailto:support@perfect-postcode.co.uk"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
||||
|
|
@ -897,7 +927,7 @@ export default function AccountPage({
|
|||
support@perfect-postcode.co.uk
|
||||
</a>
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
||||
We typically respond within 24 hours.
|
||||
{t('accountPage.responseTime')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFadeInRef } from '../../hooks/useFadeIn';
|
||||
import HexCanvas from './HexCanvas';
|
||||
import BottomIllustration from './BottomIllustration';
|
||||
|
|
@ -17,6 +18,7 @@ export default function HomePage({
|
|||
theme?: 'light' | 'dark';
|
||||
hidePricing?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [statsActive, setStatsActive] = useState(false);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setStatsActive(true), 300);
|
||||
|
|
@ -56,7 +58,7 @@ export default function HomePage({
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden bg-warm-50 dark:bg-navy-950 relative">
|
||||
<div className="relative" style={{ zIndex: 1 }}>
|
||||
{/* Hero */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
|
||||
|
|
@ -66,18 +68,17 @@ export default function HomePage({
|
|||
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
|
||||
Maximum <span className="text-teal-400">Value</span>.
|
||||
{t('home.heroTitle1')} <span className="text-teal-400">{t('home.heroTitle2')}</span>.
|
||||
<br />
|
||||
Minimum Compromise.
|
||||
{t('home.heroTitle3')}
|
||||
</h1>
|
||||
<p className="text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
|
||||
House hunting? Make your biggest investment your smartest move.
|
||||
{t('home.heroSubtitle')}
|
||||
</p>
|
||||
<p className="text-lg text-warm-400 mb-8 max-w-xl">
|
||||
So many options - choosing the right one can feel overwhelming. Our interactive map
|
||||
makes it simple: select your must-haves and instantly see the areas that fit.
|
||||
{t('home.heroDescription')}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
||||
|
|
@ -85,7 +86,7 @@ export default function HomePage({
|
|||
}}
|
||||
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Explore the map
|
||||
{t('home.exploreTheMap')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -105,34 +106,34 @@ export default function HomePage({
|
|||
let startTime: number;
|
||||
const step = (time: number) => {
|
||||
if (!startTime) startTime = time;
|
||||
const t = Math.min((time - startTime) / duration, 1);
|
||||
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
const p = Math.min((time - startTime) / duration, 1);
|
||||
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
|
||||
scroller.scrollTop = start + distance * ease;
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
if (p < 1) requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}}
|
||||
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
|
||||
>
|
||||
See the difference
|
||||
{t('home.seeTheDifference')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-12 pt-3 border-t border-white/10">
|
||||
<div className="flex flex-wrap gap-x-12 gap-y-4 pt-3 border-t border-white/10">
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||
<TickerValue text="13M" active={statsActive} />
|
||||
</div>
|
||||
<div className="text-sm text-warm-400">properties</div>
|
||||
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||
<TickerValue text="56" active={statsActive} />
|
||||
</div>
|
||||
<div className="text-sm text-warm-400">filters</div>
|
||||
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">Every</div>
|
||||
<div className="text-sm text-warm-400">postcode in England</div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">{t('home.statEvery')}</div>
|
||||
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -143,17 +144,14 @@ export default function HomePage({
|
|||
{/* Our philosophy */}
|
||||
<div className="px-6 md:px-12 lg:px-20 pt-20 pb-4">
|
||||
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6">
|
||||
Our philosophy
|
||||
{t('home.ourPhilosophy')}
|
||||
</h2>
|
||||
<div className="space-y-4 text-lg md:text-xl leading-relaxed text-warm-700 dark:text-warm-300">
|
||||
<p>
|
||||
On Rightmove, you pick an area first, then hope it's good. You end up
|
||||
cross-referencing crime stats, school reports, and broadband checkers across a dozen
|
||||
tabs, one postcode at a time.
|
||||
{t('home.philosophyP1')}
|
||||
</p>
|
||||
<p>
|
||||
We flip that. Tell us what you need (budget, commute, schools, safety) and we show you
|
||||
every area in England that qualifies. No guesswork. No wasted viewings.
|
||||
{t('home.philosophyP2')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -165,10 +163,15 @@ export default function HomePage({
|
|||
{/* Left: How to use it */}
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
|
||||
How to use it
|
||||
{t('home.howToUseIt')}
|
||||
</h2>
|
||||
<div className="space-y-8">
|
||||
{HOW_STEPS.map((step, i) => (
|
||||
{[
|
||||
{ title: t('home.howStep1Title'), desc: t('home.howStep1Desc') },
|
||||
{ title: t('home.howStep2Title'), desc: t('home.howStep2Desc') },
|
||||
{ title: t('home.howStep3Title'), desc: t('home.howStep3Desc') },
|
||||
{ title: t('home.howStep4Title'), desc: t('home.howStep4Desc') },
|
||||
].map((step, i) => (
|
||||
<div key={i} className="flex gap-5">
|
||||
<div className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-lg">
|
||||
{i + 1}
|
||||
|
|
@ -178,7 +181,7 @@ export default function HomePage({
|
|||
{step.title}
|
||||
</h3>
|
||||
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
|
||||
{step.description}
|
||||
{step.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -188,9 +191,9 @@ export default function HomePage({
|
|||
{/* Right: Comparison table */}
|
||||
<div id="comparison">
|
||||
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
|
||||
Others vs{' '}
|
||||
{t('home.othersVs')}{' '}
|
||||
<span className="inline-flex items-baseline gap-3 whitespace-nowrap">
|
||||
Perfect Postcode{' '}
|
||||
{t('header.appName')}{' '}
|
||||
<LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
|
||||
</span>
|
||||
</h2>
|
||||
|
|
@ -200,25 +203,30 @@ export default function HomePage({
|
|||
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
|
||||
<th className="px-2 md:px-5 py-3 md:py-4 text-xs md:text-sm font-bold text-navy-950 dark:text-warm-100" />
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
Listing portals
|
||||
{t('home.listingPortals')}
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
{'\u201CCheck my postcode\u201D'}
|
||||
{t('home.checkMyPostcode')}
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
|
||||
Area guides
|
||||
{t('home.areaGuides')}
|
||||
</th>
|
||||
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-extrabold text-navy-950 dark:text-warm-100 text-center bg-teal-50 dark:bg-teal-900/30">
|
||||
Perfect Postcode
|
||||
{t('header.appName')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{FEATURE_ROWS.map((row, i) => (
|
||||
{[
|
||||
{ feature: t('home.compSearchWithout'), subtitle: t('home.compSearchWithoutSub'), listings: false, postcode: false, guides: false },
|
||||
{ feature: t('home.compAreaData'), subtitle: t('home.compAreaDataSub'), listings: false, postcode: true, guides: true },
|
||||
{ feature: t('home.compPropertyData'), subtitle: t('home.compPropertyDataSub'), listings: true, postcode: false, guides: false },
|
||||
{ feature: t('home.compFilters'), subtitle: t('home.compFiltersSub'), listings: false, postcode: false, guides: false },
|
||||
].map((row, i, arr) => (
|
||||
<tr
|
||||
key={row.feature}
|
||||
key={i}
|
||||
className={
|
||||
i < FEATURE_ROWS.length - 1
|
||||
i < arr.length - 1
|
||||
? 'border-b border-warm-100 dark:border-warm-800'
|
||||
: ''
|
||||
}
|
||||
|
|
@ -256,10 +264,10 @@ export default function HomePage({
|
|||
<div className="max-w-4xl mx-auto px-6 pt-20 pb-12">
|
||||
<div ref={ctaRef} className="fade-in-section text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
|
||||
Make your biggest investment your smartest move.
|
||||
{t('home.ctaTitle')}
|
||||
</h2>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-8 max-w-xl mx-auto leading-relaxed">
|
||||
This deserves proper tools behind it, don't leave it to luck.
|
||||
{t('home.ctaDescription')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -268,7 +276,7 @@ export default function HomePage({
|
|||
}}
|
||||
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Explore the map
|
||||
{t('home.exploreTheMap')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -280,54 +288,3 @@ export default function HomePage({
|
|||
);
|
||||
}
|
||||
|
||||
const FEATURE_ROWS = [
|
||||
// listings postcode guides
|
||||
{
|
||||
feature: 'Search without choosing an area first',
|
||||
subtitle: '(start with needs, not a location)',
|
||||
listings: false,
|
||||
postcode: false,
|
||||
guides: false,
|
||||
},
|
||||
{
|
||||
feature: 'Area data',
|
||||
subtitle: '(crime, schools, noise, broadband)',
|
||||
listings: false,
|
||||
postcode: true,
|
||||
guides: true,
|
||||
},
|
||||
{
|
||||
feature: 'Property-specific data',
|
||||
subtitle: '(price, EPC, floor area)',
|
||||
listings: true,
|
||||
postcode: false,
|
||||
guides: false,
|
||||
},
|
||||
{
|
||||
feature: '56 combinable filters in one place',
|
||||
subtitle: '(all insights, one interactive map)',
|
||||
listings: false,
|
||||
postcode: false,
|
||||
guides: false,
|
||||
},
|
||||
];
|
||||
|
||||
const HOW_STEPS = [
|
||||
{
|
||||
title: 'Set your must-haves',
|
||||
description: 'Budget, commute, schools \u2014 the map shows only what qualifies.',
|
||||
},
|
||||
{
|
||||
title: 'Explore areas and discover hidden gems',
|
||||
description: 'Zoom in, dig into details and nice to haves.',
|
||||
},
|
||||
{
|
||||
title: 'Drill into postcodes',
|
||||
description: 'See individual properties, sale prices, floor area, and compare.',
|
||||
},
|
||||
{
|
||||
title: 'Shortlist with confidence',
|
||||
description:
|
||||
'Every area on your list meets your actual criteria \u2014 not just what was listed that week.',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
|
|
@ -85,6 +86,7 @@ export default function InvitePage({
|
|||
onRegisterClick,
|
||||
onLicenseGranted,
|
||||
}: InvitePageProps) {
|
||||
const { t } = useTranslation();
|
||||
const [invite, setInvite] = useState<InviteInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [redeeming, setRedeeming] = useState(false);
|
||||
|
|
@ -121,7 +123,7 @@ export default function InvitePage({
|
|||
if (!cancelled) setPricePence(pricing.current_price_pence);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError('Failed to validate invite link');
|
||||
if (!cancelled) setError(t('invitePage.failedToValidate'));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
|
|
@ -175,20 +177,20 @@ export default function InvitePage({
|
|||
<h2 className="text-7xl leading-tight font-bold text-white mb-3">
|
||||
{isValid
|
||||
? isAdminInvite
|
||||
? 'You\u2019re invited!'
|
||||
: 'Special offer!'
|
||||
: 'Perfect Postcode'}
|
||||
? t('invitePage.youreInvited')
|
||||
: t('invitePage.specialOffer')
|
||||
: t('header.appName')}
|
||||
</h2>
|
||||
<p className="text-warm-300 text-3xl leading-snug">
|
||||
{isValid && invite.invited_by
|
||||
? isAdminInvite
|
||||
? `${invite.invited_by} has invited you to get free lifetime access.`
|
||||
: `${invite.invited_by} has shared a 30% discount on lifetime access.`
|
||||
? t('invitePage.invitedByFree', { name: invite.invited_by })
|
||||
: t('invitePage.invitedByDiscount', { name: invite.invited_by })
|
||||
: isValid
|
||||
? isAdminInvite
|
||||
? 'You have been invited to get free lifetime access.'
|
||||
: 'A friend has shared a 30% discount on lifetime access.'
|
||||
: 'Explore every neighbourhood in England'}
|
||||
? t('invitePage.genericFreeInvite')
|
||||
: t('invitePage.genericDiscount')
|
||||
: t('invitePage.exploreEvery')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-16 py-8 text-center">
|
||||
|
|
@ -200,11 +202,11 @@ export default function InvitePage({
|
|||
<span className="text-[96px] leading-none font-extrabold text-teal-600 dark:text-teal-400">
|
||||
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
|
||||
</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">/once</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-2 text-3xl">{t('upgrade.once')}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-warm-600 dark:text-warm-400 text-3xl">
|
||||
Property prices, energy ratings, crime stats, school ratings and more
|
||||
{t('invitePage.propertyInfo')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -226,7 +228,7 @@ export default function InvitePage({
|
|||
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
|
||||
<HexCanvas isDark={isDark} />
|
||||
<div className="text-center relative z-10">
|
||||
<p className="text-lg font-medium text-white mb-2">Invalid invite</p>
|
||||
<p className="text-lg font-medium text-white mb-2">{t('invitePage.invalidInvite')}</p>
|
||||
<p className="text-warm-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -239,12 +241,12 @@ export default function InvitePage({
|
|||
<HexCanvas isDark={isDark} />
|
||||
<div className="text-center max-w-sm mx-4 relative z-10">
|
||||
<p className="text-lg font-medium text-white mb-2">
|
||||
{invite?.used ? 'Invite already used' : 'Invalid invite link'}
|
||||
{invite?.used ? t('invitePage.inviteAlreadyUsed') : t('invitePage.invalidInviteLink')}
|
||||
</p>
|
||||
<p className="text-warm-400">
|
||||
{invite?.used
|
||||
? 'This invite link has already been redeemed.'
|
||||
: 'This invite link is invalid or has expired.'}
|
||||
? t('invitePage.inviteAlreadyUsedDesc')
|
||||
: t('invitePage.invalidInviteLinkDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -260,8 +262,8 @@ export default function InvitePage({
|
|||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-900/30 flex items-center justify-center">
|
||||
<CheckIcon className="w-8 h-8 text-teal-400" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-white mb-2">License activated!</p>
|
||||
<p className="text-warm-400">You now have full access to Perfect Postcode.</p>
|
||||
<p className="text-lg font-medium text-white mb-2">{t('invitePage.licenseActivated')}</p>
|
||||
<p className="text-warm-400">{t('invitePage.fullAccessGranted')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -276,16 +278,16 @@ export default function InvitePage({
|
|||
<div className="w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden relative z-10">
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
{isAdminInvite ? "You're invited!" : 'Special offer!'}
|
||||
{isAdminInvite ? t('invitePage.youreInvited') : t('invitePage.specialOffer')}
|
||||
</h2>
|
||||
<p className="text-warm-300 text-sm">
|
||||
{invite.invited_by
|
||||
? isAdminInvite
|
||||
? `${invite.invited_by} has invited you to get free lifetime access.`
|
||||
: `${invite.invited_by} has shared a 30% discount on lifetime access.`
|
||||
? t('invitePage.invitedByFree', { name: invite.invited_by })
|
||||
: t('invitePage.invitedByDiscount', { name: invite.invited_by })
|
||||
: isAdminInvite
|
||||
? 'You have been invited to get free lifetime access.'
|
||||
: 'A friend has shared a 30% discount on lifetime access.'}
|
||||
? t('invitePage.genericFreeInvite')
|
||||
: t('invitePage.genericDiscount')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
|
|
@ -297,7 +299,7 @@ export default function InvitePage({
|
|||
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
|
||||
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
|
||||
</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-1">/once</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-1">{t('upgrade.once')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -307,10 +309,10 @@ export default function InvitePage({
|
|||
<CheckIcon className="w-6 h-6 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<p className="text-warm-700 dark:text-warm-300 font-medium">
|
||||
You already have a license
|
||||
{t('invitePage.youAlreadyHaveLicense')}
|
||||
</p>
|
||||
<p className="text-warm-500 dark:text-warm-400 text-sm mt-1">
|
||||
Your account already has full access.
|
||||
{t('invitePage.accountHasFullAccess')}
|
||||
</p>
|
||||
</div>
|
||||
) : user ? (
|
||||
|
|
@ -322,11 +324,11 @@ export default function InvitePage({
|
|||
{redeeming && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{isAdminInvite
|
||||
? redeeming
|
||||
? 'Activating...'
|
||||
: 'Activate license'
|
||||
? t('invitePage.activating')
|
||||
: t('invitePage.activateLicense')
|
||||
: redeeming
|
||||
? 'Redirecting...'
|
||||
: 'Claim discount'}
|
||||
? t('upgrade.redirecting')
|
||||
: t('invitePage.claimDiscount')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
|
@ -334,13 +336,13 @@ export default function InvitePage({
|
|||
onClick={onRegisterClick}
|
||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Register to claim
|
||||
{t('invitePage.registerToClaim')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Already have an account? Log in
|
||||
{t('upgrade.alreadyHaveAccount')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,312 +1,54 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { tDynamic } from '../../i18n';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
import { SubNav } from '../ui/SubNav';
|
||||
|
||||
type LearnTab = 'data-sources' | 'faq' | 'support';
|
||||
|
||||
const LEARN_TABS = [
|
||||
{ key: 'faq', label: 'FAQ' },
|
||||
{ key: 'data-sources', label: 'Data Sources' },
|
||||
{ key: 'support', label: 'Support' },
|
||||
];
|
||||
|
||||
const DATA_SOURCES = [
|
||||
{
|
||||
id: 'price-paid',
|
||||
name: 'Price Paid Data',
|
||||
origin: 'HM Land Registry',
|
||||
use: 'Complete historical property sale prices for England.',
|
||||
url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'epc',
|
||||
name: 'Energy Performance Certificates (EPC)',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Matched with Price Paid records by address within each postcode. Property owners can opt out of public disclosure.',
|
||||
optOutUrl:
|
||||
'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
|
||||
url: 'https://epc.opendatacommunities.org/downloads/domestic',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'nspl',
|
||||
name: 'National Statistics Postcode Lookup (NSPL)',
|
||||
origin: 'ONS / ArcGIS',
|
||||
use: 'Maps postcodes to coordinates and statistical area codes, used to link all area-level datasets to individual properties.',
|
||||
url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'iod',
|
||||
name: 'English Indices of Deprivation 2025',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Relative deprivation scores across income, employment, education, health, crime, and living environment for every neighbourhood in England.',
|
||||
url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ethnicity',
|
||||
name: 'Population by Ethnicity (2021 Census)',
|
||||
origin: 'ONS',
|
||||
use: 'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per local authority.',
|
||||
url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'crime',
|
||||
name: 'Street-level Crime Data',
|
||||
origin: 'data.police.uk',
|
||||
use: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).',
|
||||
url: 'https://data.police.uk/data/',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'osm-pois',
|
||||
name: 'OpenStreetMap POIs',
|
||||
origin: 'OpenStreetMap contributors / Geofabrik',
|
||||
use: 'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.',
|
||||
url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf',
|
||||
license: 'Open Data Commons Open Database License (ODbL)',
|
||||
},
|
||||
{
|
||||
id: 'os-open-greenspace',
|
||||
name: 'OS Open Greenspace',
|
||||
origin: 'Ordnance Survey',
|
||||
use: 'Authoritative green space boundaries for Great Britain, including public parks, gardens, playing fields, and play spaces. Polygon centroids are used for park proximity counts and distance-to-nearest-park calculations.',
|
||||
url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'naptan',
|
||||
name: 'NaPTAN (Public Transport Stops)',
|
||||
origin: 'Department for Transport',
|
||||
use: 'Station and stop locations for rail, bus, metro/tram, ferry, and airports across England.',
|
||||
url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'noise',
|
||||
name: 'Defra Noise Mapping',
|
||||
origin: 'Defra / Environment Agency',
|
||||
use: 'Road noise levels (24-hour weighted average) from the 2022 strategic noise mapping, modelled at high resolution and sampled at each postcode.',
|
||||
url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ofsted',
|
||||
name: 'Ofsted School Inspections',
|
||||
origin: 'Ofsted',
|
||||
use: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).',
|
||||
url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'broadband',
|
||||
name: 'Ofcom Broadband Performance',
|
||||
origin: 'Ofcom',
|
||||
use: 'Fixed broadband coverage and maximum download speeds by area from Ofcom Connected Nations 2025.',
|
||||
url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'council-tax',
|
||||
name: 'Council Tax Levels 2025-26',
|
||||
origin: 'Ministry of Housing, Communities & Local Government',
|
||||
use: 'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.',
|
||||
url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
{
|
||||
id: 'ons-rental',
|
||||
name: 'Private Rental Market Statistics',
|
||||
origin: 'ONS / Valuation Office Agency',
|
||||
use: 'Median monthly private rental prices by local authority and bedroom category (Oct 2022 - Sep 2023). Joined to properties via local authority district code and estimated bedroom count.',
|
||||
url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland',
|
||||
license: 'Open Government Licence v3.0',
|
||||
},
|
||||
];
|
||||
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
interface DataSourceDef {
|
||||
id: string;
|
||||
url: string;
|
||||
license: string;
|
||||
optOutUrl?: string;
|
||||
}
|
||||
|
||||
interface FAQSection {
|
||||
title: string;
|
||||
items: FAQItem[];
|
||||
}
|
||||
|
||||
const FAQ_SECTIONS: FAQSection[] = [
|
||||
{
|
||||
title: 'Finding Your Area',
|
||||
items: [
|
||||
{
|
||||
question: "I don't even know which areas to look at. Can this help?",
|
||||
answer:
|
||||
'That\'s exactly what it\'s for. Set your filters (budget, commute time, low crime, good schools) and the map lights up to show you every area that ticks every box. No more Googling "best areas to live near Manchester" at midnight.',
|
||||
},
|
||||
{
|
||||
question: "I'm moving somewhere I've never been. How do I even start?",
|
||||
answer:
|
||||
"Set your filters for what matters and the map instantly highlights the areas that qualify. You go from \"I don't know a single street\" to a shortlist in minutes.",
|
||||
},
|
||||
{
|
||||
question: 'How do I find areas that tick all my boxes at once?',
|
||||
answer:
|
||||
'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the best value areas. The map updates live as you drag sliders, so you can see results change in real time.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Commute and Travel',
|
||||
items: [
|
||||
{
|
||||
question: 'Can I see how long my commute would actually be from different areas?',
|
||||
answer:
|
||||
"Set your workplace as a destination and we'll colour every postcode by journey time, whether that's by car, bike, or public transport. Filter to your max commute and the rest disappears.",
|
||||
},
|
||||
{
|
||||
question: 'How is that better than checking Google Maps?',
|
||||
answer:
|
||||
'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Budget and Value',
|
||||
items: [
|
||||
{
|
||||
question: 'How do I find areas where I get the most space for my money?',
|
||||
answer:
|
||||
"Filter by price per sqm and you'll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.",
|
||||
},
|
||||
{
|
||||
question: "How do I make sure a cheap area isn't cheap for a reason?",
|
||||
answer:
|
||||
"Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, you've found genuine value, not just a low price with trade-offs you haven't spotted yet.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Safety and Neighbourhood',
|
||||
items: [
|
||||
{
|
||||
question: 'How can I check if an area is safe before I move there?',
|
||||
answer:
|
||||
'We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.',
|
||||
},
|
||||
{
|
||||
question:
|
||||
'I keep finding flats that look great online, then the area turns out to be rough.',
|
||||
answer:
|
||||
"That's exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Families and Schools',
|
||||
items: [
|
||||
{
|
||||
question: 'Can I find areas with good schools AND low crime in one search?',
|
||||
answer:
|
||||
'Yes. Stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, and the map highlights only the areas that tick every box. No more cross-referencing five different websites.',
|
||||
},
|
||||
{
|
||||
question: 'How do I know if a neighbourhood has parks and playgrounds nearby?',
|
||||
answer:
|
||||
'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Environment and Quality of Life',
|
||||
items: [
|
||||
{
|
||||
question: "Can I find energy-efficient homes that aren't on a noisy road?",
|
||||
answer:
|
||||
'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
|
||||
},
|
||||
{
|
||||
question: 'Does it show flood or subsidence risk?',
|
||||
answer:
|
||||
"We include ground stability data so you can check for subsidence, shrink-swell clay, and other geological hazards before committing to a property. Filter out risky areas early.",
|
||||
},
|
||||
{
|
||||
question: 'Can I find areas with fast broadband that are actually quiet?',
|
||||
answer:
|
||||
'Layer the broadband speed filter with road noise data to find streets with great connectivity and low traffic noise. Colour-code by either metric to compare areas at a glance.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Why Perfect Postcode',
|
||||
items: [
|
||||
{
|
||||
question: 'I already use Rightmove. What does this add?',
|
||||
answer:
|
||||
"Rightmove shows you houses. We show you areas. Crime rates, school ratings, broadband speeds, noise levels, deprivation scores, and more, all filterable on one map. You can judge a neighbourhood before you even look at listings.",
|
||||
},
|
||||
{
|
||||
question: "Can't I just research all this myself for free?",
|
||||
answer:
|
||||
'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have it all filterable and colour-coded on one map in seconds.',
|
||||
},
|
||||
{
|
||||
question: 'Where does the data actually come from?',
|
||||
answer:
|
||||
"Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don't scrape estate agents or make anything up. You can verify any record against the original source.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Pricing and Access',
|
||||
items: [
|
||||
{
|
||||
question: 'Is it really worth paying for a property search tool?',
|
||||
answer:
|
||||
"Buying a home is likely the biggest purchase you'll make. Spotting one red flag (a noisy road, poor broadband, rising crime) before committing could save you years of regret. This costs less than a tank of petrol.",
|
||||
},
|
||||
{
|
||||
question: "Is this a subscription?",
|
||||
answer:
|
||||
"No. One-time payment, yours forever. Use it intensively during your search, come back whenever you're curious about a new area, and it's still there if you ever move again.",
|
||||
},
|
||||
{
|
||||
question: 'What can I access on the free tier?',
|
||||
answer:
|
||||
'Free users can explore all features within inner London (roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
|
||||
},
|
||||
{
|
||||
question: 'Can I get a refund?',
|
||||
answer:
|
||||
'Absolutely. We offer a 30-day money-back guarantee. If you\u2019re not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tips and Tricks',
|
||||
items: [
|
||||
{
|
||||
question: 'How do I use the AI filter instead of adding filters one by one?',
|
||||
answer:
|
||||
'Type what you want in plain English, something like "quiet area near good schools with fast broadband under \u00a3400k", and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
|
||||
},
|
||||
{
|
||||
question: 'Can I save a search and come back to it later?',
|
||||
answer:
|
||||
'Hit the save button and everything is captured: your filters, zoom level, and which data layer you\u2019re colouring by. Pick up exactly where you left off or share the link with your partner.',
|
||||
},
|
||||
{
|
||||
question: "Can I export the data I'm looking at?",
|
||||
answer:
|
||||
'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
|
||||
},
|
||||
],
|
||||
},
|
||||
const DATA_SOURCE_DEFS: DataSourceDef[] = [
|
||||
{ id: 'price-paid', url: 'https://www.gov.uk/government/statistical-data-sets/price-paid-data-downloads', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'epc', url: 'https://epc.opendatacommunities.org/downloads/domestic', license: 'Open Government Licence v3.0', optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure' },
|
||||
{ id: 'nspl', url: 'https://www.arcgis.com/sharing/rest/content/items/077631e063eb4e1ab43575d01381ec33/data', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'iod', url: 'https://www.gov.uk/government/statistics/english-indices-of-deprivation-2025', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'ethnicity', url: 'https://www.ethnicity-facts-figures.service.gov.uk/uk-population-by-ethnicity/national-and-regional-populations/regional-ethnic-diversity/latest/#download-the-data', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'crime', url: 'https://data.police.uk/data/', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'osm-pois', url: 'https://download.geofabrik.de/europe/great-britain-latest.osm.pbf', license: 'Open Data Commons Open Database License (ODbL)' },
|
||||
{ id: 'os-open-greenspace', url: 'https://osdatahub.os.uk/downloads/open/OpenGreenspace', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'naptan', url: 'https://naptan.dft.gov.uk/naptan/schema/2.4/doc/NaPTANSchemaGuide-2.4-v0.57.pdf', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'noise', url: 'https://environment.data.gov.uk/spatialdata/road-noise-all-metrics-england-round-4/wcs', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'ofsted', url: 'https://www.gov.uk/government/statistical-data-sets/monthly-management-information-ofsteds-school-inspections-outcomes', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'broadband', url: 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-speeds/connected-nations-20252/data-downloads-2025', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'council-tax', url: 'https://www.gov.uk/government/statistics/council-tax-levels-set-by-local-authorities-in-england-2025-to-2026', license: 'Open Government Licence v3.0' },
|
||||
{ id: 'ons-rental', url: 'https://www.ons.gov.uk/peoplepopulationandcommunity/housing/datasets/privaterentalmarketsummarystatisticsinengland', license: 'Open Government Licence v3.0' },
|
||||
];
|
||||
|
||||
function FAQItemCard({ item }: { item: FAQItem }) {
|
||||
// Maps data source id → [nameKey, originKey, useKey] in en.ts learnPage section
|
||||
const DS_KEYS: Record<string, [string, string, string]> = {
|
||||
'price-paid': ['learnPage.dsPricePaidName', 'learnPage.dsPricePaidOrigin', 'learnPage.dsPricePaidUse'],
|
||||
'epc': ['learnPage.dsEpcName', 'learnPage.dsEpcOrigin', 'learnPage.dsEpcUse'],
|
||||
'nspl': ['learnPage.dsNsplName', 'learnPage.dsNsplOrigin', 'learnPage.dsNsplUse'],
|
||||
'iod': ['learnPage.dsIodName', 'learnPage.dsIodOrigin', 'learnPage.dsIodUse'],
|
||||
'ethnicity': ['learnPage.dsEthnicityName', 'learnPage.dsEthnicityOrigin', 'learnPage.dsEthnicityUse'],
|
||||
'crime': ['learnPage.dsCrimeName', 'learnPage.dsCrimeOrigin', 'learnPage.dsCrimeUse'],
|
||||
'osm-pois': ['learnPage.dsOsmName', 'learnPage.dsOsmOrigin', 'learnPage.dsOsmUse'],
|
||||
'os-open-greenspace': ['learnPage.dsGreenspaceName', 'learnPage.dsGreenspaceOrigin', 'learnPage.dsGreenspaceUse'],
|
||||
'naptan': ['learnPage.dsNaptanName', 'learnPage.dsNaptanOrigin', 'learnPage.dsNaptanUse'],
|
||||
'noise': ['learnPage.dsNoiseName', 'learnPage.dsNoiseOrigin', 'learnPage.dsNoiseUse'],
|
||||
'ofsted': ['learnPage.dsOfstedName', 'learnPage.dsOfstedOrigin', 'learnPage.dsOfstedUse'],
|
||||
'broadband': ['learnPage.dsBroadbandName', 'learnPage.dsBroadbandOrigin', 'learnPage.dsBroadbandUse'],
|
||||
'council-tax': ['learnPage.dsCouncilTaxName', 'learnPage.dsCouncilTaxOrigin', 'learnPage.dsCouncilTaxUse'],
|
||||
'ons-rental': ['learnPage.dsRentalName', 'learnPage.dsRentalOrigin', 'learnPage.dsRentalUse'],
|
||||
};
|
||||
|
||||
function FAQItemCard({ question, answer }: { question: string; answer: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -315,7 +57,7 @@ function FAQItemCard({ item }: { item: FAQItem }) {
|
|||
className="w-full text-left px-5 py-4 flex items-center justify-between gap-4"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{question}</span>
|
||||
<ChevronIcon
|
||||
direction="down"
|
||||
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
|
||||
|
|
@ -323,7 +65,7 @@ function FAQItemCard({ item }: { item: FAQItem }) {
|
|||
</button>
|
||||
{open && (
|
||||
<div className="px-5 pb-4">
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">{item.answer}</p>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">{answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -331,11 +73,90 @@ function FAQItemCard({ item }: { item: FAQItem }) {
|
|||
}
|
||||
|
||||
export default function LearnPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<LearnTab>('faq');
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const LEARN_TABS = [
|
||||
{ key: 'faq', label: t('learnPage.faq') },
|
||||
{ key: 'data-sources', label: t('learnPage.dataSources') },
|
||||
{ key: 'support', label: t('learnPage.support') },
|
||||
];
|
||||
|
||||
const FAQ_SECTIONS = [
|
||||
{
|
||||
title: t('learnPage.faqFindingTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqFinding1Q'), answer: t('learnPage.faqFinding1A') },
|
||||
{ question: t('learnPage.faqFinding2Q'), answer: t('learnPage.faqFinding2A') },
|
||||
{ question: t('learnPage.faqFinding3Q'), answer: t('learnPage.faqFinding3A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqCommuteTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqCommute1Q'), answer: t('learnPage.faqCommute1A') },
|
||||
{ question: t('learnPage.faqCommute2Q'), answer: t('learnPage.faqCommute2A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqBudgetTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqBudget1Q'), answer: t('learnPage.faqBudget1A') },
|
||||
{ question: t('learnPage.faqBudget2Q'), answer: t('learnPage.faqBudget2A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqSafetyTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqSafety1Q'), answer: t('learnPage.faqSafety1A') },
|
||||
{ question: t('learnPage.faqSafety2Q'), answer: t('learnPage.faqSafety2A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqFamiliesTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqFamilies1Q'), answer: t('learnPage.faqFamilies1A') },
|
||||
{ question: t('learnPage.faqFamilies2Q'), answer: t('learnPage.faqFamilies2A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqEnvironmentTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqEnv1Q'), answer: t('learnPage.faqEnv1A') },
|
||||
{ question: t('learnPage.faqEnv2Q'), answer: t('learnPage.faqEnv2A') },
|
||||
{ question: t('learnPage.faqEnv3Q'), answer: t('learnPage.faqEnv3A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqWhyTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqWhy1Q'), answer: t('learnPage.faqWhy1A') },
|
||||
{ question: t('learnPage.faqWhy2Q'), answer: t('learnPage.faqWhy2A') },
|
||||
{ question: t('learnPage.faqWhy3Q'), answer: t('learnPage.faqWhy3A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqPricingTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqPricing1Q'), answer: t('learnPage.faqPricing1A') },
|
||||
{ question: t('learnPage.faqPricing2Q'), answer: t('learnPage.faqPricing2A') },
|
||||
{ question: t('learnPage.faqPricing3Q'), answer: t('learnPage.faqPricing3A') },
|
||||
{ question: t('learnPage.faqPricing4Q'), answer: t('learnPage.faqPricing4A') },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('learnPage.faqTipsTitle'),
|
||||
items: [
|
||||
{ question: t('learnPage.faqTips1Q'), answer: t('learnPage.faqTips1A') },
|
||||
{ question: t('learnPage.faqTips2Q'), answer: t('learnPage.faqTips2A') },
|
||||
{ question: t('learnPage.faqTips3Q'), answer: t('learnPage.faqTips3A') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
function handleHash() {
|
||||
const hash = window.location.hash.replace('#', '');
|
||||
|
|
@ -345,7 +166,7 @@ export default function LearnPage() {
|
|||
} else if (hash === 'support') {
|
||||
setTab('support');
|
||||
setHighlightedId(null);
|
||||
} else if (hash && DATA_SOURCES.some((s) => s.id === hash)) {
|
||||
} else if (hash && DATA_SOURCE_DEFS.some((s) => s.id === hash)) {
|
||||
setTab('data-sources');
|
||||
setHighlightedId(hash);
|
||||
setTimeout(() => {
|
||||
|
|
@ -379,11 +200,13 @@ export default function LearnPage() {
|
|||
<div className="flex-1">
|
||||
<div className="max-w-5xl mx-auto px-6 py-6">
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
This application combines {DATA_SOURCES.length} open datasets covering property
|
||||
prices, energy performance, transport, demographics, crime, environment, and more.
|
||||
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{DATA_SOURCES.map((source) => (
|
||||
{DATA_SOURCE_DEFS.map((source) => {
|
||||
const keys = DS_KEYS[source.id];
|
||||
const [nameKey, originKey, useKey] = keys;
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
|
|
@ -398,16 +221,18 @@ export default function LearnPage() {
|
|||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
|
||||
{source.name}
|
||||
{tDynamic(nameKey)}
|
||||
</h2>
|
||||
<span className="text-xs bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded text-right">
|
||||
{source.license}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
|
||||
Source: {source.origin}
|
||||
{t('learnPage.source')} {tDynamic(originKey)}
|
||||
</p>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
|
||||
{tDynamic(useKey)}
|
||||
</p>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">{source.use}</p>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
|
|
@ -416,7 +241,7 @@ export default function LearnPage() {
|
|||
>
|
||||
{source.url}
|
||||
</a>
|
||||
{'optOutUrl' in source && source.optOutUrl && (
|
||||
{source.optOutUrl && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={source.optOutUrl}
|
||||
|
|
@ -424,12 +249,13 @@ export default function LearnPage() {
|
|||
rel="noopener noreferrer"
|
||||
className="text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Opt out of public disclosure
|
||||
{t('learnPage.optOut')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -437,44 +263,42 @@ export default function LearnPage() {
|
|||
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
|
||||
Attribution
|
||||
{t('learnPage.attribution')}
|
||||
</h2>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
<li>{t('learnPage.attrLandRegistry')}</li>
|
||||
<li>
|
||||
Contains HM Land Registry data © Crown copyright and database right 2025.
|
||||
</li>
|
||||
<li>
|
||||
Contains public sector information licensed under the{' '}
|
||||
{t('learnPage.attrOgl')}{' '}
|
||||
<a
|
||||
href="https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Open Government Licence v3.0
|
||||
{t('learnPage.attrOglLink')}
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Contains OS data © Crown copyright and database rights 2025.</li>
|
||||
<li>Powered by TfL Open Data.</li>
|
||||
<li>{t('learnPage.attrOs')}</li>
|
||||
<li>{t('learnPage.attrTfl')}</li>
|
||||
<li>
|
||||
Contains data from{' '}
|
||||
{t('learnPage.attrOsm')}{' '}
|
||||
<a
|
||||
href="https://www.openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
© OpenStreetMap contributors
|
||||
{t('learnPage.attrOsmContrib')}
|
||||
</a>
|
||||
, available under the{' '}
|
||||
, {t('learnPage.attrOsmLicense')}{' '}
|
||||
<a
|
||||
href="https://opendatacommons.org/licenses/odbl/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Open Data Commons Open Database License (ODbL)
|
||||
{t('learnPage.attrOsmLicenseLink')}
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
|
|
@ -485,8 +309,7 @@ export default function LearnPage() {
|
|||
) : tab === 'faq' ? (
|
||||
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
Whether you're buying, renting, or just exploring, here's how Perfect
|
||||
Postcode helps you find the right area.
|
||||
{t('learnPage.faqIntro')}
|
||||
</p>
|
||||
<div className="space-y-8">
|
||||
{FAQ_SECTIONS.map((section) => (
|
||||
|
|
@ -496,7 +319,7 @@ export default function LearnPage() {
|
|||
</h3>
|
||||
<div className="space-y-3">
|
||||
{section.items.map((item, index) => (
|
||||
<FAQItemCard key={index} item={item} />
|
||||
<FAQItemCard key={index} question={item.question} answer={item.answer} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -506,10 +329,10 @@ export default function LearnPage() {
|
|||
) : (
|
||||
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
Have a question? Check our FAQ or reach out to us directly.
|
||||
{t('learnPage.supportIntro')}
|
||||
</p>
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
||||
<a
|
||||
href="mailto:support@perfect-postcode.co.uk"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
||||
|
|
@ -517,7 +340,7 @@ export default function LearnPage() {
|
|||
support@perfect-postcode.co.uk
|
||||
</a>
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
||||
We typically respond within 24 hours.
|
||||
{t('accountPage.responseTime')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,24 +1,12 @@
|
|||
import { memo, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { memo, useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { SparklesIcon } from '../ui/icons/SparklesIcon';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
'Safe area near good schools',
|
||||
'30 min commute to Kings Cross, under \u00A3500k',
|
||||
'Quiet village, 3 bed, fast broadband',
|
||||
];
|
||||
|
||||
const LOADING_MESSAGES = [
|
||||
'Analysing your query...',
|
||||
'Searching for destinations...',
|
||||
'Generating filters...',
|
||||
'Refining results...',
|
||||
];
|
||||
|
||||
/** Cycle through loading messages to show progress. */
|
||||
function useLoadingMessage(loading: boolean): string {
|
||||
function useLoadingMessage(loading: boolean, messages: string[]): string {
|
||||
const [index, setIndex] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
|
|
@ -38,7 +26,7 @@ function useLoadingMessage(loading: boolean): string {
|
|||
};
|
||||
}, [loading]);
|
||||
|
||||
return LOADING_MESSAGES[index];
|
||||
return messages[index];
|
||||
}
|
||||
|
||||
interface AiFilterInputProps {
|
||||
|
|
@ -62,9 +50,12 @@ export default memo(function AiFilterInput({
|
|||
isLoggedIn,
|
||||
onLoginRequired,
|
||||
}: AiFilterInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const loadingMessage = useLoadingMessage(loading);
|
||||
const exampleQueries = useMemo(() => [t('aiFilter.example1'), t('aiFilter.example2'), t('aiFilter.example3')], [t]);
|
||||
const loadingMessages = useMemo(() => [t('aiFilter.analysing'), t('aiFilter.searchingDestinations'), t('aiFilter.generatingFilters'), t('aiFilter.refiningResults')], [t]);
|
||||
const loadingMessage = useLoadingMessage(loading, loadingMessages);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
|
@ -145,7 +136,7 @@ export default memo(function AiFilterInput({
|
|||
>
|
||||
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
|
||||
Describe your ideal area with AI
|
||||
{t('aiFilter.describeIdealArea')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -156,9 +147,9 @@ export default memo(function AiFilterInput({
|
|||
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">AI Search</span>
|
||||
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">{t('aiFilter.aiSearch')}</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
describe what you're looking for
|
||||
{t('aiFilter.describeHint')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -177,7 +168,7 @@ export default memo(function AiFilterInput({
|
|||
resizeTextarea();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g. quiet area, under £400k, near good schools..."
|
||||
placeholder={t('aiFilter.placeholder')}
|
||||
className="flex-1 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400 focus:bg-white dark:focus:bg-warm-800 resize-none overflow-hidden"
|
||||
rows={1}
|
||||
style={{ maxHeight: '6rem' }}
|
||||
|
|
@ -194,7 +185,7 @@ export default memo(function AiFilterInput({
|
|||
) : (
|
||||
<>
|
||||
<SparklesIcon className="w-3.5 h-3.5" />
|
||||
<span>Search</span>
|
||||
<span>{t('common.search')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -202,7 +193,7 @@ export default memo(function AiFilterInput({
|
|||
{loading && <p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{loadingMessage}</p>}
|
||||
{showExamples && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{EXAMPLE_QUERIES.map((example) => (
|
||||
{exampleQueries.map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
type="button"
|
||||
|
|
@ -216,7 +207,7 @@ export default memo(function AiFilterInput({
|
|||
)}
|
||||
{error && errorType === 'limit' && (
|
||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
You've reached the weekly AI usage limit. It will reset automatically next week.
|
||||
{t('aiFilter.weeklyLimitReached')}
|
||||
</p>
|
||||
)}
|
||||
{error && errorType === 'error' && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import type {
|
||||
FeatureFilters,
|
||||
FeatureMeta,
|
||||
|
|
@ -56,6 +58,7 @@ export default function AreaPane({
|
|||
isGroupExpanded,
|
||||
onToggleGroup,
|
||||
}: AreaPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
|
@ -79,8 +82,8 @@ export default function AreaPane({
|
|||
return (
|
||||
<EmptyState
|
||||
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title="No area selected"
|
||||
description="Click any coloured area on the map to see crime, schools, prices, and more"
|
||||
title={t('common.noAreaSelected')}
|
||||
description={t('common.noAreaSelectedDesc')}
|
||||
centered
|
||||
/>
|
||||
);
|
||||
|
|
@ -93,10 +96,10 @@ export default function AreaPane({
|
|||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : 'Area Statistics'}
|
||||
{isPostcode ? hexagonId : t('areaPane.areaStatistics')}
|
||||
</h2>
|
||||
{isPostcode && (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">{t('common.postcode')}</span>
|
||||
)}
|
||||
</div>
|
||||
{loading && stats && (
|
||||
|
|
@ -105,19 +108,19 @@ export default function AreaPane({
|
|||
</div>
|
||||
{propertyCount != null && (
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||
{propertyCount.toLocaleString()} properties
|
||||
{propertyCount.toLocaleString()} {t('common.propertiesPlural')}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
Stats for all properties in this {isPostcode ? 'postcode' : 'area'}
|
||||
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
|
||||
{t('areaPane.statsFor', { type: isPostcode ? t('common.postcode').toLowerCase() : t('common.area').toLowerCase() })}
|
||||
{Object.keys(filters).length > 0 ? t('areaPane.matchingFilters') : ''}
|
||||
</p>
|
||||
{stats && stats.count > 0 && (
|
||||
<button
|
||||
onClick={onViewProperties}
|
||||
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
||||
>
|
||||
View {stats.count.toLocaleString()} Properties
|
||||
{t('areaPane.viewProperties', { count: stats.count })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -147,7 +150,7 @@ export default function AreaPane({
|
|||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">{t('areaPane.priceHistory')}</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -202,19 +205,19 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{chart.label}
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
|
|
@ -308,7 +311,7 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
|
|
@ -320,7 +323,7 @@ export default function AreaPane({
|
|||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{chart.label}
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
|
|
@ -349,18 +352,18 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{chart.label}
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import {
|
||||
buildPropertySearchUrls,
|
||||
|
|
@ -23,6 +24,7 @@ export default function ExternalSearchLinks({
|
|||
location: HexagonLocation;
|
||||
filters: FeatureFilters;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rightmoveLocationId = getRightmoveLocationId(location.postcode);
|
||||
const urls = useMemo(
|
||||
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
|
||||
|
|
@ -41,7 +43,7 @@ export default function ExternalSearchLinks({
|
|||
return (
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
Search {label} on
|
||||
{t('externalSearch.searchOn', { radius: label })}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{urls.rightmove ? (
|
||||
|
|
@ -49,7 +51,7 @@ export default function ExternalSearchLinks({
|
|||
Rightmove
|
||||
</a>
|
||||
) : (
|
||||
<span className={disabledClass} title="Outcode not recognised">
|
||||
<span className={disabledClass} title={t('externalSearch.outcodeNotRecognised')}>
|
||||
Rightmove
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { Fragment, memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { ChevronIcon, LightbulbIcon } from '../ui/icons';
|
||||
import { ChevronIcon, CloseIcon, LightbulbIcon, SpinnerIcon } from '../ui/icons';
|
||||
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
import { PillGroup } from '../ui/PillGroup';
|
||||
import type { FeatureMeta, FeatureFilters } from '../../types';
|
||||
import { formatFilterValue, parseInputValue, buildPercentileScale } from '../../lib/format';
|
||||
import { formatFilterValue, formatNumber, parseInputValue, buildPercentileScale } from '../../lib/format';
|
||||
import type { PercentileScale } from '../../lib/format';
|
||||
import InfoPopup from '../ui/InfoPopup';
|
||||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
|
|
@ -197,8 +199,13 @@ interface FiltersProps {
|
|||
isLoggedIn: boolean;
|
||||
onLoginRequired: () => void;
|
||||
isLicensed: boolean;
|
||||
isAdmin: boolean;
|
||||
onUpgradeClick?: () => void;
|
||||
onResetTutorial?: () => void;
|
||||
filterImpacts?: Record<string, number>;
|
||||
onClearAll: () => void;
|
||||
onSaveSearch?: (name: string) => Promise<void>;
|
||||
savingSearch?: boolean;
|
||||
}
|
||||
|
||||
export default memo(function Filters({
|
||||
|
|
@ -234,9 +241,15 @@ export default memo(function Filters({
|
|||
isLoggedIn,
|
||||
onLoginRequired,
|
||||
isLicensed,
|
||||
isAdmin,
|
||||
onUpgradeClick,
|
||||
onResetTutorial,
|
||||
filterImpacts,
|
||||
onClearAll,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
}: FiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const modeRestrictions = useMemo(() => {
|
||||
const map: Record<string, Set<ListingType>> = {};
|
||||
for (const f of features) {
|
||||
|
|
@ -356,6 +369,7 @@ export default memo(function Filters({
|
|||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showPhilosophy, setShowPhilosophy] = useState(false);
|
||||
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [activeFilterCollapsed, setActiveFilterCollapsed] = useState(false);
|
||||
const [addFilterCollapsed, setAddFilterCollapsed] = useState(false);
|
||||
const activeEntryCount = travelTimeEntries.length;
|
||||
|
||||
|
|
@ -408,18 +422,68 @@ export default memo(function Filters({
|
|||
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount;
|
||||
|
||||
const [showClearPopup, setShowClearPopup] = useState(false);
|
||||
const [clearSaveName, setClearSaveName] = useState('');
|
||||
const [clearSaveError, setClearSaveError] = useState<string | null>(null);
|
||||
|
||||
const handleClearAllClick = useCallback(() => {
|
||||
if (badgeCount === 0) return;
|
||||
if (onSaveSearch) {
|
||||
setShowClearPopup(true);
|
||||
setClearSaveName('');
|
||||
setClearSaveError(null);
|
||||
} else {
|
||||
onClearAll();
|
||||
}
|
||||
}, [badgeCount, onSaveSearch, onClearAll]);
|
||||
|
||||
const handleSaveAndClear = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!clearSaveName.trim() || savingSearch) return;
|
||||
try {
|
||||
await onSaveSearch!(clearSaveName.trim());
|
||||
setShowClearPopup(false);
|
||||
onClearAll();
|
||||
} catch {
|
||||
setClearSaveError(t('saveSearch.saving'));
|
||||
}
|
||||
},
|
||||
[clearSaveName, savingSearch, onSaveSearch, onClearAll, t]
|
||||
);
|
||||
|
||||
const handleClearWithoutSaving = useCallback(() => {
|
||||
setShowClearPopup(false);
|
||||
onClearAll();
|
||||
}, [onClearAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showClearPopup) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setShowClearPopup(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [showClearPopup]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full touch-pan-y"
|
||||
className="relative flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full touch-pan-y"
|
||||
>
|
||||
<div
|
||||
className={`shrink-0 md:shrink md:min-h-0 flex flex-col ${addFilterCollapsed ? '' : 'md:basis-[40%]'}`}
|
||||
className="flex flex-col min-h-0"
|
||||
style={{
|
||||
flex: activeFilterCollapsed ? '0 0 auto' : addFilterCollapsed ? '1 1 0' : '3 1 0',
|
||||
}}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30">
|
||||
<button
|
||||
onClick={() => setActiveFilterCollapsed((v) => !v)}
|
||||
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">
|
||||
Active Filters
|
||||
{t('filters.activeFilters')}
|
||||
</span>
|
||||
{badgeCount > 0 && (
|
||||
<span className="text-xs font-medium px-1.5 py-0.5 rounded-full bg-teal-50 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
|
|
@ -427,9 +491,34 @@ export default memo(function Filters({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{badgeCount > 0 && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearAllClick();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
handleClearAllClick();
|
||||
}
|
||||
}}
|
||||
className="text-xs text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-200 underline"
|
||||
>
|
||||
{t('filters.clearAll')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronIcon
|
||||
direction={activeFilterCollapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto overflow-x-hidden">
|
||||
{!activeFilterCollapsed && <div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<AiFilterInput
|
||||
loading={aiFilterLoading}
|
||||
error={aiFilterError}
|
||||
|
|
@ -441,36 +530,38 @@ export default memo(function Filters({
|
|||
onLoginRequired={onLoginRequired}
|
||||
/>
|
||||
<div className="px-3 pb-2 space-y-2">
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' };
|
||||
const isActive = activeListingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleListingSelect(type)}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
|
||||
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
const labels = { historical: t('filters.historical'), buy: t('filters.buy'), rent: t('filters.rent') };
|
||||
const isActive = activeListingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleListingSelect(type)}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
|
||||
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPhilosophy(true)}
|
||||
className="w-full px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<LightbulbIcon />
|
||||
Finding the Perfect Postcode
|
||||
{t('filters.findingPerfectPostcode')}
|
||||
</button>
|
||||
</div>
|
||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
Add filters below to narrow the map to areas that match your criteria
|
||||
{t('filters.addFiltersHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -500,6 +591,7 @@ export default memo(function Filters({
|
|||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -521,7 +613,7 @@ export default memo(function Filters({
|
|||
{allValues.map((val) => (
|
||||
<PillToggle
|
||||
key={val}
|
||||
label={val}
|
||||
label={ts(val)}
|
||||
active={selectedValues.includes(val)}
|
||||
onClick={() => {
|
||||
const next = selectedValues.includes(val)
|
||||
|
|
@ -533,6 +625,11 @@ export default memo(function Filters({
|
|||
/>
|
||||
))}
|
||||
</PillGroup>
|
||||
{filterImpacts?.[feature.name] != null && filterImpacts[feature.name] > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 mt-0.5">
|
||||
+{formatNumber(filterImpacts[feature.name])} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
|
@ -592,6 +689,7 @@ export default memo(function Filters({
|
|||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -599,7 +697,7 @@ export default memo(function Filters({
|
|||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" hideIconOnMobile />
|
||||
<FeatureActions
|
||||
feature={feature}
|
||||
|
|
@ -649,6 +747,11 @@ export default memo(function Filters({
|
|||
feature={feature}
|
||||
onValueChange={(v) => onFilterChange(feature.name, v)}
|
||||
/>
|
||||
{filterImpacts?.[feature.name] != null && filterImpacts[feature.name] > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||
+{formatNumber(filterImpacts[feature.name])} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -674,28 +777,32 @@ export default memo(function Filters({
|
|||
onDragEnd={() => onTravelTimeDragEnd(index)}
|
||||
onToggleBest={() => onTravelTimeToggleBest(index)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
filterImpact={filterImpacts?.[travelFieldKey(entry)]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`shrink-0 md:shrink md:min-h-0 flex flex-col border-t border-warm-200 dark:border-warm-700 ${addFilterCollapsed ? '' : 'md:basis-[60%]'}`}
|
||||
className="flex flex-col min-h-0 border-t border-warm-200 dark:border-warm-700"
|
||||
style={{
|
||||
flex: addFilterCollapsed ? '0 0 auto' : activeFilterCollapsed ? '1 1 0' : '2 1 0',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setAddFilterCollapsed((v) => !v)}
|
||||
className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-teal-50 dark:bg-teal-900/30 cursor-pointer hover:bg-teal-100 dark:hover:bg-teal-900/50"
|
||||
>
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">Add Filter</span>
|
||||
<span className="text-sm font-semibold text-teal-700 dark:text-teal-400">{t('filters.addFilter')}</span>
|
||||
<ChevronIcon
|
||||
direction={addFilterCollapsed ? 'down' : 'up'}
|
||||
className="w-4 h-4 text-warm-400 dark:text-warm-500"
|
||||
/>
|
||||
</button>
|
||||
{!addFilterCollapsed && (
|
||||
<div className="md:min-h-0 md:flex-1 flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={features}
|
||||
|
|
@ -708,82 +815,65 @@ export default memo(function Filters({
|
|||
travelTimeEntries={travelTimeEntries}
|
||||
onAddTravelTimeEntry={handleAddTravelTimeAndScroll}
|
||||
isLicensed={isLicensed}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
{!isLicensed && (
|
||||
<div className="shrink-0 flex flex-col items-center px-5 pt-4 pb-0 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
|
||||
{t('filters.upgradePrompt')}
|
||||
</p>
|
||||
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
|
||||
{t('filters.oneTimeLifetime')}
|
||||
</p>
|
||||
<button
|
||||
onClick={onUpgradeClick}
|
||||
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||
>
|
||||
{t('filters.upgradeToFullMap')}
|
||||
</button>
|
||||
<svg
|
||||
viewBox="0 120 1600 230"
|
||||
className="w-full mt-4 block shrink-0"
|
||||
preserveAspectRatio="xMidYMax meet"
|
||||
>
|
||||
<path
|
||||
d="M0,350 C400,150 1200,150 1600,350 Z"
|
||||
className="fill-green-500 dark:fill-green-600"
|
||||
/>
|
||||
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
|
||||
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
|
||||
<image href="/house.png" x="735" y="110" width="130" height="120" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPhilosophy && (
|
||||
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
|
||||
<InfoPopup title={t('filters.findingPerfectPostcode')} onClose={() => setShowPhilosophy(false)}>
|
||||
<div className="space-y-4 text-sm">
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Start with your must-haves, then layer on nice-to-haves. The map narrows as you add
|
||||
filters. The areas left are your best matches.
|
||||
{t('philosophy.intro')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
|
||||
1
|
||||
</span>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">
|
||||
Budget and basics
|
||||
</span>{' '}
|
||||
(price range, floor area, property type)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
|
||||
2
|
||||
</span>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">Commute</span>{' '}
|
||||
(travel time to your workplace by car, bike, or transit)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
|
||||
3
|
||||
</span>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">Safety</span>{' '}
|
||||
(crime rates, noise levels, ground stability)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
|
||||
4
|
||||
</span>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">Schools</span>{' '}
|
||||
(nearby Ofsted-rated Good or Outstanding schools)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
|
||||
5
|
||||
</span>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">Lifestyle</span>{' '}
|
||||
(restaurants, parks, broadband speed)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
|
||||
6
|
||||
</span>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">Energy</span>{' '}
|
||||
(EPC ratings, insulation, heating costs)
|
||||
</p>
|
||||
</div>
|
||||
{([1, 2, 3, 4, 5, 6] as const).map((n) => (
|
||||
<div key={n} className="flex gap-2">
|
||||
<span className="shrink-0 w-5 h-5 rounded-full bg-teal-600 text-white flex items-center justify-center text-xs font-bold">
|
||||
{n}
|
||||
</span>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">
|
||||
{t(`philosophy.step${n}Title`)}
|
||||
</span>{' '}
|
||||
{t(`philosophy.step${n}Desc`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-warm-500 dark:text-warm-400 italic text-xs">
|
||||
Tip: if nothing matches, relax one constraint at a time to see which trade-off opens
|
||||
up the most options.
|
||||
{t('philosophy.tip')}
|
||||
</p>
|
||||
|
||||
{onResetTutorial && (
|
||||
|
|
@ -794,7 +884,7 @@ export default memo(function Filters({
|
|||
}}
|
||||
className="w-full px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm"
|
||||
>
|
||||
Replay interactive tutorial
|
||||
{t('filters.replayTutorial')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -808,6 +898,63 @@ export default memo(function Filters({
|
|||
onNavigateToSource={onNavigateToSource}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClearPopup && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={() => setShowClearPopup(false)}>
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
{t('filters.clearAllTitle')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowClearPopup(false)}
|
||||
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSaveAndClear} className="p-5 pt-2 space-y-4">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400">
|
||||
{t('filters.clearAllSavePrompt')}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={clearSaveName}
|
||||
onChange={(e) => setClearSaveName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={t('saveSearch.namePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{clearSaveError && (
|
||||
<p className="text-sm text-red-600 dark:text-red-300">{clearSaveError}</p>
|
||||
)}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearWithoutSaving}
|
||||
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"
|
||||
>
|
||||
{t('filters.clearWithoutSaving')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!clearSaveName.trim() || savingSearch}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function HistogramLegend() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the
|
||||
distribution in this selected area
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.tealBars')}</span> {t('histogramLegend.tealBarsDesc')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Grey bars</span> show the
|
||||
overall distribution across all areas
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.greyBars')}</span> {t('histogramLegend.greyBarsDesc')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span>{' '}
|
||||
indicates the national average
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.dashedLine')}</span>{' '}
|
||||
{t('histogramLegend.dashedLineDesc')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../types';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { ts } from '../../i18n/server';
|
||||
|
||||
interface HoverCardData {
|
||||
count: number;
|
||||
|
|
@ -26,6 +28,7 @@ export default memo(function HoverCard({
|
|||
filters,
|
||||
features,
|
||||
}: HoverCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const activeFilterNames = Object.keys(filters);
|
||||
|
||||
const featureMap = useMemo(() => new Map(features.map((f) => [f.name, f])), [features]);
|
||||
|
|
@ -43,7 +46,7 @@ export default memo(function HoverCard({
|
|||
const meta = featureMap.get(name);
|
||||
if (meta?.type === 'enum' && meta.values) {
|
||||
const label = meta.values[Math.round(val)];
|
||||
if (label) results.push({ name, value: label });
|
||||
if (label) results.push({ name, value: ts(label) });
|
||||
} else {
|
||||
results.push({ name, value: formatValue(val, meta) });
|
||||
}
|
||||
|
|
@ -85,14 +88,14 @@ export default memo(function HoverCard({
|
|||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="font-semibold text-navy-950 dark:text-white truncate">
|
||||
{isPostcode ? id : 'Area'}
|
||||
{isPostcode ? id : t('common.area')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Property count */}
|
||||
{count != null && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-300 mb-2">
|
||||
{count.toLocaleString()} {count === 1 ? 'property' : 'properties'}
|
||||
{count.toLocaleString()} {count === 1 ? t('common.property') : t('common.propertiesPlural')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -101,7 +104,7 @@ export default memo(function HoverCard({
|
|||
<div className="space-y-1 border-t border-warm-200 dark:border-warm-700 pt-2">
|
||||
{displayStats.map((stat) => (
|
||||
<div key={stat.name} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-warm-500 dark:text-warm-300 truncate">{stat.name}</span>
|
||||
<span className="text-warm-500 dark:text-warm-300 truncate">{ts(stat.name)}</span>
|
||||
<span className="font-medium text-teal-700 dark:text-teal-300 whitespace-nowrap">
|
||||
{stat.value}
|
||||
</span>
|
||||
|
|
@ -112,7 +115,7 @@ export default memo(function HoverCard({
|
|||
|
||||
{/* Hint */}
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 mt-2 text-center">
|
||||
Click for details
|
||||
{t('common.clickForDetails')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { JourneyLeg } from '../../types';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
|
|
@ -105,6 +106,7 @@ function RouteBadge({ mode }: { mode: string }) {
|
|||
}
|
||||
|
||||
function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const isAccess = leg.mode === 'walk' || leg.mode === 'bicycle';
|
||||
|
||||
if (isAccess) {
|
||||
|
|
@ -123,7 +125,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
<BicycleIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{leg.mode === 'walk' ? 'Walk' : 'Cycle'} · {leg.minutes} min
|
||||
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes} {t('common.min')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -143,7 +145,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
<div className="pb-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<RouteBadge mode={leg.mode} />
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} min</span>
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} {t('common.min')}</span>
|
||||
</div>
|
||||
{leg.from && leg.to && (
|
||||
<div className="text-[11px] text-warm-600 dark:text-warm-300 mt-0.5">
|
||||
|
|
@ -160,6 +162,7 @@ export default function JourneyInstructions({
|
|||
entries,
|
||||
label,
|
||||
}: JourneyInstructionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
||||
|
||||
// Only transit entries with a destination set
|
||||
|
|
@ -228,7 +231,7 @@ export default function JourneyInstructions({
|
|||
return (
|
||||
<div className="mx-3 mt-2 space-y-2">
|
||||
{label && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400">Journeys from {label}</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400">{t('areaPane.journeysFrom', { label })}</div>
|
||||
)}
|
||||
{journeys.map((j) => {
|
||||
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
||||
|
|
@ -239,18 +242,18 @@ export default function JourneyInstructions({
|
|||
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
|
||||
To {j.label || j.slug}
|
||||
{t('areaPane.to', { destination: j.label || j.slug })}
|
||||
</span>
|
||||
{!j.loading && totalMin > 0 && (
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
{totalMin} min
|
||||
{totalMin} {t('common.min')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{j.loading ? (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Loading...</span>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : displayLegs && displayLegs.length > 0 ? (
|
||||
<div>
|
||||
|
|
@ -263,7 +266,7 @@ export default function JourneyInstructions({
|
|||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
View on Google Maps
|
||||
{t('areaPane.viewOnGoogleMaps')}
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
|
|
@ -284,7 +287,7 @@ export default function JourneyInstructions({
|
|||
<div className="flex items-center gap-1.5 py-0.5">
|
||||
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
|
||||
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||
Walk · {j.minutes} min
|
||||
{t('areaPane.walk')} · {j.minutes} {t('common.min')}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
|
|
@ -293,7 +296,7 @@ export default function JourneyInstructions({
|
|||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
View on Google Maps
|
||||
{t('areaPane.viewOnGoogleMaps')}
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
|
|
@ -311,7 +314,7 @@ export default function JourneyInstructions({
|
|||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
No journey data available
|
||||
{t('areaPane.noJourneyData')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PostcodeGeometry } from '../../types';
|
||||
import { authHeaders } from '../../lib/api';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
|
||||
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
||||
import { LocateIcon } from '../ui/icons/LocateIcon';
|
||||
import { SearchIcon } from '../ui/icons/SearchIcon';
|
||||
|
||||
export interface SearchedLocation {
|
||||
|
|
@ -14,6 +16,7 @@ export interface SearchedLocation {
|
|||
const ZOOM_FOR_TYPE: Record<string, number> = {
|
||||
city: 10,
|
||||
borough: 12,
|
||||
outcode: 14,
|
||||
town: 13,
|
||||
suburb: 14,
|
||||
quarter: 14,
|
||||
|
|
@ -35,6 +38,7 @@ export default function LocationSearch({
|
|||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||
onMouseEnter?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const search = useLocationSearch();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -80,7 +84,7 @@ export default function LocationSearch({
|
|||
try {
|
||||
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
|
||||
if (!res.ok) {
|
||||
setError('Postcode not found');
|
||||
setError(t('locationSearch.postcodeNotFound'));
|
||||
return;
|
||||
}
|
||||
const json: {
|
||||
|
|
@ -94,7 +98,7 @@ export default function LocationSearch({
|
|||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
setError('Lookup failed');
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -102,17 +106,71 @@ export default function LocationSearch({
|
|||
[onFlyTo, onLocationSearched, isMobile, search]
|
||||
);
|
||||
|
||||
// Mobile collapsed state: just a search icon button
|
||||
const [locating, setLocating] = useState(false);
|
||||
|
||||
const locateUser = useCallback(async () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError(t('locationSearch.geolocationUnsupported'));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setLocating(true);
|
||||
search.close();
|
||||
try {
|
||||
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
const { latitude, longitude } = position.coords;
|
||||
const res = await fetch(
|
||||
`/api/nearest-postcode?lat=${latitude}&lng=${longitude}`,
|
||||
authHeaders()
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError(t('locationSearch.lookupFailed'));
|
||||
return;
|
||||
}
|
||||
const json: {
|
||||
postcode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
geometry: PostcodeGeometry;
|
||||
} = await res.json();
|
||||
onFlyTo(json.latitude, json.longitude, 16);
|
||||
onLocationSearched?.({ postcode: json.postcode, geometry: json.geometry });
|
||||
search.clear();
|
||||
if (isMobile) setExpanded(false);
|
||||
} catch {
|
||||
setError(t('locationSearch.geolocationFailed'));
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}, [onFlyTo, onLocationSearched, isMobile, search, t]);
|
||||
|
||||
// Mobile collapsed state: search icon + locate button
|
||||
if (isMobile && !expanded) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg pointer-events-auto"
|
||||
aria-label="Search places or postcodes"
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
|
||||
</button>
|
||||
<div className="flex gap-2 pointer-events-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg"
|
||||
aria-label={t('locationSearch.searchLabel')}
|
||||
>
|
||||
<SearchIcon className="w-5 h-5 text-warm-600 dark:text-warm-300" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={locateUser}
|
||||
disabled={locating}
|
||||
className="p-2 bg-white dark:bg-warm-800 rounded shadow-lg text-warm-600 dark:text-warm-300 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
|
||||
aria-label={t('locationSearch.locateMe')}
|
||||
>
|
||||
<LocateIcon className={`w-5 h-5 ${locating ? 'animate-pulse' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -129,12 +187,22 @@ export default function LocationSearch({
|
|||
search={search}
|
||||
onSelect={selectResult}
|
||||
loading={loading}
|
||||
placeholder="Search places or postcodes..."
|
||||
placeholder={t('locationSearch.placeholder')}
|
||||
size="sm"
|
||||
inputClassName="px-2 py-2 text-sm w-56 border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500"
|
||||
inputRef={inputRef}
|
||||
onInputChange={() => setError(null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={locateUser}
|
||||
disabled={locating}
|
||||
className="p-2 mr-0.5 rounded hover:bg-warm-100 dark:hover:bg-warm-700 text-warm-400 dark:text-warm-500 hover:text-teal-600 dark:hover:text-teal-400 disabled:opacity-50"
|
||||
aria-label={t('locationSearch.locateMe')}
|
||||
title={t('locationSearch.locateMe')}
|
||||
>
|
||||
<LocateIcon className={`w-4 h-4 ${locating ? 'animate-pulse' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
|
@ -28,7 +29,8 @@ import { LogoIcon } from '../ui/icons/LogoIcon';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import { useDeckLayers } from '../../hooks/useDeckLayers';
|
||||
import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { useTranslatedModes, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { ts } from '../../i18n/server';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
|
|
@ -57,6 +59,8 @@ interface MapProps {
|
|||
bounds?: Bounds | null;
|
||||
hideLegend?: boolean;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
densityLabel?: string;
|
||||
totalCount?: number;
|
||||
}
|
||||
|
||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
|
|
@ -113,8 +117,13 @@ export default memo(function Map({
|
|||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
densityLabel: densityLabelProp,
|
||||
totalCount: totalCountProp,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
const densityLabel = densityLabelProp ?? t('mapLegend.numberOfProperties');
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(
|
||||
initialViewState || INITIAL_VIEW_STATE
|
||||
);
|
||||
|
|
@ -271,7 +280,7 @@ export default memo(function Map({
|
|||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
|
||||
featureLabel={t('travel.travelTime', { mode: modes.label(viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit') })}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
|
|
@ -283,8 +292,8 @@ export default memo(function Map({
|
|||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
|
||||
: colorFeatureMeta.name
|
||||
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
|
||||
: ts(colorFeatureMeta.name)
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
|
|
@ -299,12 +308,15 @@ export default memo(function Map({
|
|||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Number of properties"
|
||||
featureLabel={densityLabel}
|
||||
range={
|
||||
usePostcodeView
|
||||
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||
: [countRange.min, countRange.max]
|
||||
}
|
||||
totalCount={
|
||||
totalCountProp ?? (usePostcodeView ? postcodeCountRange.total : countRange.total)
|
||||
}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="density"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { ts } from '../../i18n/server';
|
||||
import {
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
|
|
@ -20,7 +22,7 @@ function EnumSwatches({ values }: { values: string[] }) {
|
|||
className="w-3 h-3 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||
/>
|
||||
<span className="text-warm-600 dark:text-warm-300 truncate">{label}</span>
|
||||
<span className="text-warm-600 dark:text-warm-300 truncate">{ts(label)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -40,7 +42,7 @@ function InlineEnumSwatches({ values }: { values: string[] }) {
|
|||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||
/>
|
||||
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">
|
||||
{label}
|
||||
{ts(label)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -60,6 +62,7 @@ export default function MapLegend({
|
|||
inline = false,
|
||||
suffix,
|
||||
raw,
|
||||
totalCount,
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
|
|
@ -71,7 +74,9 @@ export default function MapLegend({
|
|||
inline?: boolean;
|
||||
suffix?: string;
|
||||
raw?: boolean;
|
||||
totalCount?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isEnum = enumValues && enumValues.length > 0;
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle =
|
||||
|
|
@ -103,7 +108,7 @@ export default function MapLegend({
|
|||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Clear colour view"
|
||||
title={t('mapLegend.clearColourView')}
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
|
@ -132,7 +137,7 @@ export default function MapLegend({
|
|||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
|
||||
title="Clear colour view"
|
||||
title={t('mapLegend.clearColourView')}
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -149,6 +154,14 @@ export default function MapLegend({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
{totalCount != null && (
|
||||
<div className="mt-2 pt-2 border-t border-warm-200 dark:border-warm-700 text-warm-600 dark:text-warm-300 flex items-center justify-between">
|
||||
<span>{t('common.total')}</span>
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">
|
||||
<TickerValue text={formatValue(totalCount)} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureFilters,
|
||||
|
|
@ -30,11 +31,12 @@ import { getTutorialStyles } from '../../lib/tutorial-styles';
|
|||
import Joyride from 'react-joyride';
|
||||
import {
|
||||
useTravelTime,
|
||||
MODE_LABELS,
|
||||
useTranslatedModes,
|
||||
travelFieldKey,
|
||||
type TravelTimeInitial,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
|
|
@ -67,7 +69,7 @@ interface MapPageProps {
|
|||
isMobile?: boolean;
|
||||
initialTravelTime?: TravelTimeInitial;
|
||||
initialPostcode?: string;
|
||||
user?: { id: string; subscription: string } | null;
|
||||
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
|
||||
onLoginClick?: () => void;
|
||||
onRegisterClick?: () => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
|
|
@ -127,6 +129,9 @@ export default function MapPage({
|
|||
[onSaveProperty]
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
|
||||
const {
|
||||
filters,
|
||||
activeFeature,
|
||||
|
|
@ -235,6 +240,8 @@ export default function MapPage({
|
|||
travelTimeEntries: travelTime.entries,
|
||||
});
|
||||
|
||||
const filterCounts = useFilterCounts(filters, features, mapData.bounds);
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
(index: number, slug: string, label: string, lat: number, lon: number) => {
|
||||
travelTime.handleSetDestination(index, slug, label);
|
||||
|
|
@ -430,6 +437,13 @@ export default function MapPage({
|
|||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
const densityLabel = useMemo(() => {
|
||||
const listingVal = filters['Listing status'] as string[] | undefined;
|
||||
if (listingVal?.includes('For sale')) return 'Properties for sale';
|
||||
if (listingVal?.includes('For rent')) return 'Properties for rent';
|
||||
return 'Historical property matches';
|
||||
}, [filters]);
|
||||
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
|
|
@ -616,8 +630,10 @@ export default function MapPage({
|
|||
isLoggedIn={!!user}
|
||||
onLoginRequired={onRegisterClick ?? (() => {})}
|
||||
isLicensed={user?.subscription === 'licensed'}
|
||||
isAdmin={user?.isAdmin === true}
|
||||
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||
onResetTutorial={tutorial.resetTutorial}
|
||||
filterImpacts={filterCounts.impacts}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -692,7 +708,7 @@ export default function MapPage({
|
|||
{viewFeature && mapData.colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
|
||||
featureLabel={t('travel.travelTime', { mode: modes.label(viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit') })}
|
||||
range={mapData.colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={handleCancelPin}
|
||||
|
|
@ -720,7 +736,7 @@ export default function MapPage({
|
|||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Number of properties"
|
||||
featureLabel={densityLabel}
|
||||
range={mobileDensityRange}
|
||||
showCancel={false}
|
||||
onCancel={handleCancelPin}
|
||||
|
|
@ -795,10 +811,14 @@ export default function MapPage({
|
|||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">{renderFilters()}</div>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...leftPaneHandlers}
|
||||
>
|
||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -827,6 +847,7 @@ export default function MapPage({
|
|||
onLocationSearched={handleLocationSearchResult}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
densityLabel={densityLabel}
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
|
|
@ -862,10 +883,14 @@ export default function MapPage({
|
|||
style={{ width: rightPaneWidth }}
|
||||
>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...rightPaneHandlers}
|
||||
>
|
||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ export default function MobileDrawer({
|
|||
tab,
|
||||
onTabChange,
|
||||
}: MobileDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
@ -35,16 +37,16 @@ export default function MobileDrawer({
|
|||
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
|
||||
{/* Tab bar + close */}
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
|
||||
<TabButton label="Area" isActive={tab === 'area'} onClick={() => onTabChange('area')} />
|
||||
<TabButton label={t('common.area')} isActive={tab === 'area'} onClick={() => onTabChange('area')} />
|
||||
<TabButton
|
||||
label="Properties"
|
||||
label={t('common.properties')}
|
||||
isActive={tab === 'properties'}
|
||||
onClick={() => onTabChange('properties')}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto flex items-center justify-center w-10 h-10 rounded-lg hover:bg-warm-100 dark:hover:bg-navy-800"
|
||||
aria-label="Close drawer"
|
||||
aria-label={t('mobileDrawer.closeDrawer')}
|
||||
>
|
||||
<CloseIcon className="w-5 h-5 text-warm-500 dark:text-warm-400" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import type { POICategoryGroup } from '../../types';
|
||||
|
|
@ -26,6 +28,7 @@ export default function POIPane({
|
|||
onNavigateToSource,
|
||||
onClose,
|
||||
}: POIPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isGroupExpanded, toggleCollapse] = useCollapsibleGroups();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
|
@ -90,12 +93,12 @@ export default function POIPane({
|
|||
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
POIs
|
||||
{t('poiPane.pois')}
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
{selectedCount}/{allCategories.length}
|
||||
</span>
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||
<IconButton onClick={() => setShowInfo(true)} title={t('poiPane.dataSourceInfo')}>
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
<div className="flex gap-1 ml-auto items-center">
|
||||
|
|
@ -103,19 +106,19 @@ export default function POIPane({
|
|||
onClick={selectAll}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
All
|
||||
{t('common.all')}
|
||||
</button>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
None
|
||||
{t('common.none')}
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Close"
|
||||
title={t('common.close')}
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -125,12 +128,12 @@ export default function POIPane({
|
|||
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title="Points of Interest"
|
||||
title={t('poiPane.pointsOfInterest')}
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
label: t('common.viewDataSource'),
|
||||
onClick: () => {
|
||||
onNavigateToSource('osm-pois');
|
||||
setShowInfo(false);
|
||||
|
|
@ -140,8 +143,7 @@ export default function POIPane({
|
|||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants,
|
||||
healthcare, leisure, and more. Updated regularly with complete category coverage.
|
||||
{t('poiPane.poiDescription')}
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
@ -152,7 +154,7 @@ export default function POIPane({
|
|||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
placeholder={t('poiPane.searchCategories')}
|
||||
/>
|
||||
</div>
|
||||
{filteredGroups.map((group) => {
|
||||
|
|
@ -171,7 +173,7 @@ export default function POIPane({
|
|||
<ChevronIcon direction="right" className="w-3 h-3" />
|
||||
</button>
|
||||
<PillToggle
|
||||
label={group.name}
|
||||
label={ts(group.name)}
|
||||
active={allInGroupSelected}
|
||||
indeterminate={someInGroupSelected}
|
||||
onClick={() => toggleGroup(group.name)}
|
||||
|
|
@ -187,7 +189,7 @@ export default function POIPane({
|
|||
{group.categories.map((category) => (
|
||||
<PillToggle
|
||||
key={category}
|
||||
label={category}
|
||||
label={ts(category)}
|
||||
active={selectedCategories.has(category)}
|
||||
onClick={() => toggleCategory(category)}
|
||||
size="xs"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Property } from '../../types';
|
||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
||||
import { getNum } from '../../lib/property-fields';
|
||||
|
|
@ -7,6 +8,7 @@ import { SearchInput } from '../ui/SearchInput';
|
|||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { InfoIcon } from '../ui/icons';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
import { ts } from '../../i18n/server';
|
||||
|
||||
interface PropertiesPaneProps {
|
||||
properties: Property[];
|
||||
|
|
@ -33,6 +35,7 @@ export function PropertiesPane({
|
|||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
}: PropertiesPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
|
|
@ -51,8 +54,8 @@ export function PropertiesPane({
|
|||
return (
|
||||
<EmptyState
|
||||
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title="No area selected"
|
||||
description="Click any coloured area on the map to see crime, schools, prices, and more"
|
||||
title={t('common.noAreaSelected')}
|
||||
description={t('common.noAreaSelectedDesc')}
|
||||
centered
|
||||
/>
|
||||
);
|
||||
|
|
@ -62,12 +65,12 @@ export function PropertiesPane({
|
|||
<div className="h-full overflow-y-auto">
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title="Property Data"
|
||||
title={t('propertyCard.propertyData')}
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
label: t('common.viewDataSource'),
|
||||
onClick: () => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
|
|
@ -77,9 +80,7 @@ export function PropertiesPane({
|
|||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Prices come from HM Land Registry (what buyers actually paid). Floor area, energy
|
||||
ratings, construction year, and tenure come from official EPC surveys. Both sources are
|
||||
matched by address within each postcode.
|
||||
{t('propertyCard.propertyDataDesc')}
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
@ -88,7 +89,7 @@ export function PropertiesPane({
|
|||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by address or postcode..."
|
||||
placeholder={t('propertyCard.searchPlaceholder')}
|
||||
className="p-2"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -117,10 +118,10 @@ export function PropertiesPane({
|
|||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
Loading...
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
`Load More (${total - properties.length} remaining)`
|
||||
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -163,6 +164,7 @@ function PropertyCard({
|
|||
isSaved?: boolean;
|
||||
savedId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleToggleSave = useCallback(() => {
|
||||
if (isSaved && savedId && onUnsave) {
|
||||
onUnsave(savedId);
|
||||
|
|
@ -189,7 +191,7 @@ function PropertyCard({
|
|||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
{property.address || t('propertyCard.unknownAddress')}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
</div>
|
||||
|
|
@ -201,7 +203,7 @@ function PropertyCard({
|
|||
? 'text-teal-600 dark:text-teal-400'
|
||||
: 'text-warm-300 dark:text-warm-600 hover:text-warm-500 dark:hover:text-warm-400'
|
||||
}`}
|
||||
title={isSaved ? 'Unsave property' : 'Save property'}
|
||||
title={isSaved ? t('propertyCard.unsaveProperty') : t('propertyCard.saveProperty')}
|
||||
>
|
||||
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
|
||||
</button>
|
||||
|
|
@ -228,7 +230,7 @@ function PropertyCard({
|
|||
{askingRent !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(askingRent)}
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">/mo</span>
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">{t('propertyCard.perMonth')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -238,7 +240,7 @@ function PropertyCard({
|
|||
>
|
||||
{askingPrice !== undefined || askingRent !== undefined ? (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
Last sold: £{formatNumber(price)}
|
||||
{t('propertyCard.lastSold', { price: formatNumber(price) })}
|
||||
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -262,7 +264,7 @@ function PropertyCard({
|
|||
)}
|
||||
{estimatedPrice !== undefined && (
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">
|
||||
Est. value:{' '}
|
||||
{t('propertyCard.estValue')}{' '}
|
||||
<span className="font-semibold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(estimatedPrice)}
|
||||
</span>
|
||||
|
|
@ -273,65 +275,65 @@ function PropertyCard({
|
|||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
|
||||
{property.property_type && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Type:</span> {property.property_type}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.type')}</span> {ts(property.property_type)}
|
||||
</div>
|
||||
)}
|
||||
{property.built_form && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Built form:</span>{' '}
|
||||
{property.built_form}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.builtForm')}</span>{' '}
|
||||
{ts(property.built_form)}
|
||||
</div>
|
||||
)}
|
||||
{property.duration && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Tenure:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.tenure')}</span>{' '}
|
||||
{formatDuration(property.duration)}
|
||||
</div>
|
||||
)}
|
||||
{floorArea !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Floor area:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.floorArea')}</span>{' '}
|
||||
{formatNumber(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{bedrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Bedrooms:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bedrooms')}</span>{' '}
|
||||
{formatNumber(bedrooms)}
|
||||
</div>
|
||||
)}
|
||||
{bathrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Bathrooms:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bathrooms')}</span>{' '}
|
||||
{formatNumber(bathrooms)}
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span> {formatNumber(rooms)}
|
||||
</div>
|
||||
)}
|
||||
{age !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Built:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.built')}</span>{' '}
|
||||
{formatAge(age, property.is_construction_date_approximate)}
|
||||
</div>
|
||||
)}
|
||||
{property.current_energy_rating && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">EPC rating:</span>{' '}
|
||||
{property.current_energy_rating}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcRating')}</span>{' '}
|
||||
{ts(property.current_energy_rating)}
|
||||
</div>
|
||||
)}
|
||||
{property.potential_energy_rating && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span>{' '}
|
||||
{property.potential_energy_rating}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcPotential')}</span>{' '}
|
||||
{ts(property.potential_energy_rating)}
|
||||
</div>
|
||||
)}
|
||||
{listingDate !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Listed:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.listed')}</span>{' '}
|
||||
{formatTransactionDate(listingDate)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -339,7 +341,7 @@ function PropertyCard({
|
|||
|
||||
{property.listing_features && property.listing_features.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Key features</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">{t('propertyCard.keyFeatures')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.listing_features.map((feature, idx) => (
|
||||
<span
|
||||
|
|
@ -355,7 +357,7 @@ function PropertyCard({
|
|||
|
||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Renovations</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">{t('propertyCard.renovations')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.renovation_history.map((reno, idx) => (
|
||||
<span
|
||||
|
|
@ -378,7 +380,7 @@ function PropertyCard({
|
|||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
View external listing →
|
||||
{t('propertyCard.viewExternalListing')} →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ interface StreetViewEmbedProps {
|
|||
type Status = 'loading' | 'ok' | 'none' | 'error';
|
||||
|
||||
export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<Status>('loading');
|
||||
const [panoId, setPanoId] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
|
|||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
|
||||
Street View
|
||||
{t('streetView.title')}
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
|
|
@ -8,9 +9,9 @@ import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
|
|||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { InfoIcon } from '../ui/icons/InfoIcon';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
import { formatFilterValue, formatNumber } from '../../lib/format';
|
||||
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
|
||||
import { MODE_LABELS, MODE_ICONS, type TransportMode } from '../../hooks/useTravelTime';
|
||||
import { MODE_ICONS, useTranslatedModes, type TransportMode } from '../../hooks/useTravelTime';
|
||||
|
||||
interface TravelTimeCardProps {
|
||||
mode: TransportMode;
|
||||
|
|
@ -29,6 +30,7 @@ interface TravelTimeCardProps {
|
|||
onDragEnd: () => void;
|
||||
onToggleBest: () => void;
|
||||
onRemove: () => void;
|
||||
filterImpact?: number;
|
||||
}
|
||||
|
||||
export function TravelTimeCard({
|
||||
|
|
@ -48,7 +50,10 @@ export function TravelTimeCard({
|
|||
onDragEnd,
|
||||
onToggleBest,
|
||||
onRemove,
|
||||
filterImpact,
|
||||
}: TravelTimeCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const [showBestInfo, setShowBestInfo] = useState(false);
|
||||
|
|
@ -75,23 +80,23 @@ export function TravelTimeCard({
|
|||
<div className="flex items-center gap-1.5">
|
||||
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
Travel Time ({MODE_LABELS[mode]})
|
||||
{t('travel.travelTime', { mode: modes.label(mode) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
|
||||
<IconButton onClick={() => setShowInfo(true)} title={t('filters.featureInfo')}>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
{slug && (
|
||||
<IconButton
|
||||
onClick={onTogglePin}
|
||||
active={isPinned}
|
||||
title={isPinned ? 'Stop previewing' : 'Preview on map'}
|
||||
title={isPinned ? t('travel.stopPreviewing') : t('travel.previewOnMap')}
|
||||
>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onRemove()} title="Remove travel time">
|
||||
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
|
@ -104,14 +109,14 @@ export function TravelTimeCard({
|
|||
onSelect={handleDestinationSelect}
|
||||
value={label || undefined}
|
||||
onClear={() => onSetDestination('', '', 0, 0)}
|
||||
placeholder="Select destination..."
|
||||
placeholder={t('travel.selectDestination')}
|
||||
/>
|
||||
|
||||
{/* Best-case toggle — transit only, shown when destination is set */}
|
||||
{slug && mode === 'transit' && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PillToggle label="Best case" active={useBest} onClick={onToggleBest} size="xs" />
|
||||
<IconButton onClick={() => setShowBestInfo(true)} title="What is best case?">
|
||||
<PillToggle label={t('travel.bestCase')} active={useBest} onClick={onToggleBest} size="xs" />
|
||||
<IconButton onClick={() => setShowBestInfo(true)} title={t('travel.bestCaseTitle')}>
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
|
@ -120,12 +125,11 @@ export function TravelTimeCard({
|
|||
{showInfo && <TravelTimeInfoPopup mode={mode} onClose={() => setShowInfo(false)} />}
|
||||
|
||||
{showBestInfo && (
|
||||
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Uses the fastest realistic journey time (if you time your departure well and catch good
|
||||
connections). The default uses the <strong>median</strong>, representing a typical journey
|
||||
regardless of when you leave.
|
||||
</p>
|
||||
<InfoPopup title={t('travel.bestCaseTitle')} onClose={() => setShowBestInfo(false)}>
|
||||
<p
|
||||
className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: t('travel.bestCaseDesc') }}
|
||||
/>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
|
|
@ -133,7 +137,7 @@ export function TravelTimeCard({
|
|||
{slug && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
Max time
|
||||
{t('travel.maxTime')}
|
||||
</span>
|
||||
<Slider
|
||||
min={sliderMin}
|
||||
|
|
@ -145,9 +149,14 @@ export function TravelTimeCard({
|
|||
onPointerUp={() => onDragEnd()}
|
||||
/>
|
||||
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span>
|
||||
<span className="absolute right-0">{formatFilterValue(displayRange[1])} min</span>
|
||||
<span className="absolute left-0">{formatFilterValue(displayRange[0])} {t('common.min')}</span>
|
||||
<span className="absolute right-0">{formatFilterValue(displayRange[1])} {t('common.min')}</span>
|
||||
</div>
|
||||
{filterImpact != null && filterImpact > 0 && (
|
||||
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
|
||||
+{formatNumber(filterImpact)} without this filter
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
|
|
@ -7,14 +8,7 @@ import { logNonAbortError } from '../../lib/api';
|
|||
import { trackEvent } from '../../lib/analytics';
|
||||
import { apiUrl } from '../../lib/api';
|
||||
|
||||
const FEATURES = [
|
||||
'56 data layers across England',
|
||||
'Every postcode scored and filterable',
|
||||
'Unlimited map exploration and exports',
|
||||
'Multiple decades of historical price data',
|
||||
'Crime, schools, transport, broadband and more',
|
||||
'All future data updates included',
|
||||
];
|
||||
// Feature list keys — resolved inside the component via t()
|
||||
|
||||
interface PricingTier {
|
||||
up_to: number | null;
|
||||
|
|
@ -28,17 +22,10 @@ interface PricingData {
|
|||
tiers: PricingTier[];
|
||||
}
|
||||
|
||||
function formatPrice(pence: number): string {
|
||||
if (pence === 0) return 'Free';
|
||||
function formatPricePence(pence: number): string {
|
||||
return `\u00A3${pence / 100}`;
|
||||
}
|
||||
|
||||
function tierLabel(tier: PricingTier, index: number): string {
|
||||
if (index === 0) return `First ${tier.slots} users`;
|
||||
if (tier.up_to === null) return 'Everyone after';
|
||||
return `Next ${tier.slots} users`;
|
||||
}
|
||||
|
||||
export default function PricingPage({
|
||||
onOpenDashboard,
|
||||
user,
|
||||
|
|
@ -50,6 +37,7 @@ export default function PricingPage({
|
|||
onLoginClick?: () => void;
|
||||
onRegisterClick?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const license = useLicense();
|
||||
const [pricing, setPricing] = useState<PricingData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -109,7 +97,7 @@ export default function PricingPage({
|
|||
onClick={onOpenDashboard}
|
||||
className="w-full mt-auto px-5 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
Open dashboard
|
||||
{t('pricingPage.openDashboard')}
|
||||
</button>
|
||||
) : user ? (
|
||||
<button
|
||||
|
|
@ -119,17 +107,17 @@ export default function PricingPage({
|
|||
>
|
||||
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{license.checkingOut
|
||||
? 'Redirecting...'
|
||||
? t('upgrade.redirecting')
|
||||
: isFree
|
||||
? 'Claim free access'
|
||||
: `Get started - ${formatPrice(currentPrice)}`}
|
||||
? t('upgrade.claimFreeAccess')
|
||||
: t('pricingPage.getStartedPrice', { price: formatPricePence(currentPrice) })}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
{isFree ? 'Claim free access' : 'Get started'}
|
||||
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
|
||||
</button>
|
||||
);
|
||||
|
||||
|
|
@ -194,20 +182,18 @@ export default function PricingPage({
|
|||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-6">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">Early access pricing</h1>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">{t('pricingPage.title')}</h1>
|
||||
<p className="text-lg text-warm-300 max-w-lg mx-auto">
|
||||
Pay once, access forever. The earlier you join, the less you pay.
|
||||
{t('pricingPage.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
|
||||
<p className="text-warm-400 text-sm leading-relaxed mb-2">
|
||||
Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500
|
||||
for a survey. Get the wrong area and you're stuck with a long commute, bad schools,
|
||||
or a road you didn't know about.
|
||||
{t('pricingPage.costContext')}
|
||||
</p>
|
||||
<p className="text-warm-200 font-semibold">
|
||||
Less than a home survey. Far more useful.
|
||||
{t('pricingPage.lessThanSurvey')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -267,7 +253,7 @@ export default function PricingPage({
|
|||
>
|
||||
{isCurrent && (
|
||||
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
|
||||
Current tier
|
||||
{t('pricingPage.currentTier')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -283,7 +269,11 @@ export default function PricingPage({
|
|||
isCurrent ? 'text-teal-300' : 'text-warm-500 dark:text-warm-400'
|
||||
}`}
|
||||
>
|
||||
{tierLabel(tier, i)}
|
||||
{i === 0
|
||||
? t('pricingPage.firstNUsers', { count: tier.slots })
|
||||
: tier.up_to === null
|
||||
? t('pricingPage.everyoneAfter')
|
||||
: t('pricingPage.nextNUsers', { count: tier.slots })}
|
||||
</p>
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span
|
||||
|
|
@ -295,7 +285,7 @@ export default function PricingPage({
|
|||
: 'text-navy-950 dark:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{formatPrice(tier.price_pence)}
|
||||
{tier.price_pence === 0 ? t('upgrade.free') : formatPricePence(tier.price_pence)}
|
||||
</span>
|
||||
{tier.price_pence > 0 && (
|
||||
<span
|
||||
|
|
@ -303,20 +293,21 @@ export default function PricingPage({
|
|||
isCurrent ? 'text-warm-400' : 'text-warm-400 dark:text-warm-500'
|
||||
}`}
|
||||
>
|
||||
/lifetime
|
||||
{t('pricingPage.lifetime')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCurrent && spotsRemaining > 0 && (
|
||||
<p className="text-teal-300 text-sm mt-2 font-medium">
|
||||
{spotsRemaining} spot
|
||||
{spotsRemaining !== 1 ? 's' : ''} remaining
|
||||
{spotsRemaining === 1
|
||||
? t('pricingPage.spotsRemaining', { count: spotsRemaining })
|
||||
: t('pricingPage.spotsRemainingPlural', { count: spotsRemaining })}
|
||||
</p>
|
||||
)}
|
||||
{isFilled && (
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
|
||||
<CheckIcon className="w-4 h-4" /> Filled
|
||||
<CheckIcon className="w-4 h-4" /> {t('pricingPage.filled')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -330,10 +321,10 @@ export default function PricingPage({
|
|||
|
||||
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
|
||||
<ul className="space-y-3 mb-6 flex-1">
|
||||
{FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5 text-sm">
|
||||
{[t('pricingPage.feat1'), t('pricingPage.feat2'), t('pricingPage.feat3'), t('pricingPage.feat4'), t('pricingPage.feat5'), t('pricingPage.feat6')].map((feat, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2.5 text-sm">
|
||||
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
|
||||
<span className="text-warm-700 dark:text-warm-300">{feature}</span>
|
||||
<span className="text-warm-700 dark:text-warm-300">{feat}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -347,16 +338,16 @@ export default function PricingPage({
|
|||
</p>
|
||||
)}
|
||||
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||
{isFree ? 'No credit card required' : '30-day money-back guarantee'}
|
||||
{isFree ? t('pricingPage.noCreditCard') : t('pricingPage.moneyBackGuarantee')}
|
||||
</p>
|
||||
</>
|
||||
) : isFilled ? (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
|
||||
Sold out
|
||||
{t('pricingPage.soldOut')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
|
||||
Upcoming
|
||||
{t('pricingPage.upcoming')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -367,7 +358,7 @@ export default function PricingPage({
|
|||
</div>
|
||||
) : (
|
||||
<p className="text-center text-warm-400 py-16">
|
||||
Failed to load pricing. Please try again later.
|
||||
{t('pricingPage.failedToLoad')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
|
|
@ -26,6 +27,7 @@ export default function AuthModal({
|
|||
onClearError: () => void;
|
||||
initialTab?: 'login' | 'register';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [view, setView] = useState<View>(initialTab);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
|
@ -78,7 +80,11 @@ export default function AuthModal({
|
|||
);
|
||||
|
||||
const title =
|
||||
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
|
||||
view === 'login'
|
||||
? t('auth.logIn')
|
||||
: view === 'register'
|
||||
? t('auth.createAccount')
|
||||
: t('auth.resetPassword');
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -111,7 +117,7 @@ export default function AuthModal({
|
|||
}`}
|
||||
onClick={() => switchView('login')}
|
||||
>
|
||||
Log in
|
||||
{t('auth.logIn')}
|
||||
</button>
|
||||
<button
|
||||
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
|
|
@ -121,7 +127,7 @@ export default function AuthModal({
|
|||
}`}
|
||||
onClick={() => switchView('register')}
|
||||
>
|
||||
Create account
|
||||
{t('auth.createAccount')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -130,7 +136,7 @@ export default function AuthModal({
|
|||
{/* Value prop */}
|
||||
{view !== 'forgot' && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 text-center">
|
||||
Save searches, bookmark properties, and pick up where you left off.
|
||||
{t('auth.valueProp')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -145,14 +151,14 @@ export default function AuthModal({
|
|||
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-100 text-sm font-medium hover:bg-warm-50 dark:hover:bg-warm-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
<GoogleIcon className="w-4 h-4" />
|
||||
Continue with Google
|
||||
{t('auth.continueWithGoogle')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">or</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">{t('common.or')}</span>
|
||||
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -162,7 +168,7 @@ export default function AuthModal({
|
|||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Email
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
|
|
@ -170,14 +176,14 @@ export default function AuthModal({
|
|||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{view !== 'forgot' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Password
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
|
|
@ -186,7 +192,7 @@ export default function AuthModal({
|
|||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
|
||||
placeholder={view === 'register' ? t('auth.passwordPlaceholderRegister') : t('auth.passwordPlaceholderLogin')}
|
||||
/>
|
||||
{view === 'login' && (
|
||||
<button
|
||||
|
|
@ -194,7 +200,7 @@ export default function AuthModal({
|
|||
onClick={() => switchView('forgot')}
|
||||
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Forgot password?
|
||||
{t('auth.forgotPassword')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -202,7 +208,7 @@ export default function AuthModal({
|
|||
|
||||
{view === 'forgot' && resetSent && (
|
||||
<p className="text-sm text-teal-700 dark:text-teal-400">
|
||||
Check your email for a reset link.
|
||||
{t('auth.resetSent')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -215,12 +221,12 @@ export default function AuthModal({
|
|||
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
||||
>
|
||||
{loading
|
||||
? 'Please wait...'
|
||||
? t('auth.pleaseWait')
|
||||
: view === 'login'
|
||||
? 'Log in'
|
||||
? t('auth.logIn')
|
||||
: view === 'register'
|
||||
? 'Create account'
|
||||
: 'Send reset link'}
|
||||
? t('auth.createAccount')
|
||||
: t('auth.sendResetLink')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
@ -230,7 +236,7 @@ export default function AuthModal({
|
|||
onClick={() => switchView('login')}
|
||||
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Back to login
|
||||
{t('auth.backToLogin')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ts } from '../../i18n/server';
|
||||
import { ChevronIcon } from './icons/ChevronIcon';
|
||||
|
||||
interface CollapsibleGroupHeaderProps {
|
||||
|
|
@ -20,7 +21,7 @@ export function CollapsibleGroupHeader({
|
|||
onClick={onToggle}
|
||||
className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}
|
||||
>
|
||||
<span>{name}</span>
|
||||
<span>{ts(name)}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-4 h-4" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Destination } from '../../hooks/useTravelDestinations';
|
||||
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
|
||||
|
|
@ -21,8 +22,9 @@ export function DestinationDropdown({
|
|||
onSelect,
|
||||
onClear,
|
||||
value,
|
||||
placeholder = 'Select destination...',
|
||||
placeholder,
|
||||
}: DestinationDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
|
@ -129,7 +131,7 @@ export function DestinationDropdown({
|
|||
setActiveIndex(-1);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type to filter..."
|
||||
placeholder={t('travel.typeToFilter')}
|
||||
className="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -138,7 +140,7 @@ export function DestinationDropdown({
|
|||
<div ref={listRef} className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-warm-400 dark:text-warm-500 text-center">
|
||||
{loading ? 'Loading...' : 'No destinations found'}
|
||||
{loading ? t('common.loading') : t('travel.noDestinations')}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((dest, idx) => (
|
||||
|
|
@ -190,7 +192,7 @@ export function DestinationDropdown({
|
|||
<span
|
||||
className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}
|
||||
>
|
||||
{value || placeholder}
|
||||
{value || placeholder || t('travel.selectDestination')}
|
||||
</span>
|
||||
</button>
|
||||
{value && onClear ? (
|
||||
|
|
@ -198,7 +200,7 @@ export function DestinationDropdown({
|
|||
type="button"
|
||||
onClick={onClear}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Clear destination"
|
||||
title={t('travel.clearDestination')}
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
import { ts, tsDesc } from '../../i18n/server';
|
||||
import InfoPopup from './InfoPopup';
|
||||
|
||||
interface FeatureInfoPopupProps {
|
||||
|
|
@ -8,14 +10,15 @@ interface FeatureInfoPopupProps {
|
|||
}
|
||||
|
||||
export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: FeatureInfoPopupProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<InfoPopup
|
||||
title={feature.name}
|
||||
title={ts(feature.name)}
|
||||
onClose={onClose}
|
||||
sourceLink={
|
||||
feature.source && onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
label: t('common.viewDataSource'),
|
||||
onClick: () => {
|
||||
onNavigateToSource(feature.source!, feature.name);
|
||||
onClose();
|
||||
|
|
@ -25,7 +28,9 @@ export function FeatureInfoPopup({ feature, onClose, onNavigateToSource }: Featu
|
|||
}
|
||||
>
|
||||
{feature.description && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">{feature.description}</p>
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-2">
|
||||
{tsDesc(feature.name, feature.description)}
|
||||
</p>
|
||||
)}
|
||||
{feature.detail && (
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
import { ts, tsDesc } from '../../i18n/server';
|
||||
import { InfoIcon } from './icons';
|
||||
import { getFeatureIcon } from '../../lib/feature-icons';
|
||||
import { getGroupIcon } from '../../lib/group-icons';
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
historical: 'Historical',
|
||||
buy: 'Buy',
|
||||
rent: 'Rent',
|
||||
};
|
||||
|
||||
interface FeatureLabelProps {
|
||||
feature: FeatureMeta;
|
||||
onShowInfo?: (feature: FeatureMeta) => void;
|
||||
|
|
@ -26,22 +22,31 @@ export function FeatureLabel({
|
|||
description,
|
||||
hideIconOnMobile,
|
||||
}: FeatureLabelProps) {
|
||||
const { t } = useTranslation();
|
||||
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
|
||||
const mobileHide = hideIconOnMobile ? 'hidden md:block ' : '';
|
||||
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
|
||||
const featureIcon = getFeatureIcon(feature.name, iconClass);
|
||||
const GroupIcon = !featureIcon && feature.group ? getGroupIcon(feature.group) : null;
|
||||
const modeLabels: Record<string, string> = {
|
||||
historical: t('filters.historical'),
|
||||
buy: t('filters.buy'),
|
||||
rent: t('filters.rent'),
|
||||
};
|
||||
const modeTag =
|
||||
feature.modes && feature.modes.length > 0
|
||||
? feature.modes.map((m) => MODE_LABELS[m] || m).join(' · ')
|
||||
? feature.modes.map((m) => modeLabels[m] || m).join(' \u00B7 ')
|
||||
: null;
|
||||
|
||||
const translatedName = ts(feature.name);
|
||||
const translatedDesc = description ? tsDesc(feature.name, description) : undefined;
|
||||
|
||||
const nameContent = (
|
||||
<>
|
||||
<span
|
||||
className={`${textClass} ${size === 'sm' ? 'font-medium text-navy-950 dark:text-warm-100' : 'text-warm-700 dark:text-warm-300 truncate'}`}
|
||||
>
|
||||
{feature.name}
|
||||
{translatedName}
|
||||
</span>
|
||||
{modeTag && (
|
||||
<span className="shrink-0 text-[10px] leading-none font-medium px-1.5 py-0.5 rounded-full bg-warm-100 dark:bg-warm-800 text-warm-500 dark:text-warm-400 border border-warm-200 dark:border-warm-700">
|
||||
|
|
@ -52,7 +57,7 @@ export function FeatureLabel({
|
|||
<button
|
||||
onClick={() => onShowInfo(feature)}
|
||||
className="p-1 -m-0.5 rounded text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 hover:bg-warm-100 dark:hover:bg-warm-700 shrink-0"
|
||||
title="Feature info"
|
||||
title={t('filters.featureInfo')}
|
||||
>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
|
@ -66,10 +71,10 @@ export function FeatureLabel({
|
|||
>
|
||||
{featureIcon}
|
||||
{GroupIcon && <GroupIcon className={iconClass} />}
|
||||
{description ? (
|
||||
{translatedDesc ? (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">{nameContent}</div>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">{description}</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">{translatedDesc}</span>
|
||||
</div>
|
||||
) : (
|
||||
nameContent
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { shortenUrl, prewarmScreenshot } from '../../lib/api';
|
||||
import { copyToClipboard } from '../../lib/clipboard';
|
||||
|
|
@ -13,6 +14,7 @@ import { MoonIcon } from './icons/MoonIcon';
|
|||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
import UserMenu from './UserMenu';
|
||||
import MobileMenu from './MobileMenu';
|
||||
import LanguageDropdown from './LanguageDropdown';
|
||||
|
||||
export type Page =
|
||||
| 'home'
|
||||
|
|
@ -64,6 +66,7 @@ export default function Header({
|
|||
onLogout: () => void;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
|
@ -131,7 +134,7 @@ export default function Header({
|
|||
onClick={(e) => navLink('home', e)}
|
||||
>
|
||||
<LogoIcon className="w-5 h-5 text-teal-400" />
|
||||
<span className="font-semibold text-lg">Perfect Postcode</span>
|
||||
<span className="font-semibold text-lg">{t('header.appName')}</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
|
|
@ -142,7 +145,7 @@ export default function Header({
|
|||
className={tabClass('dashboard')}
|
||||
onClick={(e) => navLink('dashboard', e)}
|
||||
>
|
||||
Dashboard
|
||||
{t('header.dashboard')}
|
||||
</a>
|
||||
{user && (
|
||||
<a
|
||||
|
|
@ -150,7 +153,7 @@ export default function Header({
|
|||
className={tabClass('invites')}
|
||||
onClick={(e) => navLink('invites', e)}
|
||||
>
|
||||
Invite Friends
|
||||
{t('header.inviteFriends')}
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
|
|
@ -158,7 +161,7 @@ export default function Header({
|
|||
className={tabClass('learn')}
|
||||
onClick={(e) => navLink('learn', e)}
|
||||
>
|
||||
Learn
|
||||
{t('header.learn')}
|
||||
</a>
|
||||
{user?.subscription !== 'licensed' && !user?.isAdmin && (
|
||||
<a
|
||||
|
|
@ -166,7 +169,7 @@ export default function Header({
|
|||
className={tabClass('pricing')}
|
||||
onClick={(e) => navLink('pricing', e)}
|
||||
>
|
||||
Pricing
|
||||
{t('header.pricing')}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
|
|
@ -186,17 +189,17 @@ export default function Header({
|
|||
{sharing ? (
|
||||
<>
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
Sharing...
|
||||
{t('header.sharing')}
|
||||
</>
|
||||
) : copied ? (
|
||||
<>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
Copied!
|
||||
{t('common.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ClipboardIcon className="w-4 h-4" />
|
||||
Share
|
||||
{t('common.share')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -204,10 +207,10 @@ export default function Header({
|
|||
onClick={onExport ?? undefined}
|
||||
disabled={!onExport || exporting}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50 disabled:cursor-wait"
|
||||
title="Export to Excel"
|
||||
title={t('header.exportToExcel')}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
{exporting ? t('header.exporting') : t('header.exportLabel')}
|
||||
</button>
|
||||
{onSaveSearch && (
|
||||
<button
|
||||
|
|
@ -220,7 +223,7 @@ export default function Header({
|
|||
) : (
|
||||
<BookmarkIcon className="w-4 h-4" />
|
||||
)}
|
||||
Save
|
||||
{t('common.save')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -231,7 +234,7 @@ export default function Header({
|
|||
className={tabClass('saved')}
|
||||
onClick={(e) => navLink('saved', e)}
|
||||
>
|
||||
Saved
|
||||
{t('header.saved')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
|
|
@ -252,13 +255,13 @@ export default function Header({
|
|||
onClick={onLoginClick}
|
||||
className="px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
Log in
|
||||
{t('header.logIn')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Create account
|
||||
{t('header.createAccount')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -271,16 +274,19 @@ export default function Header({
|
|||
onClick={onRegisterClick}
|
||||
className="px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold"
|
||||
>
|
||||
Create account
|
||||
{t('header.createAccount')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Language selector (desktop) */}
|
||||
{!isMobile && <LanguageDropdown />}
|
||||
|
||||
{/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */}
|
||||
{!isMobile && !user && (
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="flex items-center justify-center w-8 h-8 rounded bg-navy-800 hover:bg-navy-700 transition-colors"
|
||||
title={`Theme: ${theme}`}
|
||||
title={theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
|
||||
>
|
||||
{theme === 'light' ? <SunIcon className="w-4 h-4" /> : <MoonIcon className="w-4 h-4" />}
|
||||
</button>
|
||||
|
|
@ -291,7 +297,7 @@ export default function Header({
|
|||
<button
|
||||
onClick={() => setMenuOpen(true)}
|
||||
className="flex items-center justify-center w-10 h-10 -mr-2 rounded hover:bg-navy-800 transition-colors"
|
||||
aria-label="Open menu"
|
||||
aria-label={t('header.openMenu')}
|
||||
>
|
||||
<MenuIcon className="w-6 h-6" />
|
||||
</button>
|
||||
|
|
@ -322,7 +328,7 @@ export default function Header({
|
|||
{isMobile && copied && (
|
||||
<div className="fixed top-14 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-2 px-4 py-2 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
|
||||
<CheckIcon className="w-4 h-4 text-teal-400" />
|
||||
Copied to clipboard
|
||||
{t('common.copiedToClipboard')}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
|
|
|||
60
frontend/src/components/ui/LanguageDropdown.tsx
Normal file
60
frontend/src/components/ui/LanguageDropdown.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES, type LanguageCode } from '../../i18n';
|
||||
|
||||
export default function LanguageDropdown() {
|
||||
const { i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const current = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) ?? SUPPORTED_LANGUAGES[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const changeLanguage = (code: LanguageCode) => {
|
||||
i18n.changeLanguage(code);
|
||||
localStorage.setItem('language', code);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
aria-label="Language"
|
||||
>
|
||||
<span className="text-base leading-none">{current.flag}</span>
|
||||
<svg className="w-3 h-3 text-warm-400" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 5l3 3 3-3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-9 w-40 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50 py-1">
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-sm ${
|
||||
i18n.language === lang.code
|
||||
? 'text-teal-600 dark:text-teal-400 font-medium bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base leading-none">{lang.flag}</span>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LicenseSuccessModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const particles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 40 }, (_, i) => ({
|
||||
|
|
@ -50,18 +52,18 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
|||
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
||||
<div className="text-5xl mb-3">🎉</div>
|
||||
<h2 className="text-2xl font-bold text-white">You're in.</h2>
|
||||
<p className="text-warm-300 text-sm mt-2">Your lifetime access is now active.</p>
|
||||
<h2 className="text-2xl font-bold text-white">{t('licenseSuccess.title')}</h2>
|
||||
<p className="text-warm-300 text-sm mt-2">{t('licenseSuccess.subtitle')}</p>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
|
||||
Full access to every feature, every postcode, across all of England.
|
||||
{t('licenseSuccess.description')}
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
|
||||
>
|
||||
Start exploring
|
||||
{t('licenseSuccess.startExploring')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckIcon } from './icons/CheckIcon';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
|
@ -16,6 +17,7 @@ export default function SaveSearchModal({
|
|||
saving: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ export default function SaveSearchModal({
|
|||
>
|
||||
<div className="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
|
||||
{saved ? 'Search saved' : 'Save Search'}
|
||||
{saved ? t('saveSearch.saved') : t('saveSearch.title')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
|
@ -65,7 +67,7 @@ export default function SaveSearchModal({
|
|||
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300">
|
||||
Your search has been saved successfully.
|
||||
{t('saveSearch.savedSuccess')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
|
|
@ -74,14 +76,14 @@ export default function SaveSearchModal({
|
|||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
Close
|
||||
{t('common.close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewSearches}
|
||||
className="px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700"
|
||||
>
|
||||
View saved searches
|
||||
{t('saveSearch.viewSavedSearches')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -89,14 +91,14 @@ export default function SaveSearchModal({
|
|||
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
|
||||
Name
|
||||
{t('saveSearch.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder="My search"
|
||||
placeholder={t('saveSearch.namePlaceholder')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -109,7 +111,7 @@ export default function SaveSearchModal({
|
|||
onClick={onClose}
|
||||
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')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -117,7 +119,7 @@ export default function SaveSearchModal({
|
|||
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
{saving ? t('saveSearch.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function Slider({ className, ...props }: SliderProps) {
|
|||
{props.value?.map((_, i) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={i}
|
||||
className="block h-5 w-5 rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-navy-800 ring-offset-white dark:ring-offset-navy-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
className="block h-6 w-6 cursor-pointer rounded-full border-2 border-teal-600 dark:border-teal-500 bg-white dark:bg-navy-800 ring-offset-white dark:ring-offset-navy-950 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 before:absolute before:left-1/2 before:top-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:h-11 before:w-11 before:rounded-full before:content-['']"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import InfoPopup from './InfoPopup';
|
||||
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
|
||||
|
||||
const MODE_INFO: Record<TransportMode, string> = {
|
||||
transit:
|
||||
' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.',
|
||||
car: ' by car, based on typical road speeds and the road network.',
|
||||
bicycle: ' by bicycle, using cycle-friendly routes.',
|
||||
walking: ' on foot, using pedestrian paths and pavements.',
|
||||
};
|
||||
import { useTranslatedModes, type TransportMode } from '../../hooks/useTravelTime';
|
||||
|
||||
export function TravelTimeInfoPopup({
|
||||
mode,
|
||||
|
|
@ -16,11 +9,14 @@ export function TravelTimeInfoPopup({
|
|||
mode: TransportMode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
|
||||
return (
|
||||
<InfoPopup title={`Travel Time (${MODE_LABELS[mode]})`} onClose={onClose}>
|
||||
<InfoPopup title={t('travel.travelTime', { mode: modes.label(mode) })} onClose={onClose}>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
Shows how long it takes to reach the selected destination from each area
|
||||
{MODE_INFO[mode]} Use the slider to set your maximum commute time.
|
||||
{t('travelInfo.mainDesc')}
|
||||
{modes.desc(mode)} {t('travelInfo.sliderHint')}
|
||||
</p>
|
||||
</InfoPopup>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
|
|
@ -18,6 +19,7 @@ export default function UpgradeModal({
|
|||
onStartCheckout,
|
||||
onZoomToFreeZone,
|
||||
}: UpgradeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pricePence, setPricePence] = useState<number | null>(null);
|
||||
|
|
@ -32,7 +34,7 @@ export default function UpgradeModal({
|
|||
}, []);
|
||||
|
||||
const priceLabel =
|
||||
pricePence === null ? '...' : pricePence === 0 ? 'Free' : `\u00A3${pricePence / 100}`;
|
||||
pricePence === null ? '...' : pricePence === 0 ? t('upgrade.free') : `\u00A3${pricePence / 100}`;
|
||||
const isFree = pricePence === 0;
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
|
|
@ -41,7 +43,7 @@ export default function UpgradeModal({
|
|||
try {
|
||||
await onStartCheckout();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Checkout failed');
|
||||
setError(err instanceof Error ? err.message : t('upgrade.checkoutFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -60,10 +62,9 @@ export default function UpgradeModal({
|
|||
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">See all of England</h2>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2>
|
||||
<p className="text-warm-300 text-sm">
|
||||
You're currently exploring inner London. Get lifetime access to every postcode,
|
||||
every filter, every neighbourhood. One payment, forever.
|
||||
{t('upgrade.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -73,12 +74,12 @@ export default function UpgradeModal({
|
|||
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
|
||||
{priceLabel}
|
||||
</span>
|
||||
{!isFree && <span className="text-warm-500 dark:text-warm-400 text-lg">/once</span>}
|
||||
{!isFree && <span className="text-warm-500 dark:text-warm-400 text-lg">{t('upgrade.once')}</span>}
|
||||
</div>
|
||||
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
|
||||
{isFree
|
||||
? 'Free for early adopters. No credit card required.'
|
||||
: 'One-time payment. Lifetime access. 30-day money-back guarantee.'}
|
||||
? t('upgrade.freeForEarly')
|
||||
: t('upgrade.oneTimePayment')}
|
||||
</p>
|
||||
|
||||
{isLoggedIn ? (
|
||||
|
|
@ -89,10 +90,10 @@ export default function UpgradeModal({
|
|||
>
|
||||
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{loading
|
||||
? 'Redirecting...'
|
||||
? t('upgrade.redirecting')
|
||||
: isFree
|
||||
? 'Claim free access'
|
||||
: `Upgrade for ${priceLabel}`}
|
||||
? t('upgrade.claimFreeAccess')
|
||||
: t('upgrade.upgradeFor', { price: priceLabel })}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
|
@ -100,13 +101,13 @@ export default function UpgradeModal({
|
|||
onClick={onRegisterClick}
|
||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Register & Upgrade
|
||||
{t('upgrade.registerAndUpgrade')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
Already have an account? Log in
|
||||
{t('upgrade.alreadyHaveAccount')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -119,7 +120,7 @@ export default function UpgradeModal({
|
|||
onClick={onZoomToFreeZone}
|
||||
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
|
||||
>
|
||||
Continue exploring inner London
|
||||
{t('upgrade.continueWithDemo')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import type { Page } from './Header';
|
||||
import { PAGE_PATHS } from './Header';
|
||||
|
|
@ -18,6 +19,7 @@ export default function UserMenu({
|
|||
onLogout: () => void;
|
||||
onNavigate: (page: Page) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -59,7 +61,9 @@ export default function UserMenu({
|
|||
: 'bg-warm-100 text-warm-500 dark:bg-warm-700 dark:text-warm-400'
|
||||
}`}
|
||||
>
|
||||
{user.subscription === 'licensed' || user.isAdmin ? 'Full Access' : 'Inner London'}
|
||||
{user.subscription === 'licensed' || user.isAdmin
|
||||
? t('userMenu.fullAccess')
|
||||
: t('userMenu.demo')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,8 +77,9 @@ export default function UserMenu({
|
|||
) : (
|
||||
<MoonIcon className="w-4 h-4" />
|
||||
)}
|
||||
Theme: {theme === 'light' ? 'Light' : 'Dark'}
|
||||
{theme === 'light' ? t('userMenu.themeLight') : t('userMenu.themeDark')}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={PAGE_PATHS.account}
|
||||
onClick={(e) => {
|
||||
|
|
@ -85,7 +90,7 @@ export default function UserMenu({
|
|||
}}
|
||||
className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
|
||||
>
|
||||
Account
|
||||
{t('userMenu.account')}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -94,7 +99,7 @@ export default function UserMenu({
|
|||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
|
||||
>
|
||||
Log out
|
||||
{t('userMenu.logOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
18
frontend/src/components/ui/icons/LocateIcon.tsx
Normal file
18
frontend/src/components/ui/icons/LocateIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LocateIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ export { GraduationCapIcon } from './GraduationCapIcon';
|
|||
export { HouseIcon } from './HouseIcon';
|
||||
export { InfoIcon } from './InfoIcon';
|
||||
export { LightbulbIcon } from './LightbulbIcon';
|
||||
export { LocateIcon } from './LocateIcon';
|
||||
export { LogoIcon } from './LogoIcon';
|
||||
export { MapPinIcon } from './MapPinIcon';
|
||||
export { MenuIcon } from './MenuIcon';
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ export interface AiFiltersResult {
|
|||
notes: string;
|
||||
/** Human-readable summary of what was set */
|
||||
summary: string;
|
||||
/** The listing mode used (historical/buy/rent) */
|
||||
listingType: string;
|
||||
/** Number of properties matching the proposed filters (excludes travel time) */
|
||||
matchCount: number;
|
||||
}
|
||||
|
|
@ -34,8 +32,7 @@ export interface AiFiltersContext {
|
|||
interface UseAiFiltersResult {
|
||||
fetchAiFilters: (
|
||||
query: string,
|
||||
context?: AiFiltersContext,
|
||||
listingType?: string
|
||||
context?: AiFiltersContext
|
||||
) => Promise<AiFiltersResult | null>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
|
@ -50,27 +47,31 @@ function buildSummary(
|
|||
travelTimeFilters: AiTravelTimeFilter[],
|
||||
matchCount: number
|
||||
): string {
|
||||
const i18n = require('../i18n').default as { t: (key: string, opts?: Record<string, unknown>) => string };
|
||||
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
// Skip Listing status — shown via the mode selector UI
|
||||
if (name === 'Listing status') continue;
|
||||
if (Array.isArray(value) && value.length === 2 && typeof value[0] === 'number') {
|
||||
parts.push(name);
|
||||
parts.push(ts(name));
|
||||
} else if (Array.isArray(value)) {
|
||||
parts.push(`${name}: ${(value as string[]).join(', ')}`);
|
||||
parts.push(`${ts(name)}: ${(value as string[]).map((v) => ts(v)).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tt of travelTimeFilters) {
|
||||
const bounds =
|
||||
tt.max !== undefined ? `< ${tt.max} min` : tt.min !== undefined ? `> ${tt.min} min` : '';
|
||||
parts.push(`${tt.mode} to ${tt.label} ${bounds}`.trim());
|
||||
tt.max !== undefined
|
||||
? i18n.t('format.lessThanMin', { max: tt.max })
|
||||
: tt.min !== undefined
|
||||
? i18n.t('format.moreThanMin', { min: tt.min })
|
||||
: '';
|
||||
parts.push(i18n.t('format.toDestination', { mode: tt.mode, label: tt.label, bounds }).trim());
|
||||
}
|
||||
|
||||
if (parts.length === 0) return 'No filters set';
|
||||
const countStr = matchCount.toLocaleString();
|
||||
return `${countStr} properties match · Set ${parts.length} filter${parts.length > 1 ? 's' : ''}: ${parts.join(', ')}`;
|
||||
if (parts.length === 0) return i18n.t('format.noFiltersSet');
|
||||
return `${i18n.t('format.propertiesMatch', { count: matchCount.toLocaleString() })} \u00B7 ${i18n.t('format.setFilters', { count: parts.length, list: parts.join(', ') })}`;
|
||||
}
|
||||
|
||||
export function useAiFilters(): UseAiFiltersResult {
|
||||
|
|
@ -84,8 +85,7 @@ export function useAiFilters(): UseAiFiltersResult {
|
|||
const fetchAiFilters = useCallback(
|
||||
async (
|
||||
query: string,
|
||||
context?: AiFiltersContext,
|
||||
listingType?: string
|
||||
context?: AiFiltersContext
|
||||
): Promise<AiFiltersResult | null> => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
|
|
@ -100,7 +100,6 @@ export function useAiFilters(): UseAiFiltersResult {
|
|||
try {
|
||||
const url = apiUrl('ai-filters');
|
||||
const bodyObj: Record<string, unknown> = { query };
|
||||
if (listingType) bodyObj.listing_type = listingType;
|
||||
if (context) {
|
||||
bodyObj.context = {
|
||||
filters: context.filters,
|
||||
|
|
@ -151,7 +150,6 @@ export function useAiFilters(): UseAiFiltersResult {
|
|||
travelTimeFilters,
|
||||
notes: json.notes || '',
|
||||
summary: summaryText,
|
||||
listingType: json.listing_type || 'historical',
|
||||
matchCount,
|
||||
};
|
||||
setNotes(result.notes || null);
|
||||
|
|
|
|||
|
|
@ -126,8 +126,10 @@ export function useAuth() {
|
|||
const result = await pb.collection('users').authRefresh();
|
||||
setUser(recordToUser(result.record));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Auth refresh failed';
|
||||
setError(msg);
|
||||
// Token is invalid/expired — clear auth state but don't set error,
|
||||
// since this is a background refresh, not a user-initiated action
|
||||
pb.authStore.clear();
|
||||
setUser(null);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
|
|||
93
frontend/src/hooks/useFilterCounts.ts
Normal file
93
frontend/src/hooks/useFilterCounts.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
|
||||
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
interface FilterCountsResponse {
|
||||
total: number;
|
||||
impacts: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches per-filter marginal impact counts: for each active filter,
|
||||
* how many more properties would be visible if that filter were removed.
|
||||
*/
|
||||
export function useFilterCounts(
|
||||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
bounds: Bounds | null,
|
||||
travelTimeEntries: TravelTimeEntry[]
|
||||
) {
|
||||
const [impacts, setImpacts] = useState<Record<string, number>>({});
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Build the travel param string (same format as useMapData)
|
||||
const travelParam = useMemo(() => {
|
||||
const segments: string[] = [];
|
||||
for (const entry of travelTimeEntries) {
|
||||
if (!entry.slug) continue;
|
||||
let seg = `${entry.mode}:${entry.slug}`;
|
||||
if (entry.useBest) seg += ':best';
|
||||
if (entry.timeRange) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
segments.push(seg);
|
||||
}
|
||||
return segments.join('|');
|
||||
}, [travelTimeEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
|
||||
const filterCount = Object.keys(filters).length;
|
||||
const hasTravelFilters = travelTimeEntries.some((e) => e.slug && e.timeRange);
|
||||
if (filterCount === 0 && !hasTravelFilters) {
|
||||
setImpacts({});
|
||||
setTotal(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
|
||||
const filtersStr = buildFilterString(filters, features);
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
|
||||
const res = await fetch(
|
||||
apiUrl('filter-counts', params),
|
||||
authHeaders({ signal: abortRef.current!.signal })
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: FilterCountsResponse = await res.json();
|
||||
setImpacts(json.impacts);
|
||||
setTotal(json.total);
|
||||
} catch (err) {
|
||||
if (!isAbortError(err)) {
|
||||
logNonAbortError('Failed to fetch filter counts', err);
|
||||
}
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [filters, features, bounds, travelParam, travelTimeEntries]);
|
||||
|
||||
// Cancel in-flight on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { impacts, total };
|
||||
}
|
||||
|
|
@ -266,6 +266,14 @@ export function useHexagonSelection({
|
|||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
setAreaStats(stats);
|
||||
// Re-fetch properties if the properties tab is active
|
||||
if (rightPaneTab === 'properties') {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
fetchPostcodeProperties(selectedHexagon.id, 0);
|
||||
} else {
|
||||
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
|
@ -279,7 +287,7 @@ export function useHexagonSelection({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats]);
|
||||
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats, rightPaneTab, fetchHexagonProperties, fetchPostcodeProperties]);
|
||||
|
||||
const handleLocationSearch = useCallback(
|
||||
(postcode: string, geometry: PostcodeGeometry) => {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
|||
import type { PlaceResult } from '../types';
|
||||
import { authHeaders, logNonAbortError } from '../lib/api';
|
||||
|
||||
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
|
||||
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
|
||||
* Outcodes like "E14" or "SW1A" intentionally do NOT match — they go through /api/places instead. */
|
||||
const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i;
|
||||
|
||||
function looksLikePostcode(s: string) {
|
||||
return POSTCODE_RE.test(s.trim());
|
||||
return FULL_POSTCODE_RE.test(s.trim());
|
||||
}
|
||||
|
||||
/** Normalize a UK postcode: uppercase, strip spaces, insert canonical space before inward code. */
|
||||
|
|
@ -83,7 +85,7 @@ export function useLocationSearch(mode?: string) {
|
|||
place_type: p.place_type,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
city: p.city,
|
||||
city: p.city === 'City of London' ? 'London' : p.city,
|
||||
}));
|
||||
setResults(placeResults);
|
||||
setOpen(placeResults.length > 0);
|
||||
|
|
|
|||
|
|
@ -7,43 +7,87 @@ interface PaneResizeHandlers {
|
|||
}
|
||||
|
||||
export function usePaneResize(
|
||||
initialWidth: number,
|
||||
minWidth: number,
|
||||
maxWidth: number,
|
||||
side: 'left' | 'right'
|
||||
): [number, PaneResizeHandlers] {
|
||||
const [width, setWidth] = useState(initialWidth);
|
||||
initialSize: number,
|
||||
minSize: number,
|
||||
maxSize: number,
|
||||
side: 'left' | 'right' | 'top' | 'bottom'
|
||||
): [number, PaneResizeHandlers, React.RefCallback<HTMLElement>] {
|
||||
const [size, setSize] = useState(initialSize);
|
||||
const draggingRef = useRef(false);
|
||||
const liveSizeRef = useRef(initialSize);
|
||||
const targetRef = useRef<HTMLElement | null>(null);
|
||||
const containerOffsetRef = useRef(0);
|
||||
const containerSizeRef = useRef(0);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
draggingRef.current = true;
|
||||
const isVertical = side === 'top' || side === 'bottom';
|
||||
const styleProp = isVertical ? 'height' : 'width';
|
||||
|
||||
const targetCallbackRef = useCallback((el: HTMLElement | null) => {
|
||||
targetRef.current = el;
|
||||
}, []);
|
||||
|
||||
const computeSize = useCallback(
|
||||
(e: React.PointerEvent): number => {
|
||||
if (isVertical) {
|
||||
const total = containerSizeRef.current || window.innerHeight;
|
||||
const resolvedMax = maxSize <= 1 ? total * maxSize : maxSize;
|
||||
const pos = e.clientY - containerOffsetRef.current;
|
||||
return side === 'top'
|
||||
? Math.min(resolvedMax, Math.max(minSize, pos))
|
||||
: Math.min(resolvedMax, Math.max(minSize, total - pos));
|
||||
} else {
|
||||
const resolvedMax = maxSize <= 1 ? window.innerWidth * maxSize : maxSize;
|
||||
return side === 'left'
|
||||
? Math.min(resolvedMax, Math.max(minSize, e.clientX))
|
||||
: Math.min(resolvedMax, Math.max(minSize, window.innerWidth - e.clientX));
|
||||
}
|
||||
},
|
||||
[side, isVertical, minSize, maxSize]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
draggingRef.current = true;
|
||||
if (isVertical) {
|
||||
const container = (e.currentTarget as HTMLElement).parentElement;
|
||||
if (container) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
containerOffsetRef.current = rect.top;
|
||||
containerSizeRef.current = rect.height;
|
||||
}
|
||||
}
|
||||
},
|
||||
[isVertical]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const resolvedMax = maxWidth <= 1 ? window.innerWidth * maxWidth : maxWidth;
|
||||
const newWidth =
|
||||
side === 'left'
|
||||
? Math.min(resolvedMax, Math.max(minWidth, e.clientX))
|
||||
: Math.min(resolvedMax, Math.max(minWidth, window.innerWidth - e.clientX));
|
||||
setWidth(newWidth);
|
||||
const newSize = computeSize(e);
|
||||
liveSizeRef.current = newSize;
|
||||
if (targetRef.current) {
|
||||
targetRef.current.style[styleProp] = `${newSize}px`;
|
||||
} else {
|
||||
setSize(newSize);
|
||||
}
|
||||
},
|
||||
[side, minWidth, maxWidth]
|
||||
[computeSize, styleProp]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
draggingRef.current = false;
|
||||
setSize(liveSizeRef.current);
|
||||
}, []);
|
||||
|
||||
return [
|
||||
width,
|
||||
size,
|
||||
{
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
},
|
||||
targetCallbackRef,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon } from '../components/ui/icons';
|
||||
|
||||
export type TransportMode = 'car' | 'bicycle' | 'walking' | 'transit';
|
||||
|
|
@ -27,6 +28,24 @@ export const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: strin
|
|||
transit: TransitIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook returning translated mode labels and descriptions.
|
||||
*/
|
||||
export function useTranslatedModes() {
|
||||
const { t } = useTranslation();
|
||||
const label = useCallback(
|
||||
(mode: TransportMode): string =>
|
||||
({ car: t('travel.modeCar'), bicycle: t('travel.modeBicycle'), walking: t('travel.modeWalking'), transit: t('travel.modeTransit') })[mode],
|
||||
[t]
|
||||
);
|
||||
const desc = useCallback(
|
||||
(mode: TransportMode): string =>
|
||||
({ car: t('travel.modeCarDesc'), bicycle: t('travel.modeBicycleDesc'), walking: t('travel.modeWalkingDesc'), transit: t('travel.modeTransitDesc') })[mode],
|
||||
[t]
|
||||
);
|
||||
return { label, desc };
|
||||
}
|
||||
|
||||
export interface TravelTimeEntry {
|
||||
mode: TransportMode;
|
||||
slug: string;
|
||||
|
|
|
|||
|
|
@ -1,65 +1,22 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { Step, CallBackProps } from 'react-joyride';
|
||||
import { ACTIONS, EVENTS, STATUS } from 'react-joyride';
|
||||
|
||||
const STORAGE_KEY = 'tutorial_completed';
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{
|
||||
target: '[data-tutorial="filters"]',
|
||||
title: 'Tell the map what matters',
|
||||
content:
|
||||
'Set your budget, commute limit, school quality, crime threshold. Whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="ai-filters"]',
|
||||
title: 'Or just describe it',
|
||||
content:
|
||||
'Type what you want in plain English, like "quiet area near good schools under \u00A3400k", and we\u2019ll set up the filters for you.',
|
||||
placement: 'right',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="map"]',
|
||||
title: 'Explore what\u2019s out there',
|
||||
content:
|
||||
'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more about that neighbourhood.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="search"]',
|
||||
title: 'Jump to a location',
|
||||
content: 'Search for any place or postcode to fly straight there.',
|
||||
placement: 'bottom',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="right-pane"]',
|
||||
title: 'Dig into the details',
|
||||
content:
|
||||
'See area statistics, histograms, and individual property records: prices, floor area, energy ratings, and more.',
|
||||
placement: 'left',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="poi-button"]',
|
||||
title: 'What\u2019s nearby?',
|
||||
content:
|
||||
'Toggle schools, shops, stations, parks, and restaurants on the map to see what\u2019s within reach.',
|
||||
placement: 'left',
|
||||
disableBeacon: true,
|
||||
styles: {
|
||||
tooltip: {
|
||||
transform: 'translateY(-50px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked = false) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const steps: Step[] = useMemo(() => [
|
||||
{ target: '[data-tutorial="filters"]', title: t('tutorial.step1Title'), content: t('tutorial.step1Content'), placement: 'right' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="ai-filters"]', title: t('tutorial.step2Title'), content: t('tutorial.step2Content'), placement: 'right' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="map"]', title: t('tutorial.step3Title'), content: t('tutorial.step3Content'), placement: 'bottom' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="search"]', title: t('tutorial.step4Title'), content: t('tutorial.step4Content'), placement: 'bottom' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="right-pane"]', title: t('tutorial.step5Title'), content: t('tutorial.step5Content'), placement: 'left' as const, disableBeacon: true },
|
||||
{ target: '[data-tutorial="poi-button"]', title: t('tutorial.step6Title'), content: t('tutorial.step6Content'), placement: 'left' as const, disableBeacon: true, styles: { tooltip: { transform: 'translateY(-50px)' } } },
|
||||
], [t]);
|
||||
|
||||
const [run, setRun] = useState(() => {
|
||||
if (isMobile) return false;
|
||||
return !localStorage.getItem(STORAGE_KEY);
|
||||
|
|
@ -88,7 +45,7 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
|
|||
|
||||
return useMemo(
|
||||
() => ({
|
||||
steps: STEPS,
|
||||
steps,
|
||||
run: shouldRun,
|
||||
handleCallback,
|
||||
resetTutorial,
|
||||
|
|
|
|||
293
frontend/src/i18n/descriptions.ts
Normal file
293
frontend/src/i18n/descriptions.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import i18n from 'i18next';
|
||||
|
||||
/**
|
||||
* Feature description translations, keyed by feature name.
|
||||
*
|
||||
* English descriptions are NOT here — the server is the single source of truth
|
||||
* for English. Fix a typo in features.rs and it propagates automatically.
|
||||
*
|
||||
* Non-English translations are keyed by the stable feature name, so they're
|
||||
* independent of the English description text. If a translation is missing,
|
||||
* tsDesc() falls back to the server's English description.
|
||||
*/
|
||||
const descriptions: Record<string, Record<string, string>> = {
|
||||
fr: {
|
||||
'Listing status': 'Indique si le bien provient de ventes historiques, est en vente ou en location',
|
||||
'Property type': 'Type de bien : individuel, jumelé, mitoyen, appartement ou autre',
|
||||
'Leasehold/Freehold': 'Indique si le bien est en bail ou en pleine propriété',
|
||||
'Last known price': 'Dernier prix de vente enregistré au Land Registry',
|
||||
'Estimated current price': 'Estimation du prix actuel ajusté à l’inflation',
|
||||
'Asking price': 'Prix demandé pour les biens actuellement en vente',
|
||||
'Price per sqm': 'Prix de vente divisé par la surface totale',
|
||||
'Est. price per sqm': 'Prix actuel estimé divisé par la surface totale',
|
||||
'Asking price per sqm': 'Prix demandé divisé par la surface totale',
|
||||
'Estimated monthly rent': 'Loyer mensuel privé médian pour le secteur',
|
||||
'Asking rent (monthly)': 'Loyer mensuel affiché pour les biens en location',
|
||||
'Total floor area (sqm)': 'Surface intérieure issue du diagnostic EPC',
|
||||
'Number of bedrooms & living rooms': 'Nombre de pièces habitables selon le diagnostic EPC',
|
||||
'Bedrooms': 'Nombre de chambres selon l’annonce en ligne',
|
||||
'Bathrooms': 'Nombre de salles de bain selon l’annonce en ligne',
|
||||
'Construction year': 'Année de construction estimée selon l’EPC',
|
||||
'Date of last transaction': 'Date de la dernière vente enregistrée au Land Registry',
|
||||
'Listing date': 'Date de première mise en ligne du bien',
|
||||
'Former council house': 'Indique si le bien a été répertorié comme logement social',
|
||||
'Current energy rating': 'Classement énergétique EPC actuel (A = meilleur, G = pire)',
|
||||
'Potential energy rating': 'Classement EPC potentiel si toutes les améliorations recommandées étaient réalisées',
|
||||
'Interior height (m)': 'Hauteur moyenne d’étage selon le diagnostic EPC',
|
||||
'Distance to nearest train or tube station (km)': 'Distance à la gare ou station de métro la plus proche',
|
||||
'Good+ primary schools within 2km': 'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 2 km',
|
||||
'Good+ secondary schools within 2km': 'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 2 km',
|
||||
'Good+ primary schools within 5km': 'Écoles primaires notées Bien ou Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Good+ secondary schools within 5km': 'Collèges/lycées notés Bien ou Excellent par Ofsted dans un rayon de 5 km',
|
||||
'Education, Skills and Training Score': 'Score de qualité éducative du secteur (plus élevé = meilleur)',
|
||||
'Income Score (rate)': 'Taux de précarité de revenu, inversé (plus élevé = moins précaire)',
|
||||
'Employment Score (rate)': 'Taux de précarité d’emploi, inversé (plus élevé = moins précaire)',
|
||||
'Health Deprivation and Disability Score': 'Score de santé et handicap (plus élevé = meilleurs résultats)',
|
||||
'Living Environment Score': 'Qualité de l’environnement intérieur et extérieur (plus élevé = meilleur)',
|
||||
'Indoors Sub-domain Score': 'Qualité et état du logement (plus élevé = meilleur)',
|
||||
'Outdoors Sub-domain Score': 'Qualité de l’air et sécurité routière (plus élevé = meilleur)',
|
||||
'Serious crime per 1k residents (avg/yr)': 'Taux de crimes graves pour 1 000 habitants par an',
|
||||
'Minor crime per 1k residents (avg/yr)': 'Taux de délits mineurs pour 1 000 habitants par an',
|
||||
'Serious crime (avg/yr)': 'Agrégat des catégories de crimes graves par an',
|
||||
'Minor crime (avg/yr)': 'Agrégat des catégories de délits mineurs par an',
|
||||
'Violence and sexual offences (avg/yr)': 'Moyenne annuelle des violences et infractions sexuelles dans le secteur',
|
||||
'Burglary (avg/yr)': 'Moyenne annuelle des cambriolages dans le secteur',
|
||||
'Robbery (avg/yr)': 'Moyenne annuelle des vols avec violence dans le secteur',
|
||||
'Vehicle crime (avg/yr)': 'Moyenne annuelle des crimes liés aux véhicules dans le secteur',
|
||||
'Anti-social behaviour (avg/yr)': 'Moyenne annuelle des comportements antisociaux dans le secteur',
|
||||
'Criminal damage and arson (avg/yr)': 'Moyenne annuelle des dégradations et incendies criminels dans le secteur',
|
||||
'Other theft (avg/yr)': 'Moyenne annuelle des autres vols dans le secteur',
|
||||
'Theft from the person (avg/yr)': 'Moyenne annuelle des vols à la personne dans le secteur',
|
||||
'Shoplifting (avg/yr)': 'Moyenne annuelle des vols à l’étalage dans le secteur',
|
||||
'Bicycle theft (avg/yr)': 'Moyenne annuelle des vols de vélos dans le secteur',
|
||||
'Drugs (avg/yr)': 'Moyenne annuelle des infractions liées aux stupéfiants dans le secteur',
|
||||
'Possession of weapons (avg/yr)': 'Moyenne annuelle des infractions de possession d’armes dans le secteur',
|
||||
'Public order (avg/yr)': 'Moyenne annuelle des troubles à l’ordre public dans le secteur',
|
||||
'Other crime (avg/yr)': 'Moyenne annuelle des autres crimes dans le secteur',
|
||||
'Median age': 'Âge médian de la population locale',
|
||||
'% White': 'Pourcentage de la population se déclarant Blanche',
|
||||
'% South Asian': 'Pourcentage de la population se déclarant Sud-Asiatique',
|
||||
'% Black': 'Pourcentage de la population se déclarant Noire',
|
||||
'% East Asian': 'Pourcentage de la population se déclarant Est-Asiatique',
|
||||
'% Mixed': 'Pourcentage de la population se déclarant Métisse ou de plusieurs groupes ethniques',
|
||||
'% Other': 'Pourcentage de la population se déclarant d’un autre groupe ethnique',
|
||||
'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche',
|
||||
'Number of parks within 2km': 'Nombre de parcs et espaces verts à moins de 2 km',
|
||||
'Number of restaurants within 2km': 'Nombre de restaurants et cafés à moins de 2 km',
|
||||
'Number of grocery shops and supermarkets within 2km': 'Nombre d’épiceries et supermarchés à moins de 2 km',
|
||||
'Noise (dB)': 'Niveau de bruit routier au code postal en décibels (Lden)',
|
||||
'Max available download speed (Mbps)': 'Débit descendant maximal disponible au code postal',
|
||||
},
|
||||
de: {
|
||||
'Listing status': 'Ob die Immobilie aus historischen Verkäufen stammt, aktuell zum Verkauf oder zur Miete steht',
|
||||
'Property type': 'Immobilientyp: freistehend, Doppelhaushälfte, Reihenhaus, Wohnung oder sonstige',
|
||||
'Leasehold/Freehold': 'Ob die Immobilie Erbbaurecht oder Volleigentum ist',
|
||||
'Last known price': 'Letzter Verkaufspreis laut Land Registry',
|
||||
'Estimated current price': 'Inflationsbereinigter Schätzwert der Immobilie',
|
||||
'Asking price': 'Angebotspreis für aktuell zum Verkauf stehende Immobilien',
|
||||
'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfläche',
|
||||
'Est. price per sqm': 'Geschätzter aktueller Preis geteilt durch die Gesamtfläche',
|
||||
'Asking price per sqm': 'Angebotspreis geteilt durch die Gesamtfläche',
|
||||
'Estimated monthly rent': 'Mittlere monatliche Privatmiete in der Gegend',
|
||||
'Asking rent (monthly)': 'Angebotene Monatsmiete für Mietimmobilien',
|
||||
'Total floor area (sqm)': 'Wohnfläche laut EPC-Gutachten',
|
||||
'Number of bedrooms & living rooms': 'Anzahl bewohnbarer Räume laut EPC-Gutachten',
|
||||
'Bedrooms': 'Anzahl Schlafzimmer laut Online-Inserat',
|
||||
'Bathrooms': 'Anzahl Badezimmer laut Online-Inserat',
|
||||
'Construction year': 'Geschätztes Baujahr laut EPC',
|
||||
'Date of last transaction': 'Datum des letzten Verkaufs laut Land Registry',
|
||||
'Listing date': 'Datum der Erstveröffentlichung des Inserats',
|
||||
'Former council house': 'Ob die Immobilie jemals als Sozialbau erfasst wurde',
|
||||
'Current energy rating': 'Aktuelle EPC-Energieeffizienzklasse (A = beste, G = schlechteste)',
|
||||
'Potential energy rating': 'Potenzielle EPC-Klasse bei Umsetzung aller empfohlenen Maßnahmen',
|
||||
'Interior height (m)': 'Durchschnittliche Geschosshöhe laut EPC-Gutachten',
|
||||
'Distance to nearest train or tube station (km)': 'Entfernung zum nächsten Bahn- oder U-Bahnhof',
|
||||
'Good+ primary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 2 km',
|
||||
'Good+ secondary schools within 2km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 2 km',
|
||||
'Good+ primary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete Grundschulen im Umkreis von 5 km',
|
||||
'Good+ secondary schools within 5km': 'Von Ofsted mit Gut oder Hervorragend bewertete weiterführende Schulen im Umkreis von 5 km',
|
||||
'Education, Skills and Training Score': 'Bildungsqualitätsscore der Gegend (höher = besser)',
|
||||
'Income Score (rate)': 'Einkommensbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
|
||||
'Employment Score (rate)': 'Beschäftigungsbenachteiligungsrate, invertiert (höher = weniger benachteiligt)',
|
||||
'Health Deprivation and Disability Score': 'Gesundheits- und Behinderungsscore (höher = bessere Ergebnisse)',
|
||||
'Living Environment Score': 'Qualität der Innen- und Außenumgebung (höher = besser)',
|
||||
'Indoors Sub-domain Score': 'Wohnqualität und -zustand (höher = besser)',
|
||||
'Outdoors Sub-domain Score': 'Luftqualität und Verkehrssicherheit (höher = besser)',
|
||||
'Serious crime per 1k residents (avg/yr)': 'Rate schwerer Straftaten pro 1.000 Einwohner pro Jahr',
|
||||
'Minor crime per 1k residents (avg/yr)': 'Rate leichter Straftaten pro 1.000 Einwohner pro Jahr',
|
||||
'Serious crime (avg/yr)': 'Summe der schweren Straftaten-Kategorien pro Jahr',
|
||||
'Minor crime (avg/yr)': 'Summe der leichten Straftaten-Kategorien pro Jahr',
|
||||
'Violence and sexual offences (avg/yr)': 'Jährlicher Durchschnitt der Gewalt- und Sexualdelikte in der Gegend',
|
||||
'Burglary (avg/yr)': 'Jährlicher Durchschnitt der Einbrüche in der Gegend',
|
||||
'Robbery (avg/yr)': 'Jährlicher Durchschnitt der Raubüberfälle in der Gegend',
|
||||
'Vehicle crime (avg/yr)': 'Jährlicher Durchschnitt der Fahrzeugkriminalität in der Gegend',
|
||||
'Anti-social behaviour (avg/yr)': 'Jährlicher Durchschnitt des antisozialen Verhaltens in der Gegend',
|
||||
'Criminal damage and arson (avg/yr)': 'Jährlicher Durchschnitt der Sachbeschädigungen und Brandstiftungen in der Gegend',
|
||||
'Other theft (avg/yr)': 'Jährlicher Durchschnitt des sonstigen Diebstahls in der Gegend',
|
||||
'Theft from the person (avg/yr)': 'Jährlicher Durchschnitt des Taschendiebstahls in der Gegend',
|
||||
'Shoplifting (avg/yr)': 'Jährlicher Durchschnitt des Ladendiebstahls in der Gegend',
|
||||
'Bicycle theft (avg/yr)': 'Jährlicher Durchschnitt des Fahrraddiebstahls in der Gegend',
|
||||
'Drugs (avg/yr)': 'Jährlicher Durchschnitt der Drogendelikte in der Gegend',
|
||||
'Possession of weapons (avg/yr)': 'Jährlicher Durchschnitt der Waffenbesitzdelikte in der Gegend',
|
||||
'Public order (avg/yr)': 'Jährlicher Durchschnitt der Störungen der öffentlichen Ordnung in der Gegend',
|
||||
'Other crime (avg/yr)': 'Jährlicher Durchschnitt sonstiger Straftaten in der Gegend',
|
||||
'Median age': 'Medianalter der lokalen Bevölkerung',
|
||||
'% White': 'Anteil der Bevölkerung, der sich als Weiß identifiziert',
|
||||
'% South Asian': 'Anteil der Bevölkerung, der sich als Südasiatisch identifiziert',
|
||||
'% Black': 'Anteil der Bevölkerung, der sich als Schwarz identifiziert',
|
||||
'% East Asian': 'Anteil der Bevölkerung, der sich als Ostasiatisch identifiziert',
|
||||
'% Mixed': 'Anteil der Bevölkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifiziert',
|
||||
'% Other': 'Anteil der Bevölkerung, der sich einer anderen ethnischen Gruppe zuordnet',
|
||||
'Distance to nearest park (km)': 'Entfernung zum nächsten Park oder Grünfläche',
|
||||
'Number of parks within 2km': 'Anzahl Parks und Grünflächen im Umkreis von 2 km',
|
||||
'Number of restaurants within 2km': 'Anzahl Restaurants und Cafés im Umkreis von 2 km',
|
||||
'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
|
||||
'Noise (dB)': 'Straßenlärmpegel an der Postleitzahl in Dezibel (Lden)',
|
||||
'Max available download speed (Mbps)': 'Maximal verfügbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
|
||||
},
|
||||
zh: {
|
||||
'Listing status': '该房产是历史销售、当前在售还是出租',
|
||||
'Property type': '房产类型:独立式、半独立式、联排、公寓或其他',
|
||||
'Leasehold/Freehold': '该房产是租赁产权还是永久产权',
|
||||
'Last known price': 'Land Registry记录的最近一次售价',
|
||||
'Estimated current price': '经通胀调整后的当前估计价值',
|
||||
'Asking price': '当前在售房产的挂牌价',
|
||||
'Price per sqm': '售价除以总建筑面积',
|
||||
'Est. price per sqm': '估计当前价格除以总建筑面积',
|
||||
'Asking price per sqm': '挂牌价除以总建筑面积',
|
||||
'Estimated monthly rent': '当地私人租赁的中位月租',
|
||||
'Asking rent (monthly)': '当前出租房产的挂牌月租',
|
||||
'Total floor area (sqm)': 'EPC评估的室内建筑面积',
|
||||
'Number of bedrooms & living rooms': 'EPC评估的宜居房间数',
|
||||
'Bedrooms': '在线房源中的卧室数量',
|
||||
'Bathrooms': '在线房源中的浴室数量',
|
||||
'Construction year': 'EPC评估的建造年份',
|
||||
'Date of last transaction': 'Land Registry记录的最近一次销售日期',
|
||||
'Listing date': '房产首次在线上市的日期',
|
||||
'Former council house': '该房产是否曾被记录为公共住房',
|
||||
'Current energy rating': '当前EPC能效评级(A = 最佳,G = 最差)',
|
||||
'Potential energy rating': '实施所有建议改进后的潜在EPC评级',
|
||||
'Interior height (m)': 'EPC评估的平均层高',
|
||||
'Distance to nearest train or tube station (km)': '到最近火车或地铁站的距离',
|
||||
'Good+ primary schools within 2km': 'Ofsted评为良好或优秀的2公里内小学',
|
||||
'Good+ secondary schools within 2km': 'Ofsted评为良好或优秀的2公里内中学',
|
||||
'Good+ primary schools within 5km': 'Ofsted评为良好或优秀的5公里内小学',
|
||||
'Good+ secondary schools within 5km': 'Ofsted评为良好或优秀的5公里内中学',
|
||||
'Education, Skills and Training Score': '当地教育质量得分(越高越好)',
|
||||
'Income Score (rate)': '收入贫困率,反向指标(越高越不贫困)',
|
||||
'Employment Score (rate)': '就业贫困率,反向指标(越高越不贫困)',
|
||||
'Health Deprivation and Disability Score': '健康与残障得分(越高健康状况越好)',
|
||||
'Living Environment Score': '室内外环境质量(越高越好)',
|
||||
'Indoors Sub-domain Score': '住房质量和状况(越高越好)',
|
||||
'Outdoors Sub-domain Score': '空气质量和道路安全(越高越好)',
|
||||
'Serious crime per 1k residents (avg/yr)': '每千人每年严重犯罪率',
|
||||
'Minor crime per 1k residents (avg/yr)': '每千人每年轻微犯罪率',
|
||||
'Serious crime (avg/yr)': '严重犯罪类别年度总计',
|
||||
'Minor crime (avg/yr)': '轻微犯罪类别年度总计',
|
||||
'Violence and sexual offences (avg/yr)': '该地区年均暴力和性犯罪数',
|
||||
'Burglary (avg/yr)': '该地区年均入室盗窃数',
|
||||
'Robbery (avg/yr)': '该地区年均抢劫数',
|
||||
'Vehicle crime (avg/yr)': '该地区年均车辆犯罪数',
|
||||
'Anti-social behaviour (avg/yr)': '该地区年均反社会行为数',
|
||||
'Criminal damage and arson (avg/yr)': '该地区年均刑事毁坏和纵火数',
|
||||
'Other theft (avg/yr)': '该地区年均其他盗窃数',
|
||||
'Theft from the person (avg/yr)': '该地区年均人身盗窃数',
|
||||
'Shoplifting (avg/yr)': '该地区年均商店盗窃数',
|
||||
'Bicycle theft (avg/yr)': '该地区年均自行车盗窃数',
|
||||
'Drugs (avg/yr)': '该地区年均毒品犯罪数',
|
||||
'Possession of weapons (avg/yr)': '该地区年均非法持有武器数',
|
||||
'Public order (avg/yr)': '该地区年均扰乱公共秩序数',
|
||||
'Other crime (avg/yr)': '该地区年均其他犯罪数',
|
||||
'Median age': '当地人口的中位年龄',
|
||||
'% White': '白人人口比例',
|
||||
'% South Asian': '南亚裔人口比例',
|
||||
'% Black': '黑人人口比例',
|
||||
'% East Asian': '东亚裔人口比例',
|
||||
'% Mixed': '混血或多族裔人口比例',
|
||||
'% Other': '其他族裔人口比例',
|
||||
'Distance to nearest park (km)': '到最近公园或绿地的距离',
|
||||
'Number of parks within 2km': '2公里内公园和绿地数量',
|
||||
'Number of restaurants within 2km': '2公里内餐厅和咖啡馆数量',
|
||||
'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
|
||||
'Noise (dB)': '该邮编的道路噪音水平(分贝,Lden)',
|
||||
'Max available download speed (Mbps)': '该邮编可用的最大宽带下载速度',
|
||||
},
|
||||
hu: {
|
||||
'Listing status': 'Az ingatlan korábbi eladásból származik, jelenleg eladó vagy kiadó',
|
||||
'Property type': 'Ingatlantípus: különálló, ikerház, sorház, lakás vagy egyéb',
|
||||
'Leasehold/Freehold': 'Az ingatlan bérleti jogú vagy teljes tulajdonú',
|
||||
'Last known price': 'A Land Registry-ben rögzített utolsó eladási ár',
|
||||
'Estimated current price': 'Inflációval korrigált becsült jelenlegi érték',
|
||||
'Asking price': 'A jelenleg eladásra kínált ingatlanok irányára',
|
||||
'Price per sqm': 'Eladási ár osztva az összes alapterülettel',
|
||||
'Est. price per sqm': 'Becsült jelenlegi ár osztva az összes alapterülettel',
|
||||
'Asking price per sqm': 'Irányár osztva az összes alapterülettel',
|
||||
'Estimated monthly rent': 'A környék medián havi magánbérleti díja',
|
||||
'Asking rent (monthly)': 'A kiadó ingatlanok hirdetett havi bérleti díja',
|
||||
'Total floor area (sqm)': 'Az EPC felmérésből származó belső alapterület',
|
||||
'Number of bedrooms & living rooms': 'Lakószobák száma az EPC felmérés alapján',
|
||||
'Bedrooms': 'Hálószobák száma az online hirdetés szerint',
|
||||
'Bathrooms': 'Fürdőszobák száma az online hirdetés szerint',
|
||||
'Construction year': 'Becsült építési év az EPC alapján',
|
||||
'Date of last transaction': 'Az utolsó eladás dátuma a Land Registry szerint',
|
||||
'Listing date': 'Az ingatlan első online megjelenésének dátuma',
|
||||
'Former council house': 'Az ingatlan szerepelt-e valaha önkormányzati lakásként',
|
||||
'Current energy rating': 'Jelenlegi EPC energiabesorolás (A = legjobb, G = legrosszabb)',
|
||||
'Potential energy rating': 'Potenciális EPC besorolás az összes javasolt fejlesztés elvégzése után',
|
||||
'Interior height (m)': 'Átlagos belmagasság az EPC felmérés alapján',
|
||||
'Distance to nearest train or tube station (km)': 'Távolság a legközelebbi vasút- vagy metróállomásig',
|
||||
'Good+ primary schools within 2km': 'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 2 km-en belül',
|
||||
'Good+ secondary schools within 2km': 'Ofsted által Jó vagy Kiváló minősítésű középiskolák 2 km-en belül',
|
||||
'Good+ primary schools within 5km': 'Ofsted által Jó vagy Kiváló minősítésű általános iskolák 5 km-en belül',
|
||||
'Good+ secondary schools within 5km': 'Ofsted által Jó vagy Kiváló minősítésű középiskolák 5 km-en belül',
|
||||
'Education, Skills and Training Score': 'A környék oktatási minőségi pontszáma (magasabb = jobb)',
|
||||
'Income Score (rate)': 'Jövedelmi deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
|
||||
'Employment Score (rate)': 'Foglalkoztatási deprivációs ráta, invertálva (magasabb = kevésbé hátrányos)',
|
||||
'Health Deprivation and Disability Score': 'Egészségügyi és fogyatékossági pontszám (magasabb = jobb eredmények)',
|
||||
'Living Environment Score': 'Belső és külső környezet minősége (magasabb = jobb)',
|
||||
'Indoors Sub-domain Score': 'Lakásminőség és állapot (magasabb = jobb)',
|
||||
'Outdoors Sub-domain Score': 'Levegőminőség és közlekedésbiztonság (magasabb = jobb)',
|
||||
'Serious crime per 1k residents (avg/yr)': 'Súlyos bűncselekmények aránya 1000 lakosra évente',
|
||||
'Minor crime per 1k residents (avg/yr)': 'Kisebb bűncselekmények aránya 1000 lakosra évente',
|
||||
'Serious crime (avg/yr)': 'Súlyos bűncselekményi kategóriák éves összesítése',
|
||||
'Minor crime (avg/yr)': 'Kisebb bűncselekményi kategóriák éves összesítése',
|
||||
'Violence and sexual offences (avg/yr)': 'Erőszakos és szexuális bűncselekmények éves átlaga a környéken',
|
||||
'Burglary (avg/yr)': 'Betörések éves átlaga a környéken',
|
||||
'Robbery (avg/yr)': 'Rablások éves átlaga a környéken',
|
||||
'Vehicle crime (avg/yr)': 'Gépjárművel kapcsolatos bűncselekmények éves átlaga a környéken',
|
||||
'Anti-social behaviour (avg/yr)': 'Közösségellenes magatartás éves átlaga a környéken',
|
||||
'Criminal damage and arson (avg/yr)': 'Rongálás és gyújtogatás éves átlaga a környéken',
|
||||
'Other theft (avg/yr)': 'Egyéb lopások éves átlaga a környéken',
|
||||
'Theft from the person (avg/yr)': 'Személyek elleni lopások éves átlaga a környéken',
|
||||
'Shoplifting (avg/yr)': 'Bolti lopások éves átlaga a környéken',
|
||||
'Bicycle theft (avg/yr)': 'Kerékpárlopások éves átlaga a környéken',
|
||||
'Drugs (avg/yr)': 'Kábítószerrel kapcsolatos bűncselekmények éves átlaga a környéken',
|
||||
'Possession of weapons (avg/yr)': 'Fegyvertartással kapcsolatos bűncselekmények éves átlaga a környéken',
|
||||
'Public order (avg/yr)': 'Közrend elleni bűncselekmények éves átlaga a környéken',
|
||||
'Other crime (avg/yr)': 'Egyéb bűncselekmények éves átlaga a környéken',
|
||||
'Median age': 'A helyi lakosság medián életkora',
|
||||
'% White': 'A fehérként azonosított lakosság aránya',
|
||||
'% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya',
|
||||
'% Black': 'A feketeként azonosított lakosság aránya',
|
||||
'% East Asian': 'A kelet-ázsiaiként azonosított lakosság aránya',
|
||||
'% Mixed': 'A vegyes vagy több etnikai csoporthoz tartozóként azonosított lakosság aránya',
|
||||
'% Other': 'Az egyéb etnikai csoportba tartozóként azonosított lakosság aránya',
|
||||
'Distance to nearest park (km)': 'Távolság a legközelebbi parkig vagy zöldterületig',
|
||||
'Number of parks within 2km': 'Parkok és zöldterületek száma 2 km-en belül',
|
||||
'Number of restaurants within 2km': 'Éttermek és kávézók száma 2 km-en belül',
|
||||
'Number of grocery shops and supermarkets within 2km': 'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
|
||||
'Noise (dB)': 'Közúti zajszint az irányítószámnál decibelben (Lden)',
|
||||
'Max available download speed (Mbps)': 'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a feature description.
|
||||
* - English: returns the server-provided description (single source of truth)
|
||||
* - Other languages: looks up by feature name, falls back to English if missing
|
||||
*/
|
||||
export function tsDesc(featureName: string, englishFromServer: string): string {
|
||||
const lang = i18n.language;
|
||||
if (lang === 'en') return englishFromServer;
|
||||
return descriptions[lang]?.[featureName] ?? englishFromServer;
|
||||
}
|
||||
11
frontend/src/i18n/i18next.d.ts
vendored
Normal file
11
frontend/src/i18n/i18next.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import 'i18next';
|
||||
import type en from './locales/en';
|
||||
|
||||
declare module 'i18next' {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: 'translation';
|
||||
resources: {
|
||||
translation: typeof en;
|
||||
};
|
||||
}
|
||||
}
|
||||
64
frontend/src/i18n/index.ts
Normal file
64
frontend/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import en from './locales/en';
|
||||
import de from './locales/de';
|
||||
import fr from './locales/fr';
|
||||
import hu from './locales/hu';
|
||||
import zh from './locales/zh';
|
||||
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ code: 'en', label: 'English', flag: '\uD83C\uDDEC\uD83C\uDDE7' },
|
||||
{ code: 'fr', label: 'Fran\u00E7ais', flag: '\uD83C\uDDEB\uD83C\uDDF7' },
|
||||
{ code: 'de', label: 'Deutsch', flag: '\uD83C\uDDE9\uD83C\uDDEA' },
|
||||
{ code: 'hu', label: 'Magyar', flag: '\uD83C\uDDED\uD83C\uDDFA' },
|
||||
{ code: 'zh', label: '\u4E2D\u6587', flag: '\uD83C\uDDE8\uD83C\uDDF3' },
|
||||
] as const;
|
||||
|
||||
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
|
||||
|
||||
const supportedCodes: Set<string> = new Set(SUPPORTED_LANGUAGES.map((l) => l.code));
|
||||
|
||||
function detectLanguage(): string {
|
||||
// 1. Explicit user choice (persisted from the language dropdown)
|
||||
const stored = localStorage.getItem('language');
|
||||
if (stored && supportedCodes.has(stored)) return stored;
|
||||
|
||||
// 2. Browser preference (navigator.languages falls back to navigator.language)
|
||||
for (const tag of navigator.languages ?? [navigator.language]) {
|
||||
// Match full tag first (e.g. "zh-CN" → "zh"), then just the prefix
|
||||
const lower = tag.toLowerCase();
|
||||
if (supportedCodes.has(lower)) return lower;
|
||||
const prefix = lower.split('-')[0];
|
||||
if (supportedCodes.has(prefix)) return prefix;
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
|
||||
const initialLang = detectLanguage();
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
fr: { translation: fr },
|
||||
de: { translation: de },
|
||||
hu: { translation: hu },
|
||||
zh: { translation: zh },
|
||||
},
|
||||
lng: initialLang,
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false, // React already escapes
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate a key that is computed at runtime (not a literal).
|
||||
* Bypasses the strict type checking on t() for dynamic key construction.
|
||||
*/
|
||||
export function tDynamic(key: string): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (i18n.t as any)(key);
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
889
frontend/src/i18n/locales/de.ts
Normal file
889
frontend/src/i18n/locales/de.ts
Normal file
|
|
@ -0,0 +1,889 @@
|
|||
import type { Translations } from './en';
|
||||
|
||||
const de: Translations = {
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Speichern',
|
||||
cancel: 'Abbrechen',
|
||||
close: 'Schließen',
|
||||
delete: 'Löschen',
|
||||
open: 'Öffnen',
|
||||
share: 'Teilen',
|
||||
copy: 'Kopieren',
|
||||
copied: 'Kopiert!',
|
||||
copiedToClipboard: 'In die Zwischenablage kopiert',
|
||||
loading: 'Wird geladen...',
|
||||
loadMore: 'Mehr laden',
|
||||
remaining: '{{count}} verbleibend',
|
||||
search: 'Suchen',
|
||||
all: 'Alle',
|
||||
none: 'Keine',
|
||||
viewDataSource: 'Datenquelle ansehen',
|
||||
total: 'Gesamt',
|
||||
min: 'Min.',
|
||||
or: 'oder',
|
||||
area: 'Gebiet',
|
||||
properties: 'Immobilien',
|
||||
postcode: 'Postleitzahl',
|
||||
noAreaSelected: 'Kein Gebiet ausgewählt',
|
||||
noAreaSelectedDesc:
|
||||
'Klicke auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
|
||||
clickForDetails: 'Für Details klicken',
|
||||
property: 'Immobilie',
|
||||
propertiesPlural: 'Immobilien',
|
||||
},
|
||||
|
||||
// ── Header / Nav ───────────────────────────────────
|
||||
header: {
|
||||
appName: 'Perfect Postcode',
|
||||
dashboard: 'Übersicht',
|
||||
learn: 'Infos',
|
||||
pricing: 'Preise',
|
||||
inviteFriends: 'Freunde einladen',
|
||||
saved: 'Gespeichert',
|
||||
logIn: 'Anmelden',
|
||||
createAccount: 'Konto erstellen',
|
||||
sharing: 'Wird geteilt...',
|
||||
exportLabel: 'Exportieren',
|
||||
exporting: 'Wird exportiert...',
|
||||
exportToExcel: 'Als Excel exportieren',
|
||||
openMenu: 'Menü öffnen',
|
||||
closeMenu: 'Menü schließen',
|
||||
},
|
||||
|
||||
// ── User Menu ──────────────────────────────────────
|
||||
userMenu: {
|
||||
fullAccess: 'Vollzugriff',
|
||||
demo: 'Demo',
|
||||
themeLight: 'Design: Hell',
|
||||
themeDark: 'Design: Dunkel',
|
||||
account: 'Konto',
|
||||
logOut: 'Abmelden',
|
||||
},
|
||||
|
||||
// ── Mobile Menu ────────────────────────────────────
|
||||
mobileMenu: {
|
||||
menu: 'Menü',
|
||||
home: 'Startseite',
|
||||
},
|
||||
|
||||
// ── Auth Modal ─────────────────────────────────────
|
||||
auth: {
|
||||
logIn: 'Anmelden',
|
||||
createAccount: 'Konto erstellen',
|
||||
resetPassword: 'Passwort zurücksetzen',
|
||||
valueProp:
|
||||
'Speichere Suchen, merke dir Immobilien und mach dort weiter, wo du aufgehört hast.',
|
||||
continueWithGoogle: 'Weiter mit Google',
|
||||
email: 'E-Mail',
|
||||
emailPlaceholder: 'du@beispiel.de',
|
||||
password: 'Passwort',
|
||||
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
|
||||
passwordPlaceholderLogin: 'Dein Passwort',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
resetSent: 'Prüfe deine E-Mails für einen Link zum Zurücksetzen.',
|
||||
pleaseWait: 'Bitte warten...',
|
||||
sendResetLink: 'Link zum Zurücksetzen senden',
|
||||
backToLogin: 'Zurück zur Anmeldung',
|
||||
},
|
||||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: 'Ganz England entdecken',
|
||||
description:
|
||||
'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jeder Postleitzahl, jedem Filter, jedem Viertel. Eine Zahlung, für immer.',
|
||||
free: 'Kostenlos',
|
||||
once: '/einmalig',
|
||||
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
|
||||
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang. 30 Tage Geld-zurück-Garantie.',
|
||||
redirecting: 'Weiterleitung...',
|
||||
claimFreeAccess: 'Kostenlosen Zugang sichern',
|
||||
upgradeFor: 'Upgrade für {{price}}',
|
||||
registerAndUpgrade: 'Registrieren & Upgraden',
|
||||
alreadyHaveAccount: 'Bereits ein Konto? Anmelden',
|
||||
continueWithDemo: 'Mit Demo fortfahren',
|
||||
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
|
||||
},
|
||||
|
||||
// ── Save Search Modal ──────────────────────────────
|
||||
saveSearch: {
|
||||
title: 'Suche speichern',
|
||||
saved: 'Suche gespeichert',
|
||||
savedSuccess: 'Deine Suche wurde erfolgreich gespeichert.',
|
||||
viewSavedSearches: 'Gespeicherte Suchen ansehen',
|
||||
name: 'Name',
|
||||
namePlaceholder: 'Meine Suche',
|
||||
saving: 'Wird gespeichert...',
|
||||
},
|
||||
|
||||
// ── License Success ────────────────────────────────
|
||||
licenseSuccess: {
|
||||
title: 'Du bist dabei.',
|
||||
subtitle: 'Dein lebenslanger Zugang ist jetzt aktiv.',
|
||||
description:
|
||||
'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
|
||||
startExploring: 'Jetzt entdecken',
|
||||
},
|
||||
|
||||
// ── Filters ────────────────────────────────────────
|
||||
filters: {
|
||||
activeFilters: 'Aktive Filter',
|
||||
addFilter: 'Filter hinzufügen',
|
||||
historical: 'Historisch',
|
||||
buy: 'Kaufen',
|
||||
rent: 'Mieten',
|
||||
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
|
||||
addFiltersHint:
|
||||
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
|
||||
upgradePrompt:
|
||||
'Sieh Kriminalität, Schulen, Lärm, Breitband und 50+ weitere Filter für ganz England.',
|
||||
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
|
||||
upgradeToFullMap: 'Zur Vollversion upgraden',
|
||||
chooseFilters:
|
||||
'Wähle die Filter, die dir wichtig sind. Die Karte aktualisiert sich sofort.',
|
||||
searchFeatures: 'Filter durchsuchen...',
|
||||
noMatchingFeatures: 'Keine passenden Filter',
|
||||
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
|
||||
allFeaturesActive: 'Alle Filter sind aktiv',
|
||||
removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
|
||||
featureInfo: 'Filterinfo',
|
||||
replayTutorial: 'Interaktives Tutorial erneut abspielen',
|
||||
clearAll: 'Alle löschen',
|
||||
clearAllTitle: 'Alle Filter löschen?',
|
||||
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
|
||||
saveAndClear: 'Speichern & löschen',
|
||||
clearWithoutSaving: 'Ohne Speichern löschen',
|
||||
},
|
||||
|
||||
// ── Philosophy Popup ───────────────────────────────
|
||||
philosophy: {
|
||||
intro:
|
||||
'Beginne mit deinen Muss-Kriterien, dann füge Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn du Filter hinzufügst. Die verbleibenden Gebiete sind deine besten Treffer.',
|
||||
step1Title: 'Budget und Grundlagen',
|
||||
step1Desc: '(Preisrahmen, Wohnfläche, Immobilientyp)',
|
||||
step2Title: 'Pendelweg',
|
||||
step2Desc: '(Fahrzeit zum Arbeitsplatz mit Auto, Fahrrad oder ÖPNV)',
|
||||
step3Title: 'Sicherheit',
|
||||
step3Desc: '(Kriminalitätsraten, Lärmpegel, Bodenstabilität)',
|
||||
step4Title: 'Schulen',
|
||||
step4Desc: '(nahe gelegene Schulen mit Ofsted-Bewertung Gut oder Hervorragend)',
|
||||
step5Title: 'Lebensstil',
|
||||
step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)',
|
||||
step6Title: 'Energie',
|
||||
step6Desc: '(EPC-Bewertungen, Dämmung, Heizkosten)',
|
||||
tip: 'Tipp: Wenn nichts passt, lockere eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
|
||||
},
|
||||
|
||||
// ── Travel Time ────────────────────────────────────
|
||||
travel: {
|
||||
travelTime: 'Reisezeit ({{mode}})',
|
||||
maxTime: 'Maximale Zeit',
|
||||
selectDestination: 'Ziel auswählen...',
|
||||
bestCase: 'Bestfall',
|
||||
bestCaseTitle: 'Bestmögliche Reisezeit',
|
||||
bestCaseDesc:
|
||||
'Verwendet die schnellste realistische Reisezeit (bei guter Abfahrtsplanung und guten Anschlüssen). Standard ist der <strong>Median</strong>, der eine typische Fahrt unabhängig vom Abfahrtszeitpunkt darstellt.',
|
||||
previewOnMap: 'Auf Karte anzeigen',
|
||||
stopPreviewing: 'Vorschau beenden',
|
||||
removeTravelTime: 'Reisezeit entfernen',
|
||||
addTravelTime: '{{mode}}-Reisezeit hinzufügen',
|
||||
clearDestination: 'Ziel löschen',
|
||||
typeToFilter: 'Tippen zum Filtern...',
|
||||
noDestinations: 'Keine Ziele gefunden',
|
||||
modeCar: 'Auto',
|
||||
modeBicycle: 'Fahrrad',
|
||||
modeWalking: 'Zu Fuß',
|
||||
modeTransit: 'ÖPNV',
|
||||
modeCarDesc: 'Fahrzeit über die schnellste Straßenroute',
|
||||
modeBicycleDesc: 'Radfahrzeit auf fahrradfreundlichen Strecken',
|
||||
modeWalkingDesc: 'Gehzeit über Fußwege und Bürgersteige',
|
||||
modeTransitDesc: 'Reisezeit mit Bahn, U-Bahn und Bus',
|
||||
},
|
||||
|
||||
// ── Travel Time Info Popup ─────────────────────────
|
||||
travelInfo: {
|
||||
transitDesc:
|
||||
' mit öffentlichen Verkehrsmitteln (Bus, Bahn, U-Bahn). Die Zeiten werden über ein typisches Werktags-Morgenfenster berechnet.',
|
||||
carDesc:
|
||||
' mit dem Auto, basierend auf typischen Straßengeschwindigkeiten und dem Straßennetz.',
|
||||
bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.',
|
||||
walkingDesc: ' zu Fuß, über Fußwege und Bürgersteige.',
|
||||
mainDesc:
|
||||
'Zeigt, wie lange es dauert, das ausgewählte Ziel von jedem Gebiet aus zu erreichen',
|
||||
sliderHint:
|
||||
'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
|
||||
},
|
||||
|
||||
// ── AI Filter ──────────────────────────────────────
|
||||
aiFilter: {
|
||||
describeIdealArea: 'Beschreibe dein Wunschgebiet mit KI',
|
||||
aiSearch: 'KI-Suche',
|
||||
describeHint: 'beschreibe, wonach du suchst',
|
||||
placeholder: 'z. B. ruhige Gegend, unter £400k, nahe guten Schulen...',
|
||||
example1: 'Sichere Gegend nahe guten Schulen',
|
||||
example2: '30 Min. Pendelweg zu Kings Cross, unter £500k',
|
||||
example3: 'Ruhiges Dorf, 3 Schlafzimmer, schnelles Breitband',
|
||||
analysing: 'Anfrage wird analysiert...',
|
||||
searchingDestinations: 'Ziele werden gesucht...',
|
||||
generatingFilters: 'Filter werden generiert...',
|
||||
refiningResults: 'Ergebnisse werden verfeinert...',
|
||||
weeklyLimitReached:
|
||||
'Du hast das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
|
||||
},
|
||||
|
||||
// ── Map Legend ─────────────────────────────────────
|
||||
mapLegend: {
|
||||
clearColourView: 'Farbansicht zurücksetzen',
|
||||
historicalMatches: 'Historische Immobilientreffer',
|
||||
propertiesForSale: 'Immobilien zum Verkauf',
|
||||
propertiesForRent: 'Immobilien zur Miete',
|
||||
numberOfProperties: 'Anzahl der Immobilien',
|
||||
previewing: 'Vorschau von \u201c{{name}}\u201d',
|
||||
},
|
||||
|
||||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: 'Unbekannte Adresse',
|
||||
unsaveProperty: 'Immobilie nicht mehr merken',
|
||||
saveProperty: 'Immobilie merken',
|
||||
lastSold: 'Letzter Verkauf: £{{price}}',
|
||||
estValue: 'Gesch. Wert:',
|
||||
type: 'Typ:',
|
||||
builtForm: 'Bauweise:',
|
||||
tenure: 'Besitzart:',
|
||||
floorArea: 'Wohnfläche:',
|
||||
bedrooms: 'Schlafzimmer:',
|
||||
bathrooms: 'Badezimmer:',
|
||||
rooms: 'Zimmer:',
|
||||
built: 'Baujahr:',
|
||||
epcRating: 'EPC-Bewertung:',
|
||||
epcPotential: 'EPC-Potenzial:',
|
||||
listed: 'Inseriert:',
|
||||
keyFeatures: 'Hauptmerkmale',
|
||||
renovations: 'Renovierungen',
|
||||
viewExternalListing: 'Externes Inserat ansehen',
|
||||
perMonth: '/Monat',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: 'Nach Adresse oder Postleitzahl suchen...',
|
||||
propertyData: 'Immobiliendaten',
|
||||
propertyDataDesc:
|
||||
'Preise stammen vom HM Land Registry (was Käufer tatsächlich bezahlt haben). Wohnfläche, Energiebewertungen, Baujahr und Besitzart stammen aus offiziellen EPC-Gutachten. Beide Quellen werden nach Adresse innerhalb jeder Postleitzahl abgeglichen.',
|
||||
},
|
||||
|
||||
// ── Area Pane ──────────────────────────────────────
|
||||
areaPane: {
|
||||
areaStatistics: 'Gebietsstatistiken',
|
||||
statsFor: 'Statistiken für alle Immobilien in diesem {{type}}',
|
||||
matchingFilters: ', die allen aktiven Filtern entsprechen',
|
||||
viewProperties: '{{count}} Immobilien ansehen',
|
||||
priceHistory: 'Preisentwicklung',
|
||||
journeysFrom: 'Verbindungen ab {{label}}',
|
||||
to: 'Nach {{destination}}',
|
||||
noJourneyData: 'Keine Verbindungsdaten verfügbar',
|
||||
viewOnGoogleMaps: 'Auf Google Maps ansehen',
|
||||
walk: 'Zu Fuß',
|
||||
cycle: 'Fahrrad',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
histogramLegend: {
|
||||
tealBars: 'Türkise Balken',
|
||||
tealBarsDesc: 'zeigen die Verteilung im ausgewählten Gebiet',
|
||||
greyBars: 'Graue Balken',
|
||||
greyBarsDesc: 'zeigen die Gesamtverteilung über alle Gebiete',
|
||||
dashedLine: 'Gestrichelte Linie',
|
||||
dashedLineDesc: 'zeigt den landesweiten Durchschnitt',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
streetView: {
|
||||
title: 'Street View',
|
||||
},
|
||||
|
||||
// ── POI Pane ───────────────────────────────────────
|
||||
poiPane: {
|
||||
pois: 'POIs',
|
||||
pointsOfInterest: 'Sehenswürdigkeiten & Einrichtungen',
|
||||
poiDescription:
|
||||
'Daten von OpenStreetMap. Umfasst Haltestellen, Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit und mehr. Regelmäßig aktualisiert mit vollständiger Kategorieabdeckung.',
|
||||
searchCategories: 'Kategorien durchsuchen...',
|
||||
dataSourceInfo: 'Datenquelleninfo',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: '{{radius}} suchen auf',
|
||||
outcodeNotRecognised: 'Postleitzahlenbereich nicht erkannt',
|
||||
},
|
||||
|
||||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: 'Orte oder Postleitzahlen suchen...',
|
||||
postcodeNotFound: 'Postleitzahl nicht gefunden',
|
||||
lookupFailed: 'Suche fehlgeschlagen',
|
||||
searchLabel: 'Orte oder Postleitzahlen suchen',
|
||||
locateMe: 'Zu meinem Standort',
|
||||
geolocationUnsupported: 'Geolokalisierung wird von Ihrem Browser nicht unterstützt',
|
||||
geolocationFailed: 'Standort konnte nicht ermittelt werden',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Schublade schließen',
|
||||
},
|
||||
|
||||
// ── Home Page ──────────────────────────────────────
|
||||
home: {
|
||||
heroTitle1: 'Maximaler',
|
||||
heroTitle2: 'Wert',
|
||||
heroTitle3: 'Minimale Kompromisse.',
|
||||
heroSubtitle:
|
||||
'Auf Immobiliensuche? Mach aus deiner größten Investition deine klügste Entscheidung.',
|
||||
heroDescription:
|
||||
'So viele Möglichkeiten — die richtige Wahl kann überwältigend sein. Unsere interaktive Karte macht es einfach: Wähle deine Muss-Kriterien und sieh sofort die passenden Gebiete.',
|
||||
exploreTheMap: 'Karte entdecken',
|
||||
seeTheDifference: 'Den Unterschied sehen',
|
||||
statProperties: 'Immobilien',
|
||||
statFilters: 'Filter',
|
||||
statEvery: 'Jede',
|
||||
statPostcodeInEngland: 'Postleitzahl in England',
|
||||
ourPhilosophy: 'Unsere Philosophie',
|
||||
philosophyP1:
|
||||
'Auf Rightmove wählt man zuerst ein Gebiet und hofft, dass es gut ist. Am Ende vergleicht man Kriminalitätsstatistiken, Schulberichte und Breitband-Checker in einem Dutzend Tabs, eine Postleitzahl nach der anderen.',
|
||||
philosophyP2:
|
||||
'Wir drehen das um. Sag uns, was du brauchst (Budget, Pendelweg, Schulen, Sicherheit), und wir zeigen dir jedes Gebiet in England, das passt. Kein Raten. Keine verschwendeten Besichtigungen.',
|
||||
howToUseIt: 'So funktioniert es',
|
||||
howStep1Title: 'Lege deine Muss-Kriterien fest',
|
||||
howStep1Desc: 'Budget, Pendelweg, Schulen — die Karte zeigt nur, was passt.',
|
||||
howStep2Title: 'Entdecke Gebiete und versteckte Perlen',
|
||||
howStep2Desc: 'Zoom rein, schau dir Details und Kann-Kriterien an.',
|
||||
howStep3Title: 'Einzelne Postleitzahlen erkunden',
|
||||
howStep3Desc:
|
||||
'Sieh einzelne Immobilien, Verkaufspreise, Wohnflächen und vergleiche.',
|
||||
howStep4Title: 'Engere Auswahl mit Zuversicht',
|
||||
howStep4Desc:
|
||||
'Jedes Gebiet auf deiner Liste erfüllt deine tatsächlichen Kriterien — nicht nur, was diese Woche inseriert war.',
|
||||
othersVs: 'Andere vs',
|
||||
listingPortals: 'Immobilienportale',
|
||||
checkMyPostcode: '„Meine Postleitzahl prüfen“',
|
||||
areaGuides: 'Gebietsratgeber',
|
||||
compSearchWithout: 'Suchen, ohne zuerst ein Gebiet auszuwählen',
|
||||
compSearchWithoutSub: '(starte mit Bedürfnissen, nicht mit einem Ort)',
|
||||
compAreaData: 'Gebietsdaten',
|
||||
compAreaDataSub: '(Kriminalität, Schulen, Lärm, Breitband)',
|
||||
compPropertyData: 'Immobilienspezifische Daten',
|
||||
compPropertyDataSub: '(Preis, EPC, Wohnfläche)',
|
||||
compFilters: '56 kombinierbare Filter an einem Ort',
|
||||
compFiltersSub: '(alle Einblicke, eine interaktive Karte)',
|
||||
ctaTitle:
|
||||
'Mach aus deiner größten Investition deine klügste Entscheidung.',
|
||||
ctaDescription:
|
||||
'Das verdient die richtigen Werkzeuge — überlass es nicht dem Zufall.',
|
||||
},
|
||||
|
||||
// ── Pricing Page ───────────────────────────────────
|
||||
pricingPage: {
|
||||
title: 'Frühzugangspreis',
|
||||
subtitle:
|
||||
'Einmal zahlen, für immer nutzen. Je früher du dabei bist, desto weniger zahlst du.',
|
||||
costContext:
|
||||
'Ein Hauskauf kostet £10.000+ an Grunderwerbsteuer, £1.500 an Anwaltsgebühren, £500 für ein Gutachten. Wählst du das falsche Gebiet, steckst du mit einem langen Pendelweg, schlechten Schulen oder einer Straße fest, von der du nichts wusstest.',
|
||||
lessThanSurvey: 'Weniger als ein Hausgutachten. Deutlich nützlicher.',
|
||||
currentTier: 'Aktuelle Stufe',
|
||||
firstNUsers: 'Erste {{count}} Nutzer',
|
||||
everyoneAfter: 'Alle danach',
|
||||
nextNUsers: 'Nächste {{count}} Nutzer',
|
||||
lifetime: '/lebenslang',
|
||||
spotsRemaining: '{{count}} Platz verbleibend',
|
||||
spotsRemainingPlural: '{{count}} Plätze verbleibend',
|
||||
filled: 'Vergeben',
|
||||
openDashboard: 'Übersicht öffnen',
|
||||
getStarted: 'Jetzt starten',
|
||||
getStartedPrice: 'Jetzt starten — {{price}}',
|
||||
noCreditCard: 'Keine Kreditkarte erforderlich',
|
||||
moneyBackGuarantee: '30 Tage Geld-zurück-Garantie',
|
||||
soldOut: 'Ausverkauft',
|
||||
upcoming: 'Demnächst',
|
||||
failedToLoad:
|
||||
'Preise konnten nicht geladen werden. Bitte später erneut versuchen.',
|
||||
feat1: '56 Datenebenen für ganz England',
|
||||
feat2: 'Jede Postleitzahl bewertet und filterbar',
|
||||
feat3: 'Unbegrenztes Erkunden der Karte und Exporte',
|
||||
feat4: 'Mehrere Jahrzehnte historischer Preisdaten',
|
||||
feat5: 'Kriminalität, Schulen, Verkehr, Breitband und mehr',
|
||||
feat6: 'Alle zukünftigen Datenaktualisierungen inklusive',
|
||||
},
|
||||
|
||||
// ── Learn Page ─────────────────────────────────────
|
||||
learnPage: {
|
||||
faq: 'Häufige Fragen',
|
||||
dataSources: 'Datenquellen',
|
||||
support: 'Support',
|
||||
dataSourcesIntro: 'Diese Anwendung kombiniert {{count}} offene Datensätze zu Immobilienpreisen, Energieeffizienz, Verkehr, Demografie, Kriminalität, Umwelt und mehr.',
|
||||
faqIntro: 'Ob Sie kaufen, mieten oder einfach nur stöbern – so hilft Ihnen Perfect Postcode, das richtige Gebiet zu finden.',
|
||||
supportIntro: 'Haben Sie eine Frage? Schauen Sie in unsere FAQ oder kontaktieren Sie uns direkt.',
|
||||
source: 'Quelle:',
|
||||
optOut: 'Widerspruch gegen öffentliche Offenlegung',
|
||||
attribution: 'Quellenangaben',
|
||||
attrLandRegistry: 'Enthält Daten des HM Land Registry © Crown copyright and database right 2025.',
|
||||
attrOgl: 'Enthält öffentliche Informationen lizenziert unter der',
|
||||
attrOglLink: 'Open Government Licence v3.0',
|
||||
attrOs: 'Enthält OS-Daten © Crown copyright and database rights 2025.',
|
||||
attrTfl: 'Betrieben mit TfL Open Data.',
|
||||
attrOsm: 'Enthält Daten von',
|
||||
attrOsmContrib: '© OpenStreetMap contributors',
|
||||
attrOsmLicense: 'verfügbar unter der',
|
||||
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
|
||||
// Data source names & descriptions
|
||||
dsPricePaidName: 'Price Paid Data',
|
||||
dsPricePaidOrigin: 'HM Land Registry',
|
||||
dsPricePaidUse: 'Vollständige historische Immobilien-Verkaufspreise für England.',
|
||||
dsEpcName: 'Energy Performance Certificates (EPC)',
|
||||
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsEpcUse: 'Energieausweise für Wohngebäude mit Angaben zu Wohnfläche, Zimmeranzahl, Baujahr, Energiebewertungen, Immobilientyp und Bauform. Über Adresse innerhalb jeder Postleitzahl mit Price-Paid-Daten verknüpft. Eigentümer können der öffentlichen Offenlegung widersprechen.',
|
||||
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
|
||||
dsNsplOrigin: 'ONS / ArcGIS',
|
||||
dsNsplUse: 'Ordnet Postleitzahlen Koordinaten und statistischen Gebietscodes zu, um alle gebietsbezogenen Datensätze mit einzelnen Immobilien zu verknüpfen.',
|
||||
dsIodName: 'English Indices of Deprivation 2025',
|
||||
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsIodUse: 'Relative Benachteiligungswerte für Einkommen, Beschäftigung, Bildung, Gesundheit, Kriminalität und Wohnumfeld für jedes Viertel in England.',
|
||||
dsEthnicityName: 'Bevölkerung nach Ethnie (Zensus 2021)',
|
||||
dsEthnicityOrigin: 'ONS',
|
||||
dsEthnicityUse: 'Bevölkerungsanteile nach ethnischer Gruppe (südasiatisch, ostasiatisch, schwarz, gemischt, weiß, andere) pro Bezirk.',
|
||||
dsCrimeName: 'Street-level Crime Data',
|
||||
dsCrimeOrigin: 'data.police.uk',
|
||||
dsCrimeUse: 'Kriminalitätsdaten auf Straßenebene von 2023 bis 2025, aggregiert als Jahresdurchschnitte nach LSOA und Deliktsart (Gewalt, Einbruch, antisoziales Verhalten, Drogen, Fahrzeugkriminalität usw.).',
|
||||
dsOsmName: 'OpenStreetMap POIs',
|
||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||
dsOsmUse: 'Sehenswürdigkeiten und Einrichtungen wie Geschäfte, Restaurants, Gesundheitseinrichtungen, Freizeit, Tourismus und mehr in ganz Großbritannien.',
|
||||
dsGreenspaceName: 'OS Open Greenspace',
|
||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse: 'Offizielle Grünflächengrenzen für Großbritannien, einschließlich öffentlicher Parks, Gärten, Sportplätze und Spielplätze. Polygon-Schwerpunkte werden für die Parknähezählung und Entfernungsberechnung zum nächsten Park verwendet.',
|
||||
dsNaptanName: 'NaPTAN (Public Transport Stops)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse: 'Standorte von Bahnhöfen und Haltestellen für Bahn, Bus, U-Bahn/Straßenbahn, Fähre und Flughäfen in ganz England.',
|
||||
dsNoiseName: 'Defra Noise Mapping',
|
||||
dsNoiseOrigin: 'Defra / Environment Agency',
|
||||
dsNoiseUse: 'Straßenlärmpegel (24-Stunden-gewichteter Durchschnitt) aus der strategischen Lärmkartierung 2022, hochauflösend modelliert und an jeder Postleitzahl abgetastet.',
|
||||
dsOfstedName: 'Ofsted School Inspections',
|
||||
dsOfstedOrigin: 'Ofsted',
|
||||
dsOfstedUse: 'Neueste Inspektionsergebnisse für staatlich finanzierte Schulen (Stand April 2025). Pro Postleitzahl gemittelt für einen lokalen Schulqualitätswert (1=Hervorragend bis 4=Unzureichend).',
|
||||
dsBroadbandName: 'Ofcom Broadband Performance',
|
||||
dsBroadbandOrigin: 'Ofcom',
|
||||
dsBroadbandUse: 'Festnetz-Breitbandabdeckung und maximale Download-Geschwindigkeiten nach Gebiet aus Ofcom Connected Nations 2025.',
|
||||
dsCouncilTaxName: 'Council Tax Levels 2025-26',
|
||||
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsCouncilTaxUse: 'Jährliche Council-Tax-Sätze für die Stufen A bis H für alle 296 Abrechnungsbehörden in England, für eine von zwei Erwachsenen bewohnte Immobilie. Über den Bezirkscode aus dem NSPL-Postleitzahlenverzeichnis mit Immobilien verknüpft.',
|
||||
dsRentalName: 'Private Rental Market Statistics',
|
||||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse: 'Monatliche Medianmieten des privaten Mietmarkts nach Bezirk und Schlafzimmerkategorie (Okt. 2022 - Sept. 2023). Über Bezirkscode und geschätzte Schlafzimmeranzahl mit Immobilien verknüpft.',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: 'Ihr Gebiet finden',
|
||||
faqCommuteTitle: 'Pendelweg und Reisezeit',
|
||||
faqBudgetTitle: 'Budget und Preis-Leistung',
|
||||
faqSafetyTitle: 'Sicherheit und Nachbarschaft',
|
||||
faqFamiliesTitle: 'Familien und Schulen',
|
||||
faqEnvironmentTitle: 'Umwelt und Lebensqualität',
|
||||
faqWhyTitle: 'Warum Perfect Postcode',
|
||||
faqPricingTitle: 'Preise und Zugang',
|
||||
faqTipsTitle: 'Tipps und Tricks',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: 'Ich weiß nicht einmal, welche Gebiete ich mir ansehen soll. Kann mir das helfen?',
|
||||
faqFinding1A: 'Genau dafür ist es da. Legen Sie Ihre Filter fest (Budget, Pendelzeit, geringe Kriminalität, gute Schulen) und die Karte leuchtet auf, um Ihnen jedes Gebiet zu zeigen, das alle Kriterien erfüllt. Kein nächtliches Googeln nach „beste Wohngegenden bei Manchester“ mehr.',
|
||||
faqFinding2Q: 'Ich ziehe irgendwohin, wo ich noch nie war. Wie fange ich überhaupt an?',
|
||||
faqFinding2A: 'Stellen Sie Ihre Filter für das ein, was Ihnen wichtig ist, und die Karte hebt sofort die passenden Gebiete hervor. Sie gehen von „Ich kenne keine einzige Straße“ zu einer Auswahlliste in wenigen Minuten.',
|
||||
faqFinding3Q: 'Wie finde ich Gebiete, die alle meine Kriterien gleichzeitig erfüllen?',
|
||||
faqFinding3A: 'Kombinieren Sie mehrere Filter (Kriminalität unter dem Durchschnitt, gute Schulen, Pendelweg unter 40 Minuten) und färben Sie die Karte nach Preis, um die Gebiete mit dem besten Preis-Leistungs-Verhältnis zu finden. Die Karte aktualisiert sich in Echtzeit, wenn Sie die Regler bewegen.',
|
||||
// FAQ items — Commute and Travel
|
||||
faqCommute1Q: 'Kann ich sehen, wie lange mein Pendelweg aus verschiedenen Gebieten tatsächlich dauern würde?',
|
||||
faqCommute1A: 'Legen Sie Ihren Arbeitsplatz als Ziel fest und wir färben jede Postleitzahl nach Fahrzeit – ob mit Auto, Fahrrad oder öffentlichen Verkehrsmitteln. Filtern Sie nach Ihrer maximalen Pendelzeit und der Rest verschwindet.',
|
||||
faqCommute2Q: 'Wie ist das besser als Google Maps?',
|
||||
faqCommute2A: 'Google Maps zeigt Ihnen eine Fahrt auf einmal. Wir färben jede Postleitzahl in England nach Pendelzeit in einem Blick, sodass Sie Hunderte von Gebieten nebeneinander vergleichen können, anstatt sie einzeln zu suchen.',
|
||||
// FAQ items — Budget and Value
|
||||
faqBudget1Q: 'Wie finde ich Gebiete, in denen ich am meisten Wohnfläche für mein Geld bekomme?',
|
||||
faqBudget1A: 'Filtern Sie nach Preis pro m² und Sie sehen sofort, welche Postleitzahlen am meisten Fläche pro Pfund bieten. Kombinieren Sie es mit dem Energiebewertungsfilter, um Immobilien mit hohen Heizkosten zu vermeiden.',
|
||||
faqBudget2Q: 'Wie stelle ich sicher, dass ein günstiges Gebiet nicht aus gutem Grund günstig ist?',
|
||||
faqBudget2A: 'Legen Sie Benachteiligungswerte, Kriminalitätsstatistiken, Schulbewertungen und Breitbandgeschwindigkeiten neben den Preis. Wenn eine Postleitzahl erschwinglich ist und bei allem, was zählt, gut abschneidet, haben Sie echten Wert gefunden – nicht nur einen niedrigen Preis mit Kompromissen, die Sie noch nicht bemerkt haben.',
|
||||
// FAQ items — Safety and Neighbourhood
|
||||
faqSafety1Q: 'Wie kann ich prüfen, ob ein Gebiet sicher ist, bevor ich dorthin ziehe?',
|
||||
faqSafety1A: 'Wir überlagern echte polizeilich erfasste Kriminalitätsdaten, aufgeschlüsselt nach Art, über jedes Viertel in England. Filtern Sie nach Gewaltkriminalität, Einbruch oder antisozialem Verhalten und sehen Sie sofort, welche Postleitzahlen die niedrigsten Zahlen haben.',
|
||||
faqSafety2Q: 'Ich finde ständig Wohnungen, die online toll aussehen, aber dann stellt sich die Gegend als schwierig heraus.',
|
||||
faqSafety2A: 'Genau dafür gibt es dieses Tool. Kombinieren Sie Kriminalitätsraten, Lärmpegel, Benachteiligungswerte, Pubs und Parks in der Nähe sowie Breitbandgeschwindigkeiten auf einer Karte, damit Sie wissen, wie ein Viertel wirklich ist, bevor Sie eine Besichtigung buchen.',
|
||||
// FAQ items — Families and Schools
|
||||
faqFamilies1Q: 'Kann ich Gebiete mit guten Schulen UND geringer Kriminalität in einer Suche finden?',
|
||||
faqFamilies1A: 'Ja. Kombinieren Sie Filter für Ofsted-Bewertungen, Kriminalitätsraten, Parks und alles andere, was für Ihre Familie wichtig ist, und die Karte hebt nur die Gebiete hervor, die alles erfüllen. Kein Abgleich über fünf verschiedene Websites mehr.',
|
||||
faqFamilies2Q: 'Woher weiß ich, ob ein Viertel Parks und Spielplätze in der Nähe hat?',
|
||||
faqFamilies2A: 'Schalten Sie die POI-Ebene für Parks und Grünflächen ein, um sie direkt auf der Karte zu sehen. Sie können auch nach der Anzahl der fußläufig erreichbaren Parks pro Postleitzahl filtern.',
|
||||
// FAQ items — Environment and Quality of Life
|
||||
faqEnv1Q: 'Kann ich energieeffiziente Wohnungen finden, die nicht an einer lauten Straße liegen?',
|
||||
faqEnv1A: 'Filtern Sie nach EPC-Bewertung (A bis C), dann überlagern Sie die Straßenlärmdaten, um alles über Ihrem Schwellenwert auszuschließen. Färben Sie nach einem der beiden Kriterien, um ruhige, effiziente Straßen auf einen Blick zu erkennen.',
|
||||
faqEnv2Q: 'Zeigt es Hochwasser- oder Senkungsrisiken?',
|
||||
faqEnv2A: 'Wir integrieren Bodenstabilitätsdaten, damit Sie vor dem Kauf auf Senkungen, Schrumpf-Quell-Tone und andere geologische Risiken prüfen können. Schließen Sie Risikogebiete frühzeitig aus.',
|
||||
faqEnv3Q: 'Kann ich Gebiete mit schnellem Breitband finden, die wirklich ruhig sind?',
|
||||
faqEnv3A: 'Überlagern Sie den Breitbandfilter mit den Straßenlärmdaten, um Straßen mit guter Anbindung und wenig Verkehrslärm zu finden. Färben Sie nach einem der beiden Kriterien, um Gebiete auf einen Blick zu vergleichen.',
|
||||
// FAQ items — Why Perfect Postcode
|
||||
faqWhy1Q: 'Ich benutze bereits Rightmove. Was bringt mir das zusätzlich?',
|
||||
faqWhy1A: 'Rightmove zeigt Ihnen Häuser. Wir zeigen Ihnen Gebiete. Kriminalitätsraten, Schulbewertungen, Breitbandgeschwindigkeiten, Lärmpegel, Benachteiligungswerte und mehr – alles filterbar auf einer Karte. Sie können ein Viertel beurteilen, bevor Sie sich die Angebote ansehen.',
|
||||
faqWhy2Q: 'Kann ich das nicht alles kostenlos selbst recherchieren?',
|
||||
faqWhy2A: 'Sie könnten Polizeidaten, Ofsted-Berichte, EPC-Register, Land-Registry-Einträge und ONS-Statistiken eine Postleitzahl nach der anderen abgleichen. Oder Sie haben alles filterbar und farbkodiert auf einer Karte in Sekunden.',
|
||||
faqWhy3Q: 'Woher stammen die Daten tatsächlich?',
|
||||
faqWhy3A: 'Jeder Datensatz stammt aus offiziellen britischen Regierungsquellen: Land Registry, EPC-Register, ONS, Ofsted, Ofcom, data.police.uk und Defra. Wir scrapen keine Makler und erfinden nichts. Sie können jeden Eintrag anhand der Originalquelle überprüfen.',
|
||||
// FAQ items — Pricing and Access
|
||||
faqPricing1Q: 'Lohnt es sich wirklich, für ein Immobilien-Suchtool zu bezahlen?',
|
||||
faqPricing1A: 'Ein Hauskauf ist wahrscheinlich die größte Anschaffung Ihres Lebens. Ein einziges Warnsignal zu erkennen (eine laute Straße, schlechtes Breitband, steigende Kriminalität) bevor Sie sich festlegen, könnte Ihnen Jahre des Bedauerns ersparen. Das kostet weniger als eine Tankfüllung.',
|
||||
faqPricing2Q: 'Ist das ein Abonnement?',
|
||||
faqPricing2A: 'Nein. Einmalzahlung, Ihres für immer. Nutzen Sie es intensiv während Ihrer Suche, kommen Sie zurück, wenn Sie neugierig auf ein neues Gebiet sind, und es ist immer noch da, falls Sie erneut umziehen.',
|
||||
faqPricing3Q: 'Was kann ich mit der kostenlosen Version nutzen?',
|
||||
faqPricing3A: 'Kostenlose Nutzer können alle Funktionen im Demogebiet erkunden (Innenstadt London, ungefähr Zonen 1 bis 2). Für den Zugang zu Daten für den Rest Englands benötigen Sie den lebenslangen Zugang.',
|
||||
faqPricing4Q: 'Kann ich eine Rückerstattung erhalten?',
|
||||
faqPricing4A: 'Selbstverständlich. Wir bieten eine 30-Tage-Geld-zurück-Garantie. Wenn Sie nicht zufrieden sind, schreiben Sie innerhalb von 30 Tagen an support@perfect-postcode.co.uk für eine vollständige Rückerstattung.',
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: 'Wie nutze ich den KI-Filter, anstatt Filter einzeln hinzuzufügen?',
|
||||
faqTips1A: 'Beschreiben Sie, was Sie suchen, z. B. „ruhige Gegend nahe guten Schulen mit schnellem Breitband unter £400k“, und die KI richtet alle relevanten Filter auf einmal ein. Passen Sie danach manuell an.',
|
||||
faqTips2Q: 'Kann ich eine Suche speichern und später darauf zurückkommen?',
|
||||
faqTips2A: 'Klicken Sie auf Speichern und alles wird erfasst: Ihre Filter, die Zoomstufe und die angezeigte Datenebene. Machen Sie genau dort weiter, wo Sie aufgehört haben, oder teilen Sie den Link mit Ihrem Partner.',
|
||||
faqTips3Q: 'Kann ich die angezeigten Daten exportieren?',
|
||||
faqTips3A: 'Nutzen Sie den Export-Button, um die aktuell gefilterten Immobilien als Tabelle herunterzuladen. Der Export berücksichtigt alle aktiven Filter, sodass Sie genau die gewünschten Daten erhalten.',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
accountPage: {
|
||||
emailLabel: 'E-Mail',
|
||||
subscriptionLabel: 'Abonnement',
|
||||
upgrade: 'Upgraden',
|
||||
redirecting: 'Weiterleitung…',
|
||||
receiveNewsletter: 'Newsletter-E-Mails erhalten',
|
||||
needHelp: 'Brauchst du Hilfe? Schreib uns an',
|
||||
responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.',
|
||||
},
|
||||
|
||||
// ── Saved Page ─────────────────────────────────────
|
||||
savedPage: {
|
||||
searches: 'Suchen',
|
||||
noSavedSearches: 'Noch keine gespeicherten Suchen',
|
||||
noSavedSearchesDesc:
|
||||
'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
|
||||
noSavedProperties: 'Noch keine gespeicherten Immobilien',
|
||||
noSavedPropertiesDesc:
|
||||
'Merke dir Immobilien während du erkundest und erstelle deine Auswahlliste, ohne den Überblick zu verlieren.',
|
||||
openPostcode: 'Postleitzahl öffnen',
|
||||
viewListing: 'Inserat ansehen',
|
||||
clickToRename: 'Klicken zum Umbenennen',
|
||||
notesPlaceholder: 'Notiere deine Gedanken...',
|
||||
deleteSearch: 'Suche löschen',
|
||||
deleteSearchConfirm:
|
||||
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
deleteProperty: 'Immobilie löschen',
|
||||
deletePropertyConfirm:
|
||||
'Möchtest du diese gespeicherte Immobilie wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
|
||||
bed: 'Schlafz.',
|
||||
epc: 'EPC',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
invitesPage: {
|
||||
inviteLinksLicensed:
|
||||
'Einladungslinks sind für lizenzierte Nutzer verfügbar.',
|
||||
inviteAdminLabel: 'Freunde einladen (100% Rabatt)',
|
||||
inviteReferralLabel: 'Freunde einladen (30% Rabatt)',
|
||||
generateFreeInvite: 'Kostenlosen Einladungslink erstellen',
|
||||
generateReferralLink: 'Empfehlungslink erstellen',
|
||||
copyInviteLink: 'Einladungslink kopieren',
|
||||
adminInvitesTitle: 'Admin-Einladungen (100% Rabatt)',
|
||||
referralInvitesTitle: 'Empfehlungseinladungen (30% Rabatt)',
|
||||
yourInviteLinks: 'Deine Einladungslinks',
|
||||
noInvitesYet: 'Noch keine Einladungen erstellt',
|
||||
link: 'Link',
|
||||
status: 'Status',
|
||||
created: 'Erstellt',
|
||||
redeemed: 'Eingelöst',
|
||||
pending: 'Ausstehend',
|
||||
},
|
||||
|
||||
// ── Invite Page ────────────────────────────────────
|
||||
invitePage: {
|
||||
youreInvited: 'Du bist eingeladen!',
|
||||
specialOffer: 'Sonderangebot!',
|
||||
invitedByFree:
|
||||
'{{name}} hat dich eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
||||
invitedByDiscount:
|
||||
'{{name}} hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
|
||||
genericFreeInvite:
|
||||
'Du wurdest eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
|
||||
genericDiscount:
|
||||
'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
|
||||
exploreEvery: 'Entdecke jedes Viertel in England',
|
||||
propertyInfo:
|
||||
'Immobilienpreise, Energiebewertungen, Kriminalitätsstatistiken, Schulbewertungen und mehr',
|
||||
invalidInvite: 'Ungültige Einladung',
|
||||
inviteAlreadyUsed: 'Einladung bereits verwendet',
|
||||
inviteAlreadyUsedDesc:
|
||||
'Dieser Einladungslink wurde bereits eingelöst.',
|
||||
invalidInviteLink: 'Ungültiger Einladungslink',
|
||||
invalidInviteLinkDesc:
|
||||
'Dieser Einladungslink ist ungültig oder abgelaufen.',
|
||||
licenseActivated: 'Lizenz aktiviert!',
|
||||
fullAccessGranted:
|
||||
'Du hast jetzt vollen Zugang zu Perfect Postcode.',
|
||||
activating: 'Wird aktiviert...',
|
||||
activateLicense: 'Lizenz aktivieren',
|
||||
claimDiscount: 'Rabatt einlösen',
|
||||
registerToClaim: 'Registrieren zum Einlösen',
|
||||
youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
|
||||
accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
|
||||
failedToValidate: 'Einladungslink konnte nicht validiert werden',
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: 'Entfernt',
|
||||
savedProperty: 'Gespeichert',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: 'gerade eben',
|
||||
minutesAgo: 'vor {{count}} Min.',
|
||||
hoursAgo: 'vor {{count}} Std.',
|
||||
daysAgo: 'vor {{count}} T.',
|
||||
nFilters: '{{count}} Filter',
|
||||
noFilters: 'Keine Filter',
|
||||
poiCategory: '{{count}} POI-Kategorie',
|
||||
poiCategories: '{{count}} POI-Kategorien',
|
||||
travelDestination: '{{count}} Fahrziel',
|
||||
travelDestinations: '{{count}} Fahrziele',
|
||||
propertiesMatch: '{{count}} Immobilien stimmen überein',
|
||||
setFilters: '{{count}} Filter setzen: {{list}}',
|
||||
noFiltersSet: 'Keine Filter gesetzt',
|
||||
toDestination: '{{mode}} nach {{label}} {{bounds}}',
|
||||
lessThanMin: '< {{max}} Min.',
|
||||
moreThanMin: '> {{min}} Min.',
|
||||
},
|
||||
|
||||
// ── Tutorial ──────────────────────────────────────
|
||||
tutorial: {
|
||||
step1Title: 'Sagen Sie der Karte, was zählt',
|
||||
step1Content: 'Legen Sie Ihr Budget, maximale Pendelzeit, Schulqualität und Kriminalitätsschwelle fest. Was Ihnen wichtig ist. Nur qualifizierende Gebiete bleiben hervorgehoben. Nutzen Sie das Augensymbol, um nach beliebigem Merkmal einzufärben.',
|
||||
step2Title: 'Oder einfach beschreiben',
|
||||
step2Content: 'Tippen Sie auf Deutsch ein, was Sie suchen, z. B. „ruhige Gegend nahe guter Schulen unter £400k“, und wir richten die Filter für Sie ein.',
|
||||
step3Title: 'Erkunden Sie, was es gibt',
|
||||
step3Content: 'Schwenken und zoomen Sie durch England. Klicken Sie auf ein beliebiges farbiges Gebiet, um Kriminalität, Schulen, Preise, Breitband, Lärm und mehr zu sehen.',
|
||||
step4Title: 'Direkt zu einem Ort springen',
|
||||
step4Content: 'Suchen Sie nach einem Ort oder einer Postleitzahl, um sofort dorthin zu gelangen.',
|
||||
step5Title: 'Ins Detail gehen',
|
||||
step5Content: 'Sehen Sie Gebietsstatistiken, Histogramme und einzelne Immobiliendaten: Preise, Wohnfläche, Energiebewertungen und mehr.',
|
||||
step6Title: 'Was ist in der Nähe?',
|
||||
step6Content: 'Blenden Sie Schulen, Geschäfte, Bahnhöfe, Parks und Restaurants auf der Karte ein, um zu sehen, was erreichbar ist.',
|
||||
},
|
||||
|
||||
// ── Server-derived values ──────────────────────────
|
||||
// Keyed by the English server value. ts() looks up translations at display time.
|
||||
// The English keys MUST match exactly what the API returns.
|
||||
server: {
|
||||
// ─ Feature group names ─
|
||||
'Properties': 'Immobilien',
|
||||
'Transport': 'Verkehr',
|
||||
'Education': 'Bildung',
|
||||
'Deprivation': 'Benachteiligung',
|
||||
'Crime': 'Kriminalität',
|
||||
'Demographics': 'Demografie',
|
||||
'Amenities': 'Infrastruktur',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': 'Inseratsstatus',
|
||||
'Property type': 'Immobilientyp',
|
||||
'Leasehold/Freehold': 'Erbbaurecht/Volleigentum',
|
||||
'Last known price': 'Letzter bekannter Preis',
|
||||
'Estimated current price': 'Geschätzter aktueller Preis',
|
||||
'Asking price': 'Angebotspreis',
|
||||
'Price per sqm': 'Preis pro m²',
|
||||
'Est. price per sqm': 'Gesch. Preis pro m²',
|
||||
'Asking price per sqm': 'Angebotspreis pro m²',
|
||||
'Estimated monthly rent': 'Geschätzte Monatsmiete',
|
||||
'Asking rent (monthly)': 'Angebotsmiete (monatlich)',
|
||||
'Total floor area (sqm)': 'Gesamtwohnfläche (m²)',
|
||||
'Number of bedrooms & living rooms': 'Anzahl Schlaf- & Wohnzimmer',
|
||||
'Bedrooms': 'Schlafzimmer',
|
||||
'Bathrooms': 'Badezimmer',
|
||||
'Construction year': 'Baujahr',
|
||||
'Date of last transaction': 'Datum der letzten Transaktion',
|
||||
'Listing date': 'Inseratsdatum',
|
||||
'Former council house': 'Ehemaliger Sozialbau',
|
||||
'Current energy rating': 'Aktuelle Energiebewertung',
|
||||
'Potential energy rating': 'Potenzielle Energiebewertung',
|
||||
'Interior height (m)': 'Raumhöhe (m)',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Distance to nearest train or tube station (km)': 'Entfernung zum nächsten Bahn- oder U-Bahnhof (km)',
|
||||
|
||||
// ─ Feature names (Education) ─
|
||||
'Good+ primary schools within 2km': 'Gute+ Grundschulen im Umkreis von 2 km',
|
||||
'Good+ secondary schools within 2km': 'Gute+ weiterführende Schulen im Umkreis von 2 km',
|
||||
'Good+ primary schools within 5km': 'Gute+ Grundschulen im Umkreis von 5 km',
|
||||
'Good+ secondary schools within 5km': 'Gute+ weiterführende Schulen im Umkreis von 5 km',
|
||||
'Education, Skills and Training Score': 'Score für Bildung, Kompetenzen und Ausbildung',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
'Income Score (rate)': 'Einkommensscore (Rate)',
|
||||
'Employment Score (rate)': 'Beschäftigungsscore (Rate)',
|
||||
'Health Deprivation and Disability Score': 'Score für Gesundheit und Behinderung',
|
||||
'Living Environment Score': 'Score der Wohnumgebung',
|
||||
'Indoors Sub-domain Score': 'Score der Wohnqualität (innen)',
|
||||
'Outdoors Sub-domain Score': 'Score der Umgebungsqualität (außen)',
|
||||
|
||||
// ─ Feature names (Crime) ─
|
||||
'Serious crime per 1k residents (avg/yr)': 'Schwere Straftaten pro 1k Einwohner (Durchschn./Jahr)',
|
||||
'Minor crime per 1k residents (avg/yr)': 'Leichte Straftaten pro 1k Einwohner (Durchschn./Jahr)',
|
||||
'Serious crime (avg/yr)': 'Schwere Straftaten (Durchschn./Jahr)',
|
||||
'Minor crime (avg/yr)': 'Leichte Straftaten (Durchschn./Jahr)',
|
||||
'Violence and sexual offences (avg/yr)': 'Gewalt- und Sexualdelikte (Durchschn./Jahr)',
|
||||
'Burglary (avg/yr)': 'Einbrüche (Durchschn./Jahr)',
|
||||
'Robbery (avg/yr)': 'Raubüberfälle (Durchschn./Jahr)',
|
||||
'Vehicle crime (avg/yr)': 'Fahrzeugkriminalität (Durchschn./Jahr)',
|
||||
'Anti-social behaviour (avg/yr)': 'Antisoziales Verhalten (Durchschn./Jahr)',
|
||||
'Criminal damage and arson (avg/yr)': 'Sachbeschädigung und Brandstiftung (Durchschn./Jahr)',
|
||||
'Other theft (avg/yr)': 'Sonstiger Diebstahl (Durchschn./Jahr)',
|
||||
'Theft from the person (avg/yr)': 'Taschendiebstahl (Durchschn./Jahr)',
|
||||
'Shoplifting (avg/yr)': 'Ladendiebstahl (Durchschn./Jahr)',
|
||||
'Bicycle theft (avg/yr)': 'Fahrraddiebstahl (Durchschn./Jahr)',
|
||||
'Drugs (avg/yr)': 'Drogendelikte (Durchschn./Jahr)',
|
||||
'Possession of weapons (avg/yr)': 'Waffenbesitz (Durchschn./Jahr)',
|
||||
'Public order (avg/yr)': 'Störung der öffentlichen Ordnung (Durchschn./Jahr)',
|
||||
'Other crime (avg/yr)': 'Sonstige Straftaten (Durchschn./Jahr)',
|
||||
|
||||
// ─ Feature names (Demographics) ─
|
||||
'Median age': 'Medianalter',
|
||||
'% White': '% Weiß',
|
||||
'% South Asian': '% Südasiatisch',
|
||||
'% Black': '% Schwarz',
|
||||
'% East Asian': '% Ostasiatisch',
|
||||
'% Mixed': '% Gemischt',
|
||||
'% Other': '% Sonstige',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': 'Entfernung zum nächsten Park (km)',
|
||||
'Number of parks within 2km': 'Anzahl Parks im Umkreis von 2 km',
|
||||
'Number of restaurants within 2km': 'Anzahl Restaurants im Umkreis von 2 km',
|
||||
'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgeschäfte und Supermärkte im Umkreis von 2 km',
|
||||
'Noise (dB)': 'Lärm (dB)',
|
||||
'Max available download speed (Mbps)': 'Max. verfügbare Downloadgeschwindigkeit (Mbps)',
|
||||
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': 'Historischer Verkauf',
|
||||
'For sale': 'Zum Verkauf',
|
||||
'For rent': 'Zur Miete',
|
||||
'Detached': 'Freistehend',
|
||||
'Semi-Detached': 'Doppelhaushälfte',
|
||||
'Terraced': 'Reihenhaus',
|
||||
'Flats/Maisonettes': 'Wohnungen/Maisonetten',
|
||||
'Other': 'Sonstige',
|
||||
'Freehold': 'Volleigentum',
|
||||
'Leasehold': 'Erbbaurecht',
|
||||
'Yes': 'Ja',
|
||||
'No': 'Nein',
|
||||
|
||||
// ─ Stacked chart labels ─
|
||||
'Serious crime': 'Schwere Straftaten',
|
||||
'Minor crime': 'Leichte Straftaten',
|
||||
'Ethnic composition': 'Ethnische Zusammensetzung',
|
||||
|
||||
// ─ POI group names ─
|
||||
'Public Transport': 'Öffentlicher Nahverkehr',
|
||||
'Leisure': 'Freizeit',
|
||||
'Health': 'Gesundheit',
|
||||
'Emergency Services': 'Rettungsdienste',
|
||||
'Groceries': 'Lebensmittel',
|
||||
'Local Businesses': 'Lokale Geschäfte',
|
||||
'Culture': 'Kultur',
|
||||
'Services': 'Dienstleistungen',
|
||||
'Shops': 'Geschäfte',
|
||||
|
||||
// ─ POI categories ─
|
||||
'Airport': 'Flughafen',
|
||||
'Ferry': 'Fähre',
|
||||
'Rail station': 'Bahnhof',
|
||||
'Bus stop': 'Bushaltestelle',
|
||||
'Bus station': 'Busbahnhof',
|
||||
'Taxi rank': 'Taxistand',
|
||||
'Metro or Tram stop': 'U-Bahn- oder Straßenbahnhaltestelle',
|
||||
'Café': 'Café',
|
||||
'Restaurant': 'Restaurant',
|
||||
'Pub': 'Pub',
|
||||
'Bar': 'Bar',
|
||||
'Fast Food': 'Fast Food',
|
||||
'Nightclub': 'Nachtclub',
|
||||
'Cinema': 'Kino',
|
||||
'Theatre': 'Theater',
|
||||
'Live Music & Events': 'Live-Musik & Veranstaltungen',
|
||||
'Park': 'Park',
|
||||
'Playground': 'Spielplatz',
|
||||
'Sports Centre': 'Sportzentrum',
|
||||
'Entertainment': 'Unterhaltung',
|
||||
'Supermarket': 'Supermarkt',
|
||||
'Convenience Store': 'Spätkauf',
|
||||
'Bakery': 'Bäckerei',
|
||||
'Butcher & Fishmonger': 'Metzgerei & Fischhändler',
|
||||
'Greengrocer': 'Gemüsehändler',
|
||||
'Off-Licence': 'Getränkeladen',
|
||||
'Deli & Specialty': 'Feinkost & Spezialitäten',
|
||||
'Fashion & Clothing': 'Mode & Bekleidung',
|
||||
'Electronics': 'Elektronik',
|
||||
'Charity Shop': 'Secondhand-Laden',
|
||||
'DIY & Hardware': 'Baumarkt & Eisenwaren',
|
||||
'Home & Garden': 'Haus & Garten',
|
||||
'Bookshop': 'Buchhandlung',
|
||||
'Pet Shop': 'Tierhandlung',
|
||||
'Sports & Outdoor': 'Sport & Outdoor',
|
||||
'Newsagent': 'Zeitungshändler',
|
||||
'Department Store': 'Kaufhaus',
|
||||
'Gift & Hobby': 'Geschenke & Hobby',
|
||||
'Specialist Shop': 'Fachgeschäft',
|
||||
'Hairdresser & Beauty': 'Friseur & Kosmetik',
|
||||
'Gym & Fitness': 'Fitnessstudio',
|
||||
'Dry Cleaner & Laundry': 'Reinigung & Wäscherei',
|
||||
'Car Services': 'Autoservice',
|
||||
'Post Office': 'Postamt',
|
||||
'Vet & Pet Care': 'Tierarzt & Tierpflege',
|
||||
'Bank': 'Bank',
|
||||
'Travel Agent': 'Reisebüro',
|
||||
'Police': 'Polizei',
|
||||
'Fire Station': 'Feuerwache',
|
||||
'Ambulance Station': 'Rettungswache',
|
||||
'GP Surgery': 'Hausarztpraxis',
|
||||
'Dentist': 'Zahnarzt',
|
||||
'Pharmacy': 'Apotheke',
|
||||
'Hospital & Clinic': 'Krankenhaus & Klinik',
|
||||
'Optician': 'Optiker',
|
||||
'Physiotherapy': 'Physiotherapie',
|
||||
'Counselling & Therapy': 'Beratung & Therapie',
|
||||
'Care Home': 'Pflegeheim',
|
||||
'Medical & Mobility': 'Medizintechnik & Mobilität',
|
||||
'Museum': 'Museum',
|
||||
'Gallery': 'Galerie',
|
||||
'Library': 'Bibliothek',
|
||||
'Place of Worship': 'Gebetsstätte',
|
||||
'Arts Centre': 'Kunstzentrum',
|
||||
'Zoo': 'Zoo',
|
||||
'Tourist Attraction': 'Touristenattraktion',
|
||||
'School': 'Schule',
|
||||
'Hotel': 'Hotel',
|
||||
'Local Business': 'Lokales Geschäft',
|
||||
'Offices': 'Büros',
|
||||
'EV Charging': 'E-Ladestation',
|
||||
'Fuel Station': 'Tankstelle',
|
||||
'Community Centre': 'Gemeindezentrum',
|
||||
|
||||
// ─ Suffixes (used in formatters) ─
|
||||
'/mo': '/Monat',
|
||||
'/yr': '/Jahr',
|
||||
' sqm': ' m²',
|
||||
' km': ' km',
|
||||
' m': ' m',
|
||||
' dB': ' dB',
|
||||
' years': ' Jahre',
|
||||
' rooms': ' Zimmer',
|
||||
},
|
||||
};
|
||||
|
||||
export default de;
|
||||
861
frontend/src/i18n/locales/en.ts
Normal file
861
frontend/src/i18n/locales/en.ts
Normal file
|
|
@ -0,0 +1,861 @@
|
|||
const en = {
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
delete: 'Delete',
|
||||
open: 'Open',
|
||||
share: 'Share',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied!',
|
||||
copiedToClipboard: 'Copied to clipboard',
|
||||
loading: 'Loading...',
|
||||
loadMore: 'Load More',
|
||||
remaining: '{{count}} remaining',
|
||||
search: 'Search',
|
||||
all: 'All',
|
||||
none: 'None',
|
||||
viewDataSource: 'View data source',
|
||||
total: 'Total',
|
||||
min: 'min',
|
||||
or: 'or',
|
||||
area: 'Area',
|
||||
properties: 'Properties',
|
||||
postcode: 'Postcode',
|
||||
noAreaSelected: 'No area selected',
|
||||
noAreaSelectedDesc: 'Click any coloured area on the map to see crime, schools, prices, and more',
|
||||
clickForDetails: 'Click for details',
|
||||
property: 'property',
|
||||
propertiesPlural: 'properties',
|
||||
},
|
||||
|
||||
// ── Header / Nav ───────────────────────────────────
|
||||
header: {
|
||||
appName: 'Perfect Postcode',
|
||||
dashboard: 'Dashboard',
|
||||
learn: 'Learn',
|
||||
pricing: 'Pricing',
|
||||
inviteFriends: 'Invite Friends',
|
||||
saved: 'Saved',
|
||||
logIn: 'Log in',
|
||||
createAccount: 'Create account',
|
||||
sharing: 'Sharing...',
|
||||
exportLabel: 'Export',
|
||||
exporting: 'Exporting...',
|
||||
exportToExcel: 'Export to Excel',
|
||||
openMenu: 'Open menu',
|
||||
closeMenu: 'Close menu',
|
||||
},
|
||||
|
||||
// ── User Menu ──────────────────────────────────────
|
||||
userMenu: {
|
||||
fullAccess: 'Full Access',
|
||||
demo: 'Demo',
|
||||
themeLight: 'Theme: Light',
|
||||
themeDark: 'Theme: Dark',
|
||||
account: 'Account',
|
||||
logOut: 'Log out',
|
||||
},
|
||||
|
||||
// ── Mobile Menu ────────────────────────────────────
|
||||
mobileMenu: {
|
||||
menu: 'Menu',
|
||||
home: 'Home',
|
||||
},
|
||||
|
||||
// ── Auth Modal ─────────────────────────────────────
|
||||
auth: {
|
||||
logIn: 'Log in',
|
||||
createAccount: 'Create account',
|
||||
resetPassword: 'Reset password',
|
||||
valueProp: 'Save searches, bookmark properties, and pick up where you left off.',
|
||||
continueWithGoogle: 'Continue with Google',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: 'Password',
|
||||
passwordPlaceholderRegister: 'Min 8 characters',
|
||||
passwordPlaceholderLogin: 'Your password',
|
||||
forgotPassword: 'Forgot password?',
|
||||
resetSent: 'Check your email for a reset link.',
|
||||
pleaseWait: 'Please wait...',
|
||||
sendResetLink: 'Send reset link',
|
||||
backToLogin: 'Back to login',
|
||||
},
|
||||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: 'See all of England',
|
||||
description: "You're currently exploring the demo area. Get lifetime access to every postcode, every filter, every neighbourhood. One payment, forever.",
|
||||
free: 'Free',
|
||||
once: '/once',
|
||||
freeForEarly: 'Free for early adopters. No credit card required.',
|
||||
oneTimePayment: 'One-time payment. Lifetime access. 30-day money-back guarantee.',
|
||||
redirecting: 'Redirecting...',
|
||||
claimFreeAccess: 'Claim free access',
|
||||
upgradeFor: 'Upgrade for {{price}}',
|
||||
registerAndUpgrade: 'Register & Upgrade',
|
||||
alreadyHaveAccount: 'Already have an account? Log in',
|
||||
continueWithDemo: 'Continue with demo',
|
||||
checkoutFailed: 'Checkout failed',
|
||||
},
|
||||
|
||||
// ── Save Search Modal ──────────────────────────────
|
||||
saveSearch: {
|
||||
title: 'Save Search',
|
||||
saved: 'Search saved',
|
||||
savedSuccess: 'Your search has been saved successfully.',
|
||||
viewSavedSearches: 'View saved searches',
|
||||
name: 'Name',
|
||||
namePlaceholder: 'My search',
|
||||
saving: 'Saving...',
|
||||
},
|
||||
|
||||
// ── License Success ────────────────────────────────
|
||||
licenseSuccess: {
|
||||
title: "You're in.",
|
||||
subtitle: 'Your lifetime access is now active.',
|
||||
description: 'Full access to every feature, every postcode, across all of England.',
|
||||
startExploring: 'Start exploring',
|
||||
},
|
||||
|
||||
// ── Filters ────────────────────────────────────────
|
||||
filters: {
|
||||
activeFilters: 'Active Filters',
|
||||
addFilter: 'Add Filter',
|
||||
historical: 'Historical',
|
||||
buy: 'Buy',
|
||||
rent: 'Rent',
|
||||
findingPerfectPostcode: 'Finding the Perfect Postcode',
|
||||
addFiltersHint: 'Add filters below to narrow the map to areas that match your criteria',
|
||||
upgradePrompt: 'See crime, schools, noise, broadband, and 50+ more filters across all of England.',
|
||||
oneTimeLifetime: 'One-time payment, lifetime access.',
|
||||
upgradeToFullMap: 'Upgrade to full map',
|
||||
chooseFilters: 'Choose the filters that matter to you. The map updates as you go.',
|
||||
searchFeatures: 'Search features...',
|
||||
noMatchingFeatures: 'No matching features',
|
||||
tryDifferentSearch: 'Try a different search term',
|
||||
allFeaturesActive: 'All features are active',
|
||||
removeFilterHint: 'Remove a filter to see available features',
|
||||
featureInfo: 'Feature info',
|
||||
replayTutorial: 'Replay interactive tutorial',
|
||||
clearAll: 'Clear all',
|
||||
clearAllTitle: 'Clear all filters?',
|
||||
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
|
||||
saveAndClear: 'Save & Clear',
|
||||
clearWithoutSaving: 'Clear without saving',
|
||||
},
|
||||
|
||||
// ── Philosophy Popup ───────────────────────────────
|
||||
philosophy: {
|
||||
intro: 'Start with your must-haves, then layer on nice-to-haves. The map narrows as you add filters. The areas left are your best matches.',
|
||||
step1Title: 'Budget and basics',
|
||||
step1Desc: '(price range, floor area, property type)',
|
||||
step2Title: 'Commute',
|
||||
step2Desc: '(travel time to your workplace by car, bike, or transit)',
|
||||
step3Title: 'Safety',
|
||||
step3Desc: '(crime rates, noise levels, ground stability)',
|
||||
step4Title: 'Schools',
|
||||
step4Desc: '(nearby Ofsted-rated Good or Outstanding schools)',
|
||||
step5Title: 'Lifestyle',
|
||||
step5Desc: '(restaurants, parks, broadband speed)',
|
||||
step6Title: 'Energy',
|
||||
step6Desc: '(EPC ratings, insulation, heating costs)',
|
||||
tip: "Tip: if nothing matches, relax one constraint at a time to see which trade-off opens up the most options.",
|
||||
},
|
||||
|
||||
// ── Travel Time ────────────────────────────────────
|
||||
travel: {
|
||||
travelTime: 'Travel Time ({{mode}})',
|
||||
maxTime: 'Max time',
|
||||
selectDestination: 'Select destination...',
|
||||
bestCase: 'Best case',
|
||||
bestCaseTitle: 'Best case travel time',
|
||||
bestCaseDesc: 'Uses the fastest realistic journey time (if you time your departure well and catch good connections). The default uses the <strong>median</strong>, representing a typical journey regardless of when you leave.',
|
||||
previewOnMap: 'Preview on map',
|
||||
stopPreviewing: 'Stop previewing',
|
||||
removeTravelTime: 'Remove travel time',
|
||||
addTravelTime: 'Add {{mode}} travel time',
|
||||
clearDestination: 'Clear destination',
|
||||
typeToFilter: 'Type to filter...',
|
||||
noDestinations: 'No destinations found',
|
||||
modeCar: 'Car',
|
||||
modeBicycle: 'Bicycle',
|
||||
modeWalking: 'Walking',
|
||||
modeTransit: 'Transit',
|
||||
modeCarDesc: 'Drive time via the fastest road route',
|
||||
modeBicycleDesc: 'Cycling time using bike-friendly routes',
|
||||
modeWalkingDesc: 'Walking time along pedestrian paths and pavements',
|
||||
modeTransitDesc: 'Journey time by train, tube, and bus',
|
||||
},
|
||||
|
||||
// ── Travel Time Info Popup ─────────────────────────
|
||||
travelInfo: {
|
||||
transitDesc: ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.',
|
||||
carDesc: ' by car, based on typical road speeds and the road network.',
|
||||
bicycleDesc: ' by bicycle, using cycle-friendly routes.',
|
||||
walkingDesc: ' on foot, using pedestrian paths and pavements.',
|
||||
mainDesc: 'Shows how long it takes to reach the selected destination from each area',
|
||||
sliderHint: 'Use the slider to set your maximum commute time.',
|
||||
},
|
||||
|
||||
// ── AI Filter ──────────────────────────────────────
|
||||
aiFilter: {
|
||||
describeIdealArea: 'Describe your ideal area with AI',
|
||||
aiSearch: 'AI Search',
|
||||
describeHint: "describe what you're looking for",
|
||||
placeholder: 'e.g. quiet area, under £400k, near good schools...',
|
||||
example1: 'Safe area near good schools',
|
||||
example2: '30 min commute to Kings Cross, under £500k',
|
||||
example3: 'Quiet village, 3 bed, fast broadband',
|
||||
analysing: 'Analysing your query...',
|
||||
searchingDestinations: 'Searching for destinations...',
|
||||
generatingFilters: 'Generating filters...',
|
||||
refiningResults: 'Refining results...',
|
||||
weeklyLimitReached: "You've reached the weekly AI usage limit. It will reset automatically next week.",
|
||||
},
|
||||
|
||||
// ── Map Legend ─────────────────────────────────────
|
||||
mapLegend: {
|
||||
clearColourView: 'Clear colour view',
|
||||
historicalMatches: 'Historical property matches',
|
||||
propertiesForSale: 'Properties for sale',
|
||||
propertiesForRent: 'Properties for rent',
|
||||
numberOfProperties: 'Number of properties',
|
||||
previewing: 'Previewing \u201c{{name}}\u201d',
|
||||
},
|
||||
|
||||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: 'Unknown Address',
|
||||
unsaveProperty: 'Unsave property',
|
||||
saveProperty: 'Save property',
|
||||
lastSold: 'Last sold: £{{price}}',
|
||||
estValue: 'Est. value:',
|
||||
type: 'Type:',
|
||||
builtForm: 'Built form:',
|
||||
tenure: 'Tenure:',
|
||||
floorArea: 'Floor area:',
|
||||
bedrooms: 'Bedrooms:',
|
||||
bathrooms: 'Bathrooms:',
|
||||
rooms: 'Rooms:',
|
||||
built: 'Built:',
|
||||
epcRating: 'EPC rating:',
|
||||
epcPotential: 'EPC potential:',
|
||||
listed: 'Listed:',
|
||||
keyFeatures: 'Key features',
|
||||
renovations: 'Renovations',
|
||||
viewExternalListing: 'View external listing',
|
||||
perMonth: '/mo',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: 'Search by address or postcode...',
|
||||
propertyData: 'Property Data',
|
||||
propertyDataDesc: 'Prices come from HM Land Registry (what buyers actually paid). Floor area, energy ratings, construction year, and tenure come from official EPC surveys. Both sources are matched by address within each postcode.',
|
||||
},
|
||||
|
||||
// ── Area Pane ──────────────────────────────────────
|
||||
areaPane: {
|
||||
areaStatistics: 'Area Statistics',
|
||||
statsFor: 'Stats for all properties in this {{type}}',
|
||||
matchingFilters: ' matching all active filters',
|
||||
viewProperties: 'View {{count}} Properties',
|
||||
priceHistory: 'Price History',
|
||||
journeysFrom: 'Journeys from {{label}}',
|
||||
to: 'To {{destination}}',
|
||||
noJourneyData: 'No journey data available',
|
||||
viewOnGoogleMaps: 'View on Google Maps',
|
||||
walk: 'Walk',
|
||||
cycle: 'Cycle',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
histogramLegend: {
|
||||
tealBars: 'Teal bars',
|
||||
tealBarsDesc: 'show the distribution in this selected area',
|
||||
greyBars: 'Grey bars',
|
||||
greyBarsDesc: 'show the overall distribution across all areas',
|
||||
dashedLine: 'Dashed line',
|
||||
dashedLineDesc: 'indicates the national average',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
streetView: {
|
||||
title: 'Street View',
|
||||
},
|
||||
|
||||
// ── POI Pane ───────────────────────────────────────
|
||||
poiPane: {
|
||||
pois: 'POIs',
|
||||
pointsOfInterest: 'Points of Interest',
|
||||
poiDescription: 'Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants, healthcare, leisure, and more. Updated regularly with complete category coverage.',
|
||||
searchCategories: 'Search categories...',
|
||||
dataSourceInfo: 'Data source info',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: 'Search {{radius}} on',
|
||||
outcodeNotRecognised: 'Outcode not recognised',
|
||||
},
|
||||
|
||||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: 'Search places or postcodes...',
|
||||
postcodeNotFound: 'Postcode not found',
|
||||
lookupFailed: 'Lookup failed',
|
||||
searchLabel: 'Search places or postcodes',
|
||||
locateMe: 'Go to my location',
|
||||
geolocationUnsupported: 'Geolocation not supported by your browser',
|
||||
geolocationFailed: 'Could not determine your location',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Close drawer',
|
||||
},
|
||||
|
||||
// ── Home Page ──────────────────────────────────────
|
||||
home: {
|
||||
heroTitle1: 'Maximum',
|
||||
heroTitle2: 'Value',
|
||||
heroTitle3: 'Minimum Compromise.',
|
||||
heroSubtitle: 'House hunting? Make your biggest investment your smartest move.',
|
||||
heroDescription: 'So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that fit.',
|
||||
exploreTheMap: 'Explore the map',
|
||||
seeTheDifference: 'See the difference',
|
||||
statProperties: 'properties',
|
||||
statFilters: 'filters',
|
||||
statEvery: 'Every',
|
||||
statPostcodeInEngland: 'postcode in England',
|
||||
ourPhilosophy: 'Our philosophy',
|
||||
philosophyP1: "On Rightmove, you pick an area first, then hope it's good. You end up cross-referencing crime stats, school reports, and broadband checkers across a dozen tabs, one postcode at a time.",
|
||||
philosophyP2: 'We flip that. Tell us what you need (budget, commute, schools, safety) and we show you every area in England that qualifies. No guesswork. No wasted viewings.',
|
||||
howToUseIt: 'How to use it',
|
||||
howStep1Title: 'Set your must-haves',
|
||||
howStep1Desc: 'Budget, commute, schools — the map shows only what qualifies.',
|
||||
howStep2Title: 'Explore areas and discover hidden gems',
|
||||
howStep2Desc: 'Zoom in, dig into details and nice to haves.',
|
||||
howStep3Title: 'Drill into postcodes',
|
||||
howStep3Desc: 'See individual properties, sale prices, floor area, and compare.',
|
||||
howStep4Title: 'Shortlist with confidence',
|
||||
howStep4Desc: 'Every area on your list meets your actual criteria — not just what was listed that week.',
|
||||
othersVs: 'Others vs',
|
||||
listingPortals: 'Listing portals',
|
||||
checkMyPostcode: '“Check my postcode”',
|
||||
areaGuides: 'Area guides',
|
||||
compSearchWithout: 'Search without choosing an area first',
|
||||
compSearchWithoutSub: '(start with needs, not a location)',
|
||||
compAreaData: 'Area data',
|
||||
compAreaDataSub: '(crime, schools, noise, broadband)',
|
||||
compPropertyData: 'Property-specific data',
|
||||
compPropertyDataSub: '(price, EPC, floor area)',
|
||||
compFilters: '56 combinable filters in one place',
|
||||
compFiltersSub: '(all insights, one interactive map)',
|
||||
ctaTitle: 'Make your biggest investment your smartest move.',
|
||||
ctaDescription: "This deserves proper tools behind it, don't leave it to luck.",
|
||||
},
|
||||
|
||||
// ── Pricing Page ───────────────────────────────────
|
||||
pricingPage: {
|
||||
title: 'Early access pricing',
|
||||
subtitle: 'Pay once, access forever. The earlier you join, the less you pay.',
|
||||
costContext: "Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500 for a survey. Get the wrong area and you're stuck with a long commute, bad schools, or a road you didn't know about.",
|
||||
lessThanSurvey: 'Less than a home survey. Far more useful.',
|
||||
currentTier: 'Current tier',
|
||||
firstNUsers: 'First {{count}} users',
|
||||
everyoneAfter: 'Everyone after',
|
||||
nextNUsers: 'Next {{count}} users',
|
||||
lifetime: '/lifetime',
|
||||
spotsRemaining: '{{count}} spot remaining',
|
||||
spotsRemainingPlural: '{{count}} spots remaining',
|
||||
filled: 'Filled',
|
||||
openDashboard: 'Open dashboard',
|
||||
getStarted: 'Get started',
|
||||
getStartedPrice: 'Get started - {{price}}',
|
||||
noCreditCard: 'No credit card required',
|
||||
moneyBackGuarantee: '30-day money-back guarantee',
|
||||
soldOut: 'Sold out',
|
||||
upcoming: 'Upcoming',
|
||||
failedToLoad: 'Failed to load pricing. Please try again later.',
|
||||
feat1: '56 data layers across England',
|
||||
feat2: 'Every postcode scored and filterable',
|
||||
feat3: 'Unlimited map exploration and exports',
|
||||
feat4: 'Multiple decades of historical price data',
|
||||
feat5: 'Crime, schools, transport, broadband and more',
|
||||
feat6: 'All future data updates included',
|
||||
},
|
||||
|
||||
// ── Learn Page ─────────────────────────────────────
|
||||
learnPage: {
|
||||
faq: 'FAQ',
|
||||
dataSources: 'Data Sources',
|
||||
support: 'Support',
|
||||
dataSourcesIntro: 'This application combines {{count}} open datasets covering property prices, energy performance, transport, demographics, crime, environment, and more.',
|
||||
faqIntro: "Whether you're buying, renting, or just exploring, here's how Perfect Postcode helps you find the right area.",
|
||||
supportIntro: 'Have a question? Check our FAQ or reach out to us directly.',
|
||||
source: 'Source:',
|
||||
optOut: 'Opt out of public disclosure',
|
||||
attribution: 'Attribution',
|
||||
attrLandRegistry: 'Contains HM Land Registry data © Crown copyright and database right 2025.',
|
||||
attrOgl: 'Contains public sector information licensed under the',
|
||||
attrOglLink: 'Open Government Licence v3.0',
|
||||
attrOs: 'Contains OS data © Crown copyright and database rights 2025.',
|
||||
attrTfl: 'Powered by TfL Open Data.',
|
||||
attrOsm: 'Contains data from',
|
||||
attrOsmContrib: '© OpenStreetMap contributors',
|
||||
attrOsmLicense: 'available under the',
|
||||
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
|
||||
// Data source names & descriptions
|
||||
dsPricePaidName: 'Price Paid Data',
|
||||
dsPricePaidOrigin: 'HM Land Registry',
|
||||
dsPricePaidUse: 'Complete historical property sale prices for England.',
|
||||
dsEpcName: 'Energy Performance Certificates (EPC)',
|
||||
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsEpcUse: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Matched with Price Paid records by address within each postcode. Property owners can opt out of public disclosure.',
|
||||
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
|
||||
dsNsplOrigin: 'ONS / ArcGIS',
|
||||
dsNsplUse: 'Maps postcodes to coordinates and statistical area codes, used to link all area-level datasets to individual properties.',
|
||||
dsIodName: 'English Indices of Deprivation 2025',
|
||||
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsIodUse: 'Relative deprivation scores across income, employment, education, health, crime, and living environment for every neighbourhood in England.',
|
||||
dsEthnicityName: 'Population by Ethnicity (2021 Census)',
|
||||
dsEthnicityOrigin: 'ONS',
|
||||
dsEthnicityUse: 'Population percentages by ethnic group (South Asian, East Asian, Black, Mixed, White, Other) per local authority.',
|
||||
dsCrimeName: 'Street-level Crime Data',
|
||||
dsCrimeOrigin: 'data.police.uk',
|
||||
dsCrimeUse: 'Street-level crime data from 2023 to 2025, aggregated into yearly averages by LSOA and crime type (violence, burglary, anti-social behaviour, drugs, vehicle crime, etc.).',
|
||||
dsOsmName: 'OpenStreetMap POIs',
|
||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||
dsOsmUse: 'Points of interest covering shops, restaurants, healthcare, leisure, tourism, and more across Great Britain.',
|
||||
dsGreenspaceName: 'OS Open Greenspace',
|
||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse: 'Authoritative green space boundaries for Great Britain, including public parks, gardens, playing fields, and play spaces. Polygon centroids are used for park proximity counts and distance-to-nearest-park calculations.',
|
||||
dsNaptanName: 'NaPTAN (Public Transport Stops)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse: 'Station and stop locations for rail, bus, metro/tram, ferry, and airports across England.',
|
||||
dsNoiseName: 'Defra Noise Mapping',
|
||||
dsNoiseOrigin: 'Defra / Environment Agency',
|
||||
dsNoiseUse: 'Road noise levels (24-hour weighted average) from the 2022 strategic noise mapping, modelled at high resolution and sampled at each postcode.',
|
||||
dsOfstedName: 'Ofsted School Inspections',
|
||||
dsOfstedOrigin: 'Ofsted',
|
||||
dsOfstedUse: 'Latest inspection outcomes for state-funded schools (as at April 2025). Averaged per postcode to give a local school quality score (1=Outstanding to 4=Inadequate).',
|
||||
dsBroadbandName: 'Ofcom Broadband Performance',
|
||||
dsBroadbandOrigin: 'Ofcom',
|
||||
dsBroadbandUse: 'Fixed broadband coverage and maximum download speeds by area from Ofcom Connected Nations 2025.',
|
||||
dsCouncilTaxName: 'Council Tax Levels 2025-26',
|
||||
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsCouncilTaxUse: 'Annual council tax rates for Bands A-H for all 296 billing authorities in England, for a dwelling occupied by two adults. Joined to properties via local authority district code from the NSPL postcode lookup.',
|
||||
dsRentalName: 'Private Rental Market Statistics',
|
||||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse: 'Median monthly private rental prices by local authority and bedroom category (Oct 2022 - Sep 2023). Joined to properties via local authority district code and estimated bedroom count.',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: 'Finding Your Area',
|
||||
faqCommuteTitle: 'Commute and Travel',
|
||||
faqBudgetTitle: 'Budget and Value',
|
||||
faqSafetyTitle: 'Safety and Neighbourhood',
|
||||
faqFamiliesTitle: 'Families and Schools',
|
||||
faqEnvironmentTitle: 'Environment and Quality of Life',
|
||||
faqWhyTitle: 'Why Perfect Postcode',
|
||||
faqPricingTitle: 'Pricing and Access',
|
||||
faqTipsTitle: 'Tips and Tricks',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: "I don't even know which areas to look at. Can this help?",
|
||||
faqFinding1A: "That's exactly what it's for. Set your filters (budget, commute time, low crime, good schools) and the map lights up to show you every area that ticks every box. No more Googling \"best areas to live near Manchester\" at midnight.",
|
||||
faqFinding2Q: "I'm moving somewhere I've never been. How do I even start?",
|
||||
faqFinding2A: "Set your filters for what matters and the map instantly highlights the areas that qualify. You go from \"I don't know a single street\" to a shortlist in minutes.",
|
||||
faqFinding3Q: 'How do I find areas that tick all my boxes at once?',
|
||||
faqFinding3A: 'Stack multiple filters (crime below average, good schools, commute under 40 minutes) then colour the map by price to spot the best value areas. The map updates live as you drag sliders, so you can see results change in real time.',
|
||||
// FAQ items — Commute and Travel
|
||||
faqCommute1Q: 'Can I see how long my commute would actually be from different areas?',
|
||||
faqCommute1A: "Set your workplace as a destination and we'll colour every postcode by journey time, whether that's by car, bike, or public transport. Filter to your max commute and the rest disappears.",
|
||||
faqCommute2Q: 'How is that better than checking Google Maps?',
|
||||
faqCommute2A: 'Google Maps shows you one journey at a time. We colour every postcode in England by commute time in one go, so you can compare hundreds of areas side by side instead of searching them one by one.',
|
||||
// FAQ items — Budget and Value
|
||||
faqBudget1Q: 'How do I find areas where I get the most space for my money?',
|
||||
faqBudget1A: "Filter by price per sqm and you'll instantly see which postcodes give you the most space per pound. Pair it with the energy rating filter to avoid properties with high heating costs.",
|
||||
faqBudget2Q: "How do I make sure a cheap area isn't cheap for a reason?",
|
||||
faqBudget2A: "Layer deprivation scores, crime stats, school ratings, and broadband speeds alongside price. If a postcode is affordable and scores well on everything that matters, you've found genuine value, not just a low price with trade-offs you haven't spotted yet.",
|
||||
// FAQ items — Safety and Neighbourhood
|
||||
faqSafety1Q: 'How can I check if an area is safe before I move there?',
|
||||
faqSafety1A: 'We overlay real police-recorded crime data, broken down by type, onto every neighbourhood in England. Filter by violent crime, burglary, or antisocial behaviour and instantly see which postcodes have the lowest numbers.',
|
||||
faqSafety2Q: 'I keep finding flats that look great online, then the area turns out to be rough.',
|
||||
faqSafety2A: "That's exactly why this exists. Stack crime rates, noise levels, deprivation scores, nearby pubs and parks, and broadband speeds all on one map so you know what a neighbourhood is actually like before you book a viewing.",
|
||||
// FAQ items — Families and Schools
|
||||
faqFamilies1Q: 'Can I find areas with good schools AND low crime in one search?',
|
||||
faqFamilies1A: "Yes. Stack filters for Ofsted ratings, crime rates, parks, and whatever else matters to your family, and the map highlights only the areas that tick every box. No more cross-referencing five different websites.",
|
||||
faqFamilies2Q: 'How do I know if a neighbourhood has parks and playgrounds nearby?',
|
||||
faqFamilies2A: 'Toggle on the parks and green spaces POI layer to see them right on the map. You can also filter by how many are within walking distance of each postcode.',
|
||||
// FAQ items — Environment and Quality of Life
|
||||
faqEnv1Q: "Can I find energy-efficient homes that aren't on a noisy road?",
|
||||
faqEnv1A: 'Filter by EPC rating (A to C), then layer on road noise data to rule out anything above your threshold. Colour-code by either feature to spot quiet, efficient streets at a glance.',
|
||||
faqEnv2Q: 'Does it show flood or subsidence risk?',
|
||||
faqEnv2A: "We include ground stability data so you can check for subsidence, shrink-swell clay, and other geological hazards before committing to a property. Filter out risky areas early.",
|
||||
faqEnv3Q: 'Can I find areas with fast broadband that are actually quiet?',
|
||||
faqEnv3A: 'Layer the broadband speed filter with road noise data to find streets with great connectivity and low traffic noise. Colour-code by either metric to compare areas at a glance.',
|
||||
// FAQ items — Why Perfect Postcode
|
||||
faqWhy1Q: 'I already use Rightmove. What does this add?',
|
||||
faqWhy1A: "Rightmove shows you houses. We show you areas. Crime rates, school ratings, broadband speeds, noise levels, deprivation scores, and more, all filterable on one map. You can judge a neighbourhood before you even look at listings.",
|
||||
faqWhy2Q: "Can't I just research all this myself for free?",
|
||||
faqWhy2A: 'You could cross-reference police data, Ofsted reports, EPC registers, Land Registry records, and ONS statistics one postcode at a time. Or you could have it all filterable and colour-coded on one map in seconds.',
|
||||
faqWhy3Q: 'Where does the data actually come from?',
|
||||
faqWhy3A: "Every dataset comes from official UK government sources: Land Registry, the EPC register, ONS, Ofsted, Ofcom, data.police.uk, and Defra. We don't scrape estate agents or make anything up. You can verify any record against the original source.",
|
||||
// FAQ items — Pricing and Access
|
||||
faqPricing1Q: 'Is it really worth paying for a property search tool?',
|
||||
faqPricing1A: "Buying a home is likely the biggest purchase you'll make. Spotting one red flag (a noisy road, poor broadband, rising crime) before committing could save you years of regret. This costs less than a tank of petrol.",
|
||||
faqPricing2Q: 'Is this a subscription?',
|
||||
faqPricing2A: "No. One-time payment, yours forever. Use it intensively during your search, come back whenever you're curious about a new area, and it's still there if you ever move again.",
|
||||
faqPricing3Q: 'What can I access on the free tier?',
|
||||
faqPricing3A: 'Free users can explore all features within the demo area (inner London, roughly zones 1 to 2). To access data for the rest of England, you need lifetime access.',
|
||||
faqPricing4Q: 'Can I get a refund?',
|
||||
faqPricing4A: 'Absolutely. We offer a 30-day money-back guarantee. If you’re not satisfied, email support@perfect-postcode.co.uk within 30 days for a full refund.',
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: 'How do I use the AI filter instead of adding filters one by one?',
|
||||
faqTips1A: 'Type what you want in plain English, something like "quiet area near good schools with fast broadband under £400k", and it\'ll set up all the relevant filters in one go. Tweak any of them manually afterwards.',
|
||||
faqTips2Q: 'Can I save a search and come back to it later?',
|
||||
faqTips2A: 'Hit the save button and everything is captured: your filters, zoom level, and which data layer you’re colouring by. Pick up exactly where you left off or share the link with your partner.',
|
||||
faqTips3Q: "Can I export the data I'm looking at?",
|
||||
faqTips3A: 'Use the export button to download the currently filtered properties as a spreadsheet. The export respects all your active filters, so you get exactly the data you want.',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
accountPage: {
|
||||
emailLabel: 'Email',
|
||||
subscriptionLabel: 'Subscription',
|
||||
upgrade: 'Upgrade',
|
||||
redirecting: 'Redirecting…',
|
||||
receiveNewsletter: 'Receive newsletter emails',
|
||||
needHelp: 'Need help? Email us at',
|
||||
responseTime: 'We typically respond within 24 hours.',
|
||||
},
|
||||
|
||||
// ── Saved Page ─────────────────────────────────────
|
||||
savedPage: {
|
||||
searches: 'Searches',
|
||||
noSavedSearches: 'No saved searches yet',
|
||||
noSavedSearchesDesc: 'Save your filters and map view so you can pick up exactly where you left off.',
|
||||
noSavedProperties: 'No saved properties yet',
|
||||
noSavedPropertiesDesc: 'Bookmark properties as you explore and build your shortlist without losing track.',
|
||||
openPostcode: 'Open postcode',
|
||||
viewListing: 'View listing',
|
||||
clickToRename: 'Click to rename',
|
||||
notesPlaceholder: 'Jot down your thoughts...',
|
||||
deleteSearch: 'Delete search',
|
||||
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This cannot be undone.',
|
||||
deleteProperty: 'Delete property',
|
||||
deletePropertyConfirm: 'Are you sure you want to delete this saved property? This cannot be undone.',
|
||||
bed: 'bed',
|
||||
epc: 'EPC',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
invitesPage: {
|
||||
inviteLinksLicensed: 'Invite links are available for licensed users.',
|
||||
inviteAdminLabel: 'Invite friends (100% off)',
|
||||
inviteReferralLabel: 'Invite friends (30% off)',
|
||||
generateFreeInvite: 'Generate free invite link',
|
||||
generateReferralLink: 'Generate referral link',
|
||||
copyInviteLink: 'Copy invite link',
|
||||
adminInvitesTitle: 'Admin invites (100% off)',
|
||||
referralInvitesTitle: 'Referral invites (30% off)',
|
||||
yourInviteLinks: 'Your invite links',
|
||||
noInvitesYet: 'No invites generated yet',
|
||||
link: 'Link',
|
||||
status: 'Status',
|
||||
created: 'Created',
|
||||
redeemed: 'Redeemed',
|
||||
pending: 'Pending',
|
||||
},
|
||||
|
||||
// ── Invite Page ────────────────────────────────────
|
||||
invitePage: {
|
||||
youreInvited: "You're invited!",
|
||||
specialOffer: 'Special offer!',
|
||||
invitedByFree: '{{name}} has invited you to get free lifetime access.',
|
||||
invitedByDiscount: '{{name}} has shared a 30% discount on lifetime access.',
|
||||
genericFreeInvite: 'You have been invited to get free lifetime access.',
|
||||
genericDiscount: 'A friend has shared a 30% discount on lifetime access.',
|
||||
exploreEvery: 'Explore every neighbourhood in England',
|
||||
propertyInfo: 'Property prices, energy ratings, crime stats, school ratings and more',
|
||||
invalidInvite: 'Invalid invite',
|
||||
inviteAlreadyUsed: 'Invite already used',
|
||||
inviteAlreadyUsedDesc: 'This invite link has already been redeemed.',
|
||||
invalidInviteLink: 'Invalid invite link',
|
||||
invalidInviteLinkDesc: 'This invite link is invalid or has expired.',
|
||||
licenseActivated: 'License activated!',
|
||||
fullAccessGranted: 'You now have full access to Perfect Postcode.',
|
||||
activating: 'Activating...',
|
||||
activateLicense: 'Activate license',
|
||||
claimDiscount: 'Claim discount',
|
||||
registerToClaim: 'Register to claim',
|
||||
youAlreadyHaveLicense: 'You already have a license',
|
||||
accountHasFullAccess: 'Your account already has full access.',
|
||||
failedToValidate: 'Failed to validate invite link',
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: 'Unsave',
|
||||
savedProperty: 'Saved',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: 'just now',
|
||||
minutesAgo: '{{count}}m ago',
|
||||
hoursAgo: '{{count}}h ago',
|
||||
daysAgo: '{{count}}d ago',
|
||||
nFilters: '{{count}} filters',
|
||||
noFilters: 'No filters',
|
||||
poiCategory: '{{count}} POI category',
|
||||
poiCategories: '{{count}} POI categories',
|
||||
travelDestination: '{{count}} travel time destination',
|
||||
travelDestinations: '{{count}} travel time destinations',
|
||||
propertiesMatch: '{{count}} properties match',
|
||||
setFilters: 'Set {{count}} filter(s): {{list}}',
|
||||
noFiltersSet: 'No filters set',
|
||||
toDestination: '{{mode}} to {{label}} {{bounds}}',
|
||||
lessThanMin: '< {{max}} min',
|
||||
moreThanMin: '> {{min}} min',
|
||||
},
|
||||
|
||||
// ── Tutorial ──────────────────────────────────────
|
||||
tutorial: {
|
||||
step1Title: 'Tell the map what matters',
|
||||
step1Content: 'Set your budget, commute limit, school quality, crime threshold. Whatever matters to you. Only areas that qualify stay lit. Use the eye icon to colour by any feature.',
|
||||
step2Title: 'Or just describe it',
|
||||
step2Content: 'Type what you want in plain English, like "quiet area near good schools under £400k", and we’ll set up the filters for you.',
|
||||
step3Title: 'Explore what’s out there',
|
||||
step3Content: 'Pan and zoom across England. Click any coloured area to see crime, schools, prices, broadband, noise, and more about that neighbourhood.',
|
||||
step4Title: 'Jump to a location',
|
||||
step4Content: 'Search for any place or postcode to fly straight there.',
|
||||
step5Title: 'Dig into the details',
|
||||
step5Content: 'See area statistics, histograms, and individual property records: prices, floor area, energy ratings, and more.',
|
||||
step6Title: 'What’s nearby?',
|
||||
step6Content: 'Toggle schools, shops, stations, parks, and restaurants on the map to see what’s within reach.',
|
||||
},
|
||||
|
||||
// ── Server-derived values ──────────────────────────
|
||||
// Keyed by the English server value. ts() looks up translations at display time.
|
||||
// The English keys MUST match exactly what the API returns.
|
||||
server: {
|
||||
// ─ Feature group names ─
|
||||
'Properties': 'Properties',
|
||||
'Transport': 'Transport',
|
||||
'Education': 'Education',
|
||||
'Deprivation': 'Deprivation',
|
||||
'Crime': 'Crime',
|
||||
'Demographics': 'Demographics',
|
||||
'Amenities': 'Amenities',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': 'Listing status',
|
||||
'Property type': 'Property type',
|
||||
'Leasehold/Freehold': 'Leasehold/Freehold',
|
||||
'Last known price': 'Last known price',
|
||||
'Estimated current price': 'Estimated current price',
|
||||
'Asking price': 'Asking price',
|
||||
'Price per sqm': 'Price per sqm',
|
||||
'Est. price per sqm': 'Est. price per sqm',
|
||||
'Asking price per sqm': 'Asking price per sqm',
|
||||
'Estimated monthly rent': 'Estimated monthly rent',
|
||||
'Asking rent (monthly)': 'Asking rent (monthly)',
|
||||
'Total floor area (sqm)': 'Total floor area (sqm)',
|
||||
'Number of bedrooms & living rooms': 'Number of bedrooms & living rooms',
|
||||
'Bedrooms': 'Bedrooms',
|
||||
'Bathrooms': 'Bathrooms',
|
||||
'Construction year': 'Construction year',
|
||||
'Date of last transaction': 'Date of last transaction',
|
||||
'Listing date': 'Listing date',
|
||||
'Former council house': 'Former council house',
|
||||
'Current energy rating': 'Current energy rating',
|
||||
'Potential energy rating': 'Potential energy rating',
|
||||
'Interior height (m)': 'Interior height (m)',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Distance to nearest train or tube station (km)': 'Distance to nearest train or tube station (km)',
|
||||
|
||||
// ─ Feature names (Education) ─
|
||||
'Good+ primary schools within 2km': 'Good+ primary schools within 2km',
|
||||
'Good+ secondary schools within 2km': 'Good+ secondary schools within 2km',
|
||||
'Good+ primary schools within 5km': 'Good+ primary schools within 5km',
|
||||
'Good+ secondary schools within 5km': 'Good+ secondary schools within 5km',
|
||||
'Education, Skills and Training Score': 'Education, Skills and Training Score',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
'Income Score (rate)': 'Income Score (rate)',
|
||||
'Employment Score (rate)': 'Employment Score (rate)',
|
||||
'Health Deprivation and Disability Score': 'Health Deprivation and Disability Score',
|
||||
'Living Environment Score': 'Living Environment Score',
|
||||
'Indoors Sub-domain Score': 'Indoors Sub-domain Score',
|
||||
'Outdoors Sub-domain Score': 'Outdoors Sub-domain Score',
|
||||
|
||||
// ─ Feature names (Crime) ─
|
||||
'Serious crime per 1k residents (avg/yr)': 'Serious crime per 1k residents (avg/yr)',
|
||||
'Minor crime per 1k residents (avg/yr)': 'Minor crime per 1k residents (avg/yr)',
|
||||
'Serious crime (avg/yr)': 'Serious crime (avg/yr)',
|
||||
'Minor crime (avg/yr)': 'Minor crime (avg/yr)',
|
||||
'Violence and sexual offences (avg/yr)': 'Violence and sexual offences (avg/yr)',
|
||||
'Burglary (avg/yr)': 'Burglary (avg/yr)',
|
||||
'Robbery (avg/yr)': 'Robbery (avg/yr)',
|
||||
'Vehicle crime (avg/yr)': 'Vehicle crime (avg/yr)',
|
||||
'Anti-social behaviour (avg/yr)': 'Anti-social behaviour (avg/yr)',
|
||||
'Criminal damage and arson (avg/yr)': 'Criminal damage and arson (avg/yr)',
|
||||
'Other theft (avg/yr)': 'Other theft (avg/yr)',
|
||||
'Theft from the person (avg/yr)': 'Theft from the person (avg/yr)',
|
||||
'Shoplifting (avg/yr)': 'Shoplifting (avg/yr)',
|
||||
'Bicycle theft (avg/yr)': 'Bicycle theft (avg/yr)',
|
||||
'Drugs (avg/yr)': 'Drugs (avg/yr)',
|
||||
'Possession of weapons (avg/yr)': 'Possession of weapons (avg/yr)',
|
||||
'Public order (avg/yr)': 'Public order (avg/yr)',
|
||||
'Other crime (avg/yr)': 'Other crime (avg/yr)',
|
||||
|
||||
// ─ Feature names (Demographics) ─
|
||||
'Median age': 'Median age',
|
||||
'% White': '% White',
|
||||
'% South Asian': '% South Asian',
|
||||
'% Black': '% Black',
|
||||
'% East Asian': '% East Asian',
|
||||
'% Mixed': '% Mixed',
|
||||
'% Other': '% Other',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': 'Distance to nearest park (km)',
|
||||
'Number of parks within 2km': 'Number of parks within 2km',
|
||||
'Number of restaurants within 2km': 'Number of restaurants within 2km',
|
||||
'Number of grocery shops and supermarkets within 2km': 'Number of grocery shops and supermarkets within 2km',
|
||||
'Noise (dB)': 'Noise (dB)',
|
||||
'Max available download speed (Mbps)': 'Max available download speed (Mbps)',
|
||||
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': 'Historical sale',
|
||||
'For sale': 'For sale',
|
||||
'For rent': 'For rent',
|
||||
'Detached': 'Detached',
|
||||
'Semi-Detached': 'Semi-Detached',
|
||||
'Terraced': 'Terraced',
|
||||
'Flats/Maisonettes': 'Flats/Maisonettes',
|
||||
'Other': 'Other',
|
||||
'Freehold': 'Freehold',
|
||||
'Leasehold': 'Leasehold',
|
||||
'Yes': 'Yes',
|
||||
'No': 'No',
|
||||
|
||||
// ─ Stacked chart labels ─
|
||||
'Serious crime': 'Serious crime',
|
||||
'Minor crime': 'Minor crime',
|
||||
'Ethnic composition': 'Ethnic composition',
|
||||
|
||||
// ─ POI group names ─
|
||||
'Public Transport': 'Public Transport',
|
||||
'Leisure': 'Leisure',
|
||||
'Health': 'Health',
|
||||
'Emergency Services': 'Emergency Services',
|
||||
'Groceries': 'Groceries',
|
||||
'Local Businesses': 'Local Businesses',
|
||||
'Culture': 'Culture',
|
||||
'Services': 'Services',
|
||||
'Shops': 'Shops',
|
||||
|
||||
// ─ POI categories ─
|
||||
'Airport': 'Airport',
|
||||
'Ferry': 'Ferry',
|
||||
'Rail station': 'Rail station',
|
||||
'Bus stop': 'Bus stop',
|
||||
'Bus station': 'Bus station',
|
||||
'Taxi rank': 'Taxi rank',
|
||||
'Metro or Tram stop': 'Metro or Tram stop',
|
||||
'Café': 'Café',
|
||||
'Restaurant': 'Restaurant',
|
||||
'Pub': 'Pub',
|
||||
'Bar': 'Bar',
|
||||
'Fast Food': 'Fast Food',
|
||||
'Nightclub': 'Nightclub',
|
||||
'Cinema': 'Cinema',
|
||||
'Theatre': 'Theatre',
|
||||
'Live Music & Events': 'Live Music & Events',
|
||||
'Park': 'Park',
|
||||
'Playground': 'Playground',
|
||||
'Sports Centre': 'Sports Centre',
|
||||
'Entertainment': 'Entertainment',
|
||||
'Supermarket': 'Supermarket',
|
||||
'Convenience Store': 'Convenience Store',
|
||||
'Bakery': 'Bakery',
|
||||
'Butcher & Fishmonger': 'Butcher & Fishmonger',
|
||||
'Greengrocer': 'Greengrocer',
|
||||
'Off-Licence': 'Off-Licence',
|
||||
'Deli & Specialty': 'Deli & Specialty',
|
||||
'Fashion & Clothing': 'Fashion & Clothing',
|
||||
'Electronics': 'Electronics',
|
||||
'Charity Shop': 'Charity Shop',
|
||||
'DIY & Hardware': 'DIY & Hardware',
|
||||
'Home & Garden': 'Home & Garden',
|
||||
'Bookshop': 'Bookshop',
|
||||
'Pet Shop': 'Pet Shop',
|
||||
'Sports & Outdoor': 'Sports & Outdoor',
|
||||
'Newsagent': 'Newsagent',
|
||||
'Department Store': 'Department Store',
|
||||
'Gift & Hobby': 'Gift & Hobby',
|
||||
'Specialist Shop': 'Specialist Shop',
|
||||
'Hairdresser & Beauty': 'Hairdresser & Beauty',
|
||||
'Gym & Fitness': 'Gym & Fitness',
|
||||
'Dry Cleaner & Laundry': 'Dry Cleaner & Laundry',
|
||||
'Car Services': 'Car Services',
|
||||
'Post Office': 'Post Office',
|
||||
'Vet & Pet Care': 'Vet & Pet Care',
|
||||
'Bank': 'Bank',
|
||||
'Travel Agent': 'Travel Agent',
|
||||
'Police': 'Police',
|
||||
'Fire Station': 'Fire Station',
|
||||
'Ambulance Station': 'Ambulance Station',
|
||||
'GP Surgery': 'GP Surgery',
|
||||
'Dentist': 'Dentist',
|
||||
'Pharmacy': 'Pharmacy',
|
||||
'Hospital & Clinic': 'Hospital & Clinic',
|
||||
'Optician': 'Optician',
|
||||
'Physiotherapy': 'Physiotherapy',
|
||||
'Counselling & Therapy': 'Counselling & Therapy',
|
||||
'Care Home': 'Care Home',
|
||||
'Medical & Mobility': 'Medical & Mobility',
|
||||
'Museum': 'Museum',
|
||||
'Gallery': 'Gallery',
|
||||
'Library': 'Library',
|
||||
'Place of Worship': 'Place of Worship',
|
||||
'Arts Centre': 'Arts Centre',
|
||||
'Zoo': 'Zoo',
|
||||
'Tourist Attraction': 'Tourist Attraction',
|
||||
'School': 'School',
|
||||
'Hotel': 'Hotel',
|
||||
'Local Business': 'Local Business',
|
||||
'Offices': 'Offices',
|
||||
'EV Charging': 'EV Charging',
|
||||
'Fuel Station': 'Fuel Station',
|
||||
'Community Centre': 'Community Centre',
|
||||
|
||||
// ─ Suffixes (used in formatters) ─
|
||||
'/mo': '/mo',
|
||||
'/yr': '/yr',
|
||||
' sqm': ' sqm',
|
||||
' km': ' km',
|
||||
' m': ' m',
|
||||
' dB': ' dB',
|
||||
' years': ' years',
|
||||
' rooms': ' rooms',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
|
||||
/**
|
||||
* Recursively maps a translation object's leaf values to `string`,
|
||||
* preserving the nested key structure. Used to type-check non-English
|
||||
* locale files: they must have exactly the same keys as English,
|
||||
* but with their own string values.
|
||||
*
|
||||
* Missing key -> TS error. Extra key -> TS error.
|
||||
*/
|
||||
export type DeepStringify<T> = {
|
||||
[K in keyof T]: T[K] extends Record<string, unknown> ? DeepStringify<T[K]> : string;
|
||||
};
|
||||
|
||||
export type Translations = DeepStringify<typeof en>;
|
||||
891
frontend/src/i18n/locales/fr.ts
Normal file
891
frontend/src/i18n/locales/fr.ts
Normal file
|
|
@ -0,0 +1,891 @@
|
|||
import { Translations } from './en';
|
||||
|
||||
const fr: Translations = {
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Enregistrer',
|
||||
cancel: 'Annuler',
|
||||
close: 'Fermer',
|
||||
delete: 'Supprimer',
|
||||
open: 'Ouvrir',
|
||||
share: 'Partager',
|
||||
copy: 'Copier',
|
||||
copied: 'Copié !',
|
||||
copiedToClipboard: 'Copié dans le presse-papiers',
|
||||
loading: 'Chargement...',
|
||||
loadMore: 'Charger plus',
|
||||
remaining: '{{count}} restant(s)',
|
||||
search: 'Rechercher',
|
||||
all: 'Tous',
|
||||
none: 'Aucun',
|
||||
viewDataSource: 'Voir la source des données',
|
||||
total: 'Total',
|
||||
min: 'min',
|
||||
or: 'ou',
|
||||
area: 'Zone',
|
||||
properties: 'Propriétés',
|
||||
postcode: 'Code postal',
|
||||
noAreaSelected: 'Aucune zone sélectionnée',
|
||||
noAreaSelectedDesc:
|
||||
'Cliquez sur une zone colorée de la carte pour voir la criminalité, les écoles, les prix et plus encore',
|
||||
clickForDetails: 'Cliquez pour les détails',
|
||||
property: 'propriété',
|
||||
propertiesPlural: 'propriétés',
|
||||
},
|
||||
|
||||
// ── Header / Nav ───────────────────────────────────
|
||||
header: {
|
||||
appName: 'Perfect Postcode',
|
||||
dashboard: 'Tableau de bord',
|
||||
learn: 'En savoir plus',
|
||||
pricing: 'Tarifs',
|
||||
inviteFriends: 'Inviter des amis',
|
||||
saved: 'Enregistrés',
|
||||
logIn: 'Se connecter',
|
||||
createAccount: 'Créer un compte',
|
||||
sharing: 'Partage en cours...',
|
||||
exportLabel: 'Exporter',
|
||||
exporting: 'Exportation...',
|
||||
exportToExcel: 'Exporter vers Excel',
|
||||
openMenu: 'Ouvrir le menu',
|
||||
closeMenu: 'Fermer le menu',
|
||||
},
|
||||
|
||||
// ── User Menu ──────────────────────────────────────
|
||||
userMenu: {
|
||||
fullAccess: 'Accès complet',
|
||||
demo: 'Démo',
|
||||
themeLight: 'Thème : Clair',
|
||||
themeDark: 'Thème : Sombre',
|
||||
account: 'Compte',
|
||||
logOut: 'Se déconnecter',
|
||||
},
|
||||
|
||||
// ── Mobile Menu ────────────────────────────────────
|
||||
mobileMenu: {
|
||||
menu: 'Menu',
|
||||
home: 'Accueil',
|
||||
},
|
||||
|
||||
// ── Auth Modal ─────────────────────────────────────
|
||||
auth: {
|
||||
logIn: 'Se connecter',
|
||||
createAccount: 'Créer un compte',
|
||||
resetPassword: 'Réinitialiser le mot de passe',
|
||||
valueProp:
|
||||
'Enregistrez vos recherches, ajoutez des propriétés en favoris et reprenez là où vous vous étiez arrêté.',
|
||||
continueWithGoogle: 'Continuer avec Google',
|
||||
email: 'E-mail',
|
||||
emailPlaceholder: 'vous@exemple.com',
|
||||
password: 'Mot de passe',
|
||||
passwordPlaceholderRegister: '8 caractères minimum',
|
||||
passwordPlaceholderLogin: 'Votre mot de passe',
|
||||
forgotPassword: 'Mot de passe oublié ?',
|
||||
resetSent: 'Vérifiez votre boîte e-mail pour le lien de réinitialisation.',
|
||||
pleaseWait: 'Veuillez patienter...',
|
||||
sendResetLink: 'Envoyer le lien de réinitialisation',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
},
|
||||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: "Découvrez toute l'Angleterre",
|
||||
description:
|
||||
"Vous explorez actuellement la zone de démonstration. Obtenez un accès à vie à chaque code postal, chaque filtre, chaque quartier. Un seul paiement, pour toujours.",
|
||||
free: 'Gratuit',
|
||||
once: '/unique',
|
||||
freeForEarly: 'Gratuit pour les premiers utilisateurs. Aucune carte bancaire requise.',
|
||||
oneTimePayment:
|
||||
'Paiement unique. Accès à vie. Garantie satisfait ou remboursé sous 30 jours.',
|
||||
redirecting: 'Redirection...',
|
||||
claimFreeAccess: "Réclamer l'accès gratuit",
|
||||
upgradeFor: 'Passer à la version complète pour {{price}}',
|
||||
registerAndUpgrade: "S'inscrire et passer à la version complète",
|
||||
alreadyHaveAccount: 'Vous avez déjà un compte ? Connectez-vous',
|
||||
continueWithDemo: 'Continuer avec la démo',
|
||||
checkoutFailed: 'Échec du paiement',
|
||||
},
|
||||
|
||||
// ── Save Search Modal ──────────────────────────────
|
||||
saveSearch: {
|
||||
title: 'Enregistrer la recherche',
|
||||
saved: 'Recherche enregistrée',
|
||||
savedSuccess: 'Votre recherche a été enregistrée avec succès.',
|
||||
viewSavedSearches: 'Voir les recherches enregistrées',
|
||||
name: 'Nom',
|
||||
namePlaceholder: 'Ma recherche',
|
||||
saving: 'Enregistrement...',
|
||||
},
|
||||
|
||||
// ── License Success ────────────────────────────────
|
||||
licenseSuccess: {
|
||||
title: "C'est fait.",
|
||||
subtitle: 'Votre accès à vie est maintenant actif.',
|
||||
description:
|
||||
"Accès complet à chaque fonctionnalité, chaque code postal, dans toute l'Angleterre.",
|
||||
startExploring: 'Commencer à explorer',
|
||||
},
|
||||
|
||||
// ── Filters ────────────────────────────────────────
|
||||
filters: {
|
||||
activeFilters: 'Filtres actifs',
|
||||
addFilter: 'Ajouter un filtre',
|
||||
historical: 'Historique',
|
||||
buy: 'Acheter',
|
||||
rent: 'Louer',
|
||||
findingPerfectPostcode: 'Trouver le code postal idéal',
|
||||
addFiltersHint:
|
||||
'Ajoutez des filtres ci-dessous pour restreindre la carte aux zones correspondant à vos critères',
|
||||
upgradePrompt:
|
||||
"Voir la criminalité, les écoles, le bruit, le débit internet et plus de 50 filtres dans toute l'Angleterre.",
|
||||
oneTimeLifetime: 'Paiement unique, accès à vie.',
|
||||
upgradeToFullMap: 'Passer à la carte complète',
|
||||
chooseFilters:
|
||||
'Choisissez les filtres qui comptent pour vous. La carte se met à jour en temps réel.',
|
||||
searchFeatures: 'Rechercher des critères...',
|
||||
noMatchingFeatures: 'Aucun critère correspondant',
|
||||
tryDifferentSearch: 'Essayez un autre terme de recherche',
|
||||
allFeaturesActive: 'Tous les critères sont actifs',
|
||||
removeFilterHint: 'Supprimez un filtre pour voir les critères disponibles',
|
||||
featureInfo: 'Informations sur le critère',
|
||||
replayTutorial: 'Rejouer le tutoriel interactif',
|
||||
clearAll: 'Tout effacer',
|
||||
clearAllTitle: 'Effacer tous les filtres ?',
|
||||
clearAllSavePrompt: 'Souhaitez-vous sauvegarder vos filtres actuels avant de les effacer ?',
|
||||
saveAndClear: 'Sauvegarder et effacer',
|
||||
clearWithoutSaving: 'Effacer sans sauvegarder',
|
||||
},
|
||||
|
||||
// ── Philosophy Popup ───────────────────────────────
|
||||
philosophy: {
|
||||
intro:
|
||||
"Commencez par vos critères indispensables, puis ajoutez les critères souhaités. La carte se réduit au fur et à mesure que vous ajoutez des filtres. Les zones restantes sont vos meilleures correspondances.",
|
||||
step1Title: 'Budget et fondamentaux',
|
||||
step1Desc: '(fourchette de prix, surface, type de bien)',
|
||||
step2Title: 'Trajet',
|
||||
step2Desc: '(temps de trajet vers votre lieu de travail en voiture, vélo ou transports)',
|
||||
step3Title: 'Sécurité',
|
||||
step3Desc: '(taux de criminalité, niveaux de bruit, stabilité du sol)',
|
||||
step4Title: 'Écoles',
|
||||
step4Desc: '(proximité d’écoles notées Bien ou Excellent par Ofsted)',
|
||||
step5Title: 'Cadre de vie',
|
||||
step5Desc: '(restaurants, parcs, débit internet)',
|
||||
step6Title: 'Énergie',
|
||||
step6Desc: '(classements DPE, isolation, coûts de chauffage)',
|
||||
tip: "Astuce : si rien ne correspond, assouplissez un critère à la fois pour voir quel compromis ouvre le plus d'options.",
|
||||
},
|
||||
|
||||
// ── Travel Time ────────────────────────────────────
|
||||
travel: {
|
||||
travelTime: 'Temps de trajet ({{mode}})',
|
||||
maxTime: 'Temps maximum',
|
||||
selectDestination: 'Sélectionner une destination...',
|
||||
bestCase: 'Meilleur cas',
|
||||
bestCaseTitle: 'Meilleur temps de trajet',
|
||||
bestCaseDesc:
|
||||
"Utilise le temps de trajet réaliste le plus rapide (si vous partez au bon moment et avez de bonnes correspondances). Par défaut, la <strong>médiane</strong> est utilisée, représentant un trajet typique quelle que soit l'heure de départ.",
|
||||
previewOnMap: 'Aperçu sur la carte',
|
||||
stopPreviewing: "Arrêter l'aperçu",
|
||||
removeTravelTime: 'Supprimer le temps de trajet',
|
||||
addTravelTime: 'Ajouter le temps de trajet en {{mode}}',
|
||||
clearDestination: 'Effacer la destination',
|
||||
typeToFilter: 'Tapez pour filtrer...',
|
||||
noDestinations: 'Aucune destination trouvée',
|
||||
modeCar: 'Voiture',
|
||||
modeBicycle: 'Vélo',
|
||||
modeWalking: 'Marche',
|
||||
modeTransit: 'Transports',
|
||||
modeCarDesc: 'Temps de conduite via l’itinéraire routier le plus rapide',
|
||||
modeBicycleDesc: 'Temps de trajet à vélo via des itinéraires cyclables',
|
||||
modeWalkingDesc: 'Temps de marche le long des chemins piétons et trottoirs',
|
||||
modeTransitDesc: 'Temps de trajet en train, métro et bus',
|
||||
},
|
||||
|
||||
// ── Travel Time Info Popup ─────────────────────────
|
||||
travelInfo: {
|
||||
transitDesc:
|
||||
' en transports en commun (bus, train, métro). Les temps sont calculés sur une fenêtre typique d’un matin de semaine.',
|
||||
carDesc:
|
||||
' en voiture, basé sur les vitesses de circulation habituelles et le réseau routier.',
|
||||
bicycleDesc: ' à vélo, via des itinéraires adaptés aux cyclistes.',
|
||||
walkingDesc: ' à pied, via les chemins piétons et trottoirs.',
|
||||
mainDesc:
|
||||
'Affiche le temps nécessaire pour atteindre la destination sélectionnée depuis chaque zone',
|
||||
sliderHint:
|
||||
'Utilisez le curseur pour définir votre temps de trajet maximum.',
|
||||
},
|
||||
|
||||
// ── AI Filter ──────────────────────────────────────
|
||||
aiFilter: {
|
||||
describeIdealArea: 'Décrivez votre zone idéale avec l’IA',
|
||||
aiSearch: 'Recherche IA',
|
||||
describeHint: 'décrivez ce que vous recherchez',
|
||||
placeholder:
|
||||
'ex. quartier calme, moins de £400k, près de bonnes écoles...',
|
||||
example1: 'Quartier sûr près de bonnes écoles',
|
||||
example2: '30 min de trajet jusqu’à Kings Cross, moins de £500k',
|
||||
example3: 'Village tranquille, 3 chambres, débit internet rapide',
|
||||
analysing: 'Analyse de votre requête...',
|
||||
searchingDestinations: 'Recherche de destinations...',
|
||||
generatingFilters: 'Génération des filtres...',
|
||||
refiningResults: 'Affinage des résultats...',
|
||||
weeklyLimitReached:
|
||||
'Vous avez atteint la limite hebdomadaire d’utilisation de l’IA. Elle se réinitialisera automatiquement la semaine prochaine.',
|
||||
},
|
||||
|
||||
// ── Map Legend ─────────────────────────────────────
|
||||
mapLegend: {
|
||||
clearColourView: 'Effacer la vue en couleur',
|
||||
historicalMatches: 'Correspondances immobilières historiques',
|
||||
propertiesForSale: 'Propriétés à vendre',
|
||||
propertiesForRent: 'Propriétés à louer',
|
||||
numberOfProperties: 'Nombre de propriétés',
|
||||
previewing: 'Aperçu de \u201c{{name}}\u201d',
|
||||
},
|
||||
|
||||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: 'Adresse inconnue',
|
||||
unsaveProperty: 'Retirer des favoris',
|
||||
saveProperty: 'Ajouter aux favoris',
|
||||
lastSold: 'Dernière vente : £{{price}}',
|
||||
estValue: 'Valeur estimée :',
|
||||
type: 'Type :',
|
||||
builtForm: 'Forme du bâti :',
|
||||
tenure: 'Régime foncier :',
|
||||
floorArea: 'Surface :',
|
||||
bedrooms: 'Chambres :',
|
||||
bathrooms: 'Salles de bain :',
|
||||
rooms: 'Pièces :',
|
||||
built: 'Construction :',
|
||||
epcRating: 'Classement DPE :',
|
||||
epcPotential: 'Potentiel DPE :',
|
||||
listed: 'Mise en vente :',
|
||||
keyFeatures: 'Caractéristiques clés',
|
||||
renovations: 'Rénovations',
|
||||
viewExternalListing: 'Voir l’annonce externe',
|
||||
perMonth: '/mois',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: 'Rechercher par adresse ou code postal...',
|
||||
propertyData: 'Données immobilières',
|
||||
propertyDataDesc:
|
||||
'Les prix proviennent du HM Land Registry (ce que les acheteurs ont réellement payé). La surface, les classements énergétiques, l’année de construction et le régime foncier proviennent des diagnostics DPE officiels. Les deux sources sont reliées par adresse au sein de chaque code postal.',
|
||||
},
|
||||
|
||||
// ── Area Pane ──────────────────────────────────────
|
||||
areaPane: {
|
||||
areaStatistics: 'Statistiques de la zone',
|
||||
statsFor: 'Statistiques pour toutes les propriétés de ce/cette {{type}}',
|
||||
matchingFilters: ' correspondant à tous les filtres actifs',
|
||||
viewProperties: 'Voir {{count}} propriétés',
|
||||
priceHistory: 'Historique des prix',
|
||||
journeysFrom: 'Trajets depuis {{label}}',
|
||||
to: 'Vers {{destination}}',
|
||||
noJourneyData: 'Aucune donnée de trajet disponible',
|
||||
viewOnGoogleMaps: 'Voir sur Google Maps',
|
||||
walk: 'Marche',
|
||||
cycle: 'Vélo',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
histogramLegend: {
|
||||
tealBars: 'Barres turquoise',
|
||||
tealBarsDesc: 'montrent la distribution dans cette zone sélectionnée',
|
||||
greyBars: 'Barres grises',
|
||||
greyBarsDesc: 'montrent la distribution globale dans toutes les zones',
|
||||
dashedLine: 'Ligne pointillée',
|
||||
dashedLineDesc: 'indique la moyenne nationale',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
streetView: {
|
||||
title: 'Street View',
|
||||
},
|
||||
|
||||
// ── POI Pane ───────────────────────────────────────
|
||||
poiPane: {
|
||||
pois: 'POI',
|
||||
pointsOfInterest: "Points d'intérêt",
|
||||
poiDescription:
|
||||
"Données issues d'OpenStreetMap. Couvre les arrêts de transport, commerces, restaurants, établissements de santé, loisirs et plus encore. Mise à jour régulière avec une couverture complète des catégories.",
|
||||
searchCategories: 'Rechercher des catégories...',
|
||||
dataSourceInfo: 'Informations sur la source',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: 'Rechercher {{radius}} sur',
|
||||
outcodeNotRecognised: 'Code postal non reconnu',
|
||||
},
|
||||
|
||||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: 'Rechercher des lieux ou codes postaux...',
|
||||
postcodeNotFound: 'Code postal introuvable',
|
||||
lookupFailed: 'Échec de la recherche',
|
||||
searchLabel: 'Rechercher des lieux ou codes postaux',
|
||||
locateMe: 'Aller à ma position',
|
||||
geolocationUnsupported: 'La géolocalisation n\'est pas prise en charge par votre navigateur',
|
||||
geolocationFailed: 'Impossible de déterminer votre position',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Fermer le tiroir',
|
||||
},
|
||||
|
||||
// ── Home Page ──────────────────────────────────────
|
||||
home: {
|
||||
heroTitle1: 'Valeur',
|
||||
heroTitle2: 'Maximale',
|
||||
heroTitle3: 'Compromis Minimum.',
|
||||
heroSubtitle:
|
||||
'Vous cherchez un bien ? Faites de votre plus gros investissement votre meilleure décision.',
|
||||
heroDescription:
|
||||
"Tant d'options — choisir la bonne peut sembler décourageant. Notre carte interactive simplifie tout : sélectionnez vos critères et voyez instantanément les zones qui correspondent.",
|
||||
exploreTheMap: 'Explorer la carte',
|
||||
seeTheDifference: 'Voir la différence',
|
||||
statProperties: 'propriétés',
|
||||
statFilters: 'filtres',
|
||||
statEvery: 'Chaque',
|
||||
statPostcodeInEngland: "code postal d'Angleterre",
|
||||
ourPhilosophy: 'Notre philosophie',
|
||||
philosophyP1:
|
||||
"Sur Rightmove, vous choisissez d'abord une zone, puis vous espérez qu'elle convient. Vous finissez par croiser statistiques de criminalité, rapports scolaires et tests de débit sur une dizaine d'onglets, un code postal à la fois.",
|
||||
philosophyP2:
|
||||
"Nous inversons la logique. Dites-nous ce qu'il vous faut (budget, trajet, écoles, sécurité) et nous vous montrons chaque zone d'Angleterre qui correspond. Plus de devinettes. Plus de visites inutiles.",
|
||||
howToUseIt: 'Comment l’utiliser',
|
||||
howStep1Title: 'Définissez vos indispensables',
|
||||
howStep1Desc:
|
||||
'Budget, trajet, écoles — la carte n’affiche que ce qui correspond.',
|
||||
howStep2Title: 'Explorez les zones et découvrez des pépites cachées',
|
||||
howStep2Desc: 'Zoomez, examinez les détails et les critères secondaires.',
|
||||
howStep3Title: 'Plongez dans les codes postaux',
|
||||
howStep3Desc:
|
||||
'Consultez les propriétés individuelles, les prix de vente, la surface et comparez.',
|
||||
howStep4Title: 'Constituez votre sélection en toute confiance',
|
||||
howStep4Desc:
|
||||
'Chaque zone de votre liste répond à vos vrais critères — pas seulement à ce qui était en vente cette semaine-là.',
|
||||
othersVs: 'Les autres vs',
|
||||
listingPortals: "Portails d'annonces",
|
||||
checkMyPostcode: '« Vérifier mon code postal »',
|
||||
areaGuides: 'Guides de quartier',
|
||||
compSearchWithout: "Rechercher sans d'abord choisir une zone",
|
||||
compSearchWithoutSub: "(partir de ses besoins, pas d'un lieu)",
|
||||
compAreaData: 'Données de la zone',
|
||||
compAreaDataSub: '(criminalité, écoles, bruit, débit internet)',
|
||||
compPropertyData: 'Données par propriété',
|
||||
compPropertyDataSub: '(prix, DPE, surface)',
|
||||
compFilters: '56 filtres combinables en un seul endroit',
|
||||
compFiltersSub: '(toutes les informations, une seule carte interactive)',
|
||||
ctaTitle:
|
||||
'Faites de votre plus gros investissement votre meilleure décision.',
|
||||
ctaDescription:
|
||||
'Un tel enjeu mérite de vrais outils, ne laissez pas la chance décider.',
|
||||
},
|
||||
|
||||
// ── Pricing Page ───────────────────────────────────
|
||||
pricingPage: {
|
||||
title: 'Tarifs early access',
|
||||
subtitle:
|
||||
"Payez une fois, accédez pour toujours. Plus vous rejoignez tôt, moins vous payez.",
|
||||
costContext:
|
||||
"L'achat d'un bien coûte plus de £10 000 en droits de mutation, £1 500 en frais de notaire, £500 pour une expertise. Choisissez le mauvais quartier et vous vous retrouvez avec un long trajet, de mauvaises écoles ou une route dont vous ignoriez l'existence.",
|
||||
lessThanSurvey: "Moins cher qu'une expertise immobilière. Bien plus utile.",
|
||||
currentTier: 'Palier actuel',
|
||||
firstNUsers: '{{count}} premiers utilisateurs',
|
||||
everyoneAfter: 'Tous les suivants',
|
||||
nextNUsers: '{{count}} utilisateurs suivants',
|
||||
lifetime: '/à vie',
|
||||
spotsRemaining: '{{count}} place restante',
|
||||
spotsRemainingPlural: '{{count}} places restantes',
|
||||
filled: 'Complet',
|
||||
openDashboard: 'Ouvrir le tableau de bord',
|
||||
getStarted: 'Commencer',
|
||||
getStartedPrice: 'Commencer - {{price}}',
|
||||
noCreditCard: 'Aucune carte bancaire requise',
|
||||
moneyBackGuarantee: 'Garantie satisfait ou remboursé sous 30 jours',
|
||||
soldOut: 'Épuisé',
|
||||
upcoming: 'À venir',
|
||||
failedToLoad:
|
||||
'Échec du chargement des tarifs. Veuillez réessayer plus tard.',
|
||||
feat1: "56 couches de données à travers l'Angleterre",
|
||||
feat2: 'Chaque code postal noté et filtrable',
|
||||
feat3: 'Exploration de la carte et exportations illimitées',
|
||||
feat4: 'Plusieurs décennies de données historiques de prix',
|
||||
feat5: 'Criminalité, écoles, transports, débit internet et plus',
|
||||
feat6: 'Toutes les futures mises à jour de données incluses',
|
||||
},
|
||||
|
||||
// ── Learn Page ─────────────────────────────────────
|
||||
learnPage: {
|
||||
faq: 'FAQ',
|
||||
dataSources: 'Sources de données',
|
||||
support: 'Assistance',
|
||||
dataSourcesIntro: 'Cette application combine {{count}} jeux de données ouverts couvrant les prix immobiliers, la performance énergétique, les transports, la démographie, la criminalité, l’environnement et plus encore.',
|
||||
faqIntro: 'Que vous achetiez, louiez ou exploriez simplement, voici comment Perfect Postcode vous aide à trouver le bon quartier.',
|
||||
supportIntro: 'Vous avez une question ? Consultez notre FAQ ou contactez-nous directement.',
|
||||
source: 'Source :',
|
||||
optOut: 'Retrait de la divulgation publique',
|
||||
attribution: 'Attribution',
|
||||
attrLandRegistry: 'Contient des données du HM Land Registry © Crown copyright and database right 2025.',
|
||||
attrOgl: 'Contient des informations du secteur public sous licence',
|
||||
attrOglLink: 'Open Government Licence v3.0',
|
||||
attrOs: 'Contient des données OS © Crown copyright and database rights 2025.',
|
||||
attrTfl: 'Propulsé par TfL Open Data.',
|
||||
attrOsm: 'Contient des données de',
|
||||
attrOsmContrib: '© OpenStreetMap contributors',
|
||||
attrOsmLicense: 'disponibles sous la',
|
||||
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
|
||||
// Data source names & descriptions
|
||||
dsPricePaidName: 'Price Paid Data',
|
||||
dsPricePaidOrigin: 'HM Land Registry',
|
||||
dsPricePaidUse: 'Historique complet des prix de vente immobiliers en Angleterre.',
|
||||
dsEpcName: 'Energy Performance Certificates (EPC)',
|
||||
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsEpcUse: 'Certificats de performance énergétique domestiques fournissant la surface, le nombre de pièces, l’année de construction, les classements énergétiques, le type de bien et la forme du bâti. Associés aux données Price Paid par adresse au sein de chaque code postal. Les propriétaires peuvent demander le retrait de la divulgation publique.',
|
||||
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
|
||||
dsNsplOrigin: 'ONS / ArcGIS',
|
||||
dsNsplUse: 'Associe les codes postaux aux coordonnées et aux codes de zones statistiques, utilisé pour relier tous les jeux de données au niveau de la zone aux propriétés individuelles.',
|
||||
dsIodName: 'English Indices of Deprivation 2025',
|
||||
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsIodUse: 'Scores de défaveur relative couvrant le revenu, l’emploi, l’éducation, la santé, la criminalité et le cadre de vie pour chaque quartier d’Angleterre.',
|
||||
dsEthnicityName: 'Population par ethnie (recensement 2021)',
|
||||
dsEthnicityOrigin: 'ONS',
|
||||
dsEthnicityUse: 'Pourcentages de population par groupe ethnique (sud-asiatique, est-asiatique, noir, mixte, blanc, autre) par autorité locale.',
|
||||
dsCrimeName: 'Street-level Crime Data',
|
||||
dsCrimeOrigin: 'data.police.uk',
|
||||
dsCrimeUse: 'Données de criminalité de proximité de 2023 à 2025, agrégées en moyennes annuelles par LSOA et type d’infraction (violences, cambriolages, troubles à l’ordre public, stupéfiants, vols de véhicules, etc.).',
|
||||
dsOsmName: 'OpenStreetMap POIs',
|
||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||
dsOsmUse: 'Points d’intérêt couvrant commerces, restaurants, santé, loisirs, tourisme et plus à travers la Grande-Bretagne.',
|
||||
dsGreenspaceName: 'OS Open Greenspace',
|
||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse: 'Limites officielles des espaces verts de Grande-Bretagne, incluant parcs publics, jardins, terrains de sport et aires de jeux. Les centroïdes des polygones sont utilisés pour le comptage de proximité des parcs et le calcul de la distance au parc le plus proche.',
|
||||
dsNaptanName: 'NaPTAN (Public Transport Stops)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse: 'Emplacements des gares et arrêts pour le rail, le bus, le métro/tramway, le ferry et les aéroports à travers l’Angleterre.',
|
||||
dsNoiseName: 'Defra Noise Mapping',
|
||||
dsNoiseOrigin: 'Defra / Environment Agency',
|
||||
dsNoiseUse: 'Niveaux de bruit routier (moyenne pondérée sur 24 heures) issus de la cartographie stratégique du bruit de 2022, modélisés à haute résolution et échantillonnés à chaque code postal.',
|
||||
dsOfstedName: 'Ofsted School Inspections',
|
||||
dsOfstedOrigin: 'Ofsted',
|
||||
dsOfstedUse: 'Derniers résultats d’inspection des écoles publiques (avril 2025). Moyennés par code postal pour donner un score de qualité scolaire local (1=Excellent à 4=Insuffisant).',
|
||||
dsBroadbandName: 'Ofcom Broadband Performance',
|
||||
dsBroadbandOrigin: 'Ofcom',
|
||||
dsBroadbandUse: 'Couverture haut débit fixe et débits de téléchargement maximum par zone, issus de Ofcom Connected Nations 2025.',
|
||||
dsCouncilTaxName: 'Council Tax Levels 2025-26',
|
||||
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsCouncilTaxUse: 'Taux annuels de taxe d’habitation pour les tranches A à H pour les 296 autorités de facturation d’Angleterre, pour un logement occupé par deux adultes. Reliés aux propriétés via le code d’autorité locale du répertoire de codes postaux NSPL.',
|
||||
dsRentalName: 'Private Rental Market Statistics',
|
||||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse: 'Loyers mensuels médians du marché locatif privé par autorité locale et catégorie de chambres (oct. 2022 - sept. 2023). Reliés aux propriétés via le code d’autorité locale et le nombre estimé de chambres.',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: 'Trouver votre quartier',
|
||||
faqCommuteTitle: 'Trajet et déplacements',
|
||||
faqBudgetTitle: 'Budget et rapport qualité-prix',
|
||||
faqSafetyTitle: 'Sécurité et voisinage',
|
||||
faqFamiliesTitle: 'Familles et écoles',
|
||||
faqEnvironmentTitle: 'Environnement et qualité de vie',
|
||||
faqWhyTitle: 'Pourquoi Perfect Postcode',
|
||||
faqPricingTitle: 'Tarifs et accès',
|
||||
faqTipsTitle: 'Astuces',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: 'Je ne sais même pas quelles zones regarder. Est-ce que ça peut m’aider ?',
|
||||
faqFinding1A: 'C’est exactement à ça que ça sert. Définissez vos filtres (budget, temps de trajet, faible criminalité, bonnes écoles) et la carte s’illumine pour montrer chaque zone qui coche toutes les cases. Fini de chercher « meilleures zones pour vivre près de Manchester » à minuit.',
|
||||
faqFinding2Q: 'Je déménage dans un endroit que je ne connais pas du tout. Par où commencer ?',
|
||||
faqFinding2A: 'Définissez vos filtres pour ce qui compte et la carte met instantanément en évidence les zones qui correspondent. Vous passez de « je ne connais pas une seule rue » à une sélection en quelques minutes.',
|
||||
faqFinding3Q: 'Comment trouver des zones qui cochent toutes mes cases en une seule fois ?',
|
||||
faqFinding3A: 'Empilez plusieurs filtres (criminalité sous la moyenne, bonnes écoles, trajet de moins de 40 minutes) puis colorez la carte par prix pour repérer les zones au meilleur rapport qualité-prix. La carte se met à jour en temps réel quand vous bougez les curseurs.',
|
||||
// FAQ items — Commute and Travel
|
||||
faqCommute1Q: 'Puis-je voir combien de temps durerait mon trajet depuis différentes zones ?',
|
||||
faqCommute1A: 'Définissez votre lieu de travail comme destination et nous colorons chaque code postal par temps de trajet, que ce soit en voiture, à vélo ou en transports en commun. Filtrez par votre trajet maximum et le reste disparaît.',
|
||||
faqCommute2Q: 'En quoi c’est mieux que Google Maps ?',
|
||||
faqCommute2A: 'Google Maps vous montre un trajet à la fois. Nous colorons chaque code postal d’Angleterre par temps de trajet en une seule vue, pour que vous puissiez comparer des centaines de zones côte à côte au lieu de les chercher une par une.',
|
||||
// FAQ items — Budget and Value
|
||||
faqBudget1Q: 'Comment trouver les zones où j’ai le plus d’espace pour mon argent ?',
|
||||
faqBudget1A: 'Filtrez par prix au m² et vous verrez instantanément quels codes postaux offrent le plus d’espace par livre. Combinez avec le filtre de classement énergétique pour éviter les biens aux coûts de chauffage élevés.',
|
||||
faqBudget2Q: 'Comment m’assurer qu’une zone bon marché ne l’est pas pour de mauvaises raisons ?',
|
||||
faqBudget2A: 'Superposez les scores de défaveur, les statistiques de criminalité, les notes des écoles et les débits internet à côté du prix. Si un code postal est abordable et obtient de bons scores sur tout ce qui compte, vous avez trouvé une vraie bonne affaire, pas juste un prix bas avec des compromis que vous n’avez pas encore repérés.',
|
||||
// FAQ items — Safety and Neighbourhood
|
||||
faqSafety1Q: 'Comment vérifier si une zone est sûre avant d’y déménager ?',
|
||||
faqSafety1A: 'Nous superposons les données réelles de criminalité enregistrées par la police, ventilées par type, sur chaque quartier d’Angleterre. Filtrez par criminalité violente, cambriolages ou troubles à l’ordre public et voyez instantanément quels codes postaux ont les chiffres les plus bas.',
|
||||
faqSafety2Q: 'Je trouve sans cesse des appartements superbes en ligne, puis le quartier s’avère difficile.',
|
||||
faqSafety2A: 'C’est exactement pour ça que cet outil existe. Empilez taux de criminalité, niveaux de bruit, scores de défaveur, pubs et parcs à proximité, et débits internet, le tout sur une seule carte, pour savoir à quoi ressemble vraiment un quartier avant de réserver une visite.',
|
||||
// FAQ items — Families and Schools
|
||||
faqFamilies1Q: 'Puis-je trouver des zones avec de bonnes écoles ET peu de criminalité en une seule recherche ?',
|
||||
faqFamilies1A: 'Oui. Empilez les filtres pour les notes Ofsted, les taux de criminalité, les parcs et tout ce qui compte pour votre famille, et la carte ne met en évidence que les zones qui cochent toutes les cases. Fini de croiser cinq sites différents.',
|
||||
faqFamilies2Q: 'Comment savoir si un quartier a des parcs et des aires de jeux à proximité ?',
|
||||
faqFamilies2A: 'Activez la couche de POI parcs et espaces verts pour les voir directement sur la carte. Vous pouvez aussi filtrer par le nombre de parcs accessibles à pied depuis chaque code postal.',
|
||||
// FAQ items — Environment and Quality of Life
|
||||
faqEnv1Q: 'Puis-je trouver des logements économes en énergie qui ne sont pas sur une route bruyante ?',
|
||||
faqEnv1A: 'Filtrez par classement EPC (A à C), puis superposez les données de bruit routier pour exclure tout ce qui dépasse votre seuil. Colorez par l’un ou l’autre critère pour repérer les rues calmes et économes d’un coup d’œil.',
|
||||
faqEnv2Q: 'Est-ce que ça montre le risque d’inondation ou d’affaissement ?',
|
||||
faqEnv2A: 'Nous incluons des données de stabilité du sol pour que vous puissiez vérifier les risques d’affaissement, de retrait-gonflement des argiles et d’autres aléas géologiques avant de vous engager. Excluez les zones à risque dès le départ.',
|
||||
faqEnv3Q: 'Puis-je trouver des zones avec un bon débit internet qui soient aussi calmes ?',
|
||||
faqEnv3A: 'Superposez le filtre de débit internet avec les données de bruit routier pour trouver des rues avec une bonne connectivité et peu de bruit. Colorez par l’un ou l’autre critère pour comparer les zones d’un coup d’œil.',
|
||||
// FAQ items — Why Perfect Postcode
|
||||
faqWhy1Q: 'J’utilise déjà Rightmove. Qu’est-ce que ça apporte de plus ?',
|
||||
faqWhy1A: 'Rightmove vous montre des maisons. Nous vous montrons des quartiers. Taux de criminalité, notes des écoles, débits internet, niveaux de bruit, scores de défaveur et plus, tout filtrable sur une seule carte. Vous pouvez juger un quartier avant même de regarder les annonces.',
|
||||
faqWhy2Q: 'Je ne peux pas simplement faire ces recherches gratuitement moi-même ?',
|
||||
faqWhy2A: 'Vous pourriez croiser les données policières, les rapports Ofsted, les registres EPC, les archives du Land Registry et les statistiques ONS un code postal à la fois. Ou vous pouvez avoir le tout filtrable et coloré sur une seule carte en quelques secondes.',
|
||||
faqWhy3Q: 'D’où viennent réellement les données ?',
|
||||
faqWhy3A: 'Chaque jeu de données provient de sources officielles du gouvernement britannique : Land Registry, le registre EPC, ONS, Ofsted, Ofcom, data.police.uk et Defra. Nous ne scrapons pas les agents immobiliers et n’inventons rien. Vous pouvez vérifier chaque donnée auprès de la source originale.',
|
||||
// FAQ items — Pricing and Access
|
||||
faqPricing1Q: 'Est-ce que ça vaut vraiment le coup de payer pour un outil de recherche immobilière ?',
|
||||
faqPricing1A: 'L’achat d’un logement est probablement le plus gros achat de votre vie. Repérer un seul signal d’alerte (une route bruyante, un mauvais débit, une criminalité en hausse) avant de vous engager pourrait vous épargner des années de regrets. Ça coûte moins qu’un plein d’essence.',
|
||||
faqPricing2Q: 'Est-ce un abonnement ?',
|
||||
faqPricing2A: 'Non. Paiement unique, à vous pour toujours. Utilisez-le intensivement pendant votre recherche, revenez quand vous êtes curieux d’une nouvelle zone, et c’est toujours là si vous déménagez à nouveau.',
|
||||
faqPricing3Q: 'Que puis-je faire avec la version gratuite ?',
|
||||
faqPricing3A: 'Les utilisateurs gratuits peuvent explorer toutes les fonctionnalités dans la zone de démonstration (centre de Londres, approximativement zones 1 à 2). Pour accéder aux données du reste de l’Angleterre, il faut l’accès à vie.',
|
||||
faqPricing4Q: 'Puis-je obtenir un remboursement ?',
|
||||
faqPricing4A: 'Absolument. Nous offrons une garantie satisfait ou remboursé sous 30 jours. Si vous n’êtes pas satisfait, envoyez un e-mail à support@perfect-postcode.co.uk dans les 30 jours pour un remboursement intégral.',
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: 'Comment utiliser le filtre IA au lieu d’ajouter les filtres un par un ?',
|
||||
faqTips1A: 'Tapez ce que vous voulez en langage courant, par exemple « quartier calme près de bonnes écoles avec bon débit internet à moins de £400k », et il configurera tous les filtres pertinents d’un coup. Ajustez ensuite manuellement si nécessaire.',
|
||||
faqTips2Q: 'Puis-je enregistrer une recherche et y revenir plus tard ?',
|
||||
faqTips2A: 'Cliquez sur le bouton d’enregistrement et tout est capturé : vos filtres, le niveau de zoom et la couche de données affichée. Reprenez exactement où vous en étiez ou partagez le lien avec votre conjoint.',
|
||||
faqTips3Q: 'Puis-je exporter les données que je consulte ?',
|
||||
faqTips3A: 'Utilisez le bouton d’exportation pour télécharger les propriétés filtrées sous forme de tableur. L’export respecte tous vos filtres actifs, vous obtenez donc exactement les données souhaitées.',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
accountPage: {
|
||||
emailLabel: 'E-mail',
|
||||
subscriptionLabel: 'Abonnement',
|
||||
upgrade: 'Passer à la version complète',
|
||||
redirecting: 'Redirection…',
|
||||
receiveNewsletter: 'Recevoir les e-mails de la newsletter',
|
||||
needHelp: 'Besoin d’aide ? Écrivez-nous à',
|
||||
responseTime: 'Nous répondons généralement sous 24 heures.',
|
||||
},
|
||||
|
||||
// ── Saved Page ─────────────────────────────────────
|
||||
savedPage: {
|
||||
searches: 'Recherches',
|
||||
noSavedSearches: 'Aucune recherche enregistrée',
|
||||
noSavedSearchesDesc:
|
||||
'Enregistrez vos filtres et la vue de la carte pour reprendre exactement là où vous vous étiez arrêté.',
|
||||
noSavedProperties: 'Aucune propriété enregistrée',
|
||||
noSavedPropertiesDesc:
|
||||
'Ajoutez des propriétés en favoris au fil de votre exploration et constituez votre sélection sans rien perdre de vue.',
|
||||
openPostcode: 'Ouvrir le code postal',
|
||||
viewListing: 'Voir l’annonce',
|
||||
clickToRename: 'Cliquez pour renommer',
|
||||
notesPlaceholder: 'Notez vos impressions...',
|
||||
deleteSearch: 'Supprimer la recherche',
|
||||
deleteSearchConfirm:
|
||||
'Êtes-vous sûr de vouloir supprimer cette recherche enregistrée ? Cette action est irréversible.',
|
||||
deleteProperty: 'Supprimer la propriété',
|
||||
deletePropertyConfirm:
|
||||
'Êtes-vous sûr de vouloir supprimer cette propriété enregistrée ? Cette action est irréversible.',
|
||||
bed: 'ch.',
|
||||
epc: 'DPE',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
invitesPage: {
|
||||
inviteLinksLicensed:
|
||||
"Les liens d'invitation sont disponibles pour les utilisateurs licenciés.",
|
||||
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
|
||||
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
|
||||
generateFreeInvite: "Générer un lien d'invitation gratuit",
|
||||
generateReferralLink: 'Générer un lien de parrainage',
|
||||
copyInviteLink: "Copier le lien d'invitation",
|
||||
adminInvitesTitle: 'Invitations admin (100% de réduction)',
|
||||
referralInvitesTitle: 'Invitations de parrainage (30% de réduction)',
|
||||
yourInviteLinks: "Vos liens d'invitation",
|
||||
noInvitesYet: "Aucune invitation générée pour l'instant",
|
||||
link: 'Lien',
|
||||
status: 'Statut',
|
||||
created: 'Créé',
|
||||
redeemed: 'Utilisé',
|
||||
pending: 'En attente',
|
||||
},
|
||||
|
||||
// ── Invite Page ────────────────────────────────────
|
||||
invitePage: {
|
||||
youreInvited: 'Vous êtes invité !',
|
||||
specialOffer: 'Offre spéciale !',
|
||||
invitedByFree:
|
||||
'{{name}} vous invite à obtenir un accès à vie gratuit.',
|
||||
invitedByDiscount:
|
||||
"{{name}} vous fait bénéficier d'une réduction de 30% sur l'accès à vie.",
|
||||
genericFreeInvite:
|
||||
'Vous avez été invité à obtenir un accès à vie gratuit.',
|
||||
genericDiscount:
|
||||
"Un ami vous fait bénéficier d'une réduction de 30% sur l'accès à vie.",
|
||||
exploreEvery: "Explorez chaque quartier d'Angleterre",
|
||||
propertyInfo:
|
||||
"Prix immobiliers, classements énergétiques, statistiques de criminalité, notes des écoles et plus encore",
|
||||
invalidInvite: 'Invitation invalide',
|
||||
inviteAlreadyUsed: 'Invitation déjà utilisée',
|
||||
inviteAlreadyUsedDesc: "Ce lien d'invitation a déjà été utilisé.",
|
||||
invalidInviteLink: "Lien d'invitation invalide",
|
||||
invalidInviteLinkDesc:
|
||||
"Ce lien d'invitation est invalide ou a expiré.",
|
||||
licenseActivated: 'Licence activée !',
|
||||
fullAccessGranted:
|
||||
'Vous avez désormais un accès complet à Perfect Postcode.',
|
||||
activating: 'Activation...',
|
||||
activateLicense: 'Activer la licence',
|
||||
claimDiscount: 'Réclamer la réduction',
|
||||
registerToClaim: "S'inscrire pour réclamer",
|
||||
youAlreadyHaveLicense: 'Vous avez déjà une licence',
|
||||
accountHasFullAccess: 'Votre compte dispose déjà d’un accès complet.',
|
||||
failedToValidate: "Échec de la validation du lien d'invitation",
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: 'Retirer',
|
||||
savedProperty: 'Enregistré',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: 'à l’instant',
|
||||
minutesAgo: 'il y a {{count}} min',
|
||||
hoursAgo: 'il y a {{count}} h',
|
||||
daysAgo: 'il y a {{count}} j',
|
||||
nFilters: '{{count}} filtres',
|
||||
noFilters: 'Aucun filtre',
|
||||
poiCategory: '{{count}} catégorie de POI',
|
||||
poiCategories: '{{count}} catégories de POI',
|
||||
travelDestination: '{{count}} destination de temps de trajet',
|
||||
travelDestinations: '{{count}} destinations de temps de trajet',
|
||||
propertiesMatch: '{{count}} propriétés correspondent',
|
||||
setFilters: 'Définir {{count}} filtre(s) : {{list}}',
|
||||
noFiltersSet: 'Aucun filtre défini',
|
||||
toDestination: '{{mode}} vers {{label}} {{bounds}}',
|
||||
lessThanMin: '< {{max}} min',
|
||||
moreThanMin: '> {{min}} min',
|
||||
},
|
||||
|
||||
// ── Tutorial ──────────────────────────────────────
|
||||
tutorial: {
|
||||
step1Title: 'Dites à la carte ce qui compte',
|
||||
step1Content: 'Définissez votre budget, temps de trajet maximum, qualité des écoles, seuil de criminalité. Ce qui compte pour vous. Seules les zones qui correspondent restent éclairées. Utilisez l’icône œil pour colorier par n’importe quel critère.',
|
||||
step2Title: 'Ou décrivez simplement',
|
||||
step2Content: 'Tapez ce que vous voulez en français, par exemple « quartier calme près de bonnes écoles sous £400k », et nous configurerons les filtres pour vous.',
|
||||
step3Title: 'Explorez ce qui existe',
|
||||
step3Content: 'Naviguez et zoomez à travers l’Angleterre. Cliquez sur n’importe quelle zone colorée pour voir la criminalité, les écoles, les prix, le haut débit, le bruit et plus encore.',
|
||||
step4Title: 'Allez directement à un lieu',
|
||||
step4Content: 'Recherchez n’importe quel lieu ou code postal pour vous y rendre instantanément.',
|
||||
step5Title: 'Examinez les détails',
|
||||
step5Content: 'Consultez les statistiques de zone, histogrammes et fiches individuelles : prix, surface, performances énergétiques et plus.',
|
||||
step6Title: 'Qu’y a-t-il à proximité ?',
|
||||
step6Content: 'Activez les écoles, commerces, gares, parcs et restaurants sur la carte pour voir ce qui est à portée.',
|
||||
},
|
||||
|
||||
// ── Server-derived values ──────────────────────────
|
||||
// Keyed by the English server value. ts() looks up translations at display time.
|
||||
// The English keys MUST match exactly what the API returns.
|
||||
server: {
|
||||
// ─ Feature group names ─
|
||||
'Properties': 'Propriétés',
|
||||
'Transport': 'Transports',
|
||||
'Education': 'Éducation',
|
||||
'Deprivation': 'Précarité',
|
||||
'Crime': 'Criminalité',
|
||||
'Demographics': 'Démographie',
|
||||
'Amenities': 'Commodités',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': 'Statut de l’annonce',
|
||||
'Property type': 'Type de bien',
|
||||
'Leasehold/Freehold': 'Bail/Pleine propriété',
|
||||
'Last known price': 'Dernier prix connu',
|
||||
'Estimated current price': 'Prix actuel estimé',
|
||||
'Asking price': 'Prix demandé',
|
||||
'Price per sqm': 'Prix au m²',
|
||||
'Est. price per sqm': 'Prix estimé au m²',
|
||||
'Asking price per sqm': 'Prix demandé au m²',
|
||||
'Estimated monthly rent': 'Loyer mensuel estimé',
|
||||
'Asking rent (monthly)': 'Loyer demandé (mensuel)',
|
||||
'Total floor area (sqm)': 'Surface totale (m²)',
|
||||
'Number of bedrooms & living rooms': 'Nombre de chambres et séjours',
|
||||
'Bedrooms': 'Chambres',
|
||||
'Bathrooms': 'Salles de bain',
|
||||
'Construction year': 'Année de construction',
|
||||
'Date of last transaction': 'Date de la dernière transaction',
|
||||
'Listing date': 'Date de mise en ligne',
|
||||
'Former council house': 'Ancien logement social',
|
||||
'Current energy rating': 'Classement énergétique actuel',
|
||||
'Potential energy rating': 'Classement énergétique potentiel',
|
||||
'Interior height (m)': 'Hauteur intérieure (m)',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Distance to nearest train or tube station (km)': 'Distance à la gare ou station de métro la plus proche (km)',
|
||||
|
||||
// ─ Feature names (Education) ─
|
||||
'Good+ primary schools within 2km': 'Écoles primaires Bien+ dans un rayon de 2 km',
|
||||
'Good+ secondary schools within 2km': 'Collèges/lycées Bien+ dans un rayon de 2 km',
|
||||
'Good+ primary schools within 5km': 'Écoles primaires Bien+ dans un rayon de 5 km',
|
||||
'Good+ secondary schools within 5km': 'Collèges/lycées Bien+ dans un rayon de 5 km',
|
||||
'Education, Skills and Training Score': 'Score éducation, compétences et formation',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
'Income Score (rate)': 'Score de revenu (taux)',
|
||||
'Employment Score (rate)': 'Score d’emploi (taux)',
|
||||
'Health Deprivation and Disability Score': 'Score de santé et handicap',
|
||||
'Living Environment Score': 'Score du cadre de vie',
|
||||
'Indoors Sub-domain Score': 'Score du sous-domaine intérieur',
|
||||
'Outdoors Sub-domain Score': 'Score du sous-domaine extérieur',
|
||||
|
||||
// ─ Feature names (Crime) ─
|
||||
'Serious crime per 1k residents (avg/yr)': 'Crimes graves pour 1k habitants (moy./an)',
|
||||
'Minor crime per 1k residents (avg/yr)': 'Délits mineurs pour 1k habitants (moy./an)',
|
||||
'Serious crime (avg/yr)': 'Crimes graves (moy./an)',
|
||||
'Minor crime (avg/yr)': 'Délits mineurs (moy./an)',
|
||||
'Violence and sexual offences (avg/yr)': 'Violences et infractions sexuelles (moy./an)',
|
||||
'Burglary (avg/yr)': 'Cambriolages (moy./an)',
|
||||
'Robbery (avg/yr)': 'Vols avec violence (moy./an)',
|
||||
'Vehicle crime (avg/yr)': 'Crimes liés aux véhicules (moy./an)',
|
||||
'Anti-social behaviour (avg/yr)': 'Comportements antisociaux (moy./an)',
|
||||
'Criminal damage and arson (avg/yr)': 'Dégradations et incendies criminels (moy./an)',
|
||||
'Other theft (avg/yr)': 'Autres vols (moy./an)',
|
||||
'Theft from the person (avg/yr)': 'Vols à la personne (moy./an)',
|
||||
'Shoplifting (avg/yr)': 'Vols à l’étalage (moy./an)',
|
||||
'Bicycle theft (avg/yr)': 'Vols de vélos (moy./an)',
|
||||
'Drugs (avg/yr)': 'Infractions liées aux stupéfiants (moy./an)',
|
||||
'Possession of weapons (avg/yr)': 'Possession d’armes (moy./an)',
|
||||
'Public order (avg/yr)': 'Troubles à l’ordre public (moy./an)',
|
||||
'Other crime (avg/yr)': 'Autres crimes (moy./an)',
|
||||
|
||||
// ─ Feature names (Demographics) ─
|
||||
'Median age': 'Âge médian',
|
||||
'% White': '% Blancs',
|
||||
'% South Asian': '% Sud-Asiatiques',
|
||||
'% Black': '% Noirs',
|
||||
'% East Asian': '% Est-Asiatiques',
|
||||
'% Mixed': '% Métis',
|
||||
'% Other': '% Autres',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': 'Distance au parc le plus proche (km)',
|
||||
'Number of parks within 2km': 'Nombre de parcs à moins de 2 km',
|
||||
'Number of restaurants within 2km': 'Nombre de restaurants à moins de 2 km',
|
||||
'Number of grocery shops and supermarkets within 2km': 'Nombre d’épiceries et supermarchés à moins de 2 km',
|
||||
'Noise (dB)': 'Bruit (dB)',
|
||||
'Max available download speed (Mbps)': 'Débit descendant max. disponible (Mbps)',
|
||||
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': 'Vente historique',
|
||||
'For sale': 'En vente',
|
||||
'For rent': 'En location',
|
||||
'Detached': 'Individuelle',
|
||||
'Semi-Detached': 'Jumelée',
|
||||
'Terraced': 'Mitoyenne',
|
||||
'Flats/Maisonettes': 'Appartements/Duplex',
|
||||
'Other': 'Autre',
|
||||
'Freehold': 'Pleine propriété',
|
||||
'Leasehold': 'Bail emphytéotique',
|
||||
'Yes': 'Oui',
|
||||
'No': 'Non',
|
||||
|
||||
// ─ Stacked chart labels ─
|
||||
'Serious crime': 'Crimes graves',
|
||||
'Minor crime': 'Délits mineurs',
|
||||
'Ethnic composition': 'Composition ethnique',
|
||||
|
||||
// ─ POI group names ─
|
||||
'Public Transport': 'Transports en commun',
|
||||
'Leisure': 'Loisirs',
|
||||
'Health': 'Santé',
|
||||
'Emergency Services': 'Services d’urgence',
|
||||
'Groceries': 'Alimentation',
|
||||
'Local Businesses': 'Commerces de proximité',
|
||||
'Culture': 'Culture',
|
||||
'Services': 'Services',
|
||||
'Shops': 'Boutiques',
|
||||
|
||||
// ─ POI categories ─
|
||||
'Airport': 'Aéroport',
|
||||
'Ferry': 'Ferry',
|
||||
'Rail station': 'Gare',
|
||||
'Bus stop': 'Arrêt de bus',
|
||||
'Bus station': 'Gare routière',
|
||||
'Taxi rank': 'Station de taxi',
|
||||
'Metro or Tram stop': 'Station de métro ou tramway',
|
||||
'Café': 'Café',
|
||||
'Restaurant': 'Restaurant',
|
||||
'Pub': 'Pub',
|
||||
'Bar': 'Bar',
|
||||
'Fast Food': 'Restauration rapide',
|
||||
'Nightclub': 'Boîte de nuit',
|
||||
'Cinema': 'Cinéma',
|
||||
'Theatre': 'Théâtre',
|
||||
'Live Music & Events': 'Musique live et événements',
|
||||
'Park': 'Parc',
|
||||
'Playground': 'Aire de jeux',
|
||||
'Sports Centre': 'Centre sportif',
|
||||
'Entertainment': 'Divertissement',
|
||||
'Supermarket': 'Supermarché',
|
||||
'Convenience Store': 'Supérette',
|
||||
'Bakery': 'Boulangerie',
|
||||
'Butcher & Fishmonger': 'Boucherie et poissonnerie',
|
||||
'Greengrocer': 'Primeur',
|
||||
'Off-Licence': 'Caviste',
|
||||
'Deli & Specialty': 'Traiteur et épicerie fine',
|
||||
'Fashion & Clothing': 'Mode et vêtements',
|
||||
'Electronics': 'Électronique',
|
||||
'Charity Shop': 'Boutique caritative',
|
||||
'DIY & Hardware': 'Bricolage et quincaillerie',
|
||||
'Home & Garden': 'Maison et jardin',
|
||||
'Bookshop': 'Librairie',
|
||||
'Pet Shop': 'Animalerie',
|
||||
'Sports & Outdoor': 'Sports et plein air',
|
||||
'Newsagent': 'Marchand de journaux',
|
||||
'Department Store': 'Grand magasin',
|
||||
'Gift & Hobby': 'Cadeaux et loisirs créatifs',
|
||||
'Specialist Shop': 'Boutique spécialisée',
|
||||
'Hairdresser & Beauty': 'Coiffure et beauté',
|
||||
'Gym & Fitness': 'Salle de sport',
|
||||
'Dry Cleaner & Laundry': 'Pressing et laverie',
|
||||
'Car Services': 'Services automobiles',
|
||||
'Post Office': 'Bureau de poste',
|
||||
'Vet & Pet Care': 'Vétérinaire et soins animaliers',
|
||||
'Bank': 'Banque',
|
||||
'Travel Agent': 'Agence de voyage',
|
||||
'Police': 'Police',
|
||||
'Fire Station': 'Caserne de pompiers',
|
||||
'Ambulance Station': 'Centre ambulancier',
|
||||
'GP Surgery': 'Cabinet médical',
|
||||
'Dentist': 'Dentiste',
|
||||
'Pharmacy': 'Pharmacie',
|
||||
'Hospital & Clinic': 'Hôpital et clinique',
|
||||
'Optician': 'Opticien',
|
||||
'Physiotherapy': 'Kinésithérapie',
|
||||
'Counselling & Therapy': 'Conseil et thérapie',
|
||||
'Care Home': 'Maison de retraite',
|
||||
'Medical & Mobility': 'Matériel médical et mobilité',
|
||||
'Museum': 'Musée',
|
||||
'Gallery': 'Galerie',
|
||||
'Library': 'Bibliothèque',
|
||||
'Place of Worship': 'Lieu de culte',
|
||||
'Arts Centre': 'Centre artistique',
|
||||
'Zoo': 'Zoo',
|
||||
'Tourist Attraction': 'Attraction touristique',
|
||||
'School': 'École',
|
||||
'Hotel': 'Hôtel',
|
||||
'Local Business': 'Commerce local',
|
||||
'Offices': 'Bureaux',
|
||||
'EV Charging': 'Borne de recharge',
|
||||
'Fuel Station': 'Station-service',
|
||||
'Community Centre': 'Centre communautaire',
|
||||
|
||||
// ─ Suffixes (used in formatters) ─
|
||||
'/mo': '/mois',
|
||||
'/yr': '/an',
|
||||
' sqm': ' m²',
|
||||
' km': ' km',
|
||||
' m': ' m',
|
||||
' dB': ' dB',
|
||||
' years': ' ans',
|
||||
' rooms': ' pièces',
|
||||
},
|
||||
};
|
||||
|
||||
export default fr;
|
||||
849
frontend/src/i18n/locales/hu.ts
Normal file
849
frontend/src/i18n/locales/hu.ts
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
import type { Translations } from './en';
|
||||
|
||||
const hu: Translations = {
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: 'Mentés',
|
||||
cancel: 'Mégse',
|
||||
close: 'Bezárás',
|
||||
delete: 'Törlés',
|
||||
open: 'Megnyitás',
|
||||
share: 'Megosztás',
|
||||
copy: 'Másolás',
|
||||
copied: 'Másolva!',
|
||||
copiedToClipboard: 'Vágólapra másolva',
|
||||
loading: 'Betöltés...',
|
||||
loadMore: 'Továbbiak betöltése',
|
||||
remaining: 'még {{count}} hátra',
|
||||
search: 'Keresés',
|
||||
all: 'Mind',
|
||||
none: 'Egyik sem',
|
||||
viewDataSource: 'Adatforrás megtekintése',
|
||||
total: 'Összesen',
|
||||
min: 'perc',
|
||||
or: 'vagy',
|
||||
area: 'Terület',
|
||||
properties: 'Ingatlanok',
|
||||
postcode: 'Irányítószám',
|
||||
noAreaSelected: 'Nincs kiválasztott terület',
|
||||
noAreaSelectedDesc:
|
||||
'Kattints bármelyik színes területre a térképen a bűnözés, iskolák, árak és egyéb adatok megtekintéséhez',
|
||||
clickForDetails: 'Kattints a részletekhez',
|
||||
property: 'ingatlan',
|
||||
propertiesPlural: 'ingatlan',
|
||||
},
|
||||
|
||||
// ── Header / Nav ───────────────────────────────────
|
||||
header: {
|
||||
appName: 'Perfect Postcode',
|
||||
dashboard: 'Térkép',
|
||||
learn: 'Tudnivalók',
|
||||
pricing: 'Árak',
|
||||
inviteFriends: 'Barátok meghívása',
|
||||
saved: 'Mentett',
|
||||
logIn: 'Bejelentkezés',
|
||||
createAccount: 'Regisztráció',
|
||||
sharing: 'Megosztás...',
|
||||
exportLabel: 'Exportálás',
|
||||
exporting: 'Exportálás...',
|
||||
exportToExcel: 'Exportálás Excelbe',
|
||||
openMenu: 'Menü megnyitása',
|
||||
closeMenu: 'Menü bezárása',
|
||||
},
|
||||
|
||||
// ── User Menu ──────────────────────────────────────
|
||||
userMenu: {
|
||||
fullAccess: 'Teljes hozzáférés',
|
||||
demo: 'Demó',
|
||||
themeLight: 'Téma: Világos',
|
||||
themeDark: 'Téma: Sötét',
|
||||
account: 'Fiók',
|
||||
logOut: 'Kijelentkezés',
|
||||
},
|
||||
|
||||
// ── Mobile Menu ────────────────────────────────────
|
||||
mobileMenu: {
|
||||
menu: 'Menü',
|
||||
home: 'Főoldal',
|
||||
},
|
||||
|
||||
// ── Auth Modal ─────────────────────────────────────
|
||||
auth: {
|
||||
logIn: 'Bejelentkezés',
|
||||
createAccount: 'Regisztráció',
|
||||
resetPassword: 'Jelszó visszaállítása',
|
||||
valueProp: 'Mentsd el a kereséseidet, jelöld meg az ingatlanokat, és folytasd ott, ahol abbahagytad.',
|
||||
continueWithGoogle: 'Folytatás Google-lel',
|
||||
email: 'E-mail',
|
||||
emailPlaceholder: 'te@pelda.hu',
|
||||
password: 'Jelszó',
|
||||
passwordPlaceholderRegister: 'Minimum 8 karakter',
|
||||
passwordPlaceholderLogin: 'Jelszavad',
|
||||
forgotPassword: 'Elfelejtetted a jelszavad?',
|
||||
resetSent: 'Ellenőrizd az e-mailjeidet a visszaállító linkhez.',
|
||||
pleaseWait: 'Kérjük, várj...',
|
||||
sendResetLink: 'Visszaállító link küldése',
|
||||
backToLogin: 'Vissza a bejelentkezéshez',
|
||||
},
|
||||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: 'Fedezd fel egész Angliát',
|
||||
description: 'Jelenleg a demó területet felfedezed. Szerezz élethosszig tartó hozzáférést minden irányítószámhoz, szűrőhöz és környékhez. Egyetlen fizetés, örökre.',
|
||||
free: 'Ingyenes',
|
||||
once: '/egyszeri',
|
||||
freeForEarly: 'Ingyenes a korai felhasználóknak. Nem szükséges bankkartya.',
|
||||
oneTimePayment: 'Egyszeri fizetés. Élethosszig tartó hozzáférés. 30 napos pénzvisszatérítési garancia.',
|
||||
redirecting: 'Átirányítás...',
|
||||
claimFreeAccess: 'Ingyenes hozzáférés igénylése',
|
||||
upgradeFor: 'Frissítés {{price}} áron',
|
||||
registerAndUpgrade: 'Regisztráció és frissítés',
|
||||
alreadyHaveAccount: 'Már van fiókod? Jelentkezz be',
|
||||
continueWithDemo: 'Folytatás demóval',
|
||||
checkoutFailed: 'A fizetés sikertelen',
|
||||
},
|
||||
|
||||
// ── Save Search Modal ──────────────────────────────
|
||||
saveSearch: {
|
||||
title: 'Keresés mentése',
|
||||
saved: 'Keresés elmentve',
|
||||
savedSuccess: 'A keresés sikeresen elmentve.',
|
||||
viewSavedSearches: 'Mentett keresések megtekintése',
|
||||
name: 'Név',
|
||||
namePlaceholder: 'Keresésem',
|
||||
saving: 'Mentés...',
|
||||
},
|
||||
|
||||
// ── License Success ────────────────────────────────
|
||||
licenseSuccess: {
|
||||
title: 'Benne vagy.',
|
||||
subtitle: 'Az élethosszig tartó hozzáférésed most aktív.',
|
||||
description: 'Teljes hozzáférés minden funkcióhoz, minden irányítószámhoz, egész Angliában.',
|
||||
startExploring: 'Felfedezés indítása',
|
||||
},
|
||||
|
||||
// ── Filters ────────────────────────────────────────
|
||||
filters: {
|
||||
activeFilters: 'Aktív szűrők',
|
||||
addFilter: 'Szűrő hozzáadása',
|
||||
historical: 'Történelmi',
|
||||
buy: 'Vétel',
|
||||
rent: 'Bérlés',
|
||||
findingPerfectPostcode: 'A tökéletes irányítószám megtalálása',
|
||||
addFiltersHint: 'Adj hozzá szűrőket a térkép szűkítéséhez a feltételeidnek megfelelően',
|
||||
upgradePrompt: 'Bűnözés, iskolák, zaj, szélessáv és 50+ további szűrő egész Angliában.',
|
||||
oneTimeLifetime: 'Egyszeri fizetés, élethosszig tartó hozzáférés.',
|
||||
upgradeToFullMap: 'Frissítés a teljes térképre',
|
||||
chooseFilters: 'Válaszd ki a számodra fontos szűrőket. A térkép menet közben frissül.',
|
||||
searchFeatures: 'Jellemzők keresése...',
|
||||
noMatchingFeatures: 'Nincs találat',
|
||||
tryDifferentSearch: 'Próbálj más keresőkifejezést',
|
||||
allFeaturesActive: 'Minden jellemző aktív',
|
||||
removeFilterHint: 'Távolíts el egy szűrőt az elérhető jellemzők megtekintéséhez',
|
||||
featureInfo: 'Jellemző információ',
|
||||
replayTutorial: 'Interaktív bemutató újrajátszása',
|
||||
clearAll: 'Összes törlése',
|
||||
clearAllTitle: 'Összes szűrő törlése?',
|
||||
clearAllSavePrompt: 'Szeretnéd menteni a jelenlegi szűrőket a törlés előtt?',
|
||||
saveAndClear: 'Mentés és törlés',
|
||||
clearWithoutSaving: 'Törlés mentés nélkül',
|
||||
},
|
||||
|
||||
// ── Philosophy Popup ───────────────────────────────
|
||||
philosophy: {
|
||||
intro: 'Kezdd a feltétlenül szükséges feltételekkel, majd add hozzá a kívánalmakat. A térkép szűkül, ahogy szűrőket adsz hozzá. A megmaradó területek a legjobb találatok.',
|
||||
step1Title: 'Költségvetés és alapok',
|
||||
step1Desc: '(ártartomány, alapterület, ingatlantípus)',
|
||||
step2Title: 'Ingazás',
|
||||
step2Desc: '(utazási idő a munkahelyre autóval, kerékpárral vagy tömegközlekedéssel)',
|
||||
step3Title: 'Biztonság',
|
||||
step3Desc: '(bűnözési arányok, zajszintek, talajstabilitás)',
|
||||
step4Title: 'Iskolák',
|
||||
step4Desc: '(közeli Ofsted által Jó vagy Kiváló minősítésű iskolák)',
|
||||
step5Title: 'Életmód',
|
||||
step5Desc: '(éttermek, parkok, szélessávú internet sebesség)',
|
||||
step6Title: 'Energia',
|
||||
step6Desc: '(EPC minősítések, szigetelés, fűtési költségek)',
|
||||
tip: 'Tipp: ha semmi nem egyezik, engedj egy feltételből, és nézd meg, melyik kompromisszum nyitja meg a legtöbb lehetőséget.',
|
||||
},
|
||||
|
||||
// ── Travel Time ────────────────────────────────────
|
||||
travel: {
|
||||
travelTime: 'Utazási idő ({{mode}})',
|
||||
maxTime: 'Max. idő',
|
||||
selectDestination: 'Úticél kiválasztása...',
|
||||
bestCase: 'Legjobb eset',
|
||||
bestCaseTitle: 'Legjobb utazási idő',
|
||||
bestCaseDesc: 'A leggyorsabb reális utazási időt használja (ha jól időzíted az indulást és jó csatlakozásokat érsz el). Az alapértelmezett a <strong>mediánt</strong> használja, ami egy átlagos utazást képvisel, függetlenül az indulás idejétől.',
|
||||
previewOnMap: 'Előnézet a térképen',
|
||||
stopPreviewing: 'Előnézet leállítása',
|
||||
removeTravelTime: 'Utazási idő eltávolítása',
|
||||
addTravelTime: '{{mode}} utazási idő hozzáadása',
|
||||
clearDestination: 'Úticél törlése',
|
||||
typeToFilter: 'Gépelj a szűréshez...',
|
||||
noDestinations: 'Nem található úticél',
|
||||
modeCar: 'Autó',
|
||||
modeBicycle: 'Kerékpár',
|
||||
modeWalking: 'Gyalog',
|
||||
modeTransit: 'Tömegközlekedés',
|
||||
modeCarDesc: 'Autós menetidő a leggyorsabb úton',
|
||||
modeBicycleDesc: 'Kerékpáros menetidő kerékpárbarát útvonalakon',
|
||||
modeWalkingDesc: 'Gyalogos menetidő sétálóutakon és járdákon',
|
||||
modeTransitDesc: 'Utazási idő vonattal, metróval és busszal',
|
||||
},
|
||||
|
||||
// ── Travel Time Info Popup ─────────────────────────
|
||||
travelInfo: {
|
||||
transitDesc: ' tömegközlekedéssel (busz, vonat, metró). Az időket egy átlagos hétköznap délelőtti időablakra számítjuk.',
|
||||
carDesc: ' autóval, a típikus sebességek és az úthálózat alapján.',
|
||||
bicycleDesc: ' kerékpárral, kerékpárbarát útvonalakon.',
|
||||
walkingDesc: ' gyalog, sétálóutakon és járdákon.',
|
||||
mainDesc: 'Megmutatja, mennyi időbe telik a kiválasztott úticél elérése az egyes területekről',
|
||||
sliderHint: 'Használd a csúszkát a maximális ingazási idő beállításához.',
|
||||
},
|
||||
|
||||
// ── AI Filter ──────────────────────────────────────
|
||||
aiFilter: {
|
||||
describeIdealArea: 'Írd le az ideális területed mesterséges intelligenciával',
|
||||
aiSearch: 'AI keresés',
|
||||
describeHint: 'Írd le, mit keresel',
|
||||
placeholder: 'pl. csendes terület, £400e alatt, jó iskolák közelében...',
|
||||
example1: 'Biztonságos terület jó iskolák közelében',
|
||||
example2: '30 perces ingazás Kings Cross-hoz, £500e alatt',
|
||||
example3: 'Csendes falu, 3 háló, gyors internet',
|
||||
analysing: 'Lekérdezés elemzése...',
|
||||
searchingDestinations: 'Úticélok keresése...',
|
||||
generatingFilters: 'Szűrők létrehozása...',
|
||||
refiningResults: 'Eredmények finomhangolása...',
|
||||
weeklyLimitReached: 'Elérted a heti AI használati limitet. Automatikusan visszaáll jövő héten.',
|
||||
},
|
||||
|
||||
// ── Map Legend ─────────────────────────────────────
|
||||
mapLegend: {
|
||||
clearColourView: 'Színezés törlése',
|
||||
historicalMatches: 'Korábbi ingatlan találatok',
|
||||
propertiesForSale: 'Eladó ingatlanok',
|
||||
propertiesForRent: 'Kiadó ingatlanok',
|
||||
numberOfProperties: 'Ingatlanok száma',
|
||||
previewing: '\u201c{{name}}\u201d előnézete',
|
||||
},
|
||||
|
||||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: 'Ismeretlen cím',
|
||||
unsaveProperty: 'Ingatlan mentésének visszavonása',
|
||||
saveProperty: 'Ingatlan mentése',
|
||||
lastSold: 'Utolsó eladás: £{{price}}',
|
||||
estValue: 'Becsült érték:',
|
||||
type: 'Típus:',
|
||||
builtForm: 'Épületforma:',
|
||||
tenure: 'Tulajdonforma:',
|
||||
floorArea: 'Alapterület:',
|
||||
bedrooms: 'Hálószobák:',
|
||||
bathrooms: 'Fürdőszobák:',
|
||||
rooms: 'Szobák:',
|
||||
built: 'Építve:',
|
||||
epcRating: 'EPC minősítés:',
|
||||
epcPotential: 'EPC potenciál:',
|
||||
listed: 'Hirdetve:',
|
||||
keyFeatures: 'Főbb jellemzők',
|
||||
renovations: 'Felújítások',
|
||||
viewExternalListing: 'Külső hirdetés megtekintése',
|
||||
perMonth: '/hó',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: 'Keresés cím vagy irányítószám alapján...',
|
||||
propertyData: 'Ingatlanadatok',
|
||||
propertyDataDesc: 'Az árak a HM Land Registry-ből származnak (a vevők által ténylegesen fizetett összeg). Az alapterület, energetikai minősítések, építési év és tulajdonforma a hivatalos EPC felmérésekből származnak. Mindkét forrás cím alapján van összepárosítva az egyes irányítószámokon belül.',
|
||||
},
|
||||
|
||||
// ── Area Pane ──────────────────────────────────────
|
||||
areaPane: {
|
||||
areaStatistics: 'Területi statisztikák',
|
||||
statsFor: 'Statisztikák a(z) {{type}} összes ingatlanáról',
|
||||
matchingFilters: ' az összes aktív szűrőnek megfelelően',
|
||||
viewProperties: '{{count}} ingatlan megtekintése',
|
||||
priceHistory: 'Ártörténet',
|
||||
journeysFrom: 'Utazások innen: {{label}}',
|
||||
to: 'Ide: {{destination}}',
|
||||
noJourneyData: 'Nincs elérhető utazási adat',
|
||||
viewOnGoogleMaps: 'Megtekintés a Google Maps-en',
|
||||
walk: 'Gyalog',
|
||||
cycle: 'Kerékpár',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
histogramLegend: {
|
||||
tealBars: 'Zöldeskkék oszlopok',
|
||||
tealBarsDesc: 'a kiválasztott terület eloszlását mutatják',
|
||||
greyBars: 'Szürke oszlopok',
|
||||
greyBarsDesc: 'az összes terület általános eloszlását mutatják',
|
||||
dashedLine: 'Szaggatott vonal',
|
||||
dashedLineDesc: 'az országos átlagot jelöli',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
streetView: {
|
||||
title: 'Utcakép',
|
||||
},
|
||||
|
||||
// ── POI Pane ───────────────────────────────────────
|
||||
poiPane: {
|
||||
pois: 'POI-k',
|
||||
pointsOfInterest: 'Érdekes pontok',
|
||||
poiDescription: 'Forrás: OpenStreetMap. Tartalmazza a tömegközlekedési megállókat, üzleteket, éttermeket, egészségügyi intézményeket, szabadidős létesítményeket és még sok mást. Rendszeresen frissítve, teljes kategórialefedettséggel.',
|
||||
searchCategories: 'Kategóriák keresése...',
|
||||
dataSourceInfo: 'Adatforrás információ',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: 'Keresés {{radius}} sugárban ezen:',
|
||||
outcodeNotRecognised: 'Nem felismert körzeti kód',
|
||||
},
|
||||
|
||||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: 'Helyek vagy irányítószámok keresése...',
|
||||
postcodeNotFound: 'Irányítószám nem található',
|
||||
lookupFailed: 'A keresés sikertelen',
|
||||
searchLabel: 'Helyek vagy irányítószámok keresése',
|
||||
locateMe: 'Ugrás a tartózkodási helyemre',
|
||||
geolocationUnsupported: 'A böngésződ nem támogatja a helymeghatározást',
|
||||
geolocationFailed: 'Nem sikerült meghatározni a tartózkodási helyed',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: 'Fiók bezárása',
|
||||
},
|
||||
|
||||
// ── Home Page ──────────────────────────────────────
|
||||
home: {
|
||||
heroTitle1: 'Maximális',
|
||||
heroTitle2: 'Érték',
|
||||
heroTitle3: 'Minimális kompromisszum.',
|
||||
heroSubtitle: 'Ingatlant keresel? Legyen a legnagyobb befektetésed a legokosabb döntésed.',
|
||||
heroDescription: 'Annyi lehetőség – a megfelelő kiválasztása nehéz lehet. Interaktív térképünk egyszerűvé teszi: válaszd ki a feltételeidet, és azonnal lásd a megfelelő területeket.',
|
||||
exploreTheMap: 'Térkép felfedezése',
|
||||
seeTheDifference: 'Nézd meg a különbséget',
|
||||
statProperties: 'ingatlan',
|
||||
statFilters: 'szűrő',
|
||||
statEvery: 'Minden',
|
||||
statPostcodeInEngland: 'irányítószám Angliában',
|
||||
ourPhilosophy: 'Filozófiánk',
|
||||
philosophyP1: 'A Rightmove-on először területet választasz, és reméled, hogy jó. Végül bűnözési statisztikákat, iskolai jelentéseket és szélessáv-ellenőrzőket böngészel tucat füleken, egyszerre egy irányítószámmal.',
|
||||
philosophyP2: 'Mi megfordítjuk. Mondd el, mire van szükséged (költségvetés, ingazás, iskolák, biztonság), és megmutatjuk Anglia összes megfelelő területét. Nincs találgatás. Nincs felesleges megtekintés.',
|
||||
howToUseIt: 'Hogyan használd',
|
||||
howStep1Title: 'Állítsd be a feltételeidet',
|
||||
howStep1Desc: 'Költségvetés, ingazás, iskolák — a térkép csak a megfelelőket mutatja.',
|
||||
howStep2Title: 'Fedezz fel területeket és rejtett kincseket',
|
||||
howStep2Desc: 'Nagyíts rá, mélyedj el a részletekben és a pluszokban.',
|
||||
howStep3Title: 'Vizsgáld meg az irányítószámokat',
|
||||
howStep3Desc: 'Nézd meg az egyes ingatlanokat, eladási árakat, alapterületet, és hasonlítsd össze.',
|
||||
howStep4Title: 'Válassz magabiztosan',
|
||||
howStep4Desc: 'A listádon minden terület megfelel a valós feltételeidnek — nem csak annak, amit azon a héten hirdettek.',
|
||||
othersVs: 'Mások vs.',
|
||||
listingPortals: 'Hirdetési portálok',
|
||||
checkMyPostcode: '“Irányítószám ellenőrzése”',
|
||||
areaGuides: 'Területi útmutatók',
|
||||
compSearchWithout: 'Keresés terület előzetes kiválasztása nélkül',
|
||||
compSearchWithoutSub: '(igényekből indulj, nem helyszínből)',
|
||||
compAreaData: 'Területi adatok',
|
||||
compAreaDataSub: '(bűnözés, iskolák, zaj, szélessáv)',
|
||||
compPropertyData: 'Ingatlanspecifikus adatok',
|
||||
compPropertyDataSub: '(ár, EPC, alapterület)',
|
||||
compFilters: '56 kombinálható szűrő egy helyen',
|
||||
compFiltersSub: '(minden információ, egy interaktív térkép)',
|
||||
ctaTitle: 'Legyen a legnagyobb befektetésed a legokosabb döntésed.',
|
||||
ctaDescription: 'Ez megfelelő eszközöket érdemel, ne bízd a szerencsére.',
|
||||
},
|
||||
|
||||
// ── Pricing Page ───────────────────────────────────
|
||||
pricingPage: {
|
||||
title: 'Korai hozzáférés árak',
|
||||
subtitle: 'Fizess egyszer, használd örökre. Minél korábban csatlakozol, annál kevesebbet fizetsz.',
|
||||
costContext: 'Egy lakásvásárlás £10 000+ illetékbe, £1 500 ügyvédi díjba, £500 szakértői vizsgálatba kerül. Ha rossz területet választasz, ráragadsz egy hosszú ingazásra, rossz iskolákra, vagy egy útra, amelyről nem tudtál.',
|
||||
lessThanSurvey: 'Kevesebbe kerül, mint egy épületszakértői vizsgálat. Sokkal hasznosabb.',
|
||||
currentTier: 'Jelenlegi szint',
|
||||
firstNUsers: 'Első {{count}} felhasználó',
|
||||
everyoneAfter: 'Mindenki más utána',
|
||||
nextNUsers: 'Következő {{count}} felhasználó',
|
||||
lifetime: '/élethosszig',
|
||||
spotsRemaining: '{{count}} hely maradt',
|
||||
spotsRemainingPlural: '{{count}} hely maradt',
|
||||
filled: 'Betelt',
|
||||
openDashboard: 'Térkép megnyitása',
|
||||
getStarted: 'Kezdjük el',
|
||||
getStartedPrice: 'Kezdjük el – {{price}}',
|
||||
noCreditCard: 'Nem szükséges bankkartya',
|
||||
moneyBackGuarantee: '30 napos pénzvisszatérítési garancia',
|
||||
soldOut: 'Elfogyott',
|
||||
upcoming: 'Következő',
|
||||
failedToLoad: 'Nem sikerült betölteni az árakat. Kérjük, próbáld újra később.',
|
||||
feat1: '56 adatréteg egész Angliában',
|
||||
feat2: 'Minden irányítószám pontozva és szűrhető',
|
||||
feat3: 'Korlátlan térképfelfedezés és exportálás',
|
||||
feat4: 'Több évtizednyi történelmi áradat',
|
||||
feat5: 'Bűnözés, iskolák, közlekedés, szélessáv és még sok más',
|
||||
feat6: 'Minden jövőbeli adatfrissítés benne van',
|
||||
},
|
||||
|
||||
// ── Learn Page ─────────────────────────────────────
|
||||
learnPage: {
|
||||
faq: 'GYIK',
|
||||
dataSources: 'Adatforrások',
|
||||
support: 'Támogatás',
|
||||
dataSourcesIntro: 'Ez az alkalmazás {{count}} nyilvános adatkészletet kombinál, amelyek ingatllanárakat, energetikai teljesítményt, közlekedést, demográfiát, bűnözést, környezetet és még sok mást fednek le.',
|
||||
faqIntro: 'Akár vásárolsz, akár bérelsz, akár csak felfedezed, így segít a Perfect Postcode megtalálni a megfelelő területet.',
|
||||
supportIntro: 'Kérdésed van? Nézd meg a GYIK-et, vagy írj nekünk közvetlenül.',
|
||||
source: 'Forrás:',
|
||||
optOut: 'Nyilvános közzététel visszautasítása',
|
||||
attribution: 'Forrásmegnevezés',
|
||||
attrLandRegistry: 'HM Land Registry adatokat tartalmaz © Crown copyright and database right 2025.',
|
||||
attrOgl: 'Közszektorbeli információt tartalmaz a következő licenc alatt:',
|
||||
attrOglLink: 'Open Government Licence v3.0',
|
||||
attrOs: 'OS adatokat tartalmaz © Crown copyright and database rights 2025.',
|
||||
attrTfl: 'A TfL Open Data által támogatva.',
|
||||
attrOsm: 'Adatokat tartalmaz innen:',
|
||||
attrOsmContrib: '© OpenStreetMap contributors',
|
||||
attrOsmLicense: 'elérhető a következő alatt:',
|
||||
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
|
||||
// Data source names & descriptions
|
||||
dsPricePaidName: 'Árfizetett adatok',
|
||||
dsPricePaidOrigin: 'HM Land Registry',
|
||||
dsPricePaidUse: 'Teljes történelmi ingatlanaladási árak Angliában.',
|
||||
dsEpcName: 'Energetikai tanúsítványok (EPC)',
|
||||
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsEpcUse: 'Lakóingatlan energetikai tanúsítványok, amelyek tartalmazzpák az alapterületet, szobaszámot, építési évet, energetikai minősítéseket, ingatlantípust és épületformát. Az Árfizetett nyilvántartásokkal cím alapján párosítva az egyes irányítószámokon belül. Az ingatlantulajdonosok visszautasíthatják a nyilvános közzétételt.',
|
||||
dsNsplName: 'Nemzeti Statisztikai Irányítószám Kereső (NSPL)',
|
||||
dsNsplOrigin: 'ONS / ArcGIS',
|
||||
dsNsplUse: 'Irányítószámokat koordinátákhoz és statisztikai területkódokhoz rendeli, amelyekkel az összes területi szintű adatkészletet az egyes ingatlanokhoz kapcsoljuk.',
|
||||
dsIodName: 'Angol Deprivációs Mutatók 2025',
|
||||
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsIodUse: 'Relatív deprivációs pontok jövedelem, foglalkoztatottság, oktatás, egészség, bűnözés és lakókörnyezet területén Anglia minden szomszédságára.',
|
||||
dsEthnicityName: 'Népesség etnikai megoszlás szerint (2021-es népszámlálás)',
|
||||
dsEthnicityOrigin: 'ONS',
|
||||
dsEthnicityUse: 'Népesség százalékos megoszlása etnikai csoportonként (dél-ázsiai, kelet-ázsiai, fekete, vegyes, fehér, egyéb) helyi önkormányzatonként.',
|
||||
dsCrimeName: 'Utcaszintű bűnözési adatok',
|
||||
dsCrimeOrigin: 'data.police.uk',
|
||||
dsCrimeUse: 'Utcaszintű bűnözési adatok 2023-tól 2025-ig, éves átlagokba összegézve LSOA-nként és bűncselekménytípusonként (erőszak, betörés, közérdekű rendsértség, kábítószer, járműbűnözés stb.).',
|
||||
dsOsmName: 'OpenStreetMap POI-k',
|
||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||
dsOsmUse: 'Érdekes pontok, beleértve üzleteket, éttermeket, egészségügyet, szabadidőt, turizmust és még sok mást Nagy-Britanniában.',
|
||||
dsGreenspaceName: 'OS Open Greenspace',
|
||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse: 'Hivatalos zöldterületi határok Nagy-Britanniában, beleértve a közparkokat, kerteket, sportterületeket és játszótereket. A poligon középpontjait használjuk a park közelségi számláláshoz és a legközelebbi park távolságának számításához.',
|
||||
dsNaptanName: 'NaPTAN (Tömegközlekedési megállók)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse: 'Állomás- és megállóhelyek vasút, busz, metró/villamos, komp és repülőtér számára Angliában.',
|
||||
dsNoiseName: 'Defra zajtérképezés',
|
||||
dsNoiseOrigin: 'Defra / Environment Agency',
|
||||
dsNoiseUse: 'Közúti zajszintek (24 órás súlyozott átlag) a 2022-es stratégiai zajtérképezésből, nagy felbontásban modellezve és minden irányítószámnál mintavételezve.',
|
||||
dsOfstedName: 'Ofsted iskolai vizsgálatok',
|
||||
dsOfstedOrigin: 'Ofsted',
|
||||
dsOfstedUse: 'Legfrissebb vizsgálati eredmények az állami fenntartású iskolákról (2025 áprilisáig). Irányítószámonként átlagolva a helyi iskolai minőség pontozásához (1=Kiváló-tól 4=Elégtelenig).',
|
||||
dsBroadbandName: 'Ofcom szélessávú teljesítmény',
|
||||
dsBroadbandOrigin: 'Ofcom',
|
||||
dsBroadbandUse: 'Vezetékes szélessávú lefedettség és maximális letöltési sebességek terültenként az Ofcom Connected Nations 2025 jelentésből.',
|
||||
dsCouncilTaxName: 'Helyi adószintek 2025-26',
|
||||
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsCouncilTaxUse: 'Éves helyi adó díjszabások A-H sávokra Anglia mind a 296 számlázó hatóságánál, két felnőtt által lakott ingatlanra. Az ingatlanokhoz a helyi önkormányzati kerületi kódon keresztül csatolva az NSPL irányítószám keresőből.',
|
||||
dsRentalName: 'Magánbérleti piaci statisztikák',
|
||||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse: 'Medián havi magánbérleti díjak helyi önkormányzatonként és hálószoba-kategóriánként (2022. okt. – 2023. szept.). Az ingatlanokhoz a helyi önkormányzati kerületi kódon és becsült hálószobaszámon keresztül csatolva.',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: 'Területed megtalálása',
|
||||
faqCommuteTitle: 'Ingazás és utazás',
|
||||
faqBudgetTitle: 'Költségvetés és érték',
|
||||
faqSafetyTitle: 'Biztonság és szomszédság',
|
||||
faqFamiliesTitle: 'Családok és iskolák',
|
||||
faqEnvironmentTitle: 'Környezet és életminőség',
|
||||
faqWhyTitle: 'Miért a Perfect Postcode',
|
||||
faqPricingTitle: 'Árak és hozzáférés',
|
||||
faqTipsTitle: 'Tippek és trükkök',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: 'Fogalmam sincs, hol keressek. Segít ebben?',
|
||||
faqFinding1A: 'Pont erre való. Állítsd be a szűrőket (költségvetés, ingazási idő, alacsony bűnözés, jó iskolák), és a térkép kivilgítja minden területet, ami megfelel. Nem kell többé éjfélkor guglizni, hogy “hol a legjobb lakni Manchester közelében”.',
|
||||
faqFinding2Q: 'Olyan helyre költözöm, ahol még soha nem voltam. Hogyan kezdjem?',
|
||||
faqFinding2A: 'Állítsd be a szűrőket arra, ami fontos, és a térkép azonnal kiemeli a megfelelő területeket. Az “egyetlen utcát sem ismerek”-ből percek alatt rövid listához jutsz.',
|
||||
faqFinding3Q: 'Hogyan találom meg azokat a területeket, amelyek minden feltételemnek megfelelnek?',
|
||||
faqFinding3A: 'Kombinálj több szűrőt (bűnözés átlag alatt, jó iskolák, ingazás 40 perc alatt), majd színezd a térképet ár szerint a legjobb értékű területek megtalálásához. A térkép élőben frissül, ahogy a csúszákat húzod.',
|
||||
// FAQ items — Commute and Travel
|
||||
faqCommute1Q: 'Láthatom, mennyi lenne az ingazásom különböző területekről?',
|
||||
faqCommute1A: 'Állítsd be a munkahelyed úticélként, és minden irányítószámot kiszínezünk utazási idő szerint, legyen az autó, kerékpár vagy tömegközlekedés. Szűrj a maximális ingazási időre, és a többi eltűnik.',
|
||||
faqCommute2Q: 'Miért jobb ez, mint a Google Maps?',
|
||||
faqCommute2A: 'A Google Maps egyszerre egy utazást mutat. Mi Anglia összes irányítószámát kiszínezzük ingazási idő szerint egyszerre, így száznál több területet hasonlíthatsz össze egyetlen pillantással, ahelyett hogy egyenként keres-gétnéd őket.',
|
||||
// FAQ items — Budget and Value
|
||||
faqBudget1Q: 'Hogyan találom meg, hol kapom a legtöbb helyet a pénzememért?',
|
||||
faqBudget1A: 'Szűrj négyzetméterár szerint, és azonnal látod, mely irányítószámok adják a legtöbb helyet fontonként. Párosítsd az energetikai minősítés szűrővel, hogy elkerüld a magas fűtési költségű ingatlanokat.',
|
||||
faqBudget2Q: 'Hogyan bizonyosodjak meg, hogy egy olcsó terület nem ok nélkül olcsó?',
|
||||
faqBudget2A: 'Rétegezd rá a deprivációs pontokat, bűnözési statisztikákat, iskolai minősítéseket és szélessáv-sebességeket az ár mellé. Ha egy irányítószám megfizethető és minden fontos szempont szerint jól teljesít, valódi értéket találtál, nem csak alacsony árat észrevétlen kompromisszumokkal.',
|
||||
// FAQ items — Safety and Neighbourhood
|
||||
faqSafety1Q: 'Hogyan ellenőrizhetem, biztonságos-e egy terület, mielőtt odaköltözöm?',
|
||||
faqSafety1A: 'Valós rendőrségi bűnözési adatokat vetitünk Anglia minden szomszédságára, típusonként lebontva. Szűrj erőszakos bűncselekményre, betörésre vagy közérdekű rendsértségre, és azonnal lásd, mely irányítószámok a legbiztosabbak.',
|
||||
faqSafety2Q: 'Folyamatosan találok reméknek tűnő lakásokat online, de a környezet rossz.',
|
||||
faqSafety2A: 'Pont ezért készült ez. Rétegezd a bűnözési arányokat, zajszinteket, deprivációs pontokat, közeli kocsmkat és parkokat, valamint a szélessáv-sebességeket egyetlen térképre, így tudhatod, milyen valójában egy szomszédság, mielőtt megtekintést foglalsz.',
|
||||
// FAQ items — Families and Schools
|
||||
faqFamilies1Q: 'Találhatok területeket jó iskolákkal ÉS alacsony bűnözéssel egyetlen kereséssel?',
|
||||
faqFamilies1A: 'Igen. Kombináld az Ofsted minősítések, bűnözési arányok, parkok és bármi más, a családod számára fontos szempont szűrőit, és a térkép csak a minden feltételnek megfelelő területeket emeli ki. Nem kell többé öt különböző weboldalt összevetni.',
|
||||
faqFamilies2Q: 'Hogyan tudhatom meg, van-e park és játszótér a közelben?',
|
||||
faqFamilies2A: 'Kapcsold be a parkok és zöldterületek POI réteget, hogy közvetlenül a térképen lásd őket. Szűrhetsz aszerint is, hány van sétatávolságon belül az egyes irányítószámoktól.',
|
||||
// FAQ items — Environment and Quality of Life
|
||||
faqEnv1Q: 'Találhatok energiahatékony otthonokat, amelyek nincsenek zajos úton?',
|
||||
faqEnv1A: 'Szűrj EPC minősítés szerint (A-C), majd rétegezd rá a közúti zajadatokat, hogy kiszűrd a küszöbértéked feletti területeket. Színezd bármelyik jellemző szerint, hogy egy pillantással észrevedd a csendes, hatékony utcákat.',
|
||||
faqEnv2Q: 'Mutatja az árvíz- vagy süllyedeskockázatot?',
|
||||
faqEnv2A: 'Tartalmazunk talajstabilitási adatokat, így ellenőrizheted a süllyeedést, agyagtalan zsugorodás-duzzadást és egyéb geológiai veszélyeket, mielőtt elköteleznéd magad egy ingatlan mellett. Szűrd ki a kockázatos területeket korán.',
|
||||
faqEnv3Q: 'Találhatok területeket gyors internettel, amelyek tényleg csendesek?',
|
||||
faqEnv3A: 'Rétegezd a szélessáv-sebesség szűrőt a közúti zajadatokkal, hogy megtaláld a kitűnő kapcsolattal és alacsony forgalmi zajjal rendelkező utcákat. Színezd bármelyik mérőszám szerint a területek összehasonlításához.',
|
||||
// FAQ items — Why Perfect Postcode
|
||||
faqWhy1Q: 'Már használom a Rightmove-ot. Mit ad ez hozzá?',
|
||||
faqWhy1A: 'A Rightmove házakat mutat. Mi területeket. Bűnözési arányok, iskolai minősítések, szélessáv-sebességek, zajszintek, deprivációs pontok és még sok más, minden szűrhető egyetlen térképen. Még azelőtt megítélheted a szomszédságot, hogy akad hirdetésekre néznél.',
|
||||
faqWhy2Q: 'Nem tudom mindezt ingyen is utánanézni?',
|
||||
faqWhy2A: 'Összevethatnéd a rendőrségi adatokat, Ofsted jelentéseket, EPC nyilvántartást, Land Registry adatokat és ONS statisztikákat egyenként, irányítószámonként. Vagy mindezt szűrhetően és színkódoltan egyetlen térképen, másodpercek alatt.',
|
||||
faqWhy3Q: 'Honnan származnak az adatok?',
|
||||
faqWhy3A: 'Minden adatkészlet hivatalos brit kormányzati forrásokból származik: Land Registry, EPC nyilvántartás, ONS, Ofsted, Ofcom, data.police.uk és Defra. Nem scrapelünk ingatlanirrodákat és nem találunk ki semmit. Bármely rekordot ellenőrizheted az eredeti forrásban.',
|
||||
// FAQ items — Pricing and Access
|
||||
faqPricing1Q: 'Tényleg megéri fizetni egy ingatlan-kereső eszközért?',
|
||||
faqPricing1A: 'Egy lakásvásárlás valószínűleg a legnagyobb vásárlásod lesz. Egyetlen figyelmeztető jel felismerése (zajos út, gyenge internet, növekvő bűnözés) elköteleződés előtt éveknűi megbánást takaríthat meg. Ez kevesebbe kerül, mint egy tank benzin.',
|
||||
faqPricing2Q: 'Ez előfizetés?',
|
||||
faqPricing2A: 'Nem. Egyszeri fizetés, örökre a tied. Használd intenzíven a keresés során, gyere vissza bármikor, ha kíváncsi vagy egy új területre, és még mindig ott van, ha újra költözöl.',
|
||||
faqPricing3Q: 'Mit érhetek el az ingyenes szinten?',
|
||||
faqPricing3A: 'Az ingyenes felhasználók a demó területen (Belső-London, megközelítőleg az 1-2. zóna) fedezhetik fel az összes funkciót. Anglia többi részének adataihoz élethosszig tartó hozzáférés szükséges.',
|
||||
faqPricing4Q: 'Kérhetek visszatérítést?',
|
||||
faqPricing4A: 'Természetesen. 30 napos pénzvisszatérítési garanciát kínálunk. Ha nem vagy elégedett, írj a support@perfect-postcode.co.uk címre 30 napon belül a teljes visszatérítésért.',
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: 'Hogyan használjam az AI szűrőt a szűrők egyenkénti hozzáadása helyett?',
|
||||
faqTips1A: 'Írd le egyszerű angolul, mit szeretnél, például “csendes terület jó iskolák közelében, gyors internettel, £400e alatt”, és az összes megfelelő szűrőt egyszerre beállítja. Utána bármelyiket kézzel finomhangolhatod.',
|
||||
faqTips2Q: 'Elmenthetem a keresést, és később visszatérhetek hozzá?',
|
||||
faqTips2A: 'Nyomd meg a mentés gombot, és mindent rögzítünk: szűrőid, a nagyítási szint, és melyik adatréteg szerint színezel. Folytasd pontosan ott, ahol abbahagytad, vagy oszd meg a linket a pároddal.',
|
||||
faqTips3Q: 'Exportálhatom az adatokat, amiket látok?',
|
||||
faqTips3A: 'Az exportálás gombbal letöltheted a jelenlegi szűrőknek megfelelő ingatlanokat táblázatként. Az export figyelembe veszi az összes aktív szűrőt, így pontosan azokat az adatokat kapod, amiket szeretnél.',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
accountPage: {
|
||||
emailLabel: 'E-mail',
|
||||
subscriptionLabel: 'Előfizetés',
|
||||
upgrade: 'Frissítés',
|
||||
redirecting: 'Átirányítás…',
|
||||
receiveNewsletter: 'Hírlevél fogadása',
|
||||
needHelp: 'Segítségre van szükséged? Írj nekünk:',
|
||||
responseTime: 'Általában 24 órán belül válaszolunk.',
|
||||
},
|
||||
|
||||
// ── Saved Page ─────────────────────────────────────
|
||||
savedPage: {
|
||||
searches: 'Keresések',
|
||||
noSavedSearches: 'Még nincsenek mentett keresések',
|
||||
noSavedSearchesDesc: 'Mentsd el a szűrőket és a térképnézetet, hogy pontosan ott folytasd, ahol abbahagytad.',
|
||||
noSavedProperties: 'Még nincsenek mentett ingatlanok',
|
||||
noSavedPropertiesDesc: 'Jelöld meg az ingatlanokat felfedezés közben, és építsd a rövid listádat elvesztés nélkül.',
|
||||
openPostcode: 'Irányítószám megnyitása',
|
||||
viewListing: 'Hirdetés megtekintése',
|
||||
clickToRename: 'Kattints az átnevezéshez',
|
||||
notesPlaceholder: 'Írd le a gondolataidat...',
|
||||
deleteSearch: 'Keresés törlése',
|
||||
deleteSearchConfirm: 'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
|
||||
deleteProperty: 'Ingatlan törlése',
|
||||
deletePropertyConfirm: 'Biztosan törölni szeretnéd ezt a mentett ingatlant? Ez nem vonható vissza.',
|
||||
bed: 'háló',
|
||||
epc: 'EPC',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
invitesPage: {
|
||||
inviteLinksLicensed: 'A meghívó linkek a licencelt felhasználók számára érhetők el.',
|
||||
inviteAdminLabel: 'Barátok meghívása (100% kedvezmény)',
|
||||
inviteReferralLabel: 'Barátok meghívása (30% kedvezmény)',
|
||||
generateFreeInvite: 'Ingyenes meghívó link létrehozása',
|
||||
generateReferralLink: 'Ajánló link létrehozása',
|
||||
copyInviteLink: 'Meghívó link másolása',
|
||||
adminInvitesTitle: 'Adminisztrátori meghívók (100% kedvezmény)',
|
||||
referralInvitesTitle: 'Ajánló meghívók (30% kedvezmény)',
|
||||
yourInviteLinks: 'Meghívó linkjeid',
|
||||
noInvitesYet: 'Még nincsenek létrehozott meghívók',
|
||||
link: 'Link',
|
||||
status: 'Állapot',
|
||||
created: 'Létrehozva',
|
||||
redeemed: 'Beváltva',
|
||||
pending: 'Függőben',
|
||||
},
|
||||
|
||||
// ── Invite Page ────────────────────────────────────
|
||||
invitePage: {
|
||||
youreInvited: 'Meghívást kaptál!',
|
||||
specialOffer: 'Különleges ajánlat!',
|
||||
invitedByFree: '{{name}} meghívott, hogy ingyenes élethosszig tartó hozzáférést kapj.',
|
||||
invitedByDiscount: '{{name}} megoszt veled egy 30%-os kedvezményt az élethosszig tartó hozzáférésre.',
|
||||
genericFreeInvite: 'Meghívást kaptál ingyenes élethosszig tartó hozzáférésre.',
|
||||
genericDiscount: 'Egy barát megoszt veled egy 30%-os kedvezményt az élethosszig tartó hozzáférésre.',
|
||||
exploreEvery: 'Fedezd fel Anglia minden szomszédságát',
|
||||
propertyInfo: 'Ingatlanárak, energetikai minősítések, bűnözési adatok, iskolai minősítések és még sok más',
|
||||
invalidInvite: 'Érvénytelen meghívó',
|
||||
inviteAlreadyUsed: 'A meghívó már felhasználva',
|
||||
inviteAlreadyUsedDesc: 'Ez a meghívó link már be lett váltva.',
|
||||
invalidInviteLink: 'Érvénytelen meghívó link',
|
||||
invalidInviteLinkDesc: 'Ez a meghívó link érvénytelen vagy lejárt.',
|
||||
licenseActivated: 'Licenc aktiválva!',
|
||||
fullAccessGranted: 'Most már teljes hozzáférésed van a Perfect Postcode-hoz.',
|
||||
activating: 'Aktiválás...',
|
||||
activateLicense: 'Licenc aktiválása',
|
||||
claimDiscount: 'Kedvezmény igénylése',
|
||||
registerToClaim: 'Regisztrálj az igényléshez',
|
||||
youAlreadyHaveLicense: 'Már van licenced',
|
||||
accountHasFullAccess: 'A fiókod már teljes hozzáféréssel rendelkezik.',
|
||||
failedToValidate: 'Nem sikerült a meghívó link érvényesítése',
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: 'Eltávolítás',
|
||||
savedProperty: 'Mentve',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: 'az imént',
|
||||
minutesAgo: '{{count}} perce',
|
||||
hoursAgo: '{{count}} órája',
|
||||
daysAgo: '{{count}} napja',
|
||||
nFilters: '{{count}} szűrő',
|
||||
noFilters: 'Nincs szűrő',
|
||||
poiCategory: '{{count}} POI-kategória',
|
||||
poiCategories: '{{count}} POI-kategória',
|
||||
travelDestination: '{{count}} utazási cél',
|
||||
travelDestinations: '{{count}} utazási cél',
|
||||
propertiesMatch: '{{count}} ingatlan megfelel',
|
||||
setFilters: '{{count}} szűrő beállítása: {{list}}',
|
||||
noFiltersSet: 'Nincs szűrő beállítva',
|
||||
toDestination: '{{mode}} ide: {{label}} {{bounds}}',
|
||||
lessThanMin: '< {{max}} perc',
|
||||
moreThanMin: '> {{min}} perc',
|
||||
},
|
||||
|
||||
// ── Tutorial ──────────────────────────────────────
|
||||
tutorial: {
|
||||
step1Title: 'Mondja el a térképnek, mi fontos',
|
||||
step1Content: 'Állítsa be a költségvetést, maximalis ingazási időt, iskola minőséget és bűnözési kúszöböt. Ami Önnek fontos. Csak a megfelelő területek maradnak kiemelve. Használja a szem ikont bármely jellemző szerinti színezéshez.',
|
||||
step2Title: 'Vagy egyszerűen írja le',
|
||||
step2Content: 'Írja le magyarul, mit keres, például „csendes terület jó iskolák közelében £400k alatt”, és beállítjuk a szűrőket Önnek.',
|
||||
step3Title: 'Fedezze fel, mi van odakint',
|
||||
step3Content: 'Görgessen és nagyítson Anglia-szerte. Kattintson bármely színes területre a bűnözés, iskolák, árak, szélessáv, zaj és egyéb adatok megtekintéséhez.',
|
||||
step4Title: 'Ugrás egy helyre',
|
||||
step4Content: 'Keressen rá bármely helyre vagy irányítószámra, hogy azonnal odajusson.',
|
||||
step5Title: 'Merüljön el a részletekben',
|
||||
step5Content: 'Tekintse meg a területi statisztikákat, hisztogramokat és az egyes ingatlanadatokat: árak, alapterület, energetikai besorolás és több.',
|
||||
step6Title: 'Mi van a közelben?',
|
||||
step6Content: 'Kapcsolja be az iskolákat, üzleteket, állomásokat, parkokat és éttermeket a térképen, hogy lássa, mi érhető el.',
|
||||
},
|
||||
|
||||
// ── Server-derived values ──────────────────────────
|
||||
// Keyed by the English server value. ts() looks up translations at display time.
|
||||
// The English keys MUST match exactly what the API returns.
|
||||
server: {
|
||||
// ─ Feature group names ─
|
||||
'Properties': 'Ingatlanok',
|
||||
'Transport': 'Közlekedés',
|
||||
'Education': 'Oktatás',
|
||||
'Deprivation': 'Depriváció',
|
||||
'Crime': 'Bűnözés',
|
||||
'Demographics': 'Demográfia',
|
||||
'Amenities': 'Szolgáltatások',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': 'Hirdetés állapota',
|
||||
'Property type': 'Ingatlantípus',
|
||||
'Leasehold/Freehold': 'Bérleti/Tulajdonjog',
|
||||
'Last known price': 'Utolsó ismert ár',
|
||||
'Estimated current price': 'Becsült jelenlegi ár',
|
||||
'Asking price': 'Hirdetési ár',
|
||||
'Price per sqm': 'Ár per nm',
|
||||
'Est. price per sqm': 'Becsült ár per nm',
|
||||
'Asking price per sqm': 'Hirdetési ár per nm',
|
||||
'Estimated monthly rent': 'Becsült havi bérleti díj',
|
||||
'Asking rent (monthly)': 'Kért bérleti díj (havi)',
|
||||
'Total floor area (sqm)': 'Teljes alapterület (nm)',
|
||||
'Number of bedrooms & living rooms': 'Háló- és nappalik száma',
|
||||
'Bedrooms': 'Hálószobák',
|
||||
'Bathrooms': 'Fürdőszobák',
|
||||
'Construction year': 'Építési év',
|
||||
'Date of last transaction': 'Utolsó tranzakció dátuma',
|
||||
'Listing date': 'Hirdetés dátuma',
|
||||
'Former council house': 'Volt önkormányzati lakás',
|
||||
'Current energy rating': 'Jelenlegi energetikai minősítés',
|
||||
'Potential energy rating': 'Potenciális energetikai minősítés',
|
||||
'Interior height (m)': 'Belmagasság (m)',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Distance to nearest train or tube station (km)': 'Távolság a legközelebbi vonat- vagy metróállomástól (km)',
|
||||
|
||||
// ─ Feature names (Education) ─
|
||||
'Good+ primary schools within 2km': 'Jó+ általános iskolák 2 km-en belül',
|
||||
'Good+ secondary schools within 2km': 'Jó+ középiskolák 2 km-en belül',
|
||||
'Good+ primary schools within 5km': 'Jó+ általános iskolák 5 km-en belül',
|
||||
'Good+ secondary schools within 5km': 'Jó+ középiskolák 5 km-en belül',
|
||||
'Education, Skills and Training Score': 'Oktatás, készségek és képzés pontszám',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
'Income Score (rate)': 'Jövedelmi pontszám (arány)',
|
||||
'Employment Score (rate)': 'Foglalkoztatottsági pontszám (arány)',
|
||||
'Health Deprivation and Disability Score': 'Egészségügyi depriváció és fogyatékosság pontszám',
|
||||
'Living Environment Score': 'Lakókörnyezet pontszám',
|
||||
'Indoors Sub-domain Score': 'Beltéri alterulet pontszám',
|
||||
'Outdoors Sub-domain Score': 'Kültéri alterulet pontszám',
|
||||
|
||||
// ─ Feature names (Crime) ─
|
||||
'Serious crime per 1k residents (avg/yr)': 'Súlyos bűncselekmény 1000 lakosra (éves átlag)',
|
||||
'Minor crime per 1k residents (avg/yr)': 'Kisebb bűncselekmény 1000 lakosra (éves átlag)',
|
||||
'Serious crime (avg/yr)': 'Súlyos bűncselekmény (éves átlag)',
|
||||
'Minor crime (avg/yr)': 'Kisebb bűncselekmény (éves átlag)',
|
||||
'Violence and sexual offences (avg/yr)': 'Erőszak és szexuális bűncselekmények (éves átlag)',
|
||||
'Burglary (avg/yr)': 'Betörés (éves átlag)',
|
||||
'Robbery (avg/yr)': 'Rablás (éves átlag)',
|
||||
'Vehicle crime (avg/yr)': 'Járműbűnözés (éves átlag)',
|
||||
'Anti-social behaviour (avg/yr)': 'Közérdekű rendsértség (éves átlag)',
|
||||
'Criminal damage and arson (avg/yr)': 'Rongálás és gyújtogatás (éves átlag)',
|
||||
'Other theft (avg/yr)': 'Egyéb lopás (éves átlag)',
|
||||
'Theft from the person (avg/yr)': 'Személy elleni lopás (éves átlag)',
|
||||
'Shoplifting (avg/yr)': 'Boltí lopás (éves átlag)',
|
||||
'Bicycle theft (avg/yr)': 'Kerékpárlopás (éves átlag)',
|
||||
'Drugs (avg/yr)': 'Kábítószer (éves átlag)',
|
||||
'Possession of weapons (avg/yr)': 'Fegyvertartás (éves átlag)',
|
||||
'Public order (avg/yr)': 'Közrend (éves átlag)',
|
||||
'Other crime (avg/yr)': 'Egyéb bűncselekmény (éves átlag)',
|
||||
|
||||
// ─ Feature names (Demographics) ─
|
||||
'Median age': 'Medián életkor',
|
||||
'% White': '% fehér',
|
||||
'% South Asian': '% dél-ázsiai',
|
||||
'% Black': '% fekete',
|
||||
'% East Asian': '% kelet-ázsiai',
|
||||
'% Mixed': '% vegyes',
|
||||
'% Other': '% egyéb',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': 'Távolság a legközelebbi parktól (km)',
|
||||
'Number of parks within 2km': 'Parkok száma 2 km-en belül',
|
||||
'Number of restaurants within 2km': 'Éttermek száma 2 km-en belül',
|
||||
'Number of grocery shops and supermarkets within 2km': 'Élelmiszerboltok és szupermarketek száma 2 km-en belül',
|
||||
'Noise (dB)': 'Zaj (dB)',
|
||||
'Max available download speed (Mbps)': 'Max elérhető letöltési sebesség (Mbps)',
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': 'Történelmi eladás',
|
||||
'For sale': 'Eladó',
|
||||
'For rent': 'Kiadó',
|
||||
'Detached': 'Különálló',
|
||||
'Semi-Detached': 'Ikerház',
|
||||
'Terraced': 'Sorház',
|
||||
'Flats/Maisonettes': 'Lakások/Maisonette-ek',
|
||||
'Other': 'Egyéb',
|
||||
'Freehold': 'Tulajdonjog',
|
||||
'Leasehold': 'Bérleti jog',
|
||||
'Yes': 'Igen',
|
||||
'No': 'Nem',
|
||||
|
||||
// ─ Stacked chart labels ─
|
||||
'Serious crime': 'Súlyos bűncselekmény',
|
||||
'Minor crime': 'Kisebb bűncselekmény',
|
||||
'Ethnic composition': 'Etnikai összetétel',
|
||||
|
||||
// ─ POI group names ─
|
||||
'Public Transport': 'Tömegközlekedés',
|
||||
'Leisure': 'Szabadidő',
|
||||
'Health': 'Egészségügy',
|
||||
'Emergency Services': 'Sürgősségi szolgálatok',
|
||||
'Groceries': 'Élelmiszer',
|
||||
'Local Businesses': 'Helyi vállalkozások',
|
||||
'Culture': 'Kultúra',
|
||||
'Services': 'Szolgáltatások',
|
||||
'Shops': 'Üzletek',
|
||||
|
||||
// ─ POI categories ─
|
||||
'Airport': 'Repülőtér',
|
||||
'Ferry': 'Komp',
|
||||
'Rail station': 'Vasútállomás',
|
||||
'Bus stop': 'Buszmegálló',
|
||||
'Bus station': 'Buszpályaudvar',
|
||||
'Taxi rank': 'Taxiállomás',
|
||||
'Metro or Tram stop': 'Metró- vagy villamosmegálló',
|
||||
'Café': 'Kávézó',
|
||||
'Restaurant': 'Étterem',
|
||||
'Pub': 'Kocsma',
|
||||
'Bar': 'Bár',
|
||||
'Fast Food': 'Gyorsétterem',
|
||||
'Nightclub': 'Éjszakai klub',
|
||||
'Cinema': 'Mozi',
|
||||
'Theatre': 'Színház',
|
||||
'Live Music & Events': 'Élőzene és rendezvények',
|
||||
'Park': 'Park',
|
||||
'Playground': 'Játszótér',
|
||||
'Sports Centre': 'Sportközpont',
|
||||
'Entertainment': 'Szórakoztatás',
|
||||
'Supermarket': 'Szupermarket',
|
||||
'Convenience Store': 'Kísbolt',
|
||||
'Bakery': 'Pékség',
|
||||
'Butcher & Fishmonger': 'Hentes és halas',
|
||||
'Greengrocer': 'Zöldséges',
|
||||
'Off-Licence': 'Italozó',
|
||||
'Deli & Specialty': 'Csemege és különleges',
|
||||
'Fashion & Clothing': 'Divat és ruházat',
|
||||
'Electronics': 'Elektronika',
|
||||
'Charity Shop': 'Jótékonysági bolt',
|
||||
'DIY & Hardware': 'Barkacs és vas',
|
||||
'Home & Garden': 'Otthon és kert',
|
||||
'Bookshop': 'Könyvesbolt',
|
||||
'Pet Shop': 'Állatkereskedés',
|
||||
'Sports & Outdoor': 'Sport és szabadtér',
|
||||
'Newsagent': 'Újságárus',
|
||||
'Department Store': 'Áruház',
|
||||
'Gift & Hobby': 'Ajándék és hobbi',
|
||||
'Specialist Shop': 'Szaküzlet',
|
||||
'Hairdresser & Beauty': 'Fodrász és szépség',
|
||||
'Gym & Fitness': 'Edzterem és fitnesz',
|
||||
'Dry Cleaner & Laundry': 'Vegytisztító és mosoda',
|
||||
'Car Services': 'Autós szolgáltatások',
|
||||
'Post Office': 'Posta',
|
||||
'Vet & Pet Care': 'Állatorvos és állatgondozás',
|
||||
'Bank': 'Bank',
|
||||
'Travel Agent': 'Utazási iroda',
|
||||
'Police': 'Rendőrség',
|
||||
'Fire Station': 'Tűzoltóság',
|
||||
'Ambulance Station': 'Mentőállomás',
|
||||
'GP Surgery': 'Háziorvosi rendelő',
|
||||
'Dentist': 'Fogorvos',
|
||||
'Pharmacy': 'Gyógyszertár',
|
||||
'Hospital & Clinic': 'Kórház és klinika',
|
||||
'Optician': 'Optikus',
|
||||
'Physiotherapy': 'Fizioterápia',
|
||||
'Counselling & Therapy': 'Tanácsadás és terápia',
|
||||
'Care Home': 'Gondozóház',
|
||||
'Medical & Mobility': 'Egészségügyi és mobilitási eszközök',
|
||||
'Museum': 'Múzeum',
|
||||
'Gallery': 'Galéria',
|
||||
'Library': 'Könyvtár',
|
||||
'Place of Worship': 'Istentiszteleti hely',
|
||||
'Arts Centre': 'Művészeti központ',
|
||||
'Zoo': 'Állatkert',
|
||||
'Tourist Attraction': 'Turisztikai látványosság',
|
||||
'School': 'Iskola',
|
||||
'Hotel': 'Szálloda',
|
||||
'Local Business': 'Helyi vállalkozás',
|
||||
'Offices': 'Irodák',
|
||||
'EV Charging': 'Elektromos töltőállomás',
|
||||
'Fuel Station': 'Benzinkút',
|
||||
'Community Centre': 'Közösségi központ',
|
||||
|
||||
// ─ Suffixes (used in formatters) ─
|
||||
'/mo': '/hó',
|
||||
'/yr': '/év',
|
||||
' sqm': ' nm',
|
||||
' km': ' km',
|
||||
' m': ' m',
|
||||
' dB': ' dB',
|
||||
' years': ' év',
|
||||
' rooms': ' szoba',
|
||||
},
|
||||
};
|
||||
|
||||
export default hu;
|
||||
849
frontend/src/i18n/locales/zh.ts
Normal file
849
frontend/src/i18n/locales/zh.ts
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
import type { Translations } from './en';
|
||||
|
||||
const zh: Translations = {
|
||||
// ── Common ──────────────────────────────────────────
|
||||
common: {
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
delete: '删除',
|
||||
open: '打开',
|
||||
share: '分享',
|
||||
copy: '复制',
|
||||
copied: '已复制!',
|
||||
copiedToClipboard: '已复制到剪贴板',
|
||||
loading: '加载中...',
|
||||
loadMore: '加载更多',
|
||||
remaining: '剩余 {{count}} 项',
|
||||
search: '搜索',
|
||||
all: '全部',
|
||||
none: '无',
|
||||
viewDataSource: '查看数据来源',
|
||||
total: '总计',
|
||||
min: '分钟',
|
||||
or: '或',
|
||||
area: '区域',
|
||||
properties: '房产',
|
||||
postcode: '邮编',
|
||||
noAreaSelected: '未选择区域',
|
||||
noAreaSelectedDesc: '点击地图上任意彩色区域,查看犯罪率、学校、房价等信息',
|
||||
clickForDetails: '点击查看详情',
|
||||
property: '处房产',
|
||||
propertiesPlural: '处房产',
|
||||
},
|
||||
|
||||
// ── Header / Nav ───────────────────────────────────
|
||||
header: {
|
||||
appName: 'Perfect Postcode',
|
||||
dashboard: '地图面板',
|
||||
learn: '了解更多',
|
||||
pricing: '价格',
|
||||
inviteFriends: '邀请好友',
|
||||
saved: '已保存',
|
||||
logIn: '登录',
|
||||
createAccount: '注册账户',
|
||||
sharing: '分享中...',
|
||||
exportLabel: '导出',
|
||||
exporting: '导出中...',
|
||||
exportToExcel: '导出为 Excel',
|
||||
openMenu: '打开菜单',
|
||||
closeMenu: '关闭菜单',
|
||||
},
|
||||
|
||||
// ── User Menu ──────────────────────────────────────
|
||||
userMenu: {
|
||||
fullAccess: '完整访问',
|
||||
demo: '演示版',
|
||||
themeLight: '主题:浅色',
|
||||
themeDark: '主题:深色',
|
||||
account: '账户',
|
||||
logOut: '退出登录',
|
||||
},
|
||||
|
||||
// ── Mobile Menu ────────────────────────────────────
|
||||
mobileMenu: {
|
||||
menu: '菜单',
|
||||
home: '首页',
|
||||
},
|
||||
|
||||
// ── Auth Modal ─────────────────────────────────────
|
||||
auth: {
|
||||
logIn: '登录',
|
||||
createAccount: '注册账户',
|
||||
resetPassword: '重置密码',
|
||||
valueProp: '保存搜索、收藏房产,随时继续浏览。',
|
||||
continueWithGoogle: '使用 Google 账号继续',
|
||||
email: '邮箱',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: '密码',
|
||||
passwordPlaceholderRegister: '至少 8 个字符',
|
||||
passwordPlaceholderLogin: '您的密码',
|
||||
forgotPassword: '忘记密码?',
|
||||
resetSent: '请查收邮件中的重置链接。',
|
||||
pleaseWait: '请稍候...',
|
||||
sendResetLink: '发送重置链接',
|
||||
backToLogin: '返回登录',
|
||||
},
|
||||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
upgrade: {
|
||||
title: '查看整个英格兰',
|
||||
description: '您目前正在浏览演示区域。获取终身访问权限,覆盖每个邮编、每项筛选条件、每个社区。一次付款,永久使用。',
|
||||
free: '免费',
|
||||
once: '/一次性',
|
||||
freeForEarly: '早期用户免费。无需信用卡。',
|
||||
oneTimePayment: '一次性付款。终身访问。30天无条件退款。',
|
||||
redirecting: '跳转中...',
|
||||
claimFreeAccess: '领取免费访问权限',
|
||||
upgradeFor: '升级仅需 {{price}}',
|
||||
registerAndUpgrade: '注册并升级',
|
||||
alreadyHaveAccount: '已有账户?请登录',
|
||||
continueWithDemo: '继续使用演示版',
|
||||
checkoutFailed: '结账失败',
|
||||
},
|
||||
|
||||
// ── Save Search Modal ──────────────────────────────
|
||||
saveSearch: {
|
||||
title: '保存搜索',
|
||||
saved: '搜索已保存',
|
||||
savedSuccess: '您的搜索已成功保存。',
|
||||
viewSavedSearches: '查看已保存的搜索',
|
||||
name: '名称',
|
||||
namePlaceholder: '我的搜索',
|
||||
saving: '保存中...',
|
||||
},
|
||||
|
||||
// ── License Success ────────────────────────────────
|
||||
licenseSuccess: {
|
||||
title: '激活成功!',
|
||||
subtitle: '您的终身访问权限已生效。',
|
||||
description: '完整访问所有功能、所有邮编,覆盖整个英格兰。',
|
||||
startExploring: '开始探索',
|
||||
},
|
||||
|
||||
// ── Filters ────────────────────────────────────────
|
||||
filters: {
|
||||
activeFilters: '当前筛选条件',
|
||||
addFilter: '添加筛选条件',
|
||||
historical: '历史交易',
|
||||
buy: '买房',
|
||||
rent: '租房',
|
||||
findingPerfectPostcode: '寻找理想的邮编',
|
||||
addFiltersHint: '添加以下筛选条件,将地图缩小到符合您要求的区域',
|
||||
upgradePrompt: '查看犯罪率、学校、噪音、宽带等 50 多项筛选条件,覆盖整个英格兰。',
|
||||
oneTimeLifetime: '一次性付款,终身访问。',
|
||||
upgradeToFullMap: '升级到完整地图',
|
||||
chooseFilters: '选择您关心的筛选条件,地图会随之实时更新。',
|
||||
searchFeatures: '搜索数据指标...',
|
||||
noMatchingFeatures: '没有匹配的数据指标',
|
||||
tryDifferentSearch: '尝试不同的搜索词',
|
||||
allFeaturesActive: '所有数据指标已启用',
|
||||
removeFilterHint: '移除一个筛选条件以查看可用的数据指标',
|
||||
featureInfo: '数据指标信息',
|
||||
replayTutorial: '重新播放交互教程',
|
||||
clearAll: '全部清除',
|
||||
clearAllTitle: '清除所有筛选条件?',
|
||||
clearAllSavePrompt: '是否要在清除前保存当前的筛选条件?',
|
||||
saveAndClear: '保存并清除',
|
||||
clearWithoutSaving: '不保存直接清除',
|
||||
},
|
||||
|
||||
// ── Philosophy Popup ───────────────────────────────
|
||||
philosophy: {
|
||||
intro: '从必须满足的条件开始,再逐步添加加分项。每添加一个筛选条件,地图范围就会缩小。剩下的区域就是最适合您的。',
|
||||
step1Title: '预算和基本条件',
|
||||
step1Desc: '(价格范围、建筑面积、房产类型)',
|
||||
step2Title: '通勤',
|
||||
step2Desc: '(驾车、骑行或公共交通到工作地点的时间)',
|
||||
step3Title: '安全',
|
||||
step3Desc: '(犯罪率、噪音水平、地面稳定性)',
|
||||
step4Title: '学校',
|
||||
step4Desc: '(附近 Ofsted 评级为"良好"或"优秀"的学校)',
|
||||
step5Title: '生活方式',
|
||||
step5Desc: '(餐厅、公园、宽带速度)',
|
||||
step6Title: '能源',
|
||||
step6Desc: '(能源性能证书评级、保温、供暖费用)',
|
||||
tip: '提示:如果没有匹配结果,尝试逐个放宽条件,看看哪个让步能带来最多选择。',
|
||||
},
|
||||
|
||||
// ── Travel Time ────────────────────────────────────
|
||||
travel: {
|
||||
travelTime: '通勤时间({{mode}})',
|
||||
maxTime: '最长时间',
|
||||
selectDestination: '选择目的地...',
|
||||
bestCase: '最佳情况',
|
||||
bestCaseTitle: '最佳通勤时间',
|
||||
bestCaseDesc: '使用最快的实际出行时间(如果您把握好出发时间并赶上良好的换乘)。默认使用<strong>中位数</strong>,代表无论何时出发的典型出行时间。',
|
||||
previewOnMap: '在地图上预览',
|
||||
stopPreviewing: '停止预览',
|
||||
removeTravelTime: '移除通勤时间',
|
||||
addTravelTime: '添加{{mode}}通勤时间',
|
||||
clearDestination: '清除目的地',
|
||||
typeToFilter: '输入以筛选...',
|
||||
noDestinations: '未找到目的地',
|
||||
modeCar: '驾车',
|
||||
modeBicycle: '骑行',
|
||||
modeWalking: '步行',
|
||||
modeTransit: '公共交通',
|
||||
modeCarDesc: '经最快道路的驾车时间',
|
||||
modeBicycleDesc: '经自行车友好路线的骑行时间',
|
||||
modeWalkingDesc: '经人行道和步行路线的步行时间',
|
||||
modeTransitDesc: '乘火车、地铁和公交车的出行时间',
|
||||
},
|
||||
|
||||
// ── Travel Time Info Popup ─────────────────────────
|
||||
travelInfo: {
|
||||
transitDesc: '乘坐公共交通(公交车、火车、地铁)。时间根据典型工作日早晨时段计算。',
|
||||
carDesc: '驾车出行,基于典型道路速度和路网计算。',
|
||||
bicycleDesc: '骑自行车出行,使用自行车友好路线。',
|
||||
walkingDesc: '步行出行,使用人行道和步行路线。',
|
||||
mainDesc: '显示从每个区域到达所选目的地所需的时间',
|
||||
sliderHint: '使用滑块设置您的最长通勤时间。',
|
||||
},
|
||||
|
||||
// ── AI Filter ──────────────────────────────────────
|
||||
aiFilter: {
|
||||
describeIdealArea: '用 AI 描述您的理想区域',
|
||||
aiSearch: 'AI 搜索',
|
||||
describeHint: '描述您要找的区域',
|
||||
placeholder: '例如:安静的区域,低于 £40万,靠近好学校...',
|
||||
example1: '安全的区域,靠近好学校',
|
||||
example2: '到国王十字站30分钟通勤,低于 £50万',
|
||||
example3: '安静的村庄,3间卧室,快速宽带',
|
||||
analysing: '正在分析您的需求...',
|
||||
searchingDestinations: '正在搜索目的地...',
|
||||
generatingFilters: '正在生成筛选条件...',
|
||||
refiningResults: '正在优化结果...',
|
||||
weeklyLimitReached: '您已达到每周 AI 使用上限。下周将自动重置。',
|
||||
},
|
||||
|
||||
// ── Map Legend ─────────────────────────────────────
|
||||
mapLegend: {
|
||||
clearColourView: '清除颜色视图',
|
||||
historicalMatches: '历史房产匹配',
|
||||
propertiesForSale: '待售房产',
|
||||
propertiesForRent: '待租房产',
|
||||
numberOfProperties: '房产数量',
|
||||
previewing: '预览\u201c{{name}}\u201d',
|
||||
},
|
||||
|
||||
// ── Properties Pane ────────────────────────────────
|
||||
propertyCard: {
|
||||
unknownAddress: '地址未知',
|
||||
unsaveProperty: '取消收藏',
|
||||
saveProperty: '收藏房产',
|
||||
lastSold: '上次成交价:£{{price}}',
|
||||
estValue: '估计价值:',
|
||||
type: '类型:',
|
||||
builtForm: '建筑形式:',
|
||||
tenure: '产权:',
|
||||
floorArea: '建筑面积:',
|
||||
bedrooms: '卧室:',
|
||||
bathrooms: '浴室:',
|
||||
rooms: '房间:',
|
||||
built: '建造年份:',
|
||||
epcRating: '能源评级:',
|
||||
epcPotential: '潜在能源评级:',
|
||||
listed: '上市日期:',
|
||||
keyFeatures: '主要特点',
|
||||
renovations: '翻新记录',
|
||||
viewExternalListing: '查看外部房源',
|
||||
perMonth: '/月',
|
||||
perSqm: '/m²',
|
||||
searchPlaceholder: '按地址或邮编搜索...',
|
||||
propertyData: '房产数据',
|
||||
propertyDataDesc: '价格来自英国土地注册局(买家实际支付的金额)。建筑面积、能源评级、建造年份和产权来自官方能源性能证书调查。两个数据源通过每个邮编内的地址进行匹配。',
|
||||
},
|
||||
|
||||
// ── Area Pane ──────────────────────────────────────
|
||||
areaPane: {
|
||||
areaStatistics: '区域统计',
|
||||
statsFor: '该{{type}}内所有房产的统计数据',
|
||||
matchingFilters: ',满足所有当前筛选条件',
|
||||
viewProperties: '查看 {{count}} 处房产',
|
||||
priceHistory: '价格历史',
|
||||
journeysFrom: '从 {{label}} 出发的路线',
|
||||
to: '到 {{destination}}',
|
||||
noJourneyData: '暂无出行数据',
|
||||
viewOnGoogleMaps: '在 Google Maps 上查看',
|
||||
walk: '步行',
|
||||
cycle: '骑行',
|
||||
},
|
||||
|
||||
// ── Histogram Legend ───────────────────────────────
|
||||
histogramLegend: {
|
||||
tealBars: '青色柱状图',
|
||||
tealBarsDesc: '显示所选区域内的分布情况',
|
||||
greyBars: '灰色柱状图',
|
||||
greyBarsDesc: '显示所有区域的整体分布情况',
|
||||
dashedLine: '虚线',
|
||||
dashedLineDesc: '表示全国平均值',
|
||||
},
|
||||
|
||||
// ── Street View ────────────────────────────────────
|
||||
streetView: {
|
||||
title: '街景视图',
|
||||
},
|
||||
|
||||
// ── POI Pane ───────────────────────────────────────
|
||||
poiPane: {
|
||||
pois: '兴趣点',
|
||||
pointsOfInterest: '兴趣点',
|
||||
poiDescription: '数据来自 OpenStreetMap。涵盖公共交通站点、商店、餐厅、医疗机构、休闲场所等。定期更新,类别覆盖完整。',
|
||||
searchCategories: '搜索类别...',
|
||||
dataSourceInfo: '数据来源信息',
|
||||
},
|
||||
|
||||
// ── External Search Links ──────────────────────────
|
||||
externalSearch: {
|
||||
searchOn: '在 {{radius}} 范围内搜索',
|
||||
outcodeNotRecognised: '无法识别该邮编区域',
|
||||
},
|
||||
|
||||
// ── Location Search ────────────────────────────────
|
||||
locationSearch: {
|
||||
placeholder: '搜索地点或邮编...',
|
||||
postcodeNotFound: '未找到该邮编',
|
||||
lookupFailed: '查询失败',
|
||||
searchLabel: '搜索地点或邮编',
|
||||
locateMe: '转到我的位置',
|
||||
geolocationUnsupported: '您的浏览器不支持地理定位',
|
||||
geolocationFailed: '无法确定您的位置',
|
||||
},
|
||||
|
||||
// ── Mobile Drawer ──────────────────────────────────
|
||||
mobileDrawer: {
|
||||
closeDrawer: '关闭抽屉',
|
||||
},
|
||||
|
||||
// ── Home Page ──────────────────────────────────────
|
||||
home: {
|
||||
heroTitle1: '最大',
|
||||
heroTitle2: '价值',
|
||||
heroTitle3: '最小妥协。',
|
||||
heroSubtitle: '正在找房?让您最大的投资成为最明智的决定。',
|
||||
heroDescription: '选择太多,找到合适的可能让人不知所措。我们的交互式地图让一切变得简单:选择您的必要条件,立即看到符合的区域。',
|
||||
exploreTheMap: '探索地图',
|
||||
seeTheDifference: '看看有何不同',
|
||||
statProperties: '处房产',
|
||||
statFilters: '项筛选条件',
|
||||
statEvery: '覆盖',
|
||||
statPostcodeInEngland: '英格兰每个邮编',
|
||||
ourPhilosophy: '我们的理念',
|
||||
philosophyP1: '在 Rightmove 上,您需要先选一个区域,然后期望它足够好。最终您不得不在十几个标签页中交叉对比犯罪数据、学校报告和宽带速度,一个邮编一个邮编地查。',
|
||||
philosophyP2: '我们反其道而行。告诉我们您的需求(预算、通勤、学校、安全),我们为您展示英格兰所有符合条件的区域。不用猜测,不浪费看房时间。',
|
||||
howToUseIt: '使用方法',
|
||||
howStep1Title: '设定必要条件',
|
||||
howStep1Desc: '预算、通勤、学校——地图只显示符合条件的区域。',
|
||||
howStep2Title: '探索区域,发现隐藏的好地方',
|
||||
howStep2Desc: '放大查看,深入了解细节和加分项。',
|
||||
howStep3Title: '深入邮编级别',
|
||||
howStep3Desc: '查看单个房产、成交价、建筑面积,并进行比较。',
|
||||
howStep4Title: '自信地列出候选名单',
|
||||
howStep4Desc: '您名单上的每个区域都满足您的实际需求——而不只是当周恰好有房源。',
|
||||
othersVs: '其他平台 vs',
|
||||
listingPortals: '房源网站',
|
||||
checkMyPostcode: '"查查我的邮编"类网站',
|
||||
areaGuides: '区域指南',
|
||||
compSearchWithout: '无需先选区域即可搜索',
|
||||
compSearchWithoutSub: '(从需求出发,而非地点)',
|
||||
compAreaData: '区域数据',
|
||||
compAreaDataSub: '(犯罪率、学校、噪音、宽带)',
|
||||
compPropertyData: '房产专属数据',
|
||||
compPropertyDataSub: '(价格、能源性能证书、建筑面积)',
|
||||
compFilters: '56 项可组合筛选条件,尽在一处',
|
||||
compFiltersSub: '(所有信息,一张交互式地图)',
|
||||
ctaTitle: '让您最大的投资成为最明智的 决定。',
|
||||
ctaDescription: '这值得用专业的工具来做,别全靠运气。',
|
||||
},
|
||||
|
||||
// ── Pricing Page ───────────────────────────────────
|
||||
pricingPage: {
|
||||
title: '早期访问价格',
|
||||
subtitle: '一次付款,永久访问。越早加入,价格越优惠。',
|
||||
costContext: '买房需要支付超过 £10,000 的印花税、£1,500 的律师费、£500 的房屋评估费。选错区域,您可能要忍受漫长的通勤、差劲的学校,或一条您事先不知道的嘈杂马路。',
|
||||
lessThanSurvey: '不到一次房屋评估的费用,却有用得多。',
|
||||
currentTier: '当前档位',
|
||||
firstNUsers: '前 {{count}} 名用户',
|
||||
everyoneAfter: '之后的所有人',
|
||||
nextNUsers: '接下来 {{count}} 名用户',
|
||||
lifetime: '/终身',
|
||||
spotsRemaining: '剩余 {{count}} 个名额',
|
||||
spotsRemainingPlural: '剩余 {{count}} 个名额',
|
||||
filled: '已满',
|
||||
openDashboard: '打开地图面板',
|
||||
getStarted: '立即开始',
|
||||
getStartedPrice: '立即开始 - {{price}}',
|
||||
noCreditCard: '无需信用卡',
|
||||
moneyBackGuarantee: '30天无条件退款保证',
|
||||
soldOut: '已售罄',
|
||||
upcoming: '即将开放',
|
||||
failedToLoad: '加载价格信息失败,请稍后重试。',
|
||||
feat1: '56 个数据图层,覆盖整个英格兰',
|
||||
feat2: '每个邮编均有评分,支持筛选',
|
||||
feat3: '无限制地图浏览和数据导出',
|
||||
feat4: '数十年的历史价格数据',
|
||||
feat5: '犯罪率、学校、交通、宽带等',
|
||||
feat6: '包含所有未来数据更新',
|
||||
},
|
||||
|
||||
// ── Learn Page ─────────────────────────────────────
|
||||
learnPage: {
|
||||
faq: '常见问题',
|
||||
dataSources: '数据来源',
|
||||
support: '支持',
|
||||
dataSourcesIntro: '本应用整合了 {{count}} 个开放数据集,涵盖房产价格、能源性能、交通、人口统计、犯罪、环境等领域。',
|
||||
faqIntro: '无论您是购房、租房还是单纯浏览,以下是 Perfect Postcode 如何帮助您找到理想区域。',
|
||||
supportIntro: '有问题?请查看我们的常见问题或直接联系我们。',
|
||||
source: '来源:',
|
||||
optOut: '退出公开披露',
|
||||
attribution: '数据引用声明',
|
||||
attrLandRegistry: '包含 HM Land Registry 数据 © Crown copyright and database right 2025。',
|
||||
attrOgl: '包含根据以下许可证授权的公共部门信息:',
|
||||
attrOglLink: 'Open Government Licence v3.0',
|
||||
attrOs: '包含 OS 数据 © Crown copyright and database rights 2025。',
|
||||
attrTfl: '由 TfL Open Data 提供支持。',
|
||||
attrOsm: '包含来自',
|
||||
attrOsmContrib: '© OpenStreetMap contributors',
|
||||
attrOsmLicense: '的数据,依据',
|
||||
attrOsmLicenseLink: 'Open Data Commons Open Database License (ODbL)',
|
||||
// Data source names & descriptions
|
||||
dsPricePaidName: 'Price Paid Data',
|
||||
dsPricePaidOrigin: 'HM Land Registry',
|
||||
dsPricePaidUse: '英格兰完整的历史房产成交价格数据。',
|
||||
dsEpcName: 'Energy Performance Certificates (EPC)',
|
||||
dsEpcOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsEpcUse: '住宅能源性能证书,提供建筑面积、房间数量、建造年份、能源评级、房产类型和建筑形式等信息。通过每个邮编内的地址与成交价格数据进行匹配。业主可以退出公开披露。',
|
||||
dsNsplName: 'National Statistics Postcode Lookup (NSPL)',
|
||||
dsNsplOrigin: 'ONS / ArcGIS',
|
||||
dsNsplUse: '将邮编映射到坐标和统计区域代码,用于将所有区域级数据集关联到各个房产。',
|
||||
dsIodName: 'English Indices of Deprivation 2025',
|
||||
dsIodOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsIodUse: '英格兰每个社区在收入、就业、教育、健康、犯罪和居住环境方面的相对贫困指数。',
|
||||
dsEthnicityName: '按族裔划分的人口(2021 年人口普查)',
|
||||
dsEthnicityOrigin: 'ONS',
|
||||
dsEthnicityUse: '按族裔群体(南亚裔、东亚裔、黑人、混血、白人、其他)划分的各地方政府辖区人口百分比。',
|
||||
dsCrimeName: 'Street-level Crime Data',
|
||||
dsCrimeOrigin: 'data.police.uk',
|
||||
dsCrimeUse: '2023 年至 2025 年的街道级犯罪数据,按 LSOA 和犯罪类型(暴力犯罪、入室盗窃、反社会行为、毒品、车辆犯罪等)汇总为年均值。',
|
||||
dsOsmName: 'OpenStreetMap POIs',
|
||||
dsOsmOrigin: 'OpenStreetMap contributors / Geofabrik',
|
||||
dsOsmUse: '涵盖大不列颠地区的商店、餐厅、医疗、休闲、旅游等兴趣点。',
|
||||
dsGreenspaceName: 'OS Open Greenspace',
|
||||
dsGreenspaceOrigin: 'Ordnance Survey',
|
||||
dsGreenspaceUse: '大不列颠地区权威的绿地边界数据,包括公共公园、花园、运动场和游乐场。多边形质心用于公园邻近度计数和最近公园距离计算。',
|
||||
dsNaptanName: 'NaPTAN (Public Transport Stops)',
|
||||
dsNaptanOrigin: 'Department for Transport',
|
||||
dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。',
|
||||
dsNoiseName: 'Defra Noise Mapping',
|
||||
dsNoiseOrigin: 'Defra / Environment Agency',
|
||||
dsNoiseUse: '来自 2022 年战略噪音测绘的道路噪音水平(24 小时加权平均值),经高分辨率建模并在每个邮编处采样。',
|
||||
dsOfstedName: 'Ofsted School Inspections',
|
||||
dsOfstedOrigin: 'Ofsted',
|
||||
dsOfstedUse: '公立学校最新督察结果(截至 2025 年 4 月)。按邮编取平均值,得出当地学校质量评分(1=优秀至4=不合格)。',
|
||||
dsBroadbandName: 'Ofcom Broadband Performance',
|
||||
dsBroadbandOrigin: 'Ofcom',
|
||||
dsBroadbandUse: '来自 Ofcom Connected Nations 2025 的各区域固定宽带覆盖率和最大下载速度。',
|
||||
dsCouncilTaxName: 'Council Tax Levels 2025-26',
|
||||
dsCouncilTaxOrigin: 'Ministry of Housing, Communities & Local Government',
|
||||
dsCouncilTaxUse: '英格兰所有 296 个计费机构的 A 至 H 等级年度市政税税率,适用于两名成年人居住的住宅。通过 NSPL 邮编查询中的地方政府区域代码关联到房产。',
|
||||
dsRentalName: 'Private Rental Market Statistics',
|
||||
dsRentalOrigin: 'ONS / Valuation Office Agency',
|
||||
dsRentalUse: '按地方政府辖区和卧室类别划分的月度私人租金中位数(2022 年 10 月至 2023 年 9 月)。通过地方政府区域代码和估算卧室数量关联到房产。',
|
||||
// FAQ section titles
|
||||
faqFindingTitle: '寻找理想区域',
|
||||
faqCommuteTitle: '通勤与出行',
|
||||
faqBudgetTitle: '预算与性价比',
|
||||
faqSafetyTitle: '安全与社区环境',
|
||||
faqFamiliesTitle: '家庭与学校',
|
||||
faqEnvironmentTitle: '环境与生活质量',
|
||||
faqWhyTitle: '为什么选择 Perfect Postcode',
|
||||
faqPricingTitle: '价格与访问权限',
|
||||
faqTipsTitle: '使用技巧',
|
||||
// FAQ items — Finding Your Area
|
||||
faqFinding1Q: '我完全不知道该看哪些区域,这个工具能帮到我吗?',
|
||||
faqFinding1A: '这正是它的用途。设置您的筛选条件(预算、通勤时间、低犯罪率、好学校),地图就会亮起来,显示所有符合条件的区域。不用再半夜搜索"曼彻斯特附近最好的居住区"了。',
|
||||
faqFinding2Q: '我要搬到一个从未去过的地方,该从何开始?',
|
||||
faqFinding2A: '设置您关心的筛选条件,地图会立即高亮显示符合条件的区域。从"我一条街都不认识"到得出候选名单,只需几分钟。',
|
||||
faqFinding3Q: '如何找到同时满足我所有要求的区域?',
|
||||
faqFinding3A: '叠加多个筛选条件(犯罪率低于平均水平、好学校、通勤时间少于 40 分钟),然后按价格为地图着色,找出性价比最高的区域。拖动滑块时地图会实时更新,让您即时看到变化。',
|
||||
// FAQ items — Commute and Travel
|
||||
faqCommute1Q: '我能看到从不同区域到公司的实际通勤时间吗?',
|
||||
faqCommute1A: '设置您的工作地点作为目的地,我们会按通勤时间为每个邮编着色——无论是开车、骑车还是公共交通。筛选出您的最大通勤时间,其余区域就会消失。',
|
||||
faqCommute2Q: '这比查 Google Maps 好在哪里?',
|
||||
faqCommute2A: 'Google Maps 一次只能查看一条路线。我们一次性将英格兰每个邮编按通勤时间着色,让您可以同时比较数百个区域,而不是逐个搜索。',
|
||||
// FAQ items — Budget and Value
|
||||
faqBudget1Q: '如何找到单位面积性价比最高的区域?',
|
||||
faqBudget1A: '按每平方米价格筛选,您会立即看到哪些邮编的单位面积价格最低。搭配能源评级筛选,避免取暖费用过高的房产。',
|
||||
faqBudget2Q: '怎么确定一个便宜的区域不是因为有问题才便宜?',
|
||||
faqBudget2A: '将贫困指数、犯罪统计、学校评级和宽带速度叠加在价格旁边查看。如果一个邮编价格实惠且在各项重要指标上表现良好,那您就找到了真正的高性价比——而不是隐藏着您还没发现的问题的低价。',
|
||||
// FAQ items — Safety and Neighbourhood
|
||||
faqSafety1Q: '搬家前如何查看一个区域是否安全?',
|
||||
faqSafety1A: '我们将真实的警方犯罪记录数据按类型细分,叠加到英格兰每个社区上。按暴力犯罪、入室盗窃或反社会行为筛选,立即看到哪些邮编的犯罪数据最低。',
|
||||
faqSafety2Q: '我总是找到网上看起来很好的房子,到了才发现周边环境很差。',
|
||||
faqSafety2A: '这正是这个工具存在的意义。在一张地图上叠加犯罪率、噪音水平、贫困指数、附近的酒吧和公园以及宽带速度,这样您在预约看房之前就能了解一个社区的真实面貌。',
|
||||
// FAQ items — Families and Schools
|
||||
faqFamilies1Q: '我能在一次搜索中找到学校好又犯罪率低的区域吗?',
|
||||
faqFamilies1A: '可以。叠加 Ofsted 评级、犯罪率、公园等对您家庭重要的筛选条件,地图只会高亮显示符合所有条件的区域。不用再在五个不同网站之间交叉比对了。',
|
||||
faqFamilies2Q: '如何知道一个社区附近是否有公园和游乐场?',
|
||||
faqFamilies2A: '打开公园和绿地 POI 图层,直接在地图上查看。您还可以按每个邮编步行范围内的公园数量进行筛选。',
|
||||
// FAQ items — Environment and Quality of Life
|
||||
faqEnv1Q: '能找到不在嘈杂马路旁的节能住宅吗?',
|
||||
faqEnv1A: '按 EPC 评级(A 至 C)筛选,然后叠加道路噪音数据,排除超过您阈值的区域。按任一指标为地图着色,一目了然地找到安静且节能的街道。',
|
||||
faqEnv2Q: '有洪水或地基沉降风险数据吗?',
|
||||
faqEnv2A: '我们包含地基稳定性数据,让您在购房前检查沉降、膨胀收缩黏土和其他地质风险。尽早排除高风险区域。',
|
||||
faqEnv3Q: '能找到宽带速度快又安静的区域吗?',
|
||||
faqEnv3A: '将宽带速度筛选与道路噪音数据叠加,找到连接速度快且交通噪音低的街道。按任一指标着色,一目了然地比较各区域。',
|
||||
// FAQ items — Why Perfect Postcode
|
||||
faqWhy1Q: '我已经在用 Rightmove 了,这个工具有什么额外价值?',
|
||||
faqWhy1A: 'Rightmove 展示房源,我们展示区域。犯罪率、学校评级、宽带速度、噪音水平、贫困指数等等——全部可在一张地图上筛选。您可以在查看房源之前先了解一个社区。',
|
||||
faqWhy2Q: '我不能自己免费查到这些信息吗?',
|
||||
faqWhy2A: '您当然可以逐个邮编地交叉比对警方数据、Ofsted 报告、EPC 登记、Land Registry 记录和 ONS 统计数据。或者,您可以在几秒钟内在一张地图上筛选和查看所有信息。',
|
||||
faqWhy3Q: '数据到底来自哪里?',
|
||||
faqWhy3A: '每个数据集都来自英国官方政府来源:Land Registry、EPC 登记、ONS、Ofsted、Ofcom、data.police.uk 和 Defra。我们不抓取房产中介数据,也不编造任何信息。您可以对照原始来源验证任何记录。',
|
||||
// FAQ items — Pricing and Access
|
||||
faqPricing1Q: '花钱买一个找房工具真的值得吗?',
|
||||
faqPricing1A: '买房可能是您一生中最大的一笔支出。在做决定之前发现一个问题(嘈杂的马路、差劲的宽带、上升的犯罪率)就可能让您避免多年的后悔。而这个工具的费用还不到一箱油钱。',
|
||||
faqPricing2Q: '这是订阅制吗?',
|
||||
faqPricing2A: '不是。一次性付款,永久使用。在找房期间密集使用,对新区域好奇时随时回来看,将来再搬家时它依然在。',
|
||||
faqPricing3Q: '免费版能用哪些功能?',
|
||||
faqPricing3A: '免费用户可以在演示区域(伦敦市中心,大约 1 至 2 区)内探索所有功能。要访问英格兰其他地区的数据,需要获取终身访问权限。',
|
||||
faqPricing4Q: '可以退款吗?',
|
||||
faqPricing4A: '当然可以。我们提供 30 天退款保证。如果您不满意,请在 30 天内发送邮件至 support@perfect-postcode.co.uk 申请全额退款。',
|
||||
// FAQ items — Tips and Tricks
|
||||
faqTips1Q: '如何使用 AI 筛选功能,而不是逐个添加筛选条件?',
|
||||
faqTips1A: '用自然语言描述您的需求,例如"安静的区域、好学校附近、宽带速度快、40 万英镑以下",系统会一次性设置所有相关筛选条件。之后您可以手动微调。',
|
||||
faqTips2Q: '我能保存搜索条件以后再用吗?',
|
||||
faqTips2A: '点击保存按钮,所有内容都会被记录:您的筛选条件、缩放级别以及当前着色的数据图层。下次从上次离开的地方继续,或将链接分享给您的伴侣。',
|
||||
faqTips3Q: '我能导出正在查看的数据吗?',
|
||||
faqTips3A: '使用导出按钮将当前筛选后的房产下载为电子表格。导出结果会遵循您所有的活动筛选条件,确保您获得的正是所需的数据。',
|
||||
},
|
||||
|
||||
// ── Account Page ───────────────────────────────────
|
||||
accountPage: {
|
||||
emailLabel: '邮箱',
|
||||
subscriptionLabel: '订阅',
|
||||
upgrade: '升级',
|
||||
redirecting: '跳转中…',
|
||||
receiveNewsletter: '接收新闻邮件',
|
||||
needHelp: '需要帮助?请发邮件至',
|
||||
responseTime: '我们通常在 24 小时内回复。',
|
||||
},
|
||||
|
||||
// ── Saved Page ─────────────────────────────────────
|
||||
savedPage: {
|
||||
searches: '搜索',
|
||||
noSavedSearches: '暂无保存的搜索',
|
||||
noSavedSearchesDesc: '保存您的筛选条件和地图视图,随时从上次的位置继续浏览。',
|
||||
noSavedProperties: '暂无保存的房产',
|
||||
noSavedPropertiesDesc: '在浏览过程中收藏房产,建立您的候选名单,不会遗漏任何一处。',
|
||||
openPostcode: '打开邮编',
|
||||
viewListing: '查看房源',
|
||||
clickToRename: '点击重命名',
|
||||
notesPlaceholder: '记下您的想法...',
|
||||
deleteSearch: '删除搜索',
|
||||
deleteSearchConfirm: '确定要删除这个保存的搜索吗?此操作无法撤销。',
|
||||
deleteProperty: '删除房产',
|
||||
deletePropertyConfirm: '确定要删除这个保存的房产吗?此操作无法撤销。',
|
||||
bed: '卧室',
|
||||
epc: '能源评级',
|
||||
},
|
||||
|
||||
// ── Invites Page ───────────────────────────────────
|
||||
invitesPage: {
|
||||
inviteLinksLicensed: '邀请链接仅对已授权用户开放。',
|
||||
inviteAdminLabel: '邀请好友(100% 折扣)',
|
||||
inviteReferralLabel: '邀请好友(7折优惠)',
|
||||
generateFreeInvite: '生成免费邀请链接',
|
||||
generateReferralLink: '生成推荐链接',
|
||||
copyInviteLink: '复制邀请链接',
|
||||
adminInvitesTitle: '管理员邀请(100% 折扣)',
|
||||
referralInvitesTitle: '推荐邀请(7折优惠)',
|
||||
yourInviteLinks: '您的邀请链接',
|
||||
noInvitesYet: '暂无已生成的邀请',
|
||||
link: '链接',
|
||||
status: '状态',
|
||||
created: '创建时间',
|
||||
redeemed: '已兑换',
|
||||
pending: '待兑换',
|
||||
},
|
||||
|
||||
// ── Invite Page ────────────────────────────────────
|
||||
invitePage: {
|
||||
youreInvited: '您收到了邀请!',
|
||||
specialOffer: '特别优惠!',
|
||||
invitedByFree: '{{name}} 邀请您获取免费终身访问权限。',
|
||||
invitedByDiscount: '{{name}} 与您分享了终身访问的7折优惠。',
|
||||
genericFreeInvite: '您已被邀请获取免费终身访问权限。',
|
||||
genericDiscount: '一位朋友与您分享了终身访问的7折优惠。',
|
||||
exploreEvery: '探索英格兰的每一个社区',
|
||||
propertyInfo: '房价、能源评级、犯罪数据、学校评级等',
|
||||
invalidInvite: '无效的邀请',
|
||||
inviteAlreadyUsed: '邀请已被使用',
|
||||
inviteAlreadyUsedDesc: '此邀请链接已被兑换。',
|
||||
invalidInviteLink: '无效的邀请链接',
|
||||
invalidInviteLinkDesc: '此邀请链接无效或已过期。',
|
||||
licenseActivated: '授权已激活!',
|
||||
fullAccessGranted: '您现在拥有 Perfect Postcode 的完整访问权限。',
|
||||
activating: '激活中...',
|
||||
activateLicense: '激活授权',
|
||||
claimDiscount: '领取优惠',
|
||||
registerToClaim: '注册以领取',
|
||||
youAlreadyHaveLicense: '您已拥有授权',
|
||||
accountHasFullAccess: '您的账户已拥有完整访问权限。',
|
||||
failedToValidate: '验证邀请链接失败',
|
||||
},
|
||||
|
||||
// ── Map Page ───────────────────────────────────────
|
||||
mapPage: {
|
||||
unsavedProperty: '取消收藏',
|
||||
savedProperty: '已收藏',
|
||||
},
|
||||
|
||||
// ── Format / Time ──────────────────────────────────
|
||||
format: {
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{{count}}分钟前',
|
||||
hoursAgo: '{{count}}小时前',
|
||||
daysAgo: '{{count}}天前',
|
||||
nFilters: '{{count}} 个筛选',
|
||||
noFilters: '无筛选',
|
||||
poiCategory: '{{count}} 个 POI 类别',
|
||||
poiCategories: '{{count}} 个 POI 类别',
|
||||
travelDestination: '{{count}} 个出行目的地',
|
||||
travelDestinations: '{{count}} 个出行目的地',
|
||||
propertiesMatch: '{{count}} 套房产符合',
|
||||
setFilters: '设置 {{count}} 个筛选:{{list}}',
|
||||
noFiltersSet: '未设置筛选',
|
||||
toDestination: '{{mode}}到 {{label}} {{bounds}}',
|
||||
lessThanMin: '< {{max}} 分钟',
|
||||
moreThanMin: '> {{min}} 分钟',
|
||||
},
|
||||
|
||||
// ── Tutorial ──────────────────────────────────────
|
||||
tutorial: {
|
||||
step1Title: '告诉地图什么重要',
|
||||
step1Content: '设置预算、通勤上限、学校质量、犯罪门槛。您关心的一切。只有符合条件的区域会保持高亮。使用眼睛图标按任意特征着色。',
|
||||
step2Title: '或者直接描述',
|
||||
step2Content: '用中文输入您的需求,例如“安静的地区,靠近好学校,£400k 以下”,我们会为您设置筛选。',
|
||||
step3Title: '探索现有住宅',
|
||||
step3Content: '在英格兰各地平移和缩放。点击任何彩色区域查看犯罪、学校、价格、宽带、噪音等信息。',
|
||||
step4Title: '跳转到某个位置',
|
||||
step4Content: '搜索任何地点或邮编,即可直接跳转。',
|
||||
step5Title: '深入了解细节',
|
||||
step5Content: '查看区域统计、直方图和单个房产记录:价格、建筑面积、能效评级等。',
|
||||
step6Title: '附近有什么?',
|
||||
step6Content: '在地图上开启学校、商店、车站、公园和餐厅图层,查看周边设施。',
|
||||
},
|
||||
|
||||
// ── Server-derived values ──────────────────────────
|
||||
// Keyed by the English server value. ts() looks up translations at display time.
|
||||
// The English keys MUST match exactly what the API returns.
|
||||
server: {
|
||||
// ─ Feature group names ─
|
||||
'Properties': '房产',
|
||||
'Transport': '交通',
|
||||
'Education': '教育',
|
||||
'Deprivation': '贫困指数',
|
||||
'Crime': '犯罪',
|
||||
'Demographics': '人口统计',
|
||||
'Amenities': '配套设施',
|
||||
|
||||
// ─ Feature names (Properties) ─
|
||||
'Listing status': '房源状态',
|
||||
'Property type': '房产类型',
|
||||
'Leasehold/Freehold': '租赁产权/永久产权',
|
||||
'Last known price': '上次成交价',
|
||||
'Estimated current price': '估计当前价格',
|
||||
'Asking price': '挂牌价',
|
||||
'Price per sqm': '每平方米价格',
|
||||
'Est. price per sqm': '估计每平方米价格',
|
||||
'Asking price per sqm': '挂牌价每平方米',
|
||||
'Estimated monthly rent': '估计月租',
|
||||
'Asking rent (monthly)': '月租',
|
||||
'Total floor area (sqm)': '总建筑面积(平方米)',
|
||||
'Number of bedrooms & living rooms': '卧室和客厅数量',
|
||||
'Bedrooms': '卧室',
|
||||
'Bathrooms': '浴室',
|
||||
'Construction year': '建造年份',
|
||||
'Date of last transaction': '上次交易日期',
|
||||
'Listing date': '上市日期',
|
||||
'Former council house': '原公共住房',
|
||||
'Current energy rating': '当前能源评级',
|
||||
'Potential energy rating': '潜在能源评级',
|
||||
'Interior height (m)': '室内层高(米)',
|
||||
|
||||
// ─ Feature names (Transport) ─
|
||||
'Distance to nearest train or tube station (km)': '到最近火车或地铁站的距离(公里)',
|
||||
|
||||
// ─ Feature names (Education) ─
|
||||
'Good+ primary schools within 2km': '2公里内良好+小学数量',
|
||||
'Good+ secondary schools within 2km': '2公里内良好+中学数量',
|
||||
'Good+ primary schools within 5km': '5公里内良好+小学数量',
|
||||
'Good+ secondary schools within 5km': '5公里内良好+中学数量',
|
||||
'Education, Skills and Training Score': '教育、技能和培训得分',
|
||||
|
||||
// ─ Feature names (Deprivation) ─
|
||||
'Income Score (rate)': '收入得分(比率)',
|
||||
'Employment Score (rate)': '就业得分(比率)',
|
||||
'Health Deprivation and Disability Score': '健康与残障得分',
|
||||
'Living Environment Score': '居住环境得分',
|
||||
'Indoors Sub-domain Score': '室内子领域得分',
|
||||
'Outdoors Sub-domain Score': '室外子领域得分',
|
||||
|
||||
// ─ Feature names (Crime) ─
|
||||
'Serious crime per 1k residents (avg/yr)': '每千人严重犯罪(年均)',
|
||||
'Minor crime per 1k residents (avg/yr)': '每千人轻微犯罪(年均)',
|
||||
'Serious crime (avg/yr)': '严重犯罪(年均)',
|
||||
'Minor crime (avg/yr)': '轻微犯罪(年均)',
|
||||
'Violence and sexual offences (avg/yr)': '暴力和性犯罪(年均)',
|
||||
'Burglary (avg/yr)': '入室盗窃(年均)',
|
||||
'Robbery (avg/yr)': '抢劫(年均)',
|
||||
'Vehicle crime (avg/yr)': '车辆犯罪(年均)',
|
||||
'Anti-social behaviour (avg/yr)': '反社会行为(年均)',
|
||||
'Criminal damage and arson (avg/yr)': '刑事毁坏和纵火(年均)',
|
||||
'Other theft (avg/yr)': '其他盗窃(年均)',
|
||||
'Theft from the person (avg/yr)': '人身盗窃(年均)',
|
||||
'Shoplifting (avg/yr)': '商店盗窃(年均)',
|
||||
'Bicycle theft (avg/yr)': '自行车盗窃(年均)',
|
||||
'Drugs (avg/yr)': '毒品犯罪(年均)',
|
||||
'Possession of weapons (avg/yr)': '非法持有武器(年均)',
|
||||
'Public order (avg/yr)': '扰乱公共秩序(年均)',
|
||||
'Other crime (avg/yr)': '其他犯罪(年均)',
|
||||
|
||||
// ─ Feature names (Demographics) ─
|
||||
'Median age': '中位年龄',
|
||||
'% White': '% 白人',
|
||||
'% South Asian': '% 南亚裔',
|
||||
'% Black': '% 黑人',
|
||||
'% East Asian': '% 东亚裔',
|
||||
'% Mixed': '% 混血',
|
||||
'% Other': '% 其他',
|
||||
|
||||
// ─ Feature names (Amenities) ─
|
||||
'Distance to nearest park (km)': '到最近公园的距离(公里)',
|
||||
'Number of parks within 2km': '2公里内公园数量',
|
||||
'Number of restaurants within 2km': '2公里内餐厅数量',
|
||||
'Number of grocery shops and supermarkets within 2km': '2公里内食品店和超市数量',
|
||||
'Noise (dB)': '噪音(分贝)',
|
||||
'Max available download speed (Mbps)': '最大可用下载速度(Mbps)',
|
||||
|
||||
|
||||
// ─ Enum values ─
|
||||
'Historical sale': '历史交易',
|
||||
'For sale': '在售',
|
||||
'For rent': '出租',
|
||||
'Detached': '独立式住宅',
|
||||
'Semi-Detached': '半独立式住宅',
|
||||
'Terraced': '联排住宅',
|
||||
'Flats/Maisonettes': '公寓/复式公寓',
|
||||
'Other': '其他',
|
||||
'Freehold': '永久产权',
|
||||
'Leasehold': '租赁产权',
|
||||
'Yes': '是',
|
||||
'No': '否',
|
||||
|
||||
// ─ Stacked chart labels ─
|
||||
'Serious crime': '严重犯罪',
|
||||
'Minor crime': '轻微犯罪',
|
||||
'Ethnic composition': '族裔组成',
|
||||
|
||||
// ─ POI group names ─
|
||||
'Public Transport': '公共交通',
|
||||
'Leisure': '休闲',
|
||||
'Health': '健康',
|
||||
'Emergency Services': '紧急服务',
|
||||
'Groceries': '食品杂货',
|
||||
'Local Businesses': '本地商业',
|
||||
'Culture': '文化',
|
||||
'Services': '服务',
|
||||
'Shops': '商店',
|
||||
|
||||
// ─ POI categories ─
|
||||
'Airport': '机场',
|
||||
'Ferry': '渡轮',
|
||||
'Rail station': '火车站',
|
||||
'Bus stop': '公交站',
|
||||
'Bus station': '公交枢纽',
|
||||
'Taxi rank': '出租车站',
|
||||
'Metro or Tram stop': '地铁或有轨电车站',
|
||||
'Café': '咖啡馆',
|
||||
'Restaurant': '餐厅',
|
||||
'Pub': '酒吧',
|
||||
'Bar': '酒吧',
|
||||
'Fast Food': '快餐',
|
||||
'Nightclub': '夜店',
|
||||
'Cinema': '电影院',
|
||||
'Theatre': '剧院',
|
||||
'Live Music & Events': '现场音乐与活动',
|
||||
'Park': '公园',
|
||||
'Playground': '游乐场',
|
||||
'Sports Centre': '体育中心',
|
||||
'Entertainment': '娱乐',
|
||||
'Supermarket': '超市',
|
||||
'Convenience Store': '便利店',
|
||||
'Bakery': '面包戺',
|
||||
'Butcher & Fishmonger': '肉铺与鱼铺',
|
||||
'Greengrocer': '果蔬店',
|
||||
'Off-Licence': '酒类商店',
|
||||
'Deli & Specialty': '熟食与特产店',
|
||||
'Fashion & Clothing': '时装服饰',
|
||||
'Electronics': '电子产品',
|
||||
'Charity Shop': '慈善商店',
|
||||
'DIY & Hardware': '建材五金',
|
||||
'Home & Garden': '家居与园艺',
|
||||
'Bookshop': '书店',
|
||||
'Pet Shop': '宠物店',
|
||||
'Sports & Outdoor': '体育与户外',
|
||||
'Newsagent': '报刊亭',
|
||||
'Department Store': '百货商店',
|
||||
'Gift & Hobby': '礼品与爱好',
|
||||
'Specialist Shop': '专业商店',
|
||||
'Hairdresser & Beauty': '美发与美容',
|
||||
'Gym & Fitness': '健身房',
|
||||
'Dry Cleaner & Laundry': '干洗与洗衣',
|
||||
'Car Services': '汽车服务',
|
||||
'Post Office': '邮局',
|
||||
'Vet & Pet Care': '宠物医院与护理',
|
||||
'Bank': '银行',
|
||||
'Travel Agent': '旅行社',
|
||||
'Police': '警察',
|
||||
'Fire Station': '消防站',
|
||||
'Ambulance Station': '急救站',
|
||||
'GP Surgery': '全科诊所',
|
||||
'Dentist': '牙科',
|
||||
'Pharmacy': '药房',
|
||||
'Hospital & Clinic': '医院与诊所',
|
||||
'Optician': '眼镜店',
|
||||
'Physiotherapy': '理疗',
|
||||
'Counselling & Therapy': '心理咨询与治疗',
|
||||
'Care Home': '养老院',
|
||||
'Medical & Mobility': '医疗器械与辅助设备',
|
||||
'Museum': '博物馆',
|
||||
'Gallery': '美术馆',
|
||||
'Library': '图书馆',
|
||||
'Place of Worship': '宗教场所',
|
||||
'Arts Centre': '艺术中心',
|
||||
'Zoo': '动物园',
|
||||
'Tourist Attraction': '旅游景点',
|
||||
'School': '学校',
|
||||
'Hotel': '酒店',
|
||||
'Local Business': '本地商业',
|
||||
'Offices': '写字楼',
|
||||
'EV Charging': '电动车充电站',
|
||||
'Fuel Station': '加油站',
|
||||
'Community Centre': '社区中心',
|
||||
|
||||
// ─ Suffixes (used in formatters) ─
|
||||
'/mo': '/月',
|
||||
'/yr': '/年',
|
||||
' sqm': ' 平方米',
|
||||
' km': ' 公里',
|
||||
' m': ' 米',
|
||||
' dB': ' 分贝',
|
||||
' years': ' 年',
|
||||
' rooms': ' 间',
|
||||
},
|
||||
};
|
||||
|
||||
export default zh;
|
||||
15
frontend/src/i18n/server.ts
Normal file
15
frontend/src/i18n/server.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import i18n from 'i18next';
|
||||
|
||||
/**
|
||||
* Translate a server-derived value (feature name, enum value, group name, etc.).
|
||||
* Looks up `server.${value}` in the current locale. Falls back to the original
|
||||
* English string if no translation exists, so unknown values are safe.
|
||||
*/
|
||||
export function ts(value: string): string {
|
||||
const key = `server.${value}`;
|
||||
const result = i18n.t(key, { defaultValue: value });
|
||||
return typeof result === 'string' ? result : value;
|
||||
}
|
||||
|
||||
// Re-export tsDesc from descriptions.ts for convenience
|
||||
export { tsDesc } from './descriptions';
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { createRoot, hydrateRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './i18n';
|
||||
import './index.css';
|
||||
import './hooks/usePlausible';
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const MAP_MIN_ZOOM = 5.5;
|
|||
|
||||
export const BUFFER_MULTIPLIER = 1.5;
|
||||
|
||||
/** Inner London free zone bounds (south, west, north, east) — must match server FREE_ZONE_BOUNDS */
|
||||
/** Demo free zone bounds (south, west, north, east) — must match server FREE_ZONE_BOUNDS */
|
||||
export const FREE_ZONE_BOUNDS = { south: 51.44, west: -0.31, north: 51.59, east: 0.05 };
|
||||
|
||||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
|
|
|
|||
|
|
@ -141,15 +141,6 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
|
|||
),
|
||||
|
||||
// ── Transport ────────────────────────────────
|
||||
'Train or tube stations within 1km': (
|
||||
<>
|
||||
<rect x="4" y="3" width="16" height="14" rx="2" />
|
||||
<path d="M4 11h16" />
|
||||
<circle cx="8" cy="15" r="1" fill="currentColor" />
|
||||
<circle cx="16" cy="15" r="1" fill="currentColor" />
|
||||
<path d="M8 21l-2-4h12l-2 4" />
|
||||
</>
|
||||
),
|
||||
'Distance to nearest train or tube station (km)': (
|
||||
<>
|
||||
<path d="M12 2v8" />
|
||||
|
|
|
|||
|
|
@ -44,8 +44,12 @@ export function parseInputValue(
|
|||
}
|
||||
|
||||
export function formatDuration(d: string): string {
|
||||
if (d === 'F') return 'Freehold';
|
||||
if (d === 'L') return 'Leasehold';
|
||||
if (d === 'F' || d === 'L') {
|
||||
// These are server enum values — translate via ts()
|
||||
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
|
||||
if (d === 'F') return ts('Freehold');
|
||||
return ts('Leasehold');
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
|
|
@ -82,17 +86,18 @@ export function formatNumber(value: number | undefined, decimals = 0): string {
|
|||
}
|
||||
|
||||
export function formatRelativeTime(isoDate: string): string {
|
||||
const i18n = require('../i18n').default as { t: (key: string, opts?: Record<string, unknown>) => string };
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) return 'just now';
|
||||
if (diffSec < 60) return i18n.t('format.justNow');
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffMin < 60) return i18n.t('format.minutesAgo', { count: diffMin });
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffHr < 24) return i18n.t('format.hoursAgo', { count: diffHr });
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
if (diffDay < 30) return `${diffDay}d ago`;
|
||||
if (diffDay < 30) return i18n.t('format.daysAgo', { count: diffDay });
|
||||
return new Date(isoDate).toLocaleDateString();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@ export function stateToParams(
|
|||
}
|
||||
|
||||
export function summarizeParams(queryString: string): string {
|
||||
const i18n = require('../i18n').default as { t: (key: string, opts?: Record<string, unknown>) => string };
|
||||
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
|
||||
const params = new URLSearchParams(queryString);
|
||||
const parts: string[] = [];
|
||||
|
||||
|
|
@ -173,7 +175,9 @@ export function summarizeParams(queryString: string): string {
|
|||
.filter((n) => n && n !== 'Listing status');
|
||||
if (filterNames.length > 0) {
|
||||
parts.push(
|
||||
filterNames.length <= 2 ? filterNames.join(', ') : `${filterNames.length} filters`
|
||||
filterNames.length <= 2
|
||||
? filterNames.map((n) => ts(n)).join(', ')
|
||||
: i18n.t('format.nFilters', { count: filterNames.length })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -182,7 +186,11 @@ export function summarizeParams(queryString: string): string {
|
|||
if (poiParams.length > 0) {
|
||||
const count = poiParams.filter(Boolean).length;
|
||||
if (count > 0) {
|
||||
parts.push(`${count} POI ${count === 1 ? 'category' : 'categories'}`);
|
||||
parts.push(
|
||||
count === 1
|
||||
? i18n.t('format.poiCategory', { count })
|
||||
: i18n.t('format.poiCategories', { count })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,9 +198,13 @@ export function summarizeParams(queryString: string): string {
|
|||
if (ttParams.length > 0) {
|
||||
const count = ttParams.filter(Boolean).length;
|
||||
if (count > 0) {
|
||||
parts.push(`${count} travel time ${count === 1 ? 'destination' : 'destinations'}`);
|
||||
parts.push(
|
||||
count === 1
|
||||
? i18n.t('format.travelDestination', { count })
|
||||
: i18n.t('format.travelDestinations', { count })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' + ') : 'No filters';
|
||||
return parts.length > 0 ? parts.join(' + ') : i18n.t('format.noFilters');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ STOP_TYPES = {
|
|||
"BCE": "Bus station",
|
||||
"TXR": "Taxi rank",
|
||||
"TMU": "Metro or Tram stop",
|
||||
"MET": "Metro or Tram stop",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -44,9 +45,30 @@ def download_naptan(output: Path) -> None:
|
|||
pl.col("StopType").replace(STOP_TYPES).alias("category"),
|
||||
pl.col("Latitude").alias("lat"),
|
||||
pl.col("Longitude").alias("lng"),
|
||||
pl.col("NptgLocalityCode").alias("locality"),
|
||||
)
|
||||
)
|
||||
|
||||
before = len(df)
|
||||
|
||||
# Deduplicate: one record per name+category+locality
|
||||
# (merges entrances, bus stop pairs on opposite sides of the road, etc.)
|
||||
has_loc = df.filter(
|
||||
pl.col("locality").is_not_null() & (pl.col("locality") != "")
|
||||
)
|
||||
no_loc = df.filter(
|
||||
pl.col("locality").is_null() | (pl.col("locality") == "")
|
||||
)
|
||||
cols = ["id", "name", "category", "lat", "lng"]
|
||||
deduped = has_loc.group_by("name", "category", "locality").agg(
|
||||
pl.col("id").first(),
|
||||
pl.col("lat").mean(),
|
||||
pl.col("lng").mean(),
|
||||
)
|
||||
df = pl.concat([deduped.select(cols), no_loc.select(cols)])
|
||||
|
||||
print(f"Deduplicated {before:,} → {len(df):,} stops (by name+category+locality)")
|
||||
|
||||
df.write_parquet(output)
|
||||
size_mb = output.stat().st_size / (1024 * 1024)
|
||||
print(f"Wrote {output} ({size_mb:.1f} MB, {len(df):,} stations)")
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ _AREA_COLUMNS = [
|
|||
"Number of restaurants within 2km",
|
||||
"Number of grocery shops and supermarkets within 2km",
|
||||
"Number of parks within 2km",
|
||||
"Train or tube stations within 1km",
|
||||
"Distance to nearest train or tube station (km)",
|
||||
"Distance to nearest park (km)",
|
||||
# Environment
|
||||
|
|
@ -325,7 +324,6 @@ def _build(
|
|||
"restaurants_2km": "Number of restaurants within 2km",
|
||||
"groceries_2km": "Number of grocery shops and supermarkets within 2km",
|
||||
"parks_2km": "Number of parks within 2km",
|
||||
"train_tube_1km": "Train or tube stations within 1km",
|
||||
"train_tube_nearest_km": "Distance to nearest train or tube station (km)",
|
||||
"parks_nearest_km": "Distance to nearest park (km)",
|
||||
"latest_price": "Last known price",
|
||||
|
|
|
|||
|
|
@ -15,11 +15,6 @@ POI_GROUPS_2KM = {
|
|||
"groceries": ["Greengrocer", "Supermarket", "Convenience Store"],
|
||||
}
|
||||
|
||||
# Train/tube stations counted at 1km radius
|
||||
TRAIN_TUBE_GROUP = {
|
||||
"train_tube": ["Metro or Tram stop", "Rail station"],
|
||||
}
|
||||
|
||||
# Groups for which to compute distance to nearest POI (from filtered POIs)
|
||||
DISTANCE_GROUPS = {
|
||||
"train_tube": ["Metro or Tram stop", "Rail station"],
|
||||
|
|
@ -67,11 +62,6 @@ def main():
|
|||
postcodes, pois, groups=POI_GROUPS_2KM, radius_km=2
|
||||
)
|
||||
|
||||
# Count train/tube stations within 1km
|
||||
counts_1km = count_pois_per_postcode(
|
||||
postcodes, pois, groups=TRAIN_TUBE_GROUP, radius_km=1
|
||||
)
|
||||
|
||||
# Distance to nearest train/tube station (from filtered POIs)
|
||||
distances = min_distance_per_postcode(postcodes, pois, groups=DISTANCE_GROUPS)
|
||||
|
||||
|
|
@ -86,8 +76,7 @@ def main():
|
|||
|
||||
# Join all results on postcode
|
||||
result = (
|
||||
counts_2km.join(counts_1km, on="postcode")
|
||||
.join(distances, on="postcode")
|
||||
counts_2km.join(distances, on="postcode")
|
||||
.join(park_counts_2km, on="postcode")
|
||||
.join(park_distances, on="postcode")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ set -euo pipefail
|
|||
# --demo only compute Bank + TCR, transit only (quick test)
|
||||
|
||||
# --- Defaults ---
|
||||
THREADS=12
|
||||
HEAP=24g
|
||||
THREADS=8
|
||||
HEAP=40g
|
||||
NETWORK_DIR=property-data/r5-network
|
||||
OUTPUT_BASE=property-data/travel-times
|
||||
R5_DIR=r5-java
|
||||
|
|
@ -131,7 +131,7 @@ fi
|
|||
echo ""
|
||||
echo "--- Starting batch computation ---"
|
||||
DATA_DIR="$NETWORK_DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \
|
||||
java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
|
||||
java -Xms"$HEAP" -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \
|
||||
--postcodes property-data/arcgis_data.parquet \
|
||||
--places property-data/places.parquet \
|
||||
--output-dir "$OUTPUT_BASE" \
|
||||
|
|
|
|||
|
|
@ -34,8 +34,11 @@ public class Router {
|
|||
private static final int DEPARTURE_TO_TIME = 8 * 3600 + 30 * 60; // 08:30
|
||||
private static final int MAX_TRIP_DURATION_MINUTES = 90;
|
||||
|
||||
/** R5 PathResult throws if destinations > 5000. Chunks must be smaller when recording paths. */
|
||||
private static final int PATH_MAX_DESTINATIONS = 5000;
|
||||
/**
|
||||
* R5 PathResult throws if destinations > 5000. Chunks must be smaller when recording paths.
|
||||
* Kept well below R5's limit to reduce per-chunk memory (fewer destinations = smaller PathResult).
|
||||
*/
|
||||
private static final int PATH_MAX_DESTINATIONS = 2000;
|
||||
|
||||
// Percentile indices in R5 result arrays (order must match task.percentiles in buildTask)
|
||||
private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only)
|
||||
|
|
@ -383,7 +386,9 @@ public class Router {
|
|||
|
||||
if (recordPaths) {
|
||||
task.includePathResults = true;
|
||||
task.nPathsPerTarget = 3;
|
||||
// We only use the most common RouteSequence (see extractPaths), so 1 path
|
||||
// per target is sufficient and cuts path memory by ~67% vs 3.
|
||||
task.nPathsPerTarget = 1;
|
||||
}
|
||||
|
||||
configureMode(task, mode);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,6 @@ pub const AI_FILTERS_WEEKLY_TOKEN_LIMIT: u64 = 10_000_000;
|
|||
/// Timeout for outbound HTTP service calls (seconds).
|
||||
pub const SERVICE_CALL_TIMEOUT: u64 = 120;
|
||||
|
||||
/// Inner London free zone bounds (south, west, north, east) — roughly zone 1.
|
||||
/// Demo free zone bounds (south, west, north, east) — inner London, roughly zone 1.
|
||||
/// Users without a license can only query data within these bounds.
|
||||
pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.44, -0.31, 51.59, 0.05);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ pub mod travel_time;
|
|||
|
||||
pub use places::PlaceData;
|
||||
pub use poi::{POICategoryGroup, POIData};
|
||||
pub use postcodes::PostcodeData;
|
||||
pub use postcodes::{OutcodeData, PostcodeData};
|
||||
pub use property::{
|
||||
precompute_h3, FeatureStats, Histogram, PropertyData, QuantRef, RenovationEvent,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,94 @@ use std::fs;
|
|||
use std::path::Path;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use super::PlaceData;
|
||||
|
||||
/// Precomputed outcode data derived from postcode boundaries.
|
||||
/// An outcode is the first part of a UK postcode (e.g. "E14" from "E14 2DG").
|
||||
pub struct OutcodeData {
|
||||
pub names: Vec<String>,
|
||||
pub name_lower: Vec<String>,
|
||||
pub centroids: Vec<(f32, f32)>,
|
||||
pub cities: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
impl OutcodeData {
|
||||
/// Derive outcode data by grouping postcodes by their outcode prefix and averaging centroids.
|
||||
pub fn from_postcode_and_place_data(
|
||||
postcode_data: &PostcodeData,
|
||||
place_data: &PlaceData,
|
||||
) -> Self {
|
||||
// Group postcode centroids by outcode
|
||||
let mut outcode_centroids: FxHashMap<String, Vec<(f32, f32)>> = FxHashMap::default();
|
||||
for (idx, postcode) in postcode_data.postcodes.iter().enumerate() {
|
||||
if let Some(space_idx) = postcode.find(' ') {
|
||||
let outcode = &postcode[..space_idx];
|
||||
outcode_centroids
|
||||
.entry(outcode.to_string())
|
||||
.or_default()
|
||||
.push(postcode_data.centroids[idx]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build sorted vecs
|
||||
let mut entries: Vec<(String, (f32, f32))> = outcode_centroids
|
||||
.into_iter()
|
||||
.map(|(outcode, pts)| {
|
||||
let count = pts.len() as f32;
|
||||
let avg_lat = pts.iter().map(|(lat, _)| lat).sum::<f32>() / count;
|
||||
let avg_lon = pts.iter().map(|(_, lon)| lon).sum::<f32>() / count;
|
||||
(outcode, (avg_lat, avg_lon))
|
||||
})
|
||||
.collect();
|
||||
entries.sort_unstable_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let names: Vec<String> = entries.iter().map(|(n, _)| n.clone()).collect();
|
||||
let name_lower: Vec<String> = names.iter().map(|n| n.to_lowercase()).collect();
|
||||
let centroids: Vec<(f32, f32)> = entries.iter().map(|(_, c)| *c).collect();
|
||||
|
||||
// Compute nearest city for each outcode (same algorithm as PlaceData)
|
||||
let city_indices: Vec<usize> = place_data
|
||||
.type_rank
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, &rank)| if rank == 0 { Some(idx) } else { None })
|
||||
.collect();
|
||||
|
||||
let cities: Vec<Option<String>> = centroids
|
||||
.iter()
|
||||
.map(|&(lat, lon)| {
|
||||
let cos_lat = lat.to_radians().cos();
|
||||
let mut best_dist_sq = f32::MAX;
|
||||
let mut best_city: Option<&str> = None;
|
||||
for &ci in &city_indices {
|
||||
let dlat = place_data.lat[ci] - lat;
|
||||
let dlon = (place_data.lon[ci] - lon) * cos_lat;
|
||||
let dist_sq = dlat * dlat + dlon * dlon;
|
||||
if dist_sq < best_dist_sq {
|
||||
best_dist_sq = dist_sq;
|
||||
best_city = Some(&place_data.name[ci]);
|
||||
}
|
||||
}
|
||||
// ~100km threshold
|
||||
if best_dist_sq < 0.81 {
|
||||
best_city.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
info!(outcodes = names.len(), "Outcode data derived from postcodes");
|
||||
|
||||
OutcodeData {
|
||||
names,
|
||||
name_lower,
|
||||
centroids,
|
||||
cities,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GeoJSON structures for parsing postcode boundary files
|
||||
#[derive(Deserialize)]
|
||||
struct FeatureCollection {
|
||||
|
|
|
|||
|
|
@ -402,23 +402,6 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
|
|||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
Feature::Numeric(FeatureConfig {
|
||||
name: "Train or tube stations within 1km",
|
||||
bounds: Bounds::Percentile {
|
||||
low: 5.0,
|
||||
high: 95.0,
|
||||
},
|
||||
step: 1.0,
|
||||
description: "Number of train or tube stations within 1km",
|
||||
detail: "Rail stations and Tube/metro/tram stops within 1km of the postcode. Does not include bus stops.",
|
||||
source: "naptan",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
raw: false,
|
||||
absolute: false,
|
||||
modes: &[],
|
||||
linked: "",
|
||||
}),
|
||||
],
|
||||
},
|
||||
FeatureGroup {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ pub fn check_license_bounds(
|
|||
|
||||
let body = json!({
|
||||
"error": "license_required",
|
||||
"message": "A license is required to view data outside inner London",
|
||||
"message": "A license is required to view data outside the demo area",
|
||||
"free_zone": {
|
||||
"south": fz_south,
|
||||
"west": fz_west,
|
||||
|
|
|
|||
|
|
@ -243,6 +243,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
"Postcode boundaries loaded"
|
||||
);
|
||||
|
||||
let outcode_data =
|
||||
data::OutcodeData::from_postcode_and_place_data(&postcode_data, &place_data);
|
||||
|
||||
// Initialize tile reader
|
||||
let tiles_path = &cli.tiles;
|
||||
if !tiles_path.exists() {
|
||||
|
|
@ -375,6 +378,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
poi_grid: Arc::new(poi_grid),
|
||||
place_data: Arc::new(place_data),
|
||||
postcode_data: Arc::new(postcode_data),
|
||||
outcode_data: Arc::new(outcode_data),
|
||||
feature_name_to_index,
|
||||
min_keys,
|
||||
max_keys,
|
||||
|
|
@ -444,6 +448,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
get(routes::get_postcodes).layer(ConcurrencyLimitLayer::new(20)),
|
||||
)
|
||||
.route("/api/postcode/{postcode}", get(routes::get_postcode_lookup))
|
||||
.route("/api/nearest-postcode", get(routes::get_nearest_postcode))
|
||||
.route(
|
||||
"/api/pois",
|
||||
get(routes::get_pois).layer(ConcurrencyLimitLayer::new(20)),
|
||||
|
|
@ -460,6 +465,7 @@ async fn main() -> anyhow::Result<()> {
|
|||
"/api/hexagon-properties",
|
||||
get(routes::get_hexagon_properties),
|
||||
)
|
||||
.route("/api/filter-counts", get(routes::get_filter_counts))
|
||||
.route("/api/hexagon-stats", get(routes::get_hexagon_stats))
|
||||
.route("/api/postcode-stats", get(routes::get_postcode_stats))
|
||||
.route(
|
||||
|
|
|
|||
|
|
@ -5,5 +5,7 @@ mod h3;
|
|||
|
||||
pub use bounds::{bounds_intersect, h3_cell_bounds, parse_bounds, require_bounds};
|
||||
pub use fields::{parse_field_indices, parse_field_set};
|
||||
pub use filters::{parse_filters, row_passes_filters, ParsedEnumFilter, ParsedFilter};
|
||||
pub use filters::{
|
||||
count_filter_impacts, parse_filters, row_passes_filters, ParsedEnumFilter, ParsedFilter,
|
||||
};
|
||||
pub use h3::{cell_for_row, cell_for_row_cached, needs_parent, validate_h3_resolution};
|
||||
|
|
|
|||
|
|
@ -121,6 +121,65 @@ pub fn row_passes_filters(
|
|||
})
|
||||
}
|
||||
|
||||
/// Single-pass marginal impact counting.
|
||||
///
|
||||
/// Returns `(total_passing, impacts)` where `impacts[i]` is how many MORE rows
|
||||
/// would pass if the i-th filter (numeric first, then enum) were removed.
|
||||
///
|
||||
/// For each row we record which filters reject it:
|
||||
/// - 0 failures → passes (counted in `total_passing`)
|
||||
/// - exactly 1 failure → that filter's marginal cost (counted in `impacts[i]`)
|
||||
/// - 2+ failures → removing any single filter won't recover it (ignored)
|
||||
pub fn count_filter_impacts(
|
||||
filters: &[ParsedFilter],
|
||||
enum_filters: &[ParsedEnumFilter],
|
||||
feature_data: &[u16],
|
||||
num_features: usize,
|
||||
rows: impl Iterator<Item = u32>,
|
||||
) -> (u32, Vec<u32>) {
|
||||
let n = filters.len() + enum_filters.len();
|
||||
let mut total_passing: u32 = 0;
|
||||
let mut impacts = vec![0u32; n];
|
||||
|
||||
for row_idx in rows {
|
||||
let base = row_idx as usize * num_features;
|
||||
let mut fail_count: u32 = 0;
|
||||
let mut fail_index: usize = 0;
|
||||
|
||||
for (i, f) in filters.iter().enumerate() {
|
||||
let raw = feature_data[base + f.feat_idx];
|
||||
if raw == NAN_U16 || raw < f.min_u16 || raw > f.max_u16 {
|
||||
fail_count += 1;
|
||||
fail_index = i;
|
||||
if fail_count > 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fail_count <= 1 {
|
||||
for (i, f) in enum_filters.iter().enumerate() {
|
||||
let raw = feature_data[base + f.feat_idx];
|
||||
if raw == NAN_U16 || !f.allowed.contains(&raw) {
|
||||
fail_count += 1;
|
||||
fail_index = filters.len() + i;
|
||||
if fail_count > 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match fail_count {
|
||||
0 => total_passing += 1,
|
||||
1 => impacts[fail_index] += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(total_passing, impacts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -536,4 +595,85 @@ mod tests {
|
|||
|
||||
assert!(!row_passes_filters(0, &[], &enum_filters, &feature_data, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_impacts_single_pass() {
|
||||
// 2 numeric features, 4 rows:
|
||||
// row 0: price=150, area=100 → passes both
|
||||
// row 1: price=600, area=100 → fails price only
|
||||
// row 2: price=150, area=300 → fails area only
|
||||
// row 3: price=600, area=300 → fails both
|
||||
let tq = test_quant(2, 2);
|
||||
let feature_data = vec![
|
||||
tq.encode(0, 150.0), tq.encode(1, 100.0), // row 0
|
||||
tq.encode(0, 600.0), tq.encode(1, 100.0), // row 1
|
||||
tq.encode(0, 150.0), tq.encode(1, 300.0), // row 2
|
||||
tq.encode(0, 600.0), tq.encode(1, 300.0), // row 3
|
||||
];
|
||||
let filters = vec![
|
||||
ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min_u16: tq.as_ref().encode_min(0, 100.0),
|
||||
max_u16: tq.as_ref().encode_max(0, 500.0),
|
||||
},
|
||||
ParsedFilter {
|
||||
feat_idx: 1,
|
||||
min_u16: tq.as_ref().encode_min(1, 50.0),
|
||||
max_u16: tq.as_ref().encode_max(1, 200.0),
|
||||
},
|
||||
];
|
||||
|
||||
let (total, impacts) =
|
||||
count_filter_impacts(&filters, &[], &feature_data, 2, (0..4u32).into_iter());
|
||||
|
||||
assert_eq!(total, 1); // only row 0 passes
|
||||
assert_eq!(impacts[0], 1); // row 1 fails price only
|
||||
assert_eq!(impacts[1], 1); // row 2 fails area only
|
||||
// row 3 fails both → not counted
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_impacts_with_enum() {
|
||||
// 1 numeric + 1 enum, 3 rows:
|
||||
// row 0: price=150, type=0(A) → passes both
|
||||
// row 1: price=150, type=2(C) → fails enum only
|
||||
// row 2: price=600, type=0(A) → fails numeric only
|
||||
let tq = test_quant(2, 1);
|
||||
let feature_data = vec![
|
||||
tq.encode(0, 150.0), 0u16, // row 0
|
||||
tq.encode(0, 150.0), 2u16, // row 1
|
||||
tq.encode(0, 600.0), 0u16, // row 2
|
||||
];
|
||||
let num_filters = vec![ParsedFilter {
|
||||
feat_idx: 0,
|
||||
min_u16: tq.as_ref().encode_min(0, 100.0),
|
||||
max_u16: tq.as_ref().encode_max(0, 500.0),
|
||||
}];
|
||||
let enum_filters = vec![ParsedEnumFilter {
|
||||
feat_idx: 1,
|
||||
allowed: [0u16, 1].into_iter().collect(),
|
||||
}];
|
||||
|
||||
let (total, impacts) = count_filter_impacts(
|
||||
&num_filters,
|
||||
&enum_filters,
|
||||
&feature_data,
|
||||
2,
|
||||
(0..3u32).into_iter(),
|
||||
);
|
||||
|
||||
assert_eq!(total, 1); // row 0
|
||||
assert_eq!(impacts[0], 1); // row 2 fails numeric only → impacts[0]
|
||||
assert_eq!(impacts[1], 1); // row 1 fails enum only → impacts[1]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_impacts_no_filters() {
|
||||
let tq = test_quant(1, 1);
|
||||
let feature_data = vec![tq.encode(0, 100.0)];
|
||||
let (total, impacts) =
|
||||
count_filter_impacts(&[], &[], &feature_data, 1, (0..1u32).into_iter());
|
||||
assert_eq!(total, 1);
|
||||
assert!(impacts.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod ai_filters;
|
||||
mod checkout;
|
||||
mod export;
|
||||
mod filter_counts;
|
||||
mod features;
|
||||
mod hexagon_stats;
|
||||
pub(crate) mod hexagons;
|
||||
|
|
@ -31,6 +32,7 @@ pub(crate) mod travel_time;
|
|||
pub use ai_filters::{build_system_prompt, post_ai_filters};
|
||||
pub use checkout::post_checkout;
|
||||
pub use export::get_export;
|
||||
pub use filter_counts::get_filter_counts;
|
||||
pub use features::{build_features_response, get_features, FeatureInfo, FeaturesResponse};
|
||||
pub use hexagon_stats::get_hexagon_stats;
|
||||
pub use hexagons::get_hexagons;
|
||||
|
|
@ -43,7 +45,7 @@ pub use places::get_places;
|
|||
pub use pois::{get_poi_categories, get_pois};
|
||||
pub use postcode_properties::get_postcode_properties;
|
||||
pub use postcode_stats::get_postcode_stats;
|
||||
pub use postcodes::{get_postcode_lookup, get_postcodes};
|
||||
pub use postcodes::{get_nearest_postcode, get_postcode_lookup, get_postcodes};
|
||||
pub use pricing::get_pricing;
|
||||
pub use properties::get_hexagon_properties;
|
||||
pub use reload::post_reload;
|
||||
|
|
|
|||
|
|
@ -39,8 +39,6 @@ pub struct AiFiltersRequest {
|
|||
query: String,
|
||||
/// Current filters for conversational refinement (e.g. "make it cheaper")
|
||||
context: Option<AiFiltersContext>,
|
||||
/// Current listing mode (historical/buy/rent). Defaults to "historical".
|
||||
listing_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -62,8 +60,6 @@ pub struct AiFiltersResponse {
|
|||
/// What the LLM couldn't map to existing filters (empty if everything matched)
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
notes: String,
|
||||
/// The listing mode used for this response (historical/buy/rent)
|
||||
listing_type: String,
|
||||
/// Number of properties matching the proposed filters (excludes travel time)
|
||||
match_count: usize,
|
||||
}
|
||||
|
|
@ -345,34 +341,19 @@ pub fn build_system_prompt(
|
|||
modes_list,
|
||||
));
|
||||
|
||||
// Listing modes section
|
||||
// Feature guidance — only historical features are available
|
||||
parts.push(
|
||||
"\n--- LISTING MODES ---\n\
|
||||
There are three listing modes that control which property data is shown:\n\
|
||||
- \"historical\": Historical sales from Land Registry (default). Uses features like \
|
||||
\"Last known price\", \"Estimated current price\", \"Price per sqm\".\n\
|
||||
- \"buy\": Properties currently listed for sale. Uses features like \"Asking price\", \
|
||||
\"Asking price per sqm\".\n\
|
||||
- \"rent\": Properties currently listed for rent. Uses features like \
|
||||
\"Asking rent (monthly)\".\n\
|
||||
"\n--- DATA SOURCE ---\n\
|
||||
The data is historical property sales from the Land Registry.\n\
|
||||
\n\
|
||||
When the user mentions buying, purchasing, for-sale properties, or asking prices, \
|
||||
set listing_type to \"buy\".\n\
|
||||
When the user mentions renting, letting, rental properties, or monthly rent, \
|
||||
set listing_type to \"rent\".\n\
|
||||
When the user doesn't specify or mentions historical prices/past sales, \
|
||||
omit listing_type to keep the current mode.\n\
|
||||
Use these features for price queries:\n\
|
||||
- For purchase price: use \"Estimated current price\" or \"Last known price\"\n\
|
||||
- For price per sqm: use \"Est. price per sqm\"\n\
|
||||
- For rent: use \"Estimated monthly rent\"\n\
|
||||
\n\
|
||||
Features marked with [mode] below are only available in that mode. \
|
||||
Features without a mode annotation work in all modes. \
|
||||
ONLY use features valid for the chosen listing_type.\n\
|
||||
If the user mentions price and the mode is \"buy\", use \"Asking price\" (not \"Last known price\").\n\
|
||||
If the user mentions rent/price and the mode is \"rent\", use \"Asking rent (monthly)\".\n\
|
||||
\n\
|
||||
Feature equivalences across modes:\n\
|
||||
- \"Estimated current price\" (historical) ↔ \"Asking price\" (buy)\n\
|
||||
- \"Est. price per sqm\" (historical) ↔ \"Asking price per sqm\" (buy)\n\
|
||||
- \"Estimated monthly rent\" (historical) ↔ \"Asking rent (monthly)\" (rent)"
|
||||
Features marked with [historical] below are available. \
|
||||
Features marked with [buy] or [rent] are NOT available — do not use them.\n\
|
||||
ONLY use features marked [historical] or unmarked."
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
|
|
@ -412,7 +393,7 @@ pub fn build_system_prompt(
|
|||
description,
|
||||
..
|
||||
} => {
|
||||
// Skip Listing status — handled via listing_type field
|
||||
// Skip Listing status — auto-injected as "Historical sale"
|
||||
if name == "Listing status" {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -499,11 +480,11 @@ pub fn build_system_prompt(
|
|||
.to_string(),
|
||||
);
|
||||
|
||||
// Examples showing listing mode switching
|
||||
// Examples showing rent and price features
|
||||
parts.push(
|
||||
"\nUser: \"2 bed flat to rent under £1500/month\"\n\
|
||||
Output: {\"listing_type\": \"rent\", \
|
||||
\"numeric_filters\": [{\"name\": \"Asking rent (monthly)\", \"bound\": \"max\", \"value\": 1500}], \
|
||||
"\nUser: \"2 bed flat with rent under £1500/month\"\n\
|
||||
Output: {\
|
||||
\"numeric_filters\": [{\"name\": \"Estimated monthly rent\", \"bound\": \"max\", \"value\": 1500}], \
|
||||
\"enum_filters\": [{\"name\": \"Property type\", \"values\": [\"Flats/Maisonettes\"]}], \
|
||||
\"travel_time_filters\": [], \
|
||||
\"notes\": \"\"}"
|
||||
|
|
@ -511,9 +492,9 @@ pub fn build_system_prompt(
|
|||
);
|
||||
|
||||
parts.push(
|
||||
"\nUser: \"3 bed house to buy under 500k with good schools\"\n\
|
||||
Output: {\"listing_type\": \"buy\", \
|
||||
\"numeric_filters\": [{\"name\": \"Asking price\", \"bound\": \"max\", \"value\": 500000}, \
|
||||
"\nUser: \"3 bed house under 500k with good schools\"\n\
|
||||
Output: {\
|
||||
\"numeric_filters\": [{\"name\": \"Estimated current price\", \"bound\": \"max\", \"value\": 500000}, \
|
||||
{\"name\": \"Good+ primary schools within 2km\", \"bound\": \"min\", \"value\": 2}], \
|
||||
\"enum_filters\": [{\"name\": \"Property type\", \
|
||||
\"values\": [\"Detached\", \"Semi-Detached\", \"Terraced\"]}], \
|
||||
|
|
@ -525,11 +506,9 @@ pub fn build_system_prompt(
|
|||
// Output format reminder
|
||||
parts.push(
|
||||
"\n--- OUTPUT FORMAT ---\n\
|
||||
{\"listing_type\": \"buy\"|\"rent\" (OPTIONAL — only when switching mode), \
|
||||
\"numeric_filters\": [...], \"enum_filters\": [...], \
|
||||
{\"numeric_filters\": [...], \"enum_filters\": [...], \
|
||||
\"travel_time_filters\": [{\"mode\": \"...\", \"slug\": \"...\", \"label\": \"...\", \
|
||||
\"bound\": \"min\"|\"max\", \"value\": N}, ...], \"notes\": \"...\"}\n\
|
||||
- listing_type: include only when the user explicitly wants to buy or rent. Omit to keep current mode.\n\
|
||||
- travel_time_filters: use ONLY slugs returned by search_destinations. If a place isn't found, mention it in notes.\n\
|
||||
Respond with ONLY the JSON object. No explanation."
|
||||
.to_string(),
|
||||
|
|
@ -779,17 +758,9 @@ pub async fn post_ai_filters(
|
|||
|
||||
let tools = build_tool_declarations(&state);
|
||||
|
||||
// Resolve current listing mode from request
|
||||
let current_mode = req.listing_type.as_deref().unwrap_or("historical");
|
||||
let current_mode = match current_mode {
|
||||
"historical" | "buy" | "rent" => current_mode,
|
||||
_ => "historical",
|
||||
};
|
||||
|
||||
// Build user message with listing mode and optional context for conversational refinement
|
||||
// Build user message with optional context for conversational refinement
|
||||
let user_text = if let Some(ref ctx) = req.context {
|
||||
let mut msg = String::new();
|
||||
msg.push_str(&format!("Current listing mode: {}\n", current_mode));
|
||||
msg.push_str("Currently active filters:\n");
|
||||
msg.push_str(&serde_json::to_string(&ctx.filters).unwrap_or_default());
|
||||
if !ctx.travel_time.is_empty() {
|
||||
|
|
@ -807,10 +778,7 @@ pub async fn post_ai_filters(
|
|||
msg.push_str(&format!("\nUser request: {}", req.query));
|
||||
msg
|
||||
} else {
|
||||
format!(
|
||||
"Current listing mode: {}\nUser request: {}",
|
||||
current_mode, req.query
|
||||
)
|
||||
req.query.clone()
|
||||
};
|
||||
|
||||
let mut contents = vec![json!({
|
||||
|
|
@ -967,17 +935,8 @@ pub async fn post_ai_filters(
|
|||
}
|
||||
};
|
||||
|
||||
// Resolve listing_type: LLM output > request > "historical"
|
||||
let listing_type = raw
|
||||
.get("listing_type")
|
||||
.and_then(|val| val.as_str())
|
||||
.unwrap_or(current_mode);
|
||||
let listing_type = match listing_type {
|
||||
"historical" | "buy" | "rent" => listing_type,
|
||||
_ => current_mode,
|
||||
};
|
||||
|
||||
let mut filters = validate_and_convert(&raw, &state.features_response, listing_type);
|
||||
// Only historical mode is supported — validate features accordingly
|
||||
let mut filters = validate_and_convert(&raw, &state.features_response, "historical");
|
||||
let travel_time_filters = validate_travel_time_filters(&raw, &state);
|
||||
let notes = raw
|
||||
.get("notes")
|
||||
|
|
@ -985,14 +944,12 @@ pub async fn post_ai_filters(
|
|||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Auto-inject Listing status filter for the chosen mode
|
||||
let listing_value = match listing_type {
|
||||
"buy" => "For sale",
|
||||
"rent" => "For rent",
|
||||
_ => "Historical sale",
|
||||
};
|
||||
// Auto-inject Listing status filter for historical mode
|
||||
if let Value::Object(ref mut map) = filters {
|
||||
map.insert("Listing status".to_string(), json!([listing_value]));
|
||||
map.insert(
|
||||
"Listing status".to_string(),
|
||||
json!(["Historical sale"]),
|
||||
);
|
||||
}
|
||||
|
||||
// Count matching properties and refine if too restrictive
|
||||
|
|
@ -1031,7 +988,6 @@ pub async fn post_ai_filters(
|
|||
filters,
|
||||
travel_time_filters,
|
||||
notes,
|
||||
listing_type: listing_type.to_string(),
|
||||
match_count: 0,
|
||||
}));
|
||||
}
|
||||
|
|
@ -1073,7 +1029,7 @@ pub async fn post_ai_filters(
|
|||
let log_state = state.clone();
|
||||
let log_user_id = user.id.clone();
|
||||
let log_query = req.query.clone();
|
||||
let log_listing_type = listing_type.to_string();
|
||||
let log_listing_type = "historical".to_string();
|
||||
let log_notes = notes.clone();
|
||||
let log_rounds = (round + 1) as u64;
|
||||
tokio::spawn(async move {
|
||||
|
|
@ -1094,7 +1050,6 @@ pub async fn post_ai_filters(
|
|||
filters,
|
||||
travel_time_filters,
|
||||
notes,
|
||||
listing_type: listing_type.to_string(),
|
||||
match_count,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
203
server-rs/src/routes/filter_counts.rs
Normal file
203
server-rs/src/routes/filter_counts.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
use crate::consts::NAN_U16;
|
||||
use crate::data::travel_time::TravelData;
|
||||
use crate::parsing::{parse_filters, require_bounds};
|
||||
use crate::routes::travel_time::parse_optional_travel;
|
||||
use crate::state::SharedState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FilterCountsParams {
|
||||
bounds: Option<String>,
|
||||
filters: Option<String>,
|
||||
travel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FilterCountsResponse {
|
||||
total: u32,
|
||||
impacts: FxHashMap<String, u32>,
|
||||
}
|
||||
|
||||
pub async fn get_filter_counts(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Query(params): Query<FilterCountsParams>,
|
||||
) -> Result<Json<FilterCountsResponse>, axum::response::Response> {
|
||||
let state = shared.load_state();
|
||||
|
||||
let (south, west, north, east) =
|
||||
require_bounds(params.bounds).map_err(IntoResponse::into_response)?;
|
||||
|
||||
let quant = state.data.quant_ref();
|
||||
let (parsed_filters, parsed_enum_filters) = parse_filters(
|
||||
params.filters.as_deref(),
|
||||
&state.feature_name_to_index,
|
||||
&state.data.enum_values,
|
||||
&quant,
|
||||
)
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
let travel_entries = parse_optional_travel(params.travel.as_deref())
|
||||
.map_err(|err| (StatusCode::BAD_REQUEST, err).into_response())?;
|
||||
|
||||
let num_regular = parsed_filters.len() + parsed_enum_filters.len();
|
||||
// Only travel entries with a filter range count as filters for impact tracking
|
||||
let travel_filter_indices: Vec<usize> = travel_entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, e)| e.filter_min.is_some())
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let num_total_filters = num_regular + travel_filter_indices.len();
|
||||
|
||||
if num_total_filters == 0 {
|
||||
return Ok(Json(FilterCountsResponse {
|
||||
total: 0,
|
||||
impacts: FxHashMap::default(),
|
||||
}));
|
||||
}
|
||||
|
||||
let filters_str = params.filters;
|
||||
|
||||
let response = tokio::task::spawn_blocking(move || -> Result<FilterCountsResponse, String> {
|
||||
let t0 = std::time::Instant::now();
|
||||
let num_features = state.data.num_features;
|
||||
let feature_data = &state.data.feature_data;
|
||||
|
||||
// Load travel time data
|
||||
let travel_data: Vec<TravelData> = travel_entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
state
|
||||
.travel_time_store
|
||||
.get(&entry.mode, &entry.slug)
|
||||
.map_err(|err| format!("Failed to load travel data: {}", err))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let has_travel = !travel_entries.is_empty();
|
||||
let (pc_interner, pc_keys) = state.data.postcode_parts();
|
||||
|
||||
let rows = state.grid.query(south, west, north, east);
|
||||
let row_count = rows.len();
|
||||
|
||||
let mut total_passing: u32 = 0;
|
||||
let mut impacts = vec![0u32; num_total_filters];
|
||||
|
||||
for row_idx in rows {
|
||||
let row = row_idx as usize;
|
||||
let base = row * num_features;
|
||||
let mut fail_count: u32 = 0;
|
||||
let mut fail_index: usize = 0;
|
||||
|
||||
// Test numeric filters
|
||||
for (i, f) in parsed_filters.iter().enumerate() {
|
||||
let raw = feature_data[base + f.feat_idx];
|
||||
if raw == NAN_U16 || raw < f.min_u16 || raw > f.max_u16 {
|
||||
fail_count += 1;
|
||||
fail_index = i;
|
||||
if fail_count > 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test enum filters
|
||||
if fail_count <= 1 {
|
||||
for (i, f) in parsed_enum_filters.iter().enumerate() {
|
||||
let raw = feature_data[base + f.feat_idx];
|
||||
if raw == NAN_U16 || !f.allowed.contains(&raw) {
|
||||
fail_count += 1;
|
||||
fail_index = parsed_filters.len() + i;
|
||||
if fail_count > 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test travel time filters
|
||||
if fail_count <= 1 && has_travel {
|
||||
let postcode = pc_interner.resolve(&pc_keys[row]);
|
||||
for (slot, &ti) in travel_filter_indices.iter().enumerate() {
|
||||
let entry = &travel_entries[ti];
|
||||
let minutes = travel_data[ti].get(postcode).map(|r| {
|
||||
if entry.use_best {
|
||||
r.best_minutes.unwrap_or(r.minutes)
|
||||
} else {
|
||||
r.minutes
|
||||
}
|
||||
});
|
||||
let passes = match (minutes, entry.filter_min, entry.filter_max) {
|
||||
(Some(mins), Some(fmin), Some(fmax)) => {
|
||||
(mins as f32) >= fmin && (mins as f32) <= fmax
|
||||
}
|
||||
(None, Some(_), Some(_)) => false,
|
||||
_ => true,
|
||||
};
|
||||
if !passes {
|
||||
fail_count += 1;
|
||||
fail_index = num_regular + slot;
|
||||
if fail_count > 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match fail_count {
|
||||
0 => total_passing += 1,
|
||||
1 => impacts[fail_index] += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Map filter indices back to feature/travel names
|
||||
let mut impact_map: FxHashMap<String, u32> = FxHashMap::default();
|
||||
for (i, &count) in impacts.iter().enumerate() {
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let name = if i < parsed_filters.len() {
|
||||
state.data.feature_names[parsed_filters[i].feat_idx].clone()
|
||||
} else if i < num_regular {
|
||||
let ei = i - parsed_filters.len();
|
||||
state.data.feature_names[parsed_enum_filters[ei].feat_idx].clone()
|
||||
} else {
|
||||
let slot = i - num_regular;
|
||||
let ti = travel_filter_indices[slot];
|
||||
let e = &travel_entries[ti];
|
||||
format!("tt_{}_{}", e.mode, e.slug)
|
||||
};
|
||||
impact_map.insert(name, count);
|
||||
}
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
info!(
|
||||
rows = row_count,
|
||||
filters = num_total_filters,
|
||||
travel = travel_filter_indices.len(),
|
||||
total = total_passing,
|
||||
filters_raw = filters_str.as_deref().unwrap_or("-"),
|
||||
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
|
||||
"GET /api/filter-counts"
|
||||
);
|
||||
|
||||
Ok(FilterCountsResponse {
|
||||
total: total_passing,
|
||||
impacts: impact_map,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())?
|
||||
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err).into_response())?;
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ pub async fn get_places(
|
|||
let t0 = std::time::Instant::now();
|
||||
let query_lower = query.to_lowercase();
|
||||
let pd = &state.place_data;
|
||||
let od = &state.outcode_data;
|
||||
let tt_store = &state.travel_time_store;
|
||||
|
||||
// Linear scan — ~50-100k rows, <1ms
|
||||
|
|
@ -99,7 +100,7 @@ pub async fn get_places(
|
|||
|
||||
matches.truncate(limit);
|
||||
|
||||
let results: Vec<PlaceResult> = matches
|
||||
let mut results: Vec<PlaceResult> = matches
|
||||
.iter()
|
||||
.map(|(idx, .., slug)| PlaceResult {
|
||||
name: pd.name[*idx].clone(),
|
||||
|
|
@ -111,6 +112,49 @@ pub async fn get_places(
|
|||
})
|
||||
.collect();
|
||||
|
||||
// Also search outcodes (skip when mode filter is set — outcodes aren't travel destinations)
|
||||
if mode_filter.is_none() {
|
||||
let query_upper = query_lower.to_uppercase();
|
||||
let mut outcode_results: Vec<PlaceResult> = od
|
||||
.name_lower
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, name)| {
|
||||
if !name.starts_with(&query_lower) {
|
||||
return None;
|
||||
}
|
||||
let is_exact = name.len() == query_lower.len();
|
||||
Some((idx, is_exact))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.map(|(idx, _is_exact)| PlaceResult {
|
||||
name: od.names[idx].clone(),
|
||||
slug: od.names[idx].to_lowercase(),
|
||||
place_type: "outcode".to_string(),
|
||||
lat: od.centroids[idx].0,
|
||||
lon: od.centroids[idx].1,
|
||||
city: od.cities[idx].clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort outcodes: exact first, then by name length (shorter = broader area)
|
||||
outcode_results.sort_unstable_by(|a, b| {
|
||||
let a_exact = a.name.eq_ignore_ascii_case(&query_upper);
|
||||
let b_exact = b.name.eq_ignore_ascii_case(&query_upper);
|
||||
b_exact
|
||||
.cmp(&a_exact)
|
||||
.then(a.name.len().cmp(&b.name.len()))
|
||||
});
|
||||
|
||||
// Prepend outcode results (up to 3) before place results, keeping total ≤ limit
|
||||
outcode_results.truncate(3);
|
||||
let place_slots = limit.saturating_sub(outcode_results.len());
|
||||
results.truncate(place_slots);
|
||||
outcode_results.append(&mut results);
|
||||
results = outcode_results;
|
||||
}
|
||||
|
||||
let elapsed = t0.elapsed();
|
||||
info!(
|
||||
query = query.as_str(),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ pub struct PostcodesResponse {
|
|||
features: Vec<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NearestPostcodeParams {
|
||||
lat: f64,
|
||||
lng: f64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PostcodeParams {
|
||||
bounds: Option<String>,
|
||||
|
|
@ -311,6 +317,45 @@ pub async fn get_postcodes(
|
|||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// Find the nearest postcode to a given lat/lng coordinate.
|
||||
pub async fn get_nearest_postcode(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
Query(params): Query<NearestPostcodeParams>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
let state = shared.load_state();
|
||||
let postcode_data = &state.postcode_data;
|
||||
|
||||
let query_lat = params.lat as f32;
|
||||
let query_lng = params.lng as f32;
|
||||
let cos_lat = (query_lat as f64).to_radians().cos() as f32;
|
||||
|
||||
let mut best_idx: Option<usize> = None;
|
||||
let mut best_dist_sq = f32::MAX;
|
||||
|
||||
for (idx, &(pc_lat, pc_lon)) in postcode_data.centroids.iter().enumerate() {
|
||||
let dlat = pc_lat - query_lat;
|
||||
let dlon = (pc_lon - query_lng) * cos_lat;
|
||||
let dist_sq = dlat * dlat + dlon * dlon;
|
||||
if dist_sq < best_dist_sq {
|
||||
best_dist_sq = dist_sq;
|
||||
best_idx = Some(idx);
|
||||
}
|
||||
}
|
||||
|
||||
let idx = best_idx.ok_or(StatusCode::NOT_FOUND)?;
|
||||
let (lat, lon) = postcode_data.centroids[idx];
|
||||
let geometry = postcode_data.geometries[idx].clone();
|
||||
let postcode = &postcode_data.postcodes[idx];
|
||||
|
||||
info!(postcode = %postcode, "GET /api/nearest-postcode");
|
||||
Ok(Json(serde_json::json!({
|
||||
"postcode": postcode,
|
||||
"latitude": lat as f64,
|
||||
"longitude": lon as f64,
|
||||
"geometry": geometry,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Look up a single postcode and return its centroid coordinates and geometry.
|
||||
pub async fn get_postcode_lookup(
|
||||
State(shared): State<Arc<SharedState>>,
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ fn rebuild_data(shared: &SharedState, start: Instant) -> anyhow::Result<(usize,
|
|||
poi_grid: Arc::clone(&old.poi_grid),
|
||||
place_data: Arc::clone(&old.place_data),
|
||||
postcode_data: Arc::clone(&old.postcode_data),
|
||||
outcode_data: Arc::clone(&old.outcode_data),
|
||||
poi_category_groups: Arc::clone(&old.poi_category_groups),
|
||||
travel_time_store: Arc::clone(&old.travel_time_store),
|
||||
token_cache: Arc::clone(&old.token_cache),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ use rustc_hash::FxHashMap;
|
|||
|
||||
use crate::auth::TokenCache;
|
||||
use crate::data::{
|
||||
POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
|
||||
OutcodeData, POICategoryGroup, POIData, PlaceData, PostcodeData, PropertyData, TravelTimeStore,
|
||||
};
|
||||
use crate::pocketbase::SuperuserTokenCache;
|
||||
use crate::routes::FeaturesResponse;
|
||||
|
|
@ -39,6 +39,8 @@ pub struct AppState {
|
|||
pub place_data: Arc<PlaceData>,
|
||||
/// Postcode boundary data for high-zoom rendering
|
||||
pub postcode_data: Arc<PostcodeData>,
|
||||
/// Precomputed outcode centroids for search
|
||||
pub outcode_data: Arc<OutcodeData>,
|
||||
/// Precomputed POI category groups (sorted)
|
||||
pub poi_category_groups: Arc<Vec<POICategoryGroup>>,
|
||||
/// Precomputed travel time data store
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue