vibes
This commit is contained in:
parent
80c093b7ba
commit
f72c43a9fa
101 changed files with 2168 additions and 1177 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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's available, not what's possible — 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's available, not what's possible — 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">
|
||||
✓
|
||||
</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">
|
||||
✓
|
||||
</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',
|
||||
|
|
|
|||
|
|
@ -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're about to spend{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">
|
||||
up to £500k
|
||||
</strong>{' '}
|
||||
on a home.
|
||||
<strong className="text-navy-950 dark:text-warm-100">up to £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">
|
||||
…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's just 4 filters. We've built <strong className="text-navy-950 dark:text-warm-100">56</strong> —
|
||||
covering commute times, crime, broadband, noise, schools, amenities, and more.
|
||||
That's just 4 filters. We've built{' '}
|
||||
<strong className="text-navy-950 dark:text-warm-100">56</strong> — 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
— describe what you'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'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 — 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 — the areas that survive are your best matches.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
|
|
@ -530,9 +558,9 @@ export default memo(function Filters({
|
|||
1. Budget & 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 & transport
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Add a travel time filter to your workplace — 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 — 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 & 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'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'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 & 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 & 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 & 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export default function MobileDrawer({
|
|||
tab,
|
||||
onTabChange,
|
||||
}: MobileDrawerProps) {
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 £10k+ in stamp duty, £1,500 in solicitor fees,
|
||||
£500 for a survey. Get the wrong area and you're stuck with a long
|
||||
commute, bad schools, or a road you didn't know about.
|
||||
Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, £500
|
||||
for a survey. Get the wrong area and you're stuck with a long commute, bad schools,
|
||||
or a road you didn'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue