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">
|
||||
|
|
|
|||
|
|
@ -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_') ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
11
frontend/src/components/ui/icons/PlayIcon.tsx
Normal file
11
frontend/src/components/ui/icons/PlayIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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+ प्राथमिक स्कूल',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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公里内良好+小学数量',
|
||||
|
|
|
|||
|
|
@ -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%,
|
||||
|
|
|
|||
58
frontend/src/lib/dom-scroll.test.ts
Normal file
58
frontend/src/lib/dom-scroll.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
45
frontend/src/lib/dom-scroll.ts
Normal file
45
frontend/src/lib/dom-scroll.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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': (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue