getting there
This commit is contained in:
parent
974f005549
commit
5b5e140839
2 changed files with 284 additions and 255 deletions
|
|
@ -134,8 +134,26 @@ export default function HomePage({
|
|||
</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's available, not what's possible — 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) */}
|
||||
<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 className="grid lg:grid-cols-[2fr_3fr] gap-12 items-start">
|
||||
{/* Left: How to use it */}
|
||||
|
|
@ -163,8 +181,8 @@ export default function HomePage({
|
|||
</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 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
|
||||
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>
|
||||
<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">
|
||||
|
|
@ -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">
|
||||
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">
|
||||
<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
|
||||
</th>
|
||||
</tr>
|
||||
|
|
@ -209,7 +227,7 @@ export default function HomePage({
|
|||
{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 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">
|
||||
✓
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -223,26 +241,19 @@ export default function HomePage({
|
|||
</div>
|
||||
|
||||
{/* 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
|
||||
</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} />
|
||||
|
||||
{/* 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">
|
||||
<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.
|
||||
Make your biggest investment your smartest move.
|
||||
</h2>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-8 max-w-xl mx-auto leading-relaxed">
|
||||
Don't leave it to chance.
|
||||
This deserves proper tools behind it — don't leave it to luck.
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
|
|
|
|||
|
|
@ -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 { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { apiUrl, assertOk, authHeaders, isAbortError } from '../../lib/api';
|
||||
import { formatValue } from '../../lib/format';
|
||||
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 { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { FeatureMeta, HexagonData } from '../../types';
|
||||
|
||||
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);
|
||||
}
|
||||
const DEMO_VIEW = { longitude: -0.12, latitude: 51.51, zoom: 5.5, pitch: 0 };
|
||||
const DEMO_FEATURE_NAMES = [
|
||||
'Estimated current price',
|
||||
'Good+ primary schools within 5km',
|
||||
|
|
@ -25,42 +20,48 @@ const noop = () => { };
|
|||
// 0 = feature.min, 1 = feature.max
|
||||
interface StageDef {
|
||||
filters: Record<string, [number, number]>;
|
||||
colorFeature?: string;
|
||||
travel?: { mode: string; slug: string; min: number; max: number };
|
||||
}
|
||||
|
||||
const STAGES: StageDef[] = [
|
||||
// 0: No filters — the problem
|
||||
{ filters: {}, colorFeature: 'Estimated current price' },
|
||||
// 1: Price filter — "affordable price"
|
||||
{ filters: {} },
|
||||
// 1: Price filter
|
||||
{
|
||||
filters: { 'Estimated current price': [0, 0.25] },
|
||||
colorFeature: 'Estimated current price',
|
||||
filters: { 'Estimated current price': [0, 0.4] },
|
||||
},
|
||||
// 2: Price + schools
|
||||
{
|
||||
filters: {
|
||||
'Estimated current price': [0, 0.25],
|
||||
'Estimated current price': [0, 0.4],
|
||||
'Good+ primary schools within 5km': [0.3, 1],
|
||||
},
|
||||
colorFeature: 'Good+ primary schools within 5km',
|
||||
},
|
||||
// 3: All three
|
||||
// 3: Price + schools + restaurants
|
||||
{
|
||||
filters: {
|
||||
'Estimated current price': [0, 0.25],
|
||||
'Estimated current price': [0, 0.4],
|
||||
'Good+ primary schools within 5km': [0.3, 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: {
|
||||
'Estimated current price': [0, 0.25],
|
||||
'Estimated current price': [0, 0.4],
|
||||
'Good+ primary schools within 5km': [0.3, 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,
|
||||
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's look at an example:
|
||||
</p>
|
||||
<p className="text-lg leading-relaxed mb-4">
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
You're about to spend{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">
|
||||
£300k–£400k
|
||||
up to £500k
|
||||
</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>
|
||||
</>
|
||||
),
|
||||
|
|
@ -86,15 +87,15 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|||
heading: null,
|
||||
body: (
|
||||
<>
|
||||
<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">
|
||||
<div className="flex items-center gap-2 md:gap-3 mb-2 md:mb-3">
|
||||
<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
|
||||
</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>
|
||||
<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 className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
Say you want a home{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">under £500k</strong>…
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
|
|
@ -102,7 +103,7 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|||
{
|
||||
heading: null,
|
||||
body: (
|
||||
<p className="text-lg leading-relaxed">
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
…with{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">good primary schools</strong>{' '}
|
||||
nearby…
|
||||
|
|
@ -112,12 +113,22 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|||
{
|
||||
heading: null,
|
||||
body: (
|
||||
<p className="text-lg leading-relaxed">
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
…and{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">
|
||||
restaurants within walking distance
|
||||
</strong>
|
||||
.
|
||||
…
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
heading: null,
|
||||
body: (
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
…all within{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">45 minutes of Manchester</strong>{' '}
|
||||
by public transport.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
|
|
@ -125,11 +136,11 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|||
heading: null,
|
||||
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.
|
||||
</p>
|
||||
<p className="text-lg leading-relaxed">
|
||||
That's just 3 filters. We've built <strong className="text-navy-950 dark:text-warm-100">56</strong> —
|
||||
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
|
||||
That's just 4 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>
|
||||
</>
|
||||
|
|
@ -146,12 +157,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
const [stage, setStage] = useState(0);
|
||||
const [hexData, setHexData] = useState<HexagonData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
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(
|
||||
() =>
|
||||
|
|
@ -175,70 +181,12 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
return result;
|
||||
}, [stage, demoFeatures]);
|
||||
|
||||
// IntersectionObserver for scroll stage detection
|
||||
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]);
|
||||
const demoView = useMemo(() => DEMO_VIEW, []);
|
||||
|
||||
// Derive H3 resolution from current zoom (discrete — only changes at thresholds)
|
||||
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 { longitude, latitude, zoom } = demoView;
|
||||
const scale = Math.pow(2, zoom);
|
||||
|
|
@ -262,9 +210,14 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
return `${south},${west},${north},${east}`;
|
||||
}, [demoView]);
|
||||
|
||||
// Fetch hex data when resolution, filters, or bounds change
|
||||
// Fetch hex data when stage filters change
|
||||
useEffect(() => {
|
||||
if (features.length === 0) return;
|
||||
|
||||
// Clear stale data and show loading spinner immediately
|
||||
setLoading(true);
|
||||
setHexData([]);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
resolution: String(resolution),
|
||||
bounds: demoBounds,
|
||||
|
|
@ -274,14 +227,17 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
filterParts.push(`${name}:${min}:${max}`);
|
||||
}
|
||||
if (filterParts.length > 0) params.set('filters', filterParts.join(','));
|
||||
const stageDef = STAGES[stage];
|
||||
if (stageDef.colorFeature) params.set('fields', stageDef.colorFeature);
|
||||
|
||||
clearTimeout(fetchTimeoutRef.current);
|
||||
fetchTimeoutRef.current = setTimeout(() => {
|
||||
const stageDef = STAGES[stage];
|
||||
if (stageDef.travel) {
|
||||
const { mode, slug, min, max } = stageDef.travel;
|
||||
params.set('travel', `${mode}:${slug}:${min}:${max}`);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
|
||||
abortRef.current = controller;
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: controller.signal }))
|
||||
.then((res) => {
|
||||
assertOk(res, 'hexagons');
|
||||
return res.json();
|
||||
|
|
@ -290,42 +246,32 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
setHexData(data.features);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => logNonAbortError('Failed to fetch story hexagons', err));
|
||||
}, 300);
|
||||
return () => clearTimeout(fetchTimeoutRef.current);
|
||||
.catch((err) => {
|
||||
if (!isAbortError(err)) {
|
||||
console.error('Failed to fetch story hexagons:', err);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [features, stageFilters, stage, resolution, demoBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
const isLastStage = stage === STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<section className="relative h-[calc(100dvh-3rem)]">
|
||||
{/* Map background */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<MapComponent
|
||||
data={deferredHexData}
|
||||
data={stage === 0 ? [] : hexData}
|
||||
postcodeData={[]}
|
||||
usePostcodeView={false}
|
||||
pois={[]}
|
||||
onViewChange={noop}
|
||||
viewFeature={viewFeatureName}
|
||||
colorRange={colorRange}
|
||||
viewFeature={null}
|
||||
colorRange={null}
|
||||
filterRange={null}
|
||||
viewSource={viewFeatureName ? 'drag' : null}
|
||||
viewSource={null}
|
||||
onCancelPin={noop}
|
||||
features={features}
|
||||
selectedHexagonId={null}
|
||||
|
|
@ -349,10 +295,10 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter indicators — left sidebar */}
|
||||
<div className="absolute top-0 left-0 bottom-0 z-40 pointer-events-none w-[280px] md:w-[340px] flex items-center">
|
||||
<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">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400 mb-1">
|
||||
{/* Filter indicators — top left */}
|
||||
<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">
|
||||
<div className="text-[10px] md:text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400">
|
||||
Filters
|
||||
</div>
|
||||
{demoFeatures.map((feature) => {
|
||||
|
|
@ -368,7 +314,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
key={feature.name}
|
||||
className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`}
|
||||
>
|
||||
<div className="flex justify-between items-baseline text-sm mb-1.5 gap-2">
|
||||
<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 ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
|
||||
>
|
||||
|
|
@ -381,7 +327,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-2.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
|
||||
<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: `${leftPct}%`, width: `${widthPct}%` }}
|
||||
|
|
@ -390,48 +336,120 @@ 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>
|
||||
{/* 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–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>
|
||||
|
||||
{/* Scrolling text overlay */}
|
||||
<div className="relative z-10 -mt-[60vh] md:-mt-[calc(100dvh-3rem)] pointer-events-none">
|
||||
<div className="mx-4 md:ml-auto md:mr-[4%] md:max-w-md">
|
||||
<div className="h-[35vh] md:h-[45vh]" />
|
||||
{/* Density legend — top right */}
|
||||
<div className="absolute top-3 right-3 z-40 pointer-events-none w-[180px] md:w-[220px]">
|
||||
<div className="bg-white/85 dark:bg-warm-800/85 rounded-xl p-3 md:p-4 backdrop-blur-sm shadow-lg">
|
||||
<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) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
stepRefs.current[i] = el;
|
||||
}}
|
||||
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"
|
||||
className={`col-start-1 row-start-1 transition-all duration-500 ease-out ${
|
||||
i === stage
|
||||
? 'opacity-100 translate-y-0'
|
||||
: i < stage
|
||||
? 'opacity-0 -translate-y-4 pointer-events-none'
|
||||
: 'opacity-0 translate-y-4 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
{step.heading && (
|
||||
<h3 className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-3 leading-snug">
|
||||
<h3 className="text-lg md:text-xl font-bold text-navy-950 dark:text-warm-100 mb-2 md:mb-3 leading-snug">
|
||||
{step.heading}
|
||||
</h3>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
← 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 →
|
||||
</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 →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="h-[30vh] md:h-[40vh]" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue