Hacky demo changes

This commit is contained in:
Andras Schmelczer 2026-05-06 19:36:04 +01:00
parent 7cba369308
commit ea7afd618c
39 changed files with 2041 additions and 745 deletions

View file

@ -15,11 +15,13 @@ import { TickerValue } from '../ui/TickerValue';
import {
ChartBarIcon,
CheckIcon,
ChevronIcon,
ClipboardIcon,
DownloadIcon,
FilterIcon,
LogoIcon,
MapPinIcon,
PlayIcon,
RouteIcon,
} from '../ui/icons';
import { trackEvent } from '../../lib/analytics';
@ -84,7 +86,9 @@ function highlightBrandText(text: string) {
function ProductDemoVideo() {
const sectionRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
useEffect(() => {
const section = sectionRef.current;
@ -108,14 +112,30 @@ function ProductDemoVideo() {
return () => observer.disconnect();
}, [shouldLoadVideo]);
const playVideo = () => {
const video = videoRef.current;
setShouldLoadVideo(true);
if (!video) return;
if (!video.getAttribute('src')) {
video.src = PRODUCT_DEMO_VIDEO_SRC;
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="overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
<div className="relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
<video
ref={videoRef}
src={shouldLoadVideo ? PRODUCT_DEMO_VIDEO_SRC : undefined}
poster={PRODUCT_DEMO_POSTER_SRC}
controls
@ -123,7 +143,22 @@ function ProductDemoVideo() {
preload={shouldLoadVideo ? 'metadata' : 'none'}
className="block aspect-video w-full bg-navy-950 object-contain"
aria-label="Perfect Postcode product demo"
onPlay={() => setIsVideoPlaying(true)}
onPause={() => setIsVideoPlaying(false)}
onEnded={() => setIsVideoPlaying(false)}
/>
{!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="Play Perfect Postcode product demo"
>
<PlayIcon className="h-11 w-11 -translate-x-0.5 md:h-14 md:w-14" />
</button>
</div>
)}
</div>
</div>
);
@ -131,7 +166,7 @@ function ProductDemoVideo() {
const DEMO_FEATURES: FeatureMeta[] = [
{
name: 'Last known price',
name: 'Estimated price',
type: 'numeric',
group: 'Properties',
min: 0,
@ -165,13 +200,13 @@ const DEMO_FEATURES: FeatureMeta[] = [
suffix: ' dB',
},
{
name: 'Distance to nearest train or tube station (km)',
name: 'Travel time to nearest train or tube station (min)',
type: 'numeric',
group: 'Transport',
min: 0,
max: 5,
step: 0.1,
suffix: ' km',
max: 60,
step: 1,
suffix: ' min',
},
];
@ -234,15 +269,6 @@ const HOUSE_PRICE_BREAKDOWN = [
{ label: '£/sq ft', value: '£872', helper: 'local median' },
];
const RECENT_SALES = [
{ address: '2-bed flat, Lexham Gardens', price: '£612k', meta: 'Mar 2025 · 710 sq ft' },
{ address: '1-bed flat, Earl\'s Court Road', price: '£485k', meta: 'Feb 2025 · 544 sq ft' },
{ address: '3-bed maisonette, Warwick Road', price: '£918k', meta: 'Jan 2025 · 1,020 sq ft' },
{ address: 'Studio, Courtfield Gardens', price: '£365k', meta: 'Dec 2024 · 361 sq ft' },
{ address: '2-bed flat, Collingham Place', price: '£755k', meta: 'Nov 2024 · 814 sq ft' },
{ address: '4-bed terrace, Nevern Square', price: '£1.64m', meta: 'Oct 2024 · 1,780 sq ft' },
];
const PRICE_SIGNALS = [
['5-year change', '+22%'],
['Last 12 months', '+2.1%'],
@ -295,9 +321,9 @@ const ENGLAND_SHOWCASE_POLYGON: number[][] = [
];
const SHOWCASE_MAP_START_VIEW: ViewState = {
longitude: -1.52,
latitude: 52.92,
zoom: 5.55,
longitude: -1.7,
latitude: 52.7,
zoom: 6.6,
pitch: 0,
bearing: 0,
};
@ -386,10 +412,10 @@ function interpolateViewState(progress: number): ViewState {
}
function demoSliderStep(feature: FeatureMeta): number {
if (feature.name === 'Last known price') return 1000;
if (feature.name === 'Estimated price') return 1000;
if (feature.name === 'Noise (dB)') return 0.05;
if (feature.name === 'Good+ primary schools within 2km') return 0.01;
if (feature.name === 'Distance to nearest train or tube station (km)') return 0.01;
if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1;
return feature.step ?? 1;
}
@ -420,7 +446,7 @@ function FilterPreviewRow({
feature,
value,
rangeLabel,
withoutLabel,
withoutCount,
index,
isTightened,
onValueChange,
@ -428,19 +454,21 @@ function FilterPreviewRow({
feature: FeatureMeta;
value: [number, number];
rangeLabel: string;
withoutLabel: string;
withoutCount: number;
index: number;
isTightened: boolean;
onValueChange: (value: [number, number]) => void;
}) {
const { t } = useTranslation();
const style = FILTER_ROW_STYLES[index % FILTER_ROW_STYLES.length];
const shortLabel =
{
'Last known price': 'Price',
'Noise (dB)': 'Noise',
'Good+ primary schools within 2km': 'Schools',
'Distance to nearest train or tube station (km)': 'Station',
}[feature.name] ?? feature.name;
const shortLabelKeys = {
'Estimated price': 'home.showcaseFeaturePriceShort',
'Noise (dB)': 'home.showcaseFeatureNoiseShort',
'Good+ primary schools within 2km': 'home.showcaseFeatureSchoolsShort',
'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort',
} as const;
const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys];
const shortLabel = shortLabelKey ? t(shortLabelKey) : undefined;
return (
<div
@ -453,7 +481,7 @@ function FilterPreviewRow({
<div className="min-w-0">
<FeatureLabel feature={feature} size="sm" className="hidden sm:flex" />
<div className="flex items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300 sm:hidden">
<FeatureLabel feature={{ ...feature, name: shortLabel }} size="sm" />
<FeatureLabel feature={feature} size="sm" label={shortLabel} />
</div>
<div className="mt-1 text-sm font-medium text-warm-500 dark:text-warm-400">
{rangeLabel}
@ -462,7 +490,9 @@ function FilterPreviewRow({
<span
className={`w-fit shrink-0 rounded-md px-2.5 py-1 text-xs font-bold leading-none ${style.chip}`}
>
{withoutLabel}
+
<span className="font-mono tabular-nums">{withoutCount.toLocaleString()}</span>
{' without this filter'}
</span>
</div>
<div className="mt-3 px-2 pl-4 sm:mt-5">
@ -484,7 +514,7 @@ function formatCompactCurrency(value: number): string {
}
function formatDemoRange(feature: FeatureMeta, value: [number, number]): string {
if (feature.name === 'Last known price') {
if (feature.name === 'Estimated price') {
return `${formatCompactCurrency(value[0])} - ${formatCompactCurrency(value[1])}`;
}
if (feature.name === 'Noise (dB)') {
@ -493,8 +523,8 @@ function formatDemoRange(feature: FeatureMeta, value: [number, number]): string
if (feature.name === 'Good+ primary schools within 2km') {
return `${Math.round(value[0])}+ good primaries nearby`;
}
if (feature.name === 'Distance to nearest train or tube station (km)') {
return `Within ${value[1].toFixed(1)} km of rail`;
if (feature.name === 'Travel time to nearest train or tube station (min)') {
return `Within ${Math.round(value[1])} min of rail`;
}
return `${value[0]} - ${value[1]}`;
}
@ -539,32 +569,36 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
[240000, 535000],
[135000, 485000],
[285000, 610000],
[0, 650000],
] as [number, number][],
without: [41820, 50622, 24860, 18645, 29796],
without: [41820, 50622, 24860, 18645, 29796, 41820],
},
{
feature: DEMO_FEATURES[3],
values: [
[40, 58],
[43, 52],
[40, 58],
] as [number, number][],
without: [19412, 8706],
without: [19412, 8706, 19412],
},
{
feature: DEMO_FEATURES[4],
values: [
[0, 60],
[5, 25],
[0, 60],
] as [number, number][],
without: [11209, 4118, 11209],
},
{
feature: DEMO_FEATURES[2],
values: [
[1, 8],
[2, 6],
[1, 8],
] as [number, number][],
without: [13608, 6944],
},
{
feature: DEMO_FEATURES[4],
values: [
[0, 1.4],
[0.25, 0.9],
] as [number, number][],
without: [11209, 4118],
without: [13608, 6944, 13608],
},
],
[]
@ -617,15 +651,15 @@ function FilterOnlyScreen({ isActive }: { isActive: boolean }) {
};
return (
<div className="h-full overflow-y-auto bg-white scrollbar-hide dark:bg-navy-950">
<div className="mx-auto grid min-h-full max-w-3xl content-start gap-2 p-2.5 sm:content-between sm:gap-3 sm:p-4">
<div className="h-full overflow-y-auto bg-white scrollbar-hide dark:bg-navy-950/50 dark:backdrop-blur-sm">
<div className="mx-auto grid min-h-full max-w-3xl content-start gap-2 p-2.5 sm:content-center sm:gap-3 sm:p-4">
{rows.map((row, index) => (
<div key={row.feature.name} className={index >= 3 ? 'hidden sm:block' : undefined}>
<FilterPreviewRow
feature={row.feature}
value={filterState[index].value}
rangeLabel={formatDemoRange(row.feature, filterState[index].value)}
withoutLabel={`+${filterState[index].without.toLocaleString()} without this filter`}
withoutCount={filterState[index].without}
index={index}
isTightened
onValueChange={(value) => updateFilter(index, value)}
@ -671,7 +705,7 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) {
}, [isActive]);
return (
<div className="pointer-events-none relative h-full overflow-hidden bg-warm-100 dark:bg-navy-950">
<div className="pointer-events-none relative h-full overflow-hidden bg-warm-100 dark:bg-navy-950/50">
<ProductMap
data={SHOWCASE_MAP_DATA}
postcodeData={EMPTY_SHOWCASE_POSTCODES}
@ -758,9 +792,9 @@ function RightPaneOnlyScreen({
};
return (
<div className="h-full overflow-hidden bg-white dark:bg-navy-900">
<div className="h-full overflow-hidden bg-white dark:bg-navy-900/60 dark:backdrop-blur-sm">
<div className="flex h-full flex-col overflow-hidden">
<div className="bg-white px-3 py-3 shadow-sm dark:bg-navy-900 sm:px-5 sm:py-4">
<div className="bg-white px-3 py-3 shadow-sm dark:bg-navy-900/65 sm:px-5 sm:py-4">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 sm:h-9 sm:w-9">
<MapPinIcon className="h-4 w-4 sm:h-5 sm:w-5" />
@ -771,22 +805,6 @@ function RightPaneOnlyScreen({
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-3 divide-x divide-warm-200 rounded-md bg-warm-50 text-center dark:divide-navy-700 dark:bg-navy-950 sm:mt-4">
{[
['Sales', '1,284'],
['Median sold', '£492k'],
['Scout rank', '#3'],
].map(([label, value]) => (
<div key={label} className="px-1.5 py-1.5 sm:px-2 sm:py-2">
<div className="text-xs font-bold uppercase text-warm-400">
{label}
</div>
<div className="mt-0.5 text-sm font-black text-navy-950 dark:text-warm-100">
{value}
</div>
</div>
))}
</div>
</div>
<div
ref={scrollerRef}
@ -795,7 +813,7 @@ function RightPaneOnlyScreen({
onTouchStart={markUserScrolled}
onWheel={markUserScrolled}
>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-1 flex items-center justify-between gap-3">
<FeatureLabel feature={DEMO_FEATURES[0]} size="sm" />
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
@ -807,7 +825,7 @@ function RightPaneOnlyScreen({
{HOUSE_PRICE_BREAKDOWN.map((item) => (
<div
key={item.label}
className="rounded-md bg-white px-2.5 py-2 shadow-sm shadow-navy-950/5 dark:bg-navy-900 sm:px-3"
className="rounded-md bg-white px-2.5 py-2 shadow-sm shadow-navy-950/5 dark:bg-navy-900/60 sm:px-3"
>
<div className="text-xs font-bold uppercase text-warm-400">
{item.label}
@ -823,35 +841,7 @@ function RightPaneOnlyScreen({
</div>
))}
</div>
<div className="mt-3 rounded-md bg-white p-3 shadow-sm shadow-navy-950/5 dark:bg-navy-900">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-black text-navy-950 dark:text-warm-100">
Recent sold prices
</span>
<span className="text-xs font-bold uppercase text-warm-400">within 0.5 mi</span>
</div>
<div className="space-y-2">
{RECENT_SALES.map((sale, index) => (
<div
key={sale.address}
className={`items-baseline justify-between gap-3 ${
index >= 3 ? 'hidden sm:flex' : 'flex'
}`}
>
<div className="min-w-0">
<div className="truncate text-xs font-semibold text-warm-700 dark:text-warm-300">
{sale.address}
</div>
<div className="text-xs font-medium text-warm-400">{sale.meta}</div>
</div>
<div className="shrink-0 text-xs font-black text-navy-950 dark:text-warm-100">
{sale.price}
</div>
</div>
))}
</div>
</div>
<div className="mt-3 grid grid-cols-3 divide-x divide-warm-200 rounded-md bg-white text-center shadow-sm shadow-navy-950/5 dark:divide-navy-700 dark:bg-navy-900">
<div className="mt-3 grid grid-cols-3 divide-x divide-warm-200 rounded-md bg-white text-center shadow-sm shadow-navy-950/5 dark:divide-navy-700 dark:bg-navy-900/60">
{PRICE_SIGNALS.map(([label, value]) => (
<div key={label} className="px-2 py-2">
<div className="text-xs font-bold uppercase text-warm-400">{label}</div>
@ -862,7 +852,7 @@ function RightPaneOnlyScreen({
))}
</div>
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300">
<RouteIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
<span>Journey routes</span>
@ -876,7 +866,7 @@ function RightPaneOnlyScreen({
showGoogleMapsLink={false}
/>
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<FeatureLabel feature={DEMO_FEATURES[2]} size="sm" />
<span className="shrink-0 text-xs font-black text-teal-700 dark:text-teal-300">
@ -892,14 +882,14 @@ function RightPaneOnlyScreen({
formatLabel={(value) => value.toFixed(0)}
/>
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300">
<ChartBarIcon className="h-4 w-4 text-teal-600 dark:text-teal-400" />
<span>{t('home.showcaseStep3Stat2Label')}</span>
</div>
<StackedBarChart segments={CRIME_SEGMENTS} total={82} />
</div>
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950 sm:p-4">
<div className="rounded-lg bg-warm-50 p-3 dark:bg-navy-950/50 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-warm-700 dark:text-warm-300">
<ChartBarIcon className="h-4 w-4 shrink-0 text-teal-600 dark:text-teal-400" />
@ -928,10 +918,10 @@ function ScoutScreen() {
const { t } = useTranslation();
return (
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-[#f7f3ed] p-3 dark:bg-navy-950 sm:p-5">
<div className="relative z-10 mt-5 rounded-lg bg-white p-3 shadow-2xl shadow-navy-950/10 dark:bg-navy-900 sm:mt-9 sm:p-5">
<div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-[#f7f3ed] p-3 dark:bg-navy-950/45 sm:p-5">
<div className="relative z-10 mt-5 rounded-lg bg-white p-3 shadow-2xl shadow-navy-950/10 dark:bg-navy-900/65 dark:backdrop-blur-sm sm:mt-9 sm:p-5">
<div className="grid grid-cols-2 gap-2 sm:gap-4">
<div className="cursor-default select-none rounded-lg border border-warm-200 bg-warm-50 p-3 shadow-sm dark:border-navy-700 dark:bg-navy-950 sm:p-4">
<div className="cursor-default select-none rounded-lg border border-warm-200 bg-warm-50 p-3 shadow-sm dark:border-navy-700 dark:bg-navy-950/50 sm:p-4">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300 sm:h-10 sm:w-10">
<ClipboardIcon className="h-4 w-4 sm:h-5 sm:w-5" />
@ -973,13 +963,16 @@ function ScoutScreen() {
aria-hidden="true"
>
<path
d="M50 4 L50 78"
d="M50 4 L50 92"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeDasharray="4 4"
/>
<polygon points="50,92 39,76 61,76" fill="currentColor" />
<polygon
points="50,92 44.5,84 55.5,84"
className="fill-teal-500 dark:fill-teal-300"
/>
</svg>
<svg
className="pointer-events-none absolute right-14 top-[8.75rem] z-20 h-36 w-20 text-teal-500/80 dark:text-teal-300/80 sm:right-12 sm:top-[11rem] sm:h-56 sm:w-72"
@ -988,21 +981,20 @@ function ScoutScreen() {
aria-hidden="true"
>
<path
d="M50 4 L50 78"
d="M50 4 L50 92"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeDasharray="4 4"
/>
<polygon points="50,92 39,76 61,76" fill="currentColor" />
<polygon
points="50,92 44.5,84 55.5,84"
className="fill-teal-500 dark:fill-teal-300"
/>
</svg>
<div className="relative z-10 mt-auto rounded-lg bg-navy-950 p-4 text-white shadow-2xl shadow-navy-950/20 sm:p-5">
<div className="inline-flex items-center gap-2 rounded-md bg-teal-400/10 px-2.5 py-1 text-xs font-bold uppercase text-teal-200">
<RouteIcon className="h-3.5 w-3.5" />
{t('home.showcaseStep4Title')}
</div>
<div className="mt-3 text-lg font-black leading-tight sm:mt-4">
<div className="relative z-10 mt-auto rounded-lg bg-navy-950/55 p-4 text-white shadow-2xl shadow-navy-950/20 backdrop-blur-sm sm:p-5">
<div className="text-lg font-black leading-tight">
{t('home.showcaseStep4Conclusion')}
</div>
<div className="mt-4 grid gap-2 text-xs font-medium leading-relaxed text-warm-300 sm:mt-5 sm:gap-3 sm:text-sm">
@ -1045,9 +1037,9 @@ function DashboardShowcase({
const showStageHeader = activeStep !== 3;
return (
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-warm-100 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-warm-100 text-navy-950 dark:bg-navy-950/45 dark:text-warm-100">
{showStageHeader && (
<div className="shrink-0 bg-navy-950 px-3 py-2 text-white shadow-sm sm:px-4 sm:py-3">
<div className="shrink-0 bg-navy-950/55 px-3 py-2 text-white shadow-sm backdrop-blur-sm sm:px-4 sm:py-3">
<div className="flex items-start gap-2 sm:gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-teal-400/10 text-teal-200 sm:h-9 sm:w-9">
<ActiveIcon className="h-4 w-4" />
@ -1086,23 +1078,9 @@ function DashboardShowcase({
function HeroProductShowcase() {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState(0);
const isStagePausedRef = useRef(false);
const [isStagePaused, setIsStagePaused] = useState(false);
const inspectUserScrolledRef = useRef(false);
useEffect(() => {
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) return;
const timer = window.setInterval(() => {
if (isStagePausedRef.current) return;
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT);
}, SHOWCASE_INTERVAL_MS);
return () => window.clearInterval(timer);
}, []);
const steps: ShowcaseStep[] = [
{
tab: t('home.showcaseStep1Tab'),
@ -1134,22 +1112,14 @@ function HeroProductShowcase() {
return (
<div
className="dark relative w-full min-w-0 max-w-[58rem]"
onMouseEnter={() => {
isStagePausedRef.current = true;
}}
onMouseLeave={() => {
isStagePausedRef.current = false;
}}
onFocus={() => {
isStagePausedRef.current = true;
}}
onBlur={() => {
isStagePausedRef.current = false;
}}
className="dark relative w-full min-w-0 max-w-[58rem] justify-self-center lg:max-w-none lg:justify-self-stretch"
onMouseEnter={() => setIsStagePaused(true)}
onMouseLeave={() => setIsStagePaused(false)}
onFocus={() => setIsStagePaused(true)}
onBlur={() => setIsStagePaused(false)}
aria-label={t('home.showcaseContext')}
>
<div className="flex h-[34rem] flex-col overflow-hidden rounded-lg bg-[#080d19] shadow-2xl shadow-black/40 sm:h-[46rem] md:h-[50rem] lg:h-[45.5rem] xl:h-[43rem]">
<div className="flex h-[34rem] flex-col overflow-hidden rounded-lg bg-navy-950/50 shadow-2xl shadow-black/40 backdrop-blur-sm ring-1 ring-white/10 sm:h-[46rem] md:h-[50rem] lg:h-[47rem] xl:h-[46rem]">
<div className="shrink-0 bg-white/[0.035] p-1.5 sm:p-2 md:p-3">
<div className="grid grid-cols-4 gap-1 sm:gap-2">
{steps.map((step, index) => {
@ -1184,7 +1154,11 @@ function HeroProductShowcase() {
className="showcase-progress block h-full origin-left bg-teal-400"
style={{
animationDuration: `${SHOWCASE_INTERVAL_MS}ms`,
animationPlayState: isStagePaused ? 'paused' : 'running',
}}
onAnimationEnd={() =>
setActiveStep((step) => (step + 1) % SHOWCASE_STEP_COUNT)
}
/>
)}
</span>
@ -1255,15 +1229,39 @@ export default function HomePage({
return () => clearTimeout(timer);
}, []);
const scrollToProductDemoVideo = () => {
const target = document.getElementById(PRODUCT_DEMO_SECTION_ID);
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 className="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} />
<div className="relative z-10 max-w-[96rem] mx-auto w-full px-6 md:px-10 pt-6 pb-8 md:pt-24 lg:pb-0 backdrop-blur-[2px] flex-1 flex flex-col">
<div className="grid gap-x-8 gap-y-6 lg:grid-cols-[0.85fr_1.15fr] lg:gap-12 items-center">
<div className="min-w-0 max-w-4xl">
<div className="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 lg:py-12">
<div className="hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6 lg:grid-cols-[minmax(0,0.82fr)_minmax(38rem,1.18fr)] lg:gap-12 xl:grid-cols-[minmax(0,0.78fr)_minmax(44rem,1.22fr)] xl:gap-16">
<div className="min-w-0 max-w-4xl lg:max-w-[42rem] xl:max-w-[45rem]">
<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')}{' '}
@ -1290,27 +1288,7 @@ export default function HomePage({
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById(PRODUCT_DEMO_SECTION_ID);
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);
scrollToProductDemoVideo();
}}
className="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"
>
@ -1340,8 +1318,18 @@ export default function HomePage({
</div>
<HeroProductShowcase />
</div>
<div className="flex-1" />
</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="Scroll to product demo"
onClick={() => {
trackEvent('CTA Click', { location: 'hero_chevron', label: 'scroll_down' });
scrollToProductDemoVideo();
}}
>
<ChevronIcon direction="down" className="h-14 w-14" />
</button>
</div>
<div className="home-content-surface relative overflow-hidden">