More
Some checks failed
CI / Check (push) Failing after 2m14s
Build and publish Docker image / build-and-push (push) Failing after 2m38s

This commit is contained in:
Andras Schmelczer 2026-05-04 17:21:26 +01:00
parent cd34ee693f
commit 05a1f316e1
58 changed files with 3113 additions and 1277 deletions

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
@ -7,6 +7,386 @@ import { TickerValue } from '../ui/TickerValue';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { trackEvent } from '../../lib/analytics';
const SHOWCASE_STEP_COUNT = 4;
const SHOWCASE_INTERVAL_MS = 4200;
function ProductMapFrame({ children }: { children: ReactNode }) {
return (
<div className="relative h-full min-h-0 rounded-lg overflow-hidden bg-warm-900 border border-white/10">
<div className="absolute inset-0 opacity-70">
<div className="absolute left-[12%] top-[18%] w-[76%] h-[68%] rounded-[45%] border border-warm-500/25" />
<div className="absolute left-[22%] top-[24%] w-[52%] h-[55%] rounded-[45%] border border-warm-500/20" />
<div className="absolute left-[36%] top-[8%] h-[84%] w-px bg-warm-500/20 rotate-12" />
<div className="absolute left-[18%] top-[49%] h-px w-[68%] bg-warm-500/20 -rotate-6" />
<div className="absolute left-[48%] top-[16%] h-[72%] w-px bg-warm-500/20 -rotate-24" />
<div className="absolute left-[4%] top-[34%] h-px w-[92%] bg-teal-400/10 rotate-12" />
</div>
{children}
</div>
);
}
function DemoMapPin({
name,
detail,
x,
y,
active = false,
}: {
name: string;
detail: string;
x: string;
y: string;
active?: boolean;
}) {
return (
<div
className={`absolute -translate-x-1/2 -translate-y-1/2 ${active ? 'z-20' : 'z-10'}`}
style={{ left: x, top: y }}
>
<div className="relative mx-auto w-4 h-4">
<div
className={`absolute inset-0 rounded-full ${
active ? 'bg-coral-400 ring-8 ring-coral-400/20' : 'bg-teal-400 ring-4 ring-teal-400/15'
}`}
/>
</div>
<div
className={`mt-2 w-32 rounded-md border px-2 py-1 shadow-lg ${
active
? 'border-coral-400/40 bg-navy-950/95 text-white'
: 'border-white/10 bg-navy-950/85 text-warm-100'
}`}
>
<div className="text-xs font-semibold leading-tight">{name}</div>
<div className="text-[10px] leading-tight text-warm-400">{detail}</div>
</div>
</div>
);
}
function HeroProductShowcase() {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState(0);
const [isPaused, setIsPaused] = useState(false);
useEffect(() => {
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (isPaused || prefersReducedMotion) return;
const timer = window.setInterval(() => {
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT);
}, SHOWCASE_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [isPaused]);
const steps = [
{
tab: t('home.showcaseStep1Tab'),
title: t('home.showcaseStep1Title'),
body: t('home.showcaseStep1Body'),
},
{
tab: t('home.showcaseStep2Tab'),
title: t('home.showcaseStep2Title'),
body: t('home.showcaseStep2Body'),
},
{
tab: t('home.showcaseStep3Tab'),
title: t('home.showcaseStep3Title'),
body: t('home.showcaseStep3Body'),
},
{
tab: t('home.showcaseStep4Tab'),
title: t('home.showcaseStep4Title'),
body: t('home.showcaseStep4Body'),
},
];
const criteriaChips = [
t('home.showcaseStep1Chip1'),
t('home.showcaseStep1Chip2'),
t('home.showcaseStep1Chip3'),
t('home.showcaseStep1Chip4'),
];
const knownAreas = ['Clapham', 'St Albans', 'Brighton'];
const mapPins = [
{ name: 'Penge', detail: t('home.showcaseMatchPenge'), x: '63%', y: '54%' },
{ name: 'Abbey Wood', detail: t('home.showcaseMatchAbbeyWood'), x: '73%', y: '63%' },
{ name: 'Totterdown', detail: t('home.showcaseMatchTotterdown'), x: '42%', y: '71%' },
{ name: 'Walkley', detail: t('home.showcaseMatchWalkley'), x: '50%', y: '35%' },
];
const evidenceRows = [
t('home.showcaseEvidence1'),
t('home.showcaseEvidence2'),
t('home.showcaseEvidence3'),
t('home.showcaseEvidence4'),
];
const compareRows = [
t('home.showcaseCompare1'),
t('home.showcaseCompare2'),
t('home.showcaseCompare3'),
];
const active = steps[activeStep];
const renderSidePanel = () => {
if (activeStep === 0) {
return (
<div className="space-y-3">
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="text-[11px] font-semibold uppercase text-warm-400">
{t('aiFilter.describeIdealArea')}
</div>
<div className="mt-2 text-sm leading-snug text-white">
{t('home.showcaseStep1Prompt')}
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{criteriaChips.map((chip) => (
<span
key={chip}
className="rounded-full border border-teal-400/30 bg-teal-400/10 px-2 py-1 text-[11px] font-medium text-teal-200"
>
{chip}
</span>
))}
</div>
</div>
);
}
if (activeStep === 1) {
return (
<div className="space-y-3">
<div className="rounded-lg border border-coral-400/30 bg-coral-400/10 p-3">
<div className="text-2xl font-bold text-white">{t('home.showcaseStep2Metric')}</div>
<div className="text-xs text-coral-100">{t('home.showcaseStep2Note')}</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-2">
<div className="font-semibold text-warm-300">{t('home.showcaseKnownAreas')}</div>
{knownAreas.map((area) => (
<div key={area} className="mt-1 flex justify-between gap-2 text-warm-500">
<span>{area}</span>
<span>{t('home.showcaseKnownAreaStatus')}</span>
</div>
))}
</div>
<div className="rounded-lg border border-teal-400/30 bg-teal-400/10 p-2">
<div className="font-semibold text-teal-100">{t('home.showcaseNewMatches')}</div>
{mapPins.slice(0, 3).map((pin) => (
<div key={pin.name} className="mt-1 text-teal-200">
{pin.name}
</div>
))}
</div>
</div>
</div>
);
}
if (activeStep === 2) {
return (
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-warm-400">
{t('home.showcaseStep3Postcode')}
</div>
<div className="mt-1 text-lg font-bold text-white">{t('home.showcaseStep3Area')}</div>
</div>
<div className="rounded-full bg-teal-400/15 px-2.5 py-1 text-xs font-semibold text-teal-200">
{t('home.showcaseStep3Score')}
</div>
</div>
<div className="mt-3 space-y-1.5">
{evidenceRows.map((row) => (
<div key={row} className="flex items-center gap-2 text-xs text-warm-300">
<span className="w-4 h-4 rounded-full bg-teal-400/15 text-teal-200 flex items-center justify-center text-[10px]">
&#x2713;
</span>
<span>{row}</span>
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-2">
{compareRows.map((row, index) => (
<div key={row} className="rounded-lg border border-white/10 bg-white/[0.04] p-2.5">
<div className="flex items-start gap-2">
<span className="mt-0.5 w-5 h-5 rounded-full bg-coral-400/15 text-coral-100 flex items-center justify-center text-xs font-bold">
{index + 1}
</span>
<span className="min-w-0 flex-1 text-xs leading-snug text-warm-200">{row}</span>
</div>
</div>
))}
<div className="text-xs font-semibold text-teal-200">{t('home.showcaseSaveLabel')}</div>
</div>
);
};
const renderVisual = () => {
if (activeStep === 0) {
return (
<ProductMapFrame>
<div className="absolute inset-x-4 top-4 rounded-lg border border-white/10 bg-navy-950/90 p-3 shadow-xl">
<div className="text-[10px] font-semibold uppercase text-warm-400">
{t('home.showcaseStep1Tab')}
</div>
<div className="mt-2 rounded-md bg-white text-warm-800 px-3 py-2 text-xs leading-snug">
{t('home.showcaseStep1Prompt')}
</div>
</div>
<div className="absolute inset-x-4 bottom-4 flex flex-wrap gap-2">
{criteriaChips.map((chip) => (
<span
key={chip}
className="rounded-full border border-teal-400/30 bg-navy-950/85 px-2.5 py-1 text-[11px] font-semibold text-teal-100"
>
{chip}
</span>
))}
</div>
</ProductMapFrame>
);
}
if (activeStep === 1) {
return (
<ProductMapFrame>
<div className="absolute left-3 top-3 rounded-md bg-navy-950/85 px-2.5 py-1.5 text-xs text-warm-300 border border-white/10">
{t('home.showcaseMapLabel')}
</div>
{mapPins.map((pin, index) => (
<DemoMapPin key={pin.name} {...pin} active={index === 0} />
))}
</ProductMapFrame>
);
}
if (activeStep === 2) {
return (
<ProductMapFrame>
<DemoMapPin {...mapPins[0]} active />
<div className="absolute right-3 top-3 w-40 rounded-lg border border-white/10 bg-navy-950/95 p-3 shadow-xl">
<div className="text-xs font-semibold text-warm-400">
{t('home.showcaseStep3Postcode')}
</div>
<div className="mt-1 text-xl font-bold text-white">{t('home.showcaseStep3Code')}</div>
<div className="mt-2 rounded-full bg-teal-400/15 px-2 py-1 text-center text-xs font-semibold text-teal-200">
{t('home.showcaseStep3Score')}
</div>
</div>
<div className="absolute inset-x-3 bottom-3 rounded-lg border border-white/10 bg-navy-950/90 p-3">
<div className="grid grid-cols-2 gap-2">
{evidenceRows.map((row) => (
<div key={row} className="text-[11px] text-warm-300">
<span className="text-teal-300">&#x2713;</span> {row}
</div>
))}
</div>
</div>
</ProductMapFrame>
);
}
return (
<ProductMapFrame>
<div className="absolute inset-3 grid grid-rows-3 gap-2">
{compareRows.map((row, index) => (
<div
key={row}
className="rounded-lg border border-white/10 bg-navy-950/90 p-3 shadow-lg"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1 text-xs font-semibold leading-snug text-white">
{row}
</div>
<div className="h-2 w-16 shrink-0 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-coral-400"
style={{ width: `${88 - index * 14}%` }}
/>
</div>
</div>
</div>
))}
</div>
</ProductMapFrame>
);
};
return (
<div
className="relative w-full max-w-xl mt-10 lg:mt-0"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onFocus={() => setIsPaused(true)}
onBlur={() => setIsPaused(false)}
aria-label={t('home.showcaseHeader')}
>
<div className="h-[44rem] sm:h-[40rem] md:h-[34rem] lg:h-[35rem] xl:h-[33rem] rounded-xl border border-white/10 bg-navy-950/85 shadow-2xl overflow-hidden flex flex-col">
<div className="shrink-0 px-4 py-3 border-b border-white/10 flex items-center gap-2">
<LogoIcon className="w-5 h-5 text-teal-400" />
<div className="text-sm font-semibold text-white">{t('home.showcaseHeader')}</div>
<div className="ml-auto text-xs text-warm-400">{t('home.showcaseContext')}</div>
</div>
<div className="shrink-0 grid grid-cols-2 sm:grid-cols-4 gap-1 p-2 bg-white/[0.03] border-b border-white/10">
{steps.map((step, index) => (
<button
key={step.tab}
type="button"
onClick={() => setActiveStep(index)}
aria-pressed={activeStep === index}
className={`rounded-md px-2 py-2 text-[11px] sm:text-xs font-semibold leading-tight text-left transition-colors ${
activeStep === index
? 'bg-white/10 text-white'
: 'text-warm-400 hover:bg-white/[0.06] hover:text-warm-200'
}`}
>
<span>{step.tab}</span>
<span className="mt-2 block h-0.5 overflow-hidden rounded-full bg-white/10">
{activeStep === index && (
<span
key={activeStep}
className="showcase-progress block h-full origin-left bg-teal-400"
style={{
animationDuration: `${SHOWCASE_INTERVAL_MS}ms`,
animationPlayState: isPaused ? 'paused' : 'running',
}}
/>
)}
</span>
</button>
))}
</div>
<div className="flex-1 min-h-0 p-4 md:p-5">
<div className="grid h-full min-h-0 grid-rows-[minmax(0,0.92fr)_minmax(0,1.18fr)] md:grid-rows-1 md:grid-cols-[0.88fr_1.12fr] gap-4">
<div className="min-h-0 overflow-hidden flex flex-col justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.035] p-4">
<div className="shrink-0" aria-live={isPaused ? 'polite' : 'off'}>
<div className="text-xs font-semibold text-teal-300">{steps[activeStep].tab}</div>
<h2 className="mt-2 text-xl font-bold leading-tight text-white">{active.title}</h2>
<p className="mt-3 text-sm leading-relaxed text-warm-400">{active.body}</p>
</div>
<div className="min-h-0 overflow-hidden">{renderSidePanel()}</div>
</div>
{renderVisual()}
</div>
</div>
</div>
</div>
);
}
export default function HomePage({
onOpenDashboard,
onOpenPricing: _onOpenPricing,
@ -63,82 +443,84 @@ export default function HomePage({
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
<HexCanvas isDark={theme === 'dark'} />
<div className="absolute top-1/3 left-1/4 w-[500px] h-[500px] bg-teal-500/[0.04] rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-[400px] h-[300px] bg-teal-600/[0.03] rounded-full blur-[100px] pointer-events-none" />
<div className="relative z-10 max-w-7xl mx-auto w-full px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
<div className="max-w-4xl">
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
{t('home.heroTitle1')} <span className="text-teal-400">{t('home.heroTitle2')}</span>
.
<br />
{t('home.heroTitle3')}
</h1>
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
</p>
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
{t('home.heroDescription')}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center"
>
{t('home.exploreTheMap')}
</button>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById('comparison');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
const start = scroller.scrollTop;
const end =
start +
target.getBoundingClientRect().top -
scroller.getBoundingClientRect().top -
48;
const distance = end - start;
const duration = 1200;
let startTime: number;
const step = (time: number) => {
if (!startTime) startTime = time;
const p = Math.min((time - startTime) / duration, 1);
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
scroller.scrollTop = start + distance * ease;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}}
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
>
{t('home.seeTheDifference')}
</button>
</div>
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
</div>
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
<div className="grid lg:grid-cols-[1fr_0.9fr] gap-8 lg:gap-12 items-center">
<div className="max-w-4xl">
<p className="text-sm font-semibold text-teal-300 mb-3">{t('home.heroEyebrow')}</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1]">
{t('home.heroTitle1')}{' '}
<span className="text-teal-400">{t('home.heroTitle2')}</span>.
<br />
{t('home.heroTitle3')}
</h1>
<p className="text-base md:text-lg text-warm-300 mb-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
</p>
<p className="text-base md:text-lg text-warm-400 mb-8 max-w-xl">
{t('home.heroDescription')}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center"
>
{t('home.exploreTheMap')}
</button>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById('how-it-works');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
const start = scroller.scrollTop;
const end =
start +
target.getBoundingClientRect().top -
scroller.getBoundingClientRect().top -
48;
const distance = end - start;
const duration = 1200;
let startTime: number;
const step = (time: number) => {
if (!startTime) startTime = time;
const p = Math.min((time - startTime) / duration, 1);
const ease = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
scroller.scrollTop = start + distance * ease;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}}
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center"
>
{t('home.seeTheDifference')}
</button>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="56" active={statsActive} />
<div className="flex flex-wrap gap-x-8 sm:gap-x-12 gap-y-4 pt-3 border-t border-white/10">
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
<TickerValue text="13M" active={statsActive} />
</div>
<div className="text-sm text-warm-400">{t('home.statProperties')}</div>
</div>
<div className="text-sm text-warm-400">{t('home.statFilters')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
{t('home.statEvery')}
<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">{t('home.statFilters')}</div>
</div>
<div>
<div className="text-2xl md:text-3xl font-bold text-white">
{t('home.statEvery')}
</div>
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
</div>
<div className="text-sm text-warm-400">{t('home.statPostcodeInEngland')}</div>
</div>
</div>
<HeroProductShowcase />
</div>
<div className="flex-1" />
</div>
@ -155,6 +537,32 @@ export default function HomePage({
</div>
</div>
{/* Street by street */}
<div className="max-w-7xl mx-auto px-6 md:px-10 pt-10 pb-2">
<div className="grid md:grid-cols-3 gap-4">
<div className="md:col-span-1">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
{t('home.streetTitle')}
</h2>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
{t('home.streetIntro')}
</p>
</div>
{[
{ label: t('home.streetCard1Title'), body: t('home.streetCard1Body') },
{ label: t('home.streetCard2Title'), body: t('home.streetCard2Body') },
].map((item) => (
<div
key={item.label}
className="rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-5"
>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-2">{item.label}</h3>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">{item.body}</p>
</div>
))}
</div>
</div>
{/* How to use it + Comparison table (two columns) */}
<div id="how-it-works" className="max-w-7xl mx-auto px-6 md:px-10 pt-10 pb-2">
<div ref={whyRef} className="fade-in-section">