getting there

This commit is contained in:
Ruby 2026-02-22 23:20:12 +00:00
parent 974f005549
commit 5b5e140839
2 changed files with 284 additions and 255 deletions

View file

@ -134,8 +134,26 @@ export default function HomePage({
</div> </div>
</div> </div>
{/* Our philosophy */}
<div className="px-6 md:px-12 lg:px-20 pt-20 pb-4">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-6">
Our philosophy
</h2>
<div className="space-y-4 text-lg md:text-xl leading-relaxed text-warm-700 dark:text-warm-300">
<p>
Listings show what&apos;s available, not what&apos;s possible &mdash; fragments without context.
Traditional tools force you to begin with a location, separating area insight from property detail.
You search, cross-reference, and repeat per location.
</p>
<p>
We take a different approach. Start with what matters to you, and the right places reveal themselves.
No context lost. No property missed.
</p>
</div>
</div>
{/* How to use it + Comparison table (two columns) */} {/* 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 id="how-it-works" className="max-w-7xl mx-auto px-6 pt-10 pb-2">
<div ref={whyRef} className="fade-in-section"> <div ref={whyRef} className="fade-in-section">
<div className="grid lg:grid-cols-[2fr_3fr] gap-12 items-start"> <div className="grid lg:grid-cols-[2fr_3fr] gap-12 items-start">
{/* Left: How to use it */} {/* Left: How to use it */}
@ -163,8 +181,8 @@ export default function HomePage({
</div> </div>
{/* Right: Comparison table */} {/* Right: Comparison table */}
<div id="comparison"> <div id="comparison">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 flex items-center gap-3"> <h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
Others vs Perfect Postcode <LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" /> Others vs{' '}<span className="inline-flex items-baseline gap-3 whitespace-nowrap">Perfect Postcode <LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" /></span>
</h2> </h2>
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 shadow-sm"> <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"> <table className="w-full text-left">
@ -180,7 +198,7 @@ export default function HomePage({
<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"> <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 Area guides
</th> </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"> <th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-extrabold text-navy-950 dark:text-warm-100 text-center bg-teal-50 dark:bg-teal-900/30">
Perfect Postcode Perfect Postcode
</th> </th>
</tr> </tr>
@ -209,7 +227,7 @@ export default function HomePage({
{has ? '\u2713' : '\u2717'} {has ? '\u2713' : '\u2717'}
</td> </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 className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg text-green-500 bg-teal-50 dark:bg-teal-900/30">
&#x2713; &#x2713;
</td> </td>
</tr> </tr>
@ -223,26 +241,19 @@ export default function HomePage({
</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-16 mb-4"> <h2 id="demo" className="text-3xl font-bold text-navy-950 dark:text-warm-100 text-center pt-16 mb-8">
See It in Action See It in Action
</h2> </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} />
{/* The real cost CTA */} {/* The real cost CTA */}
<div className="max-w-3xl mx-auto px-6 pt-20 pb-12"> <div className="max-w-4xl 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 Make your biggest investment your smartest&nbsp;move.
<br />
deserves proper tools behind&nbsp;it.
</h2> </h2>
<p className="text-warm-600 dark:text-warm-400 mb-8 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">
Don&apos;t leave it to chance. This deserves proper tools behind it &mdash; don&apos;t leave it to luck.
</p> </p>
<button <button
onClick={onOpenDashboard} onClick={onOpenDashboard}

View file

@ -1,19 +1,14 @@
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import MapComponent from '../map/Map'; import MapComponent from '../map/Map';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api'; import { apiUrl, assertOk, authHeaders, isAbortError } 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 { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils'; 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: -0.12, latitude: 51.51, zoom: 5.5, pitch: 0 }; const DEMO_VIEW = { 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);
}
const DEMO_FEATURE_NAMES = [ const DEMO_FEATURE_NAMES = [
'Estimated current price', 'Estimated current price',
'Good+ primary schools within 5km', 'Good+ primary schools within 5km',
@ -25,42 +20,48 @@ const noop = () => { };
// 0 = feature.min, 1 = feature.max // 0 = feature.min, 1 = feature.max
interface StageDef { interface StageDef {
filters: Record<string, [number, number]>; filters: Record<string, [number, number]>;
colorFeature?: string; travel?: { mode: string; slug: string; min: number; max: number };
} }
const STAGES: StageDef[] = [ const STAGES: StageDef[] = [
// 0: No filters — the problem // 0: No filters — the problem
{ filters: {}, colorFeature: 'Estimated current price' }, { filters: {} },
// 1: Price filter — "affordable price" // 1: Price filter
{ {
filters: { 'Estimated current price': [0, 0.25] }, filters: { 'Estimated current price': [0, 0.4] },
colorFeature: 'Estimated current price',
}, },
// 2: Price + schools // 2: Price + schools
{ {
filters: { filters: {
'Estimated current price': [0, 0.25], 'Estimated current price': [0, 0.4],
'Good+ primary schools within 5km': [0.3, 1], 'Good+ primary schools within 5km': [0.3, 1],
}, },
colorFeature: 'Good+ primary schools within 5km',
}, },
// 3: All three // 3: Price + schools + restaurants
{ {
filters: { filters: {
'Estimated current price': [0, 0.25], 'Estimated current price': [0, 0.4],
'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',
}, },
// 4: Same filters — "that's just three" // 4: Price + schools + restaurants + commute to Manchester
{ {
filters: { filters: {
'Estimated current price': [0, 0.25], 'Estimated current price': [0, 0.4],
'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', travel: { mode: 'transit', slug: 'manchester', min: 0, max: 45 },
},
// 5: Summary — same filters
{
filters: {
'Estimated current price': [0, 0.4],
'Good+ primary schools within 5km': [0.3, 1],
'Number of restaurants within 2km': [0.15, 1],
},
travel: { mode: 'transit', slug: 'manchester', min: 0, max: 45 },
}, },
]; ];
@ -69,15 +70,15 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
heading: null, heading: null,
body: ( body: (
<> <>
<p className="text-lg leading-relaxed mb-4"> <p className="text-base md:text-lg leading-snug md:leading-relaxed mb-2 md:mb-4">
Let&apos;s look at an example: Let&apos;s look at an example:
</p> </p>
<p className="text-lg leading-relaxed mb-4"> <p className="text-base md:text-lg leading-snug md:leading-relaxed">
You&apos;re about to spend{' '} You&apos;re about to spend{' '}
<strong className="text-navy-950 dark:text-warm-100"> <strong className="text-navy-950 dark:text-warm-100">
&pound;300k&ndash;&pound;400k up to &pound;500k
</strong>{' '} </strong>{' '}
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. on a home.
</p> </p>
</> </>
), ),
@ -86,15 +87,15 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
heading: null, heading: null,
body: ( body: (
<> <>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-2 md:gap-3 mb-2 md: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"> <div className="shrink-0 w-7 h-7 md:w-8 md:h-8 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-xs md:text-sm">
1 1
</div> </div>
<h3 className="text-xl font-bold text-navy-950 dark:text-warm-100">Set your must-haves</h3> <h3 className="text-lg md:text-xl font-bold text-navy-950 dark:text-warm-100">Set your must-haves</h3>
</div> </div>
<p className="text-lg leading-relaxed"> <p className="text-base md:text-lg leading-snug md:leading-relaxed">
Say you want a home at an{' '} Say you want a home{' '}
<strong className="text-navy-950 dark:text-warm-100">affordable price</strong>&hellip; <strong className="text-navy-950 dark:text-warm-100">under &pound;500k</strong>&hellip;
</p> </p>
</> </>
), ),
@ -102,7 +103,7 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{ {
heading: null, heading: null,
body: ( body: (
<p className="text-lg leading-relaxed"> <p className="text-base md:text-lg leading-snug md:leading-relaxed">
&hellip;with{' '} &hellip;with{' '}
<strong className="text-navy-950 dark:text-warm-100">good primary schools</strong>{' '} <strong className="text-navy-950 dark:text-warm-100">good primary schools</strong>{' '}
nearby&hellip; nearby&hellip;
@ -112,12 +113,22 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{ {
heading: null, heading: null,
body: ( body: (
<p className="text-lg leading-relaxed"> <p className="text-base md:text-lg leading-snug md:leading-relaxed">
&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>
. &hellip;
</p>
),
},
{
heading: null,
body: (
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
&hellip;all within{' '}
<strong className="text-navy-950 dark:text-warm-100">45 minutes of Manchester</strong>{' '}
by public transport.
</p> </p>
), ),
}, },
@ -125,11 +136,11 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
heading: null, heading: null,
body: ( body: (
<> <>
<p className="text-lg leading-relaxed mb-4 font-semibold text-navy-950 dark:text-warm-100"> <p className="text-base md:text-lg leading-snug md:leading-relaxed mb-2 md: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. 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-base md:text-lg leading-snug md:leading-relaxed">
That&apos;s just 3 filters. We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">56</strong> &mdash; That&apos;s just 4 filters. We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">56</strong> &mdash;
covering commute times, crime, broadband, noise, schools, amenities, and more. covering commute times, crime, broadband, noise, schools, amenities, and more.
</p> </p>
</> </>
@ -146,12 +157,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const [stage, setStage] = useState(0); const [stage, setStage] = useState(0);
const [hexData, setHexData] = useState<HexagonData[]>([]); const [hexData, setHexData] = useState<HexagonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
const abortRef = useRef<AbortController>(); const abortRef = useRef<AbortController>();
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const sectionRef = useRef<HTMLElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);
const rafRef = useRef<number>(0);
const demoFeatures = useMemo( const demoFeatures = useMemo(
() => () =>
@ -175,70 +181,12 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
return result; return result;
}, [stage, demoFeatures]); }, [stage, demoFeatures]);
// IntersectionObserver for scroll stage detection const demoView = useMemo(() => DEMO_VIEW, []);
useEffect(() => {
const observers: IntersectionObserver[] = [];
stepRefs.current.forEach((el, i) => {
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setStage(i);
},
{ rootMargin: '-35% 0px -35% 0px', threshold: 0 }
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, [demoFeatures.length]);
// Track scroll progress through the section for zoom interpolation
useEffect(() => {
const section = sectionRef.current;
if (!section) return;
let scrollParent: HTMLElement | null = section.parentElement;
while (scrollParent) {
const { overflow, overflowY } = getComputedStyle(scrollParent);
if (['auto', 'scroll'].includes(overflow) || ['auto', 'scroll'].includes(overflowY)) break;
scrollParent = scrollParent.parentElement;
}
if (!scrollParent) return;
const handleScroll = () => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const rect = section.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const totalTravel = rect.height - viewportHeight;
if (totalTravel <= 0) return;
const scrolled = -rect.top;
const progress = Math.max(0, Math.min(1, scrolled / totalTravel));
setScrollProgress(progress);
});
};
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
return () => {
scrollParent.removeEventListener('scroll', handleScroll);
cancelAnimationFrame(rafRef.current);
};
}, []);
const demoView = useMemo(() => {
const t = easeOutCubic(scrollProgress);
return {
longitude: DEMO_VIEW_START.longitude + (DEMO_VIEW_END.longitude - DEMO_VIEW_START.longitude) * t,
latitude: DEMO_VIEW_START.latitude + (DEMO_VIEW_END.latitude - DEMO_VIEW_START.latitude) * t,
zoom: DEMO_VIEW_START.zoom + (DEMO_VIEW_END.zoom - DEMO_VIEW_START.zoom) * t,
pitch: 0,
};
}, [scrollProgress]);
// Derive H3 resolution from current zoom (discrete — only changes at thresholds) // Derive H3 resolution from current zoom (discrete — only changes at thresholds)
const resolution = zoomToResolution(demoView.zoom); const resolution = zoomToResolution(demoView.zoom);
// Compute bounds string from current view, rounded to 0.5° to avoid refetching on every scroll tick // Compute bounds string from current view, rounded to 0.5° for stability
const demoBounds = useMemo(() => { const demoBounds = useMemo(() => {
const { longitude, latitude, zoom } = demoView; const { longitude, latitude, zoom } = demoView;
const scale = Math.pow(2, zoom); const scale = Math.pow(2, zoom);
@ -262,9 +210,14 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
return `${south},${west},${north},${east}`; return `${south},${west},${north},${east}`;
}, [demoView]); }, [demoView]);
// Fetch hex data when resolution, filters, or bounds change // Fetch hex data when stage filters change
useEffect(() => { useEffect(() => {
if (features.length === 0) return; if (features.length === 0) return;
// Clear stale data and show loading spinner immediately
setLoading(true);
setHexData([]);
const params = new URLSearchParams({ const params = new URLSearchParams({
resolution: String(resolution), resolution: String(resolution),
bounds: demoBounds, bounds: demoBounds,
@ -274,164 +227,229 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
filterParts.push(`${name}:${min}:${max}`); filterParts.push(`${name}:${min}:${max}`);
} }
if (filterParts.length > 0) params.set('filters', filterParts.join(',')); if (filterParts.length > 0) params.set('filters', filterParts.join(','));
const stageDef = STAGES[stage];
if (stageDef.colorFeature) params.set('fields', stageDef.colorFeature);
clearTimeout(fetchTimeoutRef.current); const stageDef = STAGES[stage];
fetchTimeoutRef.current = setTimeout(() => { if (stageDef.travel) {
abortRef.current?.abort(); const { mode, slug, min, max } = stageDef.travel;
abortRef.current = new AbortController(); params.set('travel', `${mode}:${slug}:${min}:${max}`);
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal })) }
.then((res) => {
assertOk(res, 'hexagons'); const controller = new AbortController();
return res.json(); abortRef.current?.abort();
}) abortRef.current = controller;
.then((data: { features: HexagonData[] }) => { fetch(apiUrl('hexagons', params), authHeaders({ signal: controller.signal }))
setHexData(data.features); .then((res) => {
assertOk(res, 'hexagons');
return res.json();
})
.then((data: { features: HexagonData[] }) => {
setHexData(data.features);
setLoading(false);
})
.catch((err) => {
if (!isAbortError(err)) {
console.error('Failed to fetch story hexagons:', err);
setLoading(false); setLoading(false);
}) }
.catch((err) => logNonAbortError('Failed to fetch story hexagons', err)); });
}, 300);
return () => clearTimeout(fetchTimeoutRef.current); return () => controller.abort();
}, [features, stageFilters, stage, resolution, demoBounds]); }, [features, stageFilters, stage, resolution, demoBounds]);
useEffect(() => { const isLastStage = stage === STEPS.length - 1;
return () => {
abortRef.current?.abort();
clearTimeout(fetchTimeoutRef.current);
};
}, []);
const stageDef = STAGES[stage];
const viewFeatureName = stageDef.colorFeature || null;
const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null;
const colorRange: [number, number] | null =
viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
// Defer hex data so scroll zoom stays smooth while layer rebuilds happen in the background
const deferredHexData = useDeferredValue(hexData);
return ( return (
<section ref={sectionRef} className="snap-start relative"> <section className="relative h-[calc(100dvh-3rem)]">
{/* Sticky map background */} {/* Map background */}
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0"> <div className="absolute inset-0 z-0">
<div className="absolute inset-0"> <MapComponent
<MapComponent data={stage === 0 ? [] : hexData}
data={deferredHexData} postcodeData={[]}
postcodeData={[]} usePostcodeView={false}
usePostcodeView={false} pois={[]}
pois={[]} onViewChange={noop}
onViewChange={noop} viewFeature={null}
viewFeature={viewFeatureName} colorRange={null}
colorRange={colorRange} filterRange={null}
filterRange={null} viewSource={null}
viewSource={viewFeatureName ? 'drag' : null} onCancelPin={noop}
onCancelPin={noop} features={features}
features={features} selectedHexagonId={null}
selectedHexagonId={null} hoveredHexagonId={null}
hoveredHexagonId={null} onHexagonClick={noop}
onHexagonClick={noop} onHexagonHover={noop}
onHexagonHover={noop} initialViewState={demoView}
initialViewState={demoView} theme={theme}
theme={theme} screenshotMode={true}
screenshotMode={true} hideLegend={true}
hideLegend={true} />
/> </div>
{/* Interaction blocker */}
<div className="absolute inset-0 z-30" />
{/* Loading */}
{loading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<SpinnerIcon className="w-10 h-10 text-teal-600 dark:text-teal-400 animate-spin" />
</div> </div>
)}
{/* Interaction blocker */} {/* Filter indicators — top left */}
<div className="absolute inset-0 z-30" /> <div className="absolute top-3 left-3 z-40 pointer-events-none w-[200px] md:w-[340px]">
<div className="bg-white/85 dark:bg-warm-800/85 rounded-xl p-3 md:p-6 backdrop-blur-sm shadow-lg space-y-2.5 md:space-y-5 w-full">
{/* Loading */} <div className="text-[10px] md:text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400">
{loading && ( Filters
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<SpinnerIcon className="w-10 h-10 text-teal-600 dark:text-teal-400 animate-spin" />
</div> </div>
)} {demoFeatures.map((feature) => {
const filterVal = stageFilters[feature.name];
{/* Filter indicators — left sidebar */} const isActive = !!filterVal;
<div className="absolute top-0 left-0 bottom-0 z-40 pointer-events-none w-[280px] md:w-[340px] flex items-center"> const min = feature.min ?? 0;
<div className="bg-white/85 dark:bg-warm-800/85 rounded-r-xl p-5 md:p-6 backdrop-blur-sm shadow-lg space-y-5 w-full"> const max = feature.max ?? 1;
<div className="text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400 mb-1"> const range = max - min || 1;
Filters const leftPct = filterVal ? ((filterVal[0] - min) / range) * 100 : 0;
</div> const widthPct = filterVal ? ((filterVal[1] - filterVal[0]) / range) * 100 : 100;
{demoFeatures.map((feature) => { return (
const filterVal = stageFilters[feature.name]; <div
const isActive = !!filterVal; key={feature.name}
const min = feature.min ?? 0; className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`}
const max = feature.max ?? 1; >
const range = max - min || 1; <div className="flex justify-between items-baseline text-xs md:text-sm mb-1 md:mb-1.5 gap-1.5 md:gap-2">
const leftPct = filterVal ? ((filterVal[0] - min) / range) * 100 : 0; <span
const widthPct = filterVal ? ((filterVal[1] - filterVal[0]) / range) * 100 : 100; className={`font-medium truncate ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
return ( >
<div {feature.name}
key={feature.name} </span>
className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`} {isActive && filterVal && (
> <span className="text-teal-600 dark:text-teal-400 font-semibold whitespace-nowrap">
<div className="flex justify-between items-baseline text-sm mb-1.5 gap-2"> {formatValue(filterVal[0], feature)}&ndash;
<span {formatValue(filterVal[1], feature)}
className={`font-medium truncate ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
>
{feature.name}
</span> </span>
{isActive && filterVal && ( )}
<span className="text-teal-600 dark:text-teal-400 font-semibold whitespace-nowrap">
{formatValue(filterVal[0], feature)}&ndash;
{formatValue(filterVal[1], feature)}
</span>
)}
</div>
<div className="relative h-2.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
<div
className="absolute h-full bg-teal-500 dark:bg-teal-400 rounded-full transition-all duration-700 ease-out"
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
/>
</div>
</div> </div>
); <div className="relative h-1.5 md:h-2.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
})} <div
className="absolute h-full bg-teal-500 dark:bg-teal-400 rounded-full transition-all duration-700 ease-out"
{/* Color legend */} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
{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>
)} );
})}
{/* Travel time indicator */}
<div className={`transition-opacity duration-700 ${STAGES[stage].travel ? 'opacity-100' : 'opacity-30'}`}>
<div className="flex justify-between items-baseline text-xs md:text-sm mb-1 md:mb-1.5 gap-1.5 md:gap-2">
<span className={`font-medium truncate ${STAGES[stage].travel ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}>
Commute to Manchester
</span>
{STAGES[stage].travel && (
<span className="text-teal-600 dark:text-teal-400 font-semibold whitespace-nowrap">
0&ndash;45 min
</span>
)}
</div>
<div className="relative h-1.5 md:h-2.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
<div
className="absolute h-full bg-teal-500 dark:bg-teal-400 rounded-full transition-all duration-700 ease-out"
style={{ left: '0%', width: STAGES[stage].travel ? '45%' : '100%' }}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Scrolling text overlay */} {/* Density legend — top right */}
<div className="relative z-10 -mt-[60vh] md:-mt-[calc(100dvh-3rem)] pointer-events-none"> <div className="absolute top-3 right-3 z-40 pointer-events-none w-[180px] md:w-[220px]">
<div className="mx-4 md:ml-auto md:mr-[4%] md:max-w-md"> <div className="bg-white/85 dark:bg-warm-800/85 rounded-xl p-3 md:p-4 backdrop-blur-sm shadow-lg">
<div className="h-[35vh] md:h-[45vh]" /> <div className="text-[10px] md:text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400 mb-2">
Colour
</div>
<div className="text-xs md:text-sm font-medium text-navy-950 dark:text-warm-100 mb-1.5">
Number of properties
</div>
<div
className="h-1.5 md:h-2.5 rounded-full overflow-hidden"
style={{ background: gradientToCss(theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT) }}
/>
<div className="flex justify-between mt-1 text-[10px] md:text-xs text-warm-500 dark:text-warm-400">
<span>Fewer</span>
<span>More</span>
</div>
</div>
</div>
{/* Card stack overlay — bottom on mobile, right-center on desktop */}
<div className="absolute left-0 right-0 bottom-4 md:top-0 md:bottom-0 md:left-auto z-40 flex items-end md:items-center pointer-events-none mx-4 md:mr-[4%] md:ml-auto md:max-w-md md:w-full">
<div className="grid grid-cols-1 grid-rows-1 items-end w-full pointer-events-auto">
{STEPS.map((step, i) => ( {STEPS.map((step, i) => (
<div <div
key={i} key={i}
ref={(el) => { className={`col-start-1 row-start-1 transition-all duration-500 ease-out ${
stepRefs.current[i] = el; i === stage
}} ? 'opacity-100 translate-y-0'
className="pointer-events-auto mb-[30vh] md:mb-[40vh] bg-white/90 dark:bg-warm-800/90 rounded-xl p-5 md:p-6 backdrop-blur-sm shadow-lg border border-warm-200/40 dark:border-warm-700/40" : i < stage
? 'opacity-0 -translate-y-4 pointer-events-none'
: 'opacity-0 translate-y-4 pointer-events-none'
}`}
> >
{step.heading && ( <div className="bg-white/90 dark:bg-warm-800/90 rounded-xl p-4 md:p-6 backdrop-blur-sm shadow-lg border border-warm-200/40 dark:border-warm-700/40">
<h3 className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-3 leading-snug"> {step.heading && (
{step.heading} <h3 className="text-lg md:text-xl font-bold text-navy-950 dark:text-warm-100 mb-2 md:mb-3 leading-snug">
</h3> {step.heading}
)} </h3>
<div className="text-warm-700 dark:text-warm-300">{step.body}</div> )}
<div className="text-warm-700 dark:text-warm-300">{step.body}</div>
{/* Navigation */}
<div className="flex items-center justify-between mt-3 md:mt-5">
{/* Step dots */}
<div className="flex gap-1.5">
{STEPS.map((_, dotIdx) => (
<button
key={dotIdx}
onClick={() => setStage(dotIdx)}
className={`w-2 h-2 rounded-full transition-all duration-300 ${
dotIdx === stage
? 'bg-teal-600 dark:bg-teal-400 w-4'
: dotIdx < stage
? 'bg-teal-600/40 dark:bg-teal-400/40'
: 'bg-warm-300 dark:bg-warm-600'
}`}
aria-label={`Go to step ${dotIdx + 1}`}
/>
))}
</div>
{/* Prev / Next / CTA buttons */}
<div className="flex items-center gap-2">
{stage > 0 && (
<button
onClick={() => setStage(stage - 1)}
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg border border-warm-200 dark:border-warm-700 text-warm-600 dark:text-warm-300 font-semibold text-sm hover:bg-warm-50 dark:hover:bg-warm-800 transition-colors"
>
&larr; Back
</button>
)}
{isLastStage ? (
<a
href="/dashboard"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-teal-600 text-white font-semibold text-sm hover:bg-teal-700 transition-colors"
>
Start exploring &rarr;
</a>
) : (
<button
onClick={() => setStage(stage + 1)}
className="inline-flex items-center gap-1 px-4 py-2 rounded-lg bg-teal-600 text-white font-semibold text-sm hover:bg-teal-700 transition-colors"
>
Next &rarr;
</button>
)}
</div>
</div>
</div>
</div> </div>
))} ))}
<div className="h-[30vh] md:h-[40vh]" />
</div> </div>
</div> </div>
</section> </section>