Fix FE
This commit is contained in:
parent
1241132095
commit
54a5c3ca9a
28 changed files with 826 additions and 422 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)') {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) })}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue