diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 398bbf0..316f8b0 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -5,6 +5,7 @@ import ScrollStory from './ScrollStory'; import BottomIllustration from './BottomIllustration'; import { TickerValue } from '../ui/TickerValue'; import { ChevronIcon } from '../ui/icons/ChevronIcon'; +import { LogoIcon } from '../ui/icons/LogoIcon'; import type { FeatureMeta } from '../../types'; export default function HomePage({ @@ -27,7 +28,6 @@ export default function HomePage({ }, []); const whyRef = useFadeInRef(); - const howRef = useFadeInRef(); const ctaRef = useFadeInRef(); return ( @@ -41,17 +41,16 @@ export default function HomePage({

- Maximum Value. Minimum Compromise. + Maximum Value. +
+ Minimum Compromise.

-

- Buying a home may be your most important decision. Why not ensure you make your - best-ever decision? +

+ House hunting? Make your biggest investment your smartest move.

-

- You have so many options. Picking the best one is daunting and stressful. It - won't be anymore when looking at the property landscape through our - interactive map. Simply pick your exact needs and our interactive map will show - you all areas that satisfy your requirements and more. +

+ So many options — choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that + fit.

- {hidePricing ? ( - - You have lifetime access! - - ) : ( - - )} +
-
+
@@ -93,87 +104,145 @@ export default function HomePage({
-
+ +
+
+ + {/* How to use it + Comparison table (two columns) */} +
+
+
+ {/* Left: How to use it */} +
+

+ How to use it +

+
+ {HOW_STEPS.map((step, i) => ( +
+
+ {i + 1} +
+
+

+ {step.title} +

+

+ {step.description} +

+
+
+ ))} +
+
+ {/* Right: Comparison table */} +
+

+ Others vs Perfect Postcode +

+
+ + + + + + + + + + + {FEATURE_ROWS.map((row, i) => ( + + + {[row.listings, row.postcode, row.guides].map((has, j) => ( + + ))} + + + ))} + +
+ + Listing portals + + {'\u201CCheck my postcode\u201D'} + + Area guides + + Perfect Postcode +
+ {row.feature} + {row.subtitle && ( +
{row.subtitle}
+ )} +
+ {has ? '\u2713' : '\u2717'} + + ✓ +
+
+
{/* Scrollytelling: Problem + Solution + Demo map */} +

+ See It in Action +

+

+ Listings only show what's on the market right now — a tiny, random slice. + They tell you nothing about the area, or potential opportunities. We flip the search: + start with what matters to you, and the right places reveal themselves. +

- {/* Why existing tools don't cut it */} -
-
-

- Why existing tools don't cut it -

-
- {WHY_CARDS.map((card) => ( -
-
{card.icon}
-

{card.title}

-

- {card.description} -

-
- ))} -
-

- We do. 13 million historical transactions. 56 filters. Real travel-time routing to - any destination. Every postcode in England, scored and filterable, on a single map. -

-
-
- - {/* How to use it */} -
-
-

- How to use it -

-
- {HOW_STEPS.map((step, i) => ( -
-
- {i + 1} -
-
-

- {step.title} -

-

- {step.description} -

-
-
- ))} -
-
-
- {/* The real cost CTA */} -
+

The biggest financial decision of your life
deserves proper tools behind it.

-

- Stamp duty on a £400k house: £10,000. Solicitor fees: £1,500. - Survey: £500. Moving costs: £1,000. And that's just the money. Get the - wrong area and you're stuck — with a long commute, bad schools, or a street - that looked fine on the listing photos but turns out to be on a motorway. -

-

- One payment. Lifetime access. Less than your survey costs and vastly more useful. +

+ Don't leave it to chance.

diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 7e3cddc..66aaba7 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -26,7 +26,7 @@ import { travelFieldKey, type TravelTimeInitial, } from '../../hooks/useTravelTime'; -import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api'; +import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api'; import { INITIAL_VIEW_STATE } from '../../lib/consts'; import { useLicense } from '../../hooks/useLicense'; import UpgradeModal from '../ui/UpgradeModal'; @@ -94,7 +94,6 @@ export default function MapPage({ filters, activeFeature, dragValue, - dragData, pinnedFeature, enabledFeatures, viewFeature, @@ -110,7 +109,6 @@ export default function MapPage({ handleTogglePin, handleSetPin, handleCancelPin, - updateBoundsInfo, } = useFilters({ initialFilters, features, @@ -159,14 +157,9 @@ export default function MapPage({ viewFeature, activeFeature, dragValue, - dragData, travelTimeEntries: travelTime.entries, }); - useEffect(() => { - updateBoundsInfo(mapData.bounds, mapData.resolution); - }, [mapData.bounds, mapData.resolution, updateBoundsInfo]); - const selection = useHexagonSelection({ filters, features, diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx index f2b5687..0e6fcc9 100644 --- a/frontend/src/components/pricing/PricingPage.tsx +++ b/frontend/src/components/pricing/PricingPage.tsx @@ -347,6 +347,18 @@ export default function PricingPage({

)}
+ +
+

+ Stamp duty on a £400k house: £10,000. Solicitor fees: £1,500. + Survey: £500. Moving costs: £1,000. And that's just the money. Get the + wrong area and you're stuck — with a long commute, bad schools, or a street + that looked fine on the listing photos but turns out to be on a motorway. +

+

+ One payment. Lifetime access. Less than your survey costs and vastly more useful. +

+
); } diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index a41fe23..c23ede1 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -1,6 +1,5 @@ -import { useState, useCallback, useRef, useMemo } from 'react'; -import type { FeatureMeta, FeatureFilters, Bounds, HexagonData, ApiResponse } from '../types'; -import { apiUrl, logNonAbortError } from '../lib/api'; +import { useState, useCallback, useMemo } from 'react'; +import type { FeatureMeta, FeatureFilters } from '../types'; interface UseFiltersOptions { initialFilters: FeatureFilters; @@ -8,15 +7,10 @@ interface UseFiltersOptions { } export function useFilters({ initialFilters, features }: UseFiltersOptions) { - // Use refs for bounds/resolution so handleDragStart always has latest values - const boundsRef = useRef(null); - const resolutionRef = useRef(8); const [filters, setFilters] = useState(initialFilters); const [activeFeature, setActiveFeature] = useState(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [pinnedFeature, setPinnedFeature] = useState(null); - const [dragData, setDragData] = useState(null); - const dragAbortRef = useRef(null); const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); @@ -64,40 +58,6 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { setActiveFeature(name); const fval = filters[name]; setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); - - const currentBounds = boundsRef.current; - if (!currentBounds) return; - if (dragAbortRef.current) dragAbortRef.current.abort(); - dragAbortRef.current = new AbortController(); - - const otherFilters = Object.entries(filters).filter(([k]) => k !== name); - let filtersStr = ''; - if (otherFilters.length > 0) { - filtersStr = otherFilters - .map(([n, value]) => { - const m = features.find((f) => f.name === n); - if (m?.type === 'enum') return `${n}:${(value as string[]).join('|')}`; - const [min, max] = value as [number, number]; - const maxStr = m?.absolute && max === m.max ? 'inf' : String(max); - return `${n}:${min}:${maxStr}`; - }) - .join(','); - } - - const boundsStr = `${currentBounds.south},${currentBounds.west},${currentBounds.north},${currentBounds.east}`; - const params = new URLSearchParams({ - resolution: resolutionRef.current.toString(), - bounds: boundsStr, - }); - if (filtersStr) params.set('filters', filtersStr); - params.set('fields', name); - - fetch(apiUrl('hexagons', params), { - signal: dragAbortRef.current.signal, - }) - .then((res) => res.json()) - .then((json: ApiResponse) => setDragData(json.features)) - .catch((err) => logNonAbortError('Failed to fetch drag data', err)); }, [filters, features] ); @@ -112,18 +72,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { } setActiveFeature(null); setDragValue(null); - setDragData(null); - if (dragAbortRef.current) { - dragAbortRef.current.abort(); - dragAbortRef.current = null; - } }, [activeFeature, dragValue]); const handleSetFilters = useCallback((newFilters: FeatureFilters) => { setFilters(newFilters); setActiveFeature(null); setDragValue(null); - setDragData(null); setPinnedFeature(null); }, []); @@ -139,16 +93,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { setPinnedFeature(null); }, []); - const updateBoundsInfo = useCallback((newBounds: Bounds | null, newResolution: number) => { - boundsRef.current = newBounds; - resolutionRef.current = newResolution; - }, []); - return { filters, activeFeature, dragValue, - dragData, pinnedFeature, enabledFeatures, viewFeature, @@ -164,6 +112,5 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { handleTogglePin, handleSetPin, handleCancelPin, - updateBoundsInfo, }; } diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index 817be69..27f2b50 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -32,7 +32,6 @@ interface UseMapDataOptions { viewFeature: string | null; activeFeature: string | null; dragValue: [number, number] | null; - dragData: HexagonData[] | null; travelTimeEntries: TravelTimeEntry[]; } @@ -42,7 +41,6 @@ export function useMapData({ viewFeature, activeFeature, dragValue, - dragData, travelTimeEntries, }: UseMapDataOptions) { const [rawData, setRawData] = useState([]); @@ -59,6 +57,13 @@ export function useMapData({ const [licenseRequired, setLicenseRequired] = useState(false); const [freeZone, setFreeZone] = useState(null); + // Drag preview state + const [dragHexData, setDragHexData] = useState(null); + const [dragPostcodeData, setDragPostcodeData] = useState(null); + const dragFeatureRef = useRef(null); + const dragAbortRef = useRef(null); + const activeFeatureRef = useRef(null); + const debounceRef = useRef | null>(null); const abortControllerRef = useRef(null); const prevBoundsRef = useRef(''); @@ -85,6 +90,61 @@ export function useMapData({ return segments.join('|'); }, [travelTimeEntries]); + // Keep activeFeatureRef in sync + useEffect(() => { + activeFeatureRef.current = activeFeature; + }, [activeFeature]); + + // Drag prefetch: when activeFeature starts, fetch data excluding that filter + useEffect(() => { + if (!activeFeature || !bounds) return; + + if (dragAbortRef.current) dragAbortRef.current.abort(); + dragAbortRef.current = new AbortController(); + + const filtersStr = buildFilterString(filters, features, activeFeature); + const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`; + + if (usePostcodeView) { + const params = new URLSearchParams({ bounds: boundsStr }); + if (filtersStr) params.set('filters', filtersStr); + params.set('fields', activeFeature); + + fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal })) + .then((res) => res.json()) + .then((json: { features: PostcodeFeature[] }) => { + setDragPostcodeData(json.features); + setDragHexData(null); + dragFeatureRef.current = activeFeature; + }) + .catch((err) => logNonAbortError('Failed to fetch drag postcode data', err)); + } else { + const params = new URLSearchParams({ + resolution: resolution.toString(), + bounds: boundsStr, + }); + if (filtersStr) params.set('filters', filtersStr); + params.set('fields', activeFeature); + if (travelParam) params.set('travel', travelParam); + + fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal })) + .then((res) => res.json()) + .then((json: ApiResponse) => { + setDragHexData(json.features); + setDragPostcodeData(null); + dragFeatureRef.current = activeFeature; + }) + .catch((err) => logNonAbortError('Failed to fetch drag hex data', err)); + } + + return () => { + if (dragAbortRef.current) { + dragAbortRef.current.abort(); + dragAbortRef.current = null; + } + }; + }, [activeFeature, bounds, resolution, filters, features, usePostcodeView, travelParam]); + // Fetch hexagons or postcodes when bounds/filters change useEffect(() => { if (!bounds) return; @@ -157,6 +217,13 @@ export function useMapData({ setRawData(json.features); setPostcodeData([]); } + + // Clear drag data when committed fetch completes and we're not mid-drag + if (!activeFeatureRef.current) { + setDragHexData(null); + setDragPostcodeData(null); + dragFeatureRef.current = null; + } } catch (err) { if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err); } finally { @@ -171,7 +238,9 @@ export function useMapData({ }; }, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]); - const data = dragData ?? rawData; + // Use drag data when it matches the current view feature, otherwise fall back to rawData + const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData; + const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData; // Compute p5/p95 from visible data for the viewed feature const dataRange = useMemo((): [number, number] | null => { @@ -182,14 +251,14 @@ export function useMapData({ if (!isTravelTime) { const meta = features.find((f) => f.name === viewFeature); if (!meta || meta.type === 'enum') return null; - if (activeFeature && !dragData) return null; + if (activeFeature && !dragHexData && !dragPostcodeData) return null; } const vals: number[] = []; if (usePostcodeView && !isTravelTime) { - if (postcodeData.length === 0) return null; - for (const feat of postcodeData) { + if (effectivePostcodeData.length === 0) return null; + for (const feat of effectivePostcodeData) { if (bounds) { const [lng, lat] = feat.properties.centroid as [number, number]; if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) @@ -217,7 +286,7 @@ export function useMapData({ percentile(vals, COLOR_RANGE_LOW_PERCENTILE), percentile(vals, COLOR_RANGE_HIGH_PERCENTILE), ]; - }, [viewFeature, data, dragData, postcodeData, usePostcodeView, features, activeFeature, bounds]); + }, [viewFeature, data, dragHexData, dragPostcodeData, effectivePostcodeData, usePostcodeView, features, activeFeature, bounds]); // Color range for the legend and hex coloring const colorRange = useMemo((): [number, number] | null => { @@ -270,7 +339,7 @@ export function useMapData({ return { data, rawData, - postcodeData, + postcodeData: effectivePostcodeData, resolution, bounds, loading, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b8f2560..20956da 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -70,10 +70,11 @@ export async function shortenUrl(params: string): Promise { return `${window.location.origin}${data.url}`; } -export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[]): string { +export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[], exclude?: string): string { const entries = Object.entries(filters); if (entries.length === 0) return ''; return entries + .filter(([name]) => name !== exclude) .map(([name, value]) => { const meta = features.find((f) => f.name === name); if (meta?.type === 'enum') {