fine 2
This commit is contained in:
parent
9e4e65fa2a
commit
ca771a7edf
32 changed files with 1467 additions and 109 deletions
|
|
@ -8,7 +8,10 @@ import type {
|
|||
FeatureMeta,
|
||||
FilterExclusion,
|
||||
HexagonStatsResponse,
|
||||
PriceMetric,
|
||||
PricePoint,
|
||||
} from '../../types';
|
||||
import { postcodeOutcode, postcodeSector } from '../../lib/postcode';
|
||||
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import {
|
||||
|
|
@ -289,6 +292,7 @@ export default function AreaPane({
|
|||
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
|
||||
}, [paneFeatureGroups, hexagonLocation]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [priceMetric, setPriceMetric] = useState<PriceMetric>('price');
|
||||
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
|
||||
restoreKey: scrollRestoreKey ?? hexagonId,
|
||||
scrollTopRef,
|
||||
|
|
@ -548,14 +552,105 @@ export default function AreaPane({
|
|||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1 ? (
|
||||
if (uniqueYears.size <= 1) return null;
|
||||
|
||||
const charts: {
|
||||
key: string;
|
||||
label: string;
|
||||
dot: string;
|
||||
points: PricePoint[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'area',
|
||||
label: t('areaPane.priceHistoryThisArea'),
|
||||
dot: 'bg-teal-500 dark:bg-teal-400',
|
||||
points: stats.price_history,
|
||||
},
|
||||
];
|
||||
// Sector/outcode context is postcode-only: a hexagon cell
|
||||
// straddles arbitrary postcodes, so its sector/outcode is not a
|
||||
// meaningful aggregation unit. The Total/Per-m² toggle still
|
||||
// applies to the area chart in both cases.
|
||||
if (isPostcode && hexagonId) {
|
||||
const sector = postcodeSector(hexagonId);
|
||||
const outcode = postcodeOutcode(hexagonId);
|
||||
if (sector && stats.sector_price_history?.length) {
|
||||
charts.push({
|
||||
key: 'sector',
|
||||
label: sector,
|
||||
dot: 'bg-indigo-500 dark:bg-indigo-400',
|
||||
points: stats.sector_price_history,
|
||||
});
|
||||
}
|
||||
if (outcode && stats.outcode_price_history?.length) {
|
||||
charts.push({
|
||||
key: 'outcode',
|
||||
label: outcode,
|
||||
dot: 'bg-amber-500 dark:bg-amber-400',
|
||||
points: stats.outcode_price_history,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasPerSqm = charts.some((c) =>
|
||||
c.points.some((p) => p.price_per_sqm != null)
|
||||
);
|
||||
const metric: PriceMetric = hasPerSqm ? priceMetric : 'price';
|
||||
const optionClass = (active: boolean) =>
|
||||
`px-2 py-0.5 text-[11px] font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
|
||||
active
|
||||
? 'bg-teal-600 text-white dark:bg-teal-500'
|
||||
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{t('areaPane.priceHistory')}
|
||||
</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{t('areaPane.priceHistory')}
|
||||
</span>
|
||||
{hasPerSqm && (
|
||||
<div
|
||||
className="grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700"
|
||||
role="radiogroup"
|
||||
aria-label={t('areaPane.priceMetric')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={metric === 'price'}
|
||||
onClick={() => setPriceMetric('price')}
|
||||
className={optionClass(metric === 'price')}
|
||||
>
|
||||
{t('areaPane.priceMetricTotal')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={metric === 'price_per_sqm'}
|
||||
onClick={() => setPriceMetric('price_per_sqm')}
|
||||
className={optionClass(metric === 'price_per_sqm')}
|
||||
>
|
||||
{t('areaPane.priceMetricPerSqm')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{charts.map((chart) => (
|
||||
<div key={chart.key} className={chart.key === 'area' ? '' : 'mt-1.5'}>
|
||||
{charts.length > 1 && (
|
||||
<span className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-warm-500 dark:text-warm-400">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${chart.dot}`}
|
||||
/>
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
<PriceHistoryChart points={chart.points} metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
})()}
|
||||
{displayFeatureGroups.map((group) => {
|
||||
const showNearbyStations =
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export default function FeatureBrowser({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="shrink-0 px-2 py-1 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
|
|
@ -125,7 +125,7 @@ export default function FeatureBrowser({
|
|||
toggleGroup(group.name);
|
||||
onGroupToggle?.(group.name, willExpand);
|
||||
}}
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length +
|
||||
|
|
@ -141,7 +141,7 @@ export default function FeatureBrowser({
|
|||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0"
|
||||
|
|
@ -184,7 +184,7 @@ export default function FeatureBrowser({
|
|||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} size="sm" description={f.description} />
|
||||
|
|
|
|||
97
frontend/src/components/map/ListingPopups.test.tsx
Normal file
97
frontend/src/components/map/ListingPopups.test.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
|
||||
import { ListingPopupSingleContent } from './ListingPopups';
|
||||
import type { ActualListing } from '../../types';
|
||||
|
||||
// No global RTL setup file registers auto-cleanup, so unmount between cases to
|
||||
// keep each render isolated in the shared jsdom document.
|
||||
afterEach(cleanup);
|
||||
|
||||
// Minimal react-i18next stub: t() echoes a readable label per key; i18n.language
|
||||
// is fixed so date formatting is deterministic.
|
||||
vi.mock('react-i18next', () => {
|
||||
const labels: Record<string, string> = {
|
||||
'listing.priceHistory': 'Price history',
|
||||
'listing.priceListed': 'Listed',
|
||||
'listing.priceReduced': 'Reduced',
|
||||
'listing.priceIncreased': 'Increased',
|
||||
'listing.openListing': 'Open listing',
|
||||
'listing.viewed': 'Viewed',
|
||||
};
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
labels[key] ?? (opts ? `${key} ${JSON.stringify(opts)}` : key),
|
||||
i18n: { language: 'en-GB' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function baseListing(overrides: Partial<ActualListing> = {}): ActualListing {
|
||||
return {
|
||||
lat: 51.5,
|
||||
lon: -0.1,
|
||||
postcode: 'SW9 0HD',
|
||||
listing_url: 'https://www.rightmove.co.uk/properties/1',
|
||||
asking_price: 490000,
|
||||
features: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ListingPopupSingleContent price history', () => {
|
||||
it('renders points newest-first with signed deltas and reason labels', () => {
|
||||
const listing = baseListing({
|
||||
price_history: [
|
||||
{ date: '2026-07-01', price: 500000, reason: 'listed' },
|
||||
{ date: '2026-07-19', price: 480000, reason: 'reduced' },
|
||||
{ date: '2026-07-26', price: 490000, reason: 'increased' },
|
||||
],
|
||||
});
|
||||
const { getByText, container } = render(
|
||||
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
|
||||
);
|
||||
|
||||
getByText('Price history');
|
||||
// Newest first: increased row shows +£10,000 vs the £480k point before it.
|
||||
const items = Array.from(container.querySelectorAll('ol li'));
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].textContent).toContain('£490,000');
|
||||
expect(items[0].textContent).toContain('+£10,000');
|
||||
expect(items[0].textContent).toContain('Increased');
|
||||
// Middle: the reduction from £500k -> £480k.
|
||||
expect(items[1].textContent).toContain('£480,000');
|
||||
expect(items[1].textContent).toContain('−£20,000');
|
||||
expect(items[1].textContent).toContain('Reduced');
|
||||
// Oldest: the initial listing, no delta.
|
||||
expect(items[2].textContent).toContain('£500,000');
|
||||
expect(items[2].textContent).toContain('Listed');
|
||||
expect(items[2].textContent).not.toContain('£0');
|
||||
// UTC-stable date: "2026-07-01" must read as 1 Jul regardless of runner TZ.
|
||||
expect(items[2].textContent).toContain('1 Jul 2026');
|
||||
});
|
||||
|
||||
it('renders a single listed point with no delta', () => {
|
||||
const listing = baseListing({
|
||||
price_history: [{ date: '2026-06-15', price: 325000, reason: 'listed' }],
|
||||
});
|
||||
const { getByText, container } = render(
|
||||
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
|
||||
);
|
||||
getByText('Price history');
|
||||
expect(container.querySelectorAll('ol li')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('omits the section entirely when there is no history', () => {
|
||||
const { queryByText } = render(
|
||||
<ListingPopupSingleContent
|
||||
listing={baseListing({ price_history: [] })}
|
||||
clickedUrls={new Set()}
|
||||
onOpen={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(queryByText('Price history')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,12 +2,89 @@ import { memo } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { ActualListing } from '../../types';
|
||||
import type { ActualListing, PriceHistoryPoint } from '../../types';
|
||||
|
||||
function formatListingPrice(price: number): string {
|
||||
return `£${price.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function formatHistoryDate(iso: string, locale: string): string {
|
||||
const parsed = new Date(iso);
|
||||
if (Number.isNaN(parsed.getTime())) return iso;
|
||||
// `iso` is a UTC calendar date ("YYYY-MM-DD"), parsed as UTC midnight. Format
|
||||
// in UTC too, or a viewer west of UTC would see every date shifted a day back.
|
||||
return parsed.toLocaleDateString(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string, t: TFunction): string {
|
||||
switch (reason) {
|
||||
case 'reduced':
|
||||
return t('listing.priceReduced');
|
||||
case 'increased':
|
||||
return t('listing.priceIncreased');
|
||||
default:
|
||||
return t('listing.priceListed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact asking-price timeline for the hover card. Renders most-recent first,
|
||||
* with the £ change from the prior point on each reduction/increase. Accrues from
|
||||
* the scraper's forward-only store, so a fresh listing shows a single point. */
|
||||
function ListingPriceHistory({ history }: { history: PriceHistoryPoint[] }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
if (history.length === 0) return null;
|
||||
// history is oldest -> newest; show up to the 5 most recent, newest first.
|
||||
const shown = history.slice(-5);
|
||||
const baseIndex = history.length - shown.length;
|
||||
const rows = shown
|
||||
.map((point, idx) => {
|
||||
const globalIdx = baseIndex + idx;
|
||||
const prev = globalIdx > 0 ? history[globalIdx - 1] : null;
|
||||
const delta = prev ? point.price - prev.price : 0;
|
||||
return { point, delta };
|
||||
})
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-warm-100 pt-1.5 dark:border-warm-700/60">
|
||||
<div className="text-[11px] font-medium text-warm-500 dark:text-warm-400">
|
||||
{t('listing.priceHistory')}
|
||||
</div>
|
||||
<ol className="mt-1 space-y-0.5">
|
||||
{rows.map(({ point, delta }, idx) => {
|
||||
const isReduction = point.reason === 'reduced';
|
||||
const isIncrease = point.reason === 'increased';
|
||||
const accent = isReduction
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: isIncrease
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-warm-600 dark:text-warm-300';
|
||||
return (
|
||||
<li key={idx} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className={`font-semibold ${accent}`}>{formatListingPrice(point.price)}</span>
|
||||
{delta !== 0 && (
|
||||
<span className={accent}>
|
||||
{delta < 0 ? '−' : '+'}£{Math.abs(delta).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-warm-400 dark:text-warm-500">
|
||||
{reasonLabel(point.reason, t)} · {formatHistoryDate(point.date, i18n.language)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
|
||||
const parts: string[] = [];
|
||||
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
|
||||
|
|
@ -78,6 +155,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
|
|||
))}
|
||||
</ul>
|
||||
)}
|
||||
{listing.price_history && listing.price_history.length > 0 && (
|
||||
<ListingPriceHistory history={listing.price_history} />
|
||||
)}
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
|
||||
{visited && (
|
||||
<span className="text-violet-600 dark:text-violet-400">✓ {t('listing.viewed')}</span>
|
||||
|
|
|
|||
|
|
@ -710,6 +710,11 @@ export default function MapPage({
|
|||
const shareAndSaveView = isMobile
|
||||
? (mapData.currentVisibleView ?? mapData.currentView)
|
||||
: mapData.currentView;
|
||||
// Params for share/save/checkout-return/last-session, deliberately WITHOUT the
|
||||
// selected postcode: the lat/lon/zoom already convey the location, so focusing
|
||||
// a postcode adds nothing to a shared link and shouldn't be baked into a saved
|
||||
// search. The live URL (useUrlSync above) still carries `pc` so a reload
|
||||
// re-opens the selection.
|
||||
const dashboardParams = useMemo(
|
||||
() =>
|
||||
stateToParams(
|
||||
|
|
@ -723,7 +728,7 @@ export default function MapPage({
|
|||
activeOverlays,
|
||||
basemap,
|
||||
crimeTypes,
|
||||
selectedPostcodeParam,
|
||||
undefined,
|
||||
colorOpacity,
|
||||
listingsMode
|
||||
).toString(),
|
||||
|
|
@ -738,7 +743,6 @@ export default function MapPage({
|
|||
listingsMode,
|
||||
rightPaneTab,
|
||||
selectedPOICategories,
|
||||
selectedPostcodeParam,
|
||||
shareCode,
|
||||
shareAndSaveView,
|
||||
]
|
||||
|
|
@ -894,6 +898,9 @@ export default function MapPage({
|
|||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
statsUseFilters={areaStatsUseFilters}
|
||||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||||
filtersActive={Object.keys(filters).length + activeEntries.length > 0}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
scrollTopRef={propertiesPaneScrollTopRef}
|
||||
scrollRestoreKey={
|
||||
|
|
@ -903,7 +910,17 @@ export default function MapPage({
|
|||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon]
|
||||
[
|
||||
activeEntries,
|
||||
areaStatsUseFilters,
|
||||
filters,
|
||||
handleLoadMoreProperties,
|
||||
loadingProperties,
|
||||
properties,
|
||||
propertiesTotal,
|
||||
selectedHexagon,
|
||||
setAreaStatsUseFilters,
|
||||
]
|
||||
);
|
||||
|
||||
const poiPane = useMemo(
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import type { PricePoint } from '../../types';
|
||||
import type { PriceMetric, PricePoint } from '../../types';
|
||||
import { formatValue } from '../../lib/format';
|
||||
|
||||
interface PriceHistoryChartProps {
|
||||
points: PricePoint[];
|
||||
/** Which value to plot. Defaults to the absolute sale price. */
|
||||
metric?: PriceMetric;
|
||||
}
|
||||
|
||||
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
|
||||
|
|
@ -22,7 +24,7 @@ interface PriceScale {
|
|||
ticks: number[];
|
||||
}
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
export default function PriceHistoryChart({ points, metric = 'price' }: PriceHistoryChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
|
|
@ -37,7 +39,18 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Project each point onto the chosen metric as a plain {year, price} so the
|
||||
// rest of the chart is metric-agnostic. In per-m² mode, points without a
|
||||
// recorded floor area drop out.
|
||||
const plotPoints = useMemo<PricePoint[]>(() => {
|
||||
if (metric === 'price') return points;
|
||||
return points
|
||||
.filter((p) => Number.isFinite(p.price_per_sqm))
|
||||
.map((p) => ({ year: p.year, price: p.price_per_sqm as number }));
|
||||
}, [points, metric]);
|
||||
|
||||
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
|
||||
const points = plotPoints;
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity;
|
||||
for (const p of points) {
|
||||
|
|
@ -83,7 +96,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
priceScale: getPriceScale(points),
|
||||
medians: meds,
|
||||
};
|
||||
}, [points]);
|
||||
}, [plotPoints]);
|
||||
|
||||
const plotW = width - PADDING.left - PADDING.right;
|
||||
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
||||
|
|
@ -104,7 +117,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
|
||||
return (
|
||||
<div ref={containerRef} style={{ height: HEIGHT }}>
|
||||
{width > 0 && (
|
||||
{width > 0 && plotPoints.length > 0 && (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
{/* Grid lines */}
|
||||
{priceScale.ticks.map((tick) => (
|
||||
|
|
@ -120,7 +133,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
))}
|
||||
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
{plotPoints.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={scaleX(p.year)}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ interface PropertiesPaneProps {
|
|||
total: number;
|
||||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
statsUseFilters: boolean;
|
||||
onStatsUseFiltersChange: (useFilters: boolean) => void;
|
||||
filtersActive: boolean;
|
||||
onLoadMore: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
scrollTopRef?: MutableRefObject<number>;
|
||||
|
|
@ -35,6 +38,9 @@ export function PropertiesPane({
|
|||
total,
|
||||
loading,
|
||||
hexagonId,
|
||||
statsUseFilters,
|
||||
onStatsUseFiltersChange,
|
||||
filtersActive,
|
||||
onLoadMore,
|
||||
onNavigateToSource,
|
||||
scrollTopRef,
|
||||
|
|
@ -106,13 +112,41 @@ export function PropertiesPane({
|
|||
</InfoPopup>
|
||||
)}
|
||||
|
||||
<div className="p-2">
|
||||
<div className="p-2 space-y-2">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('propertyCard.searchPlaceholder')}
|
||||
className="p-2"
|
||||
/>
|
||||
{filtersActive && (
|
||||
<div className="grid grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={statsUseFilters}
|
||||
onClick={() => onStatsUseFiltersChange(true)}
|
||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
||||
statsUseFilters
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.matchingFiltersOption')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!statsUseFilters}
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
||||
!statsUseFilters
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.allPropertiesOption')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -448,12 +448,12 @@ export function ActiveFilterList({
|
|||
onToggleGroup(group.name);
|
||||
onGroupToggle?.(group.name, !expanded);
|
||||
}}
|
||||
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="sticky top-0 z-30 px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{expanded && (
|
||||
<div className="px-2 py-1.5 space-y-3.5">
|
||||
<div className="px-2 py-1 space-y-2">
|
||||
{group.name === TRANSPORT_GROUP && travelCards}
|
||||
{group.features.map((feature) => renderFeatureCard(feature))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -151,7 +151,7 @@ export function ElectionVoteShareFilterCard({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.party')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export function NumericFeatureFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`space-y-0.5 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export function PoiDistanceFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={filterName}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -156,7 +156,7 @@ export function PoiDistanceFilterCard({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.poiType')}
|
||||
</label>
|
||||
<PoiTypeDropdown
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export function SchoolFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={SCHOOL_FILTER_NAME}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`space-y-1 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel
|
||||
|
|
@ -127,7 +127,7 @@ export function SchoolFilterCard({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.schoolType')}
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export function SliderLabels({
|
|||
|
||||
if (feature && onValueChange) {
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<div className="relative h-4 mt-1.5 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<EditableLabel
|
||||
value={labels[0]}
|
||||
formatted={minLabel}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export function VariantFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={config.filterName}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -190,7 +190,7 @@ export function VariantFilterCard({
|
|||
so the dropdown is hidden and only the window toggle + slider remain. */}
|
||||
{variantOptions.length > 1 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t(config.dropdownLabelKey)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
@ -216,7 +216,7 @@ export function VariantFilterCard({
|
|||
{windowConfig && currentWindow && windowOptions.length > 1 && (
|
||||
<div>
|
||||
{windowConfig.labelKey && (
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t(windowConfig.labelKey)}
|
||||
</label>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export function DesktopMapPage({
|
|||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
|
||||
<div
|
||||
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"
|
||||
className="w-2 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="flex flex-col gap-1.5">
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export type Page =
|
|||
| 'privacy'
|
||||
| 'account'
|
||||
| 'saved'
|
||||
| 'invite';
|
||||
| 'invite'
|
||||
| 'reset-password';
|
||||
|
||||
export interface HeaderExportState {
|
||||
onExport: (options?: { postcodes?: string[] }) => void;
|
||||
|
|
@ -65,6 +66,7 @@ export const PAGE_PATHS: Record<Page, string> = {
|
|||
saved: '/saved',
|
||||
account: '/account',
|
||||
invite: '/invite',
|
||||
'reset-password': '/reset-password',
|
||||
};
|
||||
|
||||
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
|
||||
|
|
@ -206,33 +208,6 @@ export default function Header({
|
|||
return (
|
||||
<>
|
||||
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
|
||||
{showEditingBar && (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-0 flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto flex items-center gap-3 max-w-[60%]">
|
||||
<span className="text-sm text-warm-300 truncate" title={editingSearch.name}>
|
||||
<Trans
|
||||
i18nKey="savedPage.isBeingUpdated"
|
||||
values={{ name: editingSearch.name }}
|
||||
components={{ strong: <strong className="font-semibold text-white" /> }}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelEdit}
|
||||
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpdateEdit}
|
||||
disabled={savingSearch || dashboardActionsBlocked}
|
||||
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Left: Logo + nav */}
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<a
|
||||
|
|
@ -246,9 +221,9 @@ export default function Header({
|
|||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav: hidden while the saved-search "is being updated" banner
|
||||
is shown so the centered pointer-events-auto banner can't overlap (and
|
||||
block clicks on) the Invite Friends / Learn / Pricing links at ~1366px. */}
|
||||
{/* Desktop nav: hidden while the saved-search "is being updated" bar is
|
||||
shown so the centered edit bar has room and the header can't get
|
||||
cramped (the bar would otherwise crowd the Learn / Pricing links). */}
|
||||
{!useSidebarNav && !showEditingBar && (
|
||||
<nav className="top-menu flex items-center">
|
||||
<a
|
||||
|
|
@ -287,6 +262,36 @@ export default function Header({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Saved-search "is being updated" bar. In normal flow (not an absolute
|
||||
overlay) between the logo and the right-side actions so flexbox
|
||||
reserves its space: the Cancel / Update buttons can never overlap the
|
||||
Share / Export buttons. Text truncates; the buttons stay put. */}
|
||||
{showEditingBar && (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-3 px-3">
|
||||
<span className="min-w-0 truncate text-sm text-warm-300" title={editingSearch.name}>
|
||||
<Trans
|
||||
i18nKey="savedPage.isBeingUpdated"
|
||||
values={{ name: editingSearch.name }}
|
||||
components={{ strong: <strong className="font-semibold text-white" /> }}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelEdit}
|
||||
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpdateEdit}
|
||||
disabled={savingSearch || dashboardActionsBlocked}
|
||||
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click
|
||||
|
|
|
|||
22
frontend/src/components/ui/icons/EyeOffIcon.tsx
Normal file
22
frontend/src/components/ui/icons/EyeOffIcon.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EyeOffIcon({ className = 'w-7 h-7' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<path d="M6.61 6.61A13.526 13.526 0 0 0 1 12s4 8 11 8a9.74 9.74 0 0 0 5.39-1.61" />
|
||||
<line x1="2" y1="2" x2="22" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export { CloseIcon } from './CloseIcon';
|
|||
export { DownloadIcon } from './DownloadIcon';
|
||||
export { ExpandIcon } from './ExpandIcon';
|
||||
export { EyeIcon } from './EyeIcon';
|
||||
export { EyeOffIcon } from './EyeOffIcon';
|
||||
export { FilterIcon } from './FilterIcon';
|
||||
export { GoogleIcon } from './GoogleIcon';
|
||||
export { GraduationCapIcon } from './GraduationCapIcon';
|
||||
|
|
|
|||
|
|
@ -93,23 +93,23 @@ interface UseFiltersOptions {
|
|||
onFilterLimitReached?: () => void;
|
||||
}
|
||||
|
||||
// Applied in order: each normalizer folds its own raw feature names into a single
|
||||
// folded filter. Council folds AFTER tenure so a bare "% Social rent" is claimed by
|
||||
// tenure first.
|
||||
const FILTER_NORMALIZERS: Array<(filters: FeatureFilters) => FeatureFilters> = [
|
||||
normalizeSchoolFilters,
|
||||
normalizeSpecificCrimeFilters,
|
||||
normalizeCrimeSeverityFilters,
|
||||
normalizeElectionVoteShareFilters,
|
||||
normalizeEthnicityFilters,
|
||||
normalizeQualificationFilters,
|
||||
normalizeTenureFilters,
|
||||
normalizeCouncilFilters,
|
||||
normalizePoiDistanceFilters,
|
||||
];
|
||||
|
||||
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
||||
return normalizePoiDistanceFilters(
|
||||
// Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure.
|
||||
normalizeCouncilFilters(
|
||||
normalizeTenureFilters(
|
||||
normalizeQualificationFilters(
|
||||
normalizeEthnicityFilters(
|
||||
normalizeElectionVoteShareFilters(
|
||||
normalizeCrimeSeverityFilters(
|
||||
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
return FILTER_NORMALIZERS.reduce((acc, normalize) => normalize(acc), filters);
|
||||
}
|
||||
|
||||
function getBackendFeatureName(name: string): string {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import pb from '../lib/pocketbase';
|
||||
import { apiUrl, authHeaders } from '../lib/api';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import { stripSelectedPostcodeParam } from '../lib/url-state';
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
|
|
@ -141,7 +142,11 @@ export function useSavedSearches(userId: string | null) {
|
|||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
|
||||
// A saved search stores filter criteria, so drop the transient
|
||||
// selected-postcode param (a search/map-click leaves it in the URL).
|
||||
const params = stripSelectedPostcodeParam(
|
||||
paramsOverride ?? window.location.search.replace(/^\?/, '')
|
||||
);
|
||||
|
||||
// Create record immediately without screenshot
|
||||
const formData = new FormData();
|
||||
|
|
@ -220,11 +225,14 @@ export function useSavedSearches(userId: string | null) {
|
|||
);
|
||||
|
||||
const updateSearchParams = useCallback(
|
||||
async (id: string, params: string) => {
|
||||
async (id: string, rawParams: string) => {
|
||||
if (!userId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Match saveSearch: a saved search never carries the transient
|
||||
// selected-postcode param.
|
||||
const params = stripSelectedPostcodeParam(rawParams);
|
||||
const record = await pb.collection('saved_searches').update(id, { params });
|
||||
trackEvent('Search Update');
|
||||
setSearches((prev) =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ViewState } from '../types';
|
||||
import type { TravelTimeInitial } from '../hooks/useTravelTime';
|
||||
|
||||
export const INITIAL_RETRY_MS = 1000;
|
||||
export const MAX_RETRY_MS = 10000;
|
||||
|
|
@ -45,14 +46,32 @@ export function filterCapFor(isLoggedIn: boolean, filtersUnlimited: boolean): nu
|
|||
}
|
||||
|
||||
/** Funnel fix (growth): a cold map open lands empty, so first-time visitors never feel the value
|
||||
* or the 3-filter cap. These two high-intent filters (value for money + good secondary schools)
|
||||
* are pre-seeded when the map opens with no filters in the URL, so the map is immediately useful
|
||||
* and one more filter hits the cap. Deep links (OG screenshots, the SEO landing-page CTAs) carry
|
||||
* their own filters and are left untouched. Unknown feature names are dropped safely by useFilters.
|
||||
* Tune or empty this object to change/disable the behaviour. */
|
||||
* or the 3-filter cap. On a cold open (no filters AND no travel time in the URL) we pre-seed the
|
||||
* price filter plus a public-transport commute card, so the map is immediately useful and framed
|
||||
* around the two highest-intent decisions: budget and commute. Deep links (OG screenshots, the
|
||||
* SEO landing-page CTAs) carry their own filters/travel and are left untouched. Unknown feature
|
||||
* names are dropped safely by useFilters. Tune or empty these to change/disable the behaviour. */
|
||||
export const DEFAULT_DEMO_FILTERS: Record<string, [number, number]> = {
|
||||
// 'Est. price per sqm': [0, 7000],
|
||||
// 'Good+ secondary school catchments': [1, 11],
|
||||
'Estimated current price': [0, 600000],
|
||||
};
|
||||
|
||||
/** The public-transport commute half of the cold-open defaults. A single transit entry with no
|
||||
* destination selected: it renders the "Public Transport" card (prompting the visitor to pick a
|
||||
* destination) without filtering the map or touching the URL until they choose one. Mirrors what
|
||||
* clicking "add public transport" produces, so it flows through useTravelTime unchanged. */
|
||||
export const DEFAULT_DEMO_TRAVEL: TravelTimeInitial = {
|
||||
entries: [
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: '',
|
||||
label: '',
|
||||
timeRange: null,
|
||||
useBest: false,
|
||||
noChange: false,
|
||||
oneChange: false,
|
||||
noBuses: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const POI_DISTANCE_FILTER_KEY_PREFIX = `${POI_DISTANCE_FILTER_NAME}:`;
|
|||
|
||||
const TRANSPORT_POI_CATEGORIES = new Set([
|
||||
'Airport',
|
||||
'Any station',
|
||||
'Bus station',
|
||||
'Bus stop',
|
||||
'DLR station',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import { parseUrlState, stateToParams } from './url-state';
|
||||
import { parseUrlState, stateToParams, stripSelectedPostcodeParam } from './url-state';
|
||||
import { DEFAULT_OVERLAY_IDS } from './overlays';
|
||||
import { INITIAL_VIEW_STATE } from './consts';
|
||||
import { createSchoolFilterKey } from './school-filter';
|
||||
|
|
@ -60,6 +60,24 @@ describe('url-state', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('strips the selected-postcode param for saved searches, keeping filters intact', () => {
|
||||
const params =
|
||||
'lat=51.5074&lon=-0.1278&zoom=12.5&filter=Last%20known%20price:100000:500000&pc=SW1A%201AA&poi=supermarket';
|
||||
|
||||
const stripped = stripSelectedPostcodeParam(params);
|
||||
const result = new URLSearchParams(stripped);
|
||||
|
||||
expect(result.has('pc')).toBe(false);
|
||||
expect(result.get('filter')).toBe('Last known price:100000:500000');
|
||||
expect(result.get('poi')).toBe('supermarket');
|
||||
expect(result.get('lat')).toBe('51.5074');
|
||||
});
|
||||
|
||||
it('returns the query string unchanged when no selected-postcode param is present', () => {
|
||||
const params = 'filter=Last%20known%20price:100000:500000&poi=supermarket';
|
||||
expect(stripSelectedPostcodeParam(params)).toBe(params);
|
||||
});
|
||||
|
||||
it('leaves POIs unselected when URL params are omitted', () => {
|
||||
const state = parseUrlState();
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,23 @@ const CRIME_TYPES_NONE_PARAM = '__none';
|
|||
const OVERLAY_NONE_PARAM = '__none';
|
||||
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
||||
|
||||
/** URL param holding the currently focused postcode (from a search or a map
|
||||
* click). It reflects a transient selection, not search criteria, so a live or
|
||||
* shared link keeps it but a saved search must not bake it in. */
|
||||
export const SELECTED_POSTCODE_PARAM = 'pc';
|
||||
|
||||
/**
|
||||
* Drop the transient selected-postcode param from a serialized query string so a
|
||||
* saved search captures the filter criteria, not whichever postcode the user
|
||||
* last clicked. Takes and returns a query string without the leading '?'.
|
||||
*/
|
||||
export function stripSelectedPostcodeParam(params: string): string {
|
||||
const parsed = new URLSearchParams(params);
|
||||
if (!parsed.has(SELECTED_POSTCODE_PARAM)) return params;
|
||||
parsed.delete(SELECTED_POSTCODE_PARAM);
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export interface UrlState {
|
||||
viewState: ViewState;
|
||||
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
|
||||
|
|
@ -406,7 +423,7 @@ export function parseUrlState(): UrlState {
|
|||
|
||||
// Selected postcode. This is also accepted as the historical one-time
|
||||
// navigate-to-postcode param used by saved-property links.
|
||||
const pc = params.get('pc');
|
||||
const pc = params.get(SELECTED_POSTCODE_PARAM);
|
||||
if (pc) {
|
||||
result.postcode = pc;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,15 @@ export interface ActualListing {
|
|||
listing_status?: string;
|
||||
listing_date_iso?: string;
|
||||
features: string[];
|
||||
price_history?: PriceHistoryPoint[];
|
||||
}
|
||||
|
||||
/** One observed point on a listing's asking-price timeline, accrued across
|
||||
* scrapes. `reason` is 'listed' | 'reduced' | 'increased'; `date` is YYYY-MM-DD. */
|
||||
export interface PriceHistoryPoint {
|
||||
date: string;
|
||||
price: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ActualListingsResponse {
|
||||
|
|
@ -303,8 +312,14 @@ export interface EnumFeatureStats {
|
|||
export interface PricePoint {
|
||||
year: number;
|
||||
price: number;
|
||||
/** Sale price per square metre (sale price / EPC floor area). Absent where no
|
||||
* floor area is recorded; the per-m² view drops those points. */
|
||||
price_per_sqm?: number;
|
||||
}
|
||||
|
||||
/** Which value a price-history chart plots. */
|
||||
export type PriceMetric = 'price' | 'price_per_sqm';
|
||||
|
||||
export interface CrimeYearPoint {
|
||||
year: number;
|
||||
count: number;
|
||||
|
|
@ -369,6 +384,12 @@ export interface HexagonStatsResponse {
|
|||
numeric_features: NumericFeatureStats[];
|
||||
enum_features: EnumFeatureStats[];
|
||||
price_history?: PricePoint[];
|
||||
/** Price history for every sale in the selection's postcode sector (e.g.
|
||||
* "E14 2"), filter-independent: wider-area context for the selection's chart. */
|
||||
sector_price_history?: PricePoint[];
|
||||
/** Price history for every sale in the selection's outward code (e.g. "E14"),
|
||||
* filter-independent. */
|
||||
outcode_price_history?: PricePoint[];
|
||||
/** Per-crime-type per-year counts averaged across the selection. */
|
||||
crime_by_year?: CrimeYearStats[];
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue