ruby homepage changes
This commit is contained in:
parent
94ebd0f614
commit
7777d4046e
7 changed files with 312 additions and 223 deletions
|
|
@ -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({
|
|||
<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>
|
||||
<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>
|
||||
<p className="text-xl 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
|
||||
best-ever decision?
|
||||
<p className="text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
|
||||
House hunting? Make your biggest investment your smartest move.
|
||||
</p>
|
||||
<p className="text-lg text-warm-400 mb-8 max-w-lg">
|
||||
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.
|
||||
<p className="text-lg text-warm-400 mb-8 max-w-xl">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<button
|
||||
|
|
@ -60,20 +59,32 @@ export default function HomePage({
|
|||
>
|
||||
Explore the map
|
||||
</button>
|
||||
{hidePricing ? (
|
||||
<span className="px-[26px] py-[12px] border-2 border-teal-400/50 text-teal-400 rounded-lg font-semibold text-base">
|
||||
You have lifetime access!
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onOpenPricing}
|
||||
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"
|
||||
>
|
||||
Get lifetime access
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
const target = document.getElementById('comparison');
|
||||
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="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 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 className="text-2xl md:text-3xl font-bold text-white">
|
||||
<TickerValue text="13M" active={statsActive} />
|
||||
|
|
@ -93,87 +104,145 @@ export default function HomePage({
|
|||
</div>
|
||||
</div>
|
||||
<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">
|
||||
How does it work?
|
||||
See It in Action
|
||||
</p>
|
||||
<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">
|
||||
✓
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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'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.
|
||||
</p>
|
||||
<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'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 */}
|
||||
<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">
|
||||
<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
|
||||
<br />
|
||||
deserves proper tools behind it.
|
||||
</h2>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-3 max-w-xl mx-auto leading-relaxed">
|
||||
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.
|
||||
</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 className="text-warm-600 dark:text-warm-400 mb-8 max-w-xl mx-auto leading-relaxed">
|
||||
Don't leave it to chance.
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
|
|
@ -191,46 +260,33 @@ export default function HomePage({
|
|||
);
|
||||
}
|
||||
|
||||
const WHY_CARDS = [
|
||||
{
|
||||
icon: '\u{1F3D8}\uFE0F',
|
||||
title: 'Listing portals',
|
||||
description:
|
||||
"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.",
|
||||
},
|
||||
{
|
||||
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 FEATURE_ROWS = [
|
||||
// listings postcode guides
|
||||
{ feature: 'Search without choosing an area first', subtitle: '(start with needs, not a location)', listings: false, postcode: false, guides: false },
|
||||
{ feature: 'Area data', subtitle: '(crime, schools, noise, broadband)', listings: false, postcode: true, guides: true },
|
||||
{ feature: 'Property-specific data', subtitle: '(price, EPC, floor area)', listings: true, postcode: false, guides: false },
|
||||
{ feature: '56 combinable filters in one place', subtitle: '(all insights, one interactive map)', listings: false, postcode: false, guides: false },
|
||||
];
|
||||
|
||||
const HOW_STEPS = [
|
||||
{
|
||||
title: 'Set your non-negotiables',
|
||||
title: 'Set your must-haves',
|
||||
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:
|
||||
"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',
|
||||
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:
|
||||
"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.',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ import MapComponent from '../map/Map';
|
|||
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { zoomToResolution } from '../../lib/map-utils';
|
||||
import { FEATURE_GRADIENT } from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { FeatureMeta, HexagonData } from '../../types';
|
||||
|
||||
const DEMO_VIEW_START = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
|
||||
const DEMO_VIEW_END = { longitude: -1.9, latitude: 52.2, zoom: 12, pitch: 0 };
|
||||
const DEMO_VIEW_START = { longitude: -0.12, latitude: 51.51, zoom: 5.5, pitch: 0 };
|
||||
const DEMO_VIEW_END = { longitude: -0.12, latitude: 51.51, zoom: 7, pitch: 0 };
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
|
|
@ -28,7 +30,7 @@ interface StageDef {
|
|||
|
||||
const STAGES: StageDef[] = [
|
||||
// 0: No filters — the problem
|
||||
{ filters: {} },
|
||||
{ filters: {}, colorFeature: 'Estimated current price' },
|
||||
// 1: Price filter — "affordable price"
|
||||
{
|
||||
filters: { 'Estimated current price': [0, 0.25] },
|
||||
|
|
@ -58,6 +60,7 @@ const STAGES: StageDef[] = [
|
|||
'Good+ primary schools within 5km': [0.3, 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">
|
||||
£300k–£400k
|
||||
</strong>{' '}
|
||||
on a home. Your research method? Scrolling through listings and hoping for the best.
|
||||
</p>
|
||||
<p className="text-lg leading-relaxed mb-4">
|
||||
Listings only show what's on the market <em>right now</em> — a tiny, random
|
||||
slice of what's actually out there. You'll never see the 3-bed Victorian on a
|
||||
quiet street that sold six months ago, or the one that'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't let a discount on the wrong property distract
|
||||
you from finding the right one.
|
||||
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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
heading: 'Set your requirements. The map shows you where they intersect.',
|
||||
heading: null,
|
||||
body: (
|
||||
<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>…
|
||||
</p>
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="shrink-0 w-8 h-8 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-sm">
|
||||
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>…
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
@ -110,33 +112,25 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|||
{
|
||||
heading: null,
|
||||
body: (
|
||||
<>
|
||||
<p className="text-lg leading-relaxed mb-4">
|
||||
…and{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">
|
||||
restaurants within walking distance
|
||||
</strong>
|
||||
.
|
||||
</p>
|
||||
<p className="text-lg leading-relaxed font-semibold text-navy-950 dark:text-warm-100">
|
||||
You haven't opened a single listing yet — and you already know exactly where to
|
||||
focus.
|
||||
</p>
|
||||
</>
|
||||
<p className="text-lg leading-relaxed">
|
||||
…and{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">
|
||||
restaurants within walking distance
|
||||
</strong>
|
||||
.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
heading: null,
|
||||
body: (
|
||||
<>
|
||||
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-2">
|
||||
That's just three filters.
|
||||
<p className="text-lg leading-relaxed mb-4 font-semibold text-navy-950 dark:text-warm-100">
|
||||
No area chosen. No listings browsed. Yet you already know exactly where your needs are met.
|
||||
</p>
|
||||
<p className="text-lg leading-relaxed">
|
||||
We've built <strong className="text-navy-950 dark:text-warm-100">43</strong>.
|
||||
Spanning property prices, commute times, school ratings, crime rates, broadband speeds,
|
||||
road noise, energy efficiency, amenities, deprivation scores, demographics, and more. All
|
||||
layered on top of each other, all filterable at once.
|
||||
That's just 3 filters. We've built <strong className="text-navy-950 dark:text-warm-100">56</strong> —
|
||||
covering commute times, crime, broadband, noise, schools, amenities, and more.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
|
|
@ -318,7 +312,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
const deferredHexData = useDeferredValue(hexData);
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className="relative">
|
||||
<section ref={sectionRef} className="snap-start relative">
|
||||
{/* Sticky map background */}
|
||||
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
|
||||
<div className="absolute inset-0">
|
||||
|
|
@ -396,6 +390,23 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -347,6 +347,18 @@ export default function PricingPage({
|
|||
</p>
|
||||
)}
|
||||
</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 £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.
|
||||
</p>
|
||||
<p className="text-warm-200 font-semibold">
|
||||
One payment. Lifetime access. Less than your survey costs and vastly more useful.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Bounds | null>(null);
|
||||
const resolutionRef = useRef<number>(8);
|
||||
const [filters, setFilters] = useState<FeatureFilters>(initialFilters);
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [dragValue, setDragValue] = useState<[number, number] | 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]);
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HexagonData[]>([]);
|
||||
|
|
@ -59,6 +57,13 @@ export function useMapData({
|
|||
const [licenseRequired, setLicenseRequired] = useState(false);
|
||||
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 abortControllerRef = useRef<AbortController | null>(null);
|
||||
const prevBoundsRef = useRef<string>('');
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -70,10 +70,11 @@ export async function shortenUrl(params: string): Promise<string> {
|
|||
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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue