This commit is contained in:
Andras Schmelczer 2026-05-12 22:13:07 +01:00
parent 11711c57e6
commit 81a16f543c
21 changed files with 29072 additions and 1913 deletions

View file

@ -16,10 +16,19 @@ const HOME_SECTION_HEADING_CLASS =
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
const HOME_PRIMARY_BUTTON_CLASS =
'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center';
const PRODUCT_DEMO_VIDEO_SRC = '/video/recording.mp4';
const PRODUCT_DEMO_POSTER_SRC = '/video/recording.jpg';
const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = {
en: 'recording',
de: 'recording-de',
zh: 'recording-zh',
hi: 'recording-hi',
};
const PRODUCT_DEMO_SECTION_ID = 'product-demo-video';
function getProductDemoSlug(language: string | undefined): string {
const code = language?.toLowerCase().split('-')[0] ?? 'en';
return PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
}
function highlightBrandText(text: string) {
const parts = text.split(BRAND_NAME);
if (parts.length === 1) return text;
@ -37,11 +46,26 @@ function highlightBrandText(text: string) {
}
function ProductDemoVideo() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const sectionRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const currentVideoSrcRef = useRef<string | null>(null);
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
const productDemoSlug = getProductDemoSlug(i18n.language);
const productDemoVideoSrc = `/video/${productDemoSlug}.mp4`;
const productDemoPosterSrc = `/video/${productDemoSlug}.jpg`;
useEffect(() => {
if (currentVideoSrcRef.current === productDemoVideoSrc) return;
currentVideoSrcRef.current = productDemoVideoSrc;
setIsVideoPlaying(false);
const video = videoRef.current;
if (!video || !shouldLoadVideo) return;
video.pause();
video.load();
}, [productDemoVideoSrc, shouldLoadVideo]);
useEffect(() => {
const section = sectionRef.current;
@ -70,8 +94,8 @@ function ProductDemoVideo() {
setShouldLoadVideo(true);
if (!video) return;
if (!video.getAttribute('src')) {
video.src = PRODUCT_DEMO_VIDEO_SRC;
if (video.getAttribute('src') !== productDemoVideoSrc) {
video.src = productDemoVideoSrc;
video.load();
}
@ -86,11 +110,14 @@ function ProductDemoVideo() {
ref={sectionRef}
className={`${HOME_SECTION_CONTAINER_CLASS} pt-8 md:pt-12 pb-2`}
>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-5 text-center`}>
{t('home.productDemoLabel')}
</h2>
<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}
src={shouldLoadVideo ? productDemoVideoSrc : undefined}
poster={productDemoPosterSrc}
controls
playsInline
preload={shouldLoadVideo ? 'metadata' : 'none'}
@ -349,6 +376,40 @@ export default function HomePage({
</div>
</div>
{/* Street-level detail */}
<div className={`${HOME_SECTION_CONTAINER_CLASS} pt-10 md:pt-16 pb-2`}>
<div className="grid gap-6 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:items-start">
<div>
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-4`}>{t('home.streetTitle')}</h2>
<p className={`${HOME_BODY_CLASS} max-w-2xl`}>{t('home.streetIntro')}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{[
{
title: t('home.streetCard1Title'),
body: t('home.streetCard1Body'),
},
{
title: t('home.streetCard2Title'),
body: t('home.streetCard2Body'),
},
].map((item) => (
<div
key={item.title}
className="rounded-lg border border-warm-200 bg-white/90 p-5 shadow-sm dark:border-warm-700 dark:bg-warm-800/90"
>
<h3 className="text-base font-bold text-navy-950 dark:text-warm-100">
{item.title}
</h3>
<p className="mt-3 text-sm leading-relaxed text-warm-600 dark:text-warm-400">
{item.body}
</p>
</div>
))}
</div>
</div>
</div>
{/* Comparison table */}
<div
id="how-it-works"
@ -424,16 +485,25 @@ export default function HomePage({
</div>
)}
</td>
{[row.postcode, row.guides].map((has, j) => (
<td
key={j}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base ${has ? 'text-green-500' : 'text-red-500'}`}
>
{has ? '\u2713' : '\u2717'}
</td>
))}
<td className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30">
&#x2713;
{[row.postcode, row.guides].map((has, j) => {
const statusLabel = has ? 'Yes' : 'No';
return (
<td
key={j}
aria-label={statusLabel}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base ${has ? 'text-green-500' : 'text-red-500'}`}
>
<span aria-hidden="true">{has ? '\u2713' : '\u2717'}</span>
<span className="sr-only">{statusLabel}</span>
</td>
);
})}
<td
aria-label="Yes"
className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30"
>
<span aria-hidden="true">&#x2713;</span>
<span className="sr-only">Yes</span>
</td>
</tr>
))}

View file

@ -626,6 +626,9 @@ function EnglandHexMapScreen({ isActive }: { isActive: boolean }) {
value: SHOWCASE_MAP_TOTAL_COUNT.toLocaleString(),
})}
</div>
<div className="mt-2 text-[10px] font-bold uppercase tracking-wide text-warm-400">
{t('home.showcaseStep2Sources')}
</div>
</div>
</div>
);

View file

@ -91,11 +91,13 @@ export default function FeatureBrowser({
search.toLowerCase()
));
// Ensure "Transport" group exists first when travel modes should be shown.
// Keep "Transport" first because journey and transport proximity controls belong together.
const mergedGrouped = useMemo(() => {
if (!showTravelModes) return grouped;
if (grouped.some((g) => g.name === 'Transport')) return grouped;
return [{ name: 'Transport', features: [] }, ...grouped];
const transportGroup = grouped.find((g) => g.name === 'Transport');
const otherGroups = grouped.filter((g) => g.name !== 'Transport');
if (transportGroup) return [transportGroup, ...otherGroups];
if (showTravelModes) return [{ name: 'Transport', features: [] }, ...otherGroups];
return otherGroups;
}, [grouped, showTravelModes]);
return (

View file

@ -81,6 +81,7 @@ export function SliderLabels({
isAtMax,
raw,
feature,
showUnit,
onValueChange,
}: {
min: number;
@ -91,6 +92,7 @@ export function SliderLabels({
isAtMax?: boolean;
raw?: boolean;
feature?: FeatureMeta;
showUnit?: boolean;
onValueChange?: (v: [number, number]) => void;
}) {
const { t } = useTranslation();
@ -98,7 +100,10 @@ export function SliderLabels({
const leftPct = Math.max(0, Math.min(100, ((value[0] - min) / range) * 100));
const rightPct = Math.max(0, Math.min(100, ((value[1] - min) / range) * 100));
const labels = displayValues || value;
const labelFormat = feature?.suffix === '%' ? { raw, suffix: feature.suffix } : raw;
const shouldShowUnit = Boolean(feature && (showUnit || feature.suffix === '%'));
const labelFormat = shouldShowUnit
? { raw, prefix: feature?.prefix, suffix: feature?.suffix }
: raw;
const minLabel = isAtMin ? t('common.min') : formatFilterValue(labels[0], labelFormat);
const maxLabel = isAtMax ? t('common.max') : formatFilterValue(labels[1], labelFormat);