lgtm
This commit is contained in:
parent
11711c57e6
commit
81a16f543c
21 changed files with 29072 additions and 1913 deletions
|
|
@ -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">
|
||||
✓
|
||||
{[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">✓</span>
|
||||
<span className="sr-only">Yes</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue