No scroll story
This commit is contained in:
parent
852bb3f3a7
commit
e09aa574b0
3 changed files with 1 additions and 511 deletions
|
|
@ -305,7 +305,6 @@ export default function App() {
|
||||||
onOpenDashboard={() => navigateTo('dashboard')}
|
onOpenDashboard={() => navigateTo('dashboard')}
|
||||||
onOpenPricing={() => navigateTo('pricing')}
|
onOpenPricing={() => navigateTo('pricing')}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
features={features}
|
|
||||||
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
|
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
|
||||||
/>
|
/>
|
||||||
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
|
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,20 @@
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useFadeInRef } from '../../hooks/useFadeIn';
|
import { useFadeInRef } from '../../hooks/useFadeIn';
|
||||||
import HexCanvas from './HexCanvas';
|
import HexCanvas from './HexCanvas';
|
||||||
import ScrollStory from './ScrollStory';
|
|
||||||
import BottomIllustration from './BottomIllustration';
|
import BottomIllustration from './BottomIllustration';
|
||||||
import { TickerValue } from '../ui/TickerValue';
|
import { TickerValue } from '../ui/TickerValue';
|
||||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
|
||||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import type { FeatureMeta } from '../../types';
|
|
||||||
|
|
||||||
export default function HomePage({
|
export default function HomePage({
|
||||||
onOpenDashboard,
|
onOpenDashboard,
|
||||||
onOpenPricing: _onOpenPricing,
|
onOpenPricing: _onOpenPricing,
|
||||||
theme = 'light',
|
theme = 'light',
|
||||||
features = [],
|
|
||||||
hidePricing: _hidePricing,
|
hidePricing: _hidePricing,
|
||||||
}: {
|
}: {
|
||||||
onOpenDashboard: () => void;
|
onOpenDashboard: () => void;
|
||||||
onOpenPricing: () => void;
|
onOpenPricing: () => void;
|
||||||
theme?: 'light' | 'dark';
|
theme?: 'light' | 'dark';
|
||||||
features?: FeatureMeta[];
|
|
||||||
hidePricing?: boolean;
|
hidePricing?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [statsActive, setStatsActive] = useState(false);
|
const [statsActive, setStatsActive] = useState(false);
|
||||||
|
|
@ -34,7 +29,7 @@ export default function HomePage({
|
||||||
// Scroll depth tracking
|
// Scroll depth tracking
|
||||||
const scrolledSections = useRef(new Set<string>());
|
const scrolledSections = useRef(new Set<string>());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ids = ['how-it-works', 'demo'];
|
const ids = ['how-it-works'];
|
||||||
const observers: IntersectionObserver[] = [];
|
const observers: IntersectionObserver[] = [];
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
|
|
@ -142,35 +137,6 @@ export default function HomePage({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
trackEvent('CTA Click', { location: 'hero', label: 'see_it_in_action' });
|
|
||||||
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;
|
|
||||||
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">
|
|
||||||
See It in Action
|
|
||||||
</p>
|
|
||||||
<ChevronIcon direction="down" className="w-6 h-6 text-warm-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -286,15 +252,6 @@ export default function HomePage({
|
||||||
</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-16 mb-8"
|
|
||||||
>
|
|
||||||
See It in Action
|
|
||||||
</h2>
|
|
||||||
<ScrollStory features={features} theme={theme} />
|
|
||||||
|
|
||||||
{/* The real cost CTA */}
|
{/* The real cost CTA */}
|
||||||
<div className="max-w-4xl 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">
|
||||||
|
|
|
||||||
|
|
@ -1,466 +0,0 @@
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
||||||
import MapComponent from '../map/Map';
|
|
||||||
import { apiUrl, assertOk, authHeaders, isAbortError, logNonAbortError } from '../../lib/api';
|
|
||||||
import { formatValue } from '../../lib/format';
|
|
||||||
import { zoomToResolution } from '../../lib/map-utils';
|
|
||||||
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 = { longitude: -0.12, latitude: 51.51, zoom: 5.5, pitch: 0 };
|
|
||||||
const DEMO_FEATURE_NAMES = [
|
|
||||||
'Estimated current price',
|
|
||||||
'Good+ primary schools within 5km',
|
|
||||||
'Number of restaurants within 2km',
|
|
||||||
];
|
|
||||||
const noop = () => {};
|
|
||||||
|
|
||||||
// Filter fractions per stage: featureName -> [minFrac, maxFrac]
|
|
||||||
// 0 = feature.min, 1 = feature.max
|
|
||||||
interface StageDef {
|
|
||||||
filters: Record<string, [number, number]>;
|
|
||||||
travel?: { mode: string; slug: string; min: number; max: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
const STAGES: StageDef[] = [
|
|
||||||
// 0: No filters — the problem
|
|
||||||
{ filters: {} },
|
|
||||||
// 1: Price filter
|
|
||||||
{
|
|
||||||
filters: { 'Estimated current price': [0, 0.4] },
|
|
||||||
},
|
|
||||||
// 2: Price + schools
|
|
||||||
{
|
|
||||||
filters: {
|
|
||||||
'Estimated current price': [0, 0.4],
|
|
||||||
'Good+ primary schools within 5km': [0.3, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 3: Price + schools + restaurants
|
|
||||||
{
|
|
||||||
filters: {
|
|
||||||
'Estimated current price': [0, 0.4],
|
|
||||||
'Good+ primary schools within 5km': [0.3, 1],
|
|
||||||
'Number of restaurants within 2km': [0.15, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 4: Price + schools + restaurants + commute to Manchester
|
|
||||||
{
|
|
||||||
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 },
|
|
||||||
},
|
|
||||||
// 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 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const STEPS: { heading: string | null; body: React.ReactNode }[] = [
|
|
||||||
{
|
|
||||||
heading: null,
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<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-base md:text-lg leading-snug md:leading-relaxed">
|
|
||||||
You're about to spend{' '}
|
|
||||||
<strong className="text-navy-950 dark:text-warm-100">up to £500k</strong> on a home.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: null,
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<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-lg md:text-xl font-bold text-navy-950 dark:text-warm-100">
|
|
||||||
Set your must-haves
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: null,
|
|
||||||
body: (
|
|
||||||
<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…
|
|
||||||
</p>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: null,
|
|
||||||
body: (
|
|
||||||
<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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: null,
|
|
||||||
body: (
|
|
||||||
<>
|
|
||||||
<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-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>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ScrollStoryProps {
|
|
||||||
features: FeatureMeta[];
|
|
||||||
theme: 'light' | 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|
||||||
const [stage, setStage] = useState(0);
|
|
||||||
const [hexData, setHexData] = useState<HexagonData[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const abortRef = useRef<AbortController>();
|
|
||||||
|
|
||||||
const demoFeatures = useMemo(
|
|
||||||
() =>
|
|
||||||
DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter(
|
|
||||||
Boolean
|
|
||||||
) as FeatureMeta[],
|
|
||||||
[features]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Compute actual filter values from stage fractions + feature metadata
|
|
||||||
const stageFilters = useMemo(() => {
|
|
||||||
const stageDef = STAGES[stage];
|
|
||||||
const result: Record<string, [number, number]> = {};
|
|
||||||
for (const [name, [minFrac, maxFrac]] of Object.entries(stageDef.filters)) {
|
|
||||||
const meta = demoFeatures.find((f) => f.name === name);
|
|
||||||
if (meta?.min != null && meta?.max != null) {
|
|
||||||
const range = meta.max - meta.min;
|
|
||||||
result[name] = [meta.min + range * minFrac, meta.min + range * maxFrac];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [stage, demoFeatures]);
|
|
||||||
|
|
||||||
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° for stability
|
|
||||||
const demoBounds = useMemo(() => {
|
|
||||||
const { longitude, latitude, zoom } = demoView;
|
|
||||||
const scale = Math.pow(2, zoom);
|
|
||||||
const degreesPerPixelLng = 360 / (512 * scale);
|
|
||||||
const halfW = (1200 / 2) * degreesPerPixelLng * 1.3;
|
|
||||||
const latRad = (latitude * Math.PI) / 180;
|
|
||||||
const mercY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
|
||||||
const worldSize = 512 * scale;
|
|
||||||
const halfH = (800 / 2) * 1.3;
|
|
||||||
const topY = mercY * worldSize - halfH;
|
|
||||||
const botY = mercY * worldSize + halfH;
|
|
||||||
const toLat = (py: number) => {
|
|
||||||
const my = Math.max(0.001, Math.min(0.999, py / worldSize));
|
|
||||||
return (Math.atan(Math.sinh(Math.PI * (1 - 2 * my))) * 180) / Math.PI;
|
|
||||||
};
|
|
||||||
const snap = (v: number) => Math.round(v * 2) / 2;
|
|
||||||
const south = snap(Math.max(-85, toLat(botY)));
|
|
||||||
const west = snap(Math.max(-180, longitude - halfW));
|
|
||||||
const north = snap(Math.min(85, toLat(topY)));
|
|
||||||
const east = snap(Math.min(180, longitude + halfW));
|
|
||||||
return `${south},${west},${north},${east}`;
|
|
||||||
}, [demoView]);
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
const filterParts: string[] = [];
|
|
||||||
for (const [name, [min, max]] of Object.entries(stageFilters)) {
|
|
||||||
filterParts.push(`${name}:${min}:${max}`);
|
|
||||||
}
|
|
||||||
if (filterParts.length > 0) params.set('filters', filterParts.join(','));
|
|
||||||
|
|
||||||
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 = controller;
|
|
||||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: controller.signal }))
|
|
||||||
.then((res) => {
|
|
||||||
assertOk(res, 'hexagons');
|
|
||||||
return res.json();
|
|
||||||
})
|
|
||||||
.then((data: { features: HexagonData[] }) => {
|
|
||||||
setHexData(data.features);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!isAbortError(err)) {
|
|
||||||
logNonAbortError('Failed to fetch story hexagons', err);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [features, stageFilters, stage, resolution, demoBounds]);
|
|
||||||
|
|
||||||
const isLastStage = stage === STEPS.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative h-[calc(100dvh-3rem)]">
|
|
||||||
{/* Map background */}
|
|
||||||
<div className="absolute inset-0 z-0">
|
|
||||||
<MapComponent
|
|
||||||
data={stage === 0 ? [] : hexData}
|
|
||||||
postcodeData={[]}
|
|
||||||
usePostcodeView={false}
|
|
||||||
pois={[]}
|
|
||||||
onViewChange={noop}
|
|
||||||
viewFeature={null}
|
|
||||||
colorRange={null}
|
|
||||||
filterRange={null}
|
|
||||||
viewSource={null}
|
|
||||||
onCancelPin={noop}
|
|
||||||
features={features}
|
|
||||||
selectedHexagonId={null}
|
|
||||||
hoveredHexagonId={null}
|
|
||||||
onHexagonClick={noop}
|
|
||||||
onHexagonHover={noop}
|
|
||||||
initialViewState={demoView}
|
|
||||||
theme={theme}
|
|
||||||
screenshotMode={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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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) => {
|
|
||||||
const filterVal = stageFilters[feature.name];
|
|
||||||
const isActive = !!filterVal;
|
|
||||||
const min = feature.min ?? 0;
|
|
||||||
const max = feature.max ?? 1;
|
|
||||||
const range = max - min || 1;
|
|
||||||
const leftPct = filterVal ? ((filterVal[0] - min) / range) * 100 : 0;
|
|
||||||
const widthPct = filterVal ? ((filterVal[1] - filterVal[0]) / range) * 100 : 100;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={feature.name}
|
|
||||||
className={`transition-opacity duration-700 ${isActive ? '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 ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
|
|
||||||
>
|
|
||||||
{feature.name}
|
|
||||||
</span>
|
|
||||||
{isActive && filterVal && (
|
|
||||||
<span className="text-teal-600 dark:text-teal-400 font-semibold whitespace-nowrap">
|
|
||||||
{formatValue(filterVal[0], feature)}–
|
|
||||||
{formatValue(filterVal[1], feature)}
|
|
||||||
</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: `${leftPct}%`, width: `${widthPct}%` }}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 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}
|
|
||||||
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-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>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue