Merge branch 'main' of https://github.com/rubyhrzhang/property-map
This commit is contained in:
commit
afa6934a2d
91 changed files with 2122 additions and 1360 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 — don'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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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 && (
|
||||
|
|
|
|||
|
|
@ -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 — 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 & property basics
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
Your future home isn't a box of cereal you grab because it's on sale.
|
||||
Don'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 & 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's truly available in any area.
|
||||
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>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-navy-950 dark:text-warm-100 mb-1">
|
||||
Your priorities, your filters
|
||||
3. Safety & 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'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 & education
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
The best areas to live don't always have properties listed right now. We help
|
||||
you identify where you should be looking, so when something does come up,
|
||||
you'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's possible
|
||||
5. Lifestyle & amenities
|
||||
</h4>
|
||||
<p className="text-warm-600 dark:text-warm-300">
|
||||
We'd rather tell you upfront if your expectations are unrealistic than have you
|
||||
spend months searching for something that doesn'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 & 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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 £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.
|
||||
</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 £400k house: £10,000. Solicitor fees: £1,500.
|
||||
Survey: £500. Moving costs: £1,000. And that's just the money. Get the
|
||||
wrong area and you're stuck — 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' : ''}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 ${
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
23
frontend/src/components/ui/icons/BicycleIcon.tsx
Normal file
23
frontend/src/components/ui/icons/BicycleIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/CarIcon.tsx
Normal file
22
frontend/src/components/ui/icons/CarIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/ChartBarIcon.tsx
Normal file
21
frontend/src/components/ui/icons/ChartBarIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/GraduationCapIcon.tsx
Normal file
20
frontend/src/components/ui/icons/GraduationCapIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/HouseIcon.tsx
Normal file
20
frontend/src/components/ui/icons/HouseIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/ui/icons/ShieldIcon.tsx
Normal file
19
frontend/src/components/ui/icons/ShieldIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/ui/icons/ShoppingBagIcon.tsx
Normal file
21
frontend/src/components/ui/icons/ShoppingBagIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/TagIcon.tsx
Normal file
20
frontend/src/components/ui/icons/TagIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/ui/icons/TransitIcon.tsx
Normal file
25
frontend/src/components/ui/icons/TransitIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/icons/TreeIcon.tsx
Normal file
20
frontend/src/components/ui/icons/TreeIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/ui/icons/UsersIcon.tsx
Normal file
22
frontend/src/components/ui/icons/UsersIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/ui/icons/WalkingIcon.tsx
Normal file
23
frontend/src/components/ui/icons/WalkingIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ plausibleInit({
|
|||
captureOnLocalhost: true,
|
||||
logging: true,
|
||||
fileDownloads: true,
|
||||
outboundLinks: true,
|
||||
hashBasedRouting: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
19
frontend/src/lib/analytics.ts
Normal file
19
frontend/src/lib/analytics.ts
Normal 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' } });
|
||||
}
|
||||
|
|
@ -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(';;');
|
||||
}
|
||||
|
|
|
|||
16
frontend/src/lib/clipboard.ts
Normal file
16
frontend/src/lib/clipboard.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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] },
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
31
frontend/src/lib/group-icons.ts
Normal file
31
frontend/src/lib/group-icons.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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]}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue