376 lines
16 KiB
TypeScript
376 lines
16 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { useFadeInRef } from '../../hooks/useFadeIn';
|
|
import HexCanvas from './HexCanvas';
|
|
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 { trackEvent } from '../../lib/analytics';
|
|
import type { FeatureMeta } from '../../types';
|
|
|
|
export default function HomePage({
|
|
onOpenDashboard,
|
|
onOpenPricing: _onOpenPricing,
|
|
theme = 'light',
|
|
features = [],
|
|
hidePricing: _hidePricing,
|
|
}: {
|
|
onOpenDashboard: () => void;
|
|
onOpenPricing: () => void;
|
|
theme?: 'light' | 'dark';
|
|
features?: FeatureMeta[];
|
|
hidePricing?: boolean;
|
|
}) {
|
|
const [statsActive, setStatsActive] = useState(false);
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setStatsActive(true), 300);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
const whyRef = useFadeInRef();
|
|
const ctaRef = useFadeInRef();
|
|
|
|
// Scroll depth tracking
|
|
const scrolledSections = useRef(new Set<string>());
|
|
useEffect(() => {
|
|
const ids = ['how-it-works', 'demo'];
|
|
const observers: IntersectionObserver[] = [];
|
|
ids.forEach((id) => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting && !scrolledSections.current.has(id)) {
|
|
scrolledSections.current.add(id);
|
|
trackEvent('Scroll Depth', { section: id });
|
|
}
|
|
},
|
|
{ threshold: 0.1 }
|
|
);
|
|
observer.observe(el);
|
|
observers.push(observer);
|
|
});
|
|
return () => observers.forEach((o) => o.disconnect());
|
|
}, []);
|
|
|
|
// 30s time-on-page event
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => trackEvent('Time on Page', { seconds: '30' }), 30000);
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
|
|
<div className="relative" style={{ zIndex: 1 }}>
|
|
{/* Hero */}
|
|
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
|
|
<HexCanvas isDark={theme === 'dark'} />
|
|
<div className="absolute top-1/3 left-1/4 w-[500px] h-[500px] bg-teal-500/[0.04] rounded-full blur-[120px] pointer-events-none" />
|
|
<div className="absolute bottom-0 right-1/4 w-[400px] h-[300px] bg-teal-600/[0.03] rounded-full blur-[100px] pointer-events-none" />
|
|
<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>.
|
|
<br />
|
|
Minimum Compromise.
|
|
</h1>
|
|
<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-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
|
|
onClick={() => {
|
|
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
|
|
onOpenDashboard();
|
|
}}
|
|
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
|
|
>
|
|
Explore the map
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
|
|
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-3 border-t border-white/10">
|
|
<div>
|
|
<div className="text-2xl md:text-3xl font-bold text-white">
|
|
<TickerValue text="13M" active={statsActive} />
|
|
</div>
|
|
<div className="text-sm text-warm-400">properties</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl md:text-3xl font-bold text-white">
|
|
<TickerValue text="56" active={statsActive} />
|
|
</div>
|
|
<div className="text-sm text-warm-400">filters</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl md:text-3xl font-bold text-white">Every</div>
|
|
<div className="text-sm text-warm-400">postcode in England</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<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>
|
|
|
|
{/* 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-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 */}
|
|
<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">
|
|
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">
|
|
<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-extrabold text-navy-950 dark:text-warm-100 text-center bg-teal-50 dark:bg-teal-900/30">
|
|
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 bg-teal-50 dark:bg-teal-900/30">
|
|
✓
|
|
</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-16 mb-8"
|
|
>
|
|
See It in Action
|
|
</h2>
|
|
<ScrollStory features={features} theme={theme} />
|
|
|
|
{/* The real cost CTA */}
|
|
<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">
|
|
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">
|
|
This deserves proper tools behind it — don't leave it to luck.
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
trackEvent('CTA Click', { location: 'bottom', label: 'explore_map' });
|
|
onOpenDashboard();
|
|
}}
|
|
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
|
>
|
|
Explore the map
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom illustration */}
|
|
<BottomIllustration isDark={theme === 'dark'} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 must-haves',
|
|
description: 'Budget, commute, schools \u2014 the map shows only what qualifies.',
|
|
},
|
|
{
|
|
title: 'Explore areas and discover hidden gems',
|
|
description: 'Zoom in, dig into details and nice to haves.',
|
|
},
|
|
{
|
|
title: 'Drill into postcodes',
|
|
description: 'See individual properties, sale prices, floor area, and compare.',
|
|
},
|
|
{
|
|
title: 'Shortlist with confidence',
|
|
description:
|
|
'Every area on your list meets your actual criteria \u2014 not just what was listed that week.',
|
|
},
|
|
];
|