Hacky demo changes
This commit is contained in:
parent
7cba369308
commit
ea7afd618c
39 changed files with 2041 additions and 745 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue