This commit is contained in:
Andras Schmelczer 2026-06-10 22:25:15 +01:00
parent 1241132095
commit 54a5c3ca9a
28 changed files with 826 additions and 422 deletions

View file

@ -8,6 +8,7 @@ import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
const BRAND_NAME = 'Perfect Postcode';
const BRAND_TEXT_CLASS = 'text-teal-600 dark:text-teal-400';
@ -163,11 +164,78 @@ function ProductDemoVideo() {
);
}
interface PriceStripTier {
up_to: number | null;
price_pence: number;
}
/**
* Compact pricing teaser under the hero CTAs: surfaces the current lifetime
* price and tier scarcity that otherwise hide behind the Pricing nav link.
*/
function PriceStrip({
onOpenPricing,
hidePricing,
}: {
onOpenPricing: () => void;
hidePricing?: boolean;
}) {
const { t } = useTranslation();
const [pricing, setPricing] = useState<{
licensed_count: number;
current_price_pence: number;
tiers: PriceStripTier[];
} | null>(null);
useEffect(() => {
if (hidePricing) return;
const controller = new AbortController();
fetch(apiUrl('pricing'), { signal: controller.signal })
.then((res) => (res.ok ? res.json() : null))
.then(setPricing)
.catch(() => {});
return () => controller.abort();
}, [hidePricing]);
if (hidePricing || !pricing) return null;
const price = `£${pricing.current_price_pence / 100}`;
const currentTier = pricing.tiers.find(
(tier) => tier.up_to === null || pricing.licensed_count < tier.up_to
);
const spotsRemaining =
currentTier?.up_to != null ? currentTier.up_to - pricing.licensed_count : 0;
return (
<p className="text-sm text-warm-300 mb-2">
{pricing.current_price_pence === 0
? t('upgrade.freeForEarly')
: t('home.priceStrip', { price })}{' '}
{pricing.current_price_pence > 0 && spotsRemaining > 0 && (
<span className="font-semibold text-teal-300">
{spotsRemaining === 1
? t('home.priceStripSpots', { count: spotsRemaining })
: t('home.priceStripSpotsPlural', { count: spotsRemaining })}
</span>
)}{' '}
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'price_strip' });
onOpenPricing();
}}
className="underline decoration-dotted underline-offset-2 text-teal-300 hover:text-teal-200"
>
{t('home.priceStripCta')}
</button>
</p>
);
}
export default function HomePage({
onOpenDashboard,
onOpenPricing: _onOpenPricing,
onOpenPricing,
theme = 'light',
hidePricing: _hidePricing,
hidePricing,
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
@ -327,7 +395,7 @@ export default function HomePage({
<p className="text-base md:text-lg text-warm-200 mb-8 max-w-xl">
{highlightBrandText(t('home.heroDescription'), 'font-semibold text-teal-300')}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-10">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 mb-5">
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
@ -347,6 +415,8 @@ export default function HomePage({
{t('home.seeTheDifference')}
</button>
</div>
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
<p className="text-sm text-warm-400 mb-8">{t('home.coverageNote')}</p>
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
<div className="home-hero-stat">
<div className="home-hero-stat-value">
@ -356,7 +426,7 @@ export default function HomePage({
</div>
<div className="home-hero-stat">
<div className="home-hero-stat-value">
<TickerValue text="56" active={statsActive} />
<TickerValue text="40+" active={statsActive} />
</div>
<div className="home-hero-stat-label">{t('home.statFilters')}</div>
</div>

View file

@ -82,7 +82,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
step: 1,
},
{
name: 'Good+ primary schools within 2km',
name: 'Good+ primary school catchments',
type: 'numeric',
group: 'Schools',
min: 0,
@ -300,7 +300,7 @@ function interpolateViewState(progress: number): ViewState {
function demoSliderStep(feature: FeatureMeta): number {
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 === 'Good+ primary school catchments') return 0.01;
if (feature.name === 'Travel time to nearest train or tube station (min)') return 0.1;
return feature.step ?? 1;
}
@ -350,7 +350,7 @@ function FilterPreviewRow({
const shortLabelKeys = {
'Estimated price': 'home.showcaseFeaturePriceShort',
'Noise (dB)': 'home.showcaseFeatureNoiseShort',
'Good+ primary schools within 2km': 'home.showcaseFeatureSchoolsShort',
'Good+ primary school catchments': 'home.showcaseFeatureSchoolsShort',
'Travel time to nearest train or tube station (min)': 'home.showcaseFeatureTravelShort',
} as const;
const shortLabelKey = shortLabelKeys[feature.name as keyof typeof shortLabelKeys];
@ -406,7 +406,7 @@ function formatDemoRange(feature: FeatureMeta, value: [number, number], t: TFunc
if (feature.name === 'Noise (dB)') {
return `${Math.round(value[0])} - ${Math.round(value[1])} dB`;
}
if (feature.name === 'Good+ primary schools within 2km') {
if (feature.name === 'Good+ primary school catchments') {
return t('home.showcaseGoodPrimariesNearby', { count: Math.round(value[0]) });
}
if (feature.name === 'Travel time to nearest train or tube station (min)') {

View file

@ -3,9 +3,52 @@ import { useTranslation } from 'react-i18next';
import { tDynamic } from '../../i18n';
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { PlayIcon } from '../ui/icons';
import { SubNav } from '../ui/SubNav';
import Footer from '../ui/Footer';
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'videos' | 'support';
// Social-media ad cuts rendered by the recorder pipeline (video/src/storyboard.ts).
// Each `<slug>.mp4` is a 9:16 clip with a matching `<slug>.jpg` poster in /public/video.
const SOCIAL_VIDEOS: { slug: string; titleKey: string; descKey: string }[] = [
{ slug: 'ad-01-say-it', titleKey: 'learnPage.video01Title', descKey: 'learnPage.video01Desc' },
{
slug: 'ad-02-twenty-minute-map',
titleKey: 'learnPage.video02Title',
descKey: 'learnPage.video02Desc',
},
{
slug: 'ad-03-postcode-files',
titleKey: 'learnPage.video03Title',
descKey: 'learnPage.video03Desc',
},
{
slug: 'ad-04-quiet-streets',
titleKey: 'learnPage.video04Title',
descKey: 'learnPage.video04Desc',
},
{
slug: 'ad-05-school-run',
titleKey: 'learnPage.video05Title',
descKey: 'learnPage.video05Desc',
},
{
slug: 'ad-06-waitrose-test',
titleKey: 'learnPage.video06Title',
descKey: 'learnPage.video06Desc',
},
{
slug: 'ad-07-renters-map',
titleKey: 'learnPage.video07Title',
descKey: 'learnPage.video07Desc',
},
{
slug: 'ad-08-cheap-insurance',
titleKey: 'learnPage.video08Title',
descKey: 'learnPage.video08Desc',
},
];
interface DataSourceDef {
id: string;
@ -176,6 +219,68 @@ function FAQItemCard({ question, answer }: { question: string; answer: string })
);
}
function SocialVideoCard({
slug,
title,
description,
}: {
slug: string;
title: string;
description: string;
}) {
const { t } = useTranslation();
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const videoSrc = `/video/${slug}.mp4`;
const posterSrc = `/video/${slug}.jpg`;
const playVideo = () => {
const video = videoRef.current;
setIsLoaded(true);
if (!video) return;
if (video.getAttribute('src') !== videoSrc) {
video.src = videoSrc;
video.load();
}
void video.play().catch(() => setIsPlaying(false));
};
return (
<div className="flex flex-col">
<div className="relative overflow-hidden rounded-xl border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
<video
ref={videoRef}
src={isLoaded ? videoSrc : undefined}
poster={posterSrc}
controls={isPlaying}
playsInline
preload="none"
className="block aspect-[9/16] w-full bg-navy-950 object-contain"
aria-label={title}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
{!isPlaying && (
<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-16 w-16 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"
aria-label={t('home.playProductDemo')}
>
<PlayIcon className="h-8 w-8 -translate-x-0.5" />
</button>
</div>
)}
</div>
<h2 className="mt-3 text-base font-bold text-warm-900 dark:text-warm-100">{title}</h2>
<p className="mt-1 text-sm leading-relaxed text-warm-600 dark:text-warm-300">{description}</p>
</div>
);
}
export default function LearnPage() {
const { t, i18n } = useTranslation();
const [tab, setTab] = useState<LearnTab>('faq');
@ -197,6 +302,7 @@ export default function LearnPage() {
{ key: 'faq', label: t('learnPage.faq') },
{ key: 'data-sources', label: t('learnPage.dataSources') },
{ key: 'articles', label: t('learnPage.articles') },
{ key: 'videos', label: t('learnPage.videos') },
{ key: 'support', label: t('learnPage.support') },
];
@ -268,6 +374,9 @@ export default function LearnPage() {
} else if (hash === 'articles') {
setTab('articles');
setHighlightedId(null);
} else if (hash === 'videos') {
setTab('videos');
setHighlightedId(null);
} else if (hash === 'support') {
setTab('support');
setHighlightedId(null);
@ -300,141 +409,163 @@ export default function LearnPage() {
<SubNav tabs={LEARN_TABS} activeTab={tab} onTabChange={switchTab} />
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
{tab === 'data-sources' ? (
<>
<div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-6">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.dataSources')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCE_DEFS.map((source) => {
const keys = DS_KEYS[source.id];
const [nameKey, originKey, useKey] = keys;
return (
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license}
</span>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
</div>
);
})}
</div>
</div>
</div>
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
{t('learnPage.attribution')}
</h2>
<ul className="space-y-1.5 text-sm">
<li>{t('learnPage.attrLandRegistry')}</li>
<li>
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
</li>
<li>{t('learnPage.attrOs')}</li>
<li>{t('learnPage.attrTfl')}</li>
<li>
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
</li>
</ul>
</div>
</footer>
</>
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.faq')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
<div className="space-y-8">
{FAQ_SECTIONS.map((section) => (
<div key={section.title}>
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
{section.title}
</h3>
<div className="space-y-3">
{section.items.map((item, index) => (
<FAQItemCard key={index} question={item.question} answer={item.answer} />
))}
</div>
</div>
))}
</div>
</div>
) : tab === 'articles' ? (
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.articles')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.articlesIntro')}</p>
<div className="grid gap-4 md:grid-cols-2">
{seoPageLinks.map((link) => (
<a
key={link.path}
href={link.path}
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
>
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
{link.eyebrow}
</div>
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
{link.title}
</h2>
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
{link.description}
<div className="flex flex-1 flex-col">
{tab === 'data-sources' ? (
<>
<div className="flex-1">
<div className="max-w-5xl mx-auto px-6 py-6">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.dataSources')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.dataSourcesIntro', { count: DATA_SOURCE_DEFS.length })}
</p>
</a>
))}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{DATA_SOURCE_DEFS.map((source) => {
const keys = DS_KEYS[source.id];
const [nameKey, originKey, useKey] = keys;
return (
<div
key={source.id}
id={source.id}
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">
{tDynamic(nameKey)}
</h2>
<span className="max-w-44 text-left text-xs leading-snug bg-warm-100 dark:bg-navy-700 text-warm-600 dark:text-warm-300 px-2 py-1 rounded">
{source.license}
</span>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-2">
{t('learnPage.source')} {tDynamic(originKey)}
</p>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-3">
{tDynamic(useKey)}
</p>
</div>
);
})}
</div>
</div>
</div>
<footer className="bg-navy-900 text-warm-400 px-6 py-6">
<div className="max-w-5xl mx-auto">
<h2 className="text-sm font-semibold text-warm-300 uppercase tracking-wide mb-3">
{t('learnPage.attribution')}
</h2>
<ul className="space-y-1.5 text-sm">
<li>{t('learnPage.attrLandRegistry')}</li>
<li>
{t('learnPage.attrOgl')} {t('learnPage.attrOglLink')}.
</li>
<li>{t('learnPage.attrOs')}</li>
<li>{t('learnPage.attrTfl')}</li>
<li>
{t('learnPage.attrOsm')} {t('learnPage.attrOsmContrib')},{' '}
{t('learnPage.attrOsmLicense')} {t('learnPage.attrOsmLicenseLink')}.
</li>
</ul>
</div>
</footer>
</>
) : tab === 'faq' ? (
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.faq')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.faqIntro')}</p>
<div className="space-y-8">
{FAQ_SECTIONS.map((section) => (
<div key={section.title}>
<h3 className="text-sm font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
{section.title}
</h3>
<div className="space-y-3">
{section.items.map((item, index) => (
<FAQItemCard key={index} question={item.question} answer={item.answer} />
))}
</div>
</div>
))}
</div>
</div>
</div>
) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.support')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
<a
href="mailto:support@perfect-postcode.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@perfect-postcode.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
{t('accountPage.responseTime')}
) : tab === 'articles' ? (
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.articles')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">
{t('learnPage.articlesIntro')}
</p>
<div className="grid gap-4 md:grid-cols-2">
{seoPageLinks.map((link) => (
<a
key={link.path}
href={link.path}
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
>
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
{link.eyebrow}
</div>
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
{link.title}
</h2>
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
{link.description}
</p>
</a>
))}
</div>
</div>
</div>
)}
) : tab === 'videos' ? (
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.videosTitle')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.videosIntro')}</p>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
{SOCIAL_VIDEOS.map((video) => (
<SocialVideoCard
key={video.slug}
slug={video.slug}
title={tDynamic(video.titleKey)}
description={tDynamic(video.descKey)}
/>
))}
</div>
</div>
) : (
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
{t('learnPage.support')}
</h1>
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.supportIntro')}</p>
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
<a
href="mailto:support@perfect-postcode.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@perfect-postcode.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
{t('accountPage.responseTime')}
</p>
</div>
</div>
)}
</div>
<Footer />
</div>
</div>
);

View file

@ -1172,10 +1172,7 @@ export default memo(function Map({
? [postcodeCountRange.min, postcodeCountRange.max]
: [countRange.min, countRange.max]
}
totalCount={
totalCountProp ??
(usePostcodeView ? postcodeCountRange.total : countRange.total)
}
totalCount={totalCountProp}
showCancel={false}
onCancel={onCancelPin}
mode="density"

View file

@ -26,6 +26,7 @@ import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_ZOOM_THRESHOLD } from '../../lib/consts';
import { boundsToCenterZoom } from '../../lib/fit-bounds';
import type { OverlayId } from '../../lib/overlays';
import { CRIME_TYPE_VALUES } from '../../lib/crime-types';
import type { BasemapId } from '../../lib/basemaps';
@ -257,6 +258,14 @@ export default function MapPage({
}))
);
// Move the camera to where the matches actually are — flying to the
// travel-time anchor often lands on a viewport with zero matches.
if (result.matchBounds) {
const target = boundsToCenterZoom(result.matchBounds);
mapFlyToRef.current?.(target.lat, target.lng, target.zoom, getMobileMapFlyToOptions());
return;
}
const firstTravelTime = representable[0]?.tt;
if (!firstTravelTime?.slug) return;
@ -482,7 +491,15 @@ export default function MapPage({
}, []);
const shareReturnViewRef = useRef(shareCode ? initialViewState : null);
// Hide the upgrade modal as soon as the user dismisses it. We can't rely on
// the camera fly alone to close it: flying back to the free/shared zone only
// clears `licenseRequired` once the resulting refetch returns non-403, and
// when the fly target equals the current view (e.g. "back to shared area"
// while already at the shared view) `jumpTo` is a no-op, so no refetch fires
// and the modal would otherwise stay stuck open.
const [upgradeModalDismissed, setUpgradeModalDismissed] = useState(false);
const handleZoomToFreeZone = useCallback(() => {
setUpgradeModalDismissed(true);
const target = shareReturnViewRef.current ?? INITIAL_VIEW_STATE;
mapFlyToRef.current?.(target.latitude, target.longitude, target.zoom);
}, []);
@ -576,7 +593,6 @@ export default function MapPage({
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
const mobileLegendMeta = useMobileLegendMeta(viewFeature, features);
const mapViewFeature = useMapViewFeature(viewFeature);
const mobileDensityRange = useMobileDensityRange(mapData);
@ -661,7 +677,13 @@ export default function MapPage({
}, [onDashboardReadyChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
if (mapData.licenseRequired) {
trackEvent('Upgrade Modal Shown');
} else {
// Back in a viewable area — re-arm so the modal shows again the next time
// the user pans into a gated area.
setUpgradeModalDismissed(false);
}
}, [mapData.licenseRequired]);
if (screenshotMode) {
@ -856,22 +878,25 @@ export default function MapPage({
</div>
) : null;
const upgradeModal = mapData.licenseRequired ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={() =>
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
}
onRegisterClick={() =>
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
) : null;
const upgradeModal =
mapData.licenseRequired && !upgradeModalDismissed ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={() =>
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
}
onRegisterClick={() =>
onCheckoutRegisterClick
? onCheckoutRegisterClick(checkoutReturnPath)
: onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>
</Suspense>
) : null;
if (isMobile) {
return (
@ -980,7 +1005,7 @@ export default function MapPage({
onToggleActualListings={canSeeListings ? handleToggleActualListings : undefined}
travelTimeEntries={entries}
densityLabel={densityLabel}
totalCount={hasActiveFilters ? filterCounts.total : undefined}
totalCount={filterCounts.total ?? undefined}
poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane}
poiPane={renderPOIPane()}

View file

@ -6,11 +6,16 @@ interface PriceHistoryChartProps {
points: PricePoint[];
}
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
const HEIGHT = 120;
const PRICE_SCALE_TOP_PERCENTILE = 95;
const priceFmt = { prefix: '£' };
/** Ticks are nice round values; "£800.0k" both clips and reads worse than "£800k". */
function formatTick(tick: number): string {
return formatValue(tick, priceFmt).replace(/\.0(?=[kM])/, '');
}
interface PriceScale {
min: number;
max: number;
@ -148,7 +153,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
className="fill-warm-500 dark:fill-warm-400"
fontSize={10}
>
{formatValue(tick, priceFmt)}
{formatTick(tick)}
</text>
))}

View file

@ -9,7 +9,9 @@ import { TravelTimeInfoPopup } from '../ui/TravelTimeInfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { InfoIcon } from '../ui/icons/InfoIcon';
import { formatFilterValue, formatNumber } from '../../lib/format';
import { SliderLabels } from './filters/SliderLabels';
import { formatNumber } from '../../lib/format';
import type { FeatureMeta } from '../../types';
import { useTravelDestinations } from '../../hooks/useTravelDestinations';
import {
MAX_TRAVEL_MINUTES,
@ -56,7 +58,7 @@ export function TravelTimeCard({
dragValue,
onTogglePin,
onSetDestination,
onTimeRangeChange: _onTimeRangeChange,
onTimeRangeChange,
onDragStart,
onDragChange,
onDragEnd,
@ -86,6 +88,17 @@ export function TravelTimeCard({
const sliderMax = MAX_TRAVEL_MINUTES;
const displayRange = isActive && dragValue ? dragValue : (timeRange ?? [sliderMin, sliderMax]);
// Synthetic feature so the time labels reuse the shared SliderLabels (matching
// every other filter card) — editable, thumb-following, with the minute unit.
const travelFeature: FeatureMeta = {
name: 'travelTime',
type: 'numeric',
min: sliderMin,
max: sliderMax,
suffix: ` ${t('common.minute')}`,
raw: true,
};
const ModeIcon = MODE_ICONS[mode];
return (
@ -211,14 +224,17 @@ export function TravelTimeCard({
onPointerDown={() => onDragStart(displayRange)}
onPointerUp={() => onDragEnd()}
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} {t('common.minute')}
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} {t('common.minute')}
</span>
</div>
<SliderLabels
min={sliderMin}
max={sliderMax}
value={[displayRange[0], displayRange[1]]}
isAtMin={displayRange[0] <= sliderMin}
isAtMax={displayRange[1] >= sliderMax}
raw
showUnit
feature={travelFeature}
onValueChange={onTimeRangeChange}
/>
{filterImpact != null && filterImpact > 0 && (
<p className="text-[10px] text-warm-400 dark:text-warm-500 -mt-1 ml-2.5">
{t('filters.filtersOut', { value: formatNumber(filterImpact) })}

View file

@ -11,7 +11,6 @@ import {
getSchoolFilterConfig,
getSchoolFilterMeta,
replaceSchoolFilterKeySelection,
type SchoolDistance,
type SchoolPhase,
type SchoolRating,
} from '../../../lib/school-filter';
@ -74,7 +73,6 @@ export function SchoolFilterCard({
next: Partial<{
phase: SchoolPhase;
rating: SchoolRating;
distance: SchoolDistance;
}>
) => {
const nextName = replaceSchoolFilterKeySelection(schoolFeature.name, next);
@ -180,35 +178,6 @@ export function SchoolFilterCard({
</button>
</div>
</div>
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.distance')}
</div>
<div
className={segmentedClass}
role="radiogroup"
aria-label={t('filters.schoolDistance')}
>
<button
type="button"
role="radio"
aria-checked={config.distance === 2}
onClick={() => replaceSchoolFeature({ distance: 2 })}
className={optionClass(config.distance === 2)}
>
2 km
</button>
<button
type="button"
role="radio"
aria-checked={config.distance === 5}
onClick={() => replaceSchoolFeature({ distance: 5 })}
className={optionClass(config.distance === 5)}
>
5 km
</button>
</div>
</div>
</div>
<Slider

View file

@ -8,6 +8,7 @@ import { logNonAbortError } from '../../lib/api';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
import HexCanvas from '../home/HexCanvas';
import { useIsDarkTheme } from '../../hooks/useIsDarkTheme';
// Feature list keys — resolved inside the component via t()
@ -39,6 +40,7 @@ export default function PricingPage({
onRegisterClick?: () => void;
}) {
const { t } = useTranslation();
const isDark = useIsDarkTheme();
const license = useLicense();
const [pricing, setPricing] = useState<PricingData | null>(null);
const [loading, setLoading] = useState(true);
@ -137,17 +139,23 @@ export default function PricingPage({
) : null;
return (
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="fixed inset-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-navy-950 via-navy-900 to-navy-950" />
<HexCanvas isDark />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
<div className="absolute inset-0 bg-gradient-to-b from-warm-100 via-warm-50 to-warm-100 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950" />
<HexCanvas isDark={isDark} />
<div className="absolute inset-0 hidden dark:block bg-[radial-gradient(circle_at_50%_12%,rgba(20,184,166,0.16),transparent_38%),linear-gradient(180deg,rgba(10,14,26,0.20),rgba(10,14,26,0.82))]" />
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-10">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">{t('pricingPage.title')}</h1>
<p className="text-lg text-warm-300 max-w-lg mx-auto">{t('pricingPage.subtitle')}</p>
<p className="mt-5 text-warm-200 font-semibold">{t('pricingPage.lessThanSurvey')}</p>
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-white mb-3">
{t('pricingPage.title')}
</h1>
<p className="text-lg text-warm-600 dark:text-warm-300 max-w-lg mx-auto">
{t('pricingPage.subtitle')}
</p>
<p className="mt-5 text-warm-700 dark:text-warm-200 font-semibold">
{t('pricingPage.lessThanSurvey')}
</p>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-8 md:pb-16">
@ -203,7 +211,7 @@ export default function PricingPage({
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
: 'border-warm-700 shadow-md'
: 'border-warm-300 dark:border-warm-700 shadow-md'
} ${isFilled ? 'opacity-60' : ''}`}
>
{isCurrent && (
@ -266,11 +274,6 @@ export default function PricingPage({
})}
</p>
)}
{isFilled && (
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
<CheckIcon className="w-4 h-4" /> {t('pricingPage.filled')}
</p>
)}
</div>
{/* Progress bar for current tier */}
@ -308,10 +311,14 @@ export default function PricingPage({
{license.error}
</p>
)}
{isFree && (
{isFree ? (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.noCreditCard')}
</p>
) : (
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{t('pricingPage.moneyBack')}
</p>
)}
</>
) : isFilled ? (

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
@ -106,7 +106,7 @@ export default function AuthModal({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
className="fixed inset-0 z-[10001] flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
@ -283,6 +283,32 @@ export default function AuthModal({
{t('auth.backToLogin')}
</button>
)}
{view === 'register' && (
<p className="text-[11px] leading-relaxed text-center text-warm-400 dark:text-warm-500">
<Trans
i18nKey="auth.registerConsent"
components={{
terms: (
<a
href="/terms"
target="_blank"
rel="noopener"
className="underline hover:text-teal-600 dark:hover:text-teal-400"
/>
),
privacy: (
<a
href="/privacy"
target="_blank"
rel="noopener"
className="underline hover:text-teal-600 dark:hover:text-teal-400"
/>
),
}}
/>
</p>
)}
</form>
</div>
</div>

View file

@ -33,6 +33,8 @@ export type Page =
| 'data-sources'
| 'methodology'
| 'privacy-security'
| 'terms'
| 'privacy'
| 'account'
| 'saved'
| 'invite';
@ -58,6 +60,8 @@ export const PAGE_PATHS: Record<Page, string> = {
'data-sources': '/data-sources',
methodology: '/methodology',
'privacy-security': '/privacy-security',
terms: '/terms',
privacy: '/privacy',
saved: '/saved',
account: '/account',
invite: '/invite',

View file

@ -18,6 +18,12 @@ interface SearchHook {
handleInputChange: (value: string) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
showEmptySearches: () => void;
close?: () => void;
}
/** Addresses arrive in raw ALL-CAPS Land Registry casing; title-case for display. */
function titleCaseAddress(address: string): string {
return address.toLowerCase().replace(/(^|[\s\-/(])([a-z])/g, (_, sep, c) => sep + c.toUpperCase());
}
interface PlaceSearchInputProps {
@ -65,6 +71,9 @@ export function PlaceSearchInput({
const dropdown = showDropdown && (
<div
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
// Keep focus on the input while interacting with the list (incl. its
// scrollbar) so the blur-close below doesn't fire mid-click.
onMouseDown={(e) => e.preventDefault()}
style={
portal && dropdownPos
? {
@ -116,8 +125,11 @@ export function PlaceSearchInput({
) : result.type === 'address' ? (
<>
<HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="min-w-0 text-warm-700 dark:text-warm-200">
<span className="block truncate">{result.address}</span>
<span
className="min-w-0 text-warm-700 dark:text-warm-200"
title={`${titleCaseAddress(result.address)}, ${result.postcode}`}
>
<span className="block truncate">{titleCaseAddress(result.address)}</span>
<span className="block truncate text-warm-400 dark:text-warm-500">
{result.postcode}
</span>
@ -126,7 +138,10 @@ export function PlaceSearchInput({
) : (
<>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">
<span
className="text-warm-700 dark:text-warm-200"
title={result.city ? `${result.name} (${result.city})` : result.name}
>
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
@ -154,6 +169,9 @@ export function PlaceSearchInput({
onFocus={() => {
search.showEmptySearches();
}}
// Without this, instances whose parents lack outside-click handling
// leave a detached dropdown floating over the map.
onBlur={() => search.close?.()}
onKeyDown={(e) => search.handleKeyDown(e, onSelect)}
aria-label={ariaLabel ?? placeholder}
placeholder={placeholder}