738 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|