This commit is contained in:
Andras Schmelczer 2026-05-14 20:42:48 +01:00
parent 273d7a83ee
commit 084117cea8
48 changed files with 2283 additions and 890 deletions

View file

@ -2,7 +2,6 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { AuthUser } from '../../hooks/useAuth';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties';
import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { formatRelativeTime, formatNumber } from '../../lib/format';
@ -11,7 +10,6 @@ import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { ClipboardIcon } from '../ui/icons/ClipboardIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { HouseIcon } from '../ui/icons/HouseIcon';
import { TrashIcon } from '../ui/icons/TrashIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { useLicense } from '../../hooks/useLicense';
@ -126,24 +124,6 @@ function NotesInput({ value, onSave }: { value: string; onSave: (notes: string)
);
}
function formatPropertyPrice(data: SavedPropertyData): string | null {
if (data.estimatedPrice) return `${formatNumber(data.estimatedPrice)}`;
if (data.price) return `£${formatNumber(data.price)}`;
return null;
}
function formatPropertyDetails(
data: SavedPropertyData,
t: { (key: 'savedPage.bed'): string; (key: 'savedPage.epc'): string }
): string {
const parts: string[] = [];
if (data.propertySubType) parts.push(data.propertySubType);
else if (data.propertyType) parts.push(data.propertyType);
if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}`);
if (data.energyRating) parts.push(`${t('savedPage.epc')} ${data.energyRating}`);
return parts.join(' · ');
}
function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
@ -355,115 +335,6 @@ function SavedSearchesTab({
);
}
function SavedPropertiesTab({
properties,
loading,
onDelete,
onUpdateNotes,
onOpen,
}: {
properties: SavedProperty[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onUpdateNotes: (id: string, notes: string) => void;
onOpen: (postcode: string) => void;
}) {
const { t } = useTranslation();
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirmId) return;
await onDelete(deleteConfirmId);
setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
);
}
if (properties.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<HouseIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
{t('savedPage.noSavedProperties')}
</p>
<p className="text-sm text-warm-500 dark:text-warm-500">
{t('savedPage.noSavedPropertiesDesc')}
</p>
</div>
);
}
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{properties.map((prop) => {
const price = formatPropertyPrice(prop.data);
const details = formatPropertyDetails(prop.data, t);
return (
<div
key={prop.id}
className="flex flex-col bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
>
<div className="mb-1">
<h3 className="font-medium text-navy-950 dark:text-warm-100 leading-tight">
{prop.address}
</h3>
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-1">{prop.postcode}</p>
{price && (
<p className="text-lg font-bold text-teal-700 dark:text-teal-400 mb-1">{price}</p>
)}
{details && (
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">{details}</p>
)}
<p className="text-xs text-warm-400 dark:text-warm-500 mb-2">
{formatRelativeTime(prop.created)}
</p>
<div className="mb-3 flex-1">
<NotesInput value={prop.notes} onSave={(notes) => onUpdateNotes(prop.id, notes)} />
</div>
<div className="mt-auto">
<div className="flex gap-2">
<button
onClick={() => onOpen(prop.postcode)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
{t('savedPage.openPostcode')}
</button>
<button
onClick={() => setDeleteConfirmId(prop.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title={t('common.delete')}
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
})}
</div>
{deleteConfirmId && (
<DeleteDialog
title={t('savedPage.deleteProperty')}
message={t('savedPage.deletePropertyConfirm')}
onCancel={() => setDeleteConfirmId(null)}
onConfirm={handleDeleteConfirm}
/>
)}
</>
);
}
export function SavedPage({
searches,
searchesLoading,
@ -471,11 +342,6 @@ export function SavedPage({
onUpdateSearchNotes,
onUpdateSearchName,
onOpenSearch,
savedProperties,
propertiesLoading,
onDeleteProperty,
onUpdatePropertyNotes,
onOpenProperty,
}: {
searches: SavedSearch[];
searchesLoading: boolean;
@ -483,15 +349,10 @@ export function SavedPage({
onUpdateSearchNotes: (id: string, notes: string) => void;
onUpdateSearchName: (id: string, name: string) => void;
onOpenSearch: (params: string) => void;
savedProperties: SavedProperty[];
propertiesLoading: boolean;
onDeleteProperty: (id: string) => Promise<void>;
onUpdatePropertyNotes: (id: string, notes: string) => void;
onOpenProperty: (postcode: string) => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>(
window.location.hash === '#properties' ? 'properties' : 'searches'
const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>(
window.location.hash === '#shared-links' ? 'shared-links' : 'searches'
);
const tabClass = (tab: string) =>
@ -512,13 +373,8 @@ export function SavedPage({
</span>
)}
</button>
<button className={tabClass('properties')} onClick={() => setActiveTab('properties')}>
{t('common.properties')}
{savedProperties.length > 0 && (
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
{savedProperties.length}
</span>
)}
<button className={tabClass('shared-links')} onClick={() => setActiveTab('shared-links')}>
{t('accountPage.shareLinksTitle')}
</button>
</div>
@ -532,13 +388,7 @@ export function SavedPage({
onOpen={onOpenSearch}
/>
) : (
<SavedPropertiesTab
properties={savedProperties}
loading={propertiesLoading}
onDelete={onDeleteProperty}
onUpdateNotes={onUpdatePropertyNotes}
onOpen={onOpenProperty}
/>
<ShareLinksSection showTitle={false} />
)}
</PageLayout>
);
@ -655,7 +505,7 @@ function InviteTable({
);
}
function ShareLinksSection() {
function ShareLinksSection({ showTitle = true }: { showTitle?: boolean }) {
const { t } = useTranslation();
const [links, setLinks] = useState<ShareLinkListItem[]>([]);
const [loading, setLoading] = useState(false);
@ -698,9 +548,11 @@ function ShareLinksSection() {
return (
<section className="space-y-3">
<h2 className="text-base font-semibold text-navy-950 dark:text-warm-100">
{t('accountPage.shareLinksTitle')}
</h2>
{showTitle && (
<h2 className="text-base font-semibold text-navy-950 dark:text-warm-100">
{t('accountPage.shareLinksTitle')}
</h2>
)}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-8">
@ -1048,8 +900,6 @@ export default function AccountPage({
<InviteSection user={user} />
</section>
<ShareLinksSection />
{/* Support */}
<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>

View file

@ -5,7 +5,7 @@ const HOME_SECTION_HEADING_CLASS =
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
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';
'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center';
export default function HomeFinalCta({
onOpenDashboard,

View file

@ -1,6 +1,7 @@
import { lazy, Suspense, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useFadeInRef } from '../../hooks/useFadeIn';
import { useIsMobile } from '../../hooks/useIsMobile';
import HexCanvas from './HexCanvas';
import HomeFinalCta from './HomeFinalCta';
import BottomIllustration from './BottomIllustration';
@ -15,7 +16,7 @@ const HOME_SECTION_HEADING_CLASS =
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
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';
'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center';
const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = {
en: 'recording',
de: 'recording-de',
@ -34,9 +35,13 @@ function ProductShowcaseFallback({ className = '' }: { className?: string }) {
);
}
function getProductDemoSlug(language: string | undefined): string {
function getProductDemoSlug(language: string | undefined, isMobile: boolean): string {
const code = language?.toLowerCase().split('-')[0] ?? 'en';
return PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
// Mobile cuts (9:16, 540x960) are published as `<base>-mobile` alongside
// the 16:9 desktop cuts. The recorder pipeline writes both every render —
// see video/src/storyboard.ts.
return isMobile ? `${base}-mobile` : base;
}
function highlightBrandText(text: string) {
@ -57,12 +62,13 @@ function highlightBrandText(text: string) {
function ProductDemoVideo() {
const { t, i18n } = useTranslation();
const isMobile = useIsMobile();
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 productDemoSlug = getProductDemoSlug(i18n.language, isMobile);
const productDemoVideoSrc = `/video/${productDemoSlug}.mp4`;
const productDemoPosterSrc = `/video/${productDemoSlug}.jpg`;
@ -123,7 +129,11 @@ function ProductDemoVideo() {
<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">
<div
className={`relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700 ${
isMobile ? 'mx-auto max-w-sm' : ''
}`}
>
<video
ref={videoRef}
src={shouldLoadVideo ? productDemoVideoSrc : undefined}
@ -131,7 +141,9 @@ function ProductDemoVideo() {
controls
playsInline
preload={shouldLoadVideo ? 'metadata' : 'none'}
className="block aspect-video w-full bg-navy-950 object-contain"
className={`block w-full bg-navy-950 object-contain ${
isMobile ? 'aspect-[9/16]' : 'aspect-video'
}`}
aria-label={t('home.productDemoLabel')}
onPlay={() => setIsVideoPlaying(true)}
onPause={() => setIsVideoPlaying(false)}

View file

@ -338,7 +338,7 @@ export default function InvitePage({
<div className="flex flex-col gap-3">
<button
onClick={onRegisterClick}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25"
>
{t('invitePage.registerToClaim')}
</button>

View file

@ -1,21 +1,29 @@
import { lazy, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { CheckIcon } from '../ui/icons/CheckIcon';
import HomeFinalCta from '../home/HomeFinalCta';
import { usePageMeta } from '../../hooks/usePageMeta';
import {
SEO_CONTENT_PAGES,
getLocalizedSeoContentPage,
type SeoContentKey,
type SeoFaq,
type SeoLink,
type SeoSection,
} from '../../lib/seoLandingPages';
import { safeJsonLd } from '../../lib/json-ld';
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
const ProductShowcase = lazy(() => import('../home/ProductShowcase'));
function ProductShowcaseFallback() {
return (
<div className="mx-auto min-h-[28rem] max-w-6xl rounded-lg bg-navy-900" aria-hidden="true" />
);
}
function JsonLd({ data }: { data: unknown }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
/>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(data) }} />
);
}
@ -91,9 +99,10 @@ export default function SeoContentPage({
pageKey: SeoContentKey;
onOpenDashboard: () => void;
}) {
const { t } = useTranslation();
const page = SEO_CONTENT_PAGES[pageKey];
const { t, i18n } = useTranslation();
const page = getLocalizedSeoContentPage(pageKey, i18n.language);
const url = `${PUBLIC_URL}${page.path}`;
usePageMeta(page.metaTitle, page.metaDescription);
return (
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
@ -136,7 +145,7 @@ export default function SeoContentPage({
{page.cta && (
<button
onClick={onOpenDashboard}
className="mt-8 rounded-lg bg-coral-500 px-6 py-3 font-semibold text-white shadow-lg shadow-coral-500/25 transition-colors hover:bg-coral-600"
className="mt-8 rounded-lg border border-[#d27a11] bg-[#f09a22] px-6 py-3 font-semibold text-navy-950 shadow-lg shadow-[#7a3905]/25 transition-colors hover:bg-[#df8614]"
>
{page.cta}
</button>
@ -144,6 +153,14 @@ export default function SeoContentPage({
</div>
</section>
<section className="bg-navy-950 px-6 py-12 md:px-10 md:py-16">
<div className="mx-auto max-w-6xl">
<Suspense fallback={<ProductShowcaseFallback />}>
<ProductShowcase className="mx-auto" />
</Suspense>
</div>
</section>
<section className="mx-auto max-w-5xl px-6 py-14 md:px-10">
<SectionList sections={page.sections} />
</section>
@ -178,6 +195,13 @@ export default function SeoContentPage({
<RelatedLinks links={page.relatedLinks} />
</div>
</section>
<section className="mx-auto max-w-5xl px-6 pb-16 md:px-10">
<HomeFinalCta
onOpenDashboard={onOpenDashboard}
trackingLocation={`seo_${pageKey}_bottom`}
/>
</section>
</main>
);
}

View file

@ -1,22 +1,30 @@
import { lazy, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { LogoIcon } from '../ui/icons/LogoIcon';
import HomeFinalCta from '../home/HomeFinalCta';
import { usePageMeta } from '../../hooks/usePageMeta';
import {
SEO_LANDING_PAGES,
getLocalizedSeoLandingPage,
type SeoFaq,
type SeoLandingKey,
type SeoLink,
type SeoSection,
} from '../../lib/seoLandingPages';
import { safeJsonLd } from '../../lib/json-ld';
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
const ProductShowcase = lazy(() => import('../home/ProductShowcase'));
function ProductShowcaseFallback() {
return (
<div className="mx-auto min-h-[28rem] max-w-6xl rounded-lg bg-navy-900" aria-hidden="true" />
);
}
function JsonLd({ data }: { data: unknown }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
/>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(data) }} />
);
}
@ -91,9 +99,10 @@ export default function SeoLandingPage({
pageKey: SeoLandingKey;
onOpenDashboard: () => void;
}) {
const { t } = useTranslation();
const page = SEO_LANDING_PAGES[pageKey];
const { t, i18n } = useTranslation();
const page = getLocalizedSeoLandingPage(pageKey, i18n.language);
const url = `${PUBLIC_URL}${page.path}`;
usePageMeta(page.metaTitle, page.metaDescription);
return (
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
@ -138,7 +147,7 @@ export default function SeoLandingPage({
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<button
onClick={onOpenDashboard}
className="rounded-lg bg-coral-500 px-6 py-3 font-semibold text-white shadow-lg shadow-coral-500/25 transition-colors hover:bg-coral-600"
className="rounded-lg border border-[#d27a11] bg-[#f09a22] px-6 py-3 font-semibold text-navy-950 shadow-lg shadow-[#7a3905]/25 transition-colors hover:bg-[#df8614]"
>
{page.cta}
</button>
@ -152,6 +161,14 @@ export default function SeoLandingPage({
</div>
</section>
<section className="bg-navy-950 px-6 py-12 md:px-10 md:py-16">
<div className="mx-auto max-w-6xl">
<Suspense fallback={<ProductShowcaseFallback />}>
<ProductShowcase className="mx-auto" />
</Suspense>
</div>
</section>
<section className="mx-auto grid max-w-6xl gap-8 px-6 py-12 md:grid-cols-[0.85fr_1.15fr] md:px-10 md:py-16">
<div>
<div className="inline-flex items-center gap-2 rounded-md border border-teal-200 bg-teal-50 px-3 py-1.5 text-sm font-semibold text-teal-700 dark:border-teal-400/30 dark:bg-teal-400/10 dark:text-teal-200">
@ -228,6 +245,13 @@ export default function SeoLandingPage({
</div>
<LinkGrid links={page.relatedLinks} />
</section>
<section className="mx-auto max-w-6xl px-6 pb-16 md:px-10">
<HomeFinalCta
onOpenDashboard={onOpenDashboard}
trackingLocation={`seo_${pageKey}_bottom`}
/>
</section>
</main>
);
}

View file

@ -1,10 +1,11 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { tDynamic } from '../../i18n';
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { SubNav } from '../ui/SubNav';
type LearnTab = 'data-sources' | 'faq' | 'support';
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
interface DataSourceDef {
id: string;
@ -173,15 +174,26 @@ function FAQItemCard({ question, answer }: { question: string; answer: string })
}
export default function LearnPage() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [tab, setTab] = useState<LearnTab>('faq');
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const seoPageLinks = useMemo(
() =>
getLocalizedSeoPages(i18n.language).map((page) => ({
path: page.path,
eyebrow: page.eyebrow,
title: page.title,
description: page.metaDescription,
})),
[i18n.language]
);
const LEARN_TABS = [
{ key: 'faq', label: t('learnPage.faq') },
{ key: 'data-sources', label: t('learnPage.dataSources') },
{ key: 'articles', label: t('learnPage.articles') },
{ key: 'support', label: t('learnPage.support') },
];
@ -239,6 +251,9 @@ export default function LearnPage() {
if (hash === 'faq') {
setTab('faq');
setHighlightedId(null);
} else if (hash === 'articles') {
setTab('articles');
setHighlightedId(null);
} else if (hash === 'support') {
setTab('support');
setHighlightedId(null);
@ -406,6 +421,32 @@ export default function LearnPage() {
))}
</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}
</p>
</a>
))}
</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">

View file

@ -46,6 +46,7 @@ interface AreaPaneProps {
onStatsUseFiltersChange: (useFilters: boolean) => void;
onNavigateToSource?: (slug: string, featureName: string) => void;
travelTimeEntries?: TravelTimeEntry[];
shareCode?: string;
isGroupExpanded: (name: string) => boolean;
onToggleGroup: (name: string) => void;
}
@ -74,6 +75,7 @@ export default function AreaPane({
onStatsUseFiltersChange,
onNavigateToSource,
travelTimeEntries,
shareCode,
isGroupExpanded,
onToggleGroup,
}: AreaPaneProps) {
@ -226,6 +228,7 @@ export default function AreaPane({
postcode={journeyPostcode}
entries={travelTimeEntries}
label={!isPostcode ? journeyPostcode : undefined}
shareCode={shareCode}
/>
) : null;
})()}

View file

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { JourneyLeg } from '../../types';
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
import { apiUrl, logNonAbortError } from '../../lib/api';
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
import { WalkingIcon } from '../ui/icons/WalkingIcon';
import { BicycleIcon } from '../ui/icons/BicycleIcon';
@ -15,6 +15,7 @@ interface JourneyInstructionsProps {
presetJourneys?: JourneyInstructionPreset[];
className?: string;
showGoogleMapsLink?: boolean;
shareCode?: string;
}
interface JourneyData {
@ -25,6 +26,8 @@ interface JourneyData {
minutes: number | null;
/** Best-case (5th percentile) total travel time from R5. */
bestMinutes: number | null;
/** Whether the dashboard filter is currently using best-case time. */
useBest: boolean;
loading: boolean;
}
@ -36,6 +39,7 @@ export interface JourneyInstructionPreset {
minutes: number | null;
/** Best-case (5th percentile) total travel time. */
bestMinutes?: number | null;
useBest?: boolean;
}
// Official TfL line colors + other known London transit
@ -90,15 +94,15 @@ function nextMondayAt730(): number {
return Math.floor(monday.getTime() / 1000);
}
function googleMapsUrl(postcode: string, destination: string): string {
function googleMapsUrl(origin: string, destination: string): string {
const ts = nextMondayAt730();
const origin = encodeURIComponent(postcode);
const dest = encodeURIComponent(destination);
const encodedOrigin = encodeURIComponent(origin);
const encodedDestination = encodeURIComponent(destination);
// The official api=1 URL scheme doesn't support departure_time.
// Use the undocumented data= path parameter with protobuf-like encoding:
// !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp
const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`;
return `https://www.google.com/maps/dir/${origin}/${dest}/data=${data}`;
return `https://www.google.com/maps/dir/${encodedOrigin}/${encodedDestination}/data=${data}`;
}
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
@ -181,12 +185,16 @@ export default function JourneyInstructions({
presetJourneys,
className,
showGoogleMapsLink = true,
shareCode,
}: JourneyInstructionsProps) {
const { t } = useTranslation();
const [journeys, setJourneys] = useState<JourneyData[]>([]);
// Only transit entries with a destination set
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
const transitEntries = useMemo(
() => entries.filter((e) => e.mode === 'transit' && e.slug !== ''),
[entries]
);
const hasPresetJourneys = Boolean(presetJourneys?.length);
useEffect(() => {
@ -207,6 +215,7 @@ export default function JourneyInstructions({
legs: null,
minutes: null,
bestMinutes: null,
useBest: e.useBest,
loading: true,
}));
setJourneys([...results]);
@ -217,7 +226,8 @@ export default function JourneyInstructions({
mode: 'transit',
slug: entry.slug,
});
fetch(apiUrl('journey', params), { signal: controller.signal })
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('journey', params), authHeaders({ signal: controller.signal }))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
@ -232,12 +242,12 @@ export default function JourneyInstructions({
prev.map((j, i) =>
i === idx
? {
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
loading: false,
}
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
loading: false,
}
: j
)
);
@ -250,19 +260,20 @@ export default function JourneyInstructions({
});
return () => controller.abort();
}, [postcode, hasPresetJourneys, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
}, [postcode, hasPresetJourneys, transitEntries, shareCode]);
if (transitEntries.length === 0 && !hasPresetJourneys) return null;
const displayedJourneys: JourneyData[] = hasPresetJourneys
? (presetJourneys ?? []).map((journey) => ({
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
loading: false,
}))
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
useBest: journey.useBest ?? false,
loading: false,
}))
: journeys;
return (
@ -272,19 +283,22 @@ export default function JourneyInstructions({
{t('areaPane.journeysFrom', { label })}
</div>
)}
{displayedJourneys.map((j) => {
const displayLegs = j.legs ? invertLegs(j.legs) : null;
{displayedJourneys.map((j, index) => {
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
const totalMin = j.minutes ?? legSum;
const totalMin = j.useBest && j.bestMinutes != null ? j.bestMinutes : (j.minutes ?? legSum);
const isBestCase = j.useBest && j.bestMinutes != null;
const displayLegs = !isBestCase && j.legs ? invertLegs(j.legs) : null;
const destination = j.label || j.slug;
return (
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
<div key={`${j.slug}-${index}`} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
<div className="flex items-baseline justify-between mb-2">
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
{t('areaPane.to', { destination: j.label || j.slug })}
{t('areaPane.to', { destination })}
</span>
{!j.loading && totalMin > 0 && (
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
{totalMin} {t('common.min')}
</span>
)}
@ -303,7 +317,7 @@ export default function JourneyInstructions({
))}
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
href={googleMapsUrl(postcode, destination)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
@ -325,17 +339,20 @@ export default function JourneyInstructions({
</a>
)}
</div>
) : j.minutes != null ? (
) : totalMin > 0 ? (
<div>
<div className="flex items-center gap-1.5 py-0.5">
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
{!isBestCase && (
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
)}
<span className="text-xs text-warm-600 dark:text-warm-300">
{t('areaPane.walk')} · {j.minutes} {t('common.min')}
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
{t('common.min')}
</span>
</div>
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, j.label || j.slug)}
href={googleMapsUrl(postcode, destination)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"

View file

@ -1,7 +1,7 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { PostcodeGeometry, Property } from '../../types';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
@ -19,6 +19,7 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import { stateToParams } from '../../lib/url-state';
import {
AreaPane,
Filters,
@ -30,7 +31,7 @@ import { PaneFallback } from './map-page/Fallbacks';
import { DesktopMapPage } from './map-page/DesktopMapPage';
import { MobileMapPage } from './map-page/MobileMapPage';
import { ScreenshotMapPage } from './map-page/ScreenshotMapPage';
import { BookmarkToast, ExportToast } from './map-page/Toasts';
import { ExportToast } from './map-page/Toasts';
import { MobileMapLegend } from './map-page/MobileMapLegend';
import { useExportController } from './map-page/useExportController';
import {
@ -66,6 +67,7 @@ export default function MapPage({
onClearPendingInfoFeature,
onNavigateTo,
onExportStateChange,
onDashboardParamsChange,
screenshotMode,
ogMode,
isMobile = false,
@ -75,10 +77,8 @@ export default function MapPage({
user,
onLoginClick,
onRegisterClick,
onSaveProperty,
onUnsaveProperty,
isPropertySaved,
getSavedPropertyId,
onCheckoutLoginClick,
onCheckoutRegisterClick,
deferTutorial = false,
onSaveSearch,
savingSearch,
@ -92,19 +92,6 @@ export default function MapPage({
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
const handleSavePropertyWithToast = useCallback(
(property: Property) => {
onSaveProperty?.(property);
if (!bookmarkToastDismissed.current) {
setShowBookmarkToast(true);
bookmarkToastDismissed.current = true;
}
},
[onSaveProperty]
);
const {
filters,
@ -155,6 +142,22 @@ export default function MapPage({
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
if (!isMobile) return undefined;
const panelRect = mobileDrawerPanelRectRef.current;
if (mobileDrawerOpen && panelRect) {
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
if (bottomInset > 0) {
return { visibleViewportArea: { bottom: bottomInset } };
}
}
return mobileBottomSheetHeight > 0
? { visibleArea: { bottom: mobileBottomSheetHeight } }
: undefined;
}, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]);
const mapData = useMapData({
filters,
features,
@ -209,7 +212,8 @@ export default function MapPage({
mapFlyToRef.current?.(
destination.lat,
destination.lon,
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
getMobileMapFlyToOptions()
);
}
} catch {
@ -220,6 +224,7 @@ export default function MapPage({
activeEntries,
fetchAiFilters,
filters,
getMobileMapFlyToOptions,
handleSetEntries,
handleSetFilters,
mapData.currentView?.zoom,
@ -251,17 +256,22 @@ export default function MapPage({
[handleDragEndNoCommit, handleTimeRangeChange]
);
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries);
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries, shareCode);
const license = useLicense();
const handleTravelTimeSetDestination = useCallback(
(index: number, slug: string, label: string, lat: number, lon: number) => {
handleSetDestination(index, slug, label);
if (slug) {
mapFlyToRef.current?.(lat, lon, mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom);
mapFlyToRef.current?.(
lat,
lon,
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
getMobileMapFlyToOptions()
);
}
},
[handleSetDestination, mapData.currentView?.zoom]
[getMobileMapFlyToOptions, handleSetDestination, mapData.currentView?.zoom]
);
const journeyDest = useJourneyDestination(entries);
@ -430,9 +440,11 @@ export default function MapPage({
useScreenshotReadySignal({
screenshotMode,
loading: mapData.loading,
boundsReady: mapData.bounds != null,
dataLength: mapData.data.length,
postcodeDataLength: mapData.postcodeData.length,
usePostcodeView: mapData.usePostcodeView,
licenseRequired: mapData.licenseRequired,
});
const handleMobileHexagonClick = useCallback(
@ -462,10 +474,42 @@ export default function MapPage({
bounds: mapData.bounds,
filters,
features,
travelTimeEntries: entries,
shareCode,
t,
onExportStateChange,
});
const dashboardParams = useMemo(
() =>
stateToParams(
mapData.currentView,
filters,
features,
selectedPOICategories,
rightPaneTab,
entries,
shareCode
).toString(),
[
entries,
features,
filters,
mapData.currentView,
rightPaneTab,
selectedPOICategories,
shareCode,
]
);
const checkoutReturnPath = useMemo(
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
[dashboardParams]
);
useEffect(() => {
onDashboardParamsChange?.(dashboardParams);
}, [dashboardParams, onDashboardParamsChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
}, [mapData.licenseRequired]);
@ -501,6 +545,7 @@ export default function MapPage({
statsUseFilters={areaStatsUseFilters}
onStatsUseFiltersChange={setAreaStatsUseFilters}
travelTimeEntries={activeEntries}
shareCode={shareCode}
isGroupExpanded={isAreaGroupExpanded}
onToggleGroup={toggleAreaGroup}
/>
@ -515,10 +560,6 @@ export default function MapPage({
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
onLoadMore={handleLoadMoreProperties}
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
/>
</Suspense>
);
@ -589,40 +630,25 @@ export default function MapPage({
}
};
const bookmarkToast = (
<BookmarkToast
show={showBookmarkToast}
onViewSaved={() => {
setShowBookmarkToast(false);
onNavigateTo('saved', 'properties');
}}
onDismissForever={() => {
setShowBookmarkToast(false);
localStorage.setItem('bookmark_toast_dismissed', '1');
}}
/>
);
const exportToast = (
<ExportToast
notice={exportNotice}
offsetForBookmark={showBookmarkToast}
closeLabel={t('common.close')}
onClose={clearExportNotice}
/>
);
const toasts = (
<>
{bookmarkToast}
{exportToast}
</>
);
const toasts = exportToast;
const upgradeModal = mapData.licenseRequired ? (
<Suspense fallback={null}>
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick}
onStartCheckout={() => license.startCheckout()}
onLoginClick={() =>
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
}
onRegisterClick={() =>
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
}
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
onZoomToFreeZone={handleZoomToFreeZone}
isShareReturn={!!shareReturnViewRef.current}
/>

View file

@ -1,4 +1,4 @@
import { useMemo, useState, useCallback } from 'react';
import { useMemo, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
@ -7,7 +7,6 @@ import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from '../ui/EmptyState';
import { InfoIcon } from '../ui/icons';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { ts } from '../../i18n/server';
interface PropertiesPaneProps {
@ -17,10 +16,6 @@ interface PropertiesPaneProps {
hexagonId: string | null;
onLoadMore: () => void;
onNavigateToSource?: (slug: string) => void;
onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
}
export function PropertiesPane({
@ -30,15 +25,15 @@ export function PropertiesPane({
hexagonId,
onLoadMore,
onNavigateToSource,
onSaveProperty,
onUnsaveProperty,
isPropertySaved,
getSavedPropertyId,
}: PropertiesPaneProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
useEffect(() => {
setSearch('');
}, [hexagonId]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return query
@ -100,14 +95,7 @@ export function PropertiesPane({
) : (
<>
{filtered.map((property, idx) => (
<PropertyCard
key={idx}
property={property}
onSave={onSaveProperty}
onUnsave={onUnsaveProperty}
isSaved={isPropertySaved?.(property.address, property.postcode)}
savedId={getSavedPropertyId?.(property.address, property.postcode)}
/>
<PropertyCard key={idx} property={property} />
))}
{properties.length < total && (
<button
@ -151,27 +139,8 @@ function PropertyLoadingSkeleton() {
);
}
function PropertyCard({
property,
onSave,
onUnsave,
isSaved,
savedId,
}: {
property: Property;
onSave?: (property: Property) => void;
onUnsave?: (id: string) => void;
isSaved?: boolean;
savedId?: string;
}) {
function PropertyCard({ property }: { property: Property }) {
const { t } = useTranslation();
const handleToggleSave = useCallback(() => {
if (isSaved && savedId && onUnsave) {
onUnsave(savedId);
} else if (onSave) {
onSave(property);
}
}, [isSaved, savedId, onSave, onUnsave, property]);
const price = getNum(property, 'Last known price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm');
@ -197,19 +166,6 @@ function PropertyCard({
)}
</div>
</div>
{onSave && (
<button
onClick={handleToggleSave}
className={`shrink-0 p-1 rounded ${
isSaved
? 'text-teal-600 dark:text-teal-400'
: 'text-warm-300 dark:text-warm-600 hover:text-warm-500 dark:hover:text-warm-400'
}`}
title={isSaved ? t('propertyCard.unsaveProperty') : t('propertyCard.saveProperty')}
>
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
</button>
)}
</div>
{property.property_sub_type && (

View file

@ -1,5 +1,5 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { Slider } from '../ui/Slider';
import { IconButton } from '../ui/IconButton';
import { PillToggle } from '../ui/PillToggle';
@ -134,10 +134,9 @@ export function TravelTimeCard({
{showBestInfo && (
<InfoPopup title={t('travel.bestCaseTitle')} onClose={() => setShowBestInfo(false)}>
<p
className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed"
dangerouslySetInnerHTML={{ __html: t('travel.bestCaseDesc') }}
/>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
<Trans i18nKey="travel.bestCaseDesc" components={{ strong: <strong /> }} />
</p>
</InfoPopup>
)}

View file

@ -1,56 +1,22 @@
import { useTranslation } from 'react-i18next';
import type { ExportNotice } from './types';
import { BookmarkIcon } from '../../ui/icons/BookmarkIcon';
import { CheckIcon } from '../../ui/icons/CheckIcon';
import { CloseIcon } from '../../ui/icons/CloseIcon';
import { InfoIcon } from '../../ui/icons/InfoIcon';
interface BookmarkToastProps {
show: boolean;
onViewSaved: () => void;
onDismissForever: () => void;
}
export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) {
const { t } = useTranslation();
if (!show) return null;
return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
<span>{t('toasts.propertySaved')}</span>
<button
onClick={onViewSaved}
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
>
{t('toasts.viewSaved')}
</button>
<button
onClick={onDismissForever}
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
>
{t('toasts.dontShowAgain')}
</button>
</div>
);
}
interface ExportToastProps {
notice: ExportNotice | null;
offsetForBookmark: boolean;
closeLabel: string;
onClose: () => void;
}
export function ExportToast({ notice, offsetForBookmark, closeLabel, onClose }: ExportToastProps) {
export function ExportToast({ notice, closeLabel, onClose }: ExportToastProps) {
if (!notice) return null;
return (
<div
role={notice.kind === 'error' ? 'alert' : 'status'}
aria-live={notice.kind === 'error' ? 'assertive' : 'polite'}
className={`fixed ${offsetForBookmark ? 'bottom-24' : 'bottom-6'} left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in`}
className="fixed bottom-6 left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in"
>
{notice.kind === 'success' ? (
<CheckIcon className="h-4 w-4 shrink-0 text-teal-400" />

View file

@ -10,6 +10,7 @@ import type { MapFlyTo } from './types';
type MapData = ReturnType<typeof useMapData>;
type RightPaneTab = 'properties' | 'area';
const SCREENSHOT_MAP_IDLE_FALLBACK_MS = 1000;
export function useInitialMapPageView(
mapData: MapData,
@ -110,36 +111,72 @@ export function useMobileBackNavigationGuard(isMobile: boolean) {
interface UseScreenshotReadySignalOptions {
screenshotMode?: boolean;
loading: boolean;
boundsReady: boolean;
dataLength: number;
postcodeDataLength: number;
usePostcodeView: boolean;
licenseRequired: boolean;
}
export function useScreenshotReadySignal({
screenshotMode,
loading,
boundsReady,
dataLength,
postcodeDataLength,
usePostcodeView,
licenseRequired,
}: UseScreenshotReadySignalOptions) {
useEffect(() => {
if (screenshotMode && !loading) {
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
if (hasData) {
// Wait for both deck.gl data and MapLibre base map tile rendering.
const waitAndSignal = () => {
if (window.__map_idle) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.__screenshot_ready = true;
});
});
} else {
requestAnimationFrame(waitAndSignal);
}
};
waitAndSignal();
if (!screenshotMode || loading || !boundsReady) return;
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
if (!hasData && !licenseRequired) return;
let cancelled = false;
let signalled = false;
let frameId: number | null = null;
let timeoutId: number | null = null;
const signalReady = () => {
if (cancelled || signalled) return;
signalled = true;
if (timeoutId != null) window.clearTimeout(timeoutId);
if (frameId != null) window.cancelAnimationFrame(frameId);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!cancelled) window.__screenshot_ready = true;
});
});
};
const waitAndSignal = () => {
if (window.__map_idle) {
signalReady();
} else {
frameId = requestAnimationFrame(waitAndSignal);
}
}
}, [screenshotMode, loading, dataLength, postcodeDataLength, usePostcodeView]);
};
// In webpack dev mode MapLibre's idle event can be delayed by the dev
// client/HMR churn even after data has rendered. Keep production-quality
// waiting when idle fires, but avoid forcing the screenshot service to hit
// its much longer timeout in local development.
timeoutId = window.setTimeout(signalReady, SCREENSHOT_MAP_IDLE_FALLBACK_MS);
waitAndSignal();
return () => {
cancelled = true;
if (timeoutId != null) window.clearTimeout(timeoutId);
if (frameId != null) window.cancelAnimationFrame(frameId);
};
}, [
screenshotMode,
loading,
boundsReady,
dataLength,
postcodeDataLength,
usePostcodeView,
licenseRequired,
]);
}

View file

@ -3,7 +3,6 @@ import type {
FeatureMeta,
MapFlyToOptions,
POICategoryGroup,
Property,
ViewState,
} from '../../../types';
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
@ -33,6 +32,7 @@ export interface MapPageProps {
onClearPendingInfoFeature: () => void;
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
onExportStateChange?: (state: ExportState) => void;
onDashboardParamsChange?: (params: string) => void;
screenshotMode?: boolean;
ogMode?: boolean;
isMobile?: boolean;
@ -42,10 +42,8 @@ export interface MapPageProps {
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
onCheckoutLoginClick?: (returnPath?: string) => void;
onCheckoutRegisterClick?: (returnPath?: string) => void;
deferTutorial?: boolean;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;

View file

@ -5,6 +5,8 @@ import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
import { trackEvent } from '../../../lib/analytics';
import type { ExportNotice, ExportState } from './types';
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
import { buildTravelParam } from '../../../lib/travel-params';
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
const EXPORT_TIMEOUT_MS = 150_000;
@ -65,10 +67,24 @@ function triggerExportDownload(blob: Blob, fileName: string): void {
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
}
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
for (const entry of entries) {
if (!entry.slug) continue;
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) value += ':b';
if (entry.timeRange) {
value += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
params.append('tt', value);
}
}
interface UseExportControllerOptions {
bounds: Bounds | null;
filters: FeatureFilters;
features: FeatureMeta[];
travelTimeEntries: TravelTimeEntry[];
shareCode?: string;
t: TFunction;
onExportStateChange?: (state: ExportState) => void;
}
@ -77,6 +93,8 @@ export function useExportController({
bounds,
filters,
features,
travelTimeEntries,
shareCode,
t,
onExportStateChange,
}: UseExportControllerOptions) {
@ -126,6 +144,10 @@ export function useExportController({
});
const filterStr = buildFilterString(filters, features);
if (filterStr) params.set('filters', filterStr);
const travelParam = buildTravelParam(travelTimeEntries);
if (travelParam) params.set('travel', travelParam);
appendTravelStateParams(params, travelTimeEntries);
if (shareCode) params.set('share', shareCode);
const url = apiUrl('export', params);
const controller = new AbortController();
@ -161,7 +183,17 @@ export function useExportController({
setExporting(false);
}
})();
}, [bounds, clearExportNotice, exporting, features, filters, showExportNotice, t]);
}, [
bounds,
clearExportNotice,
exporting,
features,
filters,
shareCode,
showExportNotice,
t,
travelTimeEntries,
]);
useEffect(() => {
onExportStateChange?.({ onExport: handleExport, exporting });

View file

@ -115,7 +115,7 @@ export default function PricingPage({
<button
onClick={() => license.startCheckout()}
disabled={license.checkingOut}
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
className="w-full mt-auto px-5 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{license.checkingOut
@ -129,7 +129,7 @@ export default function PricingPage({
) : (
<button
onClick={onRegisterClick}
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
className="w-full mt-auto px-5 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25"
>
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
</button>

View file

@ -8,6 +8,7 @@ type View = 'login' | 'register' | 'forgot';
export default function AuthModal({
onClose,
onAuthenticated,
onLogin,
onRegister,
onOAuthLogin,
@ -18,6 +19,7 @@ export default function AuthModal({
initialTab = 'login',
}: {
onClose: () => void;
onAuthenticated?: () => void;
onLogin: (email: string, password: string) => Promise<void>;
onRegister: (email: string, password: string) => Promise<void>;
onOAuthLogin: (provider: string) => Promise<void>;
@ -52,9 +54,11 @@ export default function AuthModal({
try {
if (view === 'login') {
await onLogin(email, password);
onAuthenticated?.();
onClose();
} else if (view === 'register') {
await onRegister(email, password);
onAuthenticated?.();
onClose();
} else {
await onForgotPassword(email);
@ -64,19 +68,20 @@ export default function AuthModal({
// Error is handled by the hook
}
},
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
[view, email, password, onLogin, onRegister, onForgotPassword, onAuthenticated, onClose]
);
const handleOAuth = useCallback(
async (provider: string) => {
try {
await onOAuthLogin(provider);
onAuthenticated?.();
onClose();
} catch {
// Error is handled by the hook
}
},
[onOAuthLogin, onClose]
[onOAuthLogin, onAuthenticated, onClose]
);
const title =

View file

@ -34,7 +34,6 @@ export type Page =
| 'privacy-security'
| 'account'
| 'saved'
| 'invites'
| 'invite';
export interface HeaderExportState {
@ -59,7 +58,6 @@ export const PAGE_PATHS: Record<Page, string> = {
methodology: '/methodology',
'privacy-security': '/privacy-security',
saved: '/saved',
invites: '/invites',
account: '/account',
invite: '/invite',
};
@ -68,10 +66,12 @@ const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023p
export default function Header({
activePage,
activeHash,
onPageChange,
theme,
onToggleTheme,
exportState,
dashboardParams,
onSaveSearch,
savingSearch,
user,
@ -81,10 +81,12 @@ export default function Header({
isMobile,
}: {
activePage: Page;
onPageChange: (page: Page) => void;
activeHash: string;
onPageChange: (page: Page, hash?: string) => void;
theme: 'light' | 'dark';
onToggleTheme: () => void;
exportState: HeaderExportState | null;
dashboardParams: string;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
user: AuthUser | null;
@ -132,7 +134,8 @@ export default function Header({
}, []);
const handleShare = useCallback(async () => {
const params = window.location.search.replace(/^\?/, '');
const params =
activePage === 'dashboard' ? dashboardParams : window.location.search.replace(/^\?/, '');
if (!params) {
doCopy(window.location.href);
return;
@ -147,17 +150,18 @@ export default function Header({
} finally {
setSharing(false);
}
}, [doCopy]);
}, [activePage, dashboardParams, doCopy]);
const navLink = (page: Page, e: React.MouseEvent) => {
const navLink = (page: Page, e: React.MouseEvent, hash?: string) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onPageChange(page);
onPageChange(page, hash);
};
const tabClass = (page: Page) =>
const tabClass = (page: Page, hash?: string) =>
`inline-flex cursor-pointer items-center px-4 py-1.5 rounded text-sm font-medium transition-colors ${
activePage === page
activePage === page &&
(hash ? activeHash === hash : page !== 'account' || activeHash !== 'invites')
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
@ -188,9 +192,9 @@ export default function Header({
</a>
{user && (
<a
href={PAGE_PATHS.invites}
className={tabClass('invites')}
onClick={(e) => navLink('invites', e)}
href={`${PAGE_PATHS.account}#invites`}
className={tabClass('account', 'invites')}
onClick={(e) => navLink('account', e, 'invites')}
>
{t('header.inviteFriends')}
</a>
@ -354,6 +358,7 @@ export default function Header({
{useSidebarNav && menuOpen && (
<MobileMenu
activePage={activePage}
activeHash={activeHash}
onPageChange={onPageChange}
theme={theme}
onToggleTheme={onToggleTheme}

View file

@ -1,12 +1,19 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SpinnerIcon } from './icons/SpinnerIcon';
interface LicenseSuccessModalProps {
onClose: () => void;
status?: 'verifying' | 'success' | 'delayed';
}
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
export default function LicenseSuccessModal({
onClose,
status = 'success',
}: LicenseSuccessModalProps) {
const { t } = useTranslation();
const isSuccess = status === 'success';
const isVerifying = status === 'verifying';
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
@ -24,47 +31,75 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
);
useEffect(() => {
if (!isSuccess) return;
const timer = setTimeout(onClose, 8000);
return () => clearTimeout(timer);
}, [onClose]);
}, [isSuccess, onClose]);
const title =
status === 'verifying'
? t('licenseSuccess.verifyingTitle')
: status === 'delayed'
? t('licenseSuccess.activationDelayedTitle')
: t('licenseSuccess.title');
const subtitle =
status === 'verifying'
? t('licenseSuccess.verifyingSubtitle')
: status === 'delayed'
? t('licenseSuccess.activationDelayedSubtitle')
: t('licenseSuccess.subtitle');
const description =
status === 'verifying'
? t('licenseSuccess.verifyingDescription')
: status === 'delayed'
? t('licenseSuccess.activationDelayedDescription')
: t('licenseSuccess.description');
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{particles.map((p) => (
<div
key={p.id}
className="absolute animate-confetti"
style={{
left: `${p.left}%`,
top: '-10px',
width: `${p.size}px`,
height: `${p.size}px`,
backgroundColor: p.color,
borderRadius: p.isCircle ? '50%' : '2px',
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
/>
))}
</div>
{isSuccess && (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{particles.map((p) => (
<div
key={p.id}
className="absolute animate-confetti"
style={{
left: `${p.left}%`,
top: '-10px',
width: `${p.size}px`,
height: `${p.size}px`,
backgroundColor: p.color,
borderRadius: p.isCircle ? '50%' : '2px',
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
/>
))}
</div>
)}
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
<div className="text-5xl mb-3">🎉</div>
<h2 className="text-2xl font-bold text-white">{t('licenseSuccess.title')}</h2>
<p className="text-warm-300 text-sm mt-2">{t('licenseSuccess.subtitle')}</p>
<div className="h-14 mb-3 flex items-center justify-center">
{isVerifying ? (
<SpinnerIcon className="w-10 h-10 animate-spin text-teal-300" />
) : (
<div className="text-5xl">{isSuccess ? '🎉' : '✓'}</div>
)}
</div>
<h2 className="text-2xl font-bold text-white">{title}</h2>
<p className="text-warm-300 text-sm mt-2">{subtitle}</p>
</div>
<div className="px-6 py-6">
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
{t('licenseSuccess.description')}
</p>
<button
onClick={onClose}
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
>
{t('licenseSuccess.startExploring')}
</button>
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">{description}</p>
{!isVerifying && (
<button
onClick={onClose}
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
>
{isSuccess ? t('licenseSuccess.startExploring') : t('licenseSuccess.stayOnPricing')}
</button>
)}
</div>
</div>

View file

@ -14,7 +14,8 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
interface MobileMenuProps {
activePage: Page;
onPageChange: (page: Page) => void;
activeHash: string;
onPageChange: (page: Page, hash?: string) => void;
theme: 'light' | 'dark';
onToggleTheme: () => void;
exportState: HeaderExportState | null;
@ -32,6 +33,7 @@ interface MobileMenuProps {
export default function MobileMenu({
activePage,
activeHash,
onPageChange,
theme,
onToggleTheme,
@ -52,25 +54,30 @@ export default function MobileMenu({
const emailLocal = emailParts?.[0] ?? '';
const emailDomain = emailParts && emailParts.length > 1 ? emailParts.slice(1).join('@') : '';
const mobileNavItem = (page: Page, label: string) => (
<a
key={page}
href={PAGE_PATHS[page]}
className={`block w-full cursor-pointer text-left px-3 py-2 text-sm font-medium rounded ${
activePage === page
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onPageChange(page);
onClose();
}}
>
{label}
</a>
);
const mobileNavItem = (page: Page, label: string, hash?: string) => {
const isActive =
activePage === page &&
(hash ? activeHash === hash : page !== 'account' || activeHash !== 'invites');
const href = hash ? `${PAGE_PATHS[page]}#${hash}` : PAGE_PATHS[page];
return (
<a
key={hash ? `${page}-${hash}` : page}
href={href}
className={`block w-full cursor-pointer text-left px-3 py-2 text-sm font-medium rounded ${
isActive ? 'bg-navy-700 text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`}
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onPageChange(page, hash);
onClose();
}}
>
{label}
</a>
);
};
const dashboardActionClass =
'w-full flex cursor-pointer items-center justify-center gap-2 px-3 py-2 rounded bg-navy-800 text-sm font-semibold text-white border border-navy-700 shadow-sm hover:bg-navy-700 disabled:opacity-50 transition-colors';
@ -169,7 +176,7 @@ export default function MobileMenu({
{user?.subscription !== 'licensed' &&
!user?.isAdmin &&
mobileNavItem('pricing', t('header.pricing'))}
{user && mobileNavItem('invites', t('header.inviteFriends'))}
{user && mobileNavItem('account', t('header.inviteFriends'), 'invites')}
{user && mobileNavItem('account', t('userMenu.account'))}
{activePage !== 'dashboard' && user && mobileNavItem('saved', t('header.saved'))}
</nav>

View file

@ -11,7 +11,7 @@ export function SubNav({ tabs, activeTab, onTabChange }: SubNavProps) {
{tabs.map((tab) => (
<button
key={tab.key}
className={`px-4 py-2 text-sm font-medium border-b-2 ${
className={`cursor-pointer px-4 py-2 text-sm font-medium border-b-2 ${
activeTab === tab.key
? 'border-teal-500 text-teal-700 dark:text-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'

View file

@ -97,7 +97,7 @@ export default function UpgradeModal({
<button
onClick={handleUpgrade}
disabled={loading}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{loading
@ -110,7 +110,7 @@ export default function UpgradeModal({
<div className="flex flex-col gap-3">
<button
onClick={onRegisterClick}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25"
>
{t('upgrade.registerAndUpgrade')}
</button>