ruby homepage changes

This commit is contained in:
Andras Schmelczer 2026-02-21 20:49:56 +00:00
parent 94ebd0f614
commit 7777d4046e
7 changed files with 312 additions and 223 deletions

View file

@ -5,6 +5,7 @@ import ScrollStory from './ScrollStory';
import BottomIllustration from './BottomIllustration'; import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue'; import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon } from '../ui/icons/ChevronIcon'; import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { LogoIcon } from '../ui/icons/LogoIcon';
import type { FeatureMeta } from '../../types'; import type { FeatureMeta } from '../../types';
export default function HomePage({ export default function HomePage({
@ -27,7 +28,6 @@ export default function HomePage({
}, []); }, []);
const whyRef = useFadeInRef(); const whyRef = useFadeInRef();
const howRef = useFadeInRef();
const ctaRef = useFadeInRef(); const ctaRef = useFadeInRef();
return ( return (
@ -41,17 +41,16 @@ export default function HomePage({
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col"> <div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
<div> <div>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight"> <h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
Maximum <span className="text-teal-400">Value</span>. Minimum Compromise. Maximum <span className="text-teal-400">Value</span>.
<br />
Minimum Compromise.
</h1> </h1>
<p className="text-xl text-warm-300 mb-6 leading-relaxed max-w-xl"> <p className="text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
Buying a home may be your most important decision. Why not ensure you make your House hunting? Make your biggest investment your smartest move.
best-ever decision?
</p> </p>
<p className="text-lg text-warm-400 mb-8 max-w-lg"> <p className="text-lg text-warm-400 mb-8 max-w-xl">
You have so many options. Picking the best one is daunting and stressful. It 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
won&apos;t be anymore when looking at the property landscape through our fit.
interactive map. Simply pick your exact needs and our interactive map will show
you all areas that satisfy your requirements and more.
</p> </p>
<div className="flex items-center gap-4 mb-10"> <div className="flex items-center gap-4 mb-10">
<button <button
@ -60,20 +59,32 @@ export default function HomePage({
> >
Explore the map Explore the map
</button> </button>
{hidePricing ? ( <button
<span className="px-[26px] py-[12px] border-2 border-teal-400/50 text-teal-400 rounded-lg font-semibold text-base"> onClick={() => {
You have lifetime access! const target = document.getElementById('comparison');
</span> if (!target) return;
) : ( const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
<button if (!scroller) return;
onClick={onOpenPricing} const start = scroller.scrollTop;
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base" const end = start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 48;
> const distance = end - start;
Get lifetime access const duration = 1200;
</button> let startTime: number;
)} const step = (time: number) => {
if (!startTime) startTime = time;
const t = Math.min((time - startTime) / duration, 1);
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
scroller.scrollTop = start + distance * ease;
if (t < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}}
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
>
See the difference
</button>
</div> </div>
<div className="flex gap-12 pt-6 border-t border-white/10"> <div className="flex gap-12 pt-3 border-t border-white/10">
<div> <div>
<div className="text-2xl md:text-3xl font-bold text-white"> <div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} /> <TickerValue text="13M" active={statsActive} />
@ -93,87 +104,145 @@ export default function HomePage({
</div> </div>
</div> </div>
<div className="flex-1" /> <div className="flex-1" />
<div className="flex flex-col items-center pb-8 animate-[bounce_3s_ease-in-out_infinite]"> <button
onClick={() => {
const target = document.getElementById('demo');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
const start = scroller.scrollTop;
const end = start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 48;
const distance = end - start;
const duration = 1200;
let startTime: number;
const step = (time: number) => {
if (!startTime) startTime = time;
const t = Math.min((time - startTime) / duration, 1);
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
scroller.scrollTop = start + distance * ease;
if (t < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}}
className="flex flex-col items-center pb-8 mt-10 md:mt-0 animate-[bounce_3s_ease-in-out_infinite] cursor-pointer"
>
<p className="text-lg md:text-xl font-semibold text-warm-300 mb-2"> <p className="text-lg md:text-xl font-semibold text-warm-300 mb-2">
How does it work? See It in Action
</p> </p>
<ChevronIcon direction="down" className="w-6 h-6 text-warm-400" /> <ChevronIcon direction="down" className="w-6 h-6 text-warm-400" />
</button>
</div>
</div>
{/* How to use it + Comparison table (two columns) */}
<div id="how-it-works" className="max-w-7xl mx-auto px-6 pt-20 pb-2">
<div ref={whyRef} className="fade-in-section">
<div className="grid lg:grid-cols-[2fr_3fr] gap-12 items-start">
{/* Left: How to use it */}
<div>
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
How to use it
</h2>
<div className="space-y-8">
{HOW_STEPS.map((step, i) => (
<div key={i} className="flex gap-5">
<div className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-lg">
{i + 1}
</div>
<div>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-1">
{step.title}
</h3>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
{step.description}
</p>
</div>
</div>
))}
</div>
</div>
{/* Right: Comparison table */}
<div id="comparison">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 flex items-center gap-3">
Others vs Perfect Postcode <LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
</h2>
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 shadow-sm">
<table className="w-full text-left">
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
<th className="px-2 md:px-5 py-3 md:py-4 text-xs md:text-sm font-bold text-navy-950 dark:text-warm-100" />
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
Listing portals
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
{'\u201CCheck my postcode\u201D'}
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
Area guides
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-teal-700 dark:text-teal-400 text-center">
Perfect Postcode
</th>
</tr>
</thead>
<tbody>
{FEATURE_ROWS.map((row, i) => (
<tr
key={row.feature}
className={
i < FEATURE_ROWS.length - 1
? 'border-b border-warm-100 dark:border-warm-800'
: ''
}
>
<td className="px-2 md:px-5 py-2.5 md:py-3.5 text-xs md:text-sm text-warm-700 dark:text-warm-300">
{row.feature}
{row.subtitle && (
<div className="italic text-warm-500 dark:text-warm-400">{row.subtitle}</div>
)}
</td>
{[row.listings, row.postcode, row.guides].map((has, j) => (
<td
key={j}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg ${has ? 'text-green-500' : 'text-red-500'}`}
>
{has ? '\u2713' : '\u2717'}
</td>
))}
<td className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg text-green-500">
&#x2713;
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Scrollytelling: Problem + Solution + Demo map */} {/* Scrollytelling: Problem + Solution + Demo map */}
<h2 id="demo" className="text-3xl font-bold text-navy-950 dark:text-warm-100 text-center pt-6 mb-4">
See It in Action
</h2>
<p className="text-warm-600 dark:text-warm-400 text-center max-w-2xl mx-auto mb-8 leading-relaxed">
Listings only show what&apos;s on the market right now &mdash; 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.
</p>
<ScrollStory features={features} theme={theme} /> <ScrollStory features={features} theme={theme} />
{/* Why existing tools don't cut it */}
<div className="max-w-4xl mx-auto px-6 py-20">
<div ref={whyRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
Why existing tools don&apos;t cut it
</h2>
<div className="grid md:grid-cols-3 gap-6">
{WHY_CARDS.map((card) => (
<div
key={card.title}
className="rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-6 shadow-sm"
>
<div className="text-2xl mb-3">{card.icon}</div>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-2">{card.title}</h3>
<p className="text-warm-600 dark:text-warm-400 text-sm leading-relaxed">
{card.description}
</p>
</div>
))}
</div>
<p className="text-center mt-10 text-lg text-warm-700 dark:text-warm-300 max-w-2xl mx-auto leading-relaxed">
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.
</p>
</div>
</div>
{/* How to use it */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={howRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
How to use it
</h2>
<div className="space-y-8">
{HOW_STEPS.map((step, i) => (
<div key={i} className="flex gap-5">
<div className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-lg">
{i + 1}
</div>
<div>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-1">
{step.title}
</h3>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
{step.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* The real cost CTA */} {/* The real cost CTA */}
<div className="max-w-3xl mx-auto px-6 pb-12"> <div className="max-w-3xl mx-auto px-6 pt-20 pb-12">
<div ref={ctaRef} className="fade-in-section text-center"> <div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug"> <h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
The biggest financial decision of your life The biggest financial decision of your life
<br /> <br />
deserves proper tools behind&nbsp;it. deserves proper tools behind&nbsp;it.
</h2> </h2>
<p className="text-warm-600 dark:text-warm-400 mb-3 max-w-xl mx-auto leading-relaxed"> <p className="text-warm-600 dark:text-warm-400 mb-8 max-w-xl mx-auto leading-relaxed">
Stamp duty on a &pound;400k house: &pound;10,000. Solicitor fees: &pound;1,500. Don&apos;t leave it to chance.
Survey: &pound;500. Moving costs: &pound;1,000. And that&apos;s just the money. Get the
wrong area and you&apos;re stuck &mdash; with a long commute, bad schools, or a street
that looked fine on the listing photos but turns out to be on a motorway.
</p>
<p className="text-warm-700 dark:text-warm-300 mb-8 font-semibold">
One payment. Lifetime access. Less than your survey costs and vastly more useful.
</p> </p>
<button <button
onClick={onOpenDashboard} onClick={onOpenDashboard}
@ -191,46 +260,33 @@ export default function HomePage({
); );
} }
const WHY_CARDS = [ const FEATURE_ROWS = [
{ // listings postcode guides
icon: '\u{1F3D8}\uFE0F', { feature: 'Search without choosing an area first', subtitle: '(start with needs, not a location)', listings: false, postcode: false, guides: false },
title: 'Listing portals', { feature: 'Area data', subtitle: '(crime, schools, noise, broadband)', listings: false, postcode: true, guides: true },
description: { feature: 'Property-specific data', subtitle: '(price, EPC, floor area)', listings: true, postcode: false, guides: false },
"Show you what's for sale today. That's a snapshot, not a strategy. You can filter by price and bedrooms \u2014 that's about it. They tell you nothing about the area.", { feature: '56 combinable filters in one place', subtitle: '(all insights, one interactive map)', listings: false, postcode: false, guides: false },
},
{
icon: '\u{1F4CD}',
title: '\u201CCheck my postcode\u201D sites',
description:
"Give you stats for one postcode at a time. Useful if you already know where to look. Useless if you don't \u2014 and there are 1.5 million postcodes in England.",
},
{
icon: '\u{1F5FA}\uFE0F',
title: 'Area guides',
description:
"Show one statistic on a map \u2014 crime, or school ratings, or prices. But you care about the intersection: affordable AND safe AND good schools AND short commute. Nobody else shows you that.",
},
]; ];
const HOW_STEPS = [ const HOW_STEPS = [
{ {
title: 'Set your non-negotiables', title: 'Set your must-haves',
description: description:
'Budget, commute, bedrooms, whatever matters most. The map narrows to only the areas that qualify.', 'Budget, commute, schools \u2014 the map shows only what qualifies.',
}, },
{ {
title: "Explore what\u2019s left", title: 'Explore areas and discover hidden gems',
description: description:
"Zoom in. Toggle layers. See crime, schools, noise, amenities. Discover areas you didn\u2019t know existed.", 'Zoom in, dig into details and nice to haves.',
}, },
{ {
title: 'Drill into postcodes', title: 'Drill into postcodes',
description: description:
'At street level, see individual properties, what they sold for, floor area, energy rating, estimated current value.', 'See individual properties, sale prices, floor area, and compare.',
}, },
{ {
title: 'Go to viewings with a shortlist, not a prayer', title: 'Shortlist with confidence',
description: description:
"You\u2019ve already done the hard part. Every area on your list meets your actual criteria, not just what happened to be listed that week.", 'Every area on your list meets your actual criteria \u2014 not just what was listed that week.',
}, },
]; ];

View file

@ -3,11 +3,13 @@ import MapComponent from '../map/Map';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api'; import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format'; import { formatValue } from '../../lib/format';
import { zoomToResolution } from '../../lib/map-utils'; import { zoomToResolution } from '../../lib/map-utils';
import { FEATURE_GRADIENT } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { FeatureMeta, HexagonData } from '../../types'; import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW_START = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 }; const DEMO_VIEW_START = { longitude: -0.12, latitude: 51.51, zoom: 5.5, pitch: 0 };
const DEMO_VIEW_END = { longitude: -1.9, latitude: 52.2, zoom: 12, pitch: 0 }; const DEMO_VIEW_END = { longitude: -0.12, latitude: 51.51, zoom: 7, pitch: 0 };
function easeOutCubic(t: number): number { function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3); return 1 - Math.pow(1 - t, 3);
@ -28,7 +30,7 @@ interface StageDef {
const STAGES: StageDef[] = [ const STAGES: StageDef[] = [
// 0: No filters — the problem // 0: No filters — the problem
{ filters: {} }, { filters: {}, colorFeature: 'Estimated current price' },
// 1: Price filter — "affordable price" // 1: Price filter — "affordable price"
{ {
filters: { 'Estimated current price': [0, 0.25] }, filters: { 'Estimated current price': [0, 0.25] },
@ -58,6 +60,7 @@ const STAGES: StageDef[] = [
'Good+ primary schools within 5km': [0.3, 1], 'Good+ primary schools within 5km': [0.3, 1],
'Number of restaurants within 2km': [0.15, 1], 'Number of restaurants within 2km': [0.15, 1],
}, },
colorFeature: 'Number of restaurants within 2km',
}, },
]; ];
@ -74,27 +77,26 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
<strong className="text-navy-950 dark:text-warm-100"> <strong className="text-navy-950 dark:text-warm-100">
&pound;300k&ndash;&pound;400k &pound;300k&ndash;&pound;400k
</strong>{' '} </strong>{' '}
on a home. Your research method? Scrolling through listings and hoping for the best. on a home, somewhere commutable to work in, say, London. Your research method? Picking some areas you think are good based on word of mouth... then hope for the best.
</p>
<p className="text-lg leading-relaxed mb-4">
Listings only show what&apos;s on the market <em>right now</em> &mdash; a tiny, random
slice of what&apos;s actually out there. You&apos;ll never see the 3-bed Victorian on a
quiet street that sold six months ago, or the one that&apos;ll list next month.
</p>
<p className="text-base italic text-warm-500 dark:text-warm-400">
Your home is not a box of cereal. Don&apos;t let a discount on the wrong property distract
you from finding the right one.
</p> </p>
</> </>
), ),
}, },
{ {
heading: 'Set your requirements. The map shows you where they intersect.', heading: null,
body: ( body: (
<p className="text-lg leading-relaxed"> <>
Say you want a home at an{' '} <div className="flex items-center gap-3 mb-3">
<strong className="text-navy-950 dark:text-warm-100">affordable price</strong>&hellip; <div className="shrink-0 w-8 h-8 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-sm">
</p> 1
</div>
<h3 className="text-xl font-bold text-navy-950 dark:text-warm-100">Set your must-haves</h3>
</div>
<p className="text-lg leading-relaxed">
Say you want a home at an{' '}
<strong className="text-navy-950 dark:text-warm-100">affordable price</strong>&hellip;
</p>
</>
), ),
}, },
{ {
@ -110,33 +112,25 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{ {
heading: null, heading: null,
body: ( body: (
<> <p className="text-lg leading-relaxed">
<p className="text-lg leading-relaxed mb-4"> &hellip;and{' '}
&hellip;and{' '} <strong className="text-navy-950 dark:text-warm-100">
<strong className="text-navy-950 dark:text-warm-100"> restaurants within walking distance
restaurants within walking distance </strong>
</strong> .
. </p>
</p>
<p className="text-lg leading-relaxed font-semibold text-navy-950 dark:text-warm-100">
You haven&apos;t opened a single listing yet &mdash; and you already know exactly where to
focus.
</p>
</>
), ),
}, },
{ {
heading: null, heading: null,
body: ( body: (
<> <>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-2"> <p className="text-lg leading-relaxed mb-4 font-semibold text-navy-950 dark:text-warm-100">
That&apos;s just three filters. No area chosen. No listings browsed. Yet you already know exactly where your needs are met.
</p> </p>
<p className="text-lg leading-relaxed"> <p className="text-lg leading-relaxed">
We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">43</strong>. That&apos;s just 3 filters. We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">56</strong> &mdash;
Spanning property prices, commute times, school ratings, crime rates, broadband speeds, covering commute times, crime, broadband, noise, schools, amenities, and more.
road noise, energy efficiency, amenities, deprivation scores, demographics, and more. All
layered on top of each other, all filterable at once.
</p> </p>
</> </>
), ),
@ -318,7 +312,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const deferredHexData = useDeferredValue(hexData); const deferredHexData = useDeferredValue(hexData);
return ( return (
<section ref={sectionRef} className="relative"> <section ref={sectionRef} className="snap-start relative">
{/* Sticky map background */} {/* Sticky map background */}
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0"> <div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
<div className="absolute inset-0"> <div className="absolute inset-0">
@ -396,6 +390,23 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
</div> </div>
); );
})} })}
{/* Color legend */}
{viewFeatureName && colorRange && (
<div className="pt-4 border-t border-warm-200 dark:border-warm-700 transition-opacity duration-700">
<div className="text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400 mb-2">
Colour
</div>
<div className="text-sm font-medium text-navy-950 dark:text-warm-100 mb-1.5">
{viewFeatureName}
</div>
<div className="h-2.5 rounded-full overflow-hidden" style={{ background: gradientToCss(FEATURE_GRADIENT) }} />
<div className="flex justify-between mt-1 text-xs text-warm-500 dark:text-warm-400">
<span>{formatValue(colorRange[0], viewMeta!)}</span>
<span>{formatValue(colorRange[1], viewMeta!)}</span>
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -26,7 +26,7 @@ import {
travelFieldKey, travelFieldKey,
type TravelTimeInitial, type TravelTimeInitial,
} from '../../hooks/useTravelTime'; } 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 { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense'; import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal'; import UpgradeModal from '../ui/UpgradeModal';
@ -94,7 +94,6 @@ export default function MapPage({
filters, filters,
activeFeature, activeFeature,
dragValue, dragValue,
dragData,
pinnedFeature, pinnedFeature,
enabledFeatures, enabledFeatures,
viewFeature, viewFeature,
@ -110,7 +109,6 @@ export default function MapPage({
handleTogglePin, handleTogglePin,
handleSetPin, handleSetPin,
handleCancelPin, handleCancelPin,
updateBoundsInfo,
} = useFilters({ } = useFilters({
initialFilters, initialFilters,
features, features,
@ -159,14 +157,9 @@ export default function MapPage({
viewFeature, viewFeature,
activeFeature, activeFeature,
dragValue, dragValue,
dragData,
travelTimeEntries: travelTime.entries, travelTimeEntries: travelTime.entries,
}); });
useEffect(() => {
updateBoundsInfo(mapData.bounds, mapData.resolution);
}, [mapData.bounds, mapData.resolution, updateBoundsInfo]);
const selection = useHexagonSelection({ const selection = useHexagonSelection({
filters, filters,
features, features,

View file

@ -347,6 +347,18 @@ export default function PricingPage({
</p> </p>
)} )}
</div> </div>
<div className="relative z-10 max-w-2xl mx-auto px-6 pb-16 text-center">
<p className="text-warm-400 leading-relaxed mb-3">
Stamp duty on a &pound;400k house: &pound;10,000. Solicitor fees: &pound;1,500.
Survey: &pound;500. Moving costs: &pound;1,000. And that&apos;s just the money. Get the
wrong area and you&apos;re stuck &mdash; with a long commute, bad schools, or a street
that looked fine on the listing photos but turns out to be on a motorway.
</p>
<p className="text-warm-200 font-semibold">
One payment. Lifetime access. Less than your survey costs and vastly more useful.
</p>
</div>
</div> </div>
); );
} }

View file

@ -1,6 +1,5 @@
import { useState, useCallback, useRef, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds, HexagonData, ApiResponse } from '../types'; import type { FeatureMeta, FeatureFilters } from '../types';
import { apiUrl, logNonAbortError } from '../lib/api';
interface UseFiltersOptions { interface UseFiltersOptions {
initialFilters: FeatureFilters; initialFilters: FeatureFilters;
@ -8,15 +7,10 @@ interface UseFiltersOptions {
} }
export function useFilters({ initialFilters, features }: UseFiltersOptions) { export function useFilters({ initialFilters, features }: UseFiltersOptions) {
// Use refs for bounds/resolution so handleDragStart always has latest values
const boundsRef = useRef<Bounds | null>(null);
const resolutionRef = useRef<number>(8);
const [filters, setFilters] = useState<FeatureFilters>(initialFilters); const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
const [activeFeature, setActiveFeature] = useState<string | null>(null); const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null); const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null); const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
const [dragData, setDragData] = useState<HexagonData[] | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]); const enabledFeatures = useMemo(() => new Set(Object.keys(filters)), [filters]);
@ -64,40 +58,6 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
setActiveFeature(name); setActiveFeature(name);
const fval = filters[name]; const fval = filters[name];
setDragValue(fval && !Array.isArray(fval[0]) ? (fval as [number, number]) : null); 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] [filters, features]
); );
@ -112,18 +72,12 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
} }
setActiveFeature(null); setActiveFeature(null);
setDragValue(null); setDragValue(null);
setDragData(null);
if (dragAbortRef.current) {
dragAbortRef.current.abort();
dragAbortRef.current = null;
}
}, [activeFeature, dragValue]); }, [activeFeature, dragValue]);
const handleSetFilters = useCallback((newFilters: FeatureFilters) => { const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
setFilters(newFilters); setFilters(newFilters);
setActiveFeature(null); setActiveFeature(null);
setDragValue(null); setDragValue(null);
setDragData(null);
setPinnedFeature(null); setPinnedFeature(null);
}, []); }, []);
@ -139,16 +93,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
setPinnedFeature(null); setPinnedFeature(null);
}, []); }, []);
const updateBoundsInfo = useCallback((newBounds: Bounds | null, newResolution: number) => {
boundsRef.current = newBounds;
resolutionRef.current = newResolution;
}, []);
return { return {
filters, filters,
activeFeature, activeFeature,
dragValue, dragValue,
dragData,
pinnedFeature, pinnedFeature,
enabledFeatures, enabledFeatures,
viewFeature, viewFeature,
@ -164,6 +112,5 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
handleTogglePin, handleTogglePin,
handleSetPin, handleSetPin,
handleCancelPin, handleCancelPin,
updateBoundsInfo,
}; };
} }

View file

@ -32,7 +32,6 @@ interface UseMapDataOptions {
viewFeature: string | null; viewFeature: string | null;
activeFeature: string | null; activeFeature: string | null;
dragValue: [number, number] | null; dragValue: [number, number] | null;
dragData: HexagonData[] | null;
travelTimeEntries: TravelTimeEntry[]; travelTimeEntries: TravelTimeEntry[];
} }
@ -42,7 +41,6 @@ export function useMapData({
viewFeature, viewFeature,
activeFeature, activeFeature,
dragValue, dragValue,
dragData,
travelTimeEntries, travelTimeEntries,
}: UseMapDataOptions) { }: UseMapDataOptions) {
const [rawData, setRawData] = useState<HexagonData[]>([]); const [rawData, setRawData] = useState<HexagonData[]>([]);
@ -59,6 +57,13 @@ export function useMapData({
const [licenseRequired, setLicenseRequired] = useState(false); const [licenseRequired, setLicenseRequired] = useState(false);
const [freeZone, setFreeZone] = useState<Bounds | null>(null); const [freeZone, setFreeZone] = useState<Bounds | null>(null);
// Drag preview state
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
const [dragPostcodeData, setDragPostcodeData] = useState<PostcodeFeature[] | null>(null);
const dragFeatureRef = useRef<string | null>(null);
const dragAbortRef = useRef<AbortController | null>(null);
const activeFeatureRef = useRef<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const prevBoundsRef = useRef<string>(''); const prevBoundsRef = useRef<string>('');
@ -85,6 +90,61 @@ export function useMapData({
return segments.join('|'); return segments.join('|');
}, [travelTimeEntries]); }, [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 // Fetch hexagons or postcodes when bounds/filters change
useEffect(() => { useEffect(() => {
if (!bounds) return; if (!bounds) return;
@ -157,6 +217,13 @@ export function useMapData({
setRawData(json.features); setRawData(json.features);
setPostcodeData([]); 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) { } catch (err) {
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err); if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
} finally { } finally {
@ -171,7 +238,9 @@ export function useMapData({
}; };
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]); }, [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 // Compute p5/p95 from visible data for the viewed feature
const dataRange = useMemo((): [number, number] | null => { const dataRange = useMemo((): [number, number] | null => {
@ -182,14 +251,14 @@ export function useMapData({
if (!isTravelTime) { if (!isTravelTime) {
const meta = features.find((f) => f.name === viewFeature); const meta = features.find((f) => f.name === viewFeature);
if (!meta || meta.type === 'enum') return null; if (!meta || meta.type === 'enum') return null;
if (activeFeature && !dragData) return null; if (activeFeature && !dragHexData && !dragPostcodeData) return null;
} }
const vals: number[] = []; const vals: number[] = [];
if (usePostcodeView && !isTravelTime) { if (usePostcodeView && !isTravelTime) {
if (postcodeData.length === 0) return null; if (effectivePostcodeData.length === 0) return null;
for (const feat of postcodeData) { for (const feat of effectivePostcodeData) {
if (bounds) { if (bounds) {
const [lng, lat] = feat.properties.centroid as [number, number]; const [lng, lat] = feat.properties.centroid as [number, number];
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east) 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_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_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 // Color range for the legend and hex coloring
const colorRange = useMemo((): [number, number] | null => { const colorRange = useMemo((): [number, number] | null => {
@ -270,7 +339,7 @@ export function useMapData({
return { return {
data, data,
rawData, rawData,
postcodeData, postcodeData: effectivePostcodeData,
resolution, resolution,
bounds, bounds,
loading, loading,

View file

@ -70,10 +70,11 @@ export async function shortenUrl(params: string): Promise<string> {
return `${window.location.origin}${data.url}`; 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); const entries = Object.entries(filters);
if (entries.length === 0) return ''; if (entries.length === 0) return '';
return entries return entries
.filter(([name]) => name !== exclude)
.map(([name, value]) => { .map(([name, value]) => {
const meta = features.find((f) => f.name === name); const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') { if (meta?.type === 'enum') {