diff --git a/frontend/public/video/twin-beckenham-croydon.vtt b/frontend/public/video/twin-beckenham-croydon.vtt new file mode 100644 index 0000000..b75b2fe --- /dev/null +++ b/frontend/public/video/twin-beckenham-croydon.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:00.203 --> 00:00:06.283 +Beckenham and Croydon sit side by side: same trains, same school catchment. + +00:00:06.683 --> 00:00:10.523 +Rank them by what each pound of floor space actually buys. + +00:00:11.823 --> 00:00:16.943 +One postcode over, the same home quietly costs about a third less. + +00:00:18.093 --> 00:00:22.893 +Beckenham's cheaper twin is on this map. Find yours, free. + diff --git a/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt b/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt new file mode 100644 index 0000000..1ecb5bb --- /dev/null +++ b/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:00.202 --> 00:00:05.882 +Stanmore and Kenton sit right next door, with the same schools and transport links. + +00:00:06.282 --> 00:00:10.842 +Rank every postcode by what each pound of floor space actually buys. + +00:00:12.142 --> 00:00:17.422 +One postcode over, the same home quietly costs about a sixth less. + +00:00:18.572 --> 00:00:22.652 +Stanmore's cheaper twin is on this map. Find yours, free. + diff --git a/frontend/public/video/twin-l16-7-vs-l14-6.vtt b/frontend/public/video/twin-l16-7-vs-l14-6.vtt new file mode 100644 index 0000000..1ce3afc --- /dev/null +++ b/frontend/public/video/twin-l16-7-vs-l14-6.vtt @@ -0,0 +1,14 @@ +WEBVTT + +00:00:00.203 --> 00:00:06.843 +Childwall and Broadgreen sit right next door, with the same schools and transport links. + +00:00:07.243 --> 00:00:11.803 +Rank every postcode by what each pound of floor space actually buys. + +00:00:13.103 --> 00:00:18.223 +One postcode over, the same home quietly costs about a third less. + +00:00:19.373 --> 00:00:24.493 +Childwall's cheaper twin is on this map. Find yours, free. + diff --git a/frontend/scripts/check-translations.mjs b/frontend/scripts/check-translations.mjs index d0c4151..bc793dc 100644 --- a/frontend/scripts/check-translations.mjs +++ b/frontend/scripts/check-translations.mjs @@ -16,7 +16,7 @@ // present in en.ts > server so they can be translated. // // The script parses the TypeScript source with the compiler API and walks the -// AST — no runtime import, no transpilation, no temp files. Run it with: +// AST: no runtime import, no transpilation, no temp files. Run it with: // node frontend/scripts/check-translations.mjs import { readFileSync, readdirSync } from 'fs'; @@ -106,7 +106,7 @@ function parseFile(path) { } // Recursively turn a TS literal expression into a plain JS value. -// Returns undefined for nodes we don't understand — callers must check. +// Returns undefined for nodes we don't understand. Callers must check. function literalToJs(node) { if (!node) return undefined; if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; @@ -217,7 +217,7 @@ function readServerFeatureNames() { // Names of the Enum/Numeric feature *configs* (each needs a description + detail // translation). We take the FIRST `name:` field after every Feature::Enum( / // Feature::Numeric( opening. This deliberately skips macro-generated configs -// whose name is a `concat!(...)` expression (the crime rates — handled via +// whose name is a `concat!(...)` expression (the crime rates, handled via // deriveLegacyCrimeKeys instead) and stops the lazy match from running past such // a config into an unrelated FeatureGroup `name:` (which previously made the // group name "Properties" look like a required feature). @@ -302,7 +302,7 @@ function checkLeafConsistency(path, enValue, trValue, lang) { const got = tokenMultiset(trValue, re); if (!multisetsEqual(want, got)) { fail( - `[${lang}] ${path}: ${label} mismatch — en=${JSON.stringify(want)} ` + + `[${lang}] ${path}: ${label} mismatch, en=${JSON.stringify(want)} ` + `${lang}=${JSON.stringify(got)}` ); } @@ -425,7 +425,7 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys, required // Every key here must also be a translatable feature name in en.ts > server, // or a legacy crime key that maps onto the per-window server keys (see // deriveLegacyCrimeKeys / legacyCrimeFeatureKey). Otherwise the description is - // unreachable — ts() looks up server.${name}. + // unreachable: ts() looks up server.${name}. for (const key of union) { if (!serverKeys.has(key) && !legacyCrimeKeys.has(key)) { fail(`${file}: key "${key}" has no matching entry in en.ts > server`); @@ -532,7 +532,7 @@ function main() { console.error(`\n${errors.length} translation error(s).`); process.exit(1); } - console.log(`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`); + console.log(`i18n OK: ${supportedCodes.length} languages, ${warnings.length} warning(s).`); } main(); diff --git a/frontend/scripts/prerender.mjs b/frontend/scripts/prerender.mjs index ca60f54..a6cbafc 100644 --- a/frontend/scripts/prerender.mjs +++ b/frontend/scripts/prerender.mjs @@ -35,7 +35,7 @@ const ROUTES = [ output: 'property-price-map/index.html', title: 'Property price map for England - Compare postcodes before viewing', description: - 'Compare sold prices, estimated current value, price per square metre and local context across English postcodes before searching listings.', + 'Compare sold prices, estimated current value, price per square metre and local context across English postcodes to find the underpriced ones.', }, { path: '/postcode-property-search', @@ -127,7 +127,7 @@ const FAQ_SCHEMA_ITEMS = [ { question: 'Where should I look once the obvious areas are too expensive?', answer: - 'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map removes postcodes that fail those tests, so overlooked areas can surface before you start searching listings.', + 'Set your budget, property type, floor area, commute, schools, crime, noise, broadband, parks, and other must-haves. The map ranks every postcode in England on those measures, so overlooked, underpriced areas (the cheaper twins of the names everyone knows) rise to the top.', }, { question: 'What should I do when my search returns too many or too few areas?', @@ -145,9 +145,9 @@ const FAQ_SCHEMA_ITEMS = [ 'The estimate starts with the last HM Land Registry sale price, adjusts it to current-market terms using repeat-sales modelling and fallback models, then blends that result with a nearest-neighbour estimate from nearby, recently sold, same-type homes.', }, { - question: 'Does Perfect Postcode replace Rightmove, Zoopla and OnTheMarket?', + question: 'How is Perfect Postcode different from Rightmove, Zoopla and OnTheMarket?', answer: - 'No. Perfect Postcode helps you choose the right postcode from area data; the listing portals are still where you check live availability, photos, agent contact, viewings and alerts.', + 'Listing portals show you individual homes that are for sale right now. Perfect Postcode is where you decide which postcode to buy in, ranking every postcode in England on 200+ data fields the portals never compare, from £/sqm to crime, schools and commute.', }, ]; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index efe20a8..f3bfd1f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -640,6 +640,7 @@ export default function App() { initialCrimeTypes={urlState.crimeTypes} initialBasemap={urlState.basemap} initialColorOpacity={urlState.colorOpacity} + initialListingsMode={urlState.listings} initialTab={urlState.tab} initialLoading={initialLoading} theme={theme} @@ -754,6 +755,7 @@ export default function App() { initialCrimeTypes={mapUrlState.crimeTypes} initialBasemap={mapUrlState.basemap} initialColorOpacity={mapUrlState.colorOpacity} + initialListingsMode={mapUrlState.listings} initialTab={mapUrlState.tab} initialLoading={initialLoading} theme={theme} diff --git a/frontend/src/components/landing/SeoLandingPage.breadcrumb.test.tsx b/frontend/src/components/landing/SeoLandingPage.breadcrumb.test.tsx new file mode 100644 index 0000000..a43375b --- /dev/null +++ b/frontend/src/components/landing/SeoLandingPage.breadcrumb.test.tsx @@ -0,0 +1,24 @@ +import { render, cleanup } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { i18nReady } from '../../i18n'; +import SeoLandingPage from './SeoLandingPage'; + +// analytics pulls in @plausible-analytics/tracker, which vitest cannot resolve. +vi.mock('../../lib/analytics', () => ({ trackEvent: vi.fn() })); + +afterEach(cleanup); + +describe('SeoLandingPage structured data', () => { + it('does not render a BreadcrumbList JSON-LD (prerender owns it)', async () => { + await i18nReady; + const { container } = render( + {}} /> + ); + const scripts = Array.from( + container.querySelectorAll('script[type="application/ld+json"]') + ).map((s) => s.textContent ?? ''); + expect(scripts.some((t) => t.includes('"BreadcrumbList"'))).toBe(false); + // FAQPage JSON-LD is still expected + expect(scripts.some((t) => t.includes('"FAQPage"'))).toBe(true); + }); +}); diff --git a/frontend/src/components/map/ListingPane.tsx b/frontend/src/components/map/ListingPane.tsx new file mode 100644 index 0000000..14aae30 --- /dev/null +++ b/frontend/src/components/map/ListingPane.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from 'react-i18next'; + +import { LISTINGS_MODES, type ListingsMode } from '../../lib/listings'; +import { PillToggle } from '../ui/PillToggle'; +import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; +import { CloseIcon } from '../ui/icons'; + +interface ListingPaneProps { + mode: ListingsMode; + onModeChange: (mode: ListingsMode) => void; + /** Number of listings currently shown for the active mode. */ + count: number; + loading: boolean; + onClose?: () => void; +} + +export default function ListingPane({ + mode, + onModeChange, + count, + loading, + onClose, +}: ListingPaneProps) { + const { t } = useTranslation(); + + const modeLabel = (value: ListingsMode): string => { + switch (value) { + case 'all': + return t('common.all'); + case 'new': + return t('map.actualListings.modes.new'); + case 'non-new': + return t('map.actualListings.modes.nonNew'); + case 'none': + return t('common.none'); + } + }; + + return ( +
+
+
+ + {t('map.actualListings.label')} + + {mode !== 'none' && ( + + {loading && } + {count} + + )} + {onClose && ( + + )} +
+
+ +
+
+ {LISTINGS_MODES.map((value) => ( + onModeChange(value)} + size="sm" + /> + ))} +
+

+ {t('map.actualListings.modes.hint')} +

+
+
+ ); +} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index d302e0c..543d5c0 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -39,6 +39,7 @@ import type { BasemapId } from '../../lib/basemaps'; import { useLicense } from '../../hooks/useLicense'; import { stateToParams } from '../../lib/url-state'; import { DEFAULT_COLOR_OPACITY, normalizeColorOpacity } from '../../lib/color-opacity'; +import { DEFAULT_LISTINGS_MODE, filterListingsByMode, type ListingsMode } from '../../lib/listings'; import { groupFeaturesByCategory } from '../../lib/features'; import { getActiveAmenityFilterFeatureNames, @@ -47,6 +48,7 @@ import { import { AreaPane, Filters, + ListingPane, OverlayPane, POIPane, PropertiesPane, @@ -91,6 +93,7 @@ export default function MapPage({ initialCrimeTypes, initialBasemap = 'standard', initialColorOpacity = DEFAULT_COLOR_OPACITY, + initialListingsMode = DEFAULT_LISTINGS_MODE, initialTab, initialLoading, theme, @@ -134,13 +137,14 @@ export default function MapPage({ ); const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 0.45, 'left'); const [rightPaneWidth, rightPaneHandlers] = usePaneResize(384, 200, 0.45, 'right'); - // The POI and overlay panes are mutually exclusive, so a single state tracks - // which one (if any) is open. - const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | null>(null); + // The POI, overlay and listings panes are mutually exclusive, so a single state + // tracks which one (if any) is open. + const [openMapPane, setOpenMapPane] = useState<'poi' | 'overlay' | 'listings' | null>(null); const poiPaneOpen = openMapPane === 'poi'; const overlayPaneOpen = openMapPane === 'overlay'; + const listingsPaneOpen = openMapPane === 'listings'; const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null); - const [listingsToggleEnabled, setListingsToggleEnabled] = useState(true); + const [listingsMode, setListingsMode] = useState(initialListingsMode); const [pendingInitialPostcode, setPendingInitialPostcode] = useState( initialPostcode ?? null ); @@ -151,7 +155,7 @@ export default function MapPage({ const isLoggedIn = !!user; // Screenshot/OG renders are non-interactive previews of a (possibly shared) search. // They go through the server's internal-request exemption, so they must render the - // full filter set rather than the capped demo view — otherwise a 4–5-filter shared + // full filter set rather than the capped demo view. Otherwise a 4–5-filter shared // search is silently trimmed to the anonymous cap (3) in the generated preview image. const filtersUnlimited = screenshotMode === true || @@ -162,7 +166,7 @@ export default function MapPage({ const filterLimit = filtersUnlimited ? null : effectiveFilterCap; // On a shared link, anonymous visitors may view and adjust the shared filters but // not add new ones. Keyed off login state (not paid status) so the "create a free - // account to build your own" upsell isn't a dead-end — once registered, the user can + // account to build your own" upsell isn't a dead-end: once registered, the user can // add filters up to their normal cap right there on the shared search. The // `!filtersUnlimited` term also excludes the non-interactive screenshot/OG render // (treated as unlimited above), which must show the shared filters in full. @@ -192,7 +196,7 @@ export default function MapPage({ }, []); // Pop the upgrade modal once when the shared/bookmarked search carried more filters - // than the viewer's tier allows — so trimmed filters are surfaced, not silently + // than the viewer's tier allows, so trimmed filters are surfaced, not silently // dropped. Fires when the signal first turns true (which may be after features finish // loading), and only once so dismissing it sticks. const overCapPopupShownRef = useRef(false); @@ -308,7 +312,7 @@ export default function MapPage({ // On a shared link, non-paying users may adjust the shared filters but not build // a new set. The AI box is a "build me a search" tool (and it also drives // travel-time entries, which bypass the per-add lock), so block it outright here - // — mirroring handleAddTravelTimeEntry — and surface the upsell instead. + // (mirroring handleAddTravelTimeEntry) and surface the upsell instead. if (lockFilterAdds) { handleFilterLimitReached(); return; @@ -362,7 +366,7 @@ export default function MapPage({ })) ); - // Move the camera to where the matches actually are — flying to the + // Move the camera to where the matches actually are: flying to the // travel-time anchor often lands on a viewport with zero matches. if (result.matchBounds) { const target = boundsToCenterZoom(result.matchBounds); @@ -410,7 +414,7 @@ export default function MapPage({ ); // Travel-time entries share the non-paying user's filter cap, so block adding one - // when the combined feature + travel count is already at the limit — and block it + // when the combined feature + travel count is already at the limit, and block it // outright on a shared link, where non-paying users can't add filters at all. const handleAddTravelTimeEntry = useCallback( (mode: TransportMode) => { @@ -584,7 +588,7 @@ export default function MapPage({ const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories); // Disabling POIs (clearing every category) must remove all POI cards/markers - // immediately, not on the next fetch tick — gate on the selection itself so a + // immediately, not on the next fetch tick. Gate on the selection itself so a // stale fetch result can never keep cards on screen. const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS; const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD; @@ -597,7 +601,7 @@ export default function MapPage({ // default). Only users with the flag get the toggle button and the fetch; // the backing API independently rejects anyone else with 403. const canSeeListings = user?.canSeeListings ?? false; - const actualListingsEnabled = canSeeListings && listingsToggleEnabled; + const actualListingsEnabled = canSeeListings && listingsMode !== 'none'; const { listings: actualListings, loading: actualListingsLoading } = useActualListings( actualListingsEnabled ? mapData.visibleBounds : null, { @@ -606,10 +610,28 @@ export default function MapPage({ shareCode, } ); - const visibleActualListings = actualListingsEnabled ? actualListings : EMPTY_ACTUAL_LISTINGS; - const handleToggleActualListings = useCallback(() => { - if (!canSeeListings) return; - setListingsToggleEnabled((enabled) => !enabled); + // The fetch returns every listing in view; the new/non-new split is decided + // client-side from each listing's Rightmove channel URL. + const visibleActualListings = useMemo( + () => + actualListingsEnabled + ? filterListingsByMode(actualListings, listingsMode) + : EMPTY_ACTUAL_LISTINGS, + [actualListingsEnabled, actualListings, listingsMode] + ); + const handleListingsModeChange = useCallback( + (mode: ListingsMode) => { + if (!canSeeListings) return; + setListingsMode(mode); + }, + [canSeeListings] + ); + // Losing listings access (e.g. logging out) hides the button that dismisses the + // pane, so close the pane here to avoid leaving an orphaned card on screen. + useEffect(() => { + if (!canSeeListings) { + setOpenMapPane((pane) => (pane === 'listings' ? null : pane)); + } }, [canSeeListings]); const selectedPostcodeParam = selectedHexagon?.type === 'postcode' @@ -628,7 +650,8 @@ export default function MapPage({ basemap, crimeTypes, selectedPostcodeParam, - colorOpacity + colorOpacity, + listingsMode ); useInitialMapPageView(mapData, initialViewState, initialTab, setRightPaneTab); @@ -701,7 +724,8 @@ export default function MapPage({ basemap, crimeTypes, selectedPostcodeParam, - colorOpacity + colorOpacity, + listingsMode ).toString(), [ activeOverlays, @@ -711,6 +735,7 @@ export default function MapPage({ entries, features, filters, + listingsMode, rightPaneTab, selectedPOICategories, selectedPostcodeParam, @@ -756,7 +781,7 @@ export default function MapPage({ // Clear the filter-cap upsell once it's no longer relevant: the user became // licensed/admin, or dropped back under the cap by removing filters. On a shared - // link adds are blocked regardless of count, so don't auto-clear there — the user + // link adds are blocked regardless of count, so don't auto-clear there; the user // dismisses it themselves. Prevents a stale flag from resurfacing spuriously. useEffect(() => { if ( @@ -768,8 +793,8 @@ export default function MapPage({ }, [filtersUnlimited, lockFilterAdds, effectiveFilterCap, filters, entries]); // When the cap tightens (logout / session invalidated / license lapse), useFilters - // re-clamps the feature filters, but travel-time entries — which count toward the same - // server cap — live here. Trim them to the cap too so the combined request can't exceed + // re-clamps the feature filters, but travel-time entries, which count toward the same + // server cap, live here. Trim them to the cap too so the combined request can't exceed // it and blank the map. Read via ref so this only fires on a cap change, not on every // travel edit (those are already gated by handleAddTravelTimeEntry). const entriesRef = useRef(entries); @@ -796,6 +821,12 @@ export default function MapPage({ const handleCloseOverlayPane = useCallback(() => { setOpenMapPane((pane) => (pane === 'overlay' ? null : pane)); }, []); + const handleToggleListingsPane = useCallback(() => { + setOpenMapPane((pane) => (pane === 'listings' ? null : 'listings')); + }, []); + const handleCloseListingsPane = useCallback(() => { + setOpenMapPane((pane) => (pane === 'listings' ? null : pane)); + }, []); const handleAreaTabClick = useCallback(() => { setRightPaneTab('area'); }, [setRightPaneTab]); @@ -910,6 +941,27 @@ export default function MapPage({ [activeOverlays, basemap, colorOpacity, crimeTypes, handleCloseOverlayPane, overlaysZoomedIn] ); + const listingsPane = useMemo( + () => ( + }> + + + ), + [ + listingsMode, + handleListingsModeChange, + visibleActualListings.length, + actualListingsLoading, + handleCloseListingsPane, + ] + ); + const filtersPane = useMemo( () => ( }> @@ -1155,7 +1207,9 @@ export default function MapPage({ actualListings={visibleActualListings} actualListingsEnabled={actualListingsEnabled} actualListingsLoading={actualListingsLoading} - onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined} + onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined} + listingsPaneOpen={listingsPaneOpen} + listingsPane={listingsPane} travelTimeEntries={entries} bottomScreenInset={mobileBottomSheetHeight} onBottomSheetCoveredHeightChange={setMobileBottomSheetHeight} @@ -1216,7 +1270,9 @@ export default function MapPage({ actualListings={visibleActualListings} actualListingsEnabled={actualListingsEnabled} actualListingsLoading={actualListingsLoading} - onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined} + onToggleListingsPane={canSeeListings ? handleToggleListingsPane : undefined} + listingsPaneOpen={listingsPaneOpen} + listingsPane={listingsPane} travelTimeEntries={entries} densityLabel={densityLabel} totalCount={filterCounts.total ?? undefined} diff --git a/frontend/src/components/map/OverlayTileLayers.tsx b/frontend/src/components/map/OverlayTileLayers.tsx index 673b953..58f8948 100644 --- a/frontend/src/components/map/OverlayTileLayers.tsx +++ b/frontend/src/components/map/OverlayTileLayers.tsx @@ -85,7 +85,7 @@ export function OverlayTileLayers({ // (the ground a pixel covers halves each zoom level). Grow the // radius ~geometrically with zoom to hold a roughly constant // ~140m ground footprint, so neighbouring anchors' kernels keep - // overlapping into a connected surface — kernel-density smoothing + // overlapping into a connected surface. Kernel-density smoothing // is the honest form of "interpolating between points" for this // data; we deliberately do NOT triangulate/IDW, which would // invent crime values across parks/water and over-claim a diff --git a/frontend/src/components/map/PoiPopupCard.tsx b/frontend/src/components/map/PoiPopupCard.tsx index e2e6413..b2a562b 100644 --- a/frontend/src/components/map/PoiPopupCard.tsx +++ b/frontend/src/components/map/PoiPopupCard.tsx @@ -24,7 +24,7 @@ function getPoiGroupColor(group: string): [number, number, number] { return color; } -/** Best-effort web URL from a free-text website field — GIAS stores some with +/** Best-effort web URL from a free-text website field. GIAS stores some with * "http://", some without, and some as bare hostnames. */ function normalizeSchoolWebsiteUrl(raw: string): string | null { const trimmed = raw.trim(); diff --git a/frontend/src/components/map/PropertiesPane.test.tsx b/frontend/src/components/map/PropertiesPane.test.tsx index 63272ae..41d96bc 100644 --- a/frontend/src/components/map/PropertiesPane.test.tsx +++ b/frontend/src/components/map/PropertiesPane.test.tsx @@ -14,7 +14,7 @@ function summarize(property: Property): string[] { case 'tenure': return `tenure:${event.status}:${event.year}`; case 'sale': - return `sale:${event.year}`; + return `sale:${event.year}${event.isNew ? ':new' : ''}`; case 'reno': return `reno:${event.event}:${event.year}`; case 'built': @@ -42,9 +42,9 @@ describe('buildTimelineEvents', () => { summarize( makeProperty({ historical_prices: [ - { year: 2015, month: 6, price: 200_000 }, + { year: 2015, month: 6, price: 200_000, is_new: false }, // Most recent sale is the card headline, so it is dropped here. - { year: 2024, month: 1, price: 300_000 }, + { year: 2024, month: 1, price: 300_000, is_new: false }, ], tenure_history: [{ year: 2020, status: 'Rented (private)' }], }) @@ -55,4 +55,19 @@ describe('buildTimelineEvents', () => { it('emits nothing when there is no tenure history', () => { expect(summarize(makeProperty({}))).toEqual([]); }); + + it('flags a past sale that was a new-build transfer', () => { + expect( + summarize( + makeProperty({ + historical_prices: [ + { year: 2016, month: 3, price: 250_000, is_new: true }, + { year: 2019, month: 9, price: 280_000, is_new: false }, + // Most recent sale is the card headline, so it is dropped here. + { year: 2024, month: 1, price: 350_000, is_new: false }, + ], + }) + ) + ).toEqual(['sale:2019', 'sale:2016:new']); + }); }); diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index b026486..86f62bb 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -94,7 +94,7 @@ export function TravelTimeCard({ const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]); // Synthetic feature so the time labels reuse the shared SliderLabels (matching - // every other filter card) — editable, thumb-following, with the minute unit. + // every other filter card): editable, thumb-following, with the minute unit. const travelFeature: FeatureMeta = { name: 'travelTime', type: 'numeric', @@ -149,7 +149,7 @@ export function TravelTimeCard({ portal={destinationDropdownPortal} /> - {/* Transit-only toggles — shown when destination is set */} + {/* Transit-only toggles, shown when destination is set */} {slug && mode === 'transit' && (
@@ -236,7 +236,7 @@ export function TravelTimeCard({ )} - {/* Time range slider — only show when we have data */} + {/* Time range slider: only show when we have data */} {slug && (
diff --git a/frontend/src/components/map/filters/ActiveFilterList.tsx b/frontend/src/components/map/filters/ActiveFilterList.tsx index ea359e7..ef9882b 100644 --- a/frontend/src/components/map/filters/ActiveFilterList.tsx +++ b/frontend/src/components/map/filters/ActiveFilterList.tsx @@ -29,6 +29,11 @@ import { getTenureFeatureName, isTenureFilterName, } from '../../../lib/tenure-filter'; +import { + COUNCIL_VARIANT_CONFIG, + getCouncilFeatureName, + isCouncilFilterName, +} from '../../../lib/council-filter'; import { getSchoolBackendFeatureName, isSchoolFilterName } from '../../../lib/school-filter'; import { getPoiDistanceFeatureName, @@ -289,6 +294,31 @@ export function ActiveFilterList({ ); } + if (isCouncilFilterName(feature.name)) { + const councilBackendName = getCouncilFeatureName(feature.name); + return ( + onRemoveFilter(feature.name)} + /> + ); + } + if (isElectionVoteShareFilterName(feature.name)) { const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name); return ( diff --git a/frontend/src/components/map/filters/AddFilterPanel.tsx b/frontend/src/components/map/filters/AddFilterPanel.tsx index 0bd1f66..8303070 100644 --- a/frontend/src/components/map/filters/AddFilterPanel.tsx +++ b/frontend/src/components/map/filters/AddFilterPanel.tsx @@ -22,6 +22,7 @@ import { isQualificationFilterName, } from '../../../lib/qualification-filter'; import { TENURE_FILTER_NAME, isTenureFilterName } from '../../../lib/tenure-filter'; +import { COUNCIL_FILTER_NAME, isCouncilFilterName } from '../../../lib/council-filter'; import { SCHOOL_FILTER_NAME, isSchoolFilterName } from '../../../lib/school-filter'; import { POI_DISTANCE_FILTER_NAME, @@ -45,6 +46,7 @@ interface AddFilterPanelProps { defaultEthnicityFeatureName: string | null; defaultQualificationFeatureName: string | null; defaultTenureFeatureName: string | null; + defaultCouncilFeatureName: string | null; defaultPoiFilterFeatureNames: Record; openInfoFeature?: string | null; travelTimeEntries: TravelTimeEntry[]; @@ -73,6 +75,7 @@ export function AddFilterPanel({ defaultEthnicityFeatureName, defaultQualificationFeatureName, defaultTenureFeatureName, + defaultCouncilFeatureName, defaultPoiFilterFeatureNames, openInfoFeature, travelTimeEntries, @@ -101,11 +104,13 @@ export function AddFilterPanel({ ? QUALIFICATIONS_FILTER_NAME : pinnedFeature && isTenureFilterName(pinnedFeature) ? TENURE_FILTER_NAME - : pinnedFeature && isPoiDistanceFilterName(pinnedFeature) - ? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME) - : pinnedFeature && isCrimeSeverityFilterName(pinnedFeature) - ? (getCrimeSeverityFilterName(pinnedFeature) ?? pinnedFeature) - : pinnedFeature; + : pinnedFeature && isCouncilFilterName(pinnedFeature) + ? COUNCIL_FILTER_NAME + : pinnedFeature && isPoiDistanceFilterName(pinnedFeature) + ? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME) + : pinnedFeature && isCrimeSeverityFilterName(pinnedFeature) + ? (getCrimeSeverityFilterName(pinnedFeature) ?? pinnedFeature) + : pinnedFeature; const handleTogglePin = (name: string) => { if (name === SCHOOL_FILTER_NAME) { @@ -132,6 +137,10 @@ export function AddFilterPanel({ if (defaultTenureFeatureName) onTogglePin(defaultTenureFeatureName); return; } + if (name === COUNCIL_FILTER_NAME) { + if (defaultCouncilFeatureName) onTogglePin(defaultCouncilFeatureName); + return; + } if (CRIME_SEVERITY_FILTER_NAMES.includes(name as CrimeSeverityFilterName)) { const defaultSeverityFeatureName = defaultCrimeSeverityFeatureNames[name as CrimeSeverityFilterName]; diff --git a/frontend/src/components/map/map-page/MobileMapPage.tsx b/frontend/src/components/map/map-page/MobileMapPage.tsx index cb73d72..2f1c116 100644 --- a/frontend/src/components/map/map-page/MobileMapPage.tsx +++ b/frontend/src/components/map/map-page/MobileMapPage.tsx @@ -57,7 +57,9 @@ interface MobileMapPageProps { actualListings: ActualListing[]; actualListingsEnabled: boolean; actualListingsLoading: boolean; - onToggleActualListings?: () => void; + onToggleListingsPane?: () => void; + listingsPaneOpen: boolean; + listingsPane: ReactNode; travelTimeEntries: TravelTimeEntry[]; bottomScreenInset: number; onBottomSheetCoveredHeightChange: (height: number) => void; @@ -110,7 +112,9 @@ export function MobileMapPage({ actualListings, actualListingsEnabled, actualListingsLoading, - onToggleActualListings, + onToggleListingsPane, + listingsPaneOpen, + listingsPane, travelTimeEntries, bottomScreenInset, onBottomSheetCoveredHeightChange, @@ -145,7 +149,7 @@ export function MobileMapPage({ // Each button is p-2 + a 1.25rem icon (36px); they sit in a `gap-2` (8px) row. const ACTION_BUTTON_WIDTH = 36; const ACTION_BUTTON_GAP = 8; - const actionButtonCount = (onToggleActualListings ? 1 : 0) + 2; + const actionButtonCount = (onToggleListingsPane ? 1 : 0) + 2; const topCardsRightInset = actionButtonCount * ACTION_BUTTON_WIDTH + actionButtonCount * ACTION_BUTTON_GAP; // inter-button gaps + breathing room before the search @@ -199,19 +203,15 @@ export function MobileMapPage({
- {onToggleActualListings && ( + {onToggleListingsPane && (
+ {listingsPaneOpen && ( +
+ {listingsPane} +
+ )} + {overlayPaneOpen && (
{overlayPane} diff --git a/frontend/src/components/map/map-page/types.ts b/frontend/src/components/map/map-page/types.ts index 381e829..abebbe7 100644 --- a/frontend/src/components/map/map-page/types.ts +++ b/frontend/src/components/map/map-page/types.ts @@ -8,6 +8,7 @@ import type { import type { TravelTimeInitial } from '../../../hooks/useTravelTime'; import type { OverlayId } from '../../../lib/overlays'; import type { BasemapId } from '../../../lib/basemaps'; +import type { ListingsMode } from '../../../lib/listings'; import type { Page } from '../../ui/Header'; import type { PointerEvent } from 'react'; @@ -31,6 +32,7 @@ export interface MapPageProps { initialCrimeTypes?: Set; initialBasemap?: BasemapId; initialColorOpacity?: number; + initialListingsMode?: ListingsMode; initialTab: 'properties' | 'area'; initialLoading: boolean; theme: 'light' | 'dark'; diff --git a/frontend/src/components/map/map-page/useExportController.ts b/frontend/src/components/map/map-page/useExportController.ts index 09beaf4..7d2ff75 100644 --- a/frontend/src/components/map/map-page/useExportController.ts +++ b/frontend/src/components/map/map-page/useExportController.ts @@ -150,8 +150,8 @@ export function useExportController({ const params = new URLSearchParams(); if (isListMode) { params.set('postcodes', postcodeList.join(',')); - // Pass the active filters so the export keeps its two sheets — "Selected" - // (the filtered feature columns) and "All Data" — even in list mode. The + // Pass the active filters so the export keeps its two sheets, "Selected" + // (the filtered feature columns) and "All Data", even in list mode. The // server uses them only to pick columns; the supplied postcodes still // drive which rows appear. const filterStr = buildFilterString(filters, features); diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx index 1ec4279..7a0fb19 100644 --- a/frontend/src/components/pricing/PricingPage.tsx +++ b/frontend/src/components/pricing/PricingPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { CheckIcon } from '../ui/icons/CheckIcon'; +import { ChevronIcon } from '../ui/icons/ChevronIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import type { AuthUser } from '../../hooks/useAuth'; import { useLicense } from '../../hooks/useLicense'; @@ -9,8 +10,7 @@ import { trackEvent } from '../../lib/analytics'; import { apiUrl } from '../../lib/api'; import HexCanvas from '../home/HexCanvas'; import { useIsDarkTheme } from '../../hooks/useIsDarkTheme'; - -// Feature list keys — resolved inside the component via t() +import Footer from '../ui/Footer'; interface PricingTier { up_to: number | null; @@ -24,6 +24,8 @@ interface PricingData { tiers: PricingTier[]; } +// Feature list keys, resolved inside the component via t() + function formatPricePence(pence: number): string { return `\u00A3${pence / 100}`; } @@ -45,26 +47,61 @@ export default function PricingPage({ const [pricing, setPricing] = useState(null); const [loading, setLoading] = useState(true); const [scrolledLeft, setScrolledLeft] = useState(false); + const [scrolledRight, setScrolledRight] = useState(false); const scrollRef = useRef(null); const activeCardRef = useRef(null); + const retryTimerRef = useRef | null>(null); + const loadPricingRef = useRef<(attempt?: number) => void>(() => {}); + const onScroll = useCallback(() => { - if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0); + const el = scrollRef.current; + if (!el) return; + setScrolledLeft(el.scrollLeft > 0); + setScrolledRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }, []); + + const scrollByAmount = useCallback((dir: 1 | -1) => { + const el = scrollRef.current; + if (!el) return; + el.scrollBy({ left: dir * el.clientWidth * 0.85, behavior: 'smooth' }); }, []); useEffect(() => { trackEvent('Pricing View'); }, []); - useEffect(() => { - fetch(apiUrl('pricing')) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); - }) - .then(setPricing) - .catch((err) => logNonAbortError('Failed to load pricing', err)) - .finally(() => setLoading(false)); + const loadPricing = useCallback(async (attempt = 0): Promise => { + setLoading(true); + try { + const res = await fetch(apiUrl('pricing')); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data: PricingData = await res.json(); + setPricing(data); + setLoading(false); + } catch (err) { + logNonAbortError('Failed to load pricing', err); + // Auto-retry transient failures with exponential backoff (3 retries: 1s/2s/4s). + if (attempt < 3) { + const delay = 1000 * 2 ** attempt; + retryTimerRef.current = setTimeout(() => loadPricingRef.current(attempt + 1), delay); + return; // keep the spinner up while we back off + } + // Retries exhausted: show the failed-to-load state. + setLoading(false); + } }, []); + loadPricingRef.current = loadPricing; + + useEffect(() => { + void loadPricing(); + return () => { + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + }; + }, [loadPricing]); + + const handleRetry = useCallback(() => { + void loadPricing(); + }, [loadPricing]); const isLicensed = user?.subscription === 'licensed' || user?.isAdmin; const isFree = pricing?.current_price_pence === 0; @@ -98,8 +135,17 @@ export default function PricingPage({ containerRect.left - (container.clientWidth - card.offsetWidth) / 2; container.scrollLeft = Math.max(0, scrollLeft); - setScrolledLeft(container.scrollLeft > 0); - }, [pricing, currentTierIndex]); + onScroll(); + }, [pricing, currentTierIndex, onScroll]); + + // Keep scroll affordances (chevrons + fades) accurate on mount and resize so + // the off-screen fifth tier is always discoverable. + useEffect(() => { + if (!pricing) return; + onScroll(); + window.addEventListener('resize', onScroll); + return () => window.removeEventListener('resize', onScroll); + }, [pricing, onScroll]); if (pricing && pricing.tiers.length === 0) { throw new Error('Pricing data did not include any tiers'); @@ -159,7 +205,7 @@ export default function PricingPage({
- {/* Tier cards — full viewport width carousel */} + {/* Tier cards: full viewport width carousel */} {loading ? (
@@ -174,15 +220,37 @@ export default function PricingPage({ }} > {scrolledLeft && ( -
+ <> +
+ + + )} + {scrolledRight && ( + <> +
+ + )} -
) : ( -

{t('pricingPage.failedToLoad')}

+
+

{t('pricingPage.failedToLoad')}

+ +
)}
+
+
+
); } diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx index e226a72..0bcd2a6 100644 --- a/frontend/src/components/ui/AuthModal.tsx +++ b/frontend/src/components/ui/AuthModal.tsx @@ -5,6 +5,7 @@ import { CloseIcon } from './icons/CloseIcon'; import { GoogleIcon } from './icons/GoogleIcon'; import { trackEvent } from '../../lib/analytics'; import { useModalA11y } from '../../hooks/useModalA11y'; +import type { AuthErrorAction } from '../../lib/auth-errors'; type View = 'login' | 'register' | 'forgot'; @@ -17,6 +18,7 @@ export default function AuthModal({ onForgotPassword, loading, error, + errorAction, onClearError, initialTab = 'login', valuePropKey, @@ -29,6 +31,7 @@ export default function AuthModal({ onForgotPassword: (email: string) => Promise; loading: boolean; error: string | null; + errorAction?: AuthErrorAction; onClearError: () => void; initialTab?: 'login' | 'register'; /** Translation key for the value-prop line, e.g. a context-specific nudge. Falls @@ -261,7 +264,20 @@ export default function AuthModal({

{t('auth.resetSent')}

)} - {error &&

{error}

} + {error && ( +
+

{error}

+ {errorAction === 'switchToLogin' && view !== 'login' && ( + + )} +
+ )} {!(view === 'forgot' && resetSent) && (