perfect-postcode/frontend/src/components/home/HomePage.tsx
Andras Schmelczer badab57438
Some checks failed
CI / Check (push) Failing after 7m12s
Build and publish Docker image / build-and-push (push) Successful in 7m25s
sus
2026-07-12 15:11:21 +01:00

738 lines
31 KiB
TypeScript

import { lazy, Suspense, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useFadeInRef } from '../../hooks/useFadeIn';
import { useIsMobile } from '../../hooks/useIsMobile';
import HexCanvas from './HexCanvas';
import HomeFinalCta from './HomeFinalCta';
import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { CheckIcon, ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
const BRAND_NAME = 'Perfect Postcode';
const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400';
const HOME_SECTION_CONTAINER_CLASS = 'max-w-7xl mx-auto px-6 md:px-10';
const HOME_SECTION_HEADING_CLASS =
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
const HOME_PRIMARY_BUTTON_CLASS =
'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-300/80';
const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = {
en: 'recording',
de: 'recording-de',
zh: 'recording-zh',
hi: 'recording-hi',
};
const PRODUCT_DEMO_SECTION_ID = 'product-demo-video';
const ProductShowcase = lazy(() => import('./ProductShowcase'));
function ProductShowcaseFallback({ className = '' }: { className?: string }) {
return (
<div
className={`min-h-[28rem] rounded-lg border border-white/10 bg-navy-950/35 dark:bg-navy-950/45 ${className}`}
aria-hidden="true"
/>
);
}
function getProductDemoSlug(language: string | undefined, isMobile: boolean): string {
const code = language?.toLowerCase().split('-')[0] ?? 'en';
const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
// Mobile cuts (9:16, 540x960) are published as `<base>-mobile` alongside
// the 16:9 desktop cuts. The recorder pipeline writes both every render:
// see video/src/storyboard.ts.
return isMobile ? `${base}-mobile` : base;
}
function highlightBrandText(text: string, className = BRAND_TEXT_CLASS) {
const parts = text.split(BRAND_NAME);
if (parts.length === 1) return text;
return parts.flatMap((part, index) =>
index === 0
? [part]
: [
<span key={`brand-${index}`} className={className}>
{BRAND_NAME}
</span>,
part,
]
);
}
function ProductDemoVideo() {
const { t, i18n } = useTranslation();
const isMobile = useIsMobile();
const sectionRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const currentVideoSrcRef = useRef<string | null>(null);
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
const productDemoSlug = getProductDemoSlug(i18n.language, isMobile);
const productDemoVideoSrc = `/video/${productDemoSlug}.mp4`;
const productDemoPosterSrc = `/video/${productDemoSlug}.jpg`;
useEffect(() => {
if (currentVideoSrcRef.current === productDemoVideoSrc) return;
currentVideoSrcRef.current = productDemoVideoSrc;
setIsVideoPlaying(false);
const video = videoRef.current;
if (!video || !shouldLoadVideo) return;
video.pause();
video.load();
}, [productDemoVideoSrc, shouldLoadVideo]);
useEffect(() => {
const section = sectionRef.current;
if (!section || shouldLoadVideo) return;
if (!('IntersectionObserver' in window)) {
setShouldLoadVideo(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
setShouldLoadVideo(true);
observer.disconnect();
},
{ rootMargin: '600px 0px' }
);
observer.observe(section);
return () => observer.disconnect();
}, [shouldLoadVideo]);
const playVideo = () => {
const video = videoRef.current;
setShouldLoadVideo(true);
if (!video) return;
if (video.getAttribute('src') !== productDemoVideoSrc) {
video.src = productDemoVideoSrc;
video.load();
}
void video.play().catch(() => {
setIsVideoPlaying(false);
});
};
return (
<div
id={PRODUCT_DEMO_SECTION_ID}
ref={sectionRef}
className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-12 pb-2`}
>
<div
className={`relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700 ${
isMobile ? 'mx-auto max-w-sm' : ''
}`}
>
<video
ref={videoRef}
src={shouldLoadVideo ? productDemoVideoSrc : undefined}
poster={productDemoPosterSrc}
controls
playsInline
preload={shouldLoadVideo ? 'metadata' : 'none'}
className={`block w-full bg-navy-950 object-contain ${
isMobile ? 'aspect-[9/16]' : 'aspect-video'
}`}
aria-label={t('home.productDemoLabel')}
onPlay={() => setIsVideoPlaying(true)}
onPause={() => setIsVideoPlaying(false)}
onEnded={() => setIsVideoPlaying(false)}
>
<track
kind="captions"
srcLang={(i18n.language ?? 'en').split('-')[0]}
label={t('common.captions')}
src={`/video/${productDemoSlug}.vtt`}
/>
</video>
{!isVideoPlaying && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-navy-950/15 transition-colors">
<button
type="button"
onClick={playVideo}
className="pointer-events-auto group flex h-20 w-20 items-center justify-center rounded-full bg-white/95 text-coral-500 shadow-2xl shadow-navy-950/40 ring-1 ring-white/60 transition-transform hover:scale-105 focus:outline-none focus-visible:scale-105 focus-visible:ring-4 focus-visible:ring-teal-300/75 md:h-24 md:w-24"
aria-label={t('home.playProductDemo')}
>
<PlayIcon className="h-11 w-11 -translate-x-0.5 md:h-14 md:w-14" />
</button>
</div>
)}
</div>
</div>
);
}
interface PriceStripTier {
up_to: number | null;
price_pence: number;
}
/**
* Compact pricing teaser under the hero CTAs: surfaces the current lifetime
* price and tier scarcity that otherwise hide behind the Pricing nav link.
*/
function PriceStrip({
onOpenPricing,
hidePricing,
}: {
onOpenPricing: () => void;
hidePricing?: boolean;
}) {
const { t } = useTranslation();
const [pricing, setPricing] = useState<{
licensed_count: number;
current_price_pence: number;
tiers: PriceStripTier[];
} | null>(null);
useEffect(() => {
if (hidePricing) return;
const controller = new AbortController();
fetch(apiUrl('pricing'), { signal: controller.signal })
.then((res) => (res.ok ? res.json() : null))
.then(setPricing)
.catch(() => {});
return () => controller.abort();
}, [hidePricing]);
if (hidePricing || !pricing) return null;
const price = `£${pricing.current_price_pence / 100}`;
const currentTier = pricing.tiers.find(
(tier) => tier.up_to === null || pricing.licensed_count < tier.up_to
);
const spotsRemaining =
currentTier?.up_to != null ? currentTier.up_to - pricing.licensed_count : 0;
return (
<p className="text-sm text-warm-300 mb-2">
{pricing.current_price_pence === 0
? t('upgrade.freeForEarly')
: t('home.priceStrip', { price })}{' '}
{pricing.current_price_pence > 0 && spotsRemaining > 0 && (
<span className="font-semibold text-teal-300">
{spotsRemaining === 1
? t('home.priceStripSpots', { count: spotsRemaining })
: t('home.priceStripSpotsPlural', { count: spotsRemaining })}
</span>
)}{' '}
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'price_strip' });
onOpenPricing();
}}
className="underline decoration-dotted underline-offset-2 text-teal-300 hover:text-teal-200"
>
{t('home.priceStripCta')}
</button>
</p>
);
}
const TWIN_SECTION_ID = 'twin-proof';
/**
* "Cheaper twin" proof block: real, verified figures (not illustrative).
* Median £/sqm for Land Registry "Flats/Maisonettes" sold since 2021, derived from
* epc_pp.parquet; school/commute parity checked against postcode.parquet + modelled
* transit times to central London. Sector grain (N1 1 / N7 9) is LOAD-BEARING. Do
* not relabel as bare N1/N7: the gap collapses to ~14% at outward-district level.
* Re-run the query periodically as new sales land; figures drift slightly over time.
*/
function TwinProof({ onOpenDashboard }: { onOpenDashboard: () => void }) {
const { t } = useTranslation();
const pricey = { name: 'Angel', sector: 'N1 1', perSqm: 11356 };
const value = { name: 'Holloway', sector: 'N7 9', perSqm: 7943 };
const deltaPct = Math.round((1 - value.perSqm / pricey.perSqm) * 100);
const fmt = (n: number) => `£${n.toLocaleString('en-GB')}`;
return (
<div id={TWIN_SECTION_ID} className={`${HOME_SECTION_CONTAINER_CLASS} pt-12 md:pt-20 pb-2`}>
<div className="mx-auto max-w-4xl rounded-2xl border border-warm-200 bg-white/90 p-6 shadow-sm dark:border-warm-700 dark:bg-warm-800/90 md:p-8">
<div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="rounded-full bg-teal-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-teal-700 dark:bg-teal-900/30 dark:text-teal-300">
{t('home.twinBadge')}
</span>
<h2 className={HOME_SECTION_HEADING_CLASS}>{t('home.twinTitle')}</h2>
</div>
<p className={`${HOME_BODY_CLASS} mb-6 max-w-2xl`}>{t('home.twinIntro')}</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-warm-200 bg-warm-50 p-5 dark:border-warm-700 dark:bg-navy-950/40">
<div className="text-xs font-semibold uppercase tracking-wide text-warm-500 dark:text-warm-400">
{t('home.twinPriceyLabel')}
</div>
<div className="mt-1 text-2xl font-extrabold text-navy-950 dark:text-warm-100">
{pricey.name}{' '}
<span className="text-base font-semibold text-warm-500 dark:text-warm-400">
{pricey.sector}
</span>
</div>
<div className="mt-2 text-lg font-bold tabular-nums text-warm-700 dark:text-warm-300">
{t('home.twinPerSqm', { value: fmt(pricey.perSqm) })}
</div>
</div>
<div className="rounded-xl border-2 border-teal-400 bg-teal-50/60 p-5 dark:border-teal-500 dark:bg-teal-900/20">
<div className="text-xs font-semibold uppercase tracking-wide text-teal-700 dark:text-teal-300">
{t('home.twinValueLabel')}
</div>
<div className="mt-1 text-2xl font-extrabold text-navy-950 dark:text-warm-100">
{value.name}{' '}
<span className="text-base font-semibold text-teal-700/70 dark:text-teal-300/70">
{value.sector}
</span>
</div>
<div className="mt-2 text-lg font-bold tabular-nums text-teal-700 dark:text-teal-300">
{t('home.twinPerSqm', { value: fmt(value.perSqm) })}
</div>
<div className="mt-3 text-sm font-bold tabular-nums text-teal-700 dark:text-teal-300">
{t('home.twinDelta', { percent: `${deltaPct}%` })}
</div>
</div>
</div>
<div className="mt-5 flex flex-col gap-2 text-sm font-medium text-warm-600 dark:text-warm-400 sm:flex-row sm:gap-6">
<span className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
{t('home.twinSameSchool')}
</span>
<span className="flex items-center gap-2">
<CheckIcon className="h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
{t('home.twinSameCommute')}
</span>
</div>
<p className="mt-4 text-xs italic text-warm-500 dark:text-warm-400">
{t('home.twinFootnote')}
</p>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'twin', label: 'explore_map' });
onOpenDashboard();
}}
className={`mt-5 w-full sm:w-auto px-6 py-3 ${HOME_PRIMARY_BUTTON_CLASS}`}
>
{t('home.twinCta')}
</button>
</div>
</div>
);
}
export default function HomePage({
onOpenDashboard,
onOpenPricing,
theme = 'light',
hidePricing,
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
theme?: 'light' | 'dark';
hidePricing?: boolean;
}) {
const { t } = useTranslation();
const [statsActive, setStatsActive] = useState(false);
const homeScrollerRef = useRef<HTMLDivElement | null>(null);
const homeSurfaceRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const timer = setTimeout(() => setStatsActive(true), 300);
return () => clearTimeout(timer);
}, []);
const whyRef = useFadeInRef();
const ctaRef = useFadeInRef();
useEffect(() => {
const scroller = homeScrollerRef.current;
if (!scroller) return;
let frame = 0;
const syncParallax = () => {
frame = 0;
scroller.style.setProperty('--home-scroll-y', `${scroller.scrollTop}px`);
};
const onScroll = () => {
if (frame) return;
frame = requestAnimationFrame(syncParallax);
};
syncParallax();
scroller.addEventListener('scroll', onScroll, { passive: true });
return () => {
scroller.removeEventListener('scroll', onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, []);
useEffect(() => {
const surface = homeSurfaceRef.current;
if (!surface) return;
let frame = 0;
let pointerX = 0;
let pointerY = 0;
const syncPointer = () => {
frame = 0;
surface.style.setProperty('--home-pointer-x', `${pointerX}px`);
surface.style.setProperty('--home-pointer-y', `${pointerY}px`);
};
const queuePointerSync = (event: PointerEvent) => {
const rect = surface.getBoundingClientRect();
pointerX = event.clientX - rect.left;
pointerY = event.clientY - rect.top;
if (frame) return;
frame = requestAnimationFrame(syncPointer);
};
const onPointerEnter = (event: PointerEvent) => {
queuePointerSync(event);
surface.style.setProperty('--home-pointer-active', '1');
};
const onPointerLeave = () => {
surface.style.setProperty('--home-pointer-active', '0');
};
surface.style.setProperty('--home-pointer-active', '0');
surface.addEventListener('pointerenter', onPointerEnter);
surface.addEventListener('pointermove', queuePointerSync, { passive: true });
surface.addEventListener('pointerleave', onPointerLeave);
return () => {
surface.removeEventListener('pointerenter', onPointerEnter);
surface.removeEventListener('pointermove', queuePointerSync);
surface.removeEventListener('pointerleave', onPointerLeave);
if (frame) cancelAnimationFrame(frame);
};
}, []);
// Scroll depth tracking
const scrolledSections = useRef(new Set<string>());
useEffect(() => {
const ids = ['comparison'];
const observers: IntersectionObserver[] = [];
ids.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !scrolledSections.current.has(id)) {
scrolledSections.current.add(id);
trackEvent('Scroll Depth', { section: id });
}
},
{ threshold: 0.1 }
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
// 30s time-on-page event
useEffect(() => {
const timer = setTimeout(() => trackEvent('Time on Page', { seconds: '30' }), 30000);
return () => clearTimeout(timer);
}, []);
const scrollToSection = (sectionId: string) => {
const target = document.getElementById(sectionId);
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 + 24;
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);
};
return (
<div
ref={homeScrollerRef}
className="home-page-scroll flex-1 overflow-y-auto overflow-x-hidden bg-warm-50 dark:bg-navy-950 relative"
>
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
<HexCanvas
isDark={theme === 'dark'}
animated={false}
className="home-hero-hex-parallax"
/>
<div className="home-hero-container relative z-10 mx-auto flex w-full max-w-[104rem] flex-1 flex-col px-6 pb-8 pt-6 backdrop-blur-[2px] md:px-10 md:py-10">
<div className="home-hero-layout hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6">
<div className="home-hero-copy min-w-0 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-100 mb-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
</p>
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-3">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className={`w-full sm:w-auto px-7 py-3.5 ${HOME_PRIMARY_BUTTON_CLASS}`}
>
{t('home.exploreTheMap')}
</button>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
scrollToSection(TWIN_SECTION_ID);
}}
className="w-full sm:w-auto px-7 py-3 border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base text-center focus:outline-none focus-visible:ring-4 focus-visible:ring-teal-300/60"
>
{t('home.seeTheDifference')}
</button>
</div>
<p className="text-sm font-medium text-teal-200 mb-4">{t('home.freeToExplore')}</p>
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
<p className="text-sm text-warm-400 mb-6">{t('home.coverageNote')}</p>
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
<div className="home-hero-stat">
<div className="home-hero-stat-value tabular-nums">
<TickerValue text="13M" active={statsActive} />
</div>
<div className="home-hero-stat-label">{t('home.statProperties')}</div>
</div>
<div className="home-hero-stat">
<div className="home-hero-stat-value tabular-nums">
<TickerValue text="40+" active={statsActive} />
</div>
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
</div>
<div className="home-hero-stat">
<div className="home-hero-stat-value">{t('home.statEvery')}</div>
<div className="home-hero-stat-label">{t('home.statPostcodeInEngland')}</div>
</div>
</div>
<p className="mt-5 text-xs font-medium uppercase tracking-wide text-warm-400">
<span className="text-warm-300">{t('home.dataSourcesLabel')}:</span>{' '}
{t('home.dataSources')}
</p>
</div>
<Suspense fallback={<ProductShowcaseFallback />}>
<ProductShowcase />
</Suspense>
</div>
</div>
<button
type="button"
className="hero-scroll-chevron absolute bottom-4 left-1/2 z-20 -translate-x-1/2 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/50"
aria-label={t('home.scrollToProductDemo')}
onClick={() => {
trackEvent('CTA Click', { location: 'hero_chevron', label: 'scroll_down' });
scrollToSection(TWIN_SECTION_ID);
}}
>
<ChevronIcon direction="down" className="h-14 w-14" />
</button>
</div>
<div ref={homeSurfaceRef} className="home-content-surface relative overflow-hidden">
<div className="relative z-10">
{/* Concrete proof of the "cheaper twin" claim, high on the page */}
<TwinProof onOpenDashboard={onOpenDashboard} />
{/* Our philosophy */}
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-12 md:pt-20 pb-4`}>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-6`}>{t('home.ourPhilosophy')}</h2>
<div className="space-y-4 text-base md:text-lg leading-relaxed text-warm-700 dark:text-warm-300">
<p>{t('home.philosophyP1')}</p>
<p>{highlightBrandText(t('home.philosophyP2'))}</p>
</div>
</div>
{/* Street-level detail */}
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-10 md:pt-16 pb-2`}>
<div className="grid gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:items-start">
<div>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-4`}>{t('home.streetTitle')}</h2>
<p className={`${HOME_BODY_CLASS} max-w-2xl`}>{t('home.streetIntro')}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{[
{
title: t('home.streetCard1Title'),
body: t('home.streetCard1Body'),
},
{
title: t('home.streetCard2Title'),
body: t('home.streetCard2Body'),
},
].map((item) => (
<div
key={item.title}
className="rounded-lg border border-warm-200 bg-white/90 p-5 shadow-sm dark:border-warm-700 dark:bg-warm-800/90"
>
<h3 className="text-base font-bold text-navy-950 dark:text-warm-100">
{item.title}
</h3>
<p className="mt-3 text-sm leading-relaxed text-warm-600 dark:text-warm-400">
{item.body}
</p>
</div>
))}
</div>
</div>
</div>
{/* Comparison table */}
<div id="comparison" className={`${HOME_SECTION_CONTAINER_CLASS} pt-10 md:pt-16 pb-2`}>
<div ref={whyRef} className="fade-in-section">
<div>
<h2
className={`${HOME_SECTION_HEADING_CLASS} mb-6 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center md:mb-10 md:gap-x-3`}
>
<span>{t('home.othersVs')}</span>
<span className="inline-flex items-center gap-2 md:gap-3">
<span className={BRAND_TEXT_CLASS}>{t('header.appName')}</span>
<LogoIcon className="h-[1em] w-[1em] shrink-0 text-teal-600 dark:text-teal-400" />
</span>
</h2>
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white/95 dark:bg-warm-800/95 shadow-sm">
<table className="w-full text-left">
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
<th className="px-2 md:px-5 py-3 md:py-4 text-xs font-bold text-navy-950 dark:text-warm-100" />
<th className="px-1.5 md:px-3 py-3 md:py-4 text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
{t('home.checkMyPostcode')}
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
{t('home.areaGuides')}
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-xs font-extrabold text-navy-950 dark:text-warm-100 text-center bg-teal-50 dark:bg-teal-900/30">
<span className={BRAND_TEXT_CLASS}>{t('header.appName')}</span>
</th>
</tr>
</thead>
<tbody>
{[
{
feature: t('home.compSearchWithout'),
subtitle: t('home.compSearchWithoutSub'),
postcode: false,
guides: false,
perfect: true,
},
{
feature: t('home.compAreaData'),
subtitle: t('home.compAreaDataSub'),
postcode: true,
guides: true,
perfect: true,
},
{
feature: t('home.compPropertyData'),
subtitle: t('home.compPropertyDataSub'),
postcode: false,
guides: true,
perfect: true,
},
{
feature: t('home.compFilters'),
subtitle: t('home.compFiltersSub'),
postcode: false,
guides: false,
perfect: true,
},
{
feature: t('home.compListings'),
subtitle: t('home.compListingsSub'),
postcode: true,
guides: false,
perfect: false,
},
].map((row, i, arr) => (
<tr
key={i}
className={
i < arr.length - 1
? 'border-b border-warm-100 dark:border-warm-800'
: ''
}
>
<td className="px-2 md:px-5 py-2.5 md:py-3.5 text-sm text-warm-700 dark:text-warm-300">
{row.feature}
{row.subtitle && (
<div className="italic text-warm-500 dark:text-warm-400">
{row.subtitle}
</div>
)}
</td>
{[row.postcode, row.guides].map((has, j) => {
const statusLabel = has ? t('common.yes') : t('common.no');
return (
<td
key={j}
aria-label={statusLabel}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base ${has ? 'text-green-500' : 'text-red-500'}`}
>
<span aria-hidden="true">{has ? '\u2713' : '\u2717'}</span>
<span className="sr-only">{statusLabel}</span>
</td>
);
})}
<td
aria-label={row.perfect ? t('common.yes') : t('common.no')}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base bg-teal-50 dark:bg-teal-900/30 ${
row.perfect ? 'text-green-500' : 'text-red-500'
}`}
>
<span aria-hidden="true">{row.perfect ? '✓' : '✗'}</span>
<span className="sr-only">
{row.perfect ? t('common.yes') : t('common.no')}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* Full product walkthrough, kept as a later proof beat, after the
differentiation, instead of competing with the showcase in the hero */}
<ProductDemoVideo />
{/* The real cost CTA */}
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-14 pb-12`}>
<div ref={ctaRef} className="fade-in-section">
<HomeFinalCta onOpenDashboard={onOpenDashboard} />
</div>
</div>
{/* Bottom illustration */}
<BottomIllustration isDark={theme === 'dark'} />
</div>
</div>
</div>
</div>
);
}