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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue