This commit is contained in:
Andras Schmelczer 2026-05-14 22:07:14 +01:00
parent 084117cea8
commit a8de0a614d
36 changed files with 1329 additions and 522 deletions

View file

@ -81,9 +81,6 @@ const SAME_AS_EN_VALUE_ALLOWLIST = new Set([
const FORBIDDEN_VISIBLE_STRINGS = [
['without this filter', 'filters.filtersOut'],
['Connecting to server...', 'common.connectingToServer'],
['Property saved!', 'toasts.propertySaved'],
['View saved', 'toasts.viewSaved'],
["Don't show again", 'toasts.dontShowAgain'],
['Close pane', 'common.closePane'],
['Points of interest', 'poiPane.pointsOfInterest'],
['No data', 'common.noData'],

View file

@ -2,7 +2,14 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthUser } from '../../hooks/useAuth';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api';
import {
apiUrl,
authHeaders,
assertOk,
shortenUrl,
prewarmScreenshot,
paramsWithLanguage,
} from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { formatRelativeTime, formatNumber } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state';
@ -193,7 +200,7 @@ function SavedSearchesTab({
onUpdateName: (id: string, name: string) => void;
onOpen: (params: string) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
@ -213,18 +220,21 @@ function SavedSearchesTab({
const handleShare = useCallback(
async (params: string, id: string) => {
prewarmScreenshot(params);
prewarmScreenshot(params, i18n.language);
setSharingId(id);
try {
const shortUrl = await shortenUrl(params);
const shortUrl = await shortenUrl(params, i18n.language);
doCopy(shortUrl, id);
} catch {
doCopy(`${window.location.origin}/dashboard?${params}`, id);
doCopy(
`${window.location.origin}/dashboard?${paramsWithLanguage(params, i18n.language)}`,
id
);
} finally {
setSharingId(null);
}
},
[doCopy]
[doCopy, i18n.language]
);
if (loading) {
@ -354,6 +364,36 @@ export function SavedPage({
const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>(
window.location.hash === '#shared-links' ? 'shared-links' : 'searches'
);
const [shareLinks, setShareLinks] = useState<ShareLinkListItem[]>([]);
const [shareLinksLoading, setShareLinksLoading] = useState(false);
const [shareLinksError, setShareLinksError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setShareLinksLoading(true);
setShareLinksError(null);
fetch(apiUrl('share-links'), authHeaders())
.then((res) => {
assertOk(res, 'Fetch share links');
return res.json();
})
.then((data: { links: ShareLinkListItem[] }) => {
if (!cancelled) setShareLinks(data.links);
})
.catch((err) => {
if (!cancelled) {
setShareLinksError(err instanceof Error ? err.message : 'Failed to fetch share links');
}
})
.finally(() => {
if (!cancelled) setShareLinksLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const tabClass = (tab: string) =>
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
@ -375,6 +415,11 @@ export function SavedPage({
</button>
<button className={tabClass('shared-links')} onClick={() => setActiveTab('shared-links')}>
{t('accountPage.shareLinksTitle')}
{shareLinks.length > 0 && (
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
{shareLinks.length}
</span>
)}
</button>
</div>
@ -388,7 +433,12 @@ export function SavedPage({
onOpen={onOpenSearch}
/>
) : (
<ShareLinksSection showTitle={false} />
<ShareLinksSection
links={shareLinks}
loading={shareLinksLoading}
error={shareLinksError}
showTitle={false}
/>
)}
</PageLayout>
);
@ -505,40 +555,20 @@ function InviteTable({
);
}
function ShareLinksSection({ showTitle = true }: { showTitle?: boolean }) {
function ShareLinksSection({
links,
loading,
error,
showTitle = true,
}: {
links: ShareLinkListItem[];
loading: boolean;
error: string | null;
showTitle?: boolean;
}) {
const { t } = useTranslation();
const [links, setLinks] = useState<ShareLinkListItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedCode, setCopiedCode] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(apiUrl('share-links'), authHeaders())
.then((res) => {
assertOk(res, 'Fetch share links');
return res.json();
})
.then((data: { links: ShareLinkListItem[] }) => {
if (!cancelled) setLinks(data.links);
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to fetch share links');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const handleCopy = (url: string, code: string) => {
copyToClipboard(url, () => {
setCopiedCode(code);

View file

@ -1,8 +1,13 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ts } from '../../i18n/server';
import type { FeatureFilters, FeatureMeta, HexagonStatsResponse } from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import type {
FeatureFilters,
FeatureMeta,
FilterExclusion,
HexagonStatsResponse,
} from '../../types';
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import {
formatValue,
@ -61,6 +66,21 @@ function normalizePercentageSegments<T extends { value: number }>(segments: T[])
return segments.map((segment, index) => ({ ...segment, value: normalizedValues[index] }));
}
function filterValueFormat(feature?: FeatureMeta) {
if (!feature) return undefined;
return {
prefix: feature.prefix,
suffix: feature.suffix,
raw: feature.raw,
};
}
function formatExclusionPercent(value: number): string {
const percent = value * 100;
if (percent < 10) return `${percent.toFixed(1)}%`;
return `${Math.round(percent)}%`;
}
export default function AreaPane({
stats,
globalFeatures,
@ -103,6 +123,36 @@ export default function AreaPane({
() => new Map(globalFeatures.map((f) => [f.name, f])),
[globalFeatures]
);
const travelEntryByField = useMemo(() => {
const map = new Map<string, TravelTimeEntry>();
for (const entry of travelTimeEntries ?? []) {
map.set(travelFieldKey(entry), entry);
}
return map;
}, [travelTimeEntries]);
const filterExclusions = stats?.filter_exclusions ?? [];
const getExclusionLabel = (exclusion: FilterExclusion) => {
const travelEntry = travelEntryByField.get(exclusion.name);
if (travelEntry) return t('areaPane.travelTo', { destination: travelEntry.label });
return ts(exclusion.name);
};
const formatExclusionValue = (exclusion: FilterExclusion, value: number) => {
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.min')}`;
return formatFilterValue(value, filterValueFormat(globalFeatureByName.get(exclusion.name)));
};
const getExclusionAdjustment = (exclusion: FilterExclusion) => {
if (exclusion.direction === 'allow_value') {
return t('areaPane.allowCategory', { value: ts(exclusion.category ?? '') });
}
if (exclusion.value == null) return '';
const value = formatExclusionValue(exclusion, exclusion.value);
return exclusion.direction === 'lower_min'
? t('areaPane.lowerMinTo', { value })
: t('areaPane.raiseMaxTo', { value });
};
if (!hexagonId) {
return (
@ -205,6 +255,31 @@ export default function AreaPane({
>
{t('areaPane.showAllStats')}
</button>
{filterExclusions.length > 0 && (
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
<ol className="mt-1.5 space-y-1.5">
{filterExclusions.map((exclusion) => (
<li
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
>
<div className="flex items-baseline justify-between gap-2">
<span className="min-w-0 truncate font-medium">
{getExclusionLabel(exclusion)}
</span>
<span className="shrink-0 tabular-nums text-amber-700 dark:text-amber-200">
{formatExclusionPercent(exclusion.relative_difference)}
</span>
</div>
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
{getExclusionAdjustment(exclusion)}
</p>
</li>
))}
</ol>
</div>
)}
</div>
)}
{canViewProperties && (

View file

@ -5,6 +5,7 @@ import {
buildRightmoveExactPostcodeRedirectUrl,
buildPropertySearchUrls,
H3_RADIUS_MILES,
POSTCODE_RADIUS_MILES,
type HexagonLocation,
} from '../../lib/external-search';
import outcodeIds from '../../lib/rightmove-outcodes.json';
@ -36,8 +37,10 @@ export default function ExternalSearchLinks({
if (!location.isPostcode || !location.postcode) return urls.rightmove;
return buildRightmoveExactPostcodeRedirectUrl(location.postcode, urls.rightmove);
}, [location.isPostcode, location.postcode, urls?.rightmove]);
const radiusMiles = location.isPostcode ? 0 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = radiusMiles === 0 ? t('externalSearch.exact') : `${radiusMiles}mi radius`;
const radiusMiles = location.isPostcode
? POSTCODE_RADIUS_MILES
: (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = `${radiusMiles}mi radius`;
if (!urls) return null;

View file

@ -2,6 +2,7 @@ import { memo, useState, useMemo, useRef, useCallback, useEffect, type FormEvent
import { useTranslation } from 'react-i18next';
import type { FeatureMeta, FeatureFilters } from '../../types';
import { findActiveFilterElement } from '../../lib/active-filter-scroll';
import { buildPercentileScale } from '../../lib/format';
import type { PercentileScale } from '../../lib/format';
import InfoPopup from '../ui/InfoPopup';
@ -486,9 +487,7 @@ export default memo(function Filters({
const name = pendingScrollRef.current;
if (!name) return;
pendingScrollRef.current = null;
const el = scrollRef.current?.querySelector<HTMLElement>(
`[data-filter-name="${CSS.escape(name)}"]`
);
const el = findActiveFilterElement(scrollRef.current, name);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });

View file

@ -69,6 +69,7 @@ interface MapProps {
bounds?: Bounds | null;
hideLegend?: boolean;
hideLocationSearch?: boolean;
hideTopCardsWhenNarrow?: boolean;
travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string;
totalCount?: number;
@ -82,6 +83,17 @@ interface Dimensions {
height: number;
}
const DESKTOP_TOP_CARD_WIDTH = 300;
const DESKTOP_TOP_CARD_GAP = 8;
const DESKTOP_TOP_CARD_HORIZONTAL_INSET = 24;
const DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH =
DESKTOP_TOP_CARD_WIDTH + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
const DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH =
DESKTOP_TOP_CARD_WIDTH * 2 + DESKTOP_TOP_CARD_GAP + DESKTOP_TOP_CARD_HORIZONTAL_INSET;
const DESKTOP_TOP_CARD_CLASS = 'w-[300px]';
const DESKTOP_LOCATION_SEARCH_INPUT_CLASS =
'px-2 py-2 text-sm w-full border-none outline-none bg-transparent text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500';
type MapContainerStyle = CSSProperties & {
'--map-mobile-bottom-inset'?: string;
};
@ -208,6 +220,23 @@ function getRenderedViewState(map: MapRef | null): ViewState | null {
};
}
function getRenderedVisibleCenter(
map: MapRef | null,
dimensions: Dimensions,
bottomScreenInset: number
): Pick<ViewState, 'latitude' | 'longitude'> | null {
if (!map || dimensions.width <= 0 || dimensions.height <= 0) return null;
const visibleBottomInset = clamp(bottomScreenInset, 0, dimensions.height);
const visibleCenterY = (dimensions.height - visibleBottomInset) / 2;
const center = map.unproject([dimensions.width / 2, visibleCenterY]);
return {
longitude: center.lng,
latitude: center.lat,
};
}
function DeckOverlay({
layers,
getTooltip,
@ -260,6 +289,7 @@ export default memo(function Map({
bounds: viewportBounds,
hideLegend = false,
hideLocationSearch = false,
hideTopCardsWhenNarrow = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
densityLabel: densityLabelProp,
totalCount: totalCountProp,
@ -319,6 +349,9 @@ export default memo(function Map({
const dataBoundsHeight = dimensions.height + Math.max(0, bottomScreenInset);
const bounds = getBoundsFromViewState(renderedViewState, dimensions.width, dataBoundsHeight);
const resolution = zoomToResolution(renderedViewState.zoom);
const renderedVisibleCenter =
getRenderedVisibleCenter(mapRef.current, dimensions, bottomScreenInset) ??
renderedViewState;
onViewChange({
resolution,
@ -326,6 +359,8 @@ export default memo(function Map({
zoom: renderedViewState.zoom,
latitude: renderedViewState.latitude,
longitude: renderedViewState.longitude,
visibleLatitude: renderedVisibleCenter.latitude,
visibleLongitude: renderedVisibleCenter.longitude,
});
};
frame = window.requestAnimationFrame(emit);
@ -389,6 +424,19 @@ export default memo(function Map({
() => (bottomScreenInset > 0 ? { '--map-mobile-bottom-inset': `${bottomScreenInset}px` } : {}),
[bottomScreenInset]
);
const hideDesktopTopCardsForWidth =
hideTopCardsWhenNarrow &&
dimensions.width > 0 &&
dimensions.width < DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH;
const stackDesktopTopCards =
hideTopCardsWhenNarrow &&
dimensions.width >= DESKTOP_TOP_CARDS_STACKED_MIN_MAP_WIDTH &&
dimensions.width < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH;
const showLocationSearch = !hideLocationSearch && !hideDesktopTopCardsForWidth;
const showLegend = !hideLegend && !hideDesktopTopCardsForWidth;
const desktopTopCardsLayoutClass = stackDesktopTopCards
? 'flex-col items-start'
: 'items-start justify-between';
const {
layers,
@ -452,11 +500,11 @@ export default memo(function Map({
<div className="absolute inset-0 z-20 pointer-events-none flex flex-col">
{/* Center: Logo card with hero text */}
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10">
<LogoIcon className="w-24 h-24 text-teal-400" />
<div className="flex items-center gap-8 bg-navy-900/90 rounded-3xl px-14 py-10 max-w-[1040px]">
<LogoIcon className="w-24 h-24 shrink-0 text-teal-400" />
<span
className="font-bold text-white whitespace-nowrap"
style={{ fontSize: '5rem' }}
className="font-bold text-white/50"
style={{ fontSize: '4rem', lineHeight: 1.05, maxWidth: '760px' }}
>
{t('map.ogTitle')}
</span>
@ -494,74 +542,83 @@ export default memo(function Map({
) : null
) : (
<>
<div className="absolute top-3 left-3 right-3 z-20 flex flex-wrap items-start justify-between gap-2 pointer-events-none">
{!hideLocationSearch && (
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
/>
)}
{!hideLegend &&
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
theme={theme}
suffix=" min"
/>
) : colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
: ts(colorFeatureMeta.name)
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
}
featureName={colorFeatureMeta.name}
theme={theme}
suffix={colorFeatureMeta.suffix}
raw={colorFeatureMeta.raw}
/>
) : null
) : (
<MapLegend
featureLabel={densityLabel}
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
totalCount={
totalCountProp ??
(usePostcodeView ? postcodeCountRange.total : countRange.total)
}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
{(showLocationSearch || showLegend) && (
<div
className={`absolute top-3 left-3 right-3 z-20 flex gap-2 pointer-events-none ${desktopTopCardsLayoutClass}`}
>
{showLocationSearch && (
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
className={DESKTOP_TOP_CARD_CLASS}
inputClassName={DESKTOP_LOCATION_SEARCH_INPUT_CLASS}
/>
))}
</div>
)}
{showLegend &&
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (
<MapLegend
featureLabel={t('travel.travelTime', {
mode: modes.label(
viewFeature.split('_')[1] as 'car' | 'bicycle' | 'walking' | 'transit'
),
})}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
theme={theme}
suffix=" min"
className={DESKTOP_TOP_CARD_CLASS}
/>
) : colorFeatureMeta ? (
<MapLegend
featureLabel={
viewSource === 'eye'
? t('mapLegend.previewing', { name: ts(colorFeatureMeta.name) })
: ts(colorFeatureMeta.name)
}
range={colorRange}
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
onResetScale={viewSource === 'eye' ? onResetPreviewScale : undefined}
resetScaleDisabled={!canResetPreviewScale}
mode="feature"
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
}
featureName={colorFeatureMeta.name}
theme={theme}
suffix={colorFeatureMeta.suffix}
raw={colorFeatureMeta.raw}
className={DESKTOP_TOP_CARD_CLASS}
/>
) : null
) : (
<MapLegend
featureLabel={densityLabel}
range={
usePostcodeView
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
totalCount={
totalCountProp ??
(usePostcodeView ? postcodeCountRange.total : countRange.total)
}
showCancel={false}
onCancel={onCancelPin}
mode="density"
theme={theme}
className={DESKTOP_TOP_CARD_CLASS}
/>
))}
</div>
)}
{popupInfo && (
<div
className="pointer-events-none absolute bg-white dark:bg-warm-800 rounded-lg shadow-lg text-sm dark:text-white"

View file

@ -32,7 +32,7 @@ export function FeatureActions({
const mapLabel = isPinned ? t('filters.clearColourMap') : t('filters.colourMap');
return (
<div className="flex items-center gap-0.5 shrink-0">
<div className="flex items-center gap-2 md:gap-0.5 shrink-0">
{feature.detail &&
onShowInfo &&
(showText ? (

View file

@ -59,7 +59,7 @@ export default function UpgradeModal({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
{/* Close button */}
<button

View file

@ -1,7 +1,14 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import {
apiUrl,
buildFilterString,
logNonAbortError,
authHeaders,
isAbortError,
} from '../lib/api';
import type { TravelTimeEntry } from './useTravelTime';
import { buildTravelParam } from '../lib/travel-params';
const DEBOUNCE_MS = 400;
@ -18,32 +25,34 @@ export function useFilterCounts(
filters: FeatureFilters,
features: FeatureMeta[],
bounds: Bounds | null,
travelTimeEntries: TravelTimeEntry[]
travelTimeEntries: TravelTimeEntry[],
shareCode?: string
) {
const [impacts, setImpacts] = useState<Record<string, number>>({});
const [total, setTotal] = useState<number>(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Build the travel param string (same format as useMapData)
const travelParam = useMemo(() => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
if (entry.timeRange) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
segments.push(seg);
}
return segments.join('|');
return buildTravelParam(travelTimeEntries);
}, [travelTimeEntries]);
const requestIdRef = useRef(0);
useEffect(() => {
if (!bounds) return;
requestIdRef.current += 1;
const requestId = requestIdRef.current;
if (!bounds) {
abortRef.current?.abort();
setImpacts({});
setTotal(0);
return;
}
const filterCount = Object.keys(filters).length;
const hasTravelFilters = travelTimeEntries.some((e) => e.slug && e.timeRange);
if (filterCount === 0 && !hasTravelFilters) {
abortRef.current?.abort();
setImpacts({});
setTotal(0);
return;
@ -61,6 +70,7 @@ export function useFilterCounts(
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
if (travelParam) params.set('travel', travelParam);
if (shareCode) params.set('share', shareCode);
const res = await fetch(
apiUrl('filter-counts', params),
@ -68,6 +78,7 @@ export function useFilterCounts(
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: FilterCountsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setImpacts(json.impacts);
setTotal(json.total);
} catch (err) {
@ -79,8 +90,9 @@ export function useFilterCounts(
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
abortRef.current?.abort();
};
}, [filters, features, bounds, travelParam, travelTimeEntries]);
}, [filters, features, bounds, travelParam, travelTimeEntries, shareCode]);
// Cancel in-flight on unmount
useEffect(() => {

View file

@ -0,0 +1,58 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useFilters } from './useFilters';
import type { FeatureMeta } from '../types';
vi.mock('../lib/analytics', () => ({
trackEvent: vi.fn(),
}));
describe('useFilters', () => {
const features: FeatureMeta[] = [
{
name: 'price',
type: 'numeric',
min: 0,
max: 100,
},
];
it('activates slider preview on pointer down without committing unchanged clicks', () => {
const { result } = renderHook(() =>
useFilters({
initialFilters: { price: [0, 100] },
features,
})
);
act(() => {
result.current.handleDragStart('price');
});
expect(result.current.activeFeature).toBe('price');
expect(result.current.viewSource).toBe('drag');
act(() => {
result.current.handleDragEnd();
});
expect(result.current.activeFeature).toBeNull();
expect(result.current.filters.price).toEqual([0, 100]);
act(() => {
result.current.handleDragStart('price');
result.current.handleDragChange([10, 90]);
});
expect(result.current.activeFeature).toBe('price');
expect(result.current.viewSource).toBe('drag');
act(() => {
result.current.handleDragEnd();
});
expect(result.current.activeFeature).toBeNull();
expect(result.current.filters.price).toEqual([10, 90]);
});
});

View file

@ -2,7 +2,8 @@ import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMapData } from './useMapData';
import type { ApiResponse, Bounds, ViewChangeParams } from '../types';
import type { TravelTimeEntry } from './useTravelTime';
import type { ApiResponse, Bounds, FeatureMeta, ViewChangeParams } from '../types';
vi.mock('../lib/pocketbase', () => ({
default: { authStore: { isValid: false, token: '' } },
@ -32,6 +33,7 @@ async function flushPromises() {
describe('useMapData', () => {
const requests: Array<{ url: string; resolve: (response: Response) => void }> = [];
const noTravelTimeEntries: TravelTimeEntry[] = [];
beforeEach(() => {
vi.useFakeTimers();
@ -59,7 +61,7 @@ describe('useMapData', () => {
viewFeature: null,
activeFeature: null,
pinnedFeature: null,
travelTimeEntries: [],
travelTimeEntries: noTravelTimeEntries,
})
);
@ -93,4 +95,264 @@ describe('useMapData', () => {
expect(result.current.data).toEqual([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]);
});
it('stores the visible map center separately from the rendered map center', async () => {
const { result } = renderHook(() =>
useMapData({
filters: {},
features: [],
viewFeature: null,
activeFeature: null,
pinnedFeature: null,
travelTimeEntries: noTravelTimeEntries,
})
);
await act(async () => {
result.current.handleViewChange({
...viewChange({ south: 1, west: 1, north: 2, east: 2 }),
latitude: 51.5,
longitude: -0.1,
visibleLatitude: 51.6,
visibleLongitude: -0.2,
});
});
expect(result.current.currentView).toEqual({ latitude: 51.5, longitude: -0.1, zoom: 10 });
expect(result.current.currentVisibleView).toEqual({
latitude: 51.6,
longitude: -0.2,
zoom: 10,
});
});
it('resets the colour range to drag preview data while a slider is active', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
name: 'price',
type: 'numeric',
min: 0,
max: 100,
},
];
const filters = { price: [20, 80] as [number, number] };
const { result, rerender } = renderHook(
({ activeFeature }: { activeFeature: string | null }) =>
useMapData({
filters,
features,
viewFeature: 'price',
activeFeature,
pinnedFeature: null,
travelTimeEntries: noTravelTimeEntries,
}),
{ initialProps: { activeFeature: null as string | null } }
);
await act(async () => {
result.current.handleViewChange(viewChange(bounds));
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await act(async () => {
requests[0].resolve(
response([
{ h3: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
{ h3: 'committed-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
await act(async () => {
rerender({ activeFeature: 'price' });
await flushPromises();
});
expect(requests).toHaveLength(2);
await act(async () => {
requests[1].resolve(
response([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
])
);
await flushPromises();
});
expect(result.current.data).toEqual([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
]);
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
});
it('does not reuse cached drag preview data when the drag request changes', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
name: 'price',
type: 'numeric',
min: 0,
max: 100,
},
];
const committedData = [
{ h3: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
{ h3: 'committed-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
];
const previewData = [
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
];
const { result, rerender } = renderHook(
({
filters,
activeFeature,
}: {
filters: Record<string, [number, number]>;
activeFeature: string | null;
}) =>
useMapData({
filters,
features,
viewFeature: 'price',
activeFeature,
pinnedFeature: null,
travelTimeEntries: noTravelTimeEntries,
}),
{
initialProps: {
filters: { price: [20, 80] as [number, number] },
activeFeature: null as string | null,
},
}
);
await act(async () => {
result.current.handleViewChange(viewChange(bounds));
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await act(async () => {
requests[0].resolve(response(committedData));
await flushPromises();
});
await act(async () => {
rerender({
filters: { price: [20, 80] },
activeFeature: 'price',
});
await flushPromises();
});
await act(async () => {
requests[1].resolve(response(previewData));
await flushPromises();
});
expect(result.current.data).toEqual(previewData);
await act(async () => {
rerender({
filters: { price: [10, 90] },
activeFeature: 'price',
});
});
expect(result.current.data).toEqual(committedData);
});
it('resets a pinned colour range after a slider commits a new filter', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
name: 'price',
type: 'numeric',
min: 0,
max: 100,
},
];
const { result, rerender } = renderHook(
({
filters,
activeFeature,
}: {
filters: Record<string, [number, number]>;
activeFeature: string | null;
}) =>
useMapData({
filters,
features,
viewFeature: 'price',
activeFeature,
pinnedFeature: 'price',
travelTimeEntries: noTravelTimeEntries,
}),
{
initialProps: {
filters: { price: [20, 80] as [number, number] },
activeFeature: null as string | null,
},
}
);
await act(async () => {
result.current.handleViewChange(viewChange(bounds));
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await act(async () => {
requests[0].resolve(
response([
{ h3: 'old-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
{ h3: 'old-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
await act(async () => {
rerender({
filters: { price: [20, 80] },
activeFeature: 'price',
});
await flushPromises();
});
await act(async () => {
rerender({
filters: { price: [10, 90] },
activeFeature: null,
});
});
await act(async () => {
vi.advanceTimersByTime(150);
});
const committedRequest = requests[requests.length - 1];
await act(async () => {
committedRequest.resolve(
response([
{ h3: 'new-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'new-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
});
});

View file

@ -28,7 +28,7 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
skipBeacon: true,
},
{
target: '[data-tutorial="map-anchor"]',
target: '[data-tutorial="map"]',
title: t('tutorial.step3Title'),
content: t('tutorial.step3Content'),
placement: 'top' as const,

View file

@ -34,17 +34,31 @@ function getStoredLanguage(): LanguageCode | null {
}
}
function getUrlLanguage(): LanguageCode | null {
try {
if (typeof window === 'undefined') return null;
const value = new URLSearchParams(window.location.search).get('lang');
return value ? toSupportedLanguage(value) : null;
} catch {
return null;
}
}
function getBrowserLanguages(): readonly string[] {
if (typeof navigator === 'undefined') return [];
return navigator.languages?.length ? navigator.languages : [navigator.language];
}
function detectLanguage(): LanguageCode {
// 1. Explicit user choice (persisted from the language dropdown)
// 1. Explicit URL language, used by generated screenshot/OG image URLs.
const urlLanguage = getUrlLanguage();
if (urlLanguage) return urlLanguage;
// 2. Explicit user choice (persisted from the language dropdown)
const stored = getStoredLanguage();
if (stored) return stored;
// 2. Browser preference (navigator.languages falls back to navigator.language)
// 3. Browser preference (navigator.languages falls back to navigator.language)
for (const tag of getBrowserLanguages()) {
const language = toSupportedLanguage(tag);
if (language) return language;

View file

@ -80,13 +80,6 @@ const de: Translations = {
home: 'Startseite',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: 'Immobilie gespeichert!',
viewSaved: 'Gespeicherte ansehen',
dontShowAgain: 'Nicht erneut anzeigen',
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: 'Breadcrumb',
@ -563,8 +556,8 @@ const de: Translations = {
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
'Marketing-, Methodik-, Leitfaden- und Supportseiten sind indexierbar. Dashboard, Konto, gespeicherte Suchen, Einladungen und Einladungsrouten werden gegebenenfalls als „noindex“ markiert oder für den Crawler-Zugriff blockiert.',
'Saved search data is account-scoped': 'Gespeicherte Suchdaten sind kontobezogen',
'Saved searches and properties are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'Gespeicherte Suchen und Eigenschaften sind für die Verwendung durch angemeldete Benutzer vorgesehen. Sie sind nicht in der öffentlichen Sitemap enthalten und sollten nicht als öffentlicher Inhalt gecrawlt werden können.',
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'Gespeicherte Suchen und geteilte Links sind für angemeldete Benutzer vorgesehen. Sie sind nicht in der öffentlichen Sitemap enthalten und sollten nicht als öffentlicher Inhalt gecrawlt werden können.',
'Search measurement without exposing private data':
'Suchmessung ohne Offenlegung privater Daten',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.':
@ -797,8 +790,6 @@ const de: Translations = {
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: 'Unbekannte Adresse',
unsaveProperty: 'Immobilie nicht mehr merken',
saveProperty: 'Immobilie merken',
estValue: 'Gesch. Wert:',
type: 'Typ:',
builtForm: 'Bauweise:',
@ -838,6 +829,11 @@ const de: Translations = {
showAllStatsFallback:
'Wechseln Sie zu allen Immobilien, um dieses Gebiet ohne aktive Filter zu prüfen.',
showAllStats: 'Alle Immobilien anzeigen',
closestBlockingFilters: 'Nächste Filter, die dieses Gebiet ausschließen',
lowerMinTo: 'Minimum auf {{value}} senken',
raiseMaxTo: 'Maximum auf {{value}} erhöhen',
allowCategory: '{{value}} zulassen',
travelTo: 'Fahrt zu {{destination}}',
viewProperties: '{{count}} Immobilien ansehen',
viewPropertiesShort: 'Immobilien ansehen',
priceHistory: 'Preisentwicklung',
@ -1276,20 +1272,11 @@ const de: Translations = {
noSavedSearches: 'Noch keine gespeicherten Suchen',
noSavedSearchesDesc:
'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
noSavedProperties: 'Noch keine gespeicherten Immobilien',
noSavedPropertiesDesc:
'Merke dir Immobilien während du erkundest und erstelle deine Auswahlliste, ohne den Überblick zu verlieren.',
openPostcode: 'Postleitzahl öffnen',
clickToRename: 'Klicken zum Umbenennen',
notesPlaceholder: 'Notiere deine Gedanken...',
deleteSearch: 'Suche löschen',
deleteSearchConfirm:
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
deleteProperty: 'Immobilie löschen',
deletePropertyConfirm:
'Möchtest du diese gespeicherte Immobilie wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
bed: 'Schlafz.',
epc: 'EPC',
},
// ── Invites Page ───────────────────────────────────
@ -1337,12 +1324,6 @@ const de: Translations = {
failedToValidate: 'Einladungslink konnte nicht validiert werden',
},
// ── Map Page ───────────────────────────────────────
mapPage: {
unsavedProperty: 'Entfernt',
savedProperty: 'Gespeichert',
},
// ── Format / Time ──────────────────────────────────
format: {
justNow: 'gerade eben',

View file

@ -78,13 +78,6 @@ const en = {
home: 'Home',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: 'Property saved!',
viewSaved: 'View saved',
dontShowAgain: "Don't show again",
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: 'Breadcrumb',
@ -544,8 +537,8 @@ const en = {
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.',
'Saved search data is account-scoped': 'Saved search data is account-scoped',
'Saved searches and properties are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'Saved searches and properties are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.',
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.',
'Search measurement without exposing private data':
'Search measurement without exposing private data',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.':
@ -772,8 +765,6 @@ const en = {
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: 'Unknown Address',
unsaveProperty: 'Unsave property',
saveProperty: 'Save property',
estValue: 'Est. value:',
type: 'Type:',
builtForm: 'Built form:',
@ -811,6 +802,11 @@ const en = {
showAllStatsFallback:
'Switch to all properties to inspect this area without the active filters.',
showAllStats: 'Show all properties',
closestBlockingFilters: 'Closest filters excluding this area',
lowerMinTo: 'Lower minimum to {{value}}',
raiseMaxTo: 'Raise maximum to {{value}}',
allowCategory: 'Allow {{value}}',
travelTo: 'Travel to {{destination}}',
viewProperties: 'View {{count}} Properties',
viewPropertiesShort: 'View properties',
priceHistory: 'Price History',
@ -1186,7 +1182,7 @@ const en = {
// FAQ items — Privacy and Data Protection
faqPrivacy1Q: 'Do you store personal data about me?',
faqPrivacy1A:
'The property and neighbourhood data doesnt contain your personal details. If you create an account, we store only whats needed to run the service, such as your email address, access status, newsletter choice, saved searches, saved properties, and payment records handled by Stripe. We handle account data under UK privacy law.',
'The property and neighbourhood data doesnt contain your personal details. If you create an account, we store only whats needed to run the service, such as your email address, access status, newsletter choice, saved searches, shared links, and payment records handled by Stripe. We handle account data under UK privacy law.',
// FAQ items — Why Perfect Postcode
faqWhy1Q: 'What does this show that listing portals usually dont?',
faqWhy1A:
@ -1241,19 +1237,10 @@ const en = {
noSavedSearches: 'No saved searches yet',
noSavedSearchesDesc:
'Save your filters and map view so you can pick up exactly where you left off.',
noSavedProperties: 'No saved properties yet',
noSavedPropertiesDesc:
'Bookmark properties as you explore and build your shortlist without losing track.',
openPostcode: 'Open postcode',
clickToRename: 'Click to rename',
notesPlaceholder: 'Jot down your thoughts...',
deleteSearch: 'Delete search',
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This cant be undone.',
deleteProperty: 'Delete property',
deletePropertyConfirm:
'Are you sure you want to delete this saved property? This cant be undone.',
bed: 'bed',
epc: 'EPC',
},
// ── Invites Page ───────────────────────────────────
@ -1301,12 +1288,6 @@ const en = {
failedToValidate: 'Failed to validate invite link',
},
// ── Map Page ───────────────────────────────────────
mapPage: {
unsavedProperty: 'Unsave',
savedProperty: 'Saved',
},
// ── Format / Time ──────────────────────────────────
format: {
justNow: 'just now',

View file

@ -80,13 +80,6 @@ const fr: Translations = {
home: 'Accueil',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: 'Bien enregistré !',
viewSaved: 'Voir lenregistrement',
dontShowAgain: 'Ne plus afficher',
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: 'Fil dAriane',
@ -566,8 +559,8 @@ const fr: Translations = {
"Les pages marketing, méthodologie, guide et support sont indexables. Le tableau de bord, le compte, les recherches enregistrées, les invitations et les itinéraires d'invitation sont marqués comme non indexés ou bloqués pour l'accès du robot, le cas échéant.",
'Saved search data is account-scoped':
'Les données de recherche enregistrées sont limitées au compte',
'Saved searches and properties are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'Les recherches et propriétés enregistrées sont destinées à une utilisation connectée. Ils ne sont pas inclus dans le plan du site public et ne doivent pas être explorables en tant que contenu public.',
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'Les recherches enregistrées et les liens partagés sont destinés à une utilisation connectée. Ils ne sont pas inclus dans le plan du site public et ne doivent pas être explorables en tant que contenu public.',
'Search measurement without exposing private data':
'Rechercher des mesures sans exposer de données privées',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.':
@ -802,8 +795,6 @@ const fr: Translations = {
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: 'Adresse inconnue',
unsaveProperty: 'Retirer des favoris',
saveProperty: 'Ajouter aux favoris',
estValue: 'Valeur estimée :',
type: 'Type :',
builtForm: 'Forme du bâti :',
@ -843,6 +834,11 @@ const fr: Translations = {
showAllStatsFallback:
'Passez à toutes les propriétés pour inspecter cette zone sans les filtres actifs.',
showAllStats: 'Afficher toutes les propriétés',
closestBlockingFilters: 'Filtres les plus proches qui excluent cette zone',
lowerMinTo: 'Abaisser le minimum à {{value}}',
raiseMaxTo: 'Augmenter le maximum à {{value}}',
allowCategory: 'Autoriser {{value}}',
travelTo: 'Trajet vers {{destination}}',
viewProperties: 'Voir {{count}} propriétés',
viewPropertiesShort: 'Voir les propriétés',
priceHistory: 'Historique des prix',
@ -1280,20 +1276,11 @@ const fr: Translations = {
noSavedSearches: 'Aucune recherche enregistrée',
noSavedSearchesDesc:
'Enregistrez vos filtres et la vue de la carte pour reprendre exactement là où vous vous étiez arrêté.',
noSavedProperties: 'Aucune propriété enregistrée',
noSavedPropertiesDesc:
'Ajoutez des propriétés en favoris au fil de votre exploration et constituez votre sélection sans rien perdre de vue.',
openPostcode: 'Ouvrir le code postal',
clickToRename: 'Cliquez pour renommer',
notesPlaceholder: 'Notez vos impressions...',
deleteSearch: 'Supprimer la recherche',
deleteSearchConfirm:
'Êtes-vous sûr de vouloir supprimer cette recherche enregistrée ? Cette action est irréversible.',
deleteProperty: 'Supprimer la propriété',
deletePropertyConfirm:
'Êtes-vous sûr de vouloir supprimer cette propriété enregistrée ? Cette action est irréversible.',
bed: 'ch.',
epc: 'DPE',
},
// ── Invites Page ───────────────────────────────────
@ -1341,12 +1328,6 @@ const fr: Translations = {
failedToValidate: 'Échec de la validation du lien dinvitation',
},
// ── Map Page ───────────────────────────────────────
mapPage: {
unsavedProperty: 'Retirer',
savedProperty: 'Enregistré',
},
// ── Format / Time ──────────────────────────────────
format: {
justNow: 'à linstant',

View file

@ -76,12 +76,6 @@ const hi: Translations = {
home: 'होम',
},
toasts: {
propertySaved: 'संपत्ति सहेजी गई!',
viewSaved: 'सहेजी हुई देखें',
dontShowAgain: 'फिर न दिखाएं',
},
seo: {
breadcrumb: 'ब्रेडक्रम्ब',
reviewDataSources: 'डेटा स्रोत देखें',
@ -542,8 +536,8 @@ const hi: Translations = {
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
'मार्केटिंग, कार्यप्रणाली, मार्गदर्शिका और सहायता पृष्ठ अनुक्रमित किए जा सकते हैं। डैशबोर्ड, खाता, सहेजी गई खोजें, आमंत्रण और आमंत्रण मार्गों को नोइंडेक्स के रूप में चिह्नित किया जाता है या जहां उपयुक्त हो क्रॉलर पहुंच से अवरुद्ध कर दिया जाता है।',
'Saved search data is account-scoped': 'सहेजा गया खोज डेटा खाता-क्षेत्र है',
'Saved searches and properties are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'सहेजी गई खोजें और संपत्तियां साइन-इन उपयोग के लिए हैं। वे सार्वजनिक साइटमैप में शामिल नहीं हैं और उन्हें सार्वजनिक सामग्री के रूप में क्रॉल नहीं किया जाना चाहिए।',
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'सहेजी गई खोजें और साझा लिंक साइन-इन उपयोग के लिए हैं। वे सार्वजनिक साइटमैप में शामिल नहीं हैं और उन्हें सार्वजनिक सामग्री के रूप में क्रॉल नहीं किया जाना चाहिए।',
'Search measurement without exposing private data': 'निजी डेटा को उजागर किए बिना माप खोजें',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.':
'समग्र विश्लेषण और खोज कंसोल डेटा का उपयोग करके सार्वजनिक पृष्ठों पर एसईओ माप होना चाहिए। निजी क्वेरी पैरामीटर और खाता दृश्य अनुक्रमणीय लैंडिंग पृष्ठ नहीं बनने चाहिए।',
@ -761,8 +755,6 @@ const hi: Translations = {
propertyCard: {
unknownAddress: 'अज्ञात पता',
unsaveProperty: 'संपत्ति को असहेजें',
saveProperty: 'संपत्ति सहेजें',
estValue: 'अनु. मूल्य:',
type: 'प्रकार:',
builtForm: 'निर्माण रूप:',
@ -800,6 +792,11 @@ const hi: Translations = {
showAllStatsFallback:
'सक्रिय फिल्टर के बिना इस क्षेत्र को देखने के लिए सभी संपत्तियों पर जाएं.',
showAllStats: 'सभी संपत्तियां दिखाएं',
closestBlockingFilters: 'इस क्षेत्र को बाहर करने वाले निकटतम फिल्टर',
lowerMinTo: 'न्यूनतम को {{value}} तक घटाएं',
raiseMaxTo: 'अधिकतम को {{value}} तक बढ़ाएं',
allowCategory: '{{value}} की अनुमति दें',
travelTo: '{{destination}} तक यात्रा',
viewProperties: '{{count}} संपत्तियां देखें',
viewPropertiesShort: 'संपत्तियां देखें',
priceHistory: 'कीमत इतिहास',
@ -1204,20 +1201,11 @@ const hi: Translations = {
noSavedSearches: 'अभी कोई सहेजी गई खोज नहीं',
noSavedSearchesDesc:
'अपने फिल्टर और मानचित्र दृश्य सहेजें ताकि आप ठीक वहीं से फिर शुरू कर सकें जहां छोड़ा था.',
noSavedProperties: 'अभी कोई सहेजी गई संपत्ति नहीं',
noSavedPropertiesDesc:
'खोजते समय संपत्तियां बुकमार्क करें और बिना ट्रैक खोए अपनी शॉर्टलिस्ट बनाएं.',
openPostcode: 'पोस्टकोड खोलें',
clickToRename: 'नाम बदलने के लिए क्लिक करें',
notesPlaceholder: 'अपने विचार लिखें...',
deleteSearch: 'खोज हटाएं',
deleteSearchConfirm:
'क्या आप वाकई यह सहेजी गई खोज हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
deleteProperty: 'संपत्ति हटाएं',
deletePropertyConfirm:
'क्या आप वाकई यह सहेजी गई संपत्ति हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
bed: 'बेडरूम',
epc: 'EPC',
},
invitesPage: {
@ -1263,11 +1251,6 @@ const hi: Translations = {
failedToValidate: 'आमंत्रण लिंक सत्यापित नहीं हो सका',
},
mapPage: {
unsavedProperty: 'असहेजें',
savedProperty: 'सहेजा गया',
},
format: {
justNow: 'अभी',
minutesAgo: '{{count}} मिनट पहले',

View file

@ -80,13 +80,6 @@ const hu: Translations = {
home: 'Főoldal',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: 'Ingatlan mentve!',
viewSaved: 'Mentett megtekintése',
dontShowAgain: 'Ne mutasd újra',
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: 'Morzsanavigáció',
@ -552,8 +545,8 @@ const hu: Translations = {
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
'A marketing, a módszertan, az útmutató és a támogatási oldalak indexelhetők. Az irányítópult, a fiók, a mentett keresések, a meghívók és a meghívási útvonalak noindex-szel vannak megjelölve, vagy adott esetben blokkolva vannak a feltérképező robot számára.',
'Saved search data is account-scoped': 'A mentett keresési adatok fiókra vonatkoznak',
'Saved searches and properties are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'A mentett keresések és tulajdonságok bejelentkezett használatra szolgálnak. Nem szerepelnek a nyilvános webhelytérképen, és nyilvános tartalomként nem térképezhetők fel.',
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'A mentett keresések és megosztott hivatkozások bejelentkezett használatra szolgálnak. Nem szerepelnek a nyilvános webhelytérképen, és nyilvános tartalomként nem térképezhetők fel.',
'Search measurement without exposing private data':
'A mérési adatok keresése személyes adatok felfedése nélkül',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.':
@ -784,8 +777,6 @@ const hu: Translations = {
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: 'Ismeretlen cím',
unsaveProperty: 'Ingatlan mentésének visszavonása',
saveProperty: 'Ingatlan mentése',
estValue: 'Becsült érték:',
type: 'Típus:',
builtForm: 'Épületforma:',
@ -824,6 +815,11 @@ const hu: Translations = {
showAllStatsFallback:
'Váltson az összes ingatlanra, hogy aktív szűrők nélkül tekintse át ezt a területet.',
showAllStats: 'Összes ingatlan mutatása',
closestBlockingFilters: 'A területet kizáró legközelebbi szűrők',
lowerMinTo: 'Minimum csökkentése erre: {{value}}',
raiseMaxTo: 'Maximum növelése erre: {{value}}',
allowCategory: '{{value}} engedélyezése',
travelTo: 'Utazás ide: {{destination}}',
viewProperties: '{{count}} ingatlan megtekintése',
viewPropertiesShort: 'Ingatlanok megtekintése',
priceHistory: 'Ártörténet',
@ -1258,20 +1254,11 @@ const hu: Translations = {
noSavedSearches: 'Még nincsenek mentett keresések',
noSavedSearchesDesc:
'Mentsd el a szűrőket és a térképnézetet, hogy pontosan ott folytasd, ahol abbahagytad.',
noSavedProperties: 'Még nincsenek mentett ingatlanok',
noSavedPropertiesDesc:
'Jelöld meg az ingatlanokat felfedezés közben, és építsd a rövid listádat elvesztés nélkül.',
openPostcode: 'Irányítószám megnyitása',
clickToRename: 'Kattints az átnevezéshez',
notesPlaceholder: 'Írd le a gondolataidat...',
deleteSearch: 'Keresés törlése',
deleteSearchConfirm:
'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
deleteProperty: 'Ingatlan törlése',
deletePropertyConfirm:
'Biztosan törölni szeretnéd ezt a mentett ingatlant? Ez nem vonható vissza.',
bed: 'háló',
epc: 'EPC',
},
// ── Invites Page ───────────────────────────────────
@ -1321,12 +1308,6 @@ const hu: Translations = {
failedToValidate: 'Nem sikerült a meghívó hivatkozás érvényesítése',
},
// ── Map Page ───────────────────────────────────────
mapPage: {
unsavedProperty: 'Eltávolítás',
savedProperty: 'Mentve',
},
// ── Format / Time ──────────────────────────────────
format: {
justNow: 'az imént',

View file

@ -79,13 +79,6 @@ const zh: Translations = {
home: '首页',
},
// ── Toasts ─────────────────────────────────────────
toasts: {
propertySaved: '房产已保存!',
viewSaved: '查看已保存',
dontShowAgain: '不再显示',
},
// ── SEO Page Chrome ────────────────────────────────
seo: {
breadcrumb: '面包屑导航',
@ -511,8 +504,8 @@ const zh: Translations = {
'Marketing, methodology, guide, and support pages are indexable. Dashboard, account, saved searches, invites, and invitation routes are marked noindex or blocked from crawler access where appropriate.':
'营销、方法、指南和支持页面都是可索引的。仪表板、帐户、已保存的搜索、邀请和邀请路线被标记为 noindex 或在适当的情况下阻止爬网程序访问。',
'Saved search data is account-scoped': '保存的搜索数据是帐户范围内的',
'Saved searches and properties are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'保存的搜索和属性仅供登录使用。它们不包含在公共站点地图中,也不应作为公共内容进行抓取。',
'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.':
'保存的搜索和共享链接仅供登录使用。它们不包含在公共站点地图中,也不应作为公共内容进行抓取。',
'Search measurement without exposing private data': '搜索测量而不暴露私人数据',
'SEO measurement should happen on public pages using aggregated analytics and Search Console data. Private query parameters and account views shouldnt become indexable landing pages.':
'SEO 测量应该使用聚合分析和 Search Console 数据在公共页面上进行。私有查询参数和帐户视图不应成为可索引的登陆页面。',
@ -734,8 +727,6 @@ const zh: Translations = {
// ── Properties Pane ────────────────────────────────
propertyCard: {
unknownAddress: '地址未知',
unsaveProperty: '取消收藏',
saveProperty: '收藏房产',
estValue: '估计价值:',
type: '类型:',
builtForm: '建筑形式:',
@ -771,6 +762,11 @@ const zh: Translations = {
showAllStatsHint: '筛选前这里有 {{count}} 处房产。切换到全部房产即可查看该区域。',
showAllStatsFallback: '切换到全部房产即可在不应用当前筛选条件的情况下查看该区域。',
showAllStats: '显示全部房产',
closestBlockingFilters: '最接近的排除此区域的筛选条件',
lowerMinTo: '将最小值降至 {{value}}',
raiseMaxTo: '将最大值提高至 {{value}}',
allowCategory: '允许 {{value}}',
travelTo: '前往 {{destination}} 的出行',
viewProperties: '查看 {{count}} 处房产',
viewPropertiesShort: '查看房产',
priceHistory: '价格历史',
@ -1187,17 +1183,10 @@ const zh: Translations = {
searches: '搜索',
noSavedSearches: '暂无保存的搜索',
noSavedSearchesDesc: '保存您的筛选条件和地图视图,随时从上次的位置继续浏览。',
noSavedProperties: '暂无保存的房产',
noSavedPropertiesDesc: '在浏览过程中收藏房产,建立您的候选名单,不会遗漏任何一处。',
openPostcode: '打开邮编',
clickToRename: '点击重命名',
notesPlaceholder: '记下您的想法...',
deleteSearch: '删除搜索',
deleteSearchConfirm: '确定要删除这个保存的搜索吗?此操作无法撤销。',
deleteProperty: '删除房产',
deletePropertyConfirm: '确定要删除这个保存的房产吗?此操作无法撤销。',
bed: '卧室',
epc: '能源评级',
},
// ── Invites Page ───────────────────────────────────
@ -1245,12 +1234,6 @@ const zh: Translations = {
failedToValidate: '验证邀请链接失败',
},
// ── Map Page ───────────────────────────────────────
mapPage: {
unsavedProperty: '取消收藏',
savedProperty: '已收藏',
},
// ── Format / Time ──────────────────────────────────
format: {
justNow: '刚刚',

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { findActiveFilterElement } from './active-filter-scroll';
describe('findActiveFilterElement', () => {
it('returns the newest matching repeated filter card', () => {
const root = document.createElement('div');
root.innerHTML = `
<div data-filter-name="Schools" data-card="first"></div>
<div data-filter-name="price" data-card="price"></div>
<div data-filter-name="Schools" data-card="latest"></div>
`;
expect(findActiveFilterElement(root, 'Schools')?.dataset.card).toBe('latest');
});
it('matches filter names with selector-special characters', () => {
const root = document.createElement('div');
root.innerHTML = '<div data-filter-name="Political vote share:%25%20Labour:1"></div>';
expect(findActiveFilterElement(root, 'Political vote share:%25%20Labour:1')).not.toBeNull();
});
});

View file

@ -718,7 +718,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
},
{
title: 'Saved search data is account-scoped',
body: 'Saved searches and properties are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.',
body: 'Saved searches and shared links are intended for signed-in use. They arent included in the public sitemap and shouldnt be crawlable as public content.',
},
{
title: 'Search measurement without exposing private data',

View file

@ -88,6 +88,8 @@ export interface ViewChangeParams {
zoom: number;
latitude: number;
longitude: number;
visibleLatitude?: number;
visibleLongitude?: number;
}
export interface ApiResponse {
@ -196,10 +198,23 @@ export interface PricePoint {
price: number;
}
export interface FilterExclusion {
name: string;
kind: 'numeric' | 'enum' | 'poi' | 'travel';
direction: 'lower_min' | 'raise_max' | 'allow_value';
value?: number;
min?: number;
max?: number;
category?: string;
relative_difference: number;
rejected_count: number;
}
export interface HexagonStatsResponse {
count: number;
numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[];
price_history?: PricePoint[];
central_postcode?: string;
filter_exclusions?: FilterExclusion[];
}