Translate pages
This commit is contained in:
parent
a7aaf5effa
commit
96402228e3
49 changed files with 1458 additions and 926 deletions
|
|
@ -1,24 +1,12 @@
|
|||
import { memo, useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { memo, useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { SparklesIcon } from '../ui/icons/SparklesIcon';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
import type { AiFilterErrorType } from '../../hooks/useAiFilters';
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
'Safe area near good schools',
|
||||
'30 min commute to Kings Cross, under \u00A3500k',
|
||||
'Quiet village, 3 bed, fast broadband',
|
||||
];
|
||||
|
||||
const LOADING_MESSAGES = [
|
||||
'Analysing your query...',
|
||||
'Searching for destinations...',
|
||||
'Generating filters...',
|
||||
'Refining results...',
|
||||
];
|
||||
|
||||
/** Cycle through loading messages to show progress. */
|
||||
function useLoadingMessage(loading: boolean): string {
|
||||
function useLoadingMessage(loading: boolean, messages: string[]): string {
|
||||
const [index, setIndex] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
|
|
@ -38,7 +26,7 @@ function useLoadingMessage(loading: boolean): string {
|
|||
};
|
||||
}, [loading]);
|
||||
|
||||
return LOADING_MESSAGES[index];
|
||||
return messages[index];
|
||||
}
|
||||
|
||||
interface AiFilterInputProps {
|
||||
|
|
@ -62,9 +50,12 @@ export default memo(function AiFilterInput({
|
|||
isLoggedIn,
|
||||
onLoginRequired,
|
||||
}: AiFilterInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState('');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const loadingMessage = useLoadingMessage(loading);
|
||||
const exampleQueries = useMemo(() => [t('aiFilter.example1'), t('aiFilter.example2'), t('aiFilter.example3')], [t]);
|
||||
const loadingMessages = useMemo(() => [t('aiFilter.analysing'), t('aiFilter.searchingDestinations'), t('aiFilter.generatingFilters'), t('aiFilter.refiningResults')], [t]);
|
||||
const loadingMessage = useLoadingMessage(loading, loadingMessages);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
|
|
@ -145,7 +136,7 @@ export default memo(function AiFilterInput({
|
|||
>
|
||||
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
|
||||
Describe your ideal area with AI
|
||||
{t('aiFilter.describeIdealArea')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -156,9 +147,9 @@ export default memo(function AiFilterInput({
|
|||
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">AI Search</span>
|
||||
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">{t('aiFilter.aiSearch')}</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
describe what you're looking for
|
||||
{t('aiFilter.describeHint')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -177,7 +168,7 @@ export default memo(function AiFilterInput({
|
|||
resizeTextarea();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="e.g. quiet area, under £400k, near good schools..."
|
||||
placeholder={t('aiFilter.placeholder')}
|
||||
className="flex-1 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400 focus:bg-white dark:focus:bg-warm-800 resize-none overflow-hidden"
|
||||
rows={1}
|
||||
style={{ maxHeight: '6rem' }}
|
||||
|
|
@ -194,7 +185,7 @@ export default memo(function AiFilterInput({
|
|||
) : (
|
||||
<>
|
||||
<SparklesIcon className="w-3.5 h-3.5" />
|
||||
<span>Search</span>
|
||||
<span>{t('common.search')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -202,7 +193,7 @@ export default memo(function AiFilterInput({
|
|||
{loading && <p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{loadingMessage}</p>}
|
||||
{showExamples && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{EXAMPLE_QUERIES.map((example) => (
|
||||
{exampleQueries.map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
type="button"
|
||||
|
|
@ -216,7 +207,7 @@ export default memo(function AiFilterInput({
|
|||
)}
|
||||
{error && errorType === 'limit' && (
|
||||
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
You've reached the weekly AI usage limit. It will reset automatically next week.
|
||||
{t('aiFilter.weeklyLimitReached')}
|
||||
</p>
|
||||
)}
|
||||
{error && errorType === 'error' && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import type {
|
||||
FeatureFilters,
|
||||
FeatureMeta,
|
||||
|
|
@ -56,6 +58,7 @@ export default function AreaPane({
|
|||
isGroupExpanded,
|
||||
onToggleGroup,
|
||||
}: AreaPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
|
|
@ -79,8 +82,8 @@ export default function AreaPane({
|
|||
return (
|
||||
<EmptyState
|
||||
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title="No area selected"
|
||||
description="Click any coloured area on the map to see crime, schools, prices, and more"
|
||||
title={t('common.noAreaSelected')}
|
||||
description={t('common.noAreaSelectedDesc')}
|
||||
centered
|
||||
/>
|
||||
);
|
||||
|
|
@ -93,10 +96,10 @@ export default function AreaPane({
|
|||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold dark:text-warm-100">
|
||||
{isPostcode ? hexagonId : 'Area Statistics'}
|
||||
{isPostcode ? hexagonId : t('areaPane.areaStatistics')}
|
||||
</h2>
|
||||
{isPostcode && (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Postcode</span>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">{t('common.postcode')}</span>
|
||||
)}
|
||||
</div>
|
||||
{loading && stats && (
|
||||
|
|
@ -105,19 +108,19 @@ export default function AreaPane({
|
|||
</div>
|
||||
{propertyCount != null && (
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||
{propertyCount.toLocaleString()} properties
|
||||
{propertyCount.toLocaleString()} {t('common.propertiesPlural')}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
|
||||
Stats for all properties in this {isPostcode ? 'postcode' : 'area'}
|
||||
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
|
||||
{t('areaPane.statsFor', { type: isPostcode ? t('common.postcode').toLowerCase() : t('common.area').toLowerCase() })}
|
||||
{Object.keys(filters).length > 0 ? t('areaPane.matchingFilters') : ''}
|
||||
</p>
|
||||
{stats && stats.count > 0 && (
|
||||
<button
|
||||
onClick={onViewProperties}
|
||||
className="mt-2 w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
|
||||
>
|
||||
View {stats.count.toLocaleString()} Properties
|
||||
{t('areaPane.viewProperties', { count: stats.count })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -147,7 +150,7 @@ export default function AreaPane({
|
|||
return uniqueYears.size > 1;
|
||||
})() && (
|
||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">{t('areaPane.priceHistory')}</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -202,19 +205,19 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
className="mr-2"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{chart.label}
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
|
|
@ -308,7 +311,7 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
|
|
@ -320,7 +323,7 @@ export default function AreaPane({
|
|||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
|
||||
{chart.label}
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
|
||||
|
|
@ -349,18 +352,18 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<div
|
||||
key={chart.label}
|
||||
key={ts(chart.label)}
|
||||
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
|
||||
>
|
||||
<div className="mb-1.5">
|
||||
{featureMeta ? (
|
||||
<FeatureLabel
|
||||
feature={{ ...featureMeta, name: chart.label }}
|
||||
feature={{ ...featureMeta, name: ts(chart.label) }}
|
||||
onShowInfo={setInfoFeature}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{chart.label}
|
||||
{ts(chart.label)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import {
|
||||
buildPropertySearchUrls,
|
||||
|
|
@ -23,6 +24,7 @@ export default function ExternalSearchLinks({
|
|||
location: HexagonLocation;
|
||||
filters: FeatureFilters;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rightmoveLocationId = getRightmoveLocationId(location.postcode);
|
||||
const urls = useMemo(
|
||||
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
|
||||
|
|
@ -41,7 +43,7 @@ export default function ExternalSearchLinks({
|
|||
return (
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
|
||||
Search {label} on
|
||||
{t('externalSearch.searchOn', { radius: label })}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{urls.rightmove ? (
|
||||
|
|
@ -49,7 +51,7 @@ export default function ExternalSearchLinks({
|
|||
Rightmove
|
||||
</a>
|
||||
) : (
|
||||
<span className={disabledClass} title="Outcode not recognised">
|
||||
<span className={disabledClass} title={t('externalSearch.outcodeNotRecognised')}>
|
||||
Rightmove
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { Fragment, memo, useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { ChevronIcon, LightbulbIcon } from '../ui/icons';
|
||||
|
||||
import { 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>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,27 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function HistogramLegend() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-3 mt-3 bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-teal-500 dark:bg-teal-400 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Teal bars</span> show the
|
||||
distribution in this selected area
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.tealBars')}</span> {t('histogramLegend.tealBarsDesc')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-warm-300/60 dark:bg-warm-600/60 rounded" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Grey bars</span> show the
|
||||
overall distribution across all areas
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.greyBars')}</span> {t('histogramLegend.greyBarsDesc')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-px border-t border-dashed border-warm-500 dark:border-warm-400" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">Dashed line</span>{' '}
|
||||
indicates the national average
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{t('histogramLegend.dashedLine')}</span>{' '}
|
||||
{t('histogramLegend.dashedLineDesc')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FeatureFilters, FeatureMeta } from '../../types';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { ts } from '../../i18n/server';
|
||||
|
||||
interface HoverCardData {
|
||||
count: number;
|
||||
|
|
@ -26,6 +28,7 @@ export default memo(function HoverCard({
|
|||
filters,
|
||||
features,
|
||||
}: HoverCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const activeFilterNames = Object.keys(filters);
|
||||
|
||||
const featureMap = useMemo(() => new Map(features.map((f) => [f.name, f])), [features]);
|
||||
|
|
@ -43,7 +46,7 @@ export default memo(function HoverCard({
|
|||
const meta = featureMap.get(name);
|
||||
if (meta?.type === 'enum' && meta.values) {
|
||||
const label = meta.values[Math.round(val)];
|
||||
if (label) results.push({ name, value: label });
|
||||
if (label) results.push({ name, value: ts(label) });
|
||||
} else {
|
||||
results.push({ name, value: formatValue(val, meta) });
|
||||
}
|
||||
|
|
@ -85,14 +88,14 @@ export default memo(function HoverCard({
|
|||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="font-semibold text-navy-950 dark:text-white truncate">
|
||||
{isPostcode ? id : 'Area'}
|
||||
{isPostcode ? id : t('common.area')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Property count */}
|
||||
{count != null && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-300 mb-2">
|
||||
{count.toLocaleString()} {count === 1 ? 'property' : 'properties'}
|
||||
{count.toLocaleString()} {count === 1 ? t('common.property') : t('common.propertiesPlural')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -101,7 +104,7 @@ export default memo(function HoverCard({
|
|||
<div className="space-y-1 border-t border-warm-200 dark:border-warm-700 pt-2">
|
||||
{displayStats.map((stat) => (
|
||||
<div key={stat.name} className="flex justify-between gap-2 text-xs">
|
||||
<span className="text-warm-500 dark:text-warm-300 truncate">{stat.name}</span>
|
||||
<span className="text-warm-500 dark:text-warm-300 truncate">{ts(stat.name)}</span>
|
||||
<span className="font-medium text-teal-700 dark:text-teal-300 whitespace-nowrap">
|
||||
{stat.value}
|
||||
</span>
|
||||
|
|
@ -112,7 +115,7 @@ export default memo(function HoverCard({
|
|||
|
||||
{/* Hint */}
|
||||
<div className="text-[10px] text-warm-400 dark:text-warm-500 mt-2 text-center">
|
||||
Click for details
|
||||
{t('common.clickForDetails')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { JourneyLeg } from '../../types';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
|
|
@ -105,6 +106,7 @@ function RouteBadge({ mode }: { mode: string }) {
|
|||
}
|
||||
|
||||
function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const isAccess = leg.mode === 'walk' || leg.mode === 'bicycle';
|
||||
|
||||
if (isAccess) {
|
||||
|
|
@ -123,7 +125,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
<BicycleIcon className="w-3 h-3 text-warm-400 dark:text-warm-500 shrink-0" />
|
||||
)}
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">
|
||||
{leg.mode === 'walk' ? 'Walk' : 'Cycle'} · {leg.minutes} min
|
||||
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes} {t('common.min')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -143,7 +145,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
|
|||
<div className="pb-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<RouteBadge mode={leg.mode} />
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} min</span>
|
||||
<span className="text-[11px] text-warm-500 dark:text-warm-400">{leg.minutes} {t('common.min')}</span>
|
||||
</div>
|
||||
{leg.from && leg.to && (
|
||||
<div className="text-[11px] text-warm-600 dark:text-warm-300 mt-0.5">
|
||||
|
|
@ -160,6 +162,7 @@ export default function JourneyInstructions({
|
|||
entries,
|
||||
label,
|
||||
}: JourneyInstructionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
||||
|
||||
// Only transit entries with a destination set
|
||||
|
|
@ -228,7 +231,7 @@ export default function JourneyInstructions({
|
|||
return (
|
||||
<div className="mx-3 mt-2 space-y-2">
|
||||
{label && (
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400">Journeys from {label}</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400">{t('areaPane.journeysFrom', { label })}</div>
|
||||
)}
|
||||
{journeys.map((j) => {
|
||||
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
||||
|
|
@ -239,18 +242,18 @@ export default function JourneyInstructions({
|
|||
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
|
||||
To {j.label || j.slug}
|
||||
{t('areaPane.to', { destination: j.label || j.slug })}
|
||||
</span>
|
||||
{!j.loading && totalMin > 0 && (
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
{totalMin} min
|
||||
{totalMin} {t('common.min')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{j.loading ? (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="w-3 h-3 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">Loading...</span>
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">{t('common.loading')}</span>
|
||||
</div>
|
||||
) : displayLegs && displayLegs.length > 0 ? (
|
||||
<div>
|
||||
|
|
@ -263,7 +266,7 @@ export default function JourneyInstructions({
|
|||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
View on Google Maps
|
||||
{t('areaPane.viewOnGoogleMaps')}
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
|
|
@ -284,7 +287,7 @@ export default function JourneyInstructions({
|
|||
<div className="flex items-center gap-1.5 py-0.5">
|
||||
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
|
||||
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||
Walk · {j.minutes} min
|
||||
{t('areaPane.walk')} · {j.minutes} {t('common.min')}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
|
|
@ -293,7 +296,7 @@ export default function JourneyInstructions({
|
|||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
>
|
||||
View on Google Maps
|
||||
{t('areaPane.viewOnGoogleMaps')}
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
viewBox="0 0 12 12"
|
||||
|
|
@ -311,7 +314,7 @@ export default function JourneyInstructions({
|
|||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-warm-500 dark:text-warm-400">
|
||||
No journey data available
|
||||
{t('areaPane.noJourneyData')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,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"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { ts } from '../../i18n/server';
|
||||
import {
|
||||
FEATURE_GRADIENT,
|
||||
DENSITY_GRADIENT,
|
||||
|
|
@ -20,7 +22,7 @@ function EnumSwatches({ values }: { values: string[] }) {
|
|||
className="w-3 h-3 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||
/>
|
||||
<span className="text-warm-600 dark:text-warm-300 truncate">{label}</span>
|
||||
<span className="text-warm-600 dark:text-warm-300 truncate">{ts(label)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -40,7 +42,7 @@ function InlineEnumSwatches({ values }: { values: string[] }) {
|
|||
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
|
||||
/>
|
||||
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">
|
||||
{label}
|
||||
{ts(label)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -60,6 +62,7 @@ export default function MapLegend({
|
|||
inline = false,
|
||||
suffix,
|
||||
raw,
|
||||
totalCount,
|
||||
}: {
|
||||
featureLabel: string;
|
||||
range: [number, number];
|
||||
|
|
@ -71,7 +74,9 @@ export default function MapLegend({
|
|||
inline?: boolean;
|
||||
suffix?: string;
|
||||
raw?: boolean;
|
||||
totalCount?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isEnum = enumValues && enumValues.length > 0;
|
||||
const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
|
||||
const gradientStyle =
|
||||
|
|
@ -103,7 +108,7 @@ export default function MapLegend({
|
|||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
|
||||
title="Clear colour view"
|
||||
title={t('mapLegend.clearColourView')}
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
|
@ -132,7 +137,7 @@ export default function MapLegend({
|
|||
<button
|
||||
onClick={onCancel}
|
||||
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 ml-2"
|
||||
title="Clear colour view"
|
||||
title={t('mapLegend.clearColourView')}
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -149,6 +154,14 @@ export default function MapLegend({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
{totalCount != null && (
|
||||
<div className="mt-2 pt-2 border-t border-warm-200 dark:border-warm-700 text-warm-600 dark:text-warm-300 flex items-center justify-between">
|
||||
<span>{t('common.total')}</span>
|
||||
<span className="font-semibold text-navy-950 dark:text-warm-100">
|
||||
<TickerValue text={formatValue(totalCount)} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type {
|
||||
FeatureMeta,
|
||||
FeatureFilters,
|
||||
|
|
@ -30,11 +31,12 @@ import { getTutorialStyles } from '../../lib/tutorial-styles';
|
|||
import Joyride from 'react-joyride';
|
||||
import {
|
||||
useTravelTime,
|
||||
MODE_LABELS,
|
||||
useTranslatedModes,
|
||||
travelFieldKey,
|
||||
type TravelTimeInitial,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
||||
import { useFilterCounts } from '../../hooks/useFilterCounts';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
|
|
@ -67,7 +69,7 @@ interface MapPageProps {
|
|||
isMobile?: boolean;
|
||||
initialTravelTime?: TravelTimeInitial;
|
||||
initialPostcode?: string;
|
||||
user?: { id: string; subscription: string } | null;
|
||||
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
|
||||
onLoginClick?: () => void;
|
||||
onRegisterClick?: () => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
|
|
@ -127,6 +129,9 @@ export default function MapPage({
|
|||
[onSaveProperty]
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const modes = useTranslatedModes();
|
||||
|
||||
const {
|
||||
filters,
|
||||
activeFeature,
|
||||
|
|
@ -235,6 +240,8 @@ export default function MapPage({
|
|||
travelTimeEntries: travelTime.entries,
|
||||
});
|
||||
|
||||
const filterCounts = useFilterCounts(filters, features, mapData.bounds);
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
(index: number, slug: string, label: string, lat: number, lon: number) => {
|
||||
travelTime.handleSetDestination(index, slug, label);
|
||||
|
|
@ -430,6 +437,13 @@ export default function MapPage({
|
|||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||
}, [mapData.licenseRequired]);
|
||||
|
||||
const densityLabel = useMemo(() => {
|
||||
const listingVal = filters['Listing status'] as string[] | undefined;
|
||||
if (listingVal?.includes('For sale')) return 'Properties for sale';
|
||||
if (listingVal?.includes('For rent')) return 'Properties for rent';
|
||||
return 'Historical property matches';
|
||||
}, [filters]);
|
||||
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
|
|
@ -616,8 +630,10 @@ export default function MapPage({
|
|||
isLoggedIn={!!user}
|
||||
onLoginRequired={onRegisterClick ?? (() => {})}
|
||||
isLicensed={user?.subscription === 'licensed'}
|
||||
isAdmin={user?.isAdmin === true}
|
||||
onUpgradeClick={() => onNavigateTo('pricing')}
|
||||
onResetTutorial={tutorial.resetTutorial}
|
||||
filterImpacts={filterCounts.impacts}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -692,7 +708,7 @@ export default function MapPage({
|
|||
{viewFeature && mapData.colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
|
||||
featureLabel={t('travel.travelTime', { mode: modes.label(viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit') })}
|
||||
range={mapData.colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={handleCancelPin}
|
||||
|
|
@ -720,7 +736,7 @@ export default function MapPage({
|
|||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Number of properties"
|
||||
featureLabel={densityLabel}
|
||||
range={mobileDensityRange}
|
||||
showCancel={false}
|
||||
onCancel={handleCancelPin}
|
||||
|
|
@ -795,10 +811,14 @@ export default function MapPage({
|
|||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">{renderFilters()}</div>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...leftPaneHandlers}
|
||||
>
|
||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -827,6 +847,7 @@ export default function MapPage({
|
|||
onLocationSearched={handleLocationSearchResult}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
densityLabel={densityLabel}
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
|
|
@ -862,10 +883,14 @@ export default function MapPage({
|
|||
style={{ width: rightPaneWidth }}
|
||||
>
|
||||
<div
|
||||
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...rightPaneHandlers}
|
||||
>
|
||||
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
<div className="w-1 h-1 rounded-full bg-warm-300 dark:bg-navy-600 group-hover:bg-warm-400 dark:group-hover:bg-navy-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
|
||||
|
|
@ -17,6 +18,7 @@ export default function MobileDrawer({
|
|||
tab,
|
||||
onTabChange,
|
||||
}: MobileDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
@ -35,16 +37,16 @@ export default function MobileDrawer({
|
|||
<div className="h-[90%] bg-white dark:bg-navy-950 rounded-t-2xl flex flex-col shadow-xl overflow-hidden">
|
||||
{/* Tab bar + close */}
|
||||
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm shrink-0">
|
||||
<TabButton label="Area" isActive={tab === 'area'} onClick={() => onTabChange('area')} />
|
||||
<TabButton label={t('common.area')} isActive={tab === 'area'} onClick={() => onTabChange('area')} />
|
||||
<TabButton
|
||||
label="Properties"
|
||||
label={t('common.properties')}
|
||||
isActive={tab === 'properties'}
|
||||
onClick={() => onTabChange('properties')}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto flex items-center justify-center w-10 h-10 rounded-lg hover:bg-warm-100 dark:hover:bg-navy-800"
|
||||
aria-label="Close drawer"
|
||||
aria-label={t('mobileDrawer.closeDrawer')}
|
||||
>
|
||||
<CloseIcon className="w-5 h-5 text-warm-500 dark:text-warm-400" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ts } from '../../i18n/server';
|
||||
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import type { POICategoryGroup } from '../../types';
|
||||
|
|
@ -26,6 +28,7 @@ export default function POIPane({
|
|||
onNavigateToSource,
|
||||
onClose,
|
||||
}: POIPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isGroupExpanded, toggleCollapse] = useCollapsibleGroups();
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
|
@ -90,12 +93,12 @@ export default function POIPane({
|
|||
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
POIs
|
||||
{t('poiPane.pois')}
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
{selectedCount}/{allCategories.length}
|
||||
</span>
|
||||
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
|
||||
<IconButton onClick={() => setShowInfo(true)} title={t('poiPane.dataSourceInfo')}>
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
<div className="flex gap-1 ml-auto items-center">
|
||||
|
|
@ -103,19 +106,19 @@ export default function POIPane({
|
|||
onClick={selectAll}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
All
|
||||
{t('common.all')}
|
||||
</button>
|
||||
<button
|
||||
onClick={selectNone}
|
||||
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
|
||||
>
|
||||
None
|
||||
{t('common.none')}
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title="Close"
|
||||
title={t('common.close')}
|
||||
>
|
||||
<CloseIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -125,12 +128,12 @@ export default function POIPane({
|
|||
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title="Points of Interest"
|
||||
title={t('poiPane.pointsOfInterest')}
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
label: t('common.viewDataSource'),
|
||||
onClick: () => {
|
||||
onNavigateToSource('osm-pois');
|
||||
setShowInfo(false);
|
||||
|
|
@ -140,8 +143,7 @@ export default function POIPane({
|
|||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Sourced from OpenStreetMap. Covers public transport stops, shops, restaurants,
|
||||
healthcare, leisure, and more. Updated regularly with complete category coverage.
|
||||
{t('poiPane.poiDescription')}
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
@ -152,7 +154,7 @@ export default function POIPane({
|
|||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
placeholder="Search categories..."
|
||||
placeholder={t('poiPane.searchCategories')}
|
||||
/>
|
||||
</div>
|
||||
{filteredGroups.map((group) => {
|
||||
|
|
@ -171,7 +173,7 @@ export default function POIPane({
|
|||
<ChevronIcon direction="right" className="w-3 h-3" />
|
||||
</button>
|
||||
<PillToggle
|
||||
label={group.name}
|
||||
label={ts(group.name)}
|
||||
active={allInGroupSelected}
|
||||
indeterminate={someInGroupSelected}
|
||||
onClick={() => toggleGroup(group.name)}
|
||||
|
|
@ -187,7 +189,7 @@ export default function POIPane({
|
|||
{group.categories.map((category) => (
|
||||
<PillToggle
|
||||
key={category}
|
||||
label={category}
|
||||
label={ts(category)}
|
||||
active={selectedCategories.has(category)}
|
||||
onClick={() => toggleCategory(category)}
|
||||
size="xs"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Property } from '../../types';
|
||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
||||
import { getNum } from '../../lib/property-fields';
|
||||
|
|
@ -7,6 +8,7 @@ import { SearchInput } from '../ui/SearchInput';
|
|||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { InfoIcon } from '../ui/icons';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
import { ts } from '../../i18n/server';
|
||||
|
||||
interface PropertiesPaneProps {
|
||||
properties: Property[];
|
||||
|
|
@ -33,6 +35,7 @@ export function PropertiesPane({
|
|||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
}: PropertiesPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
|
|
@ -51,8 +54,8 @@ export function PropertiesPane({
|
|||
return (
|
||||
<EmptyState
|
||||
icon={<InfoIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
|
||||
title="No area selected"
|
||||
description="Click any coloured area on the map to see crime, schools, prices, and more"
|
||||
title={t('common.noAreaSelected')}
|
||||
description={t('common.noAreaSelectedDesc')}
|
||||
centered
|
||||
/>
|
||||
);
|
||||
|
|
@ -62,12 +65,12 @@ export function PropertiesPane({
|
|||
<div className="h-full overflow-y-auto">
|
||||
{showInfo && (
|
||||
<InfoPopup
|
||||
title="Property Data"
|
||||
title={t('propertyCard.propertyData')}
|
||||
onClose={() => setShowInfo(false)}
|
||||
sourceLink={
|
||||
onNavigateToSource
|
||||
? {
|
||||
label: 'View data source',
|
||||
label: t('common.viewDataSource'),
|
||||
onClick: () => {
|
||||
onNavigateToSource('epc');
|
||||
setShowInfo(false);
|
||||
|
|
@ -77,9 +80,7 @@ export function PropertiesPane({
|
|||
}
|
||||
>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
|
||||
Prices come from HM Land Registry (what buyers actually paid). Floor area, energy
|
||||
ratings, construction year, and tenure come from official EPC surveys. Both sources are
|
||||
matched by address within each postcode.
|
||||
{t('propertyCard.propertyDataDesc')}
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
|
@ -88,7 +89,7 @@ export function PropertiesPane({
|
|||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by address or postcode..."
|
||||
placeholder={t('propertyCard.searchPlaceholder')}
|
||||
className="p-2"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -117,10 +118,10 @@ export function PropertiesPane({
|
|||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
|
||||
Loading...
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
`Load More (${total - properties.length} remaining)`
|
||||
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -163,6 +164,7 @@ function PropertyCard({
|
|||
isSaved?: boolean;
|
||||
savedId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleToggleSave = useCallback(() => {
|
||||
if (isSaved && savedId && onUnsave) {
|
||||
onUnsave(savedId);
|
||||
|
|
@ -189,7 +191,7 @@ function PropertyCard({
|
|||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
{property.address || t('propertyCard.unknownAddress')}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
</div>
|
||||
|
|
@ -201,7 +203,7 @@ function PropertyCard({
|
|||
? 'text-teal-600 dark:text-teal-400'
|
||||
: 'text-warm-300 dark:text-warm-600 hover:text-warm-500 dark:hover:text-warm-400'
|
||||
}`}
|
||||
title={isSaved ? 'Unsave property' : 'Save property'}
|
||||
title={isSaved ? t('propertyCard.unsaveProperty') : t('propertyCard.saveProperty')}
|
||||
>
|
||||
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
|
||||
</button>
|
||||
|
|
@ -228,7 +230,7 @@ function PropertyCard({
|
|||
{askingRent !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(askingRent)}
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">/mo</span>
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">{t('propertyCard.perMonth')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -238,7 +240,7 @@ function PropertyCard({
|
|||
>
|
||||
{askingPrice !== undefined || askingRent !== undefined ? (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
Last sold: £{formatNumber(price)}
|
||||
{t('propertyCard.lastSold', { price: formatNumber(price) })}
|
||||
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -262,7 +264,7 @@ function PropertyCard({
|
|||
)}
|
||||
{estimatedPrice !== undefined && (
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">
|
||||
Est. value:{' '}
|
||||
{t('propertyCard.estValue')}{' '}
|
||||
<span className="font-semibold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(estimatedPrice)}
|
||||
</span>
|
||||
|
|
@ -273,65 +275,65 @@ function PropertyCard({
|
|||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
|
||||
{property.property_type && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Type:</span> {property.property_type}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.type')}</span> {ts(property.property_type)}
|
||||
</div>
|
||||
)}
|
||||
{property.built_form && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Built form:</span>{' '}
|
||||
{property.built_form}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.builtForm')}</span>{' '}
|
||||
{ts(property.built_form)}
|
||||
</div>
|
||||
)}
|
||||
{property.duration && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Tenure:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.tenure')}</span>{' '}
|
||||
{formatDuration(property.duration)}
|
||||
</div>
|
||||
)}
|
||||
{floorArea !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Floor area:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.floorArea')}</span>{' '}
|
||||
{formatNumber(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{bedrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Bedrooms:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bedrooms')}</span>{' '}
|
||||
{formatNumber(bedrooms)}
|
||||
</div>
|
||||
)}
|
||||
{bathrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Bathrooms:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.bathrooms')}</span>{' '}
|
||||
{formatNumber(bathrooms)}
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.rooms')}</span> {formatNumber(rooms)}
|
||||
</div>
|
||||
)}
|
||||
{age !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Built:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.built')}</span>{' '}
|
||||
{formatAge(age, property.is_construction_date_approximate)}
|
||||
</div>
|
||||
)}
|
||||
{property.current_energy_rating && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">EPC rating:</span>{' '}
|
||||
{property.current_energy_rating}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcRating')}</span>{' '}
|
||||
{ts(property.current_energy_rating)}
|
||||
</div>
|
||||
)}
|
||||
{property.potential_energy_rating && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">EPC potential:</span>{' '}
|
||||
{property.potential_energy_rating}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.epcPotential')}</span>{' '}
|
||||
{ts(property.potential_energy_rating)}
|
||||
</div>
|
||||
)}
|
||||
{listingDate !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Listed:</span>{' '}
|
||||
<span className="text-warm-500 dark:text-warm-400">{t('propertyCard.listed')}</span>{' '}
|
||||
{formatTransactionDate(listingDate)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -339,7 +341,7 @@ function PropertyCard({
|
|||
|
||||
{property.listing_features && property.listing_features.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Key features</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">{t('propertyCard.keyFeatures')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.listing_features.map((feature, idx) => (
|
||||
<span
|
||||
|
|
@ -355,7 +357,7 @@ function PropertyCard({
|
|||
|
||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Renovations</div>
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">{t('propertyCard.renovations')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.renovation_history.map((reno, idx) => (
|
||||
<span
|
||||
|
|
@ -378,7 +380,7 @@ function PropertyCard({
|
|||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
View external listing →
|
||||
{t('propertyCard.viewExternalListing')} →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
|
||||
|
|
@ -9,6 +10,7 @@ interface StreetViewEmbedProps {
|
|||
type Status = 'loading' | 'ok' | 'none' | 'error';
|
||||
|
||||
export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<Status>('loading');
|
||||
const [panoId, setPanoId] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ export default function StreetViewEmbed({ location }: StreetViewEmbedProps) {
|
|||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0">
|
||||
Street View
|
||||
{t('streetView.title')}
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="rounded overflow-hidden border border-warm-200 dark:border-warm-700">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue