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">

View file

@ -67,6 +67,7 @@ interface MapProps {
currentLocation?: { lat: number; lng: number } | null;
bounds?: Bounds | null;
hideLegend?: boolean;
hideLocationSearch?: boolean;
travelTimeEntries?: TravelTimeEntry[];
densityLabel?: string;
totalCount?: number;
@ -173,6 +174,7 @@ export default memo(function Map({
currentLocation,
bounds: viewportBounds,
hideLegend = false,
hideLocationSearch = false,
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
densityLabel: densityLabelProp,
totalCount: totalCountProp,
@ -231,6 +233,7 @@ export default memo(function Map({
}, [viewState, dimensions, onViewChange]);
const handleMove = useCallback((evt: { viewState: ViewState }) => {
if (window.__demoRecording) window.__demoMapIdle = false;
setInternalViewState((prev) => {
const next = evt.viewState;
// Skip re-render when viewport values haven't changed (e.g. container resize
@ -249,6 +252,14 @@ export default memo(function Map({
});
}, []);
const handleIdle = useCallback(() => {
if (screenshotMode) window.__map_idle = true;
if (window.__demoRecording) {
window.__demoMapIdle = true;
window.__demoMapIdleVersion = (window.__demoMapIdleVersion ?? 0) + 1;
}
}, [screenshotMode]);
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
}, []);
@ -293,13 +304,7 @@ export default memo(function Map({
{...viewState}
onMove={handleMove}
onLoad={undefined}
onIdle={
screenshotMode
? () => {
window.__map_idle = true;
}
: undefined
}
onIdle={handleIdle}
mapStyle={mapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
@ -362,12 +367,14 @@ export default memo(function Map({
) : (
<>
<div className="absolute top-3 left-3 right-3 z-[60] flex flex-wrap items-start justify-between gap-2 pointer-events-none">
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
/>
{!hideLocationSearch && (
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onCurrentLocationFound={onCurrentLocationFound}
onMouseEnter={handleMouseLeave}
/>
)}
{!hideLegend &&
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (

View file

@ -33,6 +33,7 @@ import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { ts } from '../../i18n/server';
import { trackEvent } from '../../lib/analytics';
import { canWheelScrollInsideTarget } from '../../lib/dom-scroll';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { getSchoolBackendFeatureName } from '../../lib/school-filter';
import { useLicense } from '../../hooks/useLicense';
@ -54,6 +55,18 @@ const MapPageSelectionPane = lazy(() =>
const UpgradeModal = lazy(() => import('../ui/UpgradeModal'));
const Joyride = lazy(() => import('react-joyride'));
declare global {
interface Window {
__demoRecording?: boolean;
__demoOpenBestHexagon?: () => string | null;
__demoMapSettled?: boolean;
__demoMapSettleVersion?: number;
__demoMapIdle?: boolean;
__demoMapIdleVersion?: number;
__demoSelectionReady?: boolean;
}
}
function MapFallback() {
return (
<div className="flex h-full w-full items-center justify-center bg-warm-100 dark:bg-navy-950">
@ -218,6 +231,9 @@ export default function MapPage({
travelTimeEntries: entries,
shareCode,
});
const demoMapHasData = mapData.usePostcodeView
? mapData.postcodeData.length > 0
: mapData.data.length > 0;
const handleAiFilterSubmit = useCallback(
async (query: string) => {
@ -416,6 +432,48 @@ export default function MapPage({
setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!window.__demoRecording) return;
void import('./MapPageSelectionPane');
void import('./AreaPane');
void import('./PropertiesPane');
}, []);
useEffect(() => {
if (!window.__demoRecording) return;
window.__demoMapSettled = !mapData.loading && demoMapHasData;
if (window.__demoMapSettled) {
window.__demoMapSettleVersion = (window.__demoMapSettleVersion ?? 0) + 1;
}
return () => {
window.__demoMapSettled = false;
};
}, [demoMapHasData, mapData.loading]);
useEffect(() => {
if (!window.__demoRecording) return;
window.__demoSelectionReady = Boolean(selectedHexagon && areaStats && !loadingAreaStats);
return () => {
window.__demoSelectionReady = false;
};
}, [areaStats, loadingAreaStats, selectedHexagon]);
useEffect(() => {
if (!window.__demoRecording) return;
window.__demoOpenBestHexagon = () => {
const best = mapData.data.reduce<(typeof mapData.data)[number] | null>((winner, item) => {
if (!winner || item.count > winner.count) return item;
return winner;
}, null);
if (!best) return null;
handleHexagonClick(best.h3);
return best.h3;
};
return () => {
delete window.__demoOpenBestHexagon;
};
}, [handleHexagonClick, mapData.data]);
// Navigate to a specific postcode on mount (e.g. from saved properties)
useEffect(() => {
if (!initialPostcode) return;
@ -451,7 +509,10 @@ export default function MapPage({
// Prevent browser back/forward navigation from horizontal trackpad swipes
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
if (
Math.abs(e.deltaX) > Math.abs(e.deltaY) &&
!canWheelScrollInsideTarget(e.target, e.deltaX, e.deltaY)
) {
e.preventDefault();
}
};
@ -873,6 +934,7 @@ export default function MapPage({
currentLocation={currentLocation}
bounds={mapData.bounds}
hideLegend
hideLocationSearch={mobileDrawerOpen && !!selectedHexagon}
travelTimeEntries={entries}
/>
</Suspense>
@ -898,7 +960,7 @@ export default function MapPage({
</button>
{poiPaneOpen && (
<div className="absolute top-14 right-3 left-3 z-20 max-h-[45dvh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
<div className="absolute top-14 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
@ -1045,7 +1107,7 @@ export default function MapPage({
</button>
{/* Floating POI panel */}
{poiPaneOpen && (
<div className="absolute bottom-14 right-4 z-10 w-80 max-h-[60vh] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
<div className="absolute bottom-14 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">
{renderPOIPane()}
</div>
)}
@ -1062,6 +1124,7 @@ export default function MapPage({
onClose={handleCloseSelection}
renderAreaPane={renderAreaPane}
renderPropertiesPane={renderPropertiesPane}
demoReady={Boolean(areaStats && !loadingAreaStats)}
/>
</Suspense>
)}

View file

@ -18,6 +18,7 @@ interface MapPageSelectionPaneProps {
onClose: () => void;
renderAreaPane: () => ReactNode;
renderPropertiesPane: () => ReactNode;
demoReady?: boolean;
}
export function MapPageSelectionPane({
@ -29,10 +30,12 @@ export function MapPageSelectionPane({
onClose,
renderAreaPane,
renderPropertiesPane,
demoReady = false,
}: MapPageSelectionPaneProps) {
return (
<div
data-tutorial="right-pane"
data-demo-ready={demoReady ? 'true' : 'false'}
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width }}
>

View file

@ -90,7 +90,7 @@ export default function POIPane({
const selectedCount = selectedCategories.size;
return (
<div className="flex flex-col h-full bg-white dark:bg-warm-900 shadow-lg overflow-hidden">
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-white shadow-lg dark:bg-warm-900">
<div className="flex-shrink-0 px-3 pt-3 pb-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide">
@ -150,7 +150,7 @@ export default function POIPane({
)}
</div>
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 dark:border-warm-700">
<div className="px-3 pt-2 pb-1">
<SearchInput
value={searchTerm}

View file

@ -11,6 +11,7 @@ interface FeatureLabelProps {
className?: string;
size?: 'xs' | 'sm';
description?: string;
label?: string;
hideIconOnMobile?: boolean;
}
@ -20,6 +21,7 @@ export function FeatureLabel({
className = '',
size = 'xs',
description,
label,
hideIconOnMobile,
}: FeatureLabelProps) {
const { t } = useTranslation();
@ -29,7 +31,7 @@ export function FeatureLabel({
const featureIcon = getFeatureIcon(feature.name, iconClass);
const GroupIcon = !featureIcon && feature.group ? getGroupIcon(feature.group) : null;
const translatedName = ts(feature.name);
const translatedName = label ?? ts(feature.name);
const translatedDesc = description ? tsDesc(feature.name, description) : undefined;
const nameContent = (

View file

@ -8,7 +8,7 @@ interface PillGroupProps {
export function PillGroup({ children, className = '' }: PillGroupProps) {
return (
<div
className={`flex flex-nowrap overflow-x-auto gap-1.5 md:flex-wrap md:overflow-x-visible scrollbar-hide ${className}`}
className={`flex min-w-0 max-w-full flex-nowrap gap-1.5 overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible ${className}`}
>
{children}
</div>

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function PlayIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6.75 4.35v15.3a1.05 1.05 0 0 0 1.6.9l12.55-7.65a1.05 1.05 0 0 0 0-1.8L8.35 3.45a1.05 1.05 0 0 0-1.6.9z" />
</svg>
);
}

View file

@ -20,6 +20,7 @@ export { LogoIcon } from './LogoIcon';
export { MapPinIcon } from './MapPinIcon';
export { MenuIcon } from './MenuIcon';
export { MoonIcon } from './MoonIcon';
export { PlayIcon } from './PlayIcon';
export { PlusIcon } from './PlusIcon';
export { RouteIcon } from './RouteIcon';
export { SearchIcon } from './SearchIcon';

View file

@ -348,6 +348,10 @@ const de: Translations = {
seeTheDifference: 'So funktioniert es',
showcaseHeader: 'So funktioniert es',
showcaseContext: 'So funktioniert Perfect Postcode',
showcaseFeaturePriceShort: 'Preis',
showcaseFeatureNoiseShort: 'Lärm',
showcaseFeatureSchoolsShort: 'Schulen',
showcaseFeatureTravelShort: 'Fahrzeit',
showcaseStep1Tab: 'Filtern',
showcaseStep1Title: 'Aus vagen Wünschen eine präzise Suche machen',
showcaseStep1Body:
@ -801,6 +805,7 @@ const de: Translations = {
'Property type': 'Immobilientyp',
'Leasehold/Freehold': 'Erbbaurecht/Volleigentum',
'Last known price': 'Letzter bekannter Preis',
'Estimated price': 'Geschätzter Preis',
'Estimated current price': 'Geschätzter aktueller Preis',
'Price per sqm': 'Preis pro m²',
'Est. price per sqm': 'Gesch. Preis pro m²',
@ -817,6 +822,8 @@ const de: Translations = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Entfernung zum nächsten Bahn- oder U-Bahnhof (km)',
'Travel time to nearest train or tube station (min)':
'Fahrzeit zum nächsten Bahn- oder U-Bahnhof (Min.)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Gute+ Grundschulen im Umkreis von 2 km',

View file

@ -343,6 +343,10 @@ const en = {
seeTheDifference: 'See how it works',
showcaseHeader: 'How it works',
showcaseContext: 'How Perfect Postcode works',
showcaseFeaturePriceShort: 'Price',
showcaseFeatureNoiseShort: 'Noise',
showcaseFeatureSchoolsShort: 'Schools',
showcaseFeatureTravelShort: 'Travel',
showcaseStep1Tab: 'Filter',
showcaseStep1Title: 'Turn vague needs into a tight search',
showcaseStep1Body:
@ -788,6 +792,7 @@ const en = {
'Property type': 'Property type',
'Leasehold/Freehold': 'Leasehold/Freehold',
'Last known price': 'Last known price',
'Estimated price': 'Estimated price',
'Estimated current price': 'Estimated current price',
'Price per sqm': 'Price per sqm',
'Est. price per sqm': 'Est. price per sqm',
@ -804,6 +809,8 @@ const en = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Distance to nearest train or tube station (km)',
'Travel time to nearest train or tube station (min)':
'Travel time to nearest train or tube station (min)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Good+ primary schools within 2km',

View file

@ -351,6 +351,10 @@ const fr: Translations = {
seeTheDifference: 'Voir comment ça marche',
showcaseHeader: 'Comment ça marche',
showcaseContext: 'Comment fonctionne Perfect Postcode',
showcaseFeaturePriceShort: 'Prix',
showcaseFeatureNoiseShort: 'Bruit',
showcaseFeatureSchoolsShort: 'Écoles',
showcaseFeatureTravelShort: 'Trajet',
showcaseStep1Tab: 'Filtrer',
showcaseStep1Title: 'Transformez des besoins vagues en recherche précise',
showcaseStep1Body:
@ -802,6 +806,7 @@ const fr: Translations = {
'Property type': 'Type de bien',
'Leasehold/Freehold': 'Bail/Pleine propriété',
'Last known price': 'Dernier prix connu',
'Estimated price': 'Prix estimé',
'Estimated current price': 'Prix actuel estimé',
'Price per sqm': 'Prix au m²',
'Est. price per sqm': 'Prix estimé au m²',
@ -818,6 +823,8 @@ const fr: Translations = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Distance à la gare ou station de métro la plus proche (km)',
'Travel time to nearest train or tube station (min)':
'Temps de trajet jusquà la gare ou station de métro la plus proche (min)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Écoles primaires Bien+ dans un rayon de 2 km',

View file

@ -323,6 +323,10 @@ const hi: Translations = {
seeTheDifference: 'देखें यह कैसे काम करता है',
showcaseHeader: 'यह कैसे काम करता है',
showcaseContext: 'Perfect Postcode कैसे काम करता है',
showcaseFeaturePriceShort: 'कीमत',
showcaseFeatureNoiseShort: 'शोर',
showcaseFeatureSchoolsShort: 'स्कूल',
showcaseFeatureTravelShort: 'यात्रा',
showcaseStep1Tab: 'फिल्टर',
showcaseStep1Title: 'अस्पष्ट जरूरतों को सटीक खोज में बदलें',
showcaseStep1Body:
@ -738,6 +742,7 @@ const hi: Translations = {
'Property type': 'संपत्ति प्रकार',
'Leasehold/Freehold': 'लीजहोल्ड/फ्रीहोल्ड',
'Last known price': 'अंतिम ज्ञात कीमत',
'Estimated price': 'अनुमानित कीमत',
'Estimated current price': 'अनुमानित मौजूदा कीमत',
'Price per sqm': 'प्रति वर्ग मी कीमत',
'Est. price per sqm': 'अनु. प्रति वर्ग मी कीमत',
@ -751,6 +756,8 @@ const hi: Translations = {
'Potential energy rating': 'संभावित ऊर्जा रेटिंग',
'Interior height (m)': 'भीतरी ऊंचाई (मी)',
'Distance to nearest train or tube station (km)': 'निकटतम ट्रेन या ट्यूब स्टेशन तक दूरी (किमी)',
'Travel time to nearest train or tube station (min)':
'निकटतम ट्रेन या ट्यूब स्टेशन तक यात्रा समय (मिनट)',
'Good+ primary schools within 2km': '2 किमी के अंदर Good+ प्राथमिक स्कूल',
'Good+ secondary schools within 2km': '2 किमी के अंदर Good+ सेकेंडरी स्कूल',
'Good+ primary schools within 5km': '5 किमी के अंदर Good+ प्राथमिक स्कूल',

View file

@ -345,6 +345,10 @@ const hu: Translations = {
seeTheDifference: 'Így működik',
showcaseHeader: 'Így működik',
showcaseContext: 'Így működik a Perfect Postcode',
showcaseFeaturePriceShort: 'Ár',
showcaseFeatureNoiseShort: 'Zaj',
showcaseFeatureSchoolsShort: 'Iskolák',
showcaseFeatureTravelShort: 'Utazás',
showcaseStep1Tab: 'Szűrés',
showcaseStep1Title: 'A homályos igényekből pontos keresés lesz',
showcaseStep1Body:
@ -796,6 +800,7 @@ const hu: Translations = {
'Property type': 'Ingatlantípus',
'Leasehold/Freehold': 'Bérleti/Tulajdonjog',
'Last known price': 'Utolsó ismert ár',
'Estimated price': 'Becsült ár',
'Estimated current price': 'Becsült jelenlegi ár',
'Price per sqm': 'Ár per nm',
'Est. price per sqm': 'Becsült ár per nm',
@ -812,6 +817,8 @@ const hu: Translations = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)':
'Távolság a legközelebbi vonat- vagy metróállomástól (km)',
'Travel time to nearest train or tube station (min)':
'Utazási idő a legközelebbi vonat- vagy metróállomásig (perc)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': 'Jó+ általános iskolák 2 km-en belül',

View file

@ -340,6 +340,10 @@ const zh: Translations = {
seeTheDifference: '查看使用方式',
showcaseHeader: '工作原理',
showcaseContext: 'Perfect Postcode 的工作流程',
showcaseFeaturePriceShort: '价格',
showcaseFeatureNoiseShort: '噪声',
showcaseFeatureSchoolsShort: '学校',
showcaseFeatureTravelShort: '出行',
showcaseStep1Tab: '筛选',
showcaseStep1Title: '把模糊需求变成精准搜索',
showcaseStep1Body:
@ -772,6 +776,7 @@ const zh: Translations = {
'Property type': '房产类型',
'Leasehold/Freehold': '租赁产权/永久产权',
'Last known price': '上次成交价',
'Estimated price': '估计价格',
'Estimated current price': '估计当前价格',
'Price per sqm': '每平方米价格',
'Est. price per sqm': '估计每平方米价格',
@ -787,6 +792,7 @@ const zh: Translations = {
// ─ Feature names (Transport) ─
'Distance to nearest train or tube station (km)': '到最近火车或地铁站的距离(公里)',
'Travel time to nearest train or tube station (min)': '到最近火车或地铁站的出行时间(分钟)',
// ─ Feature names (Education) ─
'Good+ primary schools within 2km': '2公里内良好+小学数量',

View file

@ -135,10 +135,42 @@ h3 {
.showcase-progress {
animation-name: showcase-progress;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
.hero-roomy-lift {
transform: translateY(0);
transition: transform 0.25s ease;
}
.hero-scroll-chevron {
display: none;
bottom: 0.75rem;
width: 5rem;
height: 5rem;
}
@media (min-width: 1024px) and (min-height: 900px) {
.hero-roomy-lift {
transform: translateY(-2.5rem);
}
.hero-scroll-chevron {
display: flex;
}
}
@media (min-width: 1280px) and (min-height: 1040px) {
.hero-roomy-lift {
transform: translateY(-3.5rem);
}
.hero-scroll-chevron {
bottom: 0.9rem;
}
}
@keyframes scout-export-click {
0%,
52%,

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { canWheelScrollInsideTarget } from './dom-scroll';
function setElementMetrics(
element: HTMLElement,
metrics: Partial<{
clientHeight: number;
clientWidth: number;
scrollHeight: number;
scrollWidth: number;
}>
) {
for (const [key, value] of Object.entries(metrics)) {
Object.defineProperty(element, key, { configurable: true, value });
}
}
describe('canWheelScrollInsideTarget', () => {
it('allows horizontal wheel gestures inside a horizontal scroller', () => {
const scroller = document.createElement('div');
scroller.style.overflowX = 'auto';
scroller.scrollLeft = 20;
setElementMetrics(scroller, { clientWidth: 100, scrollWidth: 240 });
const child = document.createElement('button');
scroller.appendChild(child);
document.body.appendChild(scroller);
expect(canWheelScrollInsideTarget(child, 40, 0)).toBe(true);
scroller.remove();
});
it('allows vertical wheel gestures inside a vertical scroller', () => {
const scroller = document.createElement('div');
scroller.style.overflowY = 'auto';
scroller.scrollTop = 20;
setElementMetrics(scroller, { clientHeight: 100, scrollHeight: 240 });
const child = document.createElement('button');
scroller.appendChild(child);
document.body.appendChild(scroller);
expect(canWheelScrollInsideTarget(child, 60, 20)).toBe(true);
scroller.remove();
});
it('does not allow horizontal gestures at the edge of a scroller', () => {
const scroller = document.createElement('div');
scroller.style.overflowX = 'auto';
scroller.scrollLeft = 0;
setElementMetrics(scroller, { clientWidth: 100, scrollWidth: 240 });
document.body.appendChild(scroller);
expect(canWheelScrollInsideTarget(scroller, -40, 0)).toBe(false);
scroller.remove();
});
});

View file

@ -0,0 +1,45 @@
const SCROLLABLE_OVERFLOW = new Set(['auto', 'scroll', 'overlay']);
function canScrollHorizontally(element: HTMLElement, deltaX: number): boolean {
if (deltaX === 0) return false;
const style = window.getComputedStyle(element);
if (!SCROLLABLE_OVERFLOW.has(style.overflowX)) return false;
if (element.scrollWidth <= element.clientWidth) return false;
const maxScrollLeft = element.scrollWidth - element.clientWidth;
if (deltaX < 0) return element.scrollLeft > 0;
return element.scrollLeft < maxScrollLeft - 1;
}
function canScrollVertically(element: HTMLElement, deltaY: number): boolean {
if (deltaY === 0) return false;
const style = window.getComputedStyle(element);
if (!SCROLLABLE_OVERFLOW.has(style.overflowY)) return false;
if (element.scrollHeight <= element.clientHeight) return false;
const maxScrollTop = element.scrollHeight - element.clientHeight;
if (deltaY < 0) return element.scrollTop > 0;
return element.scrollTop < maxScrollTop - 1;
}
export function canWheelScrollInsideTarget(
target: EventTarget | null,
deltaX: number,
deltaY: number
): boolean {
let element = target instanceof Element ? target : null;
while (element && element !== document.body && element !== document.documentElement) {
if (
element instanceof HTMLElement &&
(canScrollHorizontally(element, deltaX) || canScrollVertically(element, deltaY))
) {
return true;
}
element = element.parentElement;
}
return false;
}

View file

@ -19,6 +19,12 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<polyline points="15 6 21 6 21 12" />
</>
),
'Estimated price': (
<>
<polyline points="4 16 8 12 13 15 20 6" />
<polyline points="15 6 21 6 21 12" />
</>
),
'Price per sqm': (
<>
<rect x="3" y="3" width="7" height="7" />
@ -109,6 +115,18 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<line x1="12" y1="18" x2="12" y2="15" />
</>
),
'Travel time to nearest train or tube station (min)': (
<>
<rect x="5" y="3" width="14" height="10" rx="2" />
<path d="M8 17h8" />
<path d="M9 13l-2 4" />
<path d="M15 13l2 4" />
<circle cx="8.5" cy="8" r="1" fill="currentColor" />
<circle cx="15.5" cy="8" r="1" fill="currentColor" />
<path d="M12 18v3" />
<circle cx="12" cy="21" r="1.5" />
</>
),
// ── Education ────────────────────────────────
'Education, Skills and Training Score': (