This commit is contained in:
Andras Schmelczer 2026-03-15 17:38:26 +00:00
parent 80c093b7ba
commit f72c43a9fa
101 changed files with 2168 additions and 1177 deletions

View file

@ -178,15 +178,18 @@ export default function App() {
return () => controller.abort();
}, []);
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, '');
}
const path = pageToPath(page, inviteCode ?? undefined);
const url = hash ? `${path}#${hash}` : path;
window.history.pushState({ page }, '', url);
setActivePage(page);
}, [inviteCode]);
const navigateTo = useCallback(
(page: Page, hash?: string, infoFeature?: string) => {
if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, '');
}
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) {
@ -225,7 +228,8 @@ export default function App() {
}
}, [activePage, fetchSearches, fetchSavedProperties, user]);
const isAuthRequiredPage = activePage === 'account' || activePage === 'saved' || activePage === 'invites';
const isAuthRequiredPage =
activePage === 'account' || activePage === 'saved' || activePage === 'invites';
useEffect(() => {
if (authLoading) return;
if (isAuthRequiredPage && !user) {
@ -266,8 +270,8 @@ export default function App() {
initialLoading={initialLoading}
theme={theme}
pendingInfoFeature={null}
onClearPendingInfoFeature={() => { }}
onNavigateTo={() => { }}
onClearPendingInfoFeature={() => {}}
onNavigateTo={() => {}}
screenshotMode
ogMode={isOgMode}
initialTravelTime={urlState.travelTime}
@ -306,7 +310,13 @@ export default function App() {
/>
)}
{activePage === 'home' ? (
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} hidePricing={user?.subscription === 'licensed' || user?.isAdmin} />
<HomePage
onOpenDashboard={() => navigateTo('dashboard')}
onOpenPricing={() => navigateTo('pricing')}
theme={theme}
features={features}
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
/>
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
<PricingPage
onOpenDashboard={() => navigateTo('dashboard')}
@ -412,13 +422,21 @@ export default function App() {
<SaveSearchModal
onClose={() => setShowSaveModal(false)}
onSave={savedSearches.saveSearch}
onViewSearches={() => { setShowSaveModal(false); navigateTo('saved'); }}
onViewSearches={() => {
setShowSaveModal(false);
navigateTo('saved');
}}
saving={savedSearches.saving}
error={savedSearches.error}
/>
)}
{showLicenseSuccess && (
<LicenseSuccessModal onClose={() => { setShowLicenseSuccess(false); navigateTo('dashboard'); }} />
<LicenseSuccessModal
onClose={() => {
setShowLicenseSuccess(false);
navigateTo('dashboard');
}}
/>
)}
</div>
);

View file

@ -18,9 +18,7 @@ function PageLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-warm-900 flex flex-col">
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-6 py-6">
{children}
</div>
<div className="max-w-5xl mx-auto px-6 py-6">{children}</div>
</div>
</div>
);
@ -38,10 +36,7 @@ function DeleteDialog({
onConfirm: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onCancel}
>
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onCancel}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
@ -122,17 +117,20 @@ function SavedSearchesTab({
});
}, []);
const handleShare = useCallback(async (params: string, id: string) => {
setSharingId(id);
try {
const shortUrl = await shortenUrl(params);
doCopy(shortUrl, id);
} catch {
doCopy(`${window.location.origin}/?${params}`, id);
} finally {
setSharingId(null);
}
}, [doCopy]);
const handleShare = useCallback(
async (params: string, id: string) => {
setSharingId(id);
try {
const shortUrl = await shortenUrl(params);
doCopy(shortUrl, id);
} catch {
doCopy(`${window.location.origin}/?${params}`, id);
} finally {
setSharingId(null);
}
},
[doCopy]
);
if (loading) {
return (
@ -201,7 +199,11 @@ function SavedSearchesTab({
>
{sharingId === search.id ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : copiedId === search.id ? 'Copied!' : 'Share'}
) : copiedId === search.id ? (
'Copied!'
) : (
'Share'
)}
</button>
<button
onClick={() => setDeleteConfirmId(search.id)}
@ -452,8 +454,12 @@ function InviteTable({
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Link</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">Status</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">Created</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">
Status
</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium w-24">
Created
</th>
</tr>
</thead>
<tbody className="divide-y divide-warm-100 dark:divide-warm-800">
@ -718,10 +724,11 @@ export default function AccountPage({
</button>
)}
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${user.verified
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>
@ -732,7 +739,9 @@ export default function AccountPage({
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
<span className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}>
<span
className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}
>
{user.subscription === 'licensed' ? 'Licensed' : 'Free'}
</span>
</div>

View file

@ -47,14 +47,16 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
>
<div
className="bg-teal-500"
style={{
width: hex.size,
height: hex.size * 2 / Math.sqrt(3),
opacity: hex.opacity * (isDark ? 0.6 : 1),
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
'--bob': `${hex.bobAmount}px`,
} as React.CSSProperties}
style={
{
width: hex.size,
height: (hex.size * 2) / Math.sqrt(3),
opacity: hex.opacity * (isDark ? 0.6 : 1),
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
'--bob': `${hex.bobAmount}px`,
} as React.CSSProperties
}
/>
</div>
))}

View file

@ -11,10 +11,10 @@ import type { FeatureMeta } from '../../types';
export default function HomePage({
onOpenDashboard,
onOpenPricing,
onOpenPricing: _onOpenPricing,
theme = 'light',
features = [],
hidePricing,
hidePricing: _hidePricing,
}: {
onOpenDashboard: () => void;
onOpenPricing: () => void;
@ -79,8 +79,8 @@ export default function HomePage({
House hunting? Make your biggest investment your smartest move.
</p>
<p className="text-lg text-warm-400 mb-8 max-w-xl">
So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that
fit.
So many options - choosing the right one can feel overwhelming. Our interactive map
makes it simple: select your must-haves and instantly see the areas that fit.
</p>
<div className="flex items-center gap-4 mb-10">
<button
@ -100,7 +100,11 @@ export default function HomePage({
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
const start = scroller.scrollTop;
const end = start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - 48;
const end =
start +
target.getBoundingClientRect().top -
scroller.getBoundingClientRect().top -
48;
const distance = end - start;
const duration = 1200;
let startTime: number;
@ -146,7 +150,8 @@ export default function HomePage({
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
if (!scroller) return;
const start = scroller.scrollTop;
const end = start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
const end =
start + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
const distance = end - start;
const duration = 1200;
let startTime: number;
@ -176,13 +181,13 @@ export default function HomePage({
</h2>
<div className="space-y-4 text-lg md:text-xl leading-relaxed text-warm-700 dark:text-warm-300">
<p>
Listings show what&apos;s available, not what&apos;s possible &mdash; fragments without context.
Traditional tools force you to begin with a location, separating area insight from property detail.
You search, cross-reference, and repeat per location.
Listings show what&apos;s available, not what&apos;s possible &mdash; fragments
without context. Traditional tools force you to begin with a location, separating area
insight from property detail. You search, cross-reference, and repeat per location.
</p>
<p>
We take a different approach. Start with what matters to you, and the right places reveal themselves.
No context lost. No property missed.
We take a different approach. Start with what matters to you, and the right places
reveal themselves. No context lost. No property missed.
</p>
</div>
</div>
@ -217,66 +222,75 @@ export default function HomePage({
{/* Right: Comparison table */}
<div id="comparison">
<h2 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-10">
Others vs{' '}<span className="inline-flex items-baseline gap-3 whitespace-nowrap">Perfect Postcode <LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" /></span>
Others vs{' '}
<span className="inline-flex items-baseline gap-3 whitespace-nowrap">
Perfect Postcode{' '}
<LogoIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
</span>
</h2>
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 shadow-sm">
<table className="w-full text-left">
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
<th className="px-2 md:px-5 py-3 md:py-4 text-xs md:text-sm font-bold text-navy-950 dark:text-warm-100" />
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
Listing portals
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
{'\u201CCheck my postcode\u201D'}
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
Area guides
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-extrabold text-navy-950 dark:text-warm-100 text-center bg-teal-50 dark:bg-teal-900/30">
Perfect Postcode
</th>
</tr>
</thead>
<tbody>
{FEATURE_ROWS.map((row, i) => (
<tr
key={row.feature}
className={
i < FEATURE_ROWS.length - 1
? 'border-b border-warm-100 dark:border-warm-800'
: ''
}
>
<td className="px-2 md:px-5 py-2.5 md:py-3.5 text-xs md:text-sm text-warm-700 dark:text-warm-300">
{row.feature}
{row.subtitle && (
<div className="italic text-warm-500 dark:text-warm-400">{row.subtitle}</div>
)}
</td>
{[row.listings, row.postcode, row.guides].map((has, j) => (
<td
key={j}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg ${has ? 'text-green-500' : 'text-red-500'}`}
>
{has ? '\u2713' : '\u2717'}
</td>
))}
<td className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg text-green-500 bg-teal-50 dark:bg-teal-900/30">
&#x2713;
</td>
<div className="overflow-x-auto rounded-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 shadow-sm">
<table className="w-full text-left">
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
<th className="px-2 md:px-5 py-3 md:py-4 text-xs md:text-sm font-bold text-navy-950 dark:text-warm-100" />
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
Listing portals
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
{'\u201CCheck my postcode\u201D'}
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-bold text-navy-950 dark:text-warm-100 text-center">
Area guides
</th>
<th className="px-1.5 md:px-3 py-3 md:py-4 text-[10px] md:text-xs font-extrabold text-navy-950 dark:text-warm-100 text-center bg-teal-50 dark:bg-teal-900/30">
Perfect Postcode
</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{FEATURE_ROWS.map((row, i) => (
<tr
key={row.feature}
className={
i < FEATURE_ROWS.length - 1
? 'border-b border-warm-100 dark:border-warm-800'
: ''
}
>
<td className="px-2 md:px-5 py-2.5 md:py-3.5 text-xs md:text-sm text-warm-700 dark:text-warm-300">
{row.feature}
{row.subtitle && (
<div className="italic text-warm-500 dark:text-warm-400">
{row.subtitle}
</div>
)}
</td>
{[row.listings, row.postcode, row.guides].map((has, j) => (
<td
key={j}
className={`px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg ${has ? 'text-green-500' : 'text-red-500'}`}
>
{has ? '\u2713' : '\u2717'}
</td>
))}
<td className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base md:text-lg text-green-500 bg-teal-50 dark:bg-teal-900/30">
&#x2713;
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{/* Scrollytelling: Problem + Solution + Demo map */}
<h2 id="demo" className="text-3xl font-bold text-navy-950 dark:text-warm-100 text-center pt-16 mb-8">
<h2
id="demo"
className="text-3xl font-bold text-navy-950 dark:text-warm-100 text-center pt-16 mb-8"
>
See It in Action
</h2>
<ScrollStory features={features} theme={theme} />
@ -311,27 +325,48 @@ export default function HomePage({
const FEATURE_ROWS = [
// listings postcode guides
{ feature: 'Search without choosing an area first', subtitle: '(start with needs, not a location)', listings: false, postcode: false, guides: false },
{ feature: 'Area data', subtitle: '(crime, schools, noise, broadband)', listings: false, postcode: true, guides: true },
{ feature: 'Property-specific data', subtitle: '(price, EPC, floor area)', listings: true, postcode: false, guides: false },
{ feature: '56 combinable filters in one place', subtitle: '(all insights, one interactive map)', listings: false, postcode: false, guides: false },
{
feature: 'Search without choosing an area first',
subtitle: '(start with needs, not a location)',
listings: false,
postcode: false,
guides: false,
},
{
feature: 'Area data',
subtitle: '(crime, schools, noise, broadband)',
listings: false,
postcode: true,
guides: true,
},
{
feature: 'Property-specific data',
subtitle: '(price, EPC, floor area)',
listings: true,
postcode: false,
guides: false,
},
{
feature: '56 combinable filters in one place',
subtitle: '(all insights, one interactive map)',
listings: false,
postcode: false,
guides: false,
},
];
const HOW_STEPS = [
{
title: 'Set your must-haves',
description:
'Budget, commute, schools \u2014 the map shows only what qualifies.',
description: 'Budget, commute, schools \u2014 the map shows only what qualifies.',
},
{
title: 'Explore areas and discover hidden gems',
description:
'Zoom in, dig into details and nice to haves.',
description: 'Zoom in, dig into details and nice to haves.',
},
{
title: 'Drill into postcodes',
description:
'See individual properties, sale prices, floor area, and compare.',
description: 'See individual properties, sale prices, floor area, and compare.',
},
{
title: 'Shortlist with confidence',

View file

@ -14,7 +14,7 @@ const DEMO_FEATURE_NAMES = [
'Good+ primary schools within 5km',
'Number of restaurants within 2km',
];
const noop = () => { };
const noop = () => {};
// Filter fractions per stage: featureName -> [minFrac, maxFrac]
// 0 = feature.min, 1 = feature.max
@ -75,10 +75,7 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
</p>
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
You&apos;re about to spend{' '}
<strong className="text-navy-950 dark:text-warm-100">
up to &pound;500k
</strong>{' '}
on a home.
<strong className="text-navy-950 dark:text-warm-100">up to &pound;500k</strong> on a home.
</p>
</>
),
@ -91,7 +88,9 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
<div className="shrink-0 w-7 h-7 md:w-8 md:h-8 rounded-full bg-teal-600 text-white flex items-center justify-center font-bold text-xs md:text-sm">
1
</div>
<h3 className="text-lg md:text-xl font-bold text-navy-950 dark:text-warm-100">Set your must-haves</h3>
<h3 className="text-lg md:text-xl font-bold text-navy-950 dark:text-warm-100">
Set your must-haves
</h3>
</div>
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
Say you want a home{' '}
@ -127,8 +126,8 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
body: (
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
&hellip;all within{' '}
<strong className="text-navy-950 dark:text-warm-100">45 minutes of Manchester</strong>{' '}
by public transport.
<strong className="text-navy-950 dark:text-warm-100">45 minutes of Manchester</strong> by
public transport.
</p>
),
},
@ -137,11 +136,13 @@ const STEPS: { heading: string | null; body: React.ReactNode }[] = [
body: (
<>
<p className="text-base md:text-lg leading-snug md:leading-relaxed mb-2 md:mb-4 font-semibold text-navy-950 dark:text-warm-100">
No area chosen. No listings browsed. Yet you already know exactly where your needs are met.
No area chosen. No listings browsed. Yet you already know exactly where your needs are
met.
</p>
<p className="text-base md:text-lg leading-snug md:leading-relaxed">
That&apos;s just 4 filters. We&apos;ve built <strong className="text-navy-950 dark:text-warm-100">56</strong> &mdash;
covering commute times, crime, broadband, noise, schools, amenities, and more.
That&apos;s just 4 filters. We&apos;ve built{' '}
<strong className="text-navy-950 dark:text-warm-100">56</strong> &mdash; covering commute
times, crime, broadband, noise, schools, amenities, and more.
</p>
</>
),
@ -337,9 +338,13 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
);
})}
{/* Travel time indicator */}
<div className={`transition-opacity duration-700 ${STAGES[stage].travel ? 'opacity-100' : 'opacity-30'}`}>
<div
className={`transition-opacity duration-700 ${STAGES[stage].travel ? 'opacity-100' : 'opacity-30'}`}
>
<div className="flex justify-between items-baseline text-xs md:text-sm mb-1 md:mb-1.5 gap-1.5 md:gap-2">
<span className={`font-medium truncate ${STAGES[stage].travel ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}>
<span
className={`font-medium truncate ${STAGES[stage].travel ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
>
Commute to Manchester
</span>
{STAGES[stage].travel && (
@ -369,7 +374,11 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
</div>
<div
className="h-1.5 md:h-2.5 rounded-full overflow-hidden"
style={{ background: gradientToCss(theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT) }}
style={{
background: gradientToCss(
theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT
),
}}
/>
<div className="flex justify-between mt-1 text-[10px] md:text-xs text-warm-500 dark:text-warm-400">
<span>Fewer</span>

View file

@ -171,7 +171,7 @@ export default function InvitePage({
<h2 className="text-[144px] leading-tight font-bold text-white mb-6">
{isValid
? isAdminInvite
? "You\u2019re invited!"
? 'You\u2019re invited!'
: 'Special offer!'
: 'Perfect Postcode'}
</h2>
@ -256,12 +256,8 @@ export default function InvitePage({
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-8 h-8 text-teal-400" />
</div>
<p className="text-lg font-medium text-white mb-2">
License activated!
</p>
<p className="text-warm-400">
You now have full access to Perfect Postcode.
</p>
<p className="text-lg font-medium text-white mb-2">License activated!</p>
<p className="text-warm-400">You now have full access to Perfect Postcode.</p>
</div>
</div>
);
@ -306,8 +302,12 @@ export default function InvitePage({
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-6 h-6 text-teal-600 dark:text-teal-400" />
</div>
<p className="text-warm-700 dark:text-warm-300 font-medium">You already have a license</p>
<p className="text-warm-500 dark:text-warm-400 text-sm mt-1">Your account already has full access.</p>
<p className="text-warm-700 dark:text-warm-300 font-medium">
You already have a license
</p>
<p className="text-warm-500 dark:text-warm-400 text-sm mt-1">
Your account already has full access.
</p>
</div>
) : user ? (
<button

View file

@ -24,7 +24,8 @@ const DATA_SOURCES = [
name: 'Energy Performance Certificates (EPC)',
origin: 'Ministry of Housing, Communities & Local Government',
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
optOutUrl:
'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
url: 'https://epc.opendatacommunities.org/downloads/domestic',
license: 'Open Government Licence v3.0',
},
@ -158,7 +159,7 @@ const FAQ_ITEMS: FAQItem[] = [
'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 do I find areas that match what I\'m looking for?',
question: "How do I find areas that match what I'm looking for?",
answer:
'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.',
},
@ -168,7 +169,7 @@ const FAQ_ITEMS: FAQItem[] = [
'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: 'Can I export the data I\'m looking at?',
question: "Can I export the data I'm looking at?",
answer:
'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.',
},
@ -313,10 +314,11 @@ export default function LearnPage() {
ref={(el) => {
cardRefs.current[source.id] = el;
}}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
highlightedId === source.id
? 'border-teal-400 ring-2 ring-teal-400'
: 'border-warm-200 dark:border-warm-700'
}`}
>
<div className="flex items-start justify-between gap-4 mb-2">
<h2 className="text-lg font-semibold text-warm-900 dark:text-warm-100">

View file

@ -114,7 +114,9 @@ export default memo(function AiFilterInput({
<div className="flex items-center gap-1.5 mb-1.5">
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">AI Search</span>
<span className="text-xs text-warm-400 dark:text-warm-500"> describe what you're looking for</span>
<span className="text-xs text-warm-400 dark:text-warm-500">
&mdash; describe what you&apos;re looking for
</span>
</div>
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
<input
@ -141,11 +143,7 @@ export default memo(function AiFilterInput({
)}
</button>
</form>
{loading && (
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
{loadingMessage}
</p>
)}
{loading && <p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{loadingMessage}</p>}
{showExamples && (
<div className="mt-1.5 flex flex-wrap gap-1">
{EXAMPLE_QUERIES.map((example) => (
@ -162,12 +160,13 @@ export default memo(function AiFilterInput({
)}
{error && errorType === 'verification' && (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
Please verify your email address to use AI-powered search. Check your inbox for a verification link.
Please verify your email address to use AI-powered search. Check your inbox for a
verification link.
</p>
)}
{error && errorType === 'limit' && (
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
You've reached the weekly AI usage limit. It will reset automatically next week.
You&apos;ve reached the weekly AI usage limit. It will reset automatically next week.
</p>
)}
{error && errorType === 'error' && (
@ -176,14 +175,10 @@ export default memo(function AiFilterInput({
</p>
)}
{summary && !error && !loading && (
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">
{summary}
</p>
<p className="mt-1 text-xs text-teal-600 dark:text-teal-400">{summary}</p>
)}
{notes && !error && !loading && (
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">
{notes}
</p>
<p className="mt-1 text-xs text-warm-500 dark:text-warm-400 italic">{notes}</p>
)}
</div>
);

View file

@ -86,7 +86,7 @@ export default function AreaPane({
return (
<>
<div className="h-full overflow-y-auto">
<div className="h-full overflow-y-auto">
<div className="p-3">
<div className="flex items-center gap-2">
<div>
@ -107,8 +107,8 @@ export default function AreaPane({
</p>
)}
<p className="text-xs text-warm-500 dark:text-warm-400 mt-1">
Stats for {isPostcode ? 'current and historical' : 'all'} properties
in this {isPostcode ? 'postcode' : 'area'}
Stats for {isPostcode ? 'current and historical' : 'all'} properties in this{' '}
{isPostcode ? 'postcode' : 'area'}
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
</p>
{stats && stats.count > 0 && (
@ -142,15 +142,11 @@ export default function AreaPane({
<HistogramLegend />
{stats.price_history &&
(() => {
const uniqueYears = new Set(
stats.price_history.map((p) => Math.floor(p.year))
);
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
return uniqueYears.size > 1;
})() && (
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
Price History
</span>
<span className="text-xs text-warm-700 dark:text-warm-300">Price History</span>
<PriceHistoryChart points={stats.price_history} />
</div>
)}

View file

@ -45,12 +45,7 @@ export default function ExternalSearchLinks({
</h3>
<div className="flex gap-2">
{urls.rightmove ? (
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
<a href={urls.rightmove} target="_blank" rel="noopener noreferrer" className={linkClass}>
Rightmove
</a>
) : (
@ -58,20 +53,10 @@ export default function ExternalSearchLinks({
Rightmove
</span>
)}
<a
href={urls.onthemarket}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
<a href={urls.onthemarket} target="_blank" rel="noopener noreferrer" className={linkClass}>
OnTheMarket
</a>
<a
href={urls.zoopla}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
<a href={urls.zoopla} target="_blank" rel="noopener noreferrer" className={linkClass}>
Zoopla
</a>
</div>

View file

@ -12,7 +12,13 @@ import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
import type { ComponentType } from 'react';
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
import {
TRANSPORT_MODES,
MODE_LABELS,
MODE_DESCRIPTIONS,
type TransportMode,
type TravelTimeEntry,
} from '../../hooks/useTravelTime';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
car: CarIcon,
@ -45,7 +51,7 @@ export default function FeatureBrowser({
onNavigateToSource,
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEntries,
travelTimeEntries: _travelTimeEntries,
onAddTravelTimeEntry,
isLicensed,
onUpgradeClick,
@ -77,12 +83,13 @@ export default function FeatureBrowser({
// Only show modes that have precomputed travel time data
const visibleModes = useMemo(
() => (availableTravelModes ? TRANSPORT_MODES.filter((m) => availableTravelModes.has(m)) : []),
[availableTravelModes],
[availableTravelModes]
);
const showTravelModes =
visibleModes.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 (
<>
@ -102,36 +109,40 @@ export default function FeatureBrowser({
{visibleModes.length}
</span>
</CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) && visibleModes.map((mode) => {
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
<ModeIcon 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">
{MODE_LABELS[mode]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
{MODE_DESCRIPTIONS[mode]}
</span>
{(isSearching || expandedGroups.has('Travel Time')) &&
visibleModes.map((mode) => {
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div
className="flex items-center gap-2 min-w-0"
onClick={() => onAddTravelTimeEntry(mode)}
>
<ModeIcon 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">
{MODE_LABELS[mode]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
{MODE_DESCRIPTIONS[mode]}
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={() => onAddTravelTimeEntry(mode)}
title={`Add ${MODE_LABELS[mode]} travel time`}
className="p-1 rounded-md text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 hover:bg-teal-100 dark:hover:bg-teal-800/40"
>
<PlusIcon className="w-7 h-7 md:w-5 md:h-5" strokeWidth={2.5} />
</button>
</div>
</div>
);
})}
);
})}
</div>
)}
{grouped.map((group) => {
@ -203,8 +214,15 @@ export default function FeatureBrowser({
>
Upgrade to full map
</button>
<svg viewBox="0 120 1600 230" className="w-full mt-4 block shrink-0" preserveAspectRatio="xMidYMax meet">
<path d="M0,350 C400,150 1200,150 1600,350 Z" className="fill-green-500 dark:fill-green-600" />
<svg
viewBox="0 120 1600 230"
className="w-full mt-4 block shrink-0"
preserveAspectRatio="xMidYMax meet"
>
<path
d="M0,350 C400,150 1200,150 1600,350 Z"
className="fill-green-500 dark:fill-green-600"
/>
<path d="M100,350 C450,180 1150,180 1500,350 Z" fill="#000" opacity="0.08" />
<path d="M250,350 C550,220 1050,220 1350,350 Z" fill="#000" opacity="0.06" />
<image href="/house.png" x="735" y="110" width="130" height="120" />

View file

@ -49,16 +49,10 @@ function SliderLabels({
const labels = displayValues || value;
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span
className="absolute -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
<span className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }}>
{isAtMin ? 'min' : formatFilterValue(labels[0], raw)}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
<span className="absolute -translate-x-1/2" style={{ left: `${rightPct}%` }}>
{isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
</span>
</div>
@ -175,7 +169,8 @@ export default memo(function Filters({
}, [filters]);
const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
() =>
features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
[features, enabledFeatures, activeListingType, isAllowed]
);
const enabledFeatureList = useMemo(
@ -271,7 +266,10 @@ export default memo(function Filters({
const badgeCount = enabledFeatureList.length + activeEntryCount;
return (
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
<div
ref={containerRef}
className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full"
>
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
<div className="flex items-center gap-2">
@ -287,7 +285,16 @@ export default memo(function Filters({
</div>
<div ref={scrollRef} className="md:flex-1 md:overflow-y-auto">
<AiFilterInput loading={aiFilterLoading} error={aiFilterError} errorType={aiFilterErrorType} notes={aiFilterNotes} summary={aiFilterSummary} onSubmit={onAiFilterSubmit} isLoggedIn={isLoggedIn} onLoginRequired={onLoginRequired} />
<AiFilterInput
loading={aiFilterLoading}
error={aiFilterError}
errorType={aiFilterErrorType}
notes={aiFilterNotes}
summary={aiFilterSummary}
onSubmit={onAiFilterSubmit}
isLoggedIn={isLoggedIn}
onLoginRequired={onLoginRequired}
/>
<div className="px-3 pb-2 space-y-2">
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
{(['historical', 'buy', 'rent'] as const).map((type) => {
@ -332,19 +339,21 @@ export default memo(function Filters({
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-10">
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
useBest={entry.useBest}
isPinned={pinnedFeature === travelFieldKey(entry)}
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
onSetDestination={(slug, label) =>
onTravelTimeSetDestination(index, slug, label)
}
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
onToggleBest={() => onTravelTimeToggleBest(index)}
onRemove={() => onTravelTimeRemoveEntry(index)}
/>
</div>
))}
</div>
@ -385,7 +394,11 @@ export default memo(function Filters({
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
/>
<FeatureActions
feature={feature}
isPinned={pinnedFeature === feature.name}
@ -419,7 +432,10 @@ export default memo(function Filters({
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [hist?.min ?? feature.min!, hist?.max ?? feature.max!];
: (filters[feature.name] as [number, number]) || [
hist?.min ?? feature.min!,
hist?.max ?? feature.max!,
];
const scale = percentileScales.get(feature.name);
const dataMin = hist?.min ?? feature.min!;
const dataMax = hist?.max ?? feature.max!;
@ -442,7 +458,12 @@ export default memo(function Filters({
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between gap-1">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />
<FeatureLabel
feature={feature}
onShowInfo={setActiveInfoFeature}
size="sm"
className="min-w-0 shrink"
/>
<FeatureActions
feature={feature}
isPinned={isPinned}
@ -454,7 +475,9 @@ export default memo(function Filters({
<Slider
min={scale ? 0 : feature.min!}
max={scale ? 100 : feature.max!}
step={scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)}
step={
scale ? 1 : (feature.step ?? (feature.max! - feature.min!) / 100)
}
value={sliderValue}
onValueChange={
scale
@ -462,14 +485,19 @@ export default memo(function Filters({
const step = feature.step ?? 1;
const snap = (v: number) => Math.round(v / step) * step;
onDragChange([
pMin <= 0 ? (hist?.min ?? feature.min!) : snap(scale.toValue(pMin)),
pMax >= 100 ? (hist?.max ?? feature.max!) : snap(scale.toValue(pMax)),
pMin <= 0
? (hist?.min ?? feature.min!)
: snap(scale.toValue(pMin)),
pMax >= 100
? (hist?.max ?? feature.max!)
: snap(scale.toValue(pMax)),
]);
}
: ([min, max]) => onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
: ([min, max]) =>
onDragChange([
min <= feature.min! ? (hist?.min ?? feature.min!) : min,
max >= feature.max! ? (hist?.max ?? feature.max!) : max,
])
}
onPointerDown={() => onDragStart(feature.name)}
onPointerUp={() => onDragEnd()}
@ -521,8 +549,8 @@ export default memo(function Filters({
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
<div className="space-y-4 text-sm">
<p className="text-warm-600 dark:text-warm-300">
Start with your must-haves, then layer on nice-to-haves.
The map narrows down as you add filters &mdash; the areas that survive are your best matches.
Start with your must-haves, then layer on nice-to-haves. The map narrows down as you
add filters &mdash; the areas that survive are your best matches.
</p>
<div>
@ -530,9 +558,9 @@ export default memo(function Filters({
1. Budget &amp; property basics
</h4>
<p className="text-warm-600 dark:text-warm-300">
Set your price range, minimum floor area, and property type.
If you need a lease over freehold (or vice versa), filter for that too.
This eliminates most of the map immediately.
Set your price range, minimum floor area, and property type. If you need a lease
over freehold (or vice versa), filter for that too. This eliminates most of the map
immediately.
</p>
</div>
@ -541,9 +569,9 @@ export default memo(function Filters({
2. Commute &amp; transport
</h4>
<p className="text-warm-600 dark:text-warm-300">
Add a travel time filter to your workplace &mdash; choose public transport or cycling
and set your maximum tolerable commute. You can also filter by
how many stations are within walking distance.
Add a travel time filter to your workplace &mdash; choose public transport or
cycling and set your maximum tolerable commute. You can also filter by how many
stations are within walking distance.
</p>
</div>
@ -552,9 +580,9 @@ export default memo(function Filters({
3. Safety &amp; environment
</h4>
<p className="text-warm-600 dark:text-warm-300">
Use the crime filters to cap serious or minor crime rates.
Check road noise levels if you&apos;re a light sleeper, and
environmental risk filters for ground stability concerns.
Use the crime filters to cap serious or minor crime rates. Check road noise levels
if you&apos;re a light sleeper, and environmental risk filters for ground stability
concerns.
</p>
</div>
@ -563,9 +591,9 @@ export default memo(function Filters({
4. Schools &amp; education
</h4>
<p className="text-warm-600 dark:text-warm-300">
Filter by the number of Ofsted-rated Good or Outstanding primary and
secondary schools nearby. The education deprivation score captures
broader area-level attainment.
Filter by the number of Ofsted-rated Good or Outstanding primary and secondary
schools nearby. The education deprivation score captures broader area-level
attainment.
</p>
</div>
@ -574,9 +602,8 @@ export default memo(function Filters({
5. Lifestyle &amp; amenities
</h4>
<p className="text-warm-600 dark:text-warm-300">
Want restaurants, parks, or grocery shops within walking distance?
Filter by nearby amenity counts. Broadband speed filters help if
you work from home.
Want restaurants, parks, or grocery shops within walking distance? Filter by nearby
amenity counts. Broadband speed filters help if you work from home.
</p>
</div>
@ -585,16 +612,15 @@ export default memo(function Filters({
6. Energy &amp; running costs
</h4>
<p className="text-warm-600 dark:text-warm-300">
EPC ratings from A to G indicate energy efficiency.
Filter for better ratings to find homes with lower bills and
fewer upgrade headaches.
EPC ratings from A to G indicate energy efficiency. Filter for better ratings to
find homes with lower bills and fewer upgrade headaches.
</p>
</div>
<div className="pt-1 border-t border-warm-200 dark:border-warm-700">
<p className="text-warm-500 dark:text-warm-400 italic">
Tip: if nothing survives your filters, relax one constraint at a time
to see which compromise unlocks the most options.
Tip: if nothing survives your filters, relax one constraint at a time to see which
compromise unlocks the most options.
</p>
</div>

View file

@ -17,13 +17,18 @@ interface HoverCardProps {
features: FeatureMeta[];
}
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) {
export default memo(function HoverCard({
x,
y,
id,
isPostcode,
data,
filters,
features,
}: HoverCardProps) {
const activeFilterNames = Object.keys(filters);
const featureMap = useMemo(
() => new Map(features.map((f) => [f.name, f])),
[features]
);
const featureMap = useMemo(() => new Map(features.map((f) => [f.name, f])), [features]);
// Get key stats to show from local data (min_<feature> values)
const getDisplayStats = () => {

View file

@ -116,9 +116,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
className="w-2.5 h-2.5 rounded-full shrink-0 mt-0.5"
style={{ backgroundColor: color }}
/>
{!isLast && (
<div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />
)}
{!isLast && <div className="flex-1 min-h-[4px] w-px bg-warm-300 dark:bg-warm-600" />}
</div>
<div className="pb-1.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
@ -135,7 +133,11 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
);
}
export default function JourneyInstructions({ postcode, entries, label }: JourneyInstructionsProps) {
export default function JourneyInstructions({
postcode,
entries,
label,
}: JourneyInstructionsProps) {
const [journeys, setJourneys] = useState<JourneyData[]>([]);
// Only transit entries with a destination set
@ -192,9 +194,7 @@ export default function JourneyInstructions({ postcode, entries, label }: Journe
)
.catch((err) => {
logNonAbortError('journey', err);
setJourneys((prev) =>
prev.map((j, i) => (i === idx ? { ...j, loading: false } : j))
);
setJourneys((prev) => prev.map((j, i) => (i === idx ? { ...j, loading: false } : j)));
});
});

View file

@ -78,10 +78,7 @@ export default function LocationSearch({
setLoading(true);
search.close();
try {
const res = await fetch(
`/api/postcode/${encodeURIComponent(result.label)}`,
authHeaders(),
);
const res = await fetch(`/api/postcode/${encodeURIComponent(result.label)}`, authHeaders());
if (!res.ok) {
setError('Postcode not found');
return;
@ -102,7 +99,7 @@ export default function LocationSearch({
setLoading(false);
}
},
[onFlyTo, onLocationSearched, isMobile, search],
[onFlyTo, onLocationSearched, isMobile, search]
);
// Mobile collapsed state: just a search icon button
@ -120,7 +117,12 @@ export default function LocationSearch({
}
return (
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col" onMouseEnter={onMouseEnter}>
<div
ref={containerRef}
data-tutorial="search"
className="absolute top-3 left-3 z-10 flex flex-col"
onMouseEnter={onMouseEnter}
>
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
<PlaceSearchInput

View file

@ -14,7 +14,13 @@ import type {
} from '../../types';
import { zoomToResolution, getBoundsFromViewState, getMapStyle } from '../../lib/map-utils';
import { INITIAL_VIEW_STATE, MAP_MIN_ZOOM, MAP_BOUNDS, POI_GROUP_COLORS, POI_DEFAULT_COLOR } from '../../lib/consts';
import {
INITIAL_VIEW_STATE,
MAP_MIN_ZOOM,
MAP_BOUNDS,
POI_GROUP_COLORS,
POI_DEFAULT_COLOR,
} from '../../lib/consts';
import LocationSearch, { type SearchedLocation } from './LocationSearch';
import MapLegend from './MapLegend';
import HoverCard from './HoverCard';
@ -114,8 +120,7 @@ export default memo(function Map({
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
// In screenshot mode, use the prop directly for instant updates (no async lag)
const viewState =
screenshotMode && initialViewState ? initialViewState : internalViewState;
const viewState = screenshotMode && initialViewState ? initialViewState : internalViewState;
useEffect(() => {
const container = containerRef.current;
@ -245,10 +250,7 @@ export default memo(function Map({
Transport
</span>
</div>
<span
className="text-teal-600 font-semibold"
style={{ fontSize: '1rem' }}
>
<span className="text-teal-600 font-semibold" style={{ fontSize: '1rem' }}>
perfect-postcode.co.uk
</span>
</div>
@ -256,7 +258,11 @@ export default memo(function Map({
) : null
) : (
<>
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} onMouseEnter={handleMouseLeave} />
<LocationSearch
onFlyTo={handleFlyTo}
onLocationSearched={onLocationSearched}
onMouseEnter={handleMouseLeave}
/>
{!hideLegend &&
(viewFeature && colorRange ? (
viewFeature.startsWith('tt_') ? (
@ -280,7 +286,9 @@ export default memo(function Map({
showCancel={viewSource === 'eye'}
onCancel={onCancelPin}
mode="feature"
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
enumValues={
colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined
}
theme={theme}
raw={colorFeatureMeta.raw}
/>

View file

@ -1,5 +1,10 @@
import { formatValue } from '../../lib/format';
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK, ENUM_PALETTE } from '../../lib/consts';
import {
FEATURE_GRADIENT,
DENSITY_GRADIENT,
DENSITY_GRADIENT_DARK,
ENUM_PALETTE,
} from '../../lib/consts';
import { gradientToCss } from '../../lib/utils';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { TickerValue } from '../ui/TickerValue';
@ -34,7 +39,9 @@ function InlineEnumSwatches({ values }: { values: string[] }) {
className="w-2.5 h-2.5 rounded-sm shrink-0"
style={{ backgroundColor: `rgb(${color[0]},${color[1]},${color[2]})` }}
/>
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">{label}</span>
<span className="text-warm-500 dark:text-warm-400 whitespace-nowrap text-[11px]">
{label}
</span>
</div>
);
})}
@ -106,7 +113,10 @@ export default function MapLegend({
) : (
<div className="flex items-center gap-1.5 flex-1 min-w-[40%] text-warm-500 dark:text-warm-400">
{rangeMin}
<div className="h-2.5 rounded flex-1 min-w-[40px]" style={{ background: gradientStyle }} />
<div
className="h-2.5 rounded flex-1 min-w-[40px]"
style={{ background: gradientStyle }}
/>
{rangeMax}
</div>
)}

View file

@ -1,5 +1,12 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry, Property } from '../../types';
import type {
FeatureMeta,
FeatureFilters,
POICategoryGroup,
ViewState,
PostcodeGeometry,
Property,
} from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
import Map from './Map';
@ -103,9 +110,7 @@ export default function MapPage({
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
const bookmarkToastDismissed = useRef(
localStorage.getItem('bookmark_toast_dismissed') === '1'
);
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
const handleSavePropertyWithToast = useCallback(
(property: Property) => {
@ -158,8 +163,7 @@ export default function MapPage({
max: entry.timeRange?.[1],
})),
};
const hasContext =
Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
const hasContext = Object.keys(context.filters).length > 0 || context.travelTime.length > 0;
const result = await aiFilters.fetchAiFilters(query, hasContext ? context : undefined);
if (!result) return;
@ -174,7 +178,13 @@ export default function MapPage({
}));
travelTime.handleSetEntries(newEntries);
},
[aiFilters.fetchAiFilters, handleSetFilters, travelTime.handleSetEntries, travelTime.activeEntries, filters]
[
aiFilters.fetchAiFilters,
handleSetFilters,
travelTime.handleSetEntries,
travelTime.activeEntries,
filters,
]
);
const handleTravelTimeSetDestination = useCallback(
@ -246,7 +256,14 @@ export default function MapPage({
const pois = usePOIData(mapData.bounds, selectedPOICategories);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
useUrlSync(
mapData.currentView,
filters,
features,
selectedPOICategories,
selection.rightPaneTab,
travelTime.entries
);
useEffect(() => {
mapData.setInitialView(initialViewState);
@ -268,11 +285,18 @@ export default function MapPage({
if (!res.ok) throw new Error('Postcode not found');
return res.json();
})
.then((data: { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry }) => {
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
selection.handleLocationSearch(data.postcode, data.geometry);
if (isMobile) setMobileDrawerOpen(true);
})
.then(
(data: {
postcode: string;
latitude: number;
longitude: number;
geometry: PostcodeGeometry;
}) => {
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
selection.handleLocationSearch(data.postcode, data.geometry);
if (isMobile) setMobileDrawerOpen(true);
}
)
.catch(() => {
// Silently fail — postcode might not exist
});
@ -397,7 +421,13 @@ export default function MapPage({
window.__screenshot_ready = true;
}
}
}, [screenshotMode, mapData.loading, mapData.data.length, mapData.postcodeData.length, mapData.usePostcodeView]);
}, [
screenshotMode,
mapData.loading,
mapData.data.length,
mapData.postcodeData.length,
mapData.usePostcodeView,
]);
const bookmarkToast = showBookmarkToast && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
@ -580,7 +610,9 @@ export default function MapPage({
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
@ -641,9 +673,7 @@ export default function MapPage({
inline
/>
)}
<div className="flex-1 min-h-0">
{renderFilters()}
</div>
<div className="flex-1 min-h-0">{renderFilters()}</div>
</div>
{mobileDrawerOpen && selection.selectedHexagon && (
@ -746,7 +776,9 @@ export default function MapPage({
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">
Loading...
</span>
</div>
</div>
)}
@ -794,9 +826,7 @@ export default function MapPage({
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderAreaPane()}
{selection.rightPaneTab === 'properties' ? renderPropertiesPane() : renderAreaPane()}
</div>
</div>
</div>

View file

@ -17,7 +17,6 @@ export default function MobileDrawer({
tab,
onTabChange,
}: MobileDrawerProps) {
// Close on Escape
useEffect(() => {
const handler = (e: KeyboardEvent) => {

View file

@ -21,7 +21,7 @@ export default function POIPane({
groups,
selectedCategories,
onCategoriesChange,
poiCount,
poiCount: _poiCount,
onNavigateToSource,
}: POIPaneProps) {
const [searchTerm, setSearchTerm] = useState('');
@ -136,7 +136,6 @@ export default function POIPane({
</p>
</InfoPopup>
)}
</div>
<div className="flex-1 overflow-y-auto border-t border-warm-200 dark:border-warm-700">

View file

@ -234,7 +234,9 @@ function PropertyCard({
)}
{price !== undefined && (
<div className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}>
<div
className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}
>
{askingPrice !== undefined || askingRent !== undefined ? (
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
Last sold: £{formatNumber(price)}
@ -265,9 +267,7 @@ function PropertyCard({
<span className="font-semibold text-teal-700 dark:text-teal-400">
£{formatNumber(estimatedPrice)}
</span>
{estPricePerSqm !== undefined && (
<span> (£{formatNumber(estPricePerSqm)}/m²)</span>
)}
{estPricePerSqm !== undefined && <span> (£{formatNumber(estPricePerSqm)}/m²)</span>}
</div>
)}

View file

@ -58,7 +58,7 @@ export function TravelTimeCard({
(selectedSlug: string, selectedLabel: string) => {
onSetDestination(selectedSlug, selectedLabel);
},
[onSetDestination],
[onSetDestination]
);
const sliderMin = 0;
@ -68,7 +68,9 @@ export function TravelTimeCard({
const ModeIcon = MODE_ICONS[mode];
return (
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
<div
className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
@ -86,7 +88,11 @@ export function TravelTimeCard({
</div>
<div className="flex items-center gap-0.5">
{slug && (
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
<IconButton
onClick={onTogglePin}
active={isPinned}
title={isPinned ? 'Stop previewing' : 'Preview on map'}
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
</IconButton>
)}
@ -126,8 +132,8 @@ export function TravelTimeCard({
? ' by car, based on typical road speeds and the road network.'
: mode === 'bicycle'
? ' by bicycle, using cycle-friendly routes.'
: ' on foot, using pedestrian paths and pavements.'}
{' '}Use the slider to filter areas within your preferred commute time.
: ' on foot, using pedestrian paths and pavements.'}{' '}
Use the slider to filter areas within your preferred commute time.
</p>
</InfoPopup>
)}
@ -135,8 +141,8 @@ export function TravelTimeCard({
{showBestInfo && (
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey
if you time your departure to catch optimal connections. The default uses the{' '}
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey if
you time your departure to catch optimal connections. The default uses the{' '}
<strong>median</strong>, representing a typical journey regardless of when you leave.
</p>
</InfoPopup>
@ -156,12 +162,8 @@ export function TravelTimeCard({
onValueChange={([min, max]) => onTimeRangeChange([min, max])}
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} min
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} min
</span>
<span className="absolute left-0">{formatFilterValue(displayRange[0])} min</span>
<span className="absolute right-0">{formatFilterValue(displayRange[1])} min</span>
</div>
</div>
)}

View file

@ -42,7 +42,7 @@ function tierLabel(tier: PricingTier, index: number): string {
export default function PricingPage({
onOpenDashboard,
user,
onLoginClick,
onLoginClick: _onLoginClick,
onRegisterClick,
}: {
onOpenDashboard: () => void;
@ -87,8 +87,7 @@ export default function PricingPage({
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;
spotsRemaining = tier.up_to === null ? 0 : tier.up_to - pricing.licensed_count;
break;
}
}
@ -99,7 +98,8 @@ export default function PricingPage({
if (currentTierIndex === 0) return;
const container = scrollRef.current;
const card = activeCardRef.current;
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
const scrollLeft =
card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]);
@ -117,9 +117,7 @@ export default function PricingPage({
disabled={license.checkingOut}
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{license.checkingOut && (
<SpinnerIcon className="w-5 h-5 animate-spin" />
)}
{license.checkingOut && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{license.checkingOut
? 'Redirecting...'
: isFree
@ -143,7 +141,8 @@ export default function PricingPage({
<div
className="absolute w-[90vw] h-[80vh] -top-[10%] -left-[15%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.72 0.19 145 / 0.18) 0%, oklch(0.55 0.15 160 / 0.08) 50%, transparent 100%)',
background:
'radial-gradient(in oklch, ellipse closest-side, oklch(0.72 0.19 145 / 0.18) 0%, oklch(0.55 0.15 160 / 0.08) 50%, transparent 100%)',
animation: 'aurora-1 20s ease-in-out infinite',
}}
/>
@ -151,7 +150,8 @@ export default function PricingPage({
<div
className="absolute w-[80vw] h-[70vh] top-[5%] left-[15%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.13 175 / 0.15) 0%, oklch(0.55 0.10 195 / 0.06) 50%, transparent 100%)',
background:
'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.13 175 / 0.15) 0%, oklch(0.55 0.10 195 / 0.06) 50%, transparent 100%)',
animation: 'aurora-2 18s ease-in-out infinite',
}}
/>
@ -159,7 +159,8 @@ export default function PricingPage({
<div
className="absolute w-[85vw] h-[90vh] -top-[5%] -right-[15%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.55 0.20 290 / 0.16) 0%, oklch(0.45 0.22 275 / 0.06) 50%, transparent 100%)',
background:
'radial-gradient(in oklch, ellipse closest-side, oklch(0.55 0.20 290 / 0.16) 0%, oklch(0.45 0.22 275 / 0.06) 50%, transparent 100%)',
animation: 'aurora-4 25s ease-in-out infinite',
}}
/>
@ -167,7 +168,8 @@ export default function PricingPage({
<div
className="absolute w-[75vw] h-[70vh] -bottom-[5%] right-[5%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.22 300 / 0.13) 0%, oklch(0.50 0.20 285 / 0.05) 50%, transparent 100%)',
background:
'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.22 300 / 0.13) 0%, oklch(0.50 0.20 285 / 0.05) 50%, transparent 100%)',
animation: 'aurora-3 22s ease-in-out infinite',
}}
/>
@ -175,7 +177,8 @@ export default function PricingPage({
<div
className="absolute w-[80vw] h-[75vh] -bottom-[10%] -left-[10%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.65 0.17 155 / 0.14) 0%, oklch(0.55 0.14 165 / 0.05) 50%, transparent 100%)',
background:
'radial-gradient(in oklch, ellipse closest-side, oklch(0.65 0.17 155 / 0.14) 0%, oklch(0.55 0.14 165 / 0.05) 50%, transparent 100%)',
animation: 'aurora-5 24s ease-in-out infinite',
}}
/>
@ -183,16 +186,15 @@ export default function PricingPage({
<div
className="absolute w-[70vw] h-[60vh] top-[20%] left-[20%]"
style={{
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.12 200 / 0.10) 0%, oklch(0.52 0.10 185 / 0.04) 50%, transparent 100%)',
background:
'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.12 200 / 0.10) 0%, oklch(0.52 0.10 185 / 0.04) 50%, transparent 100%)',
animation: 'aurora-1 16s ease-in-out infinite reverse',
}}
/>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-6">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
Early access pricing
</h1>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">Early access pricing</h1>
<p className="text-lg text-warm-300 max-w-lg mx-auto">
Pay once, access forever. The earlier you join, the less you pay.
</p>
@ -200,9 +202,9 @@ export default function PricingPage({
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
<p className="text-warm-400 text-sm leading-relaxed mb-2">
Buying a home costs &pound;10k+ in stamp duty, &pound;1,500 in solicitor fees,
&pound;500 for a survey. Get the wrong area and you&apos;re stuck with a long
commute, bad schools, or a road you didn&apos;t know about.
Buying a home costs &pound;10k+ in stamp duty, &pound;1,500 in solicitor fees, &pound;500
for a survey. Get the wrong area and you&apos;re stuck with a long commute, bad schools,
or a road you didn&apos;t know about.
</p>
<p className="text-warm-200 font-semibold">
Less than your survey costs. Vastly more useful.
@ -216,145 +218,151 @@ export default function PricingPage({
<SpinnerIcon className="w-8 h-8 animate-spin text-teal-400" />
</div>
) : pricing ? (
<div className="relative mb-12" style={{ marginLeft: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)', width: '100vw' }}>
{scrolledLeft && <div className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to right, black, transparent)' }} />}
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to left, black, transparent)' }} />
<div ref={scrollRef} onScroll={onScroll} className="flex justify-center gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
{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
<div
className="relative mb-12"
style={{
marginLeft: 'calc(-50vw + 50%)',
marginRight: 'calc(-50vw + 50%)',
width: '100vw',
}}
>
{scrolledLeft && (
<div
className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm"
style={{ maskImage: 'linear-gradient(to right, black, transparent)' }}
/>
)}
<div
className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm"
style={{ maskImage: 'linear-gradient(to left, black, transparent)' }}
/>
<div
ref={scrollRef}
onScroll={onScroll}
className="flex justify-center gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide"
style={{ scrollbarWidth: 'none' }}
>
{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}
ref={isCurrent ? activeCardRef : undefined}
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
: 'border-warm-700 shadow-md'
} ${isFilled ? 'opacity-60' : ''}`}
>
{isCurrent && (
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
Current tier
</div>
)}
return (
<div
className={`px-6 py-8 text-center ${
key={i}
ref={isCurrent ? activeCardRef : undefined}
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
isCurrent
? 'bg-gradient-to-br from-navy-950 to-teal-900'
: 'bg-white dark:bg-warm-800'
}`}
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
: 'border-warm-700 shadow-md'
} ${isFilled ? 'opacity-60' : ''}`}
>
<p
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
{isCurrent && (
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
Current tier
</div>
)}
<div
className={`px-6 py-8 text-center ${
isCurrent
? 'text-teal-300'
: 'text-warm-500 dark:text-warm-400'
? 'bg-gradient-to-br from-navy-950 to-teal-900'
: 'bg-white dark:bg-warm-800'
}`}
>
{tierLabel(tier, i)}
</p>
<div className="flex items-baseline justify-center gap-1">
<span
className={`text-4xl font-extrabold ${
isCurrent
? 'text-white'
: isFilled
? 'text-warm-400 dark:text-warm-500 line-through'
: 'text-navy-950 dark:text-warm-100'
<p
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
isCurrent ? 'text-teal-300' : 'text-warm-500 dark:text-warm-400'
}`}
>
{formatPrice(tier.price_pence)}
</span>
{tier.price_pence > 0 && (
{tierLabel(tier, i)}
</p>
<div className="flex items-baseline justify-center gap-1">
<span
className={`text-lg ${
className={`text-4xl font-extrabold ${
isCurrent
? 'text-warm-400'
: 'text-warm-400 dark:text-warm-500'
? 'text-white'
: isFilled
? 'text-warm-400 dark:text-warm-500 line-through'
: 'text-navy-950 dark:text-warm-100'
}`}
>
/lifetime
{formatPrice(tier.price_pence)}
</span>
{tier.price_pence > 0 && (
<span
className={`text-lg ${
isCurrent ? 'text-warm-400' : 'text-warm-400 dark:text-warm-500'
}`}
>
/lifetime
</span>
)}
</div>
{isCurrent && spotsRemaining > 0 && (
<p className="text-teal-300 text-sm mt-2 font-medium">
{spotsRemaining} spot
{spotsRemaining !== 1 ? 's' : ''} remaining
</p>
)}
{isFilled && (
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
<CheckIcon className="w-4 h-4" /> Filled
</p>
)}
</div>
{isCurrent && spotsRemaining > 0 && (
<p className="text-teal-300 text-sm mt-2 font-medium">
{spotsRemaining} spot
{spotsRemaining !== 1 ? 's' : ''} remaining
</p>
{/* Progress bar for current tier */}
{isCurrent && tierSlots > 0 && (
<div className="h-1.5 bg-warm-200 dark:bg-warm-700">
<div className="h-full bg-teal-500" style={{ width: `${fillPercent}%` }} />
</div>
)}
{isFilled && (
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
<CheckIcon className="w-4 h-4" /> Filled
</p>
)}
</div>
{/* Progress bar for current tier */}
{isCurrent && tierSlots > 0 && (
<div className="h-1.5 bg-warm-200 dark:bg-warm-700">
<div
className="h-full bg-teal-500"
style={{ width: `${fillPercent}%` }}
/>
</div>
)}
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
<ul className="space-y-3 mb-6 flex-1">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-start gap-2.5 text-sm">
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">{feature}</span>
</li>
))}
</ul>
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
<ul className="space-y-3 mb-6 flex-1">
{FEATURES.map((feature) => (
<li key={feature} className="flex items-start gap-2.5 text-sm">
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
<span className="text-warm-700 dark:text-warm-300">
{feature}
</span>
</li>
))}
</ul>
{isCurrent ? (
<>
{ctaButton}
{license.error && (
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">
{license.error}
{isCurrent ? (
<>
{ctaButton}
{license.error && (
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">
{license.error}
</p>
)}
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{isFree ? 'No credit card required' : '30-day money-back guarantee'}
</p>
)}
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
{isFree
? 'No credit card required'
: '30-day money-back guarantee'}
</p>
</>
) : isFilled ? (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
Sold out
</div>
) : (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
Upcoming
</div>
)}
</>
) : isFilled ? (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
Sold out
</div>
) : (
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
Upcoming
</div>
)}
</div>
</div>
</div>
);
})}
);
})}
</div>
</div>
) : (
@ -363,7 +371,6 @@ export default function PricingPage({
</p>
)}
</div>
</div>
);
}

View file

@ -16,7 +16,10 @@ export function CollapsibleGroupHeader({
children,
}: CollapsibleGroupHeaderProps) {
return (
<button onClick={onToggle} className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}>
<button
onClick={onToggle}
className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}
>
<span>{name}</span>
<div className="flex items-center gap-1">
{children}

View file

@ -1,10 +1,4 @@
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
} from 'react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import type { Destination } from '../../hooks/useTravelDestinations';
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
@ -42,9 +36,7 @@ export function DestinationDropdown({
if (!filter) return destinations;
const lower = filter.toLowerCase();
return destinations.filter(
(d) =>
d.name.toLowerCase().includes(lower) ||
d.city?.toLowerCase().includes(lower),
(d) => d.name.toLowerCase().includes(lower) || d.city?.toLowerCase().includes(lower)
);
}, [destinations, filter]);
@ -79,16 +71,14 @@ export function DestinationDropdown({
setFilter('');
setActiveIndex(-1);
},
[onSelect],
[onSelect]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((prev) =>
prev < filtered.length - 1 ? prev + 1 : prev,
);
setActiveIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1));
@ -102,7 +92,7 @@ export function DestinationDropdown({
setFilter('');
}
},
[filtered, activeIndex, handleSelect],
[filtered, activeIndex, handleSelect]
);
const handleOpen = useCallback(() => {
@ -170,10 +160,7 @@ export function DestinationDropdown({
<span className="text-warm-700 dark:text-warm-200 truncate">
{dest.name}
{dest.city && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
({dest.city})
</span>
<span className="text-warm-400 dark:text-warm-500"> ({dest.city})</span>
)}
</span>
</button>
@ -185,7 +172,9 @@ export function DestinationDropdown({
return (
<div ref={containerRef} className="relative">
<div className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border ${value ? 'border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800' : 'border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 hover:border-warm-300 dark:hover:border-warm-500'}`}>
<div
className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border ${value ? 'border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800' : 'border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 hover:border-warm-300 dark:hover:border-warm-500'}`}
>
<button
type="button"
onClick={handleOpen}
@ -194,9 +183,13 @@ export function DestinationDropdown({
{loading ? (
<div className="w-3 h-3 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin shrink-0" />
) : (
<MapPinIcon className={`w-3 h-3 shrink-0 ${value ? 'text-red-500' : 'text-warm-400 dark:text-warm-500'}`} />
<MapPinIcon
className={`w-3 h-3 shrink-0 ${value ? 'text-red-500' : 'text-warm-400 dark:text-warm-500'}`}
/>
)}
<span className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}>
<span
className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}
>
{value || placeholder}
</span>
</button>

View file

@ -14,7 +14,15 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite';
export type Page =
| 'home'
| 'dashboard'
| 'learn'
| 'pricing'
| 'account'
| 'saved'
| 'invites'
| 'invite';
export const PAGE_PATHS: Record<Page, string> = {
home: '/',
@ -128,27 +136,51 @@ export default function Header({
{/* Desktop nav */}
{!isMobile && (
<nav className="flex items-center gap-2">
<a href={PAGE_PATHS.dashboard} className={tabClass('dashboard')} onClick={(e) => navLink('dashboard', e)}>
<a
href={PAGE_PATHS.dashboard}
className={tabClass('dashboard')}
onClick={(e) => navLink('dashboard', e)}
>
Dashboard
</a>
{user && (
<>
<a href={PAGE_PATHS.saved} className={tabClass('saved')} onClick={(e) => navLink('saved', e)}>
<a
href={PAGE_PATHS.saved}
className={tabClass('saved')}
onClick={(e) => navLink('saved', e)}
>
Saved
</a>
<a href={PAGE_PATHS.invites} className={tabClass('invites')} onClick={(e) => navLink('invites', e)}>
<a
href={PAGE_PATHS.invites}
className={tabClass('invites')}
onClick={(e) => navLink('invites', e)}
>
Invite
</a>
<a href={PAGE_PATHS.account} className={tabClass('account')} onClick={(e) => navLink('account', e)}>
<a
href={PAGE_PATHS.account}
className={tabClass('account')}
onClick={(e) => navLink('account', e)}
>
Account
</a>
</>
)}
<a href={PAGE_PATHS.learn} className={tabClass('learn')} onClick={(e) => navLink('learn', e)}>
<a
href={PAGE_PATHS.learn}
className={tabClass('learn')}
onClick={(e) => navLink('learn', e)}
>
Learn
</a>
{user?.subscription !== 'licensed' && !user?.isAdmin && (
<a href={PAGE_PATHS.pricing} className={tabClass('pricing')} onClick={(e) => navLink('pricing', e)}>
<a
href={PAGE_PATHS.pricing}
className={tabClass('pricing')}
onClick={(e) => navLink('pricing', e)}
>
Pricing
</a>
)}

View file

@ -51,9 +51,7 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
<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 access is now active.
</p>
<p className="text-warm-300 text-sm mt-2">Your lifetime access is now active.</p>
</div>
<div className="px-6 py-6">
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">

View file

@ -85,7 +85,9 @@ export default function MobileMenu({
{mobileNavItem('home', 'Home')}
{mobileNavItem('dashboard', 'Dashboard')}
{mobileNavItem('learn', 'Learn')}
{user?.subscription !== 'licensed' && !user?.isAdmin && mobileNavItem('pricing', 'Pricing')}
{user?.subscription !== 'licensed' &&
!user?.isAdmin &&
mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('saved', 'Saved')}
{user && mobileNavItem('invites', 'Invite')}
{user && mobileNavItem('account', 'Account')}

View file

@ -14,10 +14,7 @@ interface SearchHook {
open: boolean;
setOpen: (open: boolean) => void;
handleInputChange: (value: string) => void;
handleKeyDown: (
e: React.KeyboardEvent,
onSelect: (result: SearchResult) => void,
) => void;
handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void;
}
interface PlaceSearchInputProps {
@ -56,16 +53,20 @@ export function PlaceSearchInput({
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
style={
portal && dropdownPos
? { position: 'fixed', top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width, zIndex: 50 }
? {
position: 'fixed',
top: dropdownPos.top,
left: dropdownPos.left,
width: dropdownPos.width,
zIndex: 50,
}
: undefined
}
>
{search.results.map((result, idx) => (
<button
key={
result.type === 'postcode'
? `pc-${result.label}`
: `pl-${result.name}-${result.lat}`
result.type === 'postcode' ? `pc-${result.label}` : `pl-${result.name}-${result.lat}`
}
type="button"
className={`w-full text-left flex items-center cursor-pointer ${
@ -83,23 +84,16 @@ export function PlaceSearchInput({
>
{result.type === 'postcode' ? (
<>
<SearchIcon
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
/>
<SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
</>
) : (
<>
<MapPinIcon
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
/>
<MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} />
<span className="text-warm-700 dark:text-warm-200">
{result.name}
{result.city && (
<span className="text-warm-400 dark:text-warm-500">
{' '}
({result.city})
</span>
<span className="text-warm-400 dark:text-warm-500"> ({result.city})</span>
)}
</span>
</>
@ -133,10 +127,12 @@ export function PlaceSearchInput({
/>
)}
{showDropdown && (portal
? createPortal(dropdown, document.body)
: <div className="absolute top-full left-0 right-0 mt-1 z-20">{dropdown}</div>
)}
{showDropdown &&
(portal ? (
createPortal(dropdown, document.body)
) : (
<div className="absolute top-full left-0 right-0 mt-1 z-20">{dropdown}</div>
))}
</div>
);
}

View file

@ -22,7 +22,11 @@ function Digit({ char, delay, active }: { char: string; delay: number; active: b
}}
>
{DIGITS.split('').map((d) => (
<span key={d} className="block text-center" style={{ height: `${H}em`, lineHeight: `${H}em` }}>
<span
key={d}
className="block text-center"
style={{ height: `${H}em`, lineHeight: `${H}em` }}
>
{d}
</span>
))}

View file

@ -32,11 +32,7 @@ export default function UpgradeModal({
}, []);
const priceLabel =
pricePence === null
? '...'
: pricePence === 0
? 'Free'
: `\u00A3${pricePence / 100}`;
pricePence === null ? '...' : pricePence === 0 ? 'Free' : `\u00A3${pricePence / 100}`;
const isFree = pricePence === 0;
const handleUpgrade = async () => {
@ -76,9 +72,7 @@ export default function UpgradeModal({
<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>
)}
{!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

View file

@ -1,13 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
export default function UserMenu({
user,
onLogout,
}: {
user: AuthUser;
onLogout: () => void;
}) {
export default function UserMenu({ user, onLogout }: { user: AuthUser; onLogout: () => void }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);

View file

@ -11,11 +11,7 @@ export function LogoIcon({ className = 'w-4 h-4' }: IconProps) {
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 2L20.7 7v10L12 22l-8.7-5V7z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2L20.7 7v10L12 22l-8.7-5V7z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.5 12.5l2.5 2.5 4.5-5" />
</svg>
);

View file

@ -6,8 +6,14 @@ export function SparklesIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
<path
d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z"
opacity=".7"
/>
<path
d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z"
opacity=".4"
/>
</svg>
);
}

View file

@ -1,6 +1,6 @@
import { useState, useCallback, useRef } from 'react';
import type { FeatureFilters } from '../types';
import type { TransportMode, TravelTimeEntry } from './useTravelTime';
import type { TransportMode } from './useTravelTime';
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
export interface AiTravelTimeFilter {
@ -37,10 +37,7 @@ interface UseAiFiltersResult {
}
/** Build a human-readable summary of the AI result. */
function buildSummary(
filters: FeatureFilters,
travelTimeFilters: AiTravelTimeFilter[]
): string {
function buildSummary(filters: FeatureFilters, travelTimeFilters: AiTravelTimeFilter[]): string {
const parts: string[] = [];
for (const [name, value] of Object.entries(filters)) {

View file

@ -1,6 +1,10 @@
import { useState, useCallback } from 'react';
export function useCollapsibleGroups(): [Set<string>, (name: string) => void, (name: string) => void] {
export function useCollapsibleGroups(): [
Set<string>,
(name: string) => void,
(name: string) => void,
] {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const toggle = useCallback((name: string) => {

View file

@ -24,13 +24,9 @@ import {
POI_CLUSTER_MAX_ZOOM,
} from '../lib/consts';
import { emojiToTwemojiUrl, getFeatureFillColor } from '../lib/map-utils';
import {
type TravelTimeEntry,
travelFieldKey,
} from './useTravelTime';
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
import { MarchingAntsExtension } from '../lib/MarchingAntsExtension';
interface UseDeckLayersProps {
data: HexagonData[];
postcodeData: PostcodeFeature[];
@ -314,8 +310,17 @@ export function useDeckLayers({
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];
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,
];
}
}
@ -329,7 +334,12 @@ export function useDeckLayers({
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
return getFeatureFillColor(
ttVal as number,
@ -423,8 +433,17 @@ export function useDeckLayers({
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];
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,
];
}
}
@ -438,7 +457,12 @@ export function useDeckLayers({
if (vf.startsWith('tt_')) {
const ttVal = d[`avg_${vf}`];
if (ttVal == null) {
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [
number,
number,
number,
number,
];
}
return getFeatureFillColor(
ttVal as number,
@ -673,8 +697,7 @@ export function useDeckLayers({
id: 'poi-cluster-text',
data: clusters,
getPosition: (d) => [d.lng, d.lat],
getText: (d) =>
d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count),
getText: (d) => (d.count >= 1000 ? `${(d.count / 1000).toFixed(1)}k` : String(d.count)),
getSize: 12,
getColor: [255, 255, 255, 255],
fontWeight: 700,

View file

@ -1,10 +1,7 @@
import { useCallback, useLayoutEffect, useState } from 'react';
import type React from 'react';
export function useDropdownPosition(
anchorRef: React.RefObject<HTMLElement | null>,
open: boolean,
) {
export function useDropdownPosition(anchorRef: React.RefObject<HTMLElement | null>, open: boolean) {
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
const update = useCallback(() => {

View file

@ -29,7 +29,12 @@ interface UseHexagonSelectionOptions {
journeyDest?: JourneyDest | null;
}
export function useHexagonSelection({ filters, features, resolution, journeyDest }: UseHexagonSelectionOptions) {
export function useHexagonSelection({
filters,
features,
resolution,
journeyDest,
}: UseHexagonSelectionOptions) {
const [selectedHexagon, setSelectedHexagon] = useState<SelectedHexagon | null>(null);
const [properties, setProperties] = useState<Property[]>([]);
const [propertiesTotal, setPropertiesTotal] = useState(0);
@ -39,8 +44,9 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest
const [loadingAreaStats, setLoadingAreaStats] = useState(false);
const [hoveredHexagon, setHoveredHexagon] = useState<string | null>(null);
const [rightPaneTab, setRightPaneTab] = useState<'properties' | 'area'>('area');
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] =
useState<PostcodeGeometry | null>(null);
const [selectedPostcodeGeometry, setSelectedPostcodeGeometry] = useState<PostcodeGeometry | null>(
null
);
const fetchHexagonStats = useCallback(
async (h3: string, res: number, signal?: AbortSignal, fields?: string[]) => {
@ -204,7 +210,13 @@ export function useHexagonSelection({ filters, features, resolution, journeyDest
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}
}, [selectedHexagon, properties.length, loadingProperties, fetchHexagonProperties, fetchPostcodeProperties]);
}, [
selectedHexagon,
properties.length,
loadingProperties,
fetchHexagonProperties,
fetchPostcodeProperties,
]);
const handleLoadMoreProperties = useCallback(() => {
if (!selectedHexagon) return;

View file

@ -19,7 +19,15 @@ function normalizePostcode(s: string): string {
export type SearchResult =
| { type: 'postcode'; label: string }
| { type: 'place'; name: string; slug: 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(mode?: string) {
const [query, setQuery] = useState('');
@ -29,60 +37,63 @@ export function useLocationSearch(mode?: string) {
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleInputChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(-1);
const handleInputChange = useCallback(
(value: string) => {
setQuery(value);
setActiveIndex(-1);
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
setOpen(false);
return;
}
if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
setOpen(true);
return;
}
if (trimmed.length < 2) {
setResults([]);
setOpen(false);
return;
}
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
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 }),
);
if (!res.ok) return;
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
type: 'place' as const,
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);
} catch (err) {
logNonAbortError('places search', err);
const trimmed = value.trim();
if (!trimmed) {
setResults([]);
setOpen(false);
return;
}
}, 200);
}, [mode]);
if (!mode && looksLikePostcode(trimmed)) {
setResults([{ type: 'postcode', label: normalizePostcode(trimmed) }]);
setOpen(true);
return;
}
if (trimmed.length < 2) {
setResults([]);
setOpen(false);
return;
}
debounceRef.current = setTimeout(async () => {
const controller = new AbortController();
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 })
);
if (!res.ok) return;
const json: { places: PlaceResult[] } = await res.json();
const placeResults: SearchResult[] = json.places.map((p) => ({
type: 'place' as const,
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);
} catch (err) {
logNonAbortError('places search', err);
}
}, 200);
},
[mode]
);
const close = useCallback(() => setOpen(false), []);
@ -112,7 +123,7 @@ export function useLocationSearch(mode?: string) {
setOpen(false);
}
},
[results, activeIndex, query],
[results, activeIndex, query]
);
// Cleanup on unmount

View file

@ -8,7 +8,14 @@ import type {
ViewChangeParams,
ApiResponse,
} from '../types';
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } 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 { type TravelTimeEntry } from './useTravelTime';
@ -243,8 +250,11 @@ export function useMapData({
}, [resolution, bounds, filters, buildFilterParam, viewFeature, usePostcodeView, travelParam]);
// Use drag data when it matches the current view feature, otherwise fall back to rawData
const data = (viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
const effectivePostcodeData = (viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ?? postcodeData;
const data =
(viewFeature && dragFeatureRef.current === viewFeature ? dragHexData : null) ?? rawData;
const effectivePostcodeData =
(viewFeature && dragFeatureRef.current === viewFeature ? dragPostcodeData : null) ??
postcodeData;
// Compute p5/p95 from committed data for the viewed feature.
// Always uses rawData/postcodeData (not drag preview data) so the color

View file

@ -46,7 +46,10 @@ export function useSavedProperties(userId: string | null) {
const raw = r as Record<string, unknown>;
let data: SavedPropertyData = {};
try {
data = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data as SavedPropertyData) || {};
data =
typeof raw.data === 'string'
? JSON.parse(raw.data)
: (raw.data as SavedPropertyData) || {};
} catch {
// Invalid JSON — use empty data
}

View file

@ -24,10 +24,7 @@ export function useTravelDestinations(mode: TransportMode) {
const controller = new AbortController();
setLoading(true);
fetch(
`/api/travel-destinations?mode=${mode}`,
authHeaders({ signal: controller.signal }),
)
fetch(`/api/travel-destinations?mode=${mode}`, authHeaders({ signal: controller.signal }))
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();

View file

@ -21,7 +21,7 @@ export function useTravelModes() {
})
.then((data: { modes: TravelModeInfo[] }) => {
const modes = new Set<TransportMode>(
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode),
data.modes.filter((m) => m.destinations > 0).map((m) => m.mode)
);
setAvailableModes(modes);
})

View file

@ -40,58 +40,39 @@ export function useTravelTime(initial?: TravelTimeInitial) {
const [entries, setEntries] = useState<TravelTimeEntry[]>(initial?.entries ?? []);
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
{ mode, slug: '', label: '', timeRange: null, useBest: false },
]);
setEntries((prev) => [...prev, { mode, slug: '', label: '', timeRange: null, useBest: false }]);
}, []);
const handleRemoveEntry = useCallback((index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleSetDestination = useCallback(
(index: number, slug: string, label: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
)
);
},
[]
);
const handleSetDestination = useCallback((index: number, slug: string, label: string) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, slug, label, timeRange: slug ? [0, 120] : null } : entry
)
);
}, []);
const handleTimeRangeChange = useCallback(
(index: number, range: [number, number]) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, timeRange: range } : entry
)
);
},
[]
);
const handleTimeRangeChange = useCallback((index: number, range: [number, number]) => {
setEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, timeRange: range } : entry))
);
}, []);
const handleToggleBest = useCallback(
(index: number) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, useBest: !entry.useBest } : entry
)
);
},
[]
);
const handleToggleBest = useCallback((index: number) => {
setEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, useBest: !entry.useBest } : entry))
);
}, []);
const handleSetEntries = useCallback((newEntries: TravelTimeEntry[]) => {
setEntries(newEntries);
}, []);
/** Entries that have a destination selected (slug is set) */
const activeEntries = useMemo(
() => entries.filter((e) => e.slug !== ''),
[entries]
);
const activeEntries = useMemo(() => entries.filter((e) => e.slug !== ''), [entries]);
return {
entries,

View file

@ -32,8 +32,7 @@ const STEPS: Step[] = [
{
target: '[data-tutorial="search"]',
title: 'Search Locations',
content:
'Search for a place name or postcode to jump directly to that area on the map.',
content: 'Search for a place name or postcode to jump directly to that area on the map.',
placement: 'bottom',
disableBeacon: true,
},

View file

@ -47,13 +47,22 @@ h3 {
/* Hexagon background animations */
@keyframes hex-drift {
from { transform: translateX(-5vw); }
to { transform: translateX(105vw); }
from {
transform: translateX(-5vw);
}
to {
transform: translateX(105vw);
}
}
@keyframes hex-bob {
0%, 100% { transform: translateY(var(--bob)); }
50% { transform: translateY(calc(var(--bob) * -1)); }
0%,
100% {
transform: translateY(var(--bob));
}
50% {
transform: translateY(calc(var(--bob) * -1));
}
}
/* Fade-in animation for homepage sections */
@ -131,32 +140,65 @@ h3 {
/* Aurora gradient animation for pricing hero */
@keyframes aurora-1 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(30px, -20px) scale(1.1); }
66% { transform: translate(-20px, 15px) scale(0.9); }
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -20px) scale(1.1);
}
66% {
transform: translate(-20px, 15px) scale(0.9);
}
}
@keyframes aurora-2 {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(-40px, 20px) scale(1.15); }
66% { transform: translate(25px, -30px) scale(0.95); }
0%,
100% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(-40px, 20px) scale(1.15);
}
66% {
transform: translate(25px, -30px) scale(0.95);
}
}
@keyframes aurora-3 {
0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
50% { transform: translate(20px, 25px) scale(1.1) rotate(3deg); }
0%,
100% {
transform: translate(0, 0) scale(1) rotate(0deg);
}
50% {
transform: translate(20px, 25px) scale(1.1) rotate(3deg);
}
}
@keyframes aurora-4 {
0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
40% { transform: translate(-35px, -15px) scale(1.2) rotate(-2deg); }
70% { transform: translate(15px, 20px) scale(0.9) rotate(1deg); }
0%,
100% {
transform: translate(0, 0) scale(1) rotate(0deg);
}
40% {
transform: translate(-35px, -15px) scale(1.2) rotate(-2deg);
}
70% {
transform: translate(15px, 20px) scale(0.9) rotate(1deg);
}
}
@keyframes aurora-5 {
0%, 100% { transform: translate(0, 0) scale(1); }
30% { transform: translate(25px, 30px) scale(1.15); }
60% { transform: translate(-30px, -10px) scale(0.95); }
0%,
100% {
transform: translate(0, 0) scale(1);
}
30% {
transform: translate(25px, 30px) scale(1.15);
}
60% {
transform: translate(-30px, -10px) scale(0.95);
}
}
/* Hide scrollbar for pill groups on mobile */
@ -168,4 +210,3 @@ h3 {
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

View file

@ -70,7 +70,11 @@ export async function shortenUrl(params: string): Promise<string> {
return `${window.location.origin}${data.url}`;
}
export function buildFilterString(filters: FeatureFilters, features: FeatureMeta[], exclude?: string): string {
export function buildFilterString(
filters: FeatureFilters,
features: FeatureMeta[],
exclude?: string
): string {
const entries = Object.entries(filters);
if (entries.length === 0) return '';
return entries

View file

@ -87,12 +87,7 @@ export const POI_GROUP_COLORS: Record<string, [number, number, number]> = {
export const POI_DEFAULT_COLOR: [number, number, number] = [107, 114, 128];
/** Categories only shown when zoomed in past MINOR_POI_ZOOM_THRESHOLD */
export const MINOR_POI_CATEGORIES = new Set([
'Bus stop',
'Taxi rank',
'EV Charging',
'Playground',
]);
export const MINOR_POI_CATEGORIES = new Set(['Bus stop', 'Taxi rank', 'EV Charging', 'Playground']);
/** Zoom level below which minor POI categories are hidden */
export const MINOR_POI_ZOOM_THRESHOLD = 14;
@ -217,16 +212,16 @@ export const STACKED_ENUM_GROUPS: Record<
* 10 colors chosen for perceptual distinctness in both light and dark modes.
*/
export const ENUM_PALETTE: [number, number, number][] = [
[59, 130, 246], // blue-500
[249, 115, 22], // orange-500
[139, 92, 246], // violet-500
[34, 197, 94], // green-500
[239, 68, 68], // red-500
[6, 182, 212], // cyan-500
[236, 72, 153], // pink-500
[245, 158, 11], // amber-500
[20, 184, 166], // teal-500
[107, 114, 128], // gray-500
[59, 130, 246], // blue-500
[249, 115, 22], // orange-500
[139, 92, 246], // violet-500
[34, 197, 94], // green-500
[239, 68, 68], // red-500
[6, 182, 212], // cyan-500
[236, 72, 153], // pink-500
[245, 158, 11], // amber-500
[20, 184, 166], // teal-500
[107, 114, 128], // gray-500
];
/** Colors for stacked bar segments */

View file

@ -42,11 +42,11 @@ const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
// Rightmove only accepts these specific price values
const RIGHTMOVE_PRICES = [
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000,
160000, 170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 260000,
270000, 280000, 290000, 300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000,
550000, 600000, 650000, 700000, 800000, 900000, 1000000, 1250000, 1500000, 1750000, 2000000,
2500000, 3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
50000, 60000, 70000, 80000, 90000, 100000, 110000, 120000, 125000, 130000, 140000, 150000, 160000,
170000, 175000, 180000, 190000, 200000, 210000, 220000, 230000, 240000, 250000, 260000, 270000,
280000, 290000, 300000, 325000, 350000, 375000, 400000, 425000, 450000, 475000, 500000, 550000,
600000, 650000, 700000, 800000, 900000, 1000000, 1250000, 1500000, 1750000, 2000000, 2500000,
3000000, 4000000, 5000000, 7500000, 10000000, 15000000, 20000000,
];
function nearestRadius(target: number, allowed: number[]): number {
@ -99,15 +99,23 @@ export function buildPropertySearchUrls({
const bedroomFilter = filters['Bedrooms'];
const minBedrooms =
Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number' ? bedroomFilter[0] : undefined;
Array.isArray(bedroomFilter) && typeof bedroomFilter[0] === 'number'
? bedroomFilter[0]
: undefined;
const maxBedrooms =
Array.isArray(bedroomFilter) && typeof bedroomFilter[1] === 'number' ? bedroomFilter[1] : undefined;
Array.isArray(bedroomFilter) && typeof bedroomFilter[1] === 'number'
? bedroomFilter[1]
: undefined;
const bathroomFilter = filters['Bathrooms'];
const minBathrooms =
Array.isArray(bathroomFilter) && typeof bathroomFilter[0] === 'number' ? bathroomFilter[0] : undefined;
Array.isArray(bathroomFilter) && typeof bathroomFilter[0] === 'number'
? bathroomFilter[0]
: undefined;
const maxBathrooms =
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number' ? bathroomFilter[1] : undefined;
Array.isArray(bathroomFilter) && typeof bathroomFilter[1] === 'number'
? bathroomFilter[1]
: undefined;
const tenureFilter = filters['Leasehold/Freehold'];
const selectedTenures =
@ -123,8 +131,10 @@ export function buildPropertySearchUrls({
rmParams.set('useLocationIdentifier', 'true');
rmParams.set('locationIdentifier', rightmoveLocationId);
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
if (minPrice !== undefined) rmParams.set('minPrice', String(snapRightmovePrice(minPrice, 'floor')));
if (maxPrice !== undefined) rmParams.set('maxPrice', String(snapRightmovePrice(maxPrice, 'ceil')));
if (minPrice !== undefined)
rmParams.set('minPrice', String(snapRightmovePrice(minPrice, 'floor')));
if (maxPrice !== undefined)
rmParams.set('maxPrice', String(snapRightmovePrice(maxPrice, 'ceil')));
if (minBedrooms !== undefined) rmParams.set('minBedrooms', String(Math.floor(minBedrooms)));
if (maxBedrooms !== undefined) rmParams.set('maxBedrooms', String(Math.ceil(maxBedrooms)));
if (minBathrooms !== undefined) rmParams.set('minBathrooms', String(Math.floor(minBathrooms)));

View file

@ -494,10 +494,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
/**
* Returns a complete SVG icon element for a given feature name, or null if unmapped.
*/
export function getFeatureIcon(
featureName: string,
className: string,
): ReactElement | null {
export function getFeatureIcon(featureName: string, className: string): ReactElement | null {
const paths = FEATURE_ICON_PATHS[featureName];
if (!paths) return null;
return (

View file

@ -30,8 +30,18 @@ export function formatDuration(d: string): string {
}
const MONTH_NAMES = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
export function formatTransactionDate(fractionalYear: number): string {

View file

@ -24,8 +24,6 @@ const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
Property: TagIcon,
};
export function getGroupIcon(
group: string,
): ComponentType<{ className?: string }> | null {
export function getGroupIcon(group: string): ComponentType<{ className?: string }> | null {
return GROUP_ICONS[group] ?? null;
}