lgtm
This commit is contained in:
parent
982e0cc89c
commit
cfaf58dfba
44 changed files with 793 additions and 201 deletions
14
frontend/public/video/twin-beckenham-croydon.vtt
Normal file
14
frontend/public/video/twin-beckenham-croydon.vtt
Normal file
|
|
@ -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.
|
||||
|
||||
14
frontend/public/video/twin-ha7-2-vs-ha3-0.vtt
Normal file
14
frontend/public/video/twin-ha7-2-vs-ha3-0.vtt
Normal file
|
|
@ -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.
|
||||
|
||||
14
frontend/public/video/twin-l16-7-vs-l14-6.vtt
Normal file
14
frontend/public/video/twin-l16-7-vs-l14-6.vtt
Normal file
|
|
@ -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.
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<SeoLandingPage pageKey="property-price-map" onOpenDashboard={() => {}} />
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
87
frontend/src/components/map/ListingPane.tsx
Normal file
87
frontend/src/components/map/ListingPane.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
|
||||
<div className="flex-shrink-0 px-3 pt-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-warm-500 dark:text-warm-400">
|
||||
{t('map.actualListings.label')}
|
||||
</span>
|
||||
{mode !== 'none' && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-warm-400 dark:text-warm-500">
|
||||
{loading && <SpinnerIcon className="h-3 w-3 animate-spin" />}
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-auto p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title={t('common.close')}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 px-3 py-3 dark:border-warm-700">
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t('map.actualListings.label')}
|
||||
>
|
||||
{LISTINGS_MODES.map((value) => (
|
||||
<PillToggle
|
||||
key={value}
|
||||
label={modeLabel(value)}
|
||||
active={mode === value}
|
||||
onClick={() => onModeChange(value)}
|
||||
size="sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] leading-snug text-warm-400 dark:text-warm-500">
|
||||
{t('map.actualListings.modes.hint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ListingsMode>(initialListingsMode);
|
||||
const [pendingInitialPostcode, setPendingInitialPostcode] = useState<string | null>(
|
||||
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(
|
||||
() => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
<ListingPane
|
||||
mode={listingsMode}
|
||||
onModeChange={handleListingsModeChange}
|
||||
count={visibleActualListings.length}
|
||||
loading={actualListingsLoading}
|
||||
onClose={handleCloseListingsPane}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[
|
||||
listingsMode,
|
||||
handleListingsModeChange,
|
||||
visibleActualListings.length,
|
||||
actualListingsLoading,
|
||||
handleCloseListingsPane,
|
||||
]
|
||||
);
|
||||
|
||||
const filtersPane = useMemo(
|
||||
() => (
|
||||
<Suspense fallback={<PaneFallback />}>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
|
|
@ -236,7 +236,7 @@ export function TravelTimeCard({
|
|||
</InfoPopup>
|
||||
)}
|
||||
|
||||
{/* Time range slider — only show when we have data */}
|
||||
{/* Time range slider: only show when we have data */}
|
||||
{slug && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<VariantFilterCard
|
||||
key={feature.name}
|
||||
config={COUNCIL_VARIANT_CONFIG}
|
||||
features={features}
|
||||
variantFeature={feature}
|
||||
filters={filters}
|
||||
activeFeature={activeFeature}
|
||||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={councilBackendName ? filterImpacts?.[councilBackendName] : undefined}
|
||||
percentileScale={councilBackendName ? percentileScales.get(councilBackendName) : undefined}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
onDragEnd={onDragEnd}
|
||||
onTogglePin={onTogglePin}
|
||||
onShowInfo={onShowInfo}
|
||||
onRemove={() => onRemoveFilter(feature.name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isElectionVoteShareFilterName(feature.name)) {
|
||||
const electionVoteShareBackendName = getElectionVoteShareFeatureName(feature.name);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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<PoiFilterName, string | null>;
|
||||
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];
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</div>
|
||||
|
||||
<div className="absolute right-3 top-3 z-20 flex max-w-[calc(100%_-_1.5rem)] flex-row flex-wrap justify-end gap-2">
|
||||
{onToggleActualListings && (
|
||||
{onToggleListingsPane && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleActualListings}
|
||||
onClick={onToggleListingsPane}
|
||||
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${actualListingsEnabled ? 'text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300' : 'text-warm-500 hover:text-red-600 dark:text-warm-400 dark:hover:text-red-400'}`}
|
||||
aria-pressed={actualListingsEnabled}
|
||||
aria-expanded={listingsPaneOpen}
|
||||
aria-busy={actualListingsLoading}
|
||||
aria-label={
|
||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
||||
}
|
||||
title={
|
||||
actualListingsEnabled ? t('map.actualListings.hide') : t('map.actualListings.show')
|
||||
}
|
||||
aria-label={t('map.actualListings.label')}
|
||||
title={t('map.actualListings.label')}
|
||||
>
|
||||
{actualListingsLoading ? (
|
||||
<SpinnerIcon className="h-5 w-5 animate-spin" />
|
||||
|
|
@ -236,6 +236,12 @@ export function MobileMapPage({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{listingsPaneOpen && (
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex max-h-[60dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{listingsPane}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{overlayPaneOpen && (
|
||||
<div className="absolute top-24 right-3 left-3 z-20 flex max-h-[60dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
|
||||
{overlayPane}
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
initialBasemap?: BasemapId;
|
||||
initialColorOpacity?: number;
|
||||
initialListingsMode?: ListingsMode;
|
||||
initialTab: 'properties' | 'area';
|
||||
initialLoading: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<PricingData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scrolledLeft, setScrolledLeft] = useState(false);
|
||||
const [scrolledRight, setScrolledRight] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const activeCardRef = useRef<HTMLDivElement>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | 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<void> => {
|
||||
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({
|
|||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16">
|
||||
{/* Tier cards — full viewport width carousel */}
|
||||
{/* Tier cards: full viewport width carousel */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<SpinnerIcon className="w-8 h-8 animate-spin text-teal-400" />
|
||||
|
|
@ -174,15 +220,37 @@ export default function PricingPage({
|
|||
}}
|
||||
>
|
||||
{scrolledLeft && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to right, black, transparent)' }}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to right, black, transparent)' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollByAmount(-1)}
|
||||
aria-label={t('pricingPage.scrollPrev')}
|
||||
className="absolute left-2 top-1/2 z-20 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full border border-warm-300 bg-white/90 text-navy-950 shadow-md hover:bg-white dark:border-warm-700 dark:bg-warm-800/90 dark:text-warm-100"
|
||||
>
|
||||
<ChevronIcon direction="left" className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{scrolledRight && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to left, black, transparent)' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollByAmount(1)}
|
||||
aria-label={t('pricingPage.scrollNext')}
|
||||
className="absolute right-2 top-1/2 z-20 -translate-y-1/2 flex h-10 w-10 items-center justify-center rounded-full border border-warm-300 bg-white/90 text-navy-950 shadow-md hover:bg-white dark:border-warm-700 dark:bg-warm-800/90 dark:text-warm-100"
|
||||
>
|
||||
<ChevronIcon direction="right" className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm"
|
||||
style={{ maskImage: 'linear-gradient(to left, black, transparent)' }}
|
||||
/>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
|
|
@ -338,9 +406,21 @@ export default function PricingPage({
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-warm-400 py-16">{t('pricingPage.failedToLoad')}</p>
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
||||
<p className="text-warm-500 dark:text-warm-400">{t('pricingPage.failedToLoad')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="px-5 py-2.5 rounded-lg border border-[#d27a11] bg-[#f09a22] text-navy-950 font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25"
|
||||
>
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
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({
|
|||
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSent')}</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||
{error && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm text-red-600 dark:text-red-300">{error}</p>
|
||||
{errorAction === 'switchToLogin' && view !== 'login' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchView('login')}
|
||||
className="text-sm font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
{t('auth.logInInstead')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!(view === 'forgot' && resetSent) && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -246,8 +246,10 @@ export default function Header({
|
|||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
{!useSidebarNav && (
|
||||
{/* 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. */}
|
||||
{!useSidebarNav && !showEditingBar && (
|
||||
<nav className="top-menu flex items-center">
|
||||
<a
|
||||
href={PAGE_PATHS.dashboard}
|
||||
|
|
@ -287,7 +289,7 @@ export default function Header({
|
|||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{/* Desktop-only dashboard actions — shown to everyone; a logged-out click
|
||||
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click
|
||||
opens the register prompt instead of acting. */}
|
||||
{!useSidebarNav && activePage === 'dashboard' && (
|
||||
<>
|
||||
|
|
@ -393,7 +395,7 @@ export default function Header({
|
|||
{/* Language selector (desktop) */}
|
||||
{!useSidebarNav && <LanguageDropdown />}
|
||||
|
||||
{/* Theme toggle (desktop, logged-out only — logged-in users use UserMenu) */}
|
||||
{/* Theme toggle (desktop, logged-out only; logged-in users use UserMenu) */}
|
||||
{!useSidebarNav && !user && (
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
|
|
|
|||
40
frontend/src/components/ui/UserMenu.test.tsx
Normal file
40
frontend/src/components/ui/UserMenu.test.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-i18next')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import UserMenu from './UserMenu';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
|
||||
const user: AuthUser = {
|
||||
id: 'u1',
|
||||
email: 'casey@example.com',
|
||||
isAdmin: false,
|
||||
subscription: 'free',
|
||||
newsletter: false,
|
||||
canSeeListings: false,
|
||||
};
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('UserMenu avatar trigger (a11y)', () => {
|
||||
it('has an accessible name, opens a menu, and is type=button', () => {
|
||||
render(
|
||||
<UserMenu
|
||||
user={user}
|
||||
theme="light"
|
||||
onToggleTheme={() => {}}
|
||||
onLogout={() => {}}
|
||||
onNavigate={() => {}}
|
||||
/>
|
||||
);
|
||||
// t() is mocked to echo the key, so the accessible name is the key.
|
||||
const trigger = screen.getByRole('button', { name: 'userMenu.accountMenu' });
|
||||
expect(trigger.getAttribute('type')).toBe('button');
|
||||
expect(trigger.getAttribute('aria-haspopup')).toBe('menu');
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
|
@ -40,7 +40,11 @@ export default function UserMenu({
|
|||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
aria-label={t('userMenu.accountMenu')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="flex cursor-pointer items-center justify-center w-8 h-8 rounded-full bg-teal-600 text-white text-sm font-medium hover:bg-teal-700"
|
||||
title={user.email}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1171,8 +1171,6 @@ const de: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice:
|
||||
'Live-Preise konnten nicht aktualisiert werden; diese Angaben sind möglicherweise veraltet.',
|
||||
scrollPrev: 'Vorherige Tarife anzeigen',
|
||||
scrollNext: 'Weitere Tarife anzeigen',
|
||||
title: 'Mit einem besseren Suchgebiet kaufen',
|
||||
|
|
|
|||
|
|
@ -1156,7 +1156,6 @@ const en = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice: 'We couldn’t refresh live pricing, so these figures may be out of date.',
|
||||
scrollPrev: 'Show previous plans',
|
||||
scrollNext: 'Show more plans',
|
||||
title: 'Buy with a better search area',
|
||||
|
|
|
|||
|
|
@ -1182,8 +1182,6 @@ const fr: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice:
|
||||
'Impossible d’actualiser les tarifs en direct ; ces chiffres peuvent être obsolètes.',
|
||||
scrollPrev: 'Afficher les offres précédentes',
|
||||
scrollNext: 'Afficher plus d’offres',
|
||||
title: 'Acheter avec un meilleur secteur de recherche',
|
||||
|
|
|
|||
|
|
@ -1131,7 +1131,6 @@ const hi: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice: 'हम लाइव मूल्य ताज़ा नहीं कर सके, इसलिए ये आँकड़े पुराने हो सकते हैं।',
|
||||
scrollPrev: 'पिछली योजनाएँ दिखाएँ',
|
||||
scrollNext: 'और योजनाएँ दिखाएँ',
|
||||
title: 'बेहतर खोज क्षेत्र के साथ खरीदें',
|
||||
|
|
|
|||
|
|
@ -1170,7 +1170,6 @@ const hu: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice: 'Nem sikerült frissíteni az élő árakat, így ezek az adatok elavultak lehetnek.',
|
||||
scrollPrev: 'Előző csomagok megjelenítése',
|
||||
scrollNext: 'További csomagok megjelenítése',
|
||||
title: 'Vásárolj jobb keresési területből kiindulva',
|
||||
|
|
|
|||
|
|
@ -1101,7 +1101,6 @@ const zh: Translations = {
|
|||
},
|
||||
|
||||
pricingPage: {
|
||||
cachedNotice: '无法刷新实时价格,以下数据可能已过时。',
|
||||
scrollPrev: '显示上一组方案',
|
||||
scrollNext: '显示更多方案',
|
||||
title: '用更靠谱的搜索范围去买房',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const QUALIFICATIONS_FILTER_KEY_PREFIX = `${QUALIFICATIONS_FILTER_NAME}:`
|
|||
* 100% per neighbourhood and render as a single stacked "Qualifications"
|
||||
* composition in the area pane (see STACKED_GROUPS["Neighbours"] in consts.ts).
|
||||
* In the filter browser they fold into one "Qualifications" filter whose
|
||||
* dropdown selects a band — including "% Degree or higher" — rather than seven
|
||||
* dropdown selects a band (including "% Degree or higher") rather than seven
|
||||
* separate sliders.
|
||||
*/
|
||||
export const QUALIFICATION_FEATURE_NAMES = [
|
||||
|
|
|
|||
|
|
@ -77,9 +77,9 @@ export const SEO_LANDING_PAGES: Record<SeoLandingKey, SeoLandingContent> = {
|
|||
title: 'Compare property prices across every postcode in England',
|
||||
metaTitle: 'Property price map for England - Compare postcodes before viewing',
|
||||
metaDescription:
|
||||
'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.',
|
||||
intro:
|
||||
'Perfect Postcode maps sold prices, estimated current value, price per square metre, property type, floor area, tenure, and local context so buyers can find realistic search areas before opening listing portals.',
|
||||
'Perfect Postcode maps sold prices, estimated current value, price per square metre, property type, floor area, tenure, and local context so buyers can rank postcodes by value and find the underpriced areas the market has overlooked.',
|
||||
points: [
|
||||
'Screen historical sale prices and current-value estimates by postcode.',
|
||||
'Compare value with commute, schools, broadband, crime, noise, and amenities.',
|
||||
|
|
@ -714,7 +714,7 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
|
|||
sections: [
|
||||
{
|
||||
title: 'Your account stays separate from the public site',
|
||||
body: 'The guides, methodology, and support pages are public. Everything tied to your account — your dashboard, saved searches, and invitations — is kept out of public view and out of search engines.',
|
||||
body: 'The guides, methodology, and support pages are public. Everything tied to your account (your dashboard, saved searches, and invitations) is kept out of public view and out of search engines.',
|
||||
},
|
||||
{
|
||||
title: 'Saved searches belong to your account',
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ export const TENURE_FILTER_KEY_PREFIX = `${TENURE_FILTER_NAME}:`;
|
|||
* The Census 2021 housing tenure categories (TS054). They sum to 100% per
|
||||
* neighbourhood and render as a single stacked "Tenure" composition in the area
|
||||
* pane (see STACKED_GROUPS["Neighbours"] in consts.ts). In the filter browser
|
||||
* they fold into one "Tenure" filter whose dropdown selects a category —
|
||||
* owner-occupied, social rent or private rent — rather than three separate
|
||||
* they fold into one "Tenure" filter whose dropdown selects a category
|
||||
* (owner-occupied, social rent or private rent) rather than three separate
|
||||
* sliders.
|
||||
*/
|
||||
export const TENURE_FEATURE_NAMES = [
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { createElectionVoteShareFilterKey } from './election-filter';
|
|||
import { createEthnicityFilterKey } from './ethnicity-filter';
|
||||
import { createQualificationFilterKey } from './qualification-filter';
|
||||
import { createTenureFilterKey } from './tenure-filter';
|
||||
import { createCouncilFilterKey } from './council-filter';
|
||||
import {
|
||||
POI_COUNT_2KM_FILTER_NAME,
|
||||
TRANSPORT_DISTANCE_FILTER_NAME,
|
||||
|
|
@ -285,6 +286,52 @@ describe('url-state', () => {
|
|||
expect(parseUrlState().colorOpacity).toBe(0.1);
|
||||
});
|
||||
|
||||
it('round-trips a non-default listings mode and defaults to "all"', () => {
|
||||
// Default mode emits no param, so a fresh reload restores the default.
|
||||
const defaultParams = stateToParams(
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
new Set(),
|
||||
'area',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'all'
|
||||
);
|
||||
expect(defaultParams.has('listings')).toBe(false);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
new Set(),
|
||||
'area',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'new'
|
||||
);
|
||||
expect(params.get('listings')).toBe('new');
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
expect(parseUrlState().listings).toBe('new');
|
||||
|
||||
// A bare URL falls back to the default; an unknown value is ignored.
|
||||
window.history.replaceState({}, '', '/');
|
||||
expect(parseUrlState().listings).toBe('all');
|
||||
window.history.replaceState({}, '', '/?listings=bogus');
|
||||
expect(parseUrlState().listings).toBe('all');
|
||||
});
|
||||
|
||||
it('round-trips a crime-type subset when the crime overlay is active', () => {
|
||||
const params = stateToParams(
|
||||
null,
|
||||
|
|
@ -581,6 +628,63 @@ describe('url-state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('round-trips council filters (incl. the shared social-rent column) via the council param', () => {
|
||||
const ex = createCouncilFilterKey('% Ex-council', 1);
|
||||
const current = createCouncilFilterKey('% Social rent', 2);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[ex]: [10, 90],
|
||||
[current]: [30, 100],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('council')).toEqual(['% Ex-council:10:90', '% Social rent:30:100']);
|
||||
expect(params.getAll('tenure')).toEqual([]);
|
||||
expect(params.getAll('filter')).toEqual([]);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createCouncilFilterKey('% Ex-council', 0)]: [10, 90],
|
||||
[createCouncilFilterKey('% Social rent', 1)]: [30, 100],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a Tenure social-rent filter and a Council "current" filter as distinct params', () => {
|
||||
// The shared "% Social rent" column must serialise under BOTH params without
|
||||
// colliding: the prefixes keep the keys distinct on the way back in.
|
||||
const tenureSocial = createTenureFilterKey('% Social rent', 0);
|
||||
const councilCurrent = createCouncilFilterKey('% Social rent', 0);
|
||||
|
||||
const params = stateToParams(
|
||||
null,
|
||||
{
|
||||
[tenureSocial]: [40, 100],
|
||||
[councilCurrent]: [10, 50],
|
||||
},
|
||||
[],
|
||||
new Set(),
|
||||
'area'
|
||||
);
|
||||
|
||||
expect(params.getAll('tenure')).toEqual(['% Social rent:40:100']);
|
||||
expect(params.getAll('council')).toEqual(['% Social rent:10:50']);
|
||||
|
||||
window.history.replaceState({}, '', `/?${params.toString()}`);
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.filters).toEqual({
|
||||
[createTenureFilterKey('% Social rent', 0)]: [40, 100],
|
||||
[createCouncilFilterKey('% Social rent', 0)]: [10, 50],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips repeated amenity distance filters with dedicated URL params', () => {
|
||||
const park = createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 3);
|
||||
const cafe = createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 4);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue