Progress
This commit is contained in:
parent
5b68c8da04
commit
536fd14378
28 changed files with 1683 additions and 313 deletions
|
|
@ -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}
|
||||
|
|
|
|||
22
frontend/src/components/home/BottomIllustration.tsx
Normal file
22
frontend/src/components/home/BottomIllustration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/home/CategoryArt.tsx
Normal file
118
frontend/src/components/home/CategoryArt.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
243
frontend/src/components/home/HomeDemo.tsx
Normal file
243
frontend/src/components/home/HomeDemo.tsx
Normal 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)} – {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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 & Wales.
|
||||
<br />
|
||||
<span className="text-teal-600">One map. Your rules.</span>
|
||||
<span className="text-warm-300">before you find your 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'll
|
||||
accept. Perfect Postcodes shows you every area that qualifies — 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 · Free · 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'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 each.
|
||||
That's just three. We've built 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'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's the problem with property search: listings only show you what's on
|
||||
the market{' '}
|
||||
<strong className="font-semibold text-navy-950 dark:text-warm-100">right now</strong>{' '}
|
||||
— 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'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 — years of historical transactions and public records,
|
||||
extended with proprietary algorithms so the map doesn'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've set — 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 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',
|
||||
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
69
frontend/src/components/pricing/PricingPage.tsx
Normal file
69
frontend/src/components/pricing/PricingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
39
frontend/src/components/ui/TickerValue.tsx
Normal file
39
frontend/src/components/ui/TickerValue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue