Translate pages

This commit is contained in:
Andras Schmelczer 2026-04-04 09:47:18 +01:00
parent a7aaf5effa
commit 96402228e3
49 changed files with 1458 additions and 926 deletions

View file

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

View file

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

View file

@ -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)}`);
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 &rarr;
{t('savedPage.viewListing')} &rarr;
</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>

View file

@ -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);
@ -66,16 +68,15 @@ 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">
<button
@ -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,16 +106,16 @@ 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">
@ -122,17 +123,17 @@ export default function HomePage({
<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&apos;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&nbsp;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&apos;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.',
},
];

View file

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

View file

@ -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 &copy; 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 &copy; 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"
>
&copy; 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&apos;re buying, renting, or just exploring, here&apos;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>

View file

@ -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&apos;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&apos;ve reached the weekly AI usage limit. It will reset automatically next week.
{t('aiFilter.weeklyLimitReached')}
</p>
)}
{error && errorType === 'error' && (

View file

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

View file

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

View file

@ -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 { 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,10 @@ interface FiltersProps {
isLoggedIn: boolean;
onLoginRequired: () => void;
isLicensed: boolean;
isAdmin: boolean;
onUpgradeClick?: () => void;
onResetTutorial?: () => void;
filterImpacts?: Record<string, number>;
}
export default memo(function Filters({
@ -234,9 +238,12 @@ export default memo(function Filters({
isLoggedIn,
onLoginRequired,
isLicensed,
isAdmin,
onUpgradeClick,
onResetTutorial,
filterImpacts,
}: FiltersProps) {
const { t } = useTranslation();
const modeRestrictions = useMemo(() => {
const map: Record<string, Set<ListingType>> = {};
for (const f of features) {
@ -356,6 +363,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;
@ -411,15 +419,22 @@ export default memo(function Filters({
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={{
flexGrow: activeFilterCollapsed ? 0 : addFilterCollapsed ? 1 : 3,
flexShrink: activeFilterCollapsed ? 0 : 1,
}}
>
<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 +442,13 @@ export default memo(function Filters({
</span>
)}
</div>
</div>
<ChevronIcon
direction={activeFilterCollapsed ? 'down' : 'up'}
className="w-4 h-4 text-warm-400 dark:text-warm-500"
/>
</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 +460,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>
)}
@ -521,7 +542,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 +554,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>
);
@ -649,6 +675,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>
@ -678,24 +709,28 @@ export default memo(function Filters({
</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={{
flexGrow: addFilterCollapsed ? 0 : activeFilterCollapsed ? 1 : 2,
flexShrink: addFilterCollapsed ? 0 : 1,
}}
>
<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 flex flex-col">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={features}
@ -708,82 +743,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 +812,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>

View file

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

View file

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

View file

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

View file

@ -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,7 @@ 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';
interface MapProps {
data: HexagonData[];
@ -57,6 +58,7 @@ interface MapProps {
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string;
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
@ -113,8 +115,11 @@ export default memo(function Map({
bounds: viewportBounds,
hideLegend = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
densityLabel = 'Number of properties',
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const modes = useTranslatedModes();
const [internalViewState, setInternalViewState] = useState<ViewState>(
initialViewState || INITIAL_VIEW_STATE
);
@ -271,7 +276,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}
@ -299,12 +304,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={
usePostcodeView ? postcodeCountRange.total : countRange.total
}
showCancel={false}
onCancel={onCancelPin}
mode="density"

View file

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

View file

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

View file

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

View file

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

View file

@ -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 &rarr;
{t('propertyCard.viewExternalListing')} &rarr;
</a>
</div>
)}

View file

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

View file

@ -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 &pound;10k+ in stamp duty, &pound;1,500 in solicitor fees, &pound;500
for a survey. Get the wrong area and you&apos;re stuck with a long commute, bad schools,
or a road you didn&apos;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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -50,27 +50,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 {

View file

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

View file

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

View file

@ -0,0 +1,297 @@
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\u00E9, mitoyen, appartement ou autre',
'Leasehold/Freehold': 'Indique si le bien est en bail ou en pleine propri\u00E9t\u00E9',
'Last known price': 'Dernier prix de vente enregistr\u00E9 au Land Registry',
'Estimated current price': 'Estimation du prix actuel ajust\u00E9 \u00E0 l\u2019inflation',
'Asking price': 'Prix demand\u00E9 pour les biens actuellement en vente',
'Price per sqm': 'Prix de vente divis\u00E9 par la surface totale',
'Est. price per sqm': 'Prix actuel estim\u00E9 divis\u00E9 par la surface totale',
'Asking price per sqm': 'Prix demand\u00E9 divis\u00E9 par la surface totale',
'Estimated monthly rent': 'Loyer mensuel priv\u00E9 m\u00E9dian pour le secteur',
'Asking rent (monthly)': 'Loyer mensuel affich\u00E9 pour les biens en location',
'Total floor area (sqm)': 'Surface int\u00E9rieure issue du diagnostic EPC',
'Number of bedrooms & living rooms': 'Nombre de pi\u00E8ces habitables selon le diagnostic EPC',
'Bedrooms': 'Nombre de chambres selon l\u2019annonce en ligne',
'Bathrooms': 'Nombre de salles de bain selon l\u2019annonce en ligne',
'Construction year': 'Ann\u00E9e de construction estim\u00E9e selon l\u2019EPC',
'Date of last transaction': 'Date de la derni\u00E8re vente enregistr\u00E9e au Land Registry',
'Listing date': 'Date de premi\u00E8re mise en ligne du bien',
'Former council house': 'Indique si le bien a \u00E9t\u00E9 r\u00E9pertori\u00E9 comme logement social',
'Current energy rating': 'Classement \u00E9nerg\u00E9tique EPC actuel (A = meilleur, G = pire)',
'Potential energy rating': 'Classement EPC potentiel si toutes les am\u00E9liorations recommand\u00E9es \u00E9taient r\u00E9alis\u00E9es',
'Interior height (m)': 'Hauteur moyenne d\u2019\u00E9tage selon le diagnostic EPC',
'Distance to nearest train or tube station (km)': 'Distance \u00E0 la gare ou station de m\u00E9tro la plus proche',
'Train or tube stations within 1km': 'Nombre de gares ou stations de m\u00E9tro \u00E0 moins d\u20191 km',
'Good+ primary schools within 2km': '\u00C9coles primaires not\u00E9es Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ secondary schools within 2km': 'Coll\u00E8ges/lyc\u00E9es not\u00E9s Bien ou Excellent par Ofsted dans un rayon de 2 km',
'Good+ primary schools within 5km': '\u00C9coles primaires not\u00E9es Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Good+ secondary schools within 5km': 'Coll\u00E8ges/lyc\u00E9es not\u00E9s Bien ou Excellent par Ofsted dans un rayon de 5 km',
'Education, Skills and Training Score': 'Score de qualit\u00E9 \u00E9ducative du secteur (plus \u00E9lev\u00E9 = meilleur)',
'Income Score (rate)': 'Taux de pr\u00E9carit\u00E9 de revenu, invers\u00E9 (plus \u00E9lev\u00E9 = moins pr\u00E9caire)',
'Employment Score (rate)': 'Taux de pr\u00E9carit\u00E9 d\u2019emploi, invers\u00E9 (plus \u00E9lev\u00E9 = moins pr\u00E9caire)',
'Health Deprivation and Disability Score': 'Score de sant\u00E9 et handicap (plus \u00E9lev\u00E9 = meilleurs r\u00E9sultats)',
'Living Environment Score': 'Qualit\u00E9 de l\u2019environnement int\u00E9rieur et ext\u00E9rieur (plus \u00E9lev\u00E9 = meilleur)',
'Indoors Sub-domain Score': 'Qualit\u00E9 et \u00E9tat du logement (plus \u00E9lev\u00E9 = meilleur)',
'Outdoors Sub-domain Score': 'Qualit\u00E9 de l\u2019air et s\u00E9curit\u00E9 routi\u00E8re (plus \u00E9lev\u00E9 = 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\u00E9lits mineurs pour 1 000 habitants par an',
'Serious crime (avg/yr)': 'Agr\u00E9gat des cat\u00E9gories de crimes graves par an',
'Minor crime (avg/yr)': 'Agr\u00E9gat des cat\u00E9gories de d\u00E9lits 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\u00E9s aux v\u00E9hicules 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\u00E9gradations 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 \u00E0 la personne dans le secteur',
'Shoplifting (avg/yr)': 'Moyenne annuelle des vols \u00E0 l\u2019\u00E9talage dans le secteur',
'Bicycle theft (avg/yr)': 'Moyenne annuelle des vols de v\u00E9los dans le secteur',
'Drugs (avg/yr)': 'Moyenne annuelle des infractions li\u00E9es aux stup\u00E9fiants dans le secteur',
'Possession of weapons (avg/yr)': 'Moyenne annuelle des infractions de possession d\u2019armes dans le secteur',
'Public order (avg/yr)': 'Moyenne annuelle des troubles \u00E0 l\u2019ordre public dans le secteur',
'Other crime (avg/yr)': 'Moyenne annuelle des autres crimes dans le secteur',
'Median age': '\u00C2ge m\u00E9dian de la population locale',
'% White': 'Pourcentage de la population se d\u00E9clarant Blanche',
'% South Asian': 'Pourcentage de la population se d\u00E9clarant Sud-Asiatique',
'% Black': 'Pourcentage de la population se d\u00E9clarant Noire',
'% East Asian': 'Pourcentage de la population se d\u00E9clarant Est-Asiatique',
'% Mixed': 'Pourcentage de la population se d\u00E9clarant M\u00E9tisse ou de plusieurs groupes ethniques',
'% Other': 'Pourcentage de la population se d\u00E9clarant d\u2019un 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 \u00E0 moins de 2 km',
'Number of restaurants within 2km': 'Nombre de restaurants et caf\u00E9s \u00E0 moins de 2 km',
'Number of grocery shops and supermarkets within 2km': 'Nombre d\u2019\u00E9piceries et supermarch\u00E9s \u00E0 moins de 2 km',
'Noise (dB)': 'Niveau de bruit routier au code postal en d\u00E9cibels (Lden)',
'Max available download speed (Mbps)': 'D\u00E9bit descendant maximal disponible au code postal',
},
de: {
'Listing status': 'Ob die Immobilie aus historischen Verk\u00E4ufen stammt, aktuell zum Verkauf oder zur Miete steht',
'Property type': 'Immobilientyp: freistehend, Doppelhaush\u00E4lfte, 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\u00E4tzwert der Immobilie',
'Asking price': 'Angebotspreis f\u00FCr aktuell zum Verkauf stehende Immobilien',
'Price per sqm': 'Verkaufspreis geteilt durch die Gesamtfl\u00E4che',
'Est. price per sqm': 'Gesch\u00E4tzter aktueller Preis geteilt durch die Gesamtfl\u00E4che',
'Asking price per sqm': 'Angebotspreis geteilt durch die Gesamtfl\u00E4che',
'Estimated monthly rent': 'Mittlere monatliche Privatmiete in der Gegend',
'Asking rent (monthly)': 'Angebotene Monatsmiete f\u00FCr Mietimmobilien',
'Total floor area (sqm)': 'Wohnfl\u00E4che laut EPC-Gutachten',
'Number of bedrooms & living rooms': 'Anzahl bewohnbarer R\u00E4ume laut EPC-Gutachten',
'Bedrooms': 'Anzahl Schlafzimmer laut Online-Inserat',
'Bathrooms': 'Anzahl Badezimmer laut Online-Inserat',
'Construction year': 'Gesch\u00E4tztes Baujahr laut EPC',
'Date of last transaction': 'Datum des letzten Verkaufs laut Land Registry',
'Listing date': 'Datum der Erstver\u00F6ffentlichung 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\u00DFnahmen',
'Interior height (m)': 'Durchschnittliche Geschossh\u00F6he laut EPC-Gutachten',
'Distance to nearest train or tube station (km)': 'Entfernung zum n\u00E4chsten Bahn- oder U-Bahnhof',
'Train or tube stations within 1km': 'Anzahl Bahn- oder U-Bahnh\u00F6fe im Umkreis von 1 km',
'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\u00FChrende 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\u00FChrende Schulen im Umkreis von 5 km',
'Education, Skills and Training Score': 'Bildungsqualit\u00E4tsscore der Gegend (h\u00F6her = besser)',
'Income Score (rate)': 'Einkommensbenachteiligungsrate, invertiert (h\u00F6her = weniger benachteiligt)',
'Employment Score (rate)': 'Besch\u00E4ftigungsbenachteiligungsrate, invertiert (h\u00F6her = weniger benachteiligt)',
'Health Deprivation and Disability Score': 'Gesundheits- und Behinderungsscore (h\u00F6her = bessere Ergebnisse)',
'Living Environment Score': 'Qualit\u00E4t der Innen- und Au\u00DFenumgebung (h\u00F6her = besser)',
'Indoors Sub-domain Score': 'Wohnqualit\u00E4t und -zustand (h\u00F6her = besser)',
'Outdoors Sub-domain Score': 'Luftqualit\u00E4t und Verkehrssicherheit (h\u00F6her = 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\u00E4hrlicher Durchschnitt der Gewalt- und Sexualdelikte in der Gegend',
'Burglary (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Einbr\u00FCche in der Gegend',
'Robbery (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Raub\u00FCberf\u00E4lle in der Gegend',
'Vehicle crime (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Fahrzeugkriminalit\u00E4t in der Gegend',
'Anti-social behaviour (avg/yr)': 'J\u00E4hrlicher Durchschnitt des antisozialen Verhaltens in der Gegend',
'Criminal damage and arson (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Sachbesch\u00E4digungen und Brandstiftungen in der Gegend',
'Other theft (avg/yr)': 'J\u00E4hrlicher Durchschnitt des sonstigen Diebstahls in der Gegend',
'Theft from the person (avg/yr)': 'J\u00E4hrlicher Durchschnitt des Taschendiebstahls in der Gegend',
'Shoplifting (avg/yr)': 'J\u00E4hrlicher Durchschnitt des Ladendiebstahls in der Gegend',
'Bicycle theft (avg/yr)': 'J\u00E4hrlicher Durchschnitt des Fahrraddiebstahls in der Gegend',
'Drugs (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Drogendelikte in der Gegend',
'Possession of weapons (avg/yr)': 'J\u00E4hrlicher Durchschnitt der Waffenbesitzdelikte in der Gegend',
'Public order (avg/yr)': 'J\u00E4hrlicher Durchschnitt der St\u00F6rungen der \u00F6ffentlichen Ordnung in der Gegend',
'Other crime (avg/yr)': 'J\u00E4hrlicher Durchschnitt sonstiger Straftaten in der Gegend',
'Median age': 'Medianalter der lokalen Bev\u00F6lkerung',
'% White': 'Anteil der Bev\u00F6lkerung, der sich als Wei\u00DF identifiziert',
'% South Asian': 'Anteil der Bev\u00F6lkerung, der sich als S\u00FCdasiatisch identifiziert',
'% Black': 'Anteil der Bev\u00F6lkerung, der sich als Schwarz identifiziert',
'% East Asian': 'Anteil der Bev\u00F6lkerung, der sich als Ostasiatisch identifiziert',
'% Mixed': 'Anteil der Bev\u00F6lkerung, der sich als gemischt oder mehreren ethnischen Gruppen zugeh\u00F6rig identifiziert',
'% Other': 'Anteil der Bev\u00F6lkerung, der sich einer anderen ethnischen Gruppe zuordnet',
'Distance to nearest park (km)': 'Entfernung zum n\u00E4chsten Park oder Gr\u00FCnfl\u00E4che',
'Number of parks within 2km': 'Anzahl Parks und Gr\u00FCnfl\u00E4chen im Umkreis von 2 km',
'Number of restaurants within 2km': 'Anzahl Restaurants und Caf\u00E9s im Umkreis von 2 km',
'Number of grocery shops and supermarkets within 2km': 'Anzahl Lebensmittelgesch\u00E4fte und Superm\u00E4rkte im Umkreis von 2 km',
'Noise (dB)': 'Stra\u00DFenl\u00E4rmpegel an der Postleitzahl in Dezibel (Lden)',
'Max available download speed (Mbps)': 'Maximal verf\u00FCgbare Breitband-Downloadgeschwindigkeit an der Postleitzahl',
},
zh: {
'Listing status': '\u8BE5\u623F\u4EA7\u662F\u5386\u53F2\u9500\u552E\u3001\u5F53\u524D\u5728\u552E\u8FD8\u662F\u51FA\u79DF',
'Property type': '\u623F\u4EA7\u7C7B\u578B\uFF1A\u72EC\u7ACB\u5F0F\u3001\u534A\u72EC\u7ACB\u5F0F\u3001\u8054\u6392\u3001\u516C\u5BD3\u6216\u5176\u4ED6',
'Leasehold/Freehold': '\u8BE5\u623F\u4EA7\u662F\u79DF\u8D41\u4EA7\u6743\u8FD8\u662F\u6C38\u4E45\u4EA7\u6743',
'Last known price': 'Land Registry\u8BB0\u5F55\u7684\u6700\u8FD1\u4E00\u6B21\u552E\u4EF7',
'Estimated current price': '\u7ECF\u901A\u80C0\u8C03\u6574\u540E\u7684\u5F53\u524D\u4F30\u8BA1\u4EF7\u503C',
'Asking price': '\u5F53\u524D\u5728\u552E\u623F\u4EA7\u7684\u6302\u724C\u4EF7',
'Price per sqm': '\u552E\u4EF7\u9664\u4EE5\u603B\u5EFA\u7B51\u9762\u79EF',
'Est. price per sqm': '\u4F30\u8BA1\u5F53\u524D\u4EF7\u683C\u9664\u4EE5\u603B\u5EFA\u7B51\u9762\u79EF',
'Asking price per sqm': '\u6302\u724C\u4EF7\u9664\u4EE5\u603B\u5EFA\u7B51\u9762\u79EF',
'Estimated monthly rent': '\u5F53\u5730\u79C1\u4EBA\u79DF\u8D41\u7684\u4E2D\u4F4D\u6708\u79DF',
'Asking rent (monthly)': '\u5F53\u524D\u51FA\u79DF\u623F\u4EA7\u7684\u6302\u724C\u6708\u79DF',
'Total floor area (sqm)': 'EPC\u8BC4\u4F30\u7684\u5BA4\u5185\u5EFA\u7B51\u9762\u79EF',
'Number of bedrooms & living rooms': 'EPC\u8BC4\u4F30\u7684\u5B9C\u5C45\u623F\u95F4\u6570',
'Bedrooms': '\u5728\u7EBF\u623F\u6E90\u4E2D\u7684\u5367\u5BA4\u6570\u91CF',
'Bathrooms': '\u5728\u7EBF\u623F\u6E90\u4E2D\u7684\u6D74\u5BA4\u6570\u91CF',
'Construction year': 'EPC\u8BC4\u4F30\u7684\u5EFA\u9020\u5E74\u4EFD',
'Date of last transaction': 'Land Registry\u8BB0\u5F55\u7684\u6700\u8FD1\u4E00\u6B21\u9500\u552E\u65E5\u671F',
'Listing date': '\u623F\u4EA7\u9996\u6B21\u5728\u7EBF\u4E0A\u5E02\u7684\u65E5\u671F',
'Former council house': '\u8BE5\u623F\u4EA7\u662F\u5426\u66FE\u88AB\u8BB0\u5F55\u4E3A\u516C\u5171\u4F4F\u623F',
'Current energy rating': '\u5F53\u524DEPC\u80FD\u6548\u8BC4\u7EA7\uFF08A = \u6700\u4F73\uFF0CG = \u6700\u5DEE\uFF09',
'Potential energy rating': '\u5B9E\u65BD\u6240\u6709\u5EFA\u8BAE\u6539\u8FDB\u540E\u7684\u6F5C\u5728EPC\u8BC4\u7EA7',
'Interior height (m)': 'EPC\u8BC4\u4F30\u7684\u5E73\u5747\u5C42\u9AD8',
'Distance to nearest train or tube station (km)': '\u5230\u6700\u8FD1\u706B\u8F66\u6216\u5730\u94C1\u7AD9\u7684\u8DDD\u79BB',
'Train or tube stations within 1km': '1\u516C\u91CC\u5185\u706B\u8F66\u6216\u5730\u94C1\u7AD9\u7684\u6570\u91CF',
'Good+ primary schools within 2km': 'Ofsted\u8BC4\u4E3A\u826F\u597D\u6216\u4F18\u79C0\u76842\u516C\u91CC\u5185\u5C0F\u5B66',
'Good+ secondary schools within 2km': 'Ofsted\u8BC4\u4E3A\u826F\u597D\u6216\u4F18\u79C0\u76842\u516C\u91CC\u5185\u4E2D\u5B66',
'Good+ primary schools within 5km': 'Ofsted\u8BC4\u4E3A\u826F\u597D\u6216\u4F18\u79C0\u76845\u516C\u91CC\u5185\u5C0F\u5B66',
'Good+ secondary schools within 5km': 'Ofsted\u8BC4\u4E3A\u826F\u597D\u6216\u4F18\u79C0\u76845\u516C\u91CC\u5185\u4E2D\u5B66',
'Education, Skills and Training Score': '\u5F53\u5730\u6559\u80B2\u8D28\u91CF\u5F97\u5206\uFF08\u8D8A\u9AD8\u8D8A\u597D\uFF09',
'Income Score (rate)': '\u6536\u5165\u8D2B\u56F0\u7387\uFF0C\u53CD\u5411\u6307\u6807\uFF08\u8D8A\u9AD8\u8D8A\u4E0D\u8D2B\u56F0\uFF09',
'Employment Score (rate)': '\u5C31\u4E1A\u8D2B\u56F0\u7387\uFF0C\u53CD\u5411\u6307\u6807\uFF08\u8D8A\u9AD8\u8D8A\u4E0D\u8D2B\u56F0\uFF09',
'Health Deprivation and Disability Score': '\u5065\u5EB7\u4E0E\u6B8B\u969C\u5F97\u5206\uFF08\u8D8A\u9AD8\u5065\u5EB7\u72B6\u51B5\u8D8A\u597D\uFF09',
'Living Environment Score': '\u5BA4\u5185\u5916\u73AF\u5883\u8D28\u91CF\uFF08\u8D8A\u9AD8\u8D8A\u597D\uFF09',
'Indoors Sub-domain Score': '\u4F4F\u623F\u8D28\u91CF\u548C\u72B6\u51B5\uFF08\u8D8A\u9AD8\u8D8A\u597D\uFF09',
'Outdoors Sub-domain Score': '\u7A7A\u6C14\u8D28\u91CF\u548C\u9053\u8DEF\u5B89\u5168\uFF08\u8D8A\u9AD8\u8D8A\u597D\uFF09',
'Serious crime per 1k residents (avg/yr)': '\u6BCF\u5343\u4EBA\u6BCF\u5E74\u4E25\u91CD\u72AF\u7F6A\u7387',
'Minor crime per 1k residents (avg/yr)': '\u6BCF\u5343\u4EBA\u6BCF\u5E74\u8F7B\u5FAE\u72AF\u7F6A\u7387',
'Serious crime (avg/yr)': '\u4E25\u91CD\u72AF\u7F6A\u7C7B\u522B\u5E74\u5EA6\u603B\u8BA1',
'Minor crime (avg/yr)': '\u8F7B\u5FAE\u72AF\u7F6A\u7C7B\u522B\u5E74\u5EA6\u603B\u8BA1',
'Violence and sexual offences (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u66B4\u529B\u548C\u6027\u72AF\u7F6A\u6570',
'Burglary (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5165\u5BA4\u76D7\u7A83\u6570',
'Robbery (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u62A2\u52AB\u6570',
'Vehicle crime (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u8F66\u8F86\u72AF\u7F6A\u6570',
'Anti-social behaviour (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u53CD\u793E\u4F1A\u884C\u4E3A\u6570',
'Criminal damage and arson (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5211\u4E8B\u6BC1\u574F\u548C\u7EB5\u706B\u6570',
'Other theft (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5176\u4ED6\u76D7\u7A83\u6570',
'Theft from the person (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u4EBA\u8EAB\u76D7\u7A83\u6570',
'Shoplifting (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5546\u5E97\u76D7\u7A83\u6570',
'Bicycle theft (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u81EA\u884C\u8F66\u76D7\u7A83\u6570',
'Drugs (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u6BD2\u54C1\u72AF\u7F6A\u6570',
'Possession of weapons (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u975E\u6CD5\u6301\u6709\u6B66\u5668\u6570',
'Public order (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u6270\u4E71\u516C\u5171\u79E9\u5E8F\u6570',
'Other crime (avg/yr)': '\u8BE5\u5730\u533A\u5E74\u5747\u5176\u4ED6\u72AF\u7F6A\u6570',
'Median age': '\u5F53\u5730\u4EBA\u53E3\u7684\u4E2D\u4F4D\u5E74\u9F84',
'% White': '\u767D\u4EBA\u4EBA\u53E3\u6BD4\u4F8B',
'% South Asian': '\u5357\u4E9A\u88D4\u4EBA\u53E3\u6BD4\u4F8B',
'% Black': '\u9ED1\u4EBA\u4EBA\u53E3\u6BD4\u4F8B',
'% East Asian': '\u4E1C\u4E9A\u88D4\u4EBA\u53E3\u6BD4\u4F8B',
'% Mixed': '\u6DF7\u8840\u6216\u591A\u65CF\u88D4\u4EBA\u53E3\u6BD4\u4F8B',
'% Other': '\u5176\u4ED6\u65CF\u88D4\u4EBA\u53E3\u6BD4\u4F8B',
'Distance to nearest park (km)': '\u5230\u6700\u8FD1\u516C\u56ED\u6216\u7EFF\u5730\u7684\u8DDD\u79BB',
'Number of parks within 2km': '2\u516C\u91CC\u5185\u516C\u56ED\u548C\u7EFF\u5730\u6570\u91CF',
'Number of restaurants within 2km': '2\u516C\u91CC\u5185\u9910\u5385\u548C\u5496\u5561\u9986\u6570\u91CF',
'Number of grocery shops and supermarkets within 2km': '2\u516C\u91CC\u5185\u98DF\u54C1\u5E97\u548C\u8D85\u5E02\u6570\u91CF',
'Noise (dB)': '\u8BE5\u90AE\u7F16\u7684\u9053\u8DEF\u566A\u97F3\u6C34\u5E73\uFF08\u5206\u8D1D\uFF0CLden\uFF09',
'Max available download speed (Mbps)': '\u8BE5\u90AE\u7F16\u53EF\u7528\u7684\u6700\u5927\u5BBD\u5E26\u4E0B\u8F7D\u901F\u5EA6',
},
hu: {
'Listing status': 'Az ingatlan kor\u00E1bbi elad\u00E1sb\u00F3l sz\u00E1rmazik, jelenleg elad\u00F3 vagy kiad\u00F3',
'Property type': 'Ingatlant\u00EDpus: k\u00FCl\u00F6n\u00E1ll\u00F3, ikerh\u00E1z, sorh\u00E1z, lak\u00E1s vagy egy\u00E9b',
'Leasehold/Freehold': 'Az ingatlan b\u00E9rleti jog\u00FA vagy teljes tulajdon\u00FA',
'Last known price': 'A Land Registry-ben r\u00F6gz\u00EDtett utols\u00F3 elad\u00E1si \u00E1r',
'Estimated current price': 'Infl\u00E1ci\u00F3val korrig\u00E1lt becs\u00FClt jelenlegi \u00E9rt\u00E9k',
'Asking price': 'A jelenleg elad\u00E1sra k\u00EDn\u00E1lt ingatlanok ir\u00E1ny\u00E1ra',
'Price per sqm': 'Elad\u00E1si \u00E1r osztva az \u00F6sszes alapter\u00FClettel',
'Est. price per sqm': 'Becs\u00FClt jelenlegi \u00E1r osztva az \u00F6sszes alapter\u00FClettel',
'Asking price per sqm': 'Ir\u00E1ny\u00E1r osztva az \u00F6sszes alapter\u00FClettel',
'Estimated monthly rent': 'A k\u00F6rny\u00E9k medi\u00E1n havi mag\u00E1nb\u00E9rleti d\u00EDja',
'Asking rent (monthly)': 'A kiad\u00F3 ingatlanok hirdetett havi b\u00E9rleti d\u00EDja',
'Total floor area (sqm)': 'Az EPC felm\u00E9r\u00E9sb\u0151l sz\u00E1rmaz\u00F3 bels\u0151 alapter\u00FClet',
'Number of bedrooms & living rooms': 'Lak\u00F3szob\u00E1k sz\u00E1ma az EPC felm\u00E9r\u00E9s alapj\u00E1n',
'Bedrooms': 'H\u00E1l\u00F3szob\u00E1k sz\u00E1ma az online hirdet\u00E9s szerint',
'Bathrooms': 'F\u00FCrd\u0151szob\u00E1k sz\u00E1ma az online hirdet\u00E9s szerint',
'Construction year': 'Becs\u00FClt \u00E9p\u00EDt\u00E9si \u00E9v az EPC alapj\u00E1n',
'Date of last transaction': 'Az utols\u00F3 elad\u00E1s d\u00E1tuma a Land Registry szerint',
'Listing date': 'Az ingatlan els\u0151 online megjelen\u00E9s\u00E9nek d\u00E1tuma',
'Former council house': 'Az ingatlan szerepelt-e valaha \u00F6nkorm\u00E1nyzati lak\u00E1sk\u00E9nt',
'Current energy rating': 'Jelenlegi EPC energiabesorol\u00E1s (A = legjobb, G = legrosszabb)',
'Potential energy rating': 'Potenci\u00E1lis EPC besorol\u00E1s az \u00F6sszes javasolt fejleszt\u00E9s elv\u00E9gz\u00E9se ut\u00E1n',
'Interior height (m)': '\u00C1tlagos belmagass\u00E1g az EPC felm\u00E9r\u00E9s alapj\u00E1n',
'Distance to nearest train or tube station (km)': 'T\u00E1vols\u00E1g a legk\u00F6zelebbi vas\u00FAt- vagy metr\u00F3\u00E1llom\u00E1sig',
'Train or tube stations within 1km': 'Vas\u00FAt- vagy metr\u00F3\u00E1llom\u00E1sok sz\u00E1ma 1 km-en bel\u00FCl',
'Good+ primary schools within 2km': 'Ofsted \u00E1ltal J\u00F3 vagy Kiv\u00E1l\u00F3 min\u0151s\u00EDt\u00E9s\u0171 \u00E1ltal\u00E1nos iskol\u00E1k 2 km-en bel\u00FCl',
'Good+ secondary schools within 2km': 'Ofsted \u00E1ltal J\u00F3 vagy Kiv\u00E1l\u00F3 min\u0151s\u00EDt\u00E9s\u0171 k\u00F6z\u00E9piskol\u00E1k 2 km-en bel\u00FCl',
'Good+ primary schools within 5km': 'Ofsted \u00E1ltal J\u00F3 vagy Kiv\u00E1l\u00F3 min\u0151s\u00EDt\u00E9s\u0171 \u00E1ltal\u00E1nos iskol\u00E1k 5 km-en bel\u00FCl',
'Good+ secondary schools within 5km': 'Ofsted \u00E1ltal J\u00F3 vagy Kiv\u00E1l\u00F3 min\u0151s\u00EDt\u00E9s\u0171 k\u00F6z\u00E9piskol\u00E1k 5 km-en bel\u00FCl',
'Education, Skills and Training Score': 'A k\u00F6rny\u00E9k oktat\u00E1si min\u0151s\u00E9gi pontsz\u00E1ma (magasabb = jobb)',
'Income Score (rate)': 'J\u00F6vedelmi depriv\u00E1ci\u00F3s r\u00E1ta, invert\u00E1lva (magasabb = kev\u00E9sb\u00E9 h\u00E1tr\u00E1nyos)',
'Employment Score (rate)': 'Foglalkoztat\u00E1si depriv\u00E1ci\u00F3s r\u00E1ta, invert\u00E1lva (magasabb = kev\u00E9sb\u00E9 h\u00E1tr\u00E1nyos)',
'Health Deprivation and Disability Score': 'Eg\u00E9szs\u00E9g\u00FCgyi \u00E9s fogyat\u00E9koss\u00E1gi pontsz\u00E1m (magasabb = jobb eredm\u00E9nyek)',
'Living Environment Score': 'Bels\u0151 \u00E9s k\u00FCls\u0151 k\u00F6rnyezet min\u0151s\u00E9ge (magasabb = jobb)',
'Indoors Sub-domain Score': 'Lak\u00E1smin\u0151s\u00E9g \u00E9s \u00E1llapot (magasabb = jobb)',
'Outdoors Sub-domain Score': 'Leveg\u0151min\u0151s\u00E9g \u00E9s k\u00F6zleked\u00E9sbiztons\u00E1g (magasabb = jobb)',
'Serious crime per 1k residents (avg/yr)': 'S\u00FAlyos b\u0171ncselekm\u00E9nyek ar\u00E1nya 1000 lakosra \u00E9vente',
'Minor crime per 1k residents (avg/yr)': 'Kisebb b\u0171ncselekm\u00E9nyek ar\u00E1nya 1000 lakosra \u00E9vente',
'Serious crime (avg/yr)': 'S\u00FAlyos b\u0171ncselekm\u00E9nyi kateg\u00F3ri\u00E1k \u00E9ves \u00F6sszes\u00EDt\u00E9se',
'Minor crime (avg/yr)': 'Kisebb b\u0171ncselekm\u00E9nyi kateg\u00F3ri\u00E1k \u00E9ves \u00F6sszes\u00EDt\u00E9se',
'Violence and sexual offences (avg/yr)': 'Er\u0151szakos \u00E9s szexu\u00E1lis b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Burglary (avg/yr)': 'Bet\u00F6r\u00E9sek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Robbery (avg/yr)': 'Rabl\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Vehicle crime (avg/yr)': 'G\u00E9pj\u00E1rm\u0171vel kapcsolatos b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Anti-social behaviour (avg/yr)': 'K\u00F6z\u00F6ss\u00E9gellenes magatart\u00E1s \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Criminal damage and arson (avg/yr)': 'Rong\u00E1l\u00E1s \u00E9s gy\u00FAjtogat\u00E1s \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Other theft (avg/yr)': 'Egy\u00E9b lop\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Theft from the person (avg/yr)': 'Szem\u00E9lyek elleni lop\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Shoplifting (avg/yr)': 'Bolti lop\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Bicycle theft (avg/yr)': 'Ker\u00E9kp\u00E1rlop\u00E1sok \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Drugs (avg/yr)': 'K\u00E1b\u00EDt\u00F3szerrel kapcsolatos b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Possession of weapons (avg/yr)': 'Fegyvertart\u00E1ssal kapcsolatos b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Public order (avg/yr)': 'K\u00F6zrend elleni b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Other crime (avg/yr)': 'Egy\u00E9b b\u0171ncselekm\u00E9nyek \u00E9ves \u00E1tlaga a k\u00F6rny\u00E9ken',
'Median age': 'A helyi lakoss\u00E1g medi\u00E1n \u00E9letkora',
'% White': 'A feh\u00E9rk\u00E9nt azonos\u00EDtott lakoss\u00E1g ar\u00E1nya',
'% South Asian': 'A d\u00E9l-\u00E1zsiaiként azonos\u00EDtott lakoss\u00E1g ar\u00E1nya',
'% Black': 'A feketek\u00E9nt azonos\u00EDtott lakoss\u00E1g ar\u00E1nya',
'% East Asian': 'A kelet-\u00E1zsiaiként azonos\u00EDtott lakoss\u00E1g ar\u00E1nya',
'% Mixed': 'A vegyes vagy t\u00F6bb etnikai csoporthoz tartoz\u00F3k\u00E9nt azonos\u00EDtott lakoss\u00E1g ar\u00E1nya',
'% Other': 'Az egy\u00E9b etnikai csoportba tartoz\u00F3k\u00E9nt azonos\u00EDtott lakoss\u00E1g ar\u00E1nya',
'Distance to nearest park (km)': 'T\u00E1vols\u00E1g a legk\u00F6zelebbi parkig vagy z\u00F6ldter\u00FCletig',
'Number of parks within 2km': 'Parkok \u00E9s z\u00F6ldter\u00FCletek sz\u00E1ma 2 km-en bel\u00FCl',
'Number of restaurants within 2km': '\u00C9ttermek \u00E9s k\u00E1v\u00E9z\u00F3k sz\u00E1ma 2 km-en bel\u00FCl',
'Number of grocery shops and supermarkets within 2km': '\u00C9lelmiszerboltok \u00E9s szupermarketek sz\u00E1ma 2 km-en bel\u00FCl',
'Noise (dB)': 'K\u00F6z\u00FAti zajszint az ir\u00E1ny\u00EDt\u00F3sz\u00E1mn\u00E1l decibelben (Lden)',
'Max available download speed (Mbps)': 'Az ir\u00E1ny\u00EDt\u00F3sz\u00E1mn\u00E1l el\u00E9rhet\u0151 maxim\u00E1lis sz\u00E9less\u00E1v\u00FA let\u00F6lt\u00E9si sebess\u00E9g',
},
};
/**
* 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
View file

@ -0,0 +1,11 @@
import 'i18next';
import type en from './locales/en';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'translation';
resources: {
translation: typeof en;
};
}
}

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

View file

@ -591,6 +591,42 @@ const hu: Translations = {
savedProperty: 'Mentve',
},
// ── Format / Time ──────────────────────────────────
format: {
justNow: 'az im\u00E9nt',
minutesAgo: '{{count}} perce',
hoursAgo: '{{count}} \u00F3r\u00E1ja',
daysAgo: '{{count}} napja',
nFilters: '{{count}} sz\u0171r\u0151',
noFilters: 'Nincs sz\u0171r\u0151',
poiCategory: '{{count}} POI-kateg\u00F3ria',
poiCategories: '{{count}} POI-kateg\u00F3ria',
travelDestination: '{{count}} utaz\u00E1si c\u00E9l',
travelDestinations: '{{count}} utaz\u00E1si c\u00E9l',
propertiesMatch: '{{count}} ingatlan megfelel',
setFilters: '{{count}} sz\u0171r\u0151 be\u00E1ll\u00EDt\u00E1sa: {{list}}',
noFiltersSet: 'Nincs sz\u0171r\u0151 be\u00E1ll\u00EDtva',
toDestination: '{{mode}} ide: {{label}} {{bounds}}',
lessThanMin: '< {{max}} perc',
moreThanMin: '> {{min}} perc',
},
// ── Tutorial ──────────────────────────────────────
tutorial: {
step1Title: 'Mondja el a t\u00E9rk\u00E9pnek, mi fontos',
step1Content: '\u00C1ll\u00EDtsa be a k\u00F6lts\u00E9gvet\u00E9st, maximalis ingaz\u00E1si id\u0151t, iskola min\u0151s\u00E9get \u00E9s b\u0171n\u00F6z\u00E9si k\u00FAsz\u00F6b\u00F6t. Ami \u00D6nnek fontos. Csak a megfelel\u0151 ter\u00FCletek maradnak kiemelve. Haszn\u00E1lja a szem ikont b\u00E1rmely jellemz\u0151 szerinti sz\u00EDnez\u00E9shez.',
step2Title: 'Vagy egyszer\u0171en \u00EDrja le',
step2Content: '\u00CDrja le magyarul, mit keres, p\u00E9ld\u00E1ul \u201Ecsendes ter\u00FClet j\u00F3 iskol\u00E1k k\u00F6zel\u00E9ben \u00A3400k alatt\u201D, \u00E9s be\u00E1ll\u00EDtjuk a sz\u0171r\u0151ket \u00D6nnek.',
step3Title: 'Fedezze fel, mi van odakint',
step3Content: 'G\u00F6rgessen \u00E9s nagy\u00EDtson Anglia-szerte. Kattintson b\u00E1rmely sz\u00EDnes ter\u00FCletre a b\u0171n\u00F6z\u00E9s, iskol\u00E1k, \u00E1rak, sz\u00E9less\u00E1v, zaj \u00E9s egy\u00E9b adatok megtekint\u00E9s\u00E9hez.',
step4Title: 'Ugr\u00E1s egy helyre',
step4Content: 'Keressen r\u00E1 b\u00E1rmely helyre vagy ir\u00E1ny\u00EDt\u00F3sz\u00E1mra, hogy azonnal odajusson.',
step5Title: 'Mer\u00FClj\u00F6n el a r\u00E9szletekben',
step5Content: 'Tekintse meg a ter\u00FCleti statisztik\u00E1kat, hisztogramokat \u00E9s az egyes ingatlanadatokat: \u00E1rak, alapter\u00FClet, energetikai besorol\u00E1s \u00E9s t\u00F6bb.',
step6Title: 'Mi van a k\u00F6zelben?',
step6Content: 'Kapcsolja be az iskol\u00E1kat, \u00FCzleteket, \u00E1llom\u00E1sokat, parkokat \u00E9s \u00E9ttermeket a t\u00E9rk\u00E9pen, hogy l\u00E1ssa, mi \u00E9rhet\u0151 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.

View file

@ -590,6 +590,42 @@ const zh: Translations = {
savedProperty: '已收藏',
},
// ── Format / Time ──────────────────────────────────
format: {
justNow: '\u521A\u521A',
minutesAgo: '{{count}}\u5206\u949F\u524D',
hoursAgo: '{{count}}\u5C0F\u65F6\u524D',
daysAgo: '{{count}}\u5929\u524D',
nFilters: '{{count}} \u4E2A\u7B5B\u9009',
noFilters: '\u65E0\u7B5B\u9009',
poiCategory: '{{count}} \u4E2A POI \u7C7B\u522B',
poiCategories: '{{count}} \u4E2A POI \u7C7B\u522B',
travelDestination: '{{count}} \u4E2A\u51FA\u884C\u76EE\u7684\u5730',
travelDestinations: '{{count}} \u4E2A\u51FA\u884C\u76EE\u7684\u5730',
propertiesMatch: '{{count}} \u5957\u623F\u4EA7\u7B26\u5408',
setFilters: '\u8BBE\u7F6E {{count}} \u4E2A\u7B5B\u9009\uFF1A{{list}}',
noFiltersSet: '\u672A\u8BBE\u7F6E\u7B5B\u9009',
toDestination: '{{mode}}\u5230 {{label}} {{bounds}}',
lessThanMin: '< {{max}} \u5206\u949F',
moreThanMin: '> {{min}} \u5206\u949F',
},
// ── Tutorial ──────────────────────────────────────
tutorial: {
step1Title: '\u544A\u8BC9\u5730\u56FE\u4EC0\u4E48\u91CD\u8981',
step1Content: '\u8BBE\u7F6E\u9884\u7B97\u3001\u901A\u52E4\u4E0A\u9650\u3001\u5B66\u6821\u8D28\u91CF\u3001\u72AF\u7F6A\u95E8\u69DB\u3002\u60A8\u5173\u5FC3\u7684\u4E00\u5207\u3002\u53EA\u6709\u7B26\u5408\u6761\u4EF6\u7684\u533A\u57DF\u4F1A\u4FDD\u6301\u9AD8\u4EAE\u3002\u4F7F\u7528\u773C\u775B\u56FE\u6807\u6309\u4EFB\u610F\u7279\u5F81\u7740\u8272\u3002',
step2Title: '\u6216\u8005\u76F4\u63A5\u63CF\u8FF0',
step2Content: '\u7528\u4E2D\u6587\u8F93\u5165\u60A8\u7684\u9700\u6C42\uFF0C\u4F8B\u5982\u201C\u5B89\u9759\u7684\u5730\u533A\uFF0C\u9760\u8FD1\u597D\u5B66\u6821\uFF0C\u00A3400k \u4EE5\u4E0B\u201D\uFF0C\u6211\u4EEC\u4F1A\u4E3A\u60A8\u8BBE\u7F6E\u7B5B\u9009\u3002',
step3Title: '\u63A2\u7D22\u73B0\u6709\u4F4F\u5B85',
step3Content: '\u5728\u82F1\u683C\u5170\u5404\u5730\u5E73\u79FB\u548C\u7F29\u653E\u3002\u70B9\u51FB\u4EFB\u4F55\u5F69\u8272\u533A\u57DF\u67E5\u770B\u72AF\u7F6A\u3001\u5B66\u6821\u3001\u4EF7\u683C\u3001\u5BBD\u5E26\u3001\u566A\u97F3\u7B49\u4FE1\u606F\u3002',
step4Title: '\u8DF3\u8F6C\u5230\u67D0\u4E2A\u4F4D\u7F6E',
step4Content: '\u641C\u7D22\u4EFB\u4F55\u5730\u70B9\u6216\u90AE\u7F16\uFF0C\u5373\u53EF\u76F4\u63A5\u8DF3\u8F6C\u3002',
step5Title: '\u6DF1\u5165\u4E86\u89E3\u7EC6\u8282',
step5Content: '\u67E5\u770B\u533A\u57DF\u7EDF\u8BA1\u3001\u76F4\u65B9\u56FE\u548C\u5355\u4E2A\u623F\u4EA7\u8BB0\u5F55\uFF1A\u4EF7\u683C\u3001\u5EFA\u7B51\u9762\u79EF\u3001\u80FD\u6548\u8BC4\u7EA7\u7B49\u3002',
step6Title: '\u9644\u8FD1\u6709\u4EC0\u4E48\uFF1F',
step6Content: '\u5728\u5730\u56FE\u4E0A\u5F00\u542F\u5B66\u6821\u3001\u5546\u5E97\u3001\u8F66\u7AD9\u3001\u516C\u56ED\u548C\u9910\u5385\u56FE\u5C42\uFF0C\u67E5\u770B\u5468\u8FB9\u8BBE\u65BD\u3002',
},
// ── 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.

View 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';

View file

@ -1,5 +1,6 @@
import { createRoot, hydrateRoot } from 'react-dom/client';
import App from './App';
import './i18n';
import './index.css';
import './hooks/usePlausible';

View file

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

View file

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

View file

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