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

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