This commit is contained in:
Ruby 2026-02-22 23:22:35 +00:00
commit afa6934a2d
91 changed files with 2122 additions and 1360 deletions

View file

@ -12,6 +12,7 @@ import LicenseSuccessModal from './components/ui/LicenseSuccessModal';
import VerificationBanner from './components/ui/VerificationBanner';
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
import { fetchWithRetry, apiUrl } from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
@ -77,7 +78,10 @@ export default function App() {
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
const [initialLoading, setInitialLoading] = useState(true);
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
const [inviteCode, setInviteCode] = useState<string | null>(null);
const [inviteCode, setInviteCode] = useState<string | null>(() => {
const fromPath = pathToPage(window.location.pathname);
return fromPath?.inviteCode ?? null;
});
const [activePage, setActivePage] = useState<Page>(() => {
if (isScreenshotMode) return 'dashboard';
@ -88,16 +92,13 @@ export default function App() {
// Restore from history state (e.g. popstate)
if (window.history.state?.page) return window.history.state.page;
// Unknown path — track as 404
if (window.location.pathname !== '/') {
trackEvent('404', { path: window.location.pathname });
}
return 'home';
});
useEffect(() => {
const fromPath = pathToPage(window.location.pathname);
if (fromPath?.inviteCode) {
setInviteCode(fromPath.inviteCode);
}
}, []);
const { theme, toggleTheme } = useTheme();
const isMobile = useIsMobile();
const {
@ -126,6 +127,7 @@ export default function App() {
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
trackEvent('Purchase');
setShowLicenseSuccess(true);
refreshAuth();
}
@ -230,7 +232,7 @@ export default function App() {
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || {}}
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'}
@ -326,7 +328,7 @@ export default function App() {
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || {}}
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'}
@ -366,12 +368,13 @@ export default function App() {
<SaveSearchModal
onClose={() => setShowSaveModal(false)}
onSave={savedSearches.saveSearch}
onViewSearches={() => { setShowSaveModal(false); navigateTo('account'); }}
saving={savedSearches.saving}
error={savedSearches.error}
/>
)}
{showLicenseSuccess && (
<LicenseSuccessModal onClose={() => setShowLicenseSuccess(false)} />
<LicenseSuccessModal onClose={() => { setShowLicenseSuccess(false); navigateTo('dashboard'); }} />
)}
</div>
);

View file

@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { formatRelativeTime } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
@ -42,37 +43,24 @@ function SavedSearchesContent({
setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]);
const copyToClipboard = useCallback((text: string, id: string) => {
const onSuccess = () => {
const doCopy = useCallback((text: string, id: string) => {
copyToClipboard(text, () => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
});
}, []);
const handleShare = useCallback(async (params: string, id: string) => {
setSharingId(id);
try {
const shortUrl = await shortenUrl(params);
copyToClipboard(shortUrl, id);
doCopy(shortUrl, id);
} catch {
copyToClipboard(`${window.location.origin}/?${params}`, id);
doCopy(`${window.location.origin}/?${params}`, id);
} finally {
setSharingId(null);
}
}, [copyToClipboard]);
}, [doCopy]);
return (
<>
@ -270,7 +258,7 @@ function SettingsContent({
const handleCopyInvite = () => {
if (!inviteUrl) return;
navigator.clipboard.writeText(inviteUrl).then(() => {
copyToClipboard(inviteUrl, () => {
setInviteCopied(true);
setTimeout(() => setInviteCopied(false), 2000);
});
@ -284,7 +272,7 @@ function SettingsContent({
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
return (
<div className="max-w-lg mx-auto">
<div className="max-w-lg mx-auto space-y-6">
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 divide-y divide-warm-200 dark:divide-warm-700">
{/* Email */}
<div className="px-5 py-4 flex items-center justify-between">
@ -380,7 +368,7 @@ function SettingsContent({
{isLicensed && (
<div className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{user.isAdmin ? 'Generate invite link (free access)' : 'Invite friends (30% off)'}
{user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
</p>
{inviteUrl ? (
<div className="flex items-center gap-2">
@ -409,7 +397,7 @@ function SettingsContent({
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
>
{creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{user.isAdmin ? 'Generate invite link' : 'Generate referral link'}
{user.isAdmin ? 'Generate free invite link' : 'Generate referral link'}
</button>
)}
{inviteError && (
@ -455,6 +443,20 @@ function SettingsContent({
</div>
)}
</div>
{/* Support */}
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
<a
href="mailto:support@propertymap.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@propertymap.co.uk
</a>
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
We typically respond within 24 hours.
</p>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
import ScrollStory from './ScrollStory';
@ -6,6 +6,7 @@ import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon } from '../ui/icons/ChevronIcon';
import { LogoIcon } from '../ui/icons/LogoIcon';
import { trackEvent } from '../../lib/analytics';
import type { FeatureMeta } from '../../types';
export default function HomePage({
@ -30,6 +31,35 @@ export default function HomePage({
const whyRef = useFadeInRef();
const ctaRef = useFadeInRef();
// Scroll depth tracking
const scrolledSections = useRef(new Set<string>());
useEffect(() => {
const ids = ['how-it-works', 'demo'];
const observers: IntersectionObserver[] = [];
ids.forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !scrolledSections.current.has(id)) {
scrolledSections.current.add(id);
trackEvent('Scroll Depth', { section: id });
}
},
{ threshold: 0.1 }
);
observer.observe(el);
observers.push(observer);
});
return () => observers.forEach((o) => o.disconnect());
}, []);
// 30s time-on-page event
useEffect(() => {
const timer = setTimeout(() => trackEvent('Time on Page', { seconds: '30' }), 30000);
return () => clearTimeout(timer);
}, []);
return (
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
<div className="relative" style={{ zIndex: 1 }}>
@ -54,13 +84,17 @@ export default function HomePage({
</p>
<div className="flex items-center gap-4 mb-10">
<button
onClick={onOpenDashboard}
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'explore_map' });
onOpenDashboard();
}}
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
>
Explore the map
</button>
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_difference' });
const target = document.getElementById('comparison');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
@ -106,6 +140,7 @@ export default function HomePage({
<div className="flex-1" />
<button
onClick={() => {
trackEvent('CTA Click', { location: 'hero', label: 'see_it_in_action' });
const target = document.getElementById('demo');
if (!target) return;
const scroller = target.closest('.overflow-y-auto') as HTMLElement | null;
@ -256,7 +291,10 @@ export default function HomePage({
This deserves proper tools behind it &mdash; don&apos;t leave it to luck.
</p>
<button
onClick={onOpenDashboard}
onClick={() => {
trackEvent('CTA Click', { location: 'bottom', label: 'explore_map' });
onOpenDashboard();
}}
className="px-8 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Explore the map

View file

@ -255,7 +255,7 @@ function FAQItemCard({ item }: { item: FAQItem }) {
}
export default function LearnPage() {
const [tab, setTab] = useState<LearnTab>('data-sources');
const [tab, setTab] = useState<LearnTab>('faq');
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
@ -299,12 +299,12 @@ export default function LearnPage() {
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
<div className="max-w-5xl mx-auto w-full px-6 pt-6">
<div className="flex gap-2 border-b border-warm-200 dark:border-warm-700">
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
Data Sources
</button>
<button className={tabClass('faq')} onClick={() => setTab('faq')}>
FAQ
</button>
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
Data Sources
</button>
<button className={tabClass('support')} onClick={() => setTab('support')}>
Support
</button>

View file

@ -23,24 +23,24 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }:
return (
<div className="px-3 py-2">
<form onSubmit={handleSubmit} className="flex gap-1.5">
<form onSubmit={handleSubmit} className="flex flex-col gap-1.5">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Describe your ideal property..."
className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
placeholder="Describe your ideal property and area..."
className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="shrink-0 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center gap-1.5"
className="w-full px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium flex items-center justify-center gap-1.5"
>
{loading ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : (
'AI'
'Set filters with AI'
)}
</button>
</form>

View file

@ -109,6 +109,11 @@ export default function AreaPane({
{propertyCount.toLocaleString()} properties
</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'}
{Object.keys(filters).length > 0 ? ' matching all active filters' : ''}
</p>
{!isPostcode && stats && (
<button
onClick={onViewProperties}
@ -128,6 +133,7 @@ export default function AreaPane({
<LoadingSkeleton />
) : stats ? (
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
<HistogramLegend />
{featureGroups.map((group) => {
const hasData = group.features.some(
@ -370,7 +376,6 @@ export default function AreaPane({
</div>
);
})}
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
</div>
) : null}
</div>

View file

@ -1,10 +1,35 @@
import { useMemo } from 'react';
import { useMemo, useState, useEffect } from 'react';
import type { FeatureFilters } from '../../types';
import {
buildPropertySearchUrls,
H3_RADIUS_MILES,
type HexagonLocation,
} from '../../lib/external-search';
import { apiUrl, logNonAbortError } from '../../lib/api';
function useRightmoveLocationId(postcode: string | undefined): string | undefined {
const [locationId, setLocationId] = useState<string | undefined>();
useEffect(() => {
if (!postcode) {
setLocationId(undefined);
return;
}
setLocationId(undefined);
const controller = new AbortController();
fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), {
signal: controller.signal,
})
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.location_identifier) setLocationId(data.location_identifier);
})
.catch((err) => logNonAbortError('rightmove-location', err));
return () => controller.abort();
}, [postcode]);
return locationId;
}
export default function ExternalSearchLinks({
location,
@ -13,29 +38,46 @@ export default function ExternalSearchLinks({
location: HexagonLocation;
filters: FeatureFilters;
}) {
const urls = useMemo(() => buildPropertySearchUrls(location, filters), [location, filters]);
const radiusMiles = H3_RADIUS_MILES[location.resolution] ?? 1;
const rightmoveLocationId = useRightmoveLocationId(location.postcode);
const urls = useMemo(
() => buildPropertySearchUrls({ location, filters, rightmoveLocationId }),
[location, filters, rightmoveLocationId]
);
const radiusMiles = location.isPostcode ? 0.25 : (H3_RADIUS_MILES[location.resolution] ?? 1);
const label = `${radiusMiles}mi radius`;
if (!urls) return null;
const linkClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium';
const disabledClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 font-medium cursor-default';
return (
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
<h3 className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wider mb-2">
Search {label} on
</h3>
<div className="flex gap-2">
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
>
Rightmove
</a>
{urls.rightmove ? (
<a
href={urls.rightmove}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
Rightmove
</a>
) : (
<span className={disabledClass} title="Loading...">
Rightmove
</span>
)}
<a
href={urls.onthemarket}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
className={linkClass}
>
OnTheMarket
</a>
@ -43,7 +85,7 @@ export default function ExternalSearchLinks({
href={urls.zoopla}
target="_blank"
rel="noopener noreferrer"
className="flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium"
className={linkClass}
>
Zoopla
</a>

View file

@ -9,9 +9,17 @@ import { groupFeaturesByCategory } from '../../lib/features';
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
import { FeatureActions } from '../ui/FeatureIcons';
import { FeatureLabel } from '../ui/FeatureLabel';
import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
import type { ComponentType } from 'react';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
car: CarIcon,
bicycle: BicycleIcon,
walking: WalkingIcon,
transit: TransitIcon,
};
interface FeatureBrowserProps {
availableFeatures: FeatureMeta[];
@ -24,6 +32,8 @@ interface FeatureBrowserProps {
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
onAddTravelTimeEntry: (mode: TransportMode) => void;
isLicensed: boolean;
onUpgradeClick?: () => void;
}
export default function FeatureBrowser({
@ -37,6 +47,8 @@ export default function FeatureBrowser({
onClearOpenInfoFeature,
travelTimeEntries,
onAddTravelTimeEntry,
isLicensed,
onUpgradeClick,
}: FeatureBrowserProps) {
const [search, setSearch] = useState('');
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -77,23 +89,21 @@ export default function FeatureBrowser({
name="Travel Time"
expanded={isSearching || expandedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{TRANSPORT_MODES.length}
</span>
</CollapsibleGroupHeader>
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
const ModeIcon = MODE_ICONS[mode];
return (
<div
key={mode}
className="flex items-start 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)}>
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
<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]}
@ -104,16 +114,6 @@ export default function FeatureBrowser({
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{fieldKey && (
<IconButton
onClick={() => onTogglePin(fieldKey)}
active={isPinned}
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
size="md"
>
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
)}
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
</IconButton>
@ -131,7 +131,7 @@ export default function FeatureBrowser({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
@ -174,10 +174,31 @@ export default function FeatureBrowser({
}
className="px-3 py-4"
/>
) : (
) : isLicensed ? (
<p className="flex-1 flex items-center justify-center px-4 py-6 text-xs text-center text-warm-400 dark:text-warm-500">
Everyone cares about different things. Pick the filters that matter most to you.
</p>
) : (
<div className="mt-auto flex flex-col items-center px-5 pt-6 pb-0">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-relaxed mb-1">
The biggest financial decision of your life deserves proper tools behind it.
</p>
<p className="text-xs text-warm-400 dark:text-warm-500 text-center mb-4">
Don&apos;t leave it to chance.
</p>
<button
onClick={onUpgradeClick}
className="px-5 py-2.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
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" />
<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" />
</svg>
</div>
)}
</div>
{infoFeature && (

View file

@ -25,27 +25,20 @@ import {
type ListingType = 'historical' | 'buy' | 'rent';
const MODE_RESTRICTED_FEATURES: Record<string, Set<ListingType>> = {
'Bathrooms': new Set(['buy', 'rent']),
};
function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean {
const allowed = MODE_RESTRICTED_FEATURES[featureName];
return !allowed || allowed.has(mode);
}
function SliderLabels({
min,
max,
value,
displayValues,
absoluteMax,
isAtMin,
isAtMax,
}: {
min: number;
max: number;
value: [number, number];
displayValues?: [number, number];
absoluteMax?: boolean;
isAtMin?: boolean;
isAtMax?: boolean;
}) {
const range = max - min || 1;
const leftPct = ((value[0] - min) / range) * 100;
@ -57,13 +50,13 @@ function SliderLabels({
className="absolute -translate-x-1/2"
style={{ left: `${leftPct}%` }}
>
{formatFilterValue(labels[0])}
{isAtMin ? 'min' : formatFilterValue(labels[0])}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${rightPct}%` }}
>
{formatFilterValue(labels[1])}{absoluteMax && value[1] >= max ? '+' : ''}
{isAtMax ? 'max' : formatFilterValue(labels[1])}
</span>
</div>
);
@ -87,15 +80,18 @@ interface FiltersProps {
openInfoFeature?: string | null;
onClearOpenInfoFeature?: () => void;
travelTimeEntries: TravelTimeEntry[];
travelTimeDataRanges: Map<number, [number, number]>;
onTravelTimeAddEntry: (mode: TransportMode) => void;
onTravelTimeRemoveEntry: (index: number) => void;
onTravelTimeSetDestination: (index: number, slug: string, label: string) => void;
onTravelTimeRangeChange: (index: number, range: [number, number]) => void;
onTravelTimeToggleBest: (index: number) => void;
aiFilterLoading: boolean;
aiFilterError: string | null;
aiFilterNotes: string | null;
onAiFilterSubmit: (query: string) => void;
isLicensed: boolean;
onUpgradeClick?: () => void;
onResetTutorial?: () => void;
}
export default memo(function Filters({
@ -116,16 +112,49 @@ export default memo(function Filters({
openInfoFeature,
onClearOpenInfoFeature,
travelTimeEntries,
travelTimeDataRanges,
onTravelTimeAddEntry,
onTravelTimeRemoveEntry,
onTravelTimeSetDestination,
onTravelTimeRangeChange,
onTravelTimeToggleBest,
aiFilterLoading,
aiFilterError,
aiFilterNotes,
onAiFilterSubmit,
isLicensed,
onUpgradeClick,
onResetTutorial,
}: FiltersProps) {
const modeRestrictions = useMemo(() => {
const map: Record<string, Set<ListingType>> = {};
for (const f of features) {
if (f.modes && f.modes.length > 0) {
map[f.name] = new Set(f.modes as ListingType[]);
}
}
return map;
}, [features]);
const linkedFeatures = useMemo(() => {
const pairs: [string, string][] = [];
const seen = new Set<string>();
for (const f of features) {
if (f.linked && !seen.has(f.name)) {
pairs.push([f.name, f.linked]);
seen.add(f.linked);
}
}
return pairs;
}, [features]);
const isAllowed = useCallback(
(name: string, mode: ListingType) => {
const allowed = modeRestrictions[name];
return !allowed || allowed.has(mode);
},
[modeRestrictions]
);
const activeListingType = useMemo((): ListingType => {
const val = filters['Listing status'] as string[] | undefined;
if (!val || val.length === 0) return 'historical';
@ -135,8 +164,8 @@ export default memo(function Filters({
}, [filters]);
const availableFeatures = useMemo(
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)),
[features, enabledFeatures, activeListingType]
() => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)),
[features, enabledFeatures, activeListingType, isAllowed]
);
const enabledFeatureList = useMemo(
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
@ -145,16 +174,26 @@ export default memo(function Filters({
const handleListingSelect = useCallback(
(type: ListingType) => {
if (type === activeListingType && !filters['Listing status']) return;
for (const name of Object.keys(filters)) {
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
if (name === 'Listing status') continue;
if (isAllowed(name, type)) continue;
// Check if this feature has a linked counterpart in the new mode
let swapped = false;
for (const [a, b] of linkedFeatures) {
const counterpart = name === a ? b : name === b ? a : null;
if (counterpart && isAllowed(counterpart, type)) {
onFilterChange(counterpart, filters[name] as [number, number]);
onRemoveFilter(name);
swapped = true;
break;
}
}
if (!swapped) {
onRemoveFilter(name);
}
}
if (type === 'historical' && !filters['Listing status']) {
onFilterChange('Listing status', ['Historical sale']);
return;
}
const valueMap: Record<string, string> = {
historical: 'Historical sale',
buy: 'For sale',
@ -162,7 +201,7 @@ export default memo(function Filters({
};
onFilterChange('Listing status', [valueMap[type]]);
},
[activeListingType, filters, onFilterChange, onRemoveFilter]
[filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures]
);
const containerRef = useRef<HTMLDivElement>(null);
@ -205,7 +244,7 @@ export default memo(function Filters({
<div className="flex items-center gap-2 px-3 pb-2">
<button
onClick={() => setShowPhilosophy(true)}
className="flex-1 px-3 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md flex items-center justify-center gap-2"
className="flex-1 px-3 py-1.5 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm flex items-center justify-center gap-2"
>
<LightbulbIcon />
Finding the Perfect Postcode
@ -254,7 +293,7 @@ export default memo(function Filters({
name="Travel Time"
expanded={!collapsedGroups.has('Travel Time')}
onToggle={() => toggleGroup('Travel Time')}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{travelTimeEntries.length}
@ -269,11 +308,12 @@ export default memo(function Filters({
slug={entry.slug}
label={entry.label}
timeRange={entry.timeRange}
dataRange={travelTimeDataRanges.get(index) ?? null}
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)}
/>
))}
@ -296,7 +336,7 @@ export default memo(function Filters({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
@ -344,14 +384,25 @@ export default memo(function Filters({
const isActive = activeFeature === feature.name;
const isPinned = pinnedFeature === feature.name;
const hist = feature.histogram;
const displayValue =
isActive && dragValue
? dragValue
: (filters[feature.name] as [number, number]) || [feature.min!, 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!;
const isAtMin = displayValue[0] <= dataMin;
const isAtMax = displayValue[1] >= dataMax;
const sliderValue: [number, number] = scale
? [Math.round(scale.toPercentile(displayValue[0])), Math.round(scale.toPercentile(displayValue[1]))]
: displayValue;
? [
isAtMin ? 0 : Math.round(scale.toPercentile(displayValue[0])),
isAtMax ? 100 : Math.round(scale.toPercentile(displayValue[1])),
]
: [
isAtMin ? feature.min! : displayValue[0],
isAtMax ? feature.max! : displayValue[1],
];
return (
<div
@ -375,8 +426,18 @@ export default memo(function Filters({
value={sliderValue}
onValueChange={
scale
? ([pMin, pMax]) => onDragChange([scale.toValue(pMin), scale.toValue(pMax)])
: ([min, max]) => onDragChange([min, max])
? ([pMin, pMax]) => {
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)),
]);
}
: ([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()}
@ -386,7 +447,8 @@ export default memo(function Filters({
max={scale ? 100 : feature.max!}
value={sliderValue}
displayValues={scale ? displayValue : undefined}
absoluteMax={feature.absolute}
isAtMin={isAtMin}
isAtMax={isAtMax}
/>
</div>
</div>
@ -416,6 +478,8 @@ export default memo(function Filters({
onClearOpenInfoFeature={onClearOpenInfoFeature}
travelTimeEntries={travelTimeEntries}
onAddTravelTimeEntry={onTravelTimeAddEntry}
isLicensed={isLicensed}
onUpgradeClick={onUpgradeClick}
/>
</div>
</div>
@ -423,59 +487,95 @@ export default memo(function Filters({
{showPhilosophy && (
<InfoPopup title="Finding the Perfect Postcode" onClose={() => setShowPhilosophy(false)}>
<div className="space-y-4 text-sm">
<p className="text-warm-600 dark:text-warm-300">
Start with your must-haves, then layer on nice-to-haves.
The map narrows down as you add filters &mdash; the areas that survive are your best matches.
</p>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Be intentional, not reactive
1. Budget &amp; property basics
</h4>
<p className="text-warm-600 dark:text-warm-300">
Your future home isn&apos;t a box of cereal you grab because it&apos;s on sale.
Don&apos;t let a seemingly good deal turn into lifelong regret. Instead of waiting
for listings to appear, define what you actually want and go find it.
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>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
See the full picture
2. Commute &amp; transport
</h4>
<p className="text-warm-600 dark:text-warm-300">
Current listings show only a fraction of the market. There are too few to give you a
complete picture, yet too many to evaluate one by one. We aggregate millions of
historical sales so you can understand what&apos;s truly available in any area.
Add a travel time filter to your workplace &mdash; choose public transport or cycling
and set your maximum tolerable commute. You can also filter by
how many stations are within walking distance.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Your priorities, your filters
3. Safety &amp; environment
</h4>
<p className="text-warm-600 dark:text-warm-300">
We all care about different things. Some want peace and quiet; others want to be
near the action. Use our filters to define exactly what matters to you and discover
postcodes that match.
Use the crime filters to cap serious or minor crime rates.
Check road noise levels if you&apos;re a light sleeper, and
environmental risk filters for ground stability concerns.
</p>
</div>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Find the right place, not just the right listing
4. Schools &amp; education
</h4>
<p className="text-warm-600 dark:text-warm-300">
The best areas to live don&apos;t always have properties listed right now. We help
you identify where you should be looking, so when something does come up,
you&apos;re ready.
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>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
Know what&apos;s possible
5. Lifestyle &amp; amenities
</h4>
<p className="text-warm-600 dark:text-warm-300">
We&apos;d rather tell you upfront if your expectations are unrealistic than have you
spend months searching for something that doesn&apos;t exist.
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>
<div>
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
6. Energy &amp; running costs
</h4>
<p className="text-warm-600 dark:text-warm-300">
EPC ratings from A to G indicate energy efficiency.
Filter for better ratings to find homes with lower bills and
fewer upgrade headaches.
</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.
</p>
</div>
{onResetTutorial && (
<button
onClick={() => {
setShowPhilosophy(false);
onResetTutorial();
}}
className="w-full px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 hover:bg-warm-50 dark:hover:bg-warm-700 text-teal-600 dark:text-teal-400 font-medium text-sm"
>
Replay interactive tutorial
</button>
)}
</div>
</InfoPopup>
)}

View file

@ -1,5 +1,5 @@
import { memo } from 'react';
import type { FeatureFilters } from '../../types';
import { memo, useMemo } from 'react';
import type { FeatureFilters, FeatureMeta } from '../../types';
import { formatValue } from '../../lib/format';
interface HoverCardData {
@ -14,11 +14,17 @@ interface HoverCardProps {
isPostcode: boolean;
data: HoverCardData | null;
filters: FeatureFilters;
features: FeatureMeta[];
}
export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: 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]
);
// Get key stats to show from local data (min_<feature> values)
const getDisplayStats = () => {
if (!data) return [];
@ -28,8 +34,13 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }:
// Show stats for active filters (up to 4)
for (const name of activeFilterNames.slice(0, 4)) {
const val = data[`avg_${name}`] ?? data[`min_${name}`];
if (val != null && typeof val === 'number') {
results.push({ name, value: formatValue(val) });
if (val == null || typeof val !== 'number') continue;
const meta = featureMap.get(name);
if (meta?.type === 'enum' && meta.values) {
const label = meta.values[Math.round(val)];
if (label) results.push({ name, value: label });
} else {
results.push({ name, value: formatValue(val, meta) });
}
}

View file

@ -170,6 +170,7 @@ export default memo(function Map({
data,
postcodeData,
usePostcodeView,
zoom: viewState.zoom,
pois,
viewFeature,
colorRange,
@ -296,6 +297,7 @@ export default memo(function Map({
: data.find((d) => d.h3 === hoveredHexagonId) || null
}
filters={filters}
features={features}
/>
)}
</>

View file

@ -27,6 +27,7 @@ import {
type TravelTimeInitial,
} from '../../hooks/useTravelTime';
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE } from '../../lib/consts';
import { useLicense } from '../../hooks/useLicense';
import UpgradeModal from '../ui/UpgradeModal';
@ -188,24 +189,6 @@ export default function MapPage({
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
const ranges = new globalThis.Map<number, [number, number]>();
for (let i = 0; i < travelTime.entries.length; i++) {
const entry = travelTime.entries[i];
if (!entry.slug) continue;
const fieldName = `avg_${travelFieldKey(entry)}`;
const vals: number[] = [];
for (const item of mapData.data) {
const val = item[fieldName];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
}
if (vals.length === 0) continue;
vals.sort((a, b) => a - b);
ranges.set(i, [vals[0], vals[vals.length - 1]]);
}
return ranges;
}, [travelTime.entries, mapData.data]);
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
useEffect(() => {
@ -229,16 +212,21 @@ export default function MapPage({
const isPostcode = selection.selectedHexagon?.type === 'postcode';
if (isPostcode) {
// For postcodes, get centroid from postcodeData
// For postcodes, get centroid from postcodeData; postcode string is the selection id
const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId);
if (!postcodeFeature?.properties.centroid) return null;
const [lon, lat] = postcodeFeature.properties.centroid;
return { lat, lon, resolution: mapData.resolution };
return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true };
} else {
// For hexagons, get lat/lon from hexagon data
// For hexagons, get lat/lon from hexagon data; central postcode comes from stats
const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null;
if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null;
return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution };
return {
lat: hex.lat as number,
lon: hex.lon as number,
resolution: mapData.resolution,
postcode: selection.areaStats?.central_postcode,
};
}
}, [
selection.selectedHexagon?.id,
@ -246,6 +234,7 @@ export default function MapPage({
mapData.data,
mapData.postcodeData,
mapData.resolution,
selection.areaStats?.central_postcode,
]);
const tutorial = useTutorial(initialLoading, isMobile);
@ -273,6 +262,7 @@ export default function MapPage({
link.download = 'perfect-postcode-export.xlsx';
link.click();
URL.revokeObjectURL(link.href);
trackEvent('Export');
})
.catch((err) => logNonAbortError('Export failed', err))
.finally(() => setExporting(false));
@ -282,6 +272,10 @@ export default function MapPage({
onExportStateChange?.({ onExport: handleExport, exporting });
}, [handleExport, exporting, onExportStateChange]);
useEffect(() => {
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
}, [mapData.licenseRequired]);
const mobileLegendMeta = useMemo(
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
[viewFeature, features]
@ -395,15 +389,18 @@ export default function MapPage({
openInfoFeature={pendingInfoFeature}
onClearOpenInfoFeature={onClearPendingInfoFeature}
travelTimeEntries={travelTime.entries}
travelTimeDataRanges={travelTimeDataRanges}
onTravelTimeAddEntry={travelTime.handleAddEntry}
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
onTravelTimeSetDestination={handleTravelTimeSetDestination}
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
onTravelTimeToggleBest={travelTime.handleToggleBest}
aiFilterLoading={aiFilters.loading}
aiFilterError={aiFilters.error}
aiFilterNotes={aiFilters.notes}
onAiFilterSubmit={handleAiFilterSubmit}
isLicensed={user?.subscription === 'licensed'}
onUpgradeClick={() => onNavigateTo('pricing')}
onResetTutorial={tutorial.resetTutorial}
/>
);
@ -560,6 +557,7 @@ export default function MapPage({
callback={tutorial.handleCallback}
styles={getTutorialStyles(theme)}
disableScrolling
locale={{ last: 'Finish' }}
/>
<div
@ -614,9 +612,10 @@ export default function MapPage({
<button
data-tutorial="poi-button"
onClick={() => setPoiPaneOpen((p) => !p)}
className={`absolute bottom-4 right-4 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
className={`absolute bottom-4 right-4 z-10 px-3 py-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 flex items-center gap-2 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
>
<MapPinIcon className="w-5 h-5" />
<span className="text-sm font-medium">Points of interest</span>
</button>
{/* Floating POI panel */}
{poiPaneOpen && (
@ -626,38 +625,40 @@ export default function MapPage({
)}
</div>
<div
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
{selection.selectedHexagon && (
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
data-tutorial="right-pane"
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
style={{ width: rightPaneWidth }}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
<div
className="w-1.5 cursor-col-resize flex items-center justify-center bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...rightPaneHandlers}
>
<div className="w-0.5 h-8 rounded bg-warm-300 dark:bg-navy-600" />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex border-b border-warm-200 dark:border-navy-700 text-sm">
<TabButton
label="Area"
isActive={selection.rightPaneTab === 'area'}
onClick={() => selection.setRightPaneTab('area')}
/>
<TabButton
label="Properties"
isActive={selection.rightPaneTab === 'properties'}
onClick={selection.handlePropertiesTabClick}
/>
</div>
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderAreaPane()}
<div className="flex-1 overflow-hidden">
{selection.rightPaneTab === 'properties'
? renderPropertiesPane()
: renderAreaPane()}
</div>
</div>
</div>
</div>
)}
{mapData.licenseRequired && (
<UpgradeModal

View file

@ -1,5 +1,6 @@
import { useState, useCallback } from 'react';
import { useCollapsibleGroups } from '../../hooks/useCollapsibleGroups';
import { trackEvent } from '../../lib/analytics';
import type { POICategoryGroup } from '../../types';
import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
@ -31,19 +32,23 @@ export default function POIPane({
const toggleCategory = (category: string) => {
const newSet = new Set(selectedCategories);
if (newSet.has(category)) {
const wasSelected = newSet.has(category);
if (wasSelected) {
newSet.delete(category);
} else {
newSet.add(category);
}
trackEvent('POI Toggle', { category, selected: String(!wasSelected) });
onCategoriesChange(newSet);
};
const selectAll = () => {
trackEvent('POI Select All');
onCategoriesChange(new Set(allCategories));
};
const selectNone = () => {
trackEvent('POI Select None');
onCategoriesChange(new Set());
};

View file

@ -5,21 +5,33 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { RouteIcon } from '../ui/icons/RouteIcon';
import { CarIcon } from '../ui/icons/CarIcon';
import { BicycleIcon } from '../ui/icons/BicycleIcon';
import { WalkingIcon } from '../ui/icons/WalkingIcon';
import { TransitIcon } from '../ui/icons/TransitIcon';
import { formatFilterValue } from '../../lib/format';
import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch';
import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime';
import type { ComponentType } from 'react';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
car: CarIcon,
bicycle: BicycleIcon,
walking: WalkingIcon,
transit: TransitIcon,
};
interface TravelTimeCardProps {
mode: TransportMode;
slug: string;
label: string;
timeRange: [number, number] | null;
dataRange: [number, number] | null;
useBest: boolean;
isPinned: boolean;
onTogglePin: () => void;
onSetDestination: (slug: string, label: string) => void;
onTimeRangeChange: (range: [number, number]) => void;
onToggleBest: () => void;
onRemove: () => void;
}
@ -28,11 +40,12 @@ export function TravelTimeCard({
slug,
label,
timeRange,
dataRange,
useBest,
isPinned,
onTogglePin,
onSetDestination,
onTimeRangeChange,
onToggleBest,
onRemove,
}: TravelTimeCardProps) {
const search = useLocationSearch(mode);
@ -59,16 +72,18 @@ export function TravelTimeCard({
[onSetDestination, search.clear],
);
const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0;
const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120;
const sliderMin = 0;
const sliderMax = 120;
const displayRange = timeRange ?? [sliderMin, sliderMax];
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' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
Travel Time ({MODE_LABELS[mode]})
</span>
@ -106,8 +121,26 @@ export function TravelTimeCard({
)}
</div>
{/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={useBest}
onChange={onToggleBest}
className="accent-teal-600 rounded"
/>
<span className="text-xs text-warm-600 dark:text-warm-300">
Best case
</span>
<span className="text-[10px] text-warm-400 dark:text-warm-500">
(optimal departure)
</span>
</label>
)}
{/* Time range slider — only show when we have data */}
{slug && dataRange && (
{slug && (
<div>
<span className="text-[10px] font-medium text-warm-500 dark:text-warm-400 uppercase tracking-wide">
Max time

View file

@ -3,6 +3,7 @@ import { CheckIcon } from '../ui/icons/CheckIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { AuthUser } from '../../hooks/useAuth';
import { useLicense } from '../../hooks/useLicense';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
const FEATURES = [
@ -59,14 +60,8 @@ export default function PricingPage({
}, []);
useEffect(() => {
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
if (currentTierIndex === 0) return;
const container = scrollRef.current;
const card = activeCardRef.current;
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]);
trackEvent('Pricing View');
}, []);
useEffect(() => {
fetch(apiUrl('pricing'))
@ -98,6 +93,16 @@ export default function PricingPage({
}
}
useEffect(() => {
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
if (currentTierIndex === 0) return;
const container = scrollRef.current;
const card = activeCardRef.current;
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
container.scrollLeft = Math.max(0, scrollLeft);
setScrolledLeft(container.scrollLeft > 0);
}, [pricing, currentTierIndex]);
const ctaButton = isLicensed ? (
<button
onClick={onOpenDashboard}
@ -183,13 +188,23 @@ export default function PricingPage({
/>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-12">
<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>
<p className="text-lg text-warm-300 max-w-lg mx-auto">
No subscriptions, no recurring fees. Pay once and get lifetime
access to every feature. The earlier you join, the less you pay.
Pay once, access forever. The earlier you join, the less you pay.
</p>
</div>
<div className="relative z-10 max-w-2xl mx-auto px-6 mb-12 text-center">
<p className="text-warm-400 text-sm leading-relaxed mb-2">
Buying a home costs &pound;10k+ in stamp duty, &pound;1,500 in solicitor fees,
&pound;500 for a survey. Get the wrong area and you&apos;re stuck with a long
commute, bad schools, or a road you didn&apos;t know about.
</p>
<p className="text-warm-200 font-semibold">
Less than your survey costs. Vastly more useful.
</p>
</div>
@ -203,7 +218,7 @@ export default function PricingPage({
<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 gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
<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 =
@ -348,17 +363,6 @@ export default function PricingPage({
)}
</div>
<div className="relative z-10 max-w-2xl mx-auto px-6 pb-16 text-center">
<p className="text-warm-400 leading-relaxed mb-3">
Stamp duty on a &pound;400k house: &pound;10,000. Solicitor fees: &pound;1,500.
Survey: &pound;500. Moving costs: &pound;1,000. And that&apos;s just the money. Get the
wrong area and you&apos;re stuck &mdash; with a long commute, bad schools, or a street
that looked fine on the listing photos but turns out to be on a motorway.
</p>
<p className="text-warm-200 font-semibold">
One payment. Lifetime access. Less than your survey costs and vastly more useful.
</p>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
type View = 'login' | 'register' | 'forgot';
@ -30,6 +31,10 @@ export default function AuthModal({
const [password, setPassword] = useState('');
const [resetSent, setResetSent] = useState(false);
useEffect(() => {
trackEvent('Auth Modal Open', { tab: initialTab });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const switchView = useCallback(
(newView: View) => {
setView(newView);

View file

@ -1,5 +1,6 @@
import type { FeatureMeta } from '../../types';
import { InfoIcon } from './icons';
import { getGroupIcon } from '../../lib/group-icons';
interface FeatureLabelProps {
feature: FeatureMeta;
@ -15,11 +16,15 @@ export function FeatureLabel({
size = 'xs',
}: FeatureLabelProps) {
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
const GroupIcon = feature.group ? getGroupIcon(feature.group) : null;
return (
<div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
>
{GroupIcon && (
<GroupIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0" />
)}
<span
className={`${textClass} text-warm-700 dark:text-warm-300 ${size === 'xs' ? 'truncate' : ''}`}
>

View file

@ -1,6 +1,7 @@
import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import { shortenUrl } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
import { LogoIcon } from './icons/LogoIcon';
@ -63,42 +64,29 @@ export default function Header({
if (!isMobile) setMenuOpen(false);
}, [isMobile]);
const copyToClipboard = useCallback((text: string) => {
const onSuccess = () => {
const doCopy = useCallback((text: string) => {
copyToClipboard(text, () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
});
}, []);
const handleShare = useCallback(async () => {
const params = window.location.search.replace(/^\?/, '');
if (!params) {
copyToClipboard(window.location.href);
doCopy(window.location.href);
return;
}
setSharing(true);
try {
const shortUrl = await shortenUrl(params);
copyToClipboard(shortUrl);
doCopy(shortUrl);
} catch {
copyToClipboard(window.location.href);
doCopy(window.location.href);
} finally {
setSharing(false);
}
}, [copyToClipboard]);
}, [doCopy]);
const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${

View file

@ -1,19 +1,23 @@
import { useState, useCallback, useEffect } from 'react';
import { CheckIcon } from './icons/CheckIcon';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
export default function SaveSearchModal({
onClose,
onSave,
onViewSearches,
saving,
error,
}: {
onClose: () => void;
onSave: (name: string) => Promise<void>;
onViewSearches: () => void;
saving: boolean;
error: string | null;
}) {
const [name, setName] = useState('');
const [saved, setSaved] = useState(false);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@ -21,12 +25,12 @@ export default function SaveSearchModal({
if (!name.trim() || saving) return;
try {
await onSave(name.trim());
onClose();
setSaved(true);
} catch {
// Error displayed in modal
}
},
[name, saving, onSave, onClose]
[name, saving, onSave]
);
useEffect(() => {
@ -45,7 +49,9 @@ export default function SaveSearchModal({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">Save Search</h2>
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
{saved ? 'Search saved' : 'Save Search'}
</h2>
<button
onClick={onClose}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
@ -54,41 +60,68 @@ export default function SaveSearchModal({
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
autoFocus
/>
{saved ? (
<div className="p-5 pt-2 space-y-4">
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
<CheckIcon className="w-5 h-5" />
<p className="text-sm text-warm-700 dark:text-warm-300">
Your search has been saved successfully.
</p>
</div>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Close
</button>
<button
type="button"
onClick={onViewSearches}
className="px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700"
>
View saved searches
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="p-5 pt-2 space-y-4">
<div>
<label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder="My search"
autoFocus
/>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saving}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || saving}
className="flex items-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
)}
</div>
</div>
);

View file

@ -38,11 +38,29 @@ export default function UserMenu({
{open && (
<div className="absolute right-0 top-10 w-56 bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-lg z-50">
<div className="px-4 py-3 border-b border-warm-200 dark:border-warm-700">
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
{user.email}
</p>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium text-navy-950 dark:text-warm-100 truncate">
{user.email}
</p>
<span
className={`shrink-0 text-xs font-medium px-1.5 py-0.5 rounded ${
user.subscription === 'licensed' || user.isAdmin
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: 'bg-warm-100 text-warm-500 dark:bg-warm-700 dark:text-warm-400'
}`}
>
{user.subscription === 'licensed' || user.isAdmin ? 'Pro' : 'Free'}
</span>
</div>
</div>
<div className="p-1">
<a
href="/account"
onClick={() => setOpen(false)}
className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
>
Account
</a>
<button
onClick={() => {
setOpen(false);

View file

@ -0,0 +1,23 @@
interface IconProps {
className?: string;
}
export function BicycleIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="6" cy="17" r="3" />
<circle cx="18" cy="17" r="3" />
<path d="M6 17l3-7h4l3 7" />
<path d="M9 10l3 4h3" />
<circle cx="12" cy="7" r="1.5" />
</svg>
);
}

View file

@ -0,0 +1,22 @@
interface IconProps {
className?: string;
}
export function CarIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 17h14v-5l-2-6H7L5 12v5z" />
<circle cx="7.5" cy="17" r="2" />
<circle cx="16.5" cy="17" r="2" />
<path d="M5 12h14" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function ChartBarIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function GraduationCapIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
<path d="M6 12v5c0 2 3 3 6 3s6-1 6-3v-5" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function HouseIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}

View file

@ -0,0 +1,19 @@
interface IconProps {
className?: string;
}
export function ShieldIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
);
}

View file

@ -0,0 +1,21 @@
interface IconProps {
className?: string;
}
export function ShoppingBagIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 01-8 0" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function TagIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
);
}

View file

@ -0,0 +1,25 @@
interface IconProps {
className?: string;
}
export function TransitIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="6" y="3" width="12" height="14" rx="3" />
<path d="M6 12h12" />
<circle cx="9" cy="15" r="1" />
<circle cx="15" cy="15" r="1" />
<path d="M9 20l-2 2" />
<path d="M15 20l2 2" />
<path d="M9 3V1h6v2" />
</svg>
);
}

View file

@ -0,0 +1,20 @@
interface IconProps {
className?: string;
}
export function TreeIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22v-7" />
<path d="M17 15H7l2-4H5l7-9 7 9h-4l2 4z" />
</svg>
);
}

View file

@ -0,0 +1,22 @@
interface IconProps {
className?: string;
}
export function UsersIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
);
}

View file

@ -0,0 +1,23 @@
interface IconProps {
className?: string;
}
export function WalkingIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="4.5" r="2" />
<path d="M13.5 9L15 15l-3 4" />
<path d="M10.5 9L9 15l3 4" />
<path d="M10 9h4l2 4" />
<path d="M8 13l2-4" />
</svg>
);
}

View file

@ -7,3 +7,15 @@ export { FilterIcon } from './FilterIcon';
export { LightbulbIcon } from './LightbulbIcon';
export { MenuIcon } from './MenuIcon';
export { RouteIcon } from './RouteIcon';
export { CarIcon } from './CarIcon';
export { BicycleIcon } from './BicycleIcon';
export { WalkingIcon } from './WalkingIcon';
export { TransitIcon } from './TransitIcon';
export { HouseIcon } from './HouseIcon';
export { GraduationCapIcon } from './GraduationCapIcon';
export { ChartBarIcon } from './ChartBarIcon';
export { ShieldIcon } from './ShieldIcon';
export { UsersIcon } from './UsersIcon';
export { ShoppingBagIcon } from './ShoppingBagIcon';
export { TreeIcon } from './TreeIcon';
export { TagIcon } from './TagIcon';

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import pb from '../lib/pocketbase';
import { trackEvent } from '../lib/analytics';
export interface AuthUser {
id: string;
@ -52,6 +53,7 @@ export function useAuth() {
try {
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' });
} catch (err) {
const msg = err instanceof Error ? err.message : 'Login failed';
setError(msg);
@ -73,6 +75,7 @@ export function useAuth() {
// Auto-login after registration
const result = await pb.collection('users').authWithPassword(email, password);
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Registration failed';
setError(msg);
@ -88,6 +91,7 @@ export function useAuth() {
try {
const result = await pb.collection('users').authWithOAuth2({ provider });
setUser(recordToUser(result.record));
trackEvent('Login', { method: provider });
} catch (err) {
const msg = err instanceof Error ? err.message : 'OAuth login failed';
setError(msg);
@ -98,6 +102,7 @@ export function useAuth() {
}, []);
const logout = useCallback(() => {
trackEvent('Logout');
pb.authStore.clear();
setUser(null);
}, []);

View file

@ -1,6 +1,7 @@
import { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import { H3HexagonLayer } from '@deck.gl/geo-layers';
import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers';
import { cellToBoundary } from 'h3-js';
import type { PickingInfo } from '@deck.gl/core';
import type {
HexagonData,
@ -33,6 +34,7 @@ interface UseDeckLayersProps {
data: HexagonData[];
postcodeData: PostcodeFeature[];
usePostcodeView: boolean;
zoom: number;
pois: POI[];
viewFeature: string | null;
colorRange: [number, number] | null;
@ -60,6 +62,7 @@ export function useDeckLayers({
data,
postcodeData,
usePostcodeView,
zoom,
pois,
viewFeature,
colorRange,
@ -80,13 +83,13 @@ export function useDeckLayers({
// Marching ants animation
const [marchTime, setMarchTime] = useState(0);
const hasPostcodeGeometry = selectedPostcodeGeometry != null;
const hasSelection = selectedPostcodeGeometry != null || selectedHexagonId != null;
useEffect(() => {
if (!hasPostcodeGeometry) return;
if (!hasSelection) return;
setMarchTime(0);
const id = setInterval(() => setMarchTime((t) => t + 0.3), 50);
return () => clearInterval(id);
}, [hasPostcodeGeometry]);
}, [hasSelection]);
const isDark = theme === 'dark';
const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT;
@ -332,14 +335,11 @@ export function useDeckLayers({
);
},
getLineColor: (d) => {
if (d.h3 === selectedHexagonIdRef.current)
return [255, 255, 255, 255] as [number, number, number, number];
if (d.h3 === hoveredHexagonIdRef.current)
return [29, 228, 195, 200] as [number, number, number, number];
return [0, 0, 0, 0] as [number, number, number, number];
},
getLineWidth: (d) => {
if (d.h3 === selectedHexagonIdRef.current) return 3;
if (d.h3 === hoveredHexagonIdRef.current) return 2;
return 0;
},
@ -481,15 +481,22 @@ export function useDeckLayers({
[pois, stablePoiHover]
);
// Marching ants highlight layer for selected postcode
// Marching ants highlight layer for selected hexagon or postcode
const marchingAntsLayer = useMemo(() => {
if (!selectedPostcodeGeometry) return null;
let geometry: PostcodeGeometry | null = null;
if (selectedPostcodeGeometry) {
geometry = selectedPostcodeGeometry;
} else if (selectedHexagonId) {
const boundary = cellToBoundary(selectedHexagonId, true);
geometry = { type: 'Polygon', coordinates: [boundary] };
}
if (!geometry) return null;
return new GeoJsonLayer({
id: 'marching-ants',
data: [
{
type: 'Feature' as const,
geometry: selectedPostcodeGeometry,
geometry,
properties: {},
},
],
@ -502,17 +509,20 @@ export function useDeckLayers({
marchTime,
extensions: [new MarchingAntsExtension()],
});
}, [selectedPostcodeGeometry, marchTime]);
}, [selectedPostcodeGeometry, selectedHexagonId, marchTime]);
const layers = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseLayers: any[] = usePostcodeView
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
? zoom >= 16
? [postcodeLayer, postcodeLabelsLayer, poiLayer]
: [postcodeLayer, poiLayer]
: [hexLayer, poiLayer];
if (marchingAntsLayer) baseLayers.push(marchingAntsLayer);
return baseLayers;
}, [
usePostcodeView,
zoom,
hexLayer,
postcodeLayer,
postcodeLabelsLayer,

View file

@ -1,5 +1,6 @@
import { useState, useCallback, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters } from '../types';
import { trackEvent } from '../lib/analytics';
interface UseFiltersOptions {
initialFilters: FeatureFilters;
@ -29,8 +30,11 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
(name: string) => {
const meta = features.find((f) => f.name === name);
if (!meta) return;
trackEvent('Filter Add', { feature: name });
if (meta.type === 'enum' && meta.values) {
setFilters((prev) => ({ ...prev, [name]: [...meta.values!] }));
} else if (meta.type === 'numeric' && meta.histogram) {
setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] }));
} else if (meta.min != null && meta.max != null) {
setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] }));
}
@ -43,6 +47,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleRemoveFilter = useCallback((name: string) => {
trackEvent('Filter Remove', { feature: name });
setFilters((prev) => {
const next = { ...prev };
delete next[name];
@ -82,6 +87,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
}, []);
const handleTogglePin = useCallback((name: string) => {
trackEvent('Filter Pin', { feature: name });
setPinnedFeature((prev) => (prev === name ? null : name));
}, []);

View file

@ -1,4 +1,5 @@
import { useState, useCallback } from 'react';
import { trackEvent } from '../lib/analytics';
import type {
FeatureMeta,
FeatureFilters,
@ -107,6 +108,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
setSelectedPostcodeGeometry(null);
} else {
const type = isPostcode ? 'postcode' : 'hexagon';
trackEvent('Hexagon Click', { type });
setSelectedHexagon({ id, type, resolution });
setSelectedPostcodeGeometry(isPostcode && geometry ? geometry : null);
setProperties([]);
@ -138,6 +140,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const handleViewPropertiesFromArea = useCallback(() => {
if (selectedHexagon && selectedHexagon.type === 'hexagon') {
trackEvent('View Properties');
setRightPaneTab('properties');
setPropertiesOffset(0);
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
@ -167,6 +170,7 @@ export function useHexagonSelection({ filters, features, resolution }: UseHexago
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => {
trackEvent('Postcode Search');
setSelectedHexagon({ id: postcode, type: 'postcode', resolution });
setSelectedPostcodeGeometry(geometry);
setProperties([]);

View file

@ -1,11 +1,13 @@
import { useState, useCallback } from 'react';
import { apiUrl, authHeaders, assertOk } from '../lib/api';
import { trackEvent } from '../lib/analytics';
export function useLicense() {
const [checkingOut, setCheckingOut] = useState(false);
const [error, setError] = useState<string | null>(null);
const startCheckout = useCallback(async (referralCode?: string) => {
trackEvent('Checkout Start', { has_referral: String(!!referralCode) });
setCheckingOut(true);
setError(null);
try {
@ -22,6 +24,7 @@ export function useLicense() {
assertOk(res, 'Checkout');
const data = await res.json();
if (data.url) {
trackEvent('Checkout Redirect');
window.location.href = data.url;
}
} catch (err) {

View file

@ -76,12 +76,13 @@ export function useMapData({
);
// Build the travel param string from entries with destinations
// Format: mode:slug|mode:slug or mode:slug:min:max|mode:slug
// Format: mode:slug|mode:slug:best or mode:slug:min:max|mode:slug:best:min:max
const travelParam = useMemo((): string => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
if (entry.timeRange) {
seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}

View file

@ -7,5 +7,6 @@ plausibleInit({
captureOnLocalhost: true,
logging: true,
fileDownloads: true,
outboundLinks: true,
hashBasedRouting: true,
});

View file

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import pb from '../lib/pocketbase';
import { apiUrl, authHeaders } from '../lib/api';
import { trackEvent } from '../lib/analytics';
export interface SavedSearch {
id: string;
@ -66,6 +67,7 @@ export function useSavedSearches(userId: string | null) {
formData.append('screenshot', screenshotBlob, 'screenshot.png');
await pb.collection('saved_searches').create(formData);
trackEvent('Search Save');
await fetchSearches();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to save search';
@ -82,6 +84,7 @@ export function useSavedSearches(userId: string | null) {
setError(null);
try {
await pb.collection('saved_searches').delete(id);
trackEvent('Search Delete');
setSearches((prev) => prev.filter((s) => s.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete search');

View file

@ -16,6 +16,8 @@ export interface TravelTimeEntry {
slug: string;
label: string;
timeRange: [number, number] | null;
/** Use best-case (5th percentile) travel time instead of median. Transit only. */
useBest: boolean;
}
/** Field key matching the backend response: tt_{mode}_{slug} */
@ -33,7 +35,7 @@ export function useTravelTime(initial?: TravelTimeInitial) {
const handleAddEntry = useCallback((mode: TransportMode) => {
setEntries((prev) => [
...prev,
{ mode, slug: '', label: '', timeRange: null },
{ mode, slug: '', label: '', timeRange: null, useBest: false },
]);
}, []);
@ -63,6 +65,17 @@ export function useTravelTime(initial?: TravelTimeInitial) {
[]
);
const handleToggleBest = useCallback(
(index: number) => {
setEntries((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, useBest: !entry.useBest, timeRange: null } : entry
)
);
},
[]
);
/** Entries that have a destination selected (slug is set) */
const activeEntries = useMemo(
() => entries.filter((e) => e.slug !== ''),
@ -76,5 +89,6 @@ export function useTravelTime(initial?: TravelTimeInitial) {
handleRemoveEntry,
handleSetDestination,
handleTimeRangeChange,
handleToggleBest,
};
}

View file

@ -9,7 +9,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="filters"]',
title: 'Filter Properties',
content:
'Use filters to narrow down properties by price, energy rating, floor area, and more. Pin a filter to colour the map by that feature.',
'Use filters to narrow down to areas which contain matching properties. Filter by crime rate, number of schools around, or filter to an area with detached houses. Pin a filter with the eye icon to colour the map by that feature.',
placement: 'right',
disableBeacon: true,
},
@ -17,7 +17,7 @@ const STEPS: Step[] = [
target: '[data-tutorial="map"]',
title: 'Explore the Map',
content:
'Pan and zoom to explore property data across the UK. Click any hexagon to see detailed stats and individual properties.',
'Pan and zoom to explore property data across England. Click any area (hexagon or postcode boundary) to see detailed stats of historical or currently sold properties matching your filters.',
placement: 'bottom',
disableBeacon: true,
},
@ -44,6 +44,11 @@ const STEPS: Step[] = [
'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.',
placement: 'left',
disableBeacon: true,
styles: {
tooltip: {
transform: 'translateY(-50px)',
},
},
},
];

View file

@ -26,7 +26,7 @@ uniform marchingAntsUniforms {
} marchingAnts;`,
'fs:DECKGL_FILTER_COLOR': `\
float marchSegLen = 4.0;
float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0);
float marchPos = mod(geometry.uv.y - marchingAnts.marchTime, marchSegLen * 2.0);
if (marchPos < marchSegLen) {
color = vec4(1.0, 1.0, 1.0, color.a);
} else {

View file

@ -0,0 +1,19 @@
import { track } from '@plausible-analytics/tracker';
export function trackEvent(name: string, props?: Record<string, string | number | boolean>) {
const stringProps: Record<string, string> | undefined = props
? Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
: undefined;
track(name, { props: stringProps });
}
export function trackRevenue(
name: string,
amountPence: number,
props?: Record<string, string | number | boolean>
) {
const stringProps = props
? Object.fromEntries(Object.entries(props).map(([k, v]) => [k, String(v)]))
: undefined;
track(name, { props: stringProps, revenue: { amount: amountPence / 100, currency: 'GBP' } });
}

View file

@ -81,8 +81,9 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max);
const isAtMax = meta?.histogram ? max >= meta.histogram.max : max === meta?.max;
const maxStr = meta?.absolute && isAtMax ? 'inf' : String(max);
return `${name}:${min}:${maxStr}`;
})
.join(',');
.join(';;');
}

View file

@ -0,0 +1,16 @@
/** Copy text to clipboard with execCommand fallback for older browsers. */
export function copyToClipboard(text: string, onSuccess: () => void): void {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(onSuccess);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
onSuccess();
}
}

View file

@ -19,7 +19,7 @@ export const FREE_ZONE_BOUNDS = { south: 51.42, west: -0.34, north: 51.60, east:
export const INITIAL_VIEW_STATE: ViewState = {
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
zoom: 14,
zoom: 15,
pitch: 0,
};
@ -33,10 +33,9 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [
{ maxZoom: 10.5, resolution: 7 },
{ maxZoom: 11.5, resolution: 8 },
{ maxZoom: 13, resolution: 9 },
{ maxZoom: Infinity, resolution: 10 },
] as const;
export const POSTCODE_ZOOM_THRESHOLD = 16;
export const POSTCODE_ZOOM_THRESHOLD = 14.5;
export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [
{ t: 0, color: [46, 204, 113] },

View file

@ -4,6 +4,8 @@ export interface HexagonLocation {
lat: number;
lon: number;
resolution: number;
postcode?: string;
isPostcode?: boolean;
}
const PROPERTY_TYPE_MAP: Record<
@ -32,10 +34,10 @@ export const H3_RADIUS_MILES: Record<number, number> = {
6: 3,
7: 1,
8: 0.5,
9: 0.25,
10: 0.25,
11: 0.25,
12: 0.25,
9: 1,
10: 1,
11: 1,
12: 1,
};
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
@ -46,13 +48,21 @@ function nearestRadius(target: number, allowed: number[]): number {
return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best));
}
export function buildPropertySearchUrls(
location: HexagonLocation,
filters: FeatureFilters
): { rightmove: string; onthemarket: string; zoopla: string } {
const { lat, lon, resolution } = location;
const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1;
const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`;
interface SearchUrlOptions {
location: HexagonLocation;
filters: FeatureFilters;
rightmoveLocationId?: string;
}
export function buildPropertySearchUrls({
location,
filters,
rightmoveLocationId,
}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null {
const { postcode, resolution, isPostcode } = location;
if (!postcode) return null;
const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1);
const priceFilter = filters['Last known price'];
const minPrice =
@ -66,43 +76,51 @@ export function buildPropertySearchUrls(
? (propertyTypes as string[])
: [];
const rmParams = new URLSearchParams();
rmParams.set('searchLocation', coordStr);
rmParams.set('channel', 'BUY');
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
if (selectedTypes.length > 0) {
const rmTypes = [
...new Set(
selectedTypes.flatMap((t) => {
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
return mapped ? mapped.split(',') : [];
})
),
];
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
// Rightmove — requires locationIdentifier from typeahead API
let rightmove: string | null = null;
if (rightmoveLocationId) {
const rmParams = new URLSearchParams();
rmParams.set('searchLocation', postcode);
rmParams.set('useLocationIdentifier', 'true');
rmParams.set('locationIdentifier', rightmoveLocationId);
rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII)));
if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice)));
if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice)));
if (selectedTypes.length > 0) {
const rmTypes = [
...new Set(
selectedTypes.flatMap((t) => {
const mapped = PROPERTY_TYPE_MAP[t]?.rightmove;
return mapped ? mapped.split(',') : [];
})
),
];
if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(','));
}
rmParams.set('_includeSSTC', 'on');
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
}
const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
let otmType = 'property';
if (selectedTypes.length > 0) {
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
];
if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!;
}
// OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
const otmParams = new URLSearchParams();
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));
if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice)));
if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice)));
otmParams.set('search-site', 'geo');
otmParams.set('geo-lat', String(lat));
otmParams.set('geo-lng', String(lon));
const onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`;
if (selectedTypes.length > 0) {
const otmTypes = [
...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)),
];
for (const ot of otmTypes) {
otmParams.append('prop-types', ot!);
}
}
otmParams.set('view', 'map-list');
const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`;
// Zoopla
const zParams = new URLSearchParams();
zParams.set('q', coordStr);
zParams.set('q', postcode);
zParams.set('search_source', 'for-sale');
zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII)));
if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice)));
@ -115,7 +133,6 @@ export function buildPropertySearchUrls(
zParams.append('property_sub_type', zt!);
}
}
zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`);
const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`;
return { rightmove, onthemarket, zoopla };

View file

@ -0,0 +1,31 @@
import type { ComponentType } from 'react';
import {
HouseIcon,
RouteIcon,
GraduationCapIcon,
ChartBarIcon,
ShieldIcon,
UsersIcon,
ShoppingBagIcon,
TreeIcon,
TagIcon,
} from '../components/ui/icons';
const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
'Properties in the area': HouseIcon,
Transport: RouteIcon,
Education: GraduationCapIcon,
Deprivation: ChartBarIcon,
'Crime summary': ShieldIcon,
Crime: ShieldIcon,
Demographics: UsersIcon,
Amenities: ShoppingBagIcon,
Environment: TreeIcon,
Property: TagIcon,
};
export function getGroupIcon(
group: string,
): ComponentType<{ className?: string }> | null {
return GROUP_ICONS[group] ?? null;
}

View file

@ -71,7 +71,7 @@ export function parseUrlState(): {
}
// Travel time: repeated `tt` params
// Format: mode:slug:label or mode:slug:label:min:max
// Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max
const ttParams = params.getAll('tt');
if (ttParams.length > 0) {
const entries: TravelTimeEntry[] = [];
@ -82,15 +82,17 @@ export function parseUrlState(): {
if (!TRANSPORT_MODES.includes(mode)) continue;
const slug = parts[1];
const label = decodeURIComponent(parts[2]);
const useBest = parts.length >= 4 && parts[3] === 'b';
const rangeOffset = useBest ? 1 : 0;
let timeRange: [number, number] | null = null;
if (parts.length >= 5) {
const min = Number(parts[3]);
const max = Number(parts[4]);
if (parts.length >= 5 + rangeOffset) {
const min = Number(parts[3 + rangeOffset]);
const max = Number(parts[4 + rangeOffset]);
if (!isNaN(min) && !isNaN(max)) {
timeRange = [min, max];
}
}
entries.push({ mode, slug, label, timeRange });
entries.push({ mode, slug, label, timeRange, useBest });
}
if (entries.length > 0) {
result.travelTime = { entries };
@ -139,6 +141,7 @@ export function stateToParams(
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) val += ':b';
if (entry.timeRange) {
val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}

View file

@ -18,6 +18,9 @@ export interface FeatureMeta {
suffix?: string;
raw?: boolean;
absolute?: boolean;
// Mode restriction fields
modes?: string[];
linked?: string;
}
export interface FeatureGroup {
@ -172,4 +175,5 @@ export interface HexagonStatsResponse {
numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[];
price_history?: PricePoint[];
central_postcode?: string;
}