This commit is contained in:
Andras Schmelczer 2026-02-09 19:26:54 +00:00
parent 5b68c8da04
commit 536fd14378
28 changed files with 1683 additions and 313 deletions

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import MapPage, { type ExportState } from './components/map/MapPage';
import DataSourcesPage from './components/data-sources/DataSourcesPage';
import FAQPage from './components/faq/FAQPage';
import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
import Header, { type Page } from './components/ui/Header';
@ -32,6 +33,8 @@ function pageToPath(page: Page): string {
return '/faq';
case 'saved-searches':
return '/saved';
case 'pricing':
return '/pricing';
default:
return '/';
}
@ -42,6 +45,7 @@ function pathToPage(pathname: string): Page | null {
if (pathname === '/data-sources') return 'data-sources';
if (pathname === '/faq') return 'faq';
if (pathname === '/saved') return 'saved-searches';
if (pathname === '/pricing') return 'pricing';
if (pathname === '/') return 'home';
return null;
}
@ -235,11 +239,13 @@ export default function App() {
isMobile={isMobile}
/>
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} theme={theme} />
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
) : activePage === 'data-sources' ? (
<DataSourcesPage />
) : activePage === 'faq' ? (
<FAQPage />
) : activePage === 'pricing' ? (
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
) : activePage === 'saved-searches' ? (
<SavedSearchesPage
searches={savedSearches.searches}

View file

@ -0,0 +1,22 @@
interface Props {
isDark: boolean;
}
export default function BottomIllustration({ isDark }: Props) {
const hillColor = isDark ? '#16a34a' : '#22c55e';
return (
<div className="w-full">
<svg viewBox="0 100 1600 250" className="w-full block" preserveAspectRatio="xMidYMax meet">
{/* Green hill */}
<path d="M0,350 C400,150 1200,150 1600,350 Z" fill={hillColor} />
{/* Inner shadow for depth */}
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
{/* House */}
<image href="/house.png" x="735" y="100" width="130" height="120" />
</svg>
</div>
);
}

View file

@ -0,0 +1,118 @@
/**
* Decorative mini SVGs for homepage category cards.
* Purely visual rendered at low opacity in the corner of each card.
*/
export default function CategoryArt({
category,
className = '',
}: {
category: string;
className?: string;
}) {
const props = { className, width: 36, height: 36, viewBox: '0 0 36 36', fill: 'none' };
switch (category) {
case 'Property':
// Ascending bar chart
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="22" width="6" height="10" rx="1" fill="currentColor" opacity="0.5" />
<rect x="13" y="14" width="6" height="18" rx="1" fill="currentColor" opacity="0.65" />
<rect x="22" y="6" width="6" height="26" rx="1" fill="currentColor" opacity="0.8" />
</svg>
);
case 'Transport':
// Converging route lines
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M4 6 Q18 18 32 12" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<path d="M4 18 Q18 18 32 18" stroke="currentColor" strokeWidth="2" opacity="0.7" />
<path d="M4 30 Q18 18 32 24" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<circle cx="32" cy="18" r="3" fill="currentColor" opacity="0.5" />
</svg>
);
case 'Crime':
// Shield outline
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path
d="M18 4 L30 10 V20 C30 26 24 32 18 34 C12 32 6 26 6 20 V10 Z"
stroke="currentColor"
strokeWidth="2"
opacity="0.6"
/>
<path d="M14 18 L17 21 L23 14" stroke="currentColor" strokeWidth="2" opacity="0.5" />
</svg>
);
case 'Education':
// Mortarboard / books
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M18 8 L4 16 L18 24 L32 16 Z" fill="currentColor" opacity="0.5" />
<path d="M10 19 V27 L18 31 L26 27 V19" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<line x1="30" y1="16" x2="30" y2="28" stroke="currentColor" strokeWidth="2" opacity="0.4" />
</svg>
);
case 'Amenities':
// Scattered dots (map pins)
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="10" r="3" fill="currentColor" opacity="0.5" />
<circle cx="22" cy="7" r="2.5" fill="currentColor" opacity="0.4" />
<circle cx="30" cy="16" r="2" fill="currentColor" opacity="0.5" />
<circle cx="14" cy="22" r="3.5" fill="currentColor" opacity="0.6" />
<circle cx="26" cy="28" r="2.5" fill="currentColor" opacity="0.45" />
<circle cx="6" cy="30" r="2" fill="currentColor" opacity="0.35" />
</svg>
);
case 'Demographics':
// Pie/donut segment
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="18" r="13" stroke="currentColor" strokeWidth="3" opacity="0.3" />
<path
d="M18 5 A13 13 0 0 1 30 14 L18 18 Z"
fill="currentColor"
opacity="0.6"
/>
<path
d="M18 5 A13 13 0 0 0 8 12 L18 18 Z"
fill="currentColor"
opacity="0.4"
/>
</svg>
);
case 'Environment':
// Terrain wave lines
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M2 20 Q9 12 18 18 Q27 24 34 16" stroke="currentColor" strokeWidth="2" opacity="0.6" />
<path d="M2 26 Q9 18 18 24 Q27 30 34 22" stroke="currentColor" strokeWidth="2" opacity="0.45" />
<path d="M2 14 Q9 6 18 12 Q27 18 34 10" stroke="currentColor" strokeWidth="2" opacity="0.35" />
</svg>
);
case 'Broadband':
// Signal waves (wifi)
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<path d="M6 16 Q18 4 30 16" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.4" />
<path d="M10 21 Q18 12 26 21" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.55" />
<path d="M14 26 Q18 20 22 26" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.7" />
<circle cx="18" cy="30" r="2.5" fill="currentColor" opacity="0.7" />
</svg>
);
case 'Deprivation':
// Scale / balance
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg">
<line x1="18" y1="6" x2="18" y2="30" stroke="currentColor" strokeWidth="2" opacity="0.4" />
<line x1="6" y1="14" x2="30" y2="14" stroke="currentColor" strokeWidth="2" opacity="0.5" />
<path d="M6 14 L3 24 H12 Z" fill="currentColor" opacity="0.4" />
<path d="M30 14 L27 22 H33 Z" fill="currentColor" opacity="0.5" />
<rect x="14" y="28" width="8" height="3" rx="1" fill="currentColor" opacity="0.3" />
</svg>
);
default:
return null;
}
}

View file

@ -1,6 +1,6 @@
import { useRef, useEffect } from 'react';
const HEX_COUNT = 60;
const HEX_COUNT = 70;
const TAU = Math.PI * 2;
interface Hex {
@ -17,12 +17,14 @@ function initHexes(w: number, h: number): Hex[] {
const hexes: Hex[] = [];
for (let i = 0; i < HEX_COUNT; i++) {
const y = Math.random() * h;
const side = Math.random() < 0.5 ? 'left' : 'right';
const x = side === 'left' ? Math.random() * w * 0.3 : w * 0.7 + Math.random() * w * 0.3;
hexes.push({
x: Math.random() * w,
x,
y,
baseY: y,
size: 8 + Math.random() * 20,
opacity: 0.06 + Math.random() * 0.12,
opacity: 0.08 + Math.random() * 0.15,
speed: 6 + Math.random() * 14,
phase: Math.random() * TAU,
});
@ -42,18 +44,10 @@ function drawHex(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: numbe
ctx.closePath();
}
export default function HexCanvas({
scrollProgress,
isDark = false,
}: {
scrollProgress: number;
isDark?: boolean;
}) {
export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const hexesRef = useRef<Hex[]>([]);
const animRef = useRef(0);
const scrollRef = useRef(scrollProgress);
scrollRef.current = scrollProgress;
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
@ -88,27 +82,29 @@ export default function HexCanvas({
function frame(now: number) {
const dt = (now - prev) / 1000;
prev = now;
const scroll = scrollRef.current;
ctx!.clearRect(0, 0, w, h);
const globalAlpha = Math.max(0, 1 - scroll * 2);
for (const hex of hexesRef.current) {
hex.x = (hex.x + hex.speed * dt) % (w + hex.size * 2);
const bob = Math.sin(now / 1000 + hex.phase) * 8;
const parallax = scroll * h * 0.3 * (hex.speed / 20);
hex.y = hex.baseY + bob - parallax;
hex.x += hex.speed * dt * 0.3;
if (hex.x > w * 0.3 + hex.size && hex.x < w * 0.7 - hex.size) {
hex.x = w * 0.7 + hex.size;
}
if (hex.x > w + hex.size * 2) {
hex.x = -hex.size * 2;
hex.y = Math.random() * h;
hex.baseY = hex.y;
}
if (hex.y < -hex.size * 2) hex.y += h + hex.size * 4;
if (hex.y > h + hex.size * 2) hex.y -= h + hex.size * 4;
const bob = Math.sin(now / 1000 + hex.phase) * 8;
hex.y = hex.baseY + bob;
const dark = isDarkRef.current;
ctx!.globalAlpha = hex.opacity * globalAlpha * (dark ? 0.6 : 1);
ctx!.globalAlpha = hex.opacity * (dark ? 0.6 : 1);
ctx!.fillStyle = dark ? '#058172' : '#00a28c';
drawHex(ctx!, hex.x, hex.y, hex.size);
ctx!.fill();
ctx!.globalAlpha = hex.opacity * 0.5 * globalAlpha * (dark ? 0.6 : 1);
ctx!.globalAlpha = hex.opacity * 0.5 * (dark ? 0.6 : 1);
ctx!.strokeStyle = dark ? '#0a665b' : '#05c9aa';
ctx!.lineWidth = 1;
drawHex(ctx!, hex.x, hex.y, hex.size);

View file

@ -0,0 +1,243 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { Slider } from '../ui/Slider';
import { apiUrl, authHeaders } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { TickerValue } from '../ui/TickerValue';
import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
const DEMO_FEATURE_NAMES = ['Estimated current price', 'Good+ primary schools within 5km', 'Number of restaurants within 2km'];
const DEMO_BOUNDS = '49,-9.5,57,5';
const DEMO_RESOLUTION = 5;
const noop = () => {};
const featureGradientStyle = gradientToCss(FEATURE_GRADIENT);
interface HomeDemoProps {
features: FeatureMeta[];
theme: 'light' | 'dark';
}
export default function HomeDemo({ features, theme }: HomeDemoProps) {
const [hexData, setHexData] = useState<HexagonData[]>([]);
const [sliderValues, setSliderValues] = useState<Record<string, [number, number]>>({});
const [activeFeature, setActiveFeature] = useState<string | null>(null);
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const abortRef = useRef<AbortController>();
const dragAbortRef = useRef<AbortController>();
const activeFeatureRef = useRef<string | null>(null);
activeFeatureRef.current = activeFeature;
const demoFeatures = useMemo(
() =>
DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter(
Boolean
) as FeatureMeta[],
[features]
);
// Initialize slider values when features arrive
useEffect(() => {
if (demoFeatures.length === 0) return;
const initial: Record<string, [number, number]> = {};
for (const f of demoFeatures) {
if (f.min != null && f.max != null) {
initial[f.name] = [f.min, f.max];
}
}
setSliderValues(initial);
}, [demoFeatures]);
// Feature coloring only during drag; density (property count) otherwise
const viewFeatureName = activeFeature;
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;
const filterRange: [number, number] | null = activeFeature && dragValue ? dragValue : null;
const displayData = dragHexData ?? hexData;
// Fetch hexagons (debounced) — skipped while dragging
const fetchHexagons = useCallback(() => {
if (activeFeatureRef.current) return;
if (features.length === 0 || Object.keys(sliderValues).length === 0) return;
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
});
const filterParts: string[] = [];
for (const [name, [min, max]] of Object.entries(sliderValues)) {
const meta = features.find((f) => f.name === name);
if (meta?.min != null && meta?.max != null) {
if (min !== meta.min || max !== meta.max) {
filterParts.push(`${name}:${min}:${max}`);
}
}
}
if (filterParts.length > 0) {
params.set('filters', filterParts.join(','));
}
abortRef.current?.abort();
abortRef.current = new AbortController();
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
.then((res) => res.json())
.then((data: { features: HexagonData[] }) => setHexData(data.features))
.catch(() => {});
}, [features, sliderValues]);
useEffect(() => {
clearTimeout(fetchTimeoutRef.current);
fetchTimeoutRef.current = setTimeout(fetchHexagons, 200);
return () => clearTimeout(fetchTimeoutRef.current);
}, [fetchHexagons]);
useEffect(() => {
return () => {
abortRef.current?.abort();
dragAbortRef.current?.abort();
clearTimeout(fetchTimeoutRef.current);
};
}, []);
// Drag start: fetch preview data with other filters only, fields=dragged feature
const handleDragStart = useCallback(
(name: string) => {
setActiveFeature(name);
const currentVal = sliderValues[name];
const meta = features.find((f) => f.name === name);
setDragValue(currentVal || (meta?.min != null ? [meta.min, meta.max!] : null));
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
});
const otherFilterParts: string[] = [];
for (const [n, [min, max]] of Object.entries(sliderValues)) {
if (n === name) continue;
const m = features.find((f) => f.name === n);
if (m?.min != null && m?.max != null && (min !== m.min || max !== m.max)) {
otherFilterParts.push(`${n}:${min}:${max}`);
}
}
if (otherFilterParts.length > 0) {
params.set('filters', otherFilterParts.join(','));
}
params.set('fields', name);
dragAbortRef.current?.abort();
dragAbortRef.current = new AbortController();
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
.then((res) => res.json())
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
.catch(() => {});
},
[features, sliderValues]
);
const handleSliderChange = useCallback(
(name: string, value: [number, number]) => {
setSliderValues((prev) => ({ ...prev, [name]: value }));
if (activeFeatureRef.current === name) {
setDragValue(value);
}
},
[]
);
const handleDragEnd = useCallback(() => {
setActiveFeature(null);
setDragValue(null);
setDragHexData(null);
}, []);
return (
<div className="flex flex-col md:flex-row gap-6">
{/* Map */}
<div className="relative rounded-xl overflow-hidden shadow-sm aspect-[4/3] md:w-3/5">
<div className="absolute inset-0 z-50 cursor-default" />
<div className="absolute inset-0">
<MapComponent
data={displayData}
postcodeData={[]}
usePostcodeView={false}
pois={[]}
onViewChange={noop}
viewFeature={viewFeatureName}
colorRange={colorRange}
filterRange={filterRange}
viewSource={activeFeature ? 'drag' : null}
onCancelPin={noop}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={noop}
onHexagonHover={noop}
initialViewState={DEMO_VIEW}
theme={theme}
screenshotMode={true}
hideLegend={true}
/>
</div>
{/* Colour spectrum legend */}
<div className="absolute bottom-3 left-3 right-3 z-50 pointer-events-none">
<div className="bg-white/90 dark:bg-warm-800/90 rounded-lg px-3 py-2 backdrop-blur-sm text-xs">
<div className="font-semibold text-navy-950 dark:text-warm-100 mb-1 truncate">
{activeFeature ? viewMeta?.name || activeFeature : 'Property density'}
</div>
<div
className="h-2.5 rounded-full"
style={{
background: activeFeature
? featureGradientStyle
: gradientToCss(theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT),
}}
/>
{colorRange && (
<div className="flex justify-between mt-0.5 text-warm-500 dark:text-warm-400">
<TickerValue text={formatValue(colorRange[0], viewMeta ?? undefined)} />
<TickerValue text={formatValue(colorRange[1], viewMeta ?? undefined)} />
</div>
)}
</div>
</div>
</div>
{/* Sliders */}
<div className="md:w-2/5 flex flex-col justify-center space-y-6">
{demoFeatures.map((feature) => {
const value = sliderValues[feature.name];
if (!value || feature.min == null || feature.max == null) return null;
const isActive = activeFeature === feature.name;
return (
<div
key={feature.name}
className={`rounded-lg p-3 ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : ''}`}
>
<div className="flex justify-between mb-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
{feature.name}
</span>
<span className="text-sm text-warm-500 dark:text-warm-400">
{formatValue(value[0], feature)} &ndash; {formatValue(value[1], feature)}
</span>
</div>
<Slider
min={feature.min}
max={feature.max}
step={feature.step || 1}
value={[value[0], value[1]]}
onValueChange={([min, max]) => handleSliderChange(feature.name, [min, max])}
onPointerDown={() => handleDragStart(feature.name)}
onPointerUp={() => handleDragEnd()}
/>
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,221 +1,326 @@
import { useRef, useState, useEffect, useCallback } from 'react';
import { useRef, useState, useEffect } from 'react';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
import HomeDemo from './HomeDemo';
import BottomIllustration from './BottomIllustration';
import CategoryArt from './CategoryArt';
import { TickerValue } from '../ui/TickerValue';
import type { FeatureMeta } from '../../types';
export default function HomePage({
onOpenDashboard,
onOpenPricing,
theme = 'light',
features = [],
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
theme?: 'light' | 'dark';
features?: FeatureMeta[];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollProgress, setScrollProgress] = useState(0);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollHeight - el.clientHeight;
if (max <= 0) return;
setScrollProgress(el.scrollTop / max);
}, []);
const [statsActive, setStatsActive] = useState(false);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
const timer = setTimeout(() => setStatsActive(true), 300);
return () => clearTimeout(timer);
}, []);
const heroRef = useFadeInRef();
const demoRef = useFadeInRef();
const scaleRef = useFadeInRef();
const problemRef = useFadeInRef();
const filtersRef = useFadeInRef();
const howRef = useFadeInRef();
const numbersRef = useFadeInRef();
const ctaRef = useFadeInRef();
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<HexCanvas scrollProgress={scrollProgress} isDark={theme === 'dark'} />
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="max-w-3xl mx-auto px-6 pt-12 pb-16 md:pt-20 md:pb-24">
<div
ref={heroRef}
className="fade-in-section backdrop-blur-sm bg-warm-50/60 dark:bg-navy-950/60 rounded-2xl p-8 -mx-2"
>
<p className="text-teal-600 font-semibold tracking-wide uppercase text-sm mb-4">
Find where to live, not just what&apos;s for sale
{/* Hero — full-bleed */}
<div
ref={heroRef}
className="fade-in-section relative overflow-hidden bg-gradient-to-br from-navy-950 via-navy-900 to-teal-900 dark:from-navy-950 dark:via-navy-900 dark:to-teal-900/60 pt-16 pb-20 md:pt-24 md:pb-28 shadow-[0_12px_50px_0px_rgba(13,148,136,0.5)] dark:shadow-[0_12px_50px_0px_rgba(13,148,136,0.4)]"
>
<HexCanvas isDark={theme === 'dark'} />
{/* Radial teal glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-teal-500/[0.07] rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6">
<p className="text-teal-400 font-semibold tracking-wide uppercase text-sm mb-4">
Browsing listings is not a strategy. Knowing what you want is.
</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-navy-950 dark:text-warm-100 mb-6 leading-[1.1] tracking-tight">
Every neighbourhood
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-6 leading-[1.1] tracking-tight">
Find your{' '}
<span className="text-teal-400">perfect postcode</span>
<br />
in England &amp; Wales.
<br />
<span className="text-teal-600">One map. Your&nbsp;rules.</span>
<span className="text-warm-300">before you find your&nbsp;property.</span>
</h1>
<p className="text-xl text-warm-600 dark:text-warm-400 mb-8 leading-relaxed max-w-xl">
Set the commute, budget, school rating, noise level, and crime threshold you&apos;ll
accept. Perfect Postcodes shows you every area that qualifies &mdash; instantly.
<p className="text-xl text-warm-300 mb-8 leading-relaxed max-w-xl">
Set the sliders to your expectations and the map highlights the areas that actually
match. Instantly.
</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 mb-12">
<button
onClick={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>
<span className="text-warm-400 text-sm">
No signup &middot; Free &middot; Open data
</span>
<button
onClick={onOpenPricing}
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
>
Get a lifetime license
</button>
</div>
</div>
</div>
{/* The flip */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={problemRef} className="fade-in-section">
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-8">
<div className="grid md:grid-cols-2 gap-8">
<div>
<h3 className="text-sm font-semibold text-warm-400 uppercase tracking-wide mb-2">
The old way
</h3>
<p className="text-warm-700 dark:text-warm-300 leading-relaxed">
Pick a postcode. Google the schools. Check crime stats on another site. Look up
commute times. Realise it&apos;s too expensive. Start over. Repeat 40 times.
</p>
<div className="flex gap-12 pt-6 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
</div>
<div>
<h3 className="text-sm font-semibold text-teal-600 uppercase tracking-wide mb-2">
With Perfect Postcodes
</h3>
<p className="text-warm-700 dark:text-warm-300 leading-relaxed">
Tell the map what you need. Every hexagon that lights up is a place worth
looking at. Drill into any one to see individual properties, prices, and energy
ratings.
</p>
<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">data layers</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>
{/* Filter showcase */}
{/* Map + Slider demo */}
<div className="max-w-4xl mx-auto px-6 pt-16 pb-20">
<div ref={demoRef} className="fade-in-section">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2">
See it in action
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-5 max-w-lg">
Drag the sliders and watch the map respond. Every postcode scored, every filter instant.
</p>
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-4 md:p-6">
<HomeDemo features={features} theme={theme} />
</div>
</div>
</div>
{/* Scale — "That's just two" + category cards */}
<div className="max-w-4xl mx-auto px-6 pb-20">
<div ref={filtersRef} className="fade-in-section">
<div ref={scaleRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2 text-center">
12 datasets. One slider&nbsp;each.
That&apos;s just three. We&apos;ve built&nbsp;43.
</h2>
<p className="text-warm-500 dark:text-warm-400 text-center mb-10 max-w-lg mx-auto">
Every filter narrows the map in real time. Combine as many as you like.
Spanning transport links, amenities, demographics, environment risk, broadband speeds,
crime, and more.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{FILTERS.map((f) => (
<div
key={f.label}
className="rounded-xl bg-white dark:bg-navy-800 border border-warm-200 dark:border-navy-700 p-4 shadow-sm hover:shadow-md hover:border-teal-300 dark:hover:border-teal-600 transition-all"
>
<div className="text-2xl mb-2">{f.icon}</div>
<div className="font-semibold text-navy-950 dark:text-warm-100 text-sm">
{f.label}
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{CATEGORIES.map((c) => (
<div
key={c.label}
className={`rounded-xl border-l-4 border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-4 shadow-sm hover:shadow-md transition-shadow ${c.borderClass} ${c.hoverBgClass}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`shrink-0 flex items-center justify-center rounded-lg w-8 h-8 text-base ${c.iconBgClass}`}
>
{c.icon}
</div>
<span className="font-semibold text-navy-950 dark:text-warm-100 text-sm">
{c.label}
</span>
</div>
<CategoryArt
category={c.group === 'Environment' && c.label === 'Broadband' ? 'Broadband' : c.group}
className={`shrink-0 ${c.artColorClass} opacity-40`}
/>
</div>
</div>
<div className="text-xs text-warm-500 dark:text-warm-400 mt-0.5">{f.example}</div>
</div>
))}
</div>
</div>
</div>
{/* How it works */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={howRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
Three clicks to clarity
</h2>
<div className="space-y-6">
{STEPS.map((step, i) => (
<div key={i} className="flex gap-5 items-start">
<span className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center text-lg font-bold">
{i + 1}
</span>
<div>
<h3 className="font-semibold text-navy-950 dark:text-warm-100 text-lg">
{step.title}
</h3>
<p className="text-warm-600 dark:text-warm-400 mt-0.5">{step.body}</p>
</div>
</div>
))}
{/* Problem / solution / philosophy */}
<div className="max-w-4xl mx-auto px-6 pb-20 relative">
{/* Cereal box — quirky margin note, hidden on narrow screens */}
<div className="hidden lg:block group absolute -right-44 top-8 cursor-pointer">
<div className="cereal-wobble">
<img src="/cereal.png" alt="Discounted cereal box" className="w-36 h-auto" />
</div>
<p className="cereal-text text-sm italic text-warm-500 dark:text-warm-400 mt-2 w-[9rem] leading-snug">
Your home is not a box of cereal. Don&apos;t let a discount on the wrong
property distract you from finding the right one.
</p>
</div>
</div>
{/* Numbers */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={numbersRef} className="fade-in-section">
<div className="grid grid-cols-3 gap-6 text-center">
{STATS.map((s) => (
<div key={s.label}>
<div className="text-2xl md:text-3xl font-extrabold text-teal-600">{s.value}</div>
<div className="text-sm text-warm-500 dark:text-warm-400 mt-1">{s.label}</div>
</div>
))}
</div>
<div ref={problemRef} className="fade-in-section">
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
Here&apos;s the problem with property search: listings only show you what&apos;s on
the market{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">right now</strong>{' '}
&mdash; a thin slice of what an area is actually like. And even if you could look
beyond them, there are{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
millions of postcodes
</strong>{' '}
across England. You can&apos;t research them all yourself.
</p>
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
We built this for you &mdash; years of historical transactions and public records,
extended with proprietary algorithms so the map doesn&apos;t just show raw data, it{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
surfaces the patterns that matter
</strong>
.
</p>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 leading-relaxed">
Understand areas first. Then find the right property within them, with expectations
you&apos;ve set &mdash; not ones the market set for you.
</p>
</div>
</div>
{/* Final CTA */}
<div className="max-w-3xl mx-auto px-6 pb-24">
<div className="max-w-3xl mx-auto px-6 pb-12">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
Ready to narrow it down?
The biggest financial decision of your life
<br />
deserves proper tools behind&nbsp;it.
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-8 max-w-md mx-auto">
100% open data. No account required. Just set your filters and go.
One payment, lifetime access. Set your filters and go.
</p>
<button
onClick={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"
>
Open the map
</button>
<div className="flex items-center justify-center gap-4">
<button
onClick={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"
>
Give your journey a headstart
</button>
<button
onClick={onOpenPricing}
className="px-[30px] py-[14px] border-2 border-navy-950 dark:border-warm-300 text-navy-950 dark:text-warm-300 rounded-lg font-semibold hover:bg-navy-950/5 dark:hover:bg-warm-300/5 transition-colors text-lg"
>
See pricing
</button>
</div>
</div>
</div>
{/* Bottom illustration */}
<BottomIllustration isDark={theme === 'dark'} />
</div>
</div>
);
}
const FILTERS = [
{ icon: '\u00A3', label: 'Sale price', example: 'e.g. under \u00A3400k' },
{ icon: '\uD83D\uDE86', label: 'Commute time', example: 'e.g. < 45 min to Bank' },
{ icon: '\uD83C\uDFEB', label: 'School quality', example: 'Ofsted Outstanding' },
{ icon: '\uD83D\uDEA8', label: 'Crime rate', example: 'Low burglary areas' },
{ icon: '\u26A1', label: 'Energy rating', example: 'EPC band A\u2013C' },
{ icon: '\uD83D\uDCCF', label: 'Floor area', example: 'e.g. 80+ sqm' },
{ icon: '\uD83D\uDD07', label: 'Road noise', example: 'Below 55 dB Lden' },
{ icon: '\uD83C\uDF10', label: 'Broadband speed', example: '100+ Mbps available' },
];
interface Category {
icon: string;
label: string;
group: string;
borderClass: string;
hoverBgClass: string;
iconBgClass: string;
artColorClass: string;
}
const STEPS = [
const CATEGORIES: Category[] = [
{
title: 'Add your deal-breakers',
body: 'Slide the filters for everything you care about \u2014 price cap, max commute, school quality, noise. The map updates as you drag.',
},
{
title: 'Spot the clusters',
body: 'Hexagons light up where properties match. Zoom in and they split into finer cells. At street level you see individual postcode boundaries.',
},
{
title: 'Dive into a neighbourhood',
body: 'Click any hexagon to see every property inside it \u2014 sale prices, floor plans, energy ratings, tenure. Layer on cafes, GP surgeries, and parks from OpenStreetMap.',
},
];
icon: '\u{1F3E0}',
label: 'Property',
group: 'Property',
borderClass: 'border-l-teal-400 dark:border-l-teal-500',
hoverBgClass: 'hover:bg-teal-50/50 dark:hover:bg-teal-900/20',
iconBgClass: 'bg-teal-100 dark:bg-teal-900/40',
artColorClass: 'text-teal-400 dark:text-teal-600',
const STATS = [
{ value: '26M+', label: 'property records' },
{ value: '12', label: 'open datasets' },
{ value: '1.7M', label: 'postcodes mapped' },
},
{
icon: '\u{1F686}',
label: 'Transport',
group: 'Transport',
borderClass: 'border-l-blue-400 dark:border-l-blue-500',
hoverBgClass: 'hover:bg-blue-50/50 dark:hover:bg-blue-900/20',
iconBgClass: 'bg-blue-100 dark:bg-blue-900/40',
artColorClass: 'text-blue-400 dark:text-blue-600',
},
{
icon: '\u{1F3EB}',
label: 'Schools',
group: 'Education',
borderClass: 'border-l-amber-400 dark:border-l-amber-500',
hoverBgClass: 'hover:bg-amber-50/50 dark:hover:bg-amber-900/20',
iconBgClass: 'bg-amber-100 dark:bg-amber-900/40',
artColorClass: 'text-amber-400 dark:text-amber-600',
},
{
icon: '\u{1F6A8}',
label: 'Crime',
group: 'Crime',
borderClass: 'border-l-rose-400 dark:border-l-rose-500',
hoverBgClass: 'hover:bg-rose-50/50 dark:hover:bg-rose-900/20',
iconBgClass: 'bg-rose-100 dark:bg-rose-900/40',
artColorClass: 'text-rose-400 dark:text-rose-600',
},
{
icon: '\u{1F465}',
label: 'Demographics',
group: 'Demographics',
borderClass: 'border-l-violet-400 dark:border-l-violet-500',
hoverBgClass: 'hover:bg-violet-50/50 dark:hover:bg-violet-900/20',
iconBgClass: 'bg-violet-100 dark:bg-violet-900/40',
artColorClass: 'text-violet-400 dark:text-violet-600',
},
{
icon: '\u{1F3EA}',
label: 'Amenities',
group: 'Amenities',
borderClass: 'border-l-emerald-400 dark:border-l-emerald-500',
hoverBgClass: 'hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20',
iconBgClass: 'bg-emerald-100 dark:bg-emerald-900/40',
artColorClass: 'text-emerald-400 dark:text-emerald-600',
},
{
icon: '\u{1F30D}',
label: 'Environment',
group: 'Environment',
borderClass: 'border-l-orange-400 dark:border-l-orange-500',
hoverBgClass: 'hover:bg-orange-50/50 dark:hover:bg-orange-900/20',
iconBgClass: 'bg-orange-100 dark:bg-orange-900/40',
artColorClass: 'text-orange-400 dark:text-orange-600',
},
{
icon: '\u{1F4E1}',
label: 'Broadband',
group: 'Environment',
borderClass: 'border-l-sky-400 dark:border-l-sky-500',
hoverBgClass: 'hover:bg-sky-50/50 dark:hover:bg-sky-900/20',
iconBgClass: 'bg-sky-100 dark:bg-sky-900/40',
artColorClass: 'text-sky-400 dark:text-sky-600',
},
{
icon: '\u{1F4CA}',
label: 'Deprivation',
group: 'Deprivation',
borderClass: 'border-l-fuchsia-400 dark:border-l-fuchsia-500',
hoverBgClass: 'hover:bg-fuchsia-50/50 dark:hover:bg-fuchsia-900/20',
iconBgClass: 'bg-fuchsia-100 dark:bg-fuchsia-900/40',
artColorClass: 'text-fuchsia-400 dark:text-fuchsia-600',
},
];

View file

@ -2,6 +2,7 @@ import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TickerValue } from '../ui/TickerValue';
export default function MapLegend({
featureLabel,
@ -50,8 +51,8 @@ export default function MapLegend({
<div className="flex justify-between mt-1 text-warm-600 dark:text-warm-200">
{mode === 'density' ? (
<>
<span>{formatValue(range[0])}</span>
<span>{formatValue(range[1])}</span>
<TickerValue text={formatValue(range[0])} />
<TickerValue text={formatValue(range[1])} />
</>
) : enumValues && enumValues.length > 0 ? (
<>
@ -60,8 +61,8 @@ export default function MapLegend({
</>
) : (
<>
<span>{formatValue(range[0])}</span>
<span>{formatValue(range[1])}</span>
<TickerValue text={formatValue(range[0])} />
<TickerValue text={formatValue(range[1])} />
</>
)}
</div>

View file

@ -143,6 +143,7 @@ function PropertyLoadingSkeleton() {
function PropertyCard({ property }: { property: Property }) {
const price = getNum(property, 'Last known price', 'latest_price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm', 'price_per_sqm');
const floorArea = getNum(property, 'Total floor area (sqm)', 'total_floor_area');
const rooms = getNum(
@ -172,6 +173,14 @@ function PropertyCard({ property }: { property: Property }) {
)}
</div>
)}
{estimatedPrice !== undefined && (
<div className="text-sm text-warm-600 dark:text-warm-400">
Est. value:{' '}
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(estimatedPrice)}
</span>
</div>
)}
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-sm dark:text-warm-300">
{property.property_type && (

View file

@ -0,0 +1,69 @@
import { CheckIcon } from '../ui/icons/CheckIcon';
const FEATURES = [
'56 data layers across England',
'Every postcode scored and filterable',
'Unlimited map exploration and exports',
'Historical price data back to 1995',
'Crime, schools, transport, broadband & more',
'All future data updates included',
];
export default function PricingPage({
onOpenDashboard,
}: {
onOpenDashboard: () => void;
}) {
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-3xl mx-auto px-6 py-16">
<div className="text-center mb-12">
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-warm-100 mb-3">
One price. Yours forever.
</h1>
<p className="text-lg text-warm-500 dark:text-warm-400 max-w-lg mx-auto">
No subscriptions, no recurring fees. Pay once and get lifetime access to every feature.
</p>
</div>
<div className="max-w-md mx-auto bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
{/* Price header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-8 py-10 text-center">
<div className="text-sm font-semibold text-teal-400 uppercase tracking-wide mb-2">
Lifetime License
</div>
<div className="flex items-baseline justify-center gap-1">
<span className="text-5xl font-extrabold text-white">£100</span>
<span className="text-warm-400 text-lg">/once</span>
</div>
<p className="text-warm-300 text-sm mt-2">
One-time payment, no subscription
</p>
</div>
{/* Features list */}
<div className="px-8 py-8">
<ul className="space-y-4">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-start gap-3">
<CheckIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feature}</span>
</li>
))}
</ul>
<button
onClick={onOpenDashboard}
className="w-full mt-8 px-6 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"
>
Get started
</button>
<p className="text-center text-sm text-warm-400 dark:text-warm-500 mt-3">
30-day money-back guarantee
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { MapPinIcon } from './icons/MapPinIcon';
import { LogoIcon } from './icons/LogoIcon';
import { CheckIcon } from './icons/CheckIcon';
import { ClipboardIcon } from './icons/ClipboardIcon';
import { MenuIcon } from './icons/MenuIcon';
@ -12,7 +12,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches';
export type Page = 'home' | 'dashboard' | 'data-sources' | 'faq' | 'saved-searches' | 'pricing';
export default function Header({
activePage,
@ -97,7 +97,7 @@ export default function Header({
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={() => onPageChange('home')}
>
<MapPinIcon className="w-5 h-5 text-teal-400" />
<LogoIcon className="w-5 h-5 text-teal-400" />
<span className="font-semibold text-lg">Perfect Postcodes</span>
</button>
@ -124,6 +124,9 @@ export default function Header({
<button className={tabClass('faq')} onClick={() => onPageChange('faq')}>
FAQ
</button>
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
</nav>
)}
</div>

View file

@ -82,6 +82,7 @@ export default function MobileMenu({
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('data-sources', 'Data Sources')}
{mobileNavItem('faq', 'FAQ')}
{mobileNavItem('pricing', 'Pricing')}
{/* Dashboard actions */}
{activePage === 'dashboard' && (

View file

@ -0,0 +1,39 @@
const DIGITS = '0123456789';
const H = 1.15; // digit slot height in em
function Digit({ char, delay, active }: { char: string; delay: number; active: boolean }) {
const idx = DIGITS.indexOf(char);
if (idx === -1) return <span>{char}</span>;
const offset = active ? -idx * H : 0;
return (
<span className="inline-block overflow-hidden" style={{ height: `${H}em` }}>
<span
className="block"
style={{
transform: `translateY(${offset}em)`,
transition: `transform 0.5s cubic-bezier(0.22, 1, 0.36, 1) ${delay}ms`,
}}
>
{DIGITS.split('').map((d) => (
<span key={d} className="block text-center" style={{ height: `${H}em`, lineHeight: `${H}em` }}>
{d}
</span>
))}
</span>
</span>
);
}
export function TickerValue({ text, active = true }: { text: string; active?: boolean }) {
const chars = text.split('');
const len = chars.length;
return (
<span className="inline-flex" style={{ fontVariantNumeric: 'tabular-nums' }}>
{chars.map((ch, i) => (
<Digit key={i} char={ch} delay={(len - 1 - i) * 30} active={active} />
))}
</span>
);
}

View file

@ -53,3 +53,63 @@ h3 {
opacity: 1;
transform: translateY(0);
}
/* Cereal aside — hover to reveal */
@keyframes cereal-wobble {
0%,
100% {
transform: rotate(0deg);
}
15% {
transform: rotate(-8deg);
}
30% {
transform: rotate(6deg);
}
45% {
transform: rotate(-4deg);
}
60% {
transform: rotate(2deg);
}
80% {
transform: rotate(-1deg);
}
}
.cereal-wobble {
transform-origin: bottom center;
}
.group:hover .cereal-wobble {
animation: cereal-wobble 0.8s ease-in-out;
}
.cereal-reveal {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 0.5s ease-out,
color 0.2s ease;
}
.group:hover .cereal-reveal {
grid-template-rows: 1fr;
}
.cereal-reveal > * {
overflow: hidden;
}
.cereal-text {
opacity: 0;
transition:
opacity 0.4s ease-out,
color 0.2s ease;
}
.group:hover .cereal-text {
opacity: 1;
transition-delay: 0.2s, 0s;
}