This commit is contained in:
Andras Schmelczer 2026-02-15 22:39:49 +00:00
parent 03445188ea
commit 524580eb25
102 changed files with 36625 additions and 1295 deletions

View file

@ -5,9 +5,13 @@ import HomePage from './components/home/HomePage';
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
import LearnPage from './components/learn/LearnPage';
import AccountPage from './components/account/AccountPage';
import InvitePage from './components/invite/InvitePage';
import SupportPage from './components/support/SupportPage';
import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
import SaveSearchModal from './components/ui/SaveSearchModal';
import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
import VerificationBanner from './components/ui/VerificationBanner';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl } from './lib/api';
import { parseUrlState } from './lib/url-state';
@ -23,11 +27,11 @@ declare global {
}
}
function pageToPath(page: Page): string {
function pageToPath(page: Page, inviteCode?: string): string {
switch (page) {
case 'dashboard':
return '/dashboard';
case 'saved-searches':
case 'saved-searches':
return '/saved';
case 'learn':
return '/learn';
@ -35,18 +39,27 @@ case 'saved-searches':
return '/pricing';
case 'account':
return '/account';
case 'invite':
return `/invite/${inviteCode || ''}`;
case 'support':
return '/support';
default:
return '/';
}
}
function pathToPage(pathname: string): Page | null {
if (pathname === '/dashboard') return 'dashboard';
if (pathname === '/saved') return 'saved-searches';
if (pathname === '/learn') return 'learn';
if (pathname === '/pricing') return 'pricing';
if (pathname === '/account') return 'account';
if (pathname === '/') return 'home';
function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null {
if (pathname === '/dashboard') return { page: 'dashboard' };
if (pathname === '/saved') return { page: 'saved-searches' };
if (pathname === '/learn') return { page: 'learn' };
if (pathname === '/pricing') return { page: 'pricing' };
if (pathname === '/account') return { page: 'account' };
if (pathname === '/support') return { page: 'support' };
if (pathname.startsWith('/invite/')) {
const code = pathname.slice('/invite/'.length);
return { page: 'invite', inviteCode: code };
}
if (pathname === '/') return { page: 'home' };
return null;
}
@ -73,12 +86,13 @@ export default function App() {
// UI state
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
const [inviteCode, setInviteCode] = useState<string | null>(null);
const [activePage, setActivePage] = useState<Page>(() => {
if (isScreenshotMode) return 'dashboard';
// Derive page from URL pathname
const fromPath = pathToPage(window.location.pathname);
if (fromPath) return fromPath;
if (fromPath) return fromPath.page;
// Restore from history state (e.g. popstate)
if (window.history.state?.page) return window.history.state.page;
@ -86,6 +100,14 @@ export default function App() {
return 'home';
});
// Initialize invite code from URL
useEffect(() => {
const fromPath = pathToPage(window.location.pathname);
if (fromPath?.inviteCode) {
setInviteCode(fromPath.inviteCode);
}
}, []);
const { theme, toggleTheme } = useTheme();
const isMobile = useIsMobile();
const {
@ -94,13 +116,31 @@ export default function App() {
error: authError,
login,
register,
loginWithOAuth,
logout,
requestPasswordReset,
requestVerification,
refreshAuth,
clearError,
} = useAuth();
const [showAuthModal, setShowAuthModal] = useState(false);
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
const [verificationDismissed, setVerificationDismissed] = useState(false);
// Handle license_success query param (redirect from Stripe)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('license_success') === '1') {
params.delete('license_success');
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
setShowLicenseSuccess(true);
refreshAuth();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const savedSearches = useSavedSearches(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
@ -148,11 +188,11 @@ export default function App() {
if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, '');
}
const path = pageToPath(page);
const path = pageToPath(page, inviteCode ?? undefined);
const url = hash ? `${path}#${hash}` : path;
window.history.pushState({ page }, '', url);
setActivePage(page);
}, []);
}, [inviteCode]);
useEffect(() => {
if (!window.history.state?.page) {
@ -170,8 +210,9 @@ export default function App() {
}
} else {
// Fall back to deriving page from pathname
const page = pathToPage(window.location.pathname);
setActivePage(page || 'home');
const parsed = pathToPage(window.location.pathname);
setActivePage(parsed?.page || 'home');
if (parsed?.inviteCode) setInviteCode(parsed.inviteCode);
}
};
window.addEventListener('popstate', handlePopState);
@ -232,14 +273,51 @@ export default function App() {
onLogout={logout}
isMobile={isMobile}
/>
{user && !user.verified && !verificationDismissed && (
<VerificationBanner
email={user.email}
onRequestVerification={requestVerification}
onDismiss={() => setVerificationDismissed(true)}
/>
)}
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
) : activePage === 'pricing' ? (
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
<PricingPage
onOpenDashboard={() => navigateTo('dashboard')}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
/>
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'account' && user ? (
<AccountPage user={user} onRefreshAuth={refreshAuth} />
<AccountPage user={user} onRefreshAuth={refreshAuth} onRequestVerification={requestVerification} />
) : activePage === 'support' ? (
<SupportPage />
) : activePage === 'invite' && inviteCode ? (
<InvitePage
code={inviteCode}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
onLicenseGranted={() => {
setShowLicenseSuccess(true);
refreshAuth();
}}
/>
) : activePage === 'saved-searches' ? (
<SavedSearchesPage
searches={savedSearches.searches}
@ -265,6 +343,15 @@ export default function App() {
onExportStateChange={setExportState}
isMobile={isMobile}
initialTravelTime={urlState.travelTime}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
/>
)}
{showAuthModal && (
@ -272,6 +359,7 @@ export default function App() {
onClose={() => setShowAuthModal(false)}
onLogin={login}
onRegister={register}
onOAuthLogin={loginWithOAuth}
onForgotPassword={requestPasswordReset}
loading={authLoading}
error={authError}
@ -287,6 +375,9 @@ export default function App() {
error={savedSearches.error}
/>
)}
{showLicenseSuccess && (
<LicenseSuccessModal onClose={() => setShowLicenseSuccess(false)} />
)}
</div>
);
}

View file

@ -3,26 +3,40 @@ import type { AuthUser } from '../../hooks/useAuth';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { ClipboardIcon } from '../ui/icons/ClipboardIcon';
const SUBSCRIPTION_OPTIONS = ['free', 'rental', 'buyer'] as const;
const SUBSCRIPTION_OPTIONS = ['free', 'licensed'] as const;
const SUBSCRIPTION_LABELS: Record<string, string> = {
free: 'Free',
rental: 'Rental',
buyer: 'Buyer',
licensed: 'Licensed',
};
export default function AccountPage({
user,
onRefreshAuth,
onRequestVerification,
}: {
user: AuthUser;
onRefreshAuth: () => Promise<void>;
onRequestVerification: (email: string) => Promise<void>;
}) {
const [selectedSubscription, setSelectedSubscription] = useState(user.subscription || 'free');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [newsletterSaving, setNewsletterSaving] = useState(false);
const [newsletterError, setNewsletterError] = useState<string | null>(null);
// Verification state
const [verificationSending, setVerificationSending] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
// Invite state
const [creatingInvite, setCreatingInvite] = useState(false);
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [inviteError, setInviteError] = useState<string | null>(null);
const [inviteCopied, setInviteCopied] = useState(false);
const handleSave = async () => {
setSaving(true);
@ -48,12 +62,44 @@ export default function AccountPage({
}
};
const handleCreateInvite = async () => {
setCreatingInvite(true);
setInviteError(null);
setInviteUrl(null);
setInviteCopied(false);
try {
const res = await fetch(apiUrl('invites'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
}),
});
assertOk(res, 'Create invite');
const data = await res.json();
setInviteUrl(data.url);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create invite';
setInviteError(msg);
} finally {
setCreatingInvite(false);
}
};
const handleCopyInvite = () => {
if (!inviteUrl) return;
navigator.clipboard.writeText(inviteUrl).then(() => {
setInviteCopied(true);
setTimeout(() => setInviteCopied(false), 2000);
});
};
const badgeColor =
user.subscription === 'buyer'
user.subscription === 'licensed'
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: user.subscription === 'rental'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
@ -67,15 +113,38 @@ export default function AccountPage({
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
</div>
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
user.verified
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{user.verified ? 'Verified' : 'Unverified'}
</span>
<div className="flex items-center gap-2">
{!user.verified && (
<button
onClick={async () => {
setVerificationSending(true);
try {
await onRequestVerification(user.email);
setVerificationSent(true);
setTimeout(() => setVerificationSent(false), 3000);
} catch {
// Error handled by hook
} finally {
setVerificationSending(false);
}
}}
disabled={verificationSending || verificationSent}
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 disabled:opacity-50 flex items-center gap-1"
>
{verificationSending && <SpinnerIcon className="w-3 h-3 animate-spin" />}
{verificationSent ? 'Sent!' : 'Resend verification'}
</button>
)}
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${
user.verified
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{user.verified ? 'Verified' : 'Unverified'}
</span>
</div>
</div>
{/* Subscription */}
@ -88,6 +157,88 @@ export default function AccountPage({
</div>
</div>
{/* Newsletter */}
<div className="px-5 py-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={user.newsletter}
disabled={newsletterSaving}
onChange={async (e) => {
const checked = e.target.checked;
setNewsletterSaving(true);
setNewsletterError(null);
try {
const res = await fetch(apiUrl('newsletter'), {
method: 'PATCH',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newsletter: checked }),
}),
});
assertOk(res, 'Update newsletter');
await onRefreshAuth();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update newsletter';
setNewsletterError(msg);
} finally {
setNewsletterSaving(false);
}
}}
className="w-4 h-4 accent-teal-600 rounded"
/>
<span className="text-navy-950 dark:text-warm-100 text-sm">
Receive newsletter emails
</span>
{newsletterSaving && <SpinnerIcon className="w-4 h-4 animate-spin text-warm-400" />}
</label>
{newsletterError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{newsletterError}</p>
)}
</div>
{/* Invite friends */}
{isLicensed && (
<div className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{user.isAdmin ? 'Generate invite link (free license)' : 'Invite friends (30% off)'}
</p>
{inviteUrl ? (
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={inviteUrl}
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
/>
<button
onClick={handleCopyInvite}
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
>
{inviteCopied ? (
<CheckIcon className="w-4 h-4" />
) : (
<ClipboardIcon className="w-4 h-4" />
)}
{inviteCopied ? 'Copied' : 'Copy'}
</button>
</div>
) : (
<button
onClick={handleCreateInvite}
disabled={creatingInvite}
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
>
{creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{user.isAdmin ? 'Generate invite link' : 'Generate referral link'}
</button>
)}
{inviteError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError}</p>
)}
</div>
)}
{/* Admin section */}
{user.isAdmin && (
<div className="px-5 py-4">

View file

@ -1,9 +1,8 @@
import { useRef, useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
import HomeDemo from './HomeDemo';
import ScrollStory from './ScrollStory';
import BottomIllustration from './BottomIllustration';
import CategoryArt from './CategoryArt';
import { TickerValue } from '../ui/TickerValue';
import type { FeatureMeta } from '../../types';
@ -18,46 +17,36 @@ export default function HomePage({
theme?: 'light' | 'dark';
features?: FeatureMeta[];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [statsActive, setStatsActive] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setStatsActive(true), 300);
return () => clearTimeout(timer);
}, []);
const heroRef = useFadeInRef();
const demoRef = useFadeInRef();
const scaleRef = useFadeInRef();
const problemRef = useFadeInRef();
const whyRef = useFadeInRef();
const howRef = useFadeInRef();
const ctaRef = useFadeInRef();
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="relative" style={{ zIndex: 1 }}>
{/* Hero — full-bleed */}
<div
ref={heroRef}
className="fade-in-section relative overflow-hidden bg-gradient-to-br from-navy-950 via-navy-900 to-teal-900 dark:from-navy-950 dark:via-navy-900 dark:to-teal-900/60 pt-16 pb-20 md:pt-24 md:pb-28 shadow-[0_12px_50px_0px_rgba(13,148,136,0.5)] dark:shadow-[0_12px_50px_0px_rgba(13,148,136,0.4)]"
>
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-br from-navy-950 via-navy-900 to-teal-900 dark:from-navy-950 dark:via-navy-900 dark:to-teal-900/60 pt-16 pb-20 md:pt-24 md:pb-28 shadow-[0_12px_50px_0px_rgba(13,148,136,0.5)] dark:shadow-[0_12px_50px_0px_rgba(13,148,136,0.4)]">
<HexCanvas isDark={theme === 'dark'} />
{/* Radial teal glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-teal-500/[0.07] rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 py-6 backdrop-blur-sm bg-navy-950/30 rounded-2xl">
<p className="text-teal-400 font-semibold tracking-wide uppercase text-sm mb-4">
Browsing listings is not a strategy. Knowing what you want is.
</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-6 leading-[1.1] tracking-tight">
Find your{' '}
<span className="text-teal-400">perfect postcode</span>
<br />
<span className="text-warm-300">before you find your&nbsp;property.</span>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
Get more <span className="text-teal-400">home</span> for your money.
</h1>
<p className="text-xl text-warm-300 mb-8 leading-relaxed max-w-xl">
Set the sliders to your expectations and the map highlights the areas that actually
match. Instantly.
<p className="text-xl text-warm-300 mb-6 leading-relaxed max-w-xl">
Buying a home may be your most important decision. Why not ensure you make your
best-ever decision?
</p>
<div className="flex items-center gap-4 mb-12">
<p className="text-lg text-warm-400 mb-8 max-w-lg">
You have so many options. Picking the best one is daunting and stressful. It
won&apos;t be anymore when looking at the property landscape through our dashboard.
</p>
<div className="flex items-center gap-4 mb-10">
<button
onClick={onOpenDashboard}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
@ -92,124 +81,85 @@ export default function HomePage({
</div>
</div>
{/* Map + Slider demo */}
<div className="max-w-4xl mx-auto px-6 pt-16 pb-20">
<div ref={demoRef} className="fade-in-section">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2">
See it in action
{/* Scrollytelling: Problem + Solution + Demo map */}
<ScrollStory features={features} theme={theme} />
{/* Why existing tools don't cut it */}
<div className="max-w-4xl mx-auto px-6 py-20">
<div ref={whyRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
Why existing tools don&apos;t cut it
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-5 max-w-lg">
Drag the sliders and watch the map respond. Every postcode scored, every filter instant.
</p>
<div className="rounded-2xl backdrop-blur-sm bg-warm-50/40 dark:bg-navy-800/40 border border-warm-200/50 dark:border-navy-700/50 p-4 md:p-6">
<HomeDemo features={features} theme={theme} />
<div className="grid md:grid-cols-3 gap-6">
{WHY_CARDS.map((card) => (
<div
key={card.title}
className="rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-6 shadow-sm"
>
<div className="text-2xl mb-3">{card.icon}</div>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-2">{card.title}</h3>
<p className="text-warm-600 dark:text-warm-400 text-sm leading-relaxed">
{card.description}
</p>
</div>
))}
</div>
<p className="text-center mt-10 text-lg text-warm-700 dark:text-warm-300 max-w-2xl mx-auto leading-relaxed">
We do. 13 million historical transactions. 56 data layers. Real travel-time routing to
any destination. Every postcode in England, scored and filterable, on a single map.
</p>
</div>
</div>
{/* Scale — "That's just two" + category cards */}
<div className="max-w-4xl mx-auto px-6 pb-20">
<div ref={scaleRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-2 text-center">
That&apos;s just three. We&apos;ve built&nbsp;43.
{/* How to use it */}
<div className="max-w-3xl mx-auto px-6 pb-20">
<div ref={howRef} className="fade-in-section">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10 text-center">
How to use it
</h2>
<p className="text-warm-500 dark:text-warm-400 text-center mb-10 max-w-lg mx-auto">
Spanning transport links, amenities, demographics, environment risk, broadband speeds,
crime, and more.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{CATEGORIES.map((c) => (
<div
key={c.label}
className={`rounded-xl border-l-4 border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 p-4 shadow-sm hover:shadow-md transition-shadow ${c.borderClass} ${c.hoverBgClass}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<div
className={`shrink-0 flex items-center justify-center rounded-lg w-8 h-8 text-base ${c.iconBgClass}`}
>
{c.icon}
</div>
<span className="font-semibold text-navy-950 dark:text-warm-100 text-sm">
{c.label}
</span>
</div>
<CategoryArt
category={c.group === 'Environment' && c.label === 'Broadband' ? 'Broadband' : c.group}
className={`shrink-0 ${c.artColorClass} opacity-40`}
/>
</div>
<div className="space-y-8">
{HOW_STEPS.map((step, i) => (
<div key={i} className="flex gap-5">
<div className="shrink-0 w-10 h-10 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-lg">
{i + 1}
</div>
<div>
<h3 className="font-bold text-navy-950 dark:text-warm-100 mb-1">
{step.title}
</h3>
<p className="text-warm-600 dark:text-warm-400 leading-relaxed">
{step.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Problem / solution / philosophy */}
<div className="max-w-4xl mx-auto px-6 pb-20 relative">
{/* Cereal box — quirky margin note, hidden on narrow screens */}
<div className="hidden lg:block group absolute -right-44 top-8 cursor-pointer">
<div className="cereal-wobble">
<img src="/cereal.png" alt="Discounted cereal box" className="w-36 h-auto" />
</div>
<p className="cereal-text text-sm italic text-warm-500 dark:text-warm-400 mt-2 w-[9rem] leading-snug">
Your home is not a box of cereal. Don&apos;t let a discount on the wrong
property distract you from finding the right one.
</p>
</div>
<div ref={problemRef} className="fade-in-section">
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
Here&apos;s the problem with property search: listings only show you what&apos;s on
the market{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">right now</strong>{' '}
&mdash; a thin slice of what an area is actually like. And even if you could look
beyond them, there are{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
millions of postcodes
</strong>{' '}
across England. You can&apos;t research them all yourself.
</p>
<p className="text-lg text-warm-700 dark:text-warm-300 leading-relaxed mb-6">
We built this for you &mdash; years of historical transactions and public records,
extended with proprietary algorithms so the map doesn&apos;t just show raw data, it{' '}
<strong className="font-semibold text-navy-950 dark:text-warm-100">
surfaces the patterns that matter
</strong>
.
</p>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 leading-relaxed">
Understand areas first. Then find the right property within them, with expectations
you&apos;ve set &mdash; not ones the market set for you.
</p>
</div>
</div>
{/* Final CTA */}
{/* The real cost CTA */}
<div className="max-w-3xl mx-auto px-6 pb-12">
<div ref={ctaRef} className="fade-in-section text-center">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
<h2 className="text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100 mb-4 leading-snug">
The biggest financial decision of your life
<br />
deserves proper tools behind&nbsp;it.
</h2>
<p className="text-warm-500 dark:text-warm-400 mb-8 max-w-md mx-auto">
One payment, lifetime access. Set your filters and go.
<p className="text-warm-600 dark:text-warm-400 mb-3 max-w-xl mx-auto leading-relaxed">
Stamp duty on a &pound;400k house: &pound;10,000. Solicitor fees: &pound;1,500.
Survey: &pound;500. Moving costs: &pound;1,000. And that&apos;s just the money. Get the
wrong area and you&apos;re stuck &mdash; with a long commute, bad schools, or a street
that looked fine on the listing photos but turns out to be on a motorway.
</p>
<div className="flex items-center justify-center gap-4">
<button
onClick={onOpenDashboard}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Give your journey a headstart
</button>
<button
onClick={onOpenPricing}
className="px-[30px] py-[14px] border-2 border-navy-950 dark:border-warm-300 text-navy-950 dark:text-warm-300 rounded-lg font-semibold hover:bg-navy-950/5 dark:hover:bg-warm-300/5 transition-colors text-lg"
>
See pricing
</button>
</div>
<p className="text-warm-700 dark:text-warm-300 mb-8 font-semibold">
One payment. Lifetime access. Less than your survey costs and vastly more useful.
</p>
<button
onClick={onOpenDashboard}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Explore the map
</button>
</div>
</div>
@ -220,107 +170,46 @@ export default function HomePage({
);
}
interface Category {
icon: string;
label: string;
group: string;
borderClass: string;
hoverBgClass: string;
iconBgClass: string;
artColorClass: string;
}
const CATEGORIES: Category[] = [
const WHY_CARDS = [
{
icon: '\u{1F3E0}',
label: 'Property',
group: 'Property',
borderClass: 'border-l-teal-400 dark:border-l-teal-500',
hoverBgClass: 'hover:bg-teal-50/50 dark:hover:bg-teal-900/20',
iconBgClass: 'bg-teal-100 dark:bg-teal-900/40',
artColorClass: 'text-teal-400 dark:text-teal-600',
icon: '\u{1F3D8}\uFE0F',
title: 'Listing portals',
description:
"Show you what's for sale today. That's a snapshot, not a strategy. You can filter by price and bedrooms \u2014 that's about it. They tell you nothing about the area.",
},
{
icon: '\u{1F686}',
label: 'Transport',
group: 'Transport',
borderClass: 'border-l-blue-400 dark:border-l-blue-500',
hoverBgClass: 'hover:bg-blue-50/50 dark:hover:bg-blue-900/20',
iconBgClass: 'bg-blue-100 dark:bg-blue-900/40',
artColorClass: 'text-blue-400 dark:text-blue-600',
icon: '\u{1F4CD}',
title: '\u201CCheck my postcode\u201D sites',
description:
"Give you stats for one postcode at a time. Useful if you already know where to look. Useless if you don't \u2014 and there are 1.5 million postcodes in England.",
},
{
icon: '\u{1F3EB}',
label: 'Schools',
group: 'Education',
borderClass: 'border-l-amber-400 dark:border-l-amber-500',
hoverBgClass: 'hover:bg-amber-50/50 dark:hover:bg-amber-900/20',
iconBgClass: 'bg-amber-100 dark:bg-amber-900/40',
artColorClass: 'text-amber-400 dark:text-amber-600',
},
{
icon: '\u{1F6A8}',
label: 'Crime',
group: 'Crime',
borderClass: 'border-l-rose-400 dark:border-l-rose-500',
hoverBgClass: 'hover:bg-rose-50/50 dark:hover:bg-rose-900/20',
iconBgClass: 'bg-rose-100 dark:bg-rose-900/40',
artColorClass: 'text-rose-400 dark:text-rose-600',
},
{
icon: '\u{1F465}',
label: 'Demographics',
group: 'Demographics',
borderClass: 'border-l-violet-400 dark:border-l-violet-500',
hoverBgClass: 'hover:bg-violet-50/50 dark:hover:bg-violet-900/20',
iconBgClass: 'bg-violet-100 dark:bg-violet-900/40',
artColorClass: 'text-violet-400 dark:text-violet-600',
},
{
icon: '\u{1F3EA}',
label: 'Amenities',
group: 'Amenities',
borderClass: 'border-l-emerald-400 dark:border-l-emerald-500',
hoverBgClass: 'hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20',
iconBgClass: 'bg-emerald-100 dark:bg-emerald-900/40',
artColorClass: 'text-emerald-400 dark:text-emerald-600',
},
{
icon: '\u{1F30D}',
label: 'Environment',
group: 'Environment',
borderClass: 'border-l-orange-400 dark:border-l-orange-500',
hoverBgClass: 'hover:bg-orange-50/50 dark:hover:bg-orange-900/20',
iconBgClass: 'bg-orange-100 dark:bg-orange-900/40',
artColorClass: 'text-orange-400 dark:text-orange-600',
},
{
icon: '\u{1F4E1}',
label: 'Broadband',
group: 'Environment',
borderClass: 'border-l-sky-400 dark:border-l-sky-500',
hoverBgClass: 'hover:bg-sky-50/50 dark:hover:bg-sky-900/20',
iconBgClass: 'bg-sky-100 dark:bg-sky-900/40',
artColorClass: 'text-sky-400 dark:text-sky-600',
},
{
icon: '\u{1F4CA}',
label: 'Deprivation',
group: 'Deprivation',
borderClass: 'border-l-fuchsia-400 dark:border-l-fuchsia-500',
hoverBgClass: 'hover:bg-fuchsia-50/50 dark:hover:bg-fuchsia-900/20',
iconBgClass: 'bg-fuchsia-100 dark:bg-fuchsia-900/40',
artColorClass: 'text-fuchsia-400 dark:text-fuchsia-600',
icon: '\u{1F5FA}\uFE0F',
title: 'Area guides',
description:
"Show one statistic on a map \u2014 crime, or school ratings, or prices. But you care about the intersection: affordable AND safe AND good schools AND short commute. Nobody else shows you that.",
},
];
const HOW_STEPS = [
{
title: 'Set your non-negotiables',
description:
'Budget, commute, bedrooms, whatever matters most. The map narrows to only the areas that qualify.',
},
{
title: "Explore what\u2019s left",
description:
"Zoom in. Toggle layers. See crime, schools, noise, amenities. Discover areas you didn\u2019t know existed.",
},
{
title: 'Drill into postcodes',
description:
'At street level, see individual properties, what they sold for, floor area, energy rating, estimated current value.',
},
{
title: 'Go to viewings with a shortlist, not a prayer',
description:
"You\u2019ve already done the hard part. Every area on your list meets your actual criteria, not just what happened to be listed that week.",
},
];

View file

@ -0,0 +1,342 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { FeatureMeta, HexagonData } from '../../types';
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
const DEMO_FEATURE_NAMES = [
'Estimated current price',
'Good+ primary schools within 5km',
'Number of restaurants within 2km',
];
const DEMO_BOUNDS = '49,-9.5,57,5';
const DEMO_RESOLUTION = 5;
const noop = () => {};
// Filter fractions per stage: featureName -> [minFrac, maxFrac]
// 0 = feature.min, 1 = feature.max
interface StageDef {
filters: Record<string, [number, number]>;
colorFeature?: string;
}
const STAGES: StageDef[] = [
// 0: No filters — the problem
{ filters: {} },
// 1: Price filter — "affordable price"
{
filters: { 'Estimated current price': [0, 0.25] },
colorFeature: 'Estimated current price',
},
// 2: Price + schools
{
filters: {
'Estimated current price': [0, 0.25],
'Good+ primary schools within 5km': [0.3, 1],
},
colorFeature: 'Good+ primary schools within 5km',
},
// 3: All three
{
filters: {
'Estimated current price': [0, 0.25],
'Good+ primary schools within 5km': [0.3, 1],
'Number of restaurants within 2km': [0.15, 1],
},
colorFeature: 'Number of restaurants within 2km',
},
// 4: Same filters — "that's just three"
{
filters: {
'Estimated current price': [0, 0.25],
'Good+ primary schools within 5km': [0.3, 1],
'Number of restaurants within 2km': [0.15, 1],
},
},
];
const STEPS: { heading: string | null; body: React.ReactNode }[] = [
{
heading: null,
body: (
<>
<p className="text-lg leading-relaxed mb-4">
You&apos;re about to spend{' '}
<strong className="text-navy-950 dark:text-warm-100">
&pound;300,000&ndash;600,000
</strong>{' '}
on a home. Your research method? Scrolling through listings and hoping for the best.
</p>
<p className="text-lg leading-relaxed mb-4">
Listings only show what&apos;s on the market <em>right now</em> &mdash; a tiny, random
slice of what&apos;s actually out there. You&apos;ll never see the 3-bed Victorian on a
quiet street that sold six months ago, or the one that&apos;ll list next month.
</p>
<p className="text-base italic text-warm-500 dark:text-warm-400">
Your home is not a box of cereal. Don&apos;t let a discount on the wrong property distract
you from finding the right one.
</p>
</>
),
},
{
heading: 'Set your requirements. The map shows you where they intersect.',
body: (
<p className="text-lg leading-relaxed">
Say you want a home at an{' '}
<strong className="text-navy-950 dark:text-warm-100">affordable price</strong>&hellip;
</p>
),
},
{
heading: null,
body: (
<p className="text-lg leading-relaxed">
&hellip;with{' '}
<strong className="text-navy-950 dark:text-warm-100">good primary schools</strong>{' '}
nearby&hellip;
</p>
),
},
{
heading: null,
body: (
<>
<p className="text-lg leading-relaxed mb-4">
&hellip;and{' '}
<strong className="text-navy-950 dark:text-warm-100">
restaurants within walking distance
</strong>
.
</p>
<p className="text-lg leading-relaxed font-semibold text-navy-950 dark:text-warm-100">
You haven&apos;t opened a single listing yet &mdash; and you already know exactly where to
focus.
</p>
</>
),
},
{
heading: null,
body: (
<>
<p className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-2">
That&apos;s just three filters.
</p>
<p className="text-lg leading-relaxed">
We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">43</strong>.
Spanning property prices, commute times, school ratings, crime rates, broadband speeds,
road noise, energy efficiency, amenities, deprivation scores, demographics, and more. All
layered on top of each other, all filterable at once.
</p>
</>
),
},
];
interface ScrollStoryProps {
features: FeatureMeta[];
theme: 'light' | 'dark';
}
export default function ScrollStory({ features, theme }: ScrollStoryProps) {
const [stage, setStage] = useState(0);
const [hexData, setHexData] = useState<HexagonData[]>([]);
const [loading, setLoading] = useState(true);
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
const abortRef = useRef<AbortController>();
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const demoFeatures = useMemo(
() =>
DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter(
Boolean
) as FeatureMeta[],
[features]
);
// Compute actual filter values from stage fractions + feature metadata
const stageFilters = useMemo(() => {
const stageDef = STAGES[stage];
const result: Record<string, [number, number]> = {};
for (const [name, [minFrac, maxFrac]] of Object.entries(stageDef.filters)) {
const meta = demoFeatures.find((f) => f.name === name);
if (meta?.min != null && meta?.max != null) {
const range = meta.max - meta.min;
result[name] = [meta.min + range * minFrac, meta.min + range * maxFrac];
}
}
return result;
}, [stage, demoFeatures]);
// IntersectionObserver for scroll stage detection
useEffect(() => {
const observers: IntersectionObserver[] = [];
stepRefs.current.forEach((el, i) => {
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setStage(i);
},
{ rootMargin: '-35% 0px -35% 0px', threshold: 0 }
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, [demoFeatures.length]);
// Fetch hex data when filters change
useEffect(() => {
if (features.length === 0) return;
const params = new URLSearchParams({
resolution: String(DEMO_RESOLUTION),
bounds: DEMO_BOUNDS,
});
const filterParts: string[] = [];
for (const [name, [min, max]] of Object.entries(stageFilters)) {
filterParts.push(`${name}:${min}:${max}`);
}
if (filterParts.length > 0) params.set('filters', filterParts.join(','));
const stageDef = STAGES[stage];
if (stageDef.colorFeature) params.set('fields', stageDef.colorFeature);
clearTimeout(fetchTimeoutRef.current);
fetchTimeoutRef.current = setTimeout(() => {
abortRef.current?.abort();
abortRef.current = new AbortController();
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
.then((res) => {
assertOk(res, 'hexagons');
return res.json();
})
.then((data: { features: HexagonData[] }) => {
setHexData(data.features);
setLoading(false);
})
.catch((err) => logNonAbortError('Failed to fetch story hexagons', err));
}, 300);
return () => clearTimeout(fetchTimeoutRef.current);
}, [features, stageFilters, stage]);
useEffect(() => {
return () => {
abortRef.current?.abort();
clearTimeout(fetchTimeoutRef.current);
};
}, []);
const stageDef = STAGES[stage];
const viewFeatureName = stageDef.colorFeature || null;
const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null;
const colorRange: [number, number] | null =
viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
return (
<section className="relative">
{/* Sticky map background */}
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
<div className="absolute inset-0">
<MapComponent
data={hexData}
postcodeData={[]}
usePostcodeView={false}
pois={[]}
onViewChange={noop}
viewFeature={viewFeatureName}
colorRange={colorRange}
filterRange={null}
viewSource={viewFeatureName ? 'drag' : null}
onCancelPin={noop}
features={features}
selectedHexagonId={null}
hoveredHexagonId={null}
onHexagonClick={noop}
onHexagonHover={noop}
initialViewState={DEMO_VIEW}
theme={theme}
screenshotMode={true}
hideLegend={true}
/>
</div>
{/* Interaction blocker */}
<div className="absolute inset-0 z-30" />
{/* Loading */}
{loading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
<SpinnerIcon className="w-10 h-10 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
)}
{/* Filter indicators */}
<div className="absolute bottom-4 left-4 z-40 pointer-events-none w-[200px] md:w-[240px]">
<div className="bg-white/85 dark:bg-warm-800/85 rounded-lg p-3 backdrop-blur-sm shadow-lg space-y-2.5">
{demoFeatures.map((feature) => {
const filterVal = stageFilters[feature.name];
const isActive = !!filterVal;
const min = feature.min ?? 0;
const max = feature.max ?? 1;
const range = max - min || 1;
const leftPct = filterVal ? ((filterVal[0] - min) / range) * 100 : 0;
const widthPct = filterVal ? ((filterVal[1] - filterVal[0]) / range) * 100 : 100;
return (
<div
key={feature.name}
className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`}
>
<div className="flex justify-between items-baseline text-[11px] mb-1 gap-2">
<span
className={`font-medium truncate ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
>
{feature.name}
</span>
{isActive && filterVal && (
<span className="text-teal-600 dark:text-teal-400 font-medium whitespace-nowrap">
{formatValue(filterVal[0], feature)}&ndash;
{formatValue(filterVal[1], feature)}
</span>
)}
</div>
<div className="relative h-1.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
<div
className="absolute h-full bg-teal-500 dark:bg-teal-400 rounded-full transition-all duration-700 ease-out"
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Scrolling text overlay */}
<div className="relative z-10 -mt-[60vh] md:-mt-[calc(100dvh-3rem)] pointer-events-none">
<div className="mx-4 md:ml-auto md:mr-[4%] md:max-w-md">
<div className="h-[35vh] md:h-[45vh]" />
{STEPS.map((step, i) => (
<div
key={i}
ref={(el) => {
stepRefs.current[i] = el;
}}
className="pointer-events-auto mb-[30vh] md:mb-[40vh] bg-white/90 dark:bg-warm-800/90 rounded-xl p-5 md:p-6 backdrop-blur-sm shadow-lg border border-warm-200/40 dark:border-warm-700/40"
>
{step.heading && (
<h3 className="text-xl font-bold text-navy-950 dark:text-warm-100 mb-3 leading-snug">
{step.heading}
</h3>
)}
<div className="text-warm-700 dark:text-warm-300">{step.body}</div>
</div>
))}
<div className="h-[30vh] md:h-[40vh]" />
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import type { AuthUser } from '../../hooks/useAuth';
interface InvitePageProps {
code: string;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onLicenseGranted: () => void;
}
interface InviteInfo {
valid: boolean;
invite_type: string;
used: boolean;
}
export default function InvitePage({
code,
user,
onLoginClick,
onRegisterClick,
onLicenseGranted,
}: InvitePageProps) {
const [invite, setInvite] = useState<InviteInfo | null>(null);
const [loading, setLoading] = useState(true);
const [redeeming, setRedeeming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [redeemed, setRedeemed] = useState(false);
const [pricePence, setPricePence] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [inviteRes, pricingRes] = await Promise.all([
fetch(apiUrl(`invite/${encodeURIComponent(code)}`)),
fetch(apiUrl('pricing')),
]);
if (!inviteRes.ok) throw new Error('Failed to validate invite');
const data: InviteInfo = await inviteRes.json();
if (!cancelled) setInvite(data);
if (pricingRes.ok) {
const pricing = await pricingRes.json();
if (!cancelled) setPricePence(pricing.current_price_pence);
}
} catch {
if (!cancelled) setError('Failed to validate invite link');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [code]);
const handleRedeem = useCallback(async () => {
if (!user) return;
setRedeeming(true);
setError(null);
try {
const res = await fetch(apiUrl('redeem-invite'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
}),
});
assertOk(res, 'Redeem invite');
const data = await res.json();
if (data.result === 'licensed') {
setRedeemed(true);
onLicenseGranted();
} else if (data.checkout_url) {
window.location.href = data.checkout_url;
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to redeem invite');
} finally {
setRedeeming(false);
}
}, [code, user, onLicenseGranted]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
);
}
if (error && !invite) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">Invalid invite</p>
<p className="text-warm-500 dark:text-warm-400">{error}</p>
</div>
</div>
);
}
if (!invite?.valid || invite.used) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
{invite?.used ? 'Invite already used' : 'Invalid invite link'}
</p>
<p className="text-warm-500 dark:text-warm-400">
{invite?.used
? 'This invite link has already been redeemed.'
: 'This invite link is invalid or has expired.'}
</p>
</div>
</div>
);
}
if (redeemed) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
</div>
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
License activated!
</p>
<p className="text-warm-500 dark:text-warm-400">
You now have full access to Perfect Postcode.
</p>
</div>
</div>
);
}
const isAdminInvite = invite.invite_type === 'admin';
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">
{isAdminInvite ? "You're invited!" : 'Special offer!'}
</h2>
<p className="text-warm-300 text-sm">
{isAdminInvite
? 'You have been invited to get a free lifetime license.'
: 'A friend has shared a 30% discount on the lifetime license.'}
</p>
</div>
<div className="px-6 py-6">
{isAdminInvite && (
<div className="text-center mb-4">
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">Free</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">lifetime license</span>
</div>
)}
{!isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="text-center mb-4">
<span className="text-warm-400 dark:text-warm-500 line-through text-xl mr-2">
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${Math.round(pricePence * 0.7) / 100}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">/once</span>
</div>
)}
{user ? (
<button
onClick={handleRedeem}
disabled={redeeming}
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"
>
{redeeming && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{isAdminInvite
? redeeming
? 'Activating...'
: 'Activate license'
: redeeming
? 'Redirecting...'
: 'Claim discount'}
</button>
) : (
<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"
>
Register to claim
</button>
<button
onClick={onLoginClick}
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Already have an account? Log in
</button>
</div>
)}
{error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
</div>
</div>
);
}

View file

@ -134,64 +134,79 @@ interface FAQItem {
const FAQ_ITEMS: FAQItem[] = [
{
question: 'What is this application?',
question: 'Are the prices shown current market values?',
answer:
'Perfect Postcode is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
'No. The prices shown are the last known sale price recorded by HM Land Registry, which is the price the property actually sold for. A property last sold in 2005 will show its 2005 price. These are not valuations or estimates of current market value. You can use the "Last known price" filter to focus on properties sold within a recent date range, and compare against the median rental prices for the local authority.',
},
{
question: 'Where does the data come from?',
question: 'What is the "Estimated current price" and how is it calculated?',
answer:
'All data comes from open government and community sources. Property prices are from HM Land Registry, energy certificates from MHCLG, transport times from TfL, deprivation scores from the English Indices of Deprivation 2025, crime data from data.police.uk, school ratings from Ofsted, broadband from Ofcom, noise from Defra, ethnicity from the 2021 Census, and points of interest from OpenStreetMap. See the Data Sources tab for full details and links.',
'The estimated current price is an inflation-adjusted estimate of what a property might be worth today, based on its last known sale price. It works in three stages. First, a repeat-sales price index (built from properties that have sold more than once) tracks how prices have moved within each postcode sector and property type over time — this adjusts the historical sale price to current levels. Second, spatial comparable sales from nearby properties of the same type are used to refine the estimate. Third, a machine learning model captures quality differences that the index alone misses (such as floor area, energy rating, local amenities, and deprivation). Properties with post-sale improvements detected from EPC records — such as extensions or renovations — also receive a renovation premium. The more recently a property sold, the closer the estimate will be to the actual sale price; older sales are adjusted more heavily.',
},
{
question: 'What are the coloured hexagons on the map?',
question: 'How are current for-sale and for-rent listings found?',
answer:
'The map uses H3 hexagons to aggregate property data at different zoom levels. Each hexagon summarises the properties within it. The colour represents the value of whichever feature you have pinned or are actively filtering — for example, average price or energy rating. Zoom in to see smaller, more detailed hexagons; zoom out for a broader overview.',
'Properties currently on the market are sourced by periodically searching independent property portals (Rightmove, OnTheMarket, and Zoopla). These listings are fuzzy-matched by address to existing Land Registry records so that current asking prices appear alongside the historical sale price, EPC data, and all area-level statistics. You can filter by "Listing status" to show only properties currently for sale or for rent. When you click on a hexagon, you\'ll also see direct links to search Rightmove, OnTheMarket, and Zoopla for that area, pre-filled with your active price filters.',
},
{
question: 'How do filters work?',
question: 'What area does this cover?',
answer:
'Use the Filters panel on the left to narrow down properties. Add a filter by clicking a feature name, then drag the range slider to set minimum and maximum values. For categorical features like property type, select or deselect individual values. Only hexagons containing properties that match all active filters are shown. Filters are combined with AND logic — every property must satisfy every filter.',
'England. The core datasets — Land Registry prices, EPC energy certificates, deprivation indices, crime statistics, school ratings, broadband speeds, noise mapping, and council tax — all cover England. Points of interest from OpenStreetMap cover Great Britain, but the property-level data is England only.',
},
{
question: 'What does the eye icon do on a filter?',
question: 'Why is data missing for my property?',
answer:
"The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.",
'There are a few common reasons. If a property has never been sold (or was last sold before Land Registry digital records began in 1995), there will be no price record. EPC data may be missing if the property has never had an energy assessment, or if the owner has opted out of public disclosure. Floor area, number of rooms, and energy ratings all come from EPC records, so a missing EPC means those fields will be blank. Finally, the fuzzy address matching between EPC and Land Registry records occasionally fails for unusual addresses.',
},
{
question: 'How fresh is the data?',
question: 'How do I find areas that match what I\'m looking for?',
answer:
'Property prices cover all Land Registry transactions up to the most recent quarterly release. EPC data includes certificates issued up to the latest available download. Crime data spans 20232025 as yearly averages. TfL journey times are computed from current timetables. Deprivation indices are from the 2025 release. School ratings reflect the latest Ofsted inspections as at April 2025. Broadband data is from Ofcom Connected Nations 2025.',
'Use the Filters panel on the left. Add filters for the features you care about — for example, set a price range, require a minimum energy rating, or select "Freehold" only. All filters combine with AND logic, so every property must satisfy every filter. Use the eye icon to pin a feature as the colour source — this lets you, say, colour the map by price while filtering on floor area and energy rating at the same time. The hexagons will update in real time as you adjust.',
},
{
question: 'How are EPC records matched to Land Registry sales?',
question: 'How does the travel time feature work?',
answer:
"EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.",
'Click the travel time icon in the filters panel, search for a destination (any address or postcode in England), and choose a transport mode (car, bicycle, walking, or public transport). The map will colour hexagons by average journey time to that destination. You can add a time range filter to only show areas within, say, 30 minutes. Multiple destinations can be added simultaneously to find areas that are well-connected to several places.',
},
{
question: 'What are Points of Interest (POIs)?',
question: 'Can I export the data I\'m looking at?',
answer:
'POIs are places like cafes, schools, supermarkets, GP surgeries, parks, and train stations extracted from OpenStreetMap and the NaPTAN public transport dataset. Use the POI panel on the right to toggle categories on and off. POIs appear as markers on the map when you are zoomed in far enough.',
'Yes. Use the export button to download the currently filtered properties within your map view as an Excel spreadsheet. The export respects all your active filters, so you can narrow down to exactly the properties you want before downloading.',
},
{
question: 'What do the deprivation scores mean?',
answer:
'The English Indices of Deprivation 2025 rank every small area (LSOA, roughly 1,500 people) in England from most to least deprived. A rank of 1 means the most deprived area in the country. The scores cover seven domains: Income, Employment, Education, Health, Crime, Barriers to Housing & Services, and Living Environment. Each domain can be filtered independently. Lower rank numbers indicate higher deprivation.',
},
{
question: 'How reliable is the crime data at this scale?',
answer:
'Crime figures are sourced from data.police.uk and aggregated as yearly averages (2023\u20132025) per LSOA — an area of roughly 1,500 people. This means the numbers reflect a neighbourhood average, not a specific street. Crime counts are broken down by type (violence, burglary, anti-social behaviour, vehicle crime, etc.) so you can filter on the categories that matter to you. As with all area-level statistics, they are useful for comparing neighbourhoods but should not be over-interpreted for individual streets.',
},
{
question: 'What does the school rating represent?',
answer:
'The school rating is the average Ofsted inspection outcome for state-funded schools near each postcode. Ofsted grades schools from 1 (Outstanding) to 4 (Inadequate). A value of 1.5 for a postcode means the nearby schools average between Outstanding and Good. This covers primary and secondary schools with inspection results as at April 2025.',
},
{
question: 'What happens when I zoom in very far?',
answer:
'At lower zoom levels, properties are grouped into hexagons that get smaller as you zoom in. When you zoom past level 15, the map switches from hexagons to individual postcode polygons, showing the actual postcode boundary shapes. Click any postcode polygon to see the properties within it.',
},
{
question: 'Can I share a specific view with someone?',
answer:
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL to your clipboard. Anyone who opens that link will see the same view, filters, and active POI categories.',
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL. Anyone who opens that link will see the same map position, zoom level, filters, pinned feature, and active POI categories.',
},
{
question: 'How do I see individual properties?',
question: 'How can I remove my property from the map?',
answer:
'Click on a hexagon to open the Properties panel on the right. It lists all matching properties within that hexagon, showing address, price, and key features. Use "Load more" at the bottom to paginate through large hexagons.',
'Property sale prices are public records from HM Land Registry and cannot be removed. EPC data (energy rating, floor area, number of rooms, etc.) can be removed by opting out of public disclosure through the government\u2019s official process at gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure. Once opted out, your EPC data will no longer appear in future data updates.',
},
{
question: 'Why are some hexagons grey?',
question: 'How often is the data updated?',
answer:
'Grey hexagons contain properties that have data but fall outside the range of your currently pinned or active feature. This gives you a sense of where properties exist even when their values are outside your selected range.',
},
{
question: 'Does this work on mobile?',
answer:
'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.',
'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source.',
},
];

View file

@ -11,7 +11,7 @@ import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { RouteIcon, PlusIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
@ -22,8 +22,8 @@ interface FeatureBrowserProps {
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
activeTravelModes: TransportMode[];
onEnableTravelMode: (mode: TransportMode) => void;
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
}
export default function FeatureBrowser({
@ -35,8 +35,8 @@ export default function FeatureBrowser({
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
activeTravelModes,
onEnableTravelMode,
travelTimeEntries,
onAddTravelTimeEntry,
}: FeatureBrowserProps) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -61,15 +61,9 @@ export default function FeatureBrowser({
// When searching, expand all groups so results are visible
const isSearching = search.length > 0;
// Inactive modes available to add
const inactiveModes = useMemo(
() => TRANSPORT_MODES.filter((m) => !activeTravelModes.includes(m)),
[activeTravelModes]
);
// All modes are always available (can add multiple entries per mode)
const showTravelModes =
inactiveModes.length > 0 &&
(!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase()));
!search || 'travel time journey commute car bicycle walking transit'.includes(search.toLowerCase());
return (
<>
@ -77,21 +71,21 @@ export default function FeatureBrowser({
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
</div>
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
{showTravelModes && inactiveModes.map((mode) => (
{showTravelModes && TRANSPORT_MODES.map((mode) => (
<div key={mode} className="shrink-0 border-b border-warm-200 dark:border-warm-700">
<div className="flex items-start justify-between px-3 py-2 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer">
<div className="flex items-center gap-2 min-w-0" onClick={() => onEnableTravelMode(mode)}>
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time ({MODE_LABELS[mode]})
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
Color by journey time to a destination
Filter by journey time to a destination
</span>
</div>
</div>
<IconButton onClick={() => onEnableTravelMode(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
<PlusIcon className="w-3.5 h-3.5" />
</IconButton>
</div>
@ -139,7 +133,7 @@ export default function FeatureBrowser({
</div>
);
})}
{grouped.length === 0 && !showTravelModes ? (
{grouped.length === 0 ? (
<EmptyState
icon={<FilterIcon className="w-8 h-8 text-warm-300 dark:text-warm-600" />}
title={search ? 'No matching features' : 'All features are active'}

View file

@ -18,9 +18,8 @@ import AiFilterInput from './AiFilterInput';
import FeatureBrowser from './FeatureBrowser';
import { TravelTimeCard } from './TravelTimeCard';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
type TravelTimeEntry,
} from '../../hooks/useTravelTime';
function SliderLabels({
@ -77,12 +76,12 @@ interface FiltersProps {
onNavigateToSource?: (slug: string, featureName: string) => void;
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntries;
travelTimeDataRanges: Partial<Record<TransportMode, [number, number]>>;
onTravelTimeEnableMode: (mode: TransportMode) => void;
onTravelTimeDisableMode: (mode: TransportMode) => void;
onTravelTimeSetDestination: (mode: TransportMode, lat: number, lon: number, label: string) => void;
onTravelTimeRangeChange: (mode: TransportMode, range: [number, number]) => void;
travelTimeEntries: TravelTimeEntry[];
travelTimeDataRanges: Map<number, [number, number]>;
onTravelTimeAddEntry: (mode: TransportMode) => void;
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterNotes: string | null;
@ -109,8 +108,8 @@ export default memo(function Filters({
onClearOpenInfoFeature,
travelTimeEntries,
travelTimeDataRanges,
onTravelTimeEnableMode,
onTravelTimeDisableMode,
onTravelTimeAddEntry,
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
onTravelTimeRangeChange,
aiFilterLoading,
@ -156,10 +155,7 @@ export default memo(function Filters({
const [activeInfoFeature, setActiveInfoFeature] = useState<FeatureMeta | null>(null);
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
const activeModes = useMemo(
() => TRANSPORT_MODES.filter((m) => m in travelTimeEntries),
[travelTimeEntries]
);
const activeEntryCount = travelTimeEntries.length;
const handleAddAndScroll = useCallback(
(name: string) => {
@ -186,7 +182,7 @@ export default memo(function Filters({
}, [features]);
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
const badgeCount = enabledFeatureList.length + activeModes.length + (hasListingFilter ? 1 : 0);
const badgeCount = enabledFeatureList.length + activeEntryCount + (hasListingFilter ? 1 : 0);
return (
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
@ -228,25 +224,22 @@ export default memo(function Filters({
</div>
<div className="md:flex-1 md:overflow-y-auto">
{activeModes.map((mode) => {
const entry = travelTimeEntries[mode]!;
return (
<div key={mode} className="px-2 py-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} className="px-2 py-1">
<TravelTimeCard
mode={mode}
destination={entry.destination}
destinationLabel={entry.destinationLabel}
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
dataRange={travelTimeDataRanges[mode] ?? null}
onSetDestination={(lat, lon, label) => onTravelTimeSetDestination(mode, lat, lon, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(mode, range)}
onRemove={() => onTravelTimeDisableMode(mode)}
dataRange={travelTimeDataRanges.get(index) ?? null}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
);
})}
))}
{enabledFeatureList.length === 0 && activeModes.length === 0 && (
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
Browse features below and click + to add a filter
</p>
@ -378,8 +371,8 @@ export default memo(function Filters({
onNavigateToSource={onNavigateToSource}
openInfoFeature={openInfoFeature}
onClearOpenInfoFeature={onClearOpenInfoFeature}
activeTravelModes={activeModes}
onEnableTravelMode={onTravelTimeEnableMode}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onTravelTimeAddEntry}
/>
</div>
</div>

View file

@ -51,7 +51,7 @@ export default function LocationSearch({
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isMobile, search]);
}, [isMobile, search.close]);
// Focus input when expanding on mobile
useEffect(() => {

View file

@ -21,7 +21,7 @@ import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
import type { FeatureFilters } from '../../types';
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
import { MODE_LABELS, type TransportMode, type TravelTimeEntries } from '../../hooks/useTravelTime';
import { MODE_LABELS, type TravelTimeEntry, travelFieldKey } from '../../hooks/useTravelTime';
interface MapProps {
data: HexagonData[];
@ -48,10 +48,13 @@ interface MapProps {
onLocationSearched?: (location: SearchedLocation | null) => void;
bounds?: Bounds | null;
hideLegend?: boolean;
travelTimeEntries?: TravelTimeEntries;
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
const EMPTY_TRAVEL_RANGES = new globalThis.Map<number, [number, number]>();
interface Dimensions {
width: number;
height: number;
@ -102,8 +105,8 @@ export default memo(function Map({
onLocationSearched,
bounds: viewportBounds,
hideLegend = false,
travelTimeEntries = {},
travelTimeColorRanges = {},
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
travelTimeColorRanges = EMPTY_TRAVEL_RANGES,
}: MapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
@ -166,7 +169,7 @@ export default memo(function Map({
postcodeCountRange,
colorFeatureMeta,
handleMouseLeave,
primaryTravelMode,
primaryTravelIndex,
} = useDeckLayers({
data,
postcodeData,
@ -221,10 +224,10 @@ export default memo(function Map({
<>
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} />
{!hideLegend &&
(primaryTravelMode && travelTimeColorRanges[primaryTravelMode] ? (
(primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[primaryTravelMode]})`}
range={travelTimeColorRanges[primaryTravelMode]!}
featureLabel={`Travel time (${MODE_LABELS[travelTimeEntries[primaryTravelIndex].mode]})`}
range={travelTimeColorRanges.get(primaryTravelIndex)!}
showCancel={false}
onCancel={onCancelPin}
mode="feature"

View file

@ -23,12 +23,13 @@ import { getTutorialStyles } from '../../lib/tutorial-styles';
import Joyride from 'react-joyride';
import {
useTravelTime,
TRANSPORT_MODES,
MODE_LABELS,
type TransportMode,
travelFieldKey,
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, assertOk, buildFilterString, logNonAbortError } from '../../lib/api';
import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
@ -54,6 +55,9 @@ interface MapPageProps {
ogMode?: boolean;
isMobile?: boolean;
initialTravelTime?: TravelTimeInitial;
user?: { id: string; subscription: string } | null;
onLoginClick?: () => void;
onRegisterClick?: () => void;
}
export default function MapPage({
@ -73,6 +77,9 @@ export default function MapPage({
ogMode,
isMobile = false,
initialTravelTime,
user,
onLoginClick,
onRegisterClick,
}: MapPageProps) {
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
@ -125,6 +132,9 @@ export default function MapPage({
// Travel time hook
const travelTime = useTravelTime(initialTravelTime);
// License hook
const license = useLicense();
// Map data hook
const mapData = useMapData({
filters,
@ -164,20 +174,21 @@ export default function MapPage({
// POI data
const pois = usePOIData(mapData.bounds, selectedPOICategories);
// Compute data range for travel time slider per mode (full min/max for slider bounds)
const travelTimeDataRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
for (const mode of TRANSPORT_MODES) {
const entry = travelTime.entries[mode];
if (!entry?.destination) continue;
// Compute data range for travel time slider per entry index (full min/max for slider bounds)
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
const ranges = new globalThis.Map<number, [number, number]>();
for (let i = 0; i < travelTime.entries.length; i++) {
const entry = travelTime.entries[i];
if (!entry.slug) continue;
const fieldName = `avg_${travelFieldKey(entry)}`;
const vals: number[] = [];
for (const item of mapData.data) {
const val = item[`travel_time_${mode}`];
const val = item[fieldName];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges[mode] = [vals[0], vals[vals.length - 1]];
ranges.set(i, [vals[0], vals[vals.length - 1]]);
}
return ranges;
}, [travelTime.entries, mapData.data]);
@ -253,7 +264,7 @@ export default function MapPage({
const url = apiUrl('export', params);
setExporting(true);
fetch(url)
fetch(url, authHeaders())
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
@ -397,8 +408,8 @@ export default function MapPage({
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeEnableMode={travelTime.handleEnableMode}
onTravelTimeDisableMode={travelTime.handleDisableMode}
onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={travelTime.handleRemoveEntry}
onTravelTimeSetDestination={travelTime.handleSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
aiFilterLoading={aiFilters.loading}
@ -478,14 +489,14 @@ export default function MapPage({
>
{/* Legend */}
{(() => {
const primaryMode = TRANSPORT_MODES.find(
(m) => travelTime.entries[m]?.destination && mapData.travelTimeColorRanges[m]
const primaryIdx = travelTime.entries.findIndex(
(e, i) => e.slug && mapData.travelTimeColorRanges.get(i)
);
if (primaryMode) {
if (primaryIdx >= 0) {
return (
<MapLegend
featureLabel={`Travel time (${MODE_LABELS[primaryMode]})`}
range={mapData.travelTimeColorRanges[primaryMode]!}
featureLabel={`Travel time (${MODE_LABELS[travelTime.entries[primaryIdx].mode]})`}
range={mapData.travelTimeColorRanges.get(primaryIdx)!}
showCancel={false}
onCancel={handleCancelPin}
mode="feature"
@ -539,6 +550,16 @@ export default function MapPage({
renderProperties={renderPropertiesPane}
/>
)}
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
/>
)}
</div>
);
}
@ -664,6 +685,16 @@ export default function MapPage({
</div>
</div>
</div>
{mapData.licenseRequired && (
<UpgradeModal
isLoggedIn={!!user}
onLoginClick={onLoginClick ?? (() => {})}
onRegisterClick={onRegisterClick ?? (() => {})}
onStartCheckout={() => license.startCheckout()}
onDismiss={() => {}}
/>
)}
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useRef, useEffect, useCallback } from 'react';
import { Slider } from '../ui/Slider';
import { IconButton } from '../ui/IconButton';
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
@ -6,34 +6,31 @@ import { CloseIcon } from '../ui/icons/CloseIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { RouteIcon } from '../ui/icons/RouteIcon';
import { formatFilterValue } from '../../lib/format';
import { authHeaders, logNonAbortError } from '../../lib/api';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
interface TravelTimeCardProps {
mode: TransportMode;
destination: [number, number] | null;
destinationLabel: string;
slug: string;
label: string;
timeRange: [number, number] | null;
dataRange: [number, number] | null;
onSetDestination: (lat: number, lon: number, label: string) => void;
onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void;
onRemove: () => void;
}
export function TravelTimeCard({
mode,
destination,
destinationLabel,
slug,
label,
timeRange,
dataRange,
onSetDestination,
onTimeRangeChange,
onRemove,
}: TravelTimeCardProps) {
const search = useLocationSearch();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const search = useLocationSearch(mode);
const containerRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
@ -45,42 +42,16 @@ export function TravelTimeCard({
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [search]);
}, [search.close]);
const selectResult = useCallback(
async (result: SearchResult) => {
(result: SearchResult) => {
if (result.type === 'place') {
onSetDestination(result.lat, result.lon, result.name);
onSetDestination(result.slug, result.name);
search.clear();
setError(null);
return;
}
// Postcode — fetch coordinates
setError(null);
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.label)}`,
authHeaders(),
);
if (!res.ok) {
setError('Postcode not found');
return;
}
const json: { postcode: string; latitude: number; longitude: number } =
await res.json();
onSetDestination(json.latitude, json.longitude, json.postcode);
search.clear();
} catch (err) {
logNonAbortError('Postcode lookup failed', err);
setError('Lookup failed');
} finally {
setLoading(false);
}
},
[onSetDestination, search],
[onSetDestination, search.clear],
);
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
@ -107,28 +78,23 @@ export function TravelTimeCard({
<PlaceSearchInput
search={search}
onSelect={selectResult}
loading={loading}
placeholder={destination ? 'Change destination...' : 'Search destination...'}
placeholder={slug ? 'Change destination...' : 'Search destination...'}
size="xs"
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
onInputChange={() => setError(null)}
/>
{error && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{error}</p>
)}
{destination && destinationLabel && (
{slug && label && (
<div className="flex items-center gap-1 mt-1">
<MapPinIcon className="w-3 h-3 text-red-500 shrink-0" />
<span className="text-xs text-warm-600 dark:text-warm-300">
{destinationLabel}
{label}
</span>
</div>
)}
</div>
{/* Time range slider — only show when we have data */}
{destination && dataRange && (
{slug && dataRange && (
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Max time

View file

@ -1,4 +1,9 @@
import { useState, useEffect } from 'react';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { AuthUser } from '../../hooks/useAuth';
import { useLicense } from '../../hooks/useLicense';
import { apiUrl } from '../../lib/api';
const FEATURES = [
'56 data layers across England',
@ -9,20 +14,82 @@ const FEATURES = [
'All future data updates included',
];
interface PricingTier {
up_to: number | null;
price_pence: number;
slots: number;
}
interface PricingData {
licensed_count: number;
current_price_pence: number;
tiers: PricingTier[];
}
function formatPrice(pence: number): string {
if (pence === 0) return 'Free';
return `\u00A3${pence / 100}`;
}
function tierLabel(tier: PricingTier, index: number): string {
if (index === 0) return `First ${tier.slots} users`;
if (tier.up_to === null) return 'Everyone after';
return `Next ${tier.slots} users`;
}
export default function PricingPage({
onOpenDashboard,
user,
onLoginClick,
onRegisterClick,
}: {
onOpenDashboard: () => void;
user?: AuthUser | null;
onLoginClick?: () => void;
onRegisterClick?: () => void;
}) {
const license = useLicense();
const [pricing, setPricing] = useState<PricingData | null>(null);
useEffect(() => {
fetch(apiUrl('pricing'))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setPricing)
.catch((err) => console.error('Failed to load pricing:', err));
}, []);
const isLicensed = user?.subscription === 'licensed' || user?.isAdmin;
const currentPrice = pricing?.current_price_pence ?? 10000;
const isFree = currentPrice === 0;
// Find current tier index and remaining spots
let currentTierIndex = (pricing?.tiers.length ?? 1) - 1;
let spotsRemaining = 0;
if (pricing) {
for (let i = 0; i < pricing.tiers.length; i++) {
const tier = pricing.tiers[i];
if (tier.up_to === null || pricing.licensed_count < tier.up_to) {
currentTierIndex = i;
spotsRemaining =
tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
break;
}
}
}
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-3xl mx-auto px-6 py-16">
<div className="text-center mb-12">
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-warm-100 mb-3">
One price. Yours forever.
Early access pricing
</h1>
<p className="text-lg text-warm-500 dark:text-warm-400 max-w-lg mx-auto">
No subscriptions, no recurring fees. Pay once and get lifetime access to every feature.
No subscriptions, no recurring fees. Pay once and get lifetime
access to every feature. The earlier you join, the less you pay.
</p>
</div>
@ -33,33 +100,147 @@ export default function PricingPage({
Lifetime License
</div>
<div className="flex items-baseline justify-center gap-1">
<span className="text-5xl font-extrabold text-white">£100</span>
<span className="text-warm-400 text-lg">/once</span>
<span className="text-5xl font-extrabold text-white">
{pricing ? formatPrice(currentPrice) : '...'}
</span>
{!isFree && (
<span className="text-warm-400 text-lg">/once</span>
)}
</div>
<p className="text-warm-300 text-sm mt-2">
One-time payment, no subscription
{spotsRemaining > 0 && pricing && (
<p className="text-teal-300 text-sm mt-2 font-medium">
{spotsRemaining} spot{spotsRemaining !== 1 ? 's' : ''}{' '}
remaining at this price
</p>
)}
<p className="text-warm-300 text-sm mt-1">
{isFree
? 'Free for early adopters'
: 'One-time payment, no subscription'}
</p>
</div>
{/* Features list */}
<div className="px-8 py-8">
{/* Tier breakdown */}
{pricing && (
<div className="mb-8 space-y-1.5">
<p className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
Pricing tiers
</p>
{pricing.tiers.map((tier, i) => {
const isCurrent = i === currentTierIndex;
const isFilled =
tier.up_to !== null &&
pricing.licensed_count >= tier.up_to;
const filledInTier = isCurrent
? pricing.licensed_count -
(i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
: 0;
const tierSlots = tier.slots;
const fillPercent = isFilled
? 100
: isCurrent && tierSlots > 0
? (filledInTier / tierSlots) * 100
: 0;
return (
<div
key={i}
className={`relative flex items-center justify-between px-3 py-2 rounded-lg text-sm ${
isCurrent
? 'bg-teal-50 dark:bg-teal-900/30 ring-1 ring-teal-400'
: isFilled
? 'opacity-50'
: ''
}`}
>
<span
className={`${isCurrent ? 'text-navy-950 dark:text-warm-100 font-medium' : 'text-warm-600 dark:text-warm-400'}`}
>
{tierLabel(tier, i)}
</span>
<div className="flex items-center gap-2">
{isCurrent && tierSlots > 0 && (
<div className="w-16 h-1.5 rounded-full bg-warm-200 dark:bg-warm-700 overflow-hidden">
<div
className="h-full rounded-full bg-teal-500"
style={{ width: `${fillPercent}%` }}
/>
</div>
)}
<span
className={`font-semibold ${
isCurrent
? 'text-teal-700 dark:text-teal-400'
: isFilled
? 'text-warm-400 dark:text-warm-500 line-through'
: 'text-warm-600 dark:text-warm-400'
}`}
>
{formatPrice(tier.price_pence)}
</span>
{isFilled && (
<CheckIcon className="w-4 h-4 text-warm-400 dark:text-warm-500" />
)}
</div>
</div>
);
})}
</div>
)}
{/* Features list */}
<ul className="space-y-4">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-start gap-3">
<CheckIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feature}</span>
<span className="text-warm-700 dark:text-warm-300">
{feature}
</span>
</li>
))}
</ul>
<button
onClick={onOpenDashboard}
className="w-full mt-8 px-6 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Get started
</button>
{isLicensed ? (
<button
onClick={onOpenDashboard}
className="w-full mt-8 px-6 py-4 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
>
Open dashboard
</button>
) : user ? (
<button
onClick={() => license.startCheckout()}
disabled={license.checkingOut}
className="w-full mt-8 px-6 py-4 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"
>
{license.checkingOut && (
<SpinnerIcon className="w-5 h-5 animate-spin" />
)}
{license.checkingOut
? 'Redirecting...'
: isFree
? 'Claim free license'
: `Get started \u2014 ${formatPrice(currentPrice)}`}
</button>
) : (
<button
onClick={onRegisterClick}
className="w-full mt-8 px-6 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
{isFree ? 'Claim free license' : 'Get started'}
</button>
)}
{license.error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">
{license.error}
</p>
)}
<p className="text-center text-sm text-warm-400 dark:text-warm-500 mt-3">
30-day money-back guarantee
{isFree
? 'No credit card required'
: '30-day money-back guarantee'}
</p>
</div>
</div>

View file

@ -0,0 +1,99 @@
import { useState, useCallback } from 'react';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
const FAQ_ITEMS = [
{
question: 'What data is included?',
answer:
'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.',
},
{
question: 'What can I access on the free tier?',
answer:
'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need a lifetime license.',
},
{
question: 'What does "lifetime" mean?',
answer:
'Your license never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.',
},
{
question: 'Can I get a refund?',
answer:
'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@propertymap.co.uk within 30 days of purchase for a full refund.',
},
{
question: 'How often is the data updated?',
answer:
'We update the data regularly as new Land Registry, EPC, crime, and other government datasets are published. Updates are typically quarterly. All updates are included with your license at no extra cost.',
},
];
function FAQItem({ question, answer }: { question: string; answer: string }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-warm-200 dark:border-warm-700 last:border-b-0">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between px-5 py-4 text-left"
>
<span className="text-navy-950 dark:text-warm-100 font-medium pr-4">{question}</span>
<ChevronIcon
direction="down"
className={`w-5 h-5 text-warm-400 dark:text-warm-500 shrink-0 transition-transform ${
open ? 'rotate-180' : ''
}`}
/>
</button>
{open && (
<div className="px-5 pb-4">
<p className="text-warm-600 dark:text-warm-300 text-sm leading-relaxed">{answer}</p>
</div>
)}
</div>
);
}
export default function SupportPage() {
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
<div className="max-w-2xl mx-auto px-6 py-16">
<div className="text-center mb-12">
<h1 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
Support & FAQ
</h1>
<p className="text-lg text-warm-500 dark:text-warm-400">
Have a question? Check below or reach out to us directly.
</p>
</div>
{/* Contact */}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 mb-8 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
<a
href="mailto:support@propertymap.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@propertymap.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
We typically respond within 24 hours.
</p>
</div>
{/* FAQ */}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 overflow-hidden">
<div className="px-5 py-4 border-b border-warm-200 dark:border-warm-700">
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
Frequently Asked Questions
</h2>
</div>
{FAQ_ITEMS.map((item) => (
<FAQItem key={item.question} question={item.question} answer={item.answer} />
))}
</div>
</div>
</div>
);
}

View file

@ -1,5 +1,7 @@
import { useState, useCallback } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { AppleIcon } from './icons/AppleIcon';
type View = 'login' | 'register' | 'forgot';
@ -7,6 +9,7 @@ export default function AuthModal({
onClose,
onLogin,
onRegister,
onOAuthLogin,
onForgotPassword,
loading,
error,
@ -16,6 +19,7 @@ export default function AuthModal({
onClose: () => void;
onLogin: (email: string, password: string) => Promise<void>;
onRegister: (email: string, password: string) => Promise<void>;
onOAuthLogin: (provider: string) => Promise<void>;
onForgotPassword: (email: string) => Promise<void>;
loading: boolean;
error: string | null;
@ -57,6 +61,18 @@ export default function AuthModal({
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
);
const handleOAuth = useCallback(
async (provider: string) => {
try {
await onOAuthLogin(provider);
onClose();
} catch {
// Error is handled by the hook
}
},
[onOAuthLogin, onClose]
);
const title =
view === 'login' ? 'Log in' : view === 'register' ? 'Create account' : 'Reset password';
@ -104,82 +120,117 @@ export default function AuthModal({
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="you@example.com"
/>
</div>
<div className="p-5 space-y-4">
{/* OAuth buttons (hidden in forgot view) */}
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (
<>
<div className="space-y-2">
<button
type="button"
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
onClick={() => handleOAuth('google')}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-100 text-sm font-medium hover:bg-warm-50 dark:hover:bg-warm-700 disabled:opacity-50 disabled:cursor-wait"
>
Forgot password?
<GoogleIcon className="w-4 h-4" />
Continue with Google
</button>
)}
<button
type="button"
onClick={() => handleOAuth('apple')}
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded bg-navy-950 dark:bg-white text-white dark:text-navy-950 text-sm font-medium hover:bg-navy-900 dark:hover:bg-warm-100 disabled:opacity-50 disabled:cursor-wait"
>
<AppleIcon className="w-4 h-4" />
Continue with Apple
</button>
</div>
{/* Divider */}
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
<span className="text-xs text-warm-400 dark:text-warm-500">or</span>
<div className="flex-1 h-px bg-warm-200 dark:bg-warm-700" />
</div>
</>
)}
{/* Email form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="you@example.com"
/>
</div>
)}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
</p>
)}
{view !== 'forgot' && (
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={view === 'register' ? 'Min 8 characters' : 'Your password'}
/>
{view === 'login' && (
<button
type="button"
onClick={() => switchView('forgot')}
className="mt-1 text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Forgot password?
</button>
)}
</div>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{view === 'forgot' && resetSent && (
<p className="text-sm text-teal-700 dark:text-teal-400">
Check your email for a reset link.
</p>
)}
{!(view === 'forgot' && resetSent) && (
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading
? 'Please wait...'
: view === 'login'
? 'Log in'
: view === 'register'
? 'Create account'
: 'Send reset link'}
</button>
)}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{view === 'forgot' && (
<button
type="button"
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
</button>
)}
</form>
{!(view === 'forgot' && resetSent) && (
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading
? 'Please wait...'
: view === 'login'
? 'Log in'
: view === 'register'
? 'Create account'
: 'Send reset link'}
</button>
)}
{view === 'forgot' && (
<button
type="button"
onClick={() => switchView('login')}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Back to login
</button>
)}
</form>
</div>
</div>
</div>
);

View file

@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account';
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account' | 'invite' | 'support';
export default function Header({
activePage,
@ -139,6 +139,9 @@ export default function Header({
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
Pricing
</button>
<button className={tabClass('support')} onClick={() => onPageChange('support')}>
Support
</button>
</nav>
)}
</div>

View file

@ -0,0 +1,92 @@
import { useEffect, useMemo } from 'react';
interface LicenseSuccessModalProps {
onClose: () => void;
}
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
// Generate confetti particles once
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
id: i,
left: Math.random() * 100,
delay: Math.random() * 2,
duration: 2 + Math.random() * 2,
color: ['#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'][
Math.floor(Math.random() * 6)
],
size: 6 + Math.random() * 6,
})),
[]
);
// Auto-dismiss after 8 seconds
useEffect(() => {
const timer = setTimeout(onClose, 8000);
return () => clearTimeout(timer);
}, [onClose]);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
{/* Confetti */}
<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: Math.random() > 0.5 ? '50%' : '2px',
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
/>
))}
</div>
{/* Card */}
<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">Welcome aboard!</h2>
<p className="text-warm-300 text-sm mt-2">
Your lifetime license is now active.
</p>
</div>
<div className="px-6 py-6">
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
You now have full access to every feature across all of England. Happy exploring!
</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"
>
Start exploring
</button>
</div>
</div>
{/* CSS animation for confetti */}
<style>{`
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
.animate-confetti {
animation: confetti-fall linear forwards;
}
`}</style>
</div>
);
}

View file

@ -83,6 +83,7 @@ export default function MobileMenu({
{user && mobileNavItem('saved-searches', 'Saved')}
{mobileNavItem('learn', 'Learn')}
{mobileNavItem('pricing', 'Pricing')}
{mobileNavItem('support', 'Support')}
{user && mobileNavItem('account', 'Account')}
{/* Dashboard actions */}

View file

@ -0,0 +1,133 @@
import { useState, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { apiUrl } from '../../lib/api';
interface UpgradeModalProps {
isLoggedIn: boolean;
onLoginClick: () => void;
onRegisterClick: () => void;
onStartCheckout: () => Promise<void>;
onDismiss: () => void;
}
export default function UpgradeModal({
isLoggedIn,
onLoginClick,
onRegisterClick,
onStartCheckout,
onDismiss,
}: UpgradeModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pricePence, setPricePence] = useState<number | null>(null);
useEffect(() => {
fetch(apiUrl('pricing'))
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) setPricePence(data.current_price_pence);
})
.catch(() => {});
}, []);
const priceLabel =
pricePence === null
? '...'
: pricePence === 0
? 'Free'
: `\u00A3${pricePence / 100}`;
const isFree = pricePence === 0;
const handleUpgrade = async () => {
setLoading(true);
setError(null);
try {
await onStartCheckout();
} catch (err) {
setError(err instanceof Error ? err.message : 'Checkout failed');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
{/* Close button */}
<button
onClick={onDismiss}
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
<CloseIcon className="w-5 h-5" />
</button>
{/* Header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">Unlock the full map</h2>
<p className="text-warm-300 text-sm">
Free users can explore inner London. Upgrade for lifetime access to all of England.
</p>
</div>
{/* Body */}
<div className="px-6 py-6">
<div className="flex items-baseline justify-center gap-1 mb-4">
<span className="text-4xl font-extrabold text-navy-950 dark:text-warm-100">
{priceLabel}
</span>
{!isFree && (
<span className="text-warm-500 dark:text-warm-400 text-lg">/once</span>
)}
</div>
<p className="text-center text-sm text-warm-500 dark:text-warm-400 mb-6">
{isFree
? 'Free for early adopters. No credit card required.'
: 'One-time payment. Lifetime access. 30-day money-back guarantee.'}
</p>
{isLoggedIn ? (
<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"
>
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{loading
? 'Redirecting...'
: isFree
? 'Claim free license'
: `Upgrade for ${priceLabel}`}
</button>
) : (
<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"
>
Register & Upgrade
</button>
<button
onClick={onLoginClick}
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Already have an account? Log in
</button>
</div>
)}
{error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<button
onClick={onDismiss}
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
>
Or zoom back into London
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { useState, useCallback } from 'react';
import { SpinnerIcon } from './icons/SpinnerIcon';
export default function VerificationBanner({
email,
onRequestVerification,
onDismiss,
}: {
email: string;
onRequestVerification: (email: string) => Promise<void>;
onDismiss: () => void;
}) {
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const handleResend = useCallback(async () => {
setSending(true);
try {
await onRequestVerification(email);
setSent(true);
setTimeout(() => setSent(false), 3000);
} catch {
// Error handled by hook
} finally {
setSending(false);
}
}, [email, onRequestVerification]);
return (
<div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-2.5 flex items-center justify-between gap-3">
<p className="text-sm text-amber-800 dark:text-amber-200">
Please verify your email address. Check your inbox.
</p>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={handleResend}
disabled={sending || sent}
className="text-sm font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100 disabled:opacity-50 flex items-center gap-1"
>
{sending && <SpinnerIcon className="w-3.5 h-3.5 animate-spin" />}
{sent ? 'Sent!' : 'Resend'}
</button>
<button
onClick={onDismiss}
className="text-amber-400 dark:text-amber-600 hover:text-amber-600 dark:hover:text-amber-400 text-lg leading-none"
aria-label="Dismiss"
>
&times;
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
interface IconProps {
className?: string;
}
export function AppleIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</svg>
);
}

View file

@ -0,0 +1,26 @@
interface IconProps {
className?: string;
}
export function GoogleIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
}

View file

@ -117,7 +117,7 @@ export function useAreaSummary({
return () => {
abortRef.current?.abort();
};
}, [stats, hexagonId]); // eslint-disable-line react-hooks/exhaustive-deps
}, [fetchSummary]);
return { summary, loading, error };
}

View file

@ -7,6 +7,7 @@ export interface AuthUser {
verified: boolean;
isAdmin: boolean;
subscription: string;
newsletter: boolean;
}
function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser {
@ -19,6 +20,7 @@ function recordToUser(record: { id: string; [key: string]: unknown }): AuthUser
verified: typeof record.verified === 'boolean' ? record.verified : false,
isAdmin: typeof record.is_admin === 'boolean' ? record.is_admin : false,
subscription: typeof record.subscription === 'string' ? record.subscription : 'free',
newsletter: typeof record.newsletter === 'boolean' ? record.newsletter : false,
};
}
@ -115,8 +117,32 @@ export function useAuth() {
}, []);
const refreshAuth = useCallback(async () => {
const result = await pb.collection('users').authRefresh();
setUser(recordToUser(result.record));
setLoading(true);
setError(null);
try {
const result = await pb.collection('users').authRefresh();
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Auth refresh failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const requestVerification = useCallback(async (email: string) => {
setLoading(true);
setError(null);
try {
await pb.collection('users').requestVerification(email);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Verification request failed';
setError(msg);
throw err;
} finally {
setLoading(false);
}
}, []);
const clearError = useCallback(() => {
@ -132,6 +158,7 @@ export function useAuth() {
loginWithOAuth,
logout,
requestPasswordReset,
requestVerification,
refreshAuth,
clearError,
};

View file

@ -1,6 +1,6 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers';
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
import type { PickingInfo } from '@deck.gl/core';
import type {
HexagonData,
@ -14,9 +14,8 @@ import type {
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
type TravelTimeEntry,
travelFieldKey,
} from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
@ -46,8 +45,8 @@ interface UseDeckLayersProps {
theme: 'light' | 'dark';
selectedPostcodeGeometry?: PostcodeGeometry | null;
bounds?: Bounds | null;
travelTimeEntries?: TravelTimeEntries;
travelTimeColorRanges?: Partial<Record<TransportMode, [number, number]>>;
travelTimeEntries?: TravelTimeEntry[];
travelTimeColorRanges?: Map<number, [number, number]>;
}
export interface PopupInfo {
@ -58,15 +57,15 @@ export interface PopupInfo {
id: string;
}
/** Find the primary travel mode: first mode (in canonical order) with a destination and color range. */
function getPrimaryTravelMode(
entries: TravelTimeEntries,
colorRanges: Partial<Record<TransportMode, [number, number]>>
): TransportMode | null {
for (const mode of TRANSPORT_MODES) {
if (entries[mode]?.destination && colorRanges[mode]) return mode;
/** Find the primary travel time entry: first entry with a slug and color range. */
function getPrimaryTravelIndex(
entries: TravelTimeEntry[],
colorRanges: Map<number, [number, number]>
): number {
for (let i = 0; i < entries.length; i++) {
if (entries[i].slug && colorRanges.has(i)) return i;
}
return null;
return -1;
}
export function useDeckLayers({
@ -85,8 +84,8 @@ export function useDeckLayers({
theme,
selectedPostcodeGeometry,
bounds: viewportBounds,
travelTimeEntries = {},
travelTimeColorRanges = {},
travelTimeEntries = [],
travelTimeColorRanges = new Map(),
}: UseDeckLayersProps) {
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
@ -105,7 +104,7 @@ export function useDeckLayers({
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
// --- Refs for deck.gl accessors (avoid re-creating layers on every change) ---
// --- Refs for deck.gl accessors ---
const viewFeatureRef = useRef(viewFeature);
viewFeatureRef.current = viewFeature;
const colorRangeRef = useRef(colorRange);
@ -128,12 +127,12 @@ export function useDeckLayers({
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
travelTimeColorRangesRef.current = travelTimeColorRanges;
const primaryTravelMode = useMemo(
() => getPrimaryTravelMode(travelTimeEntries, travelTimeColorRanges),
const primaryTravelIndex = useMemo(
() => getPrimaryTravelIndex(travelTimeEntries, travelTimeColorRanges),
[travelTimeEntries, travelTimeColorRanges]
);
const primaryTravelModeRef = useRef(primaryTravelMode);
primaryTravelModeRef.current = primaryTravelMode;
const primaryTravelIndexRef = useRef(primaryTravelIndex);
primaryTravelIndexRef.current = primaryTravelIndex;
const colorFeatureMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
@ -260,13 +259,12 @@ export function useDeckLayers({
}, []);
// --- Color triggers ---
// Build travel time trigger from all entries
const ttTrigger = useMemo(() => {
const parts: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
const cr = travelTimeColorRanges[mode];
parts.push(`${mode}:${entry?.destination?.[0]}|${entry?.destination?.[1]}|${cr?.[0]}|${cr?.[1]}|${entry?.timeRange?.[0]}|${entry?.timeRange?.[1]}`);
for (let i = 0; i < travelTimeEntries.length; i++) {
const entry = travelTimeEntries[i];
const cr = travelTimeColorRanges.get(i);
parts.push(`${i}:${entry.slug}|${cr?.[0]}|${cr?.[1]}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
}
return parts.join(';');
}, [travelTimeEntries, travelTimeColorRanges]);
@ -283,23 +281,26 @@ export function useDeckLayers({
getHexagon: (d) => d.h3,
getFillColor: (d) => {
const dark = isDarkRef.current;
const pm = primaryTravelModeRef.current;
const pti = primaryTravelIndexRef.current;
const entries = travelTimeEntriesRef.current;
const colorRanges = travelTimeColorRangesRef.current;
// Travel time coloring: primary mode colors, others dim-filter
if (pm) {
const ttVal = d[`travel_time_${pm}`];
const ttClr = colorRanges[pm];
// Travel time coloring: primary entry colors, others dim-filter
if (pti >= 0) {
const primaryEntry = entries[pti];
const fieldKey = travelFieldKey(primaryEntry);
const ttVal = d[`avg_${fieldKey}`];
const ttClr = colorRanges.get(pti);
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
}
// Check all modes with time ranges as filters (including primary)
for (const mode of TRANSPORT_MODES) {
const entry = entries[mode];
if (!entry?.timeRange) continue;
const modeVal = d[`travel_time_${mode}`];
// Check all entries with time ranges as filters
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (!entry.timeRange || !entry.slug) continue;
const fk = travelFieldKey(entry);
const modeVal = d[`avg_${fk}`];
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
}
@ -504,7 +505,7 @@ export function useDeckLayers({
[pois, stablePoiHover]
);
// Marching ants highlight layer for selected postcode (click or search)
// Marching ants highlight layer for selected postcode
const marchingAntsLayer = useMemo(() => {
if (!selectedPostcodeGeometry) return null;
return new GeoJsonLayer({
@ -527,42 +528,12 @@ export function useDeckLayers({
});
}, [selectedPostcodeGeometry, marchTime]);
// Destination markers: one red dot per mode with a destination
const destinationMarkerData = useMemo(() => {
const points: { position: [number, number] }[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
points.push({ position: [entry.destination[1], entry.destination[0]] });
}
}
return points;
}, [travelTimeEntries]);
const destinationMarkerLayer = useMemo(() => {
if (destinationMarkerData.length === 0) return null;
return new ScatterplotLayer({
id: 'travel-time-destinations',
data: destinationMarkerData,
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 8,
getFillColor: [239, 68, 68, 220],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
lineWidthUnits: 'pixels' as const,
radiusUnits: 'pixels' as const,
stroked: true,
pickable: false,
});
}, [destinationMarkerData]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [hexLayer, poiLayer];
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
if (destinationMarkerLayer) baseLayers.push(destinationMarkerLayer);
return baseLayers;
}, [
usePostcodeView,
@ -571,7 +542,6 @@ export function useDeckLayers({
postcodeLabelsLayer,
poiLayer,
marchingAntsLayer,
destinationMarkerLayer,
]);
const handleMouseLeave = useCallback(() => {
@ -590,6 +560,6 @@ export function useDeckLayers({
colorFeatureMeta,
handleMouseLeave,
hoveredPostcode,
primaryTravelMode,
primaryTravelIndex,
};
}

View file

@ -0,0 +1,37 @@
import { useState, useCallback } from 'react';
import { apiUrl, authHeaders, assertOk } from '../lib/api';
export function useLicense() {
const [checkingOut, setCheckingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
const startCheckout = useCallback(async (referralCode?: string) => {
setCheckingOut(true);
setError(null);
try {
const body: Record<string, string> = {};
if (referralCode) body.referral_code = referralCode;
const res = await fetch(apiUrl('checkout'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
});
assertOk(res, 'Checkout');
const data = await res.json();
if (data.url) {
window.location.href = data.url;
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Checkout failed';
setError(msg);
throw err;
} finally {
setCheckingOut(false);
}
}, []);
return { startCheckout, checkingOut, error };
}

View file

@ -10,9 +10,9 @@ export function looksLikePostcode(s: string) {
export type SearchResult =
| { type: 'postcode'; label: string }
| { type: 'place'; name: string; place_type: string; lat: number; lon: number; city?: string };
| { type: 'place'; name: string; slug: string; place_type: string; lat: number; lon: number; city?: string };
export function useLocationSearch() {
export function useLocationSearch(mode?: string) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [activeIndex, setActiveIndex] = useState(-1);
@ -34,7 +34,7 @@ export function useLocationSearch() {
return;
}
if (looksLikePostcode(trimmed)) {
if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: trimmed.toUpperCase() }]);
setOpen(true);
return;
@ -51,6 +51,7 @@ export function useLocationSearch() {
abortRef.current = controller;
try {
const params = new URLSearchParams({ q: trimmed, limit: '7' });
if (mode) params.set('mode', mode);
const res = await fetch(
`/api/places?${params}`,
authHeaders({ signal: controller.signal }),
@ -59,7 +60,12 @@ export function useLocationSearch() {
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
type: 'place' as const,
...p,
name: p.name,
slug: p.slug,
place_type: p.place_type,
lat: p.lat,
lon: p.lon,
city: p.city,
}));
setResults(placeResults);
setOpen(placeResults.length > 0);
@ -67,7 +73,7 @@ export function useLocationSearch() {
logNonAbortError('places search', err);
}
}, 200);
}, []);
}, [mode]);
const close = useCallback(() => setOpen(false), []);

View file

@ -8,10 +8,10 @@ import type {
ViewChangeParams,
ApiResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } from '../lib/api';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
import { TRANSPORT_MODES, type TransportMode, type TravelTimeEntries } from './useTravelTime';
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
@ -33,7 +33,7 @@ interface UseMapDataOptions {
activeFeature: string | null;
dragValue: [number, number] | null;
dragData: HexagonData[] | null;
travelTimeEntries: TravelTimeEntries;
travelTimeEntries: TravelTimeEntry[];
}
export function useMapData({
@ -56,6 +56,8 @@ export function useMapData({
longitude: number;
zoom: number;
} | null>(null);
const [licenseRequired, setLicenseRequired] = useState(false);
const [freeZone, setFreeZone] = useState<Bounds | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
@ -69,13 +71,16 @@ export function useMapData({
);
// Build the travel param string from entries with destinations
// Format: mode:slug|mode:slug or mode:slug:min:max|mode:slug
const travelParam = useMemo((): string => {
const segments: string[] = [];
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (entry?.destination) {
segments.push(`${entry.destination[0]},${entry.destination[1]},${mode}`);
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.timeRange) {
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(seg);
}
return segments.join('|');
}, [travelTimeEntries]);
@ -109,7 +114,16 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
return;
}
}
assertOk(res, 'postcodes');
setLicenseRequired(false);
const json: { features: PostcodeFeature[] } = await res.json();
setPostcodeData(json.features);
setRawData([]);
@ -129,13 +143,22 @@ export function useMapData({
signal: abortControllerRef.current.signal,
})
);
if (res.status === 403) {
const errBody = await res.json();
if (errBody.error === 'license_required' && errBody.free_zone) {
setLicenseRequired(true);
setFreeZone(errBody.free_zone);
return;
}
}
assertOk(res, 'hexagons');
setLicenseRequired(false);
const json: ApiResponse = await res.json();
setRawData(json.features);
setPostcodeData([]);
}
} catch (err) {
logNonAbortError('Failed to fetch data', err);
if (!isAbortError(err)) logNonAbortError('Failed to fetch data', err);
} finally {
setLoading(false);
}
@ -151,7 +174,6 @@ export function useMapData({
const data = dragData ?? rawData;
// Compute p5/p95 from visible data for the viewed feature
// Only considers hexagons/postcodes whose center falls within the viewport bounds
const dataRange = useMemo((): [number, number] | null => {
if (!viewFeature) return null;
const meta = features.find((f) => f.name === viewFeature);
@ -207,13 +229,13 @@ export function useMapData({
return null;
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
// Color ranges for travel time per mode (computed from response data)
const travelTimeColorRanges = useMemo((): Partial<Record<TransportMode, [number, number]>> => {
const ranges: Partial<Record<TransportMode, [number, number]>> = {};
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (!entry?.destination) continue;
const fieldName = `travel_time_${mode}`;
// Color ranges for travel time per entry (computed from response data)
const travelTimeColorRanges = useMemo((): Map<number, [number, number]> => {
const ranges = new Map<number, [number, number]>();
for (let i = 0; i < travelTimeEntries.length; i++) {
const entry = travelTimeEntries[i];
if (!entry.slug) continue;
const fieldName = `avg_${travelFieldKey(entry)}`;
const vals: number[] = [];
for (const item of data) {
if (bounds) {
@ -226,10 +248,10 @@ export function useMapData({
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges[mode] = [
ranges.set(i, [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
];
]);
}
return ranges;
}, [travelTimeEntries, data, bounds]);
@ -276,5 +298,7 @@ export function useMapData({
travelTimeColorRanges,
handleViewChange,
setInitialView,
licenseRequired,
freeZone,
};
}

View file

@ -38,6 +38,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
signal: abortControllerRef.current.signal,
})
);
if (!res.ok) throw new Error(`POIs fetch failed: HTTP ${res.status}`);
const json: POIResponse = await res.json();
setPois(json.pois || []);
} catch (err) {

View file

@ -12,71 +12,73 @@ export const MODE_LABELS: Record<TransportMode, string> = {
};
export interface TravelTimeEntry {
destination: [number, number] | null; // [lat, lon]
destinationLabel: string;
mode: TransportMode;
slug: string;
label: string;
timeRange: [number, number] | null;
}
export type TravelTimeEntries = Partial<Record<TransportMode, TravelTimeEntry>>;
/** Unique key for a travel time entry */
export function travelEntryKey(entry: TravelTimeEntry): string {
return `${entry.mode}:${entry.slug}`;
}
/** Field key matching the backend response: tt_{mode}_{slug} */
export function travelFieldKey(entry: TravelTimeEntry): string {
return `tt_${entry.mode}_${entry.slug}`;
}
export interface TravelTimeInitial {
entries?: TravelTimeEntries;
entries?: TravelTimeEntry[];
}
export function useTravelTime(initial?: TravelTimeInitial) {
const [entries, setEntries] = useState<TravelTimeEntries>(initial?.entries ?? {});
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
const activeModes = useMemo(
() => TRANSPORT_MODES.filter((m) => m in entries),
[entries]
);
const modesWithDestination = useMemo(
() => TRANSPORT_MODES.filter((m) => entries[m]?.destination != null),
[entries]
);
const handleEnableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => ({
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
[mode]: { destination: null, destinationLabel: '', timeRange: null },
}));
{ mode, slug: '', label: '', timeRange: null },
]);
}, []);
const handleDisableMode = useCallback((mode: TransportMode) => {
setEntries((prev) => {
const next = { ...prev };
delete next[mode];
return next;
});
const handleRemoveEntry = useCallback((index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleSetDestination = useCallback(
(mode: TransportMode, lat: number, lon: number, label: string) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], destination: [lat, lon] as [number, number], destinationLabel: label },
}));
(index: number, slug: string, label: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: null } : entry
)
);
},
[]
);
const handleTimeRangeChange = useCallback(
(mode: TransportMode, range: [number, number]) => {
setEntries((prev) => ({
...prev,
[mode]: { ...prev[mode], timeRange: range },
}));
(index: number, range: [number, number]) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, timeRange: range } : entry
)
);
},
[]
);
/** Entries that have a destination selected (slug is set) */
const activeEntries = useMemo(
() => entries.filter((e) => e.slug !== ''),
[entries]
);
return {
entries,
activeModes,
modesWithDestination,
handleEnableMode,
handleDisableMode,
activeEntries,
handleAddEntry,
handleRemoveEntry,
handleSetDestination,
handleTimeRangeChange,
};

View file

@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { stateToParams } from '../lib/url-state';
import type { TravelTimeEntries } from './useTravelTime';
import type { TravelTimeEntry } from './useTravelTime';
const URL_DEBOUNCE_MS = 300;
@ -11,7 +11,7 @@ export function useUrlSync(
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntries
travelTimeEntries?: TravelTimeEntry[]
) {
const urlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View file

@ -2,7 +2,7 @@ import type { FeatureMeta, FeatureFilters, ViewState } from '../types';
import {
TRANSPORT_MODES,
type TransportMode,
type TravelTimeEntries,
type TravelTimeEntry,
type TravelTimeInitial,
} from '../hooks/useTravelTime';
@ -70,32 +70,31 @@ export function parseUrlState(): {
result.tab = tab;
}
// Travel time: per-mode params (tt_car=lat,lon ttl_car=label ttr_car=min:max)
const entries: TravelTimeEntries = {};
for (const mode of TRANSPORT_MODES) {
const dest = params.get(`tt_${mode}`);
if (dest) {
const parts = dest.split(',').map(Number);
if (parts.length === 2 && parts.every((n) => !isNaN(n))) {
const label = params.get(`ttl_${mode}`) || '';
let timeRange: [number, number] | null = null;
const rangeStr = params.get(`ttr_${mode}`);
if (rangeStr) {
const [min, max] = rangeStr.split(':').map(Number);
if (!isNaN(min) && !isNaN(max)) {
timeRange = [min, max];
}
// Travel time: repeated `tt` params
// Format: mode:slug:label or mode:slug:label:min:max
const ttParams = params.getAll('tt');
if (ttParams.length > 0) {
const entries: TravelTimeEntry[] = [];
for (const tt of ttParams) {
const parts = tt.split(':');
if (parts.length < 3) continue;
const mode = parts[0] as TransportMode;
if (!TRANSPORT_MODES.includes(mode)) continue;
const slug = parts[1];
const label = decodeURIComponent(parts[2]);
let timeRange: [number, number] | null = null;
if (parts.length >= 5) {
const min = Number(parts[3]);
const max = Number(parts[4]);
if (!isNaN(min) && !isNaN(max)) {
timeRange = [min, max];
}
entries[mode] = {
destination: [parts[0], parts[1]],
destinationLabel: label,
timeRange,
};
}
entries.push({ mode, slug, label, timeRange });
}
if (entries.length > 0) {
result.travelTime = { entries };
}
}
if (Object.keys(entries).length > 0) {
result.travelTime = { entries };
}
return result;
@ -107,7 +106,7 @@ export function stateToParams(
features: FeatureMeta[],
selectedPOICategories: Set<string>,
rightPaneTab: 'properties' | 'area',
travelTimeEntries?: TravelTimeEntries
travelTimeEntries?: TravelTimeEntry[]
): URLSearchParams {
const params = new URLSearchParams();
@ -135,18 +134,15 @@ export function stateToParams(
params.set('tab', 'properties');
}
// Travel time: per-mode params
// Travel time: repeated `tt` params
if (travelTimeEntries) {
for (const mode of TRANSPORT_MODES) {
const entry = travelTimeEntries[mode];
if (!entry?.destination) continue;
params.set(`tt_${mode}`, `${entry.destination[0].toFixed(5)},${entry.destination[1].toFixed(5)}`);
if (entry.destinationLabel) {
params.set(`ttl_${mode}`, entry.destinationLabel);
}
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.timeRange) {
params.set(`ttr_${mode}`, `${entry.timeRange[0]}:${entry.timeRange[1]}`);
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
params.append('tt', val);
}
}

View file

@ -102,6 +102,7 @@ export interface POICategoriesResponse {
export interface PlaceResult {
name: string;
slug: string;
place_type: string;
lat: number;
lon: number;

View file

@ -85,6 +85,12 @@ module.exports = (env, argv) => {
},
hot: true,
liveReload: true,
static: {
directory: path.resolve(__dirname, 'public'),
watch: {
ignored: ['**/assets/**'],
},
},
proxy: [
{
context: ['/api'],