All changes

This commit is contained in:
Andras Schmelczer 2026-03-14 21:36:00 +00:00
parent 593f380581
commit 49f7ec2f5a
60 changed files with 1783 additions and 679 deletions

View file

@ -3,7 +3,7 @@ import MapPage, { type ExportState } from './components/map/MapPage';
import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage';
import LearnPage from './components/learn/LearnPage';
import AccountPage from './components/account/AccountPage';
import AccountPage, { SavedPage, InvitesPage } from './components/account/AccountPage';
import InvitePage from './components/invite/InvitePage';
import Header, { type Page } from './components/ui/Header';
import AuthModal from './components/ui/AuthModal';
@ -19,6 +19,7 @@ import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile';
import { useAuth } from './hooks/useAuth';
import { useSavedSearches } from './hooks/useSavedSearches';
import { useSavedProperties } from './hooks/useSavedProperties';
declare global {
interface Window {
@ -34,6 +35,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
return '/learn';
case 'pricing':
return '/pricing';
case 'saved':
return '/saved';
case 'invites':
return '/invites';
case 'account':
return '/account';
case 'invite':
@ -45,7 +50,8 @@ function pageToPath(page: Page, inviteCode?: string): string {
function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null {
if (pathname === '/dashboard') return { page: 'dashboard' };
if (pathname === '/saved') return { page: 'account' };
if (pathname === '/saved') return { page: 'saved' };
if (pathname === '/invites') return { page: 'invites' };
if (pathname === '/learn') return { page: 'learn' };
if (pathname === '/pricing') return { page: 'pricing' };
if (pathname === '/account') return { page: 'account' };
@ -134,6 +140,7 @@ export default function App() {
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const savedSearches = useSavedSearches(user?.id ?? null);
const savedProperties = useSavedProperties(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
useEffect(() => {
@ -207,15 +214,21 @@ export default function App() {
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const { fetchSearches } = savedSearches;
const { fetchProperties: fetchSavedProperties } = savedProperties;
useEffect(() => {
if (activePage === 'account') {
if (activePage === 'saved') {
fetchSearches();
fetchSavedProperties();
}
}, [activePage, fetchSearches]);
if (activePage === 'dashboard' && user) {
fetchSavedProperties();
}
}, [activePage, fetchSearches, fetchSavedProperties, user]);
const isAuthRequiredPage = activePage === 'account' || activePage === 'saved' || activePage === 'invites';
useEffect(() => {
if (authLoading) return;
if (activePage === 'account' && !user) {
if (isAuthRequiredPage && !user) {
setAuthModalTab('login');
setShowAuthModal(true);
navigateTo('home');
@ -223,10 +236,24 @@ export default function App() {
if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) {
navigateTo('dashboard');
}
}, [activePage, user, authLoading, navigateTo]);
}, [activePage, isAuthRequiredPage, user, authLoading, navigateTo]);
const [exportState, setExportState] = useState<ExportState | null>(null);
if ((isScreenshotMode || isOgMode) && inviteCode) {
return (
<InvitePage
code={inviteCode}
user={null}
theme={theme}
screenshotMode
onLoginClick={() => {}}
onRegisterClick={() => {}}
onLicenseGranted={() => {}}
/>
);
}
if (isScreenshotMode) {
return (
<MapPage
@ -271,7 +298,7 @@ export default function App() {
onLogout={logout}
isMobile={isMobile}
/>
{user && !user.verified && !verificationDismissed && activePage === 'account' && (
{user && !user.verified && !verificationDismissed && isAuthRequiredPage && (
<VerificationBanner
email={user.email}
onRequestVerification={requestVerification}
@ -295,22 +322,34 @@ export default function App() {
/>
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'account' && user ? (
<AccountPage
user={user}
onRefreshAuth={refreshAuth}
onRequestVerification={requestVerification}
) : activePage === 'saved' && user ? (
<SavedPage
searches={savedSearches.searches}
searchesLoading={savedSearches.loading}
onDeleteSearch={savedSearches.deleteSearch}
onOpenSearch={(params) => {
window.location.href = `/dashboard?${params}`;
}}
savedProperties={savedProperties.properties}
propertiesLoading={savedProperties.loading}
onDeleteProperty={savedProperties.deleteProperty}
onOpenProperty={(postcode) => {
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
}}
/>
) : activePage === 'invites' && user ? (
<InvitesPage user={user} />
) : activePage === 'account' && user ? (
<AccountPage
user={user}
onRefreshAuth={refreshAuth}
onRequestVerification={requestVerification}
/>
) : activePage === 'invite' && inviteCode ? (
<InvitePage
code={inviteCode}
user={user}
theme={theme}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
@ -340,6 +379,7 @@ export default function App() {
onExportStateChange={setExportState}
isMobile={isMobile}
initialTravelTime={urlState.travelTime}
initialPostcode={urlState.postcode}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
@ -349,6 +389,10 @@ export default function App() {
setAuthModalTab('register');
setShowAuthModal(true);
}}
onSaveProperty={user ? savedProperties.saveProperty : undefined}
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
/>
)}
{showAuthModal && (
@ -368,7 +412,7 @@ export default function App() {
<SaveSearchModal
onClose={() => setShowSaveModal(false)}
onSave={savedSearches.saveSearch}
onViewSearches={() => { setShowSaveModal(false); navigateTo('account'); }}
onViewSearches={() => { setShowSaveModal(false); navigateTo('saved'); }}
saving={savedSearches.saving}
error={savedSearches.error}
/>

View file

@ -1,26 +1,100 @@
import { useState, useCallback, useEffect } from 'react';
import type { AuthUser } from '../../hooks/useAuth';
import type { SavedSearch } from '../../hooks/useSavedSearches';
import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties';
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
import { formatRelativeTime } from '../../lib/format';
import { formatRelativeTime, formatNumber } from '../../lib/format';
import { summarizeParams } from '../../lib/url-state';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import { ClipboardIcon } from '../ui/icons/ClipboardIcon';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
import { HouseIcon } from '../ui/icons/HouseIcon';
import { TrashIcon } from '../ui/icons/TrashIcon';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { SubNav } from '../ui/SubNav';
type AccountTab = 'saved' | 'settings';
function PageLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-warm-900 flex flex-col">
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-6 py-6">
{children}
</div>
</div>
</div>
);
}
const ACCOUNT_TABS = [
{ key: 'saved', label: 'Saved Searches' },
{ key: 'settings', label: 'Settings' },
];
function DeleteDialog({
title,
message,
onCancel,
onConfirm,
}: {
title: string;
message: string;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onCancel}
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
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">{title}</h2>
<button
onClick={onCancel}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">{message}</p>
<div className="flex gap-3 justify-end px-5 pb-5">
<button
onClick={onCancel}
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
onClick={onConfirm}
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
);
}
function SavedSearchesContent({
function formatPropertyPrice(data: SavedPropertyData): string | null {
if (data.askingPrice) return `£${formatNumber(data.askingPrice)}`;
if (data.askingRent) return `£${formatNumber(data.askingRent)}/mo`;
if (data.estimatedPrice) return `${formatNumber(data.estimatedPrice)}`;
if (data.price) return `£${formatNumber(data.price)}`;
return null;
}
function formatPropertyDetails(data: SavedPropertyData): string {
const parts: string[] = [];
if (data.propertySubType) parts.push(data.propertySubType);
else if (data.propertyType) parts.push(data.propertyType);
if (data.bedrooms) parts.push(`${data.bedrooms} bed`);
if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}`);
if (data.energyRating) parts.push(`EPC ${data.energyRating}`);
return parts.join(' · ');
}
function SavedSearchesTab({
searches,
loading,
onDelete,
@ -60,148 +134,400 @@ function SavedSearchesContent({
}
}, [doCopy]);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
);
}
if (searches.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<BookmarkIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
No saved searches yet
</p>
<p className="text-sm text-warm-500 dark:text-warm-500">
Save your dashboard filters and view to quickly return to them later.
</p>
</div>
);
}
return (
<>
{loading ? (
<div className="flex items-center justify-center py-20">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
) : searches.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<BookmarkIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
No saved searches yet
</p>
<p className="text-sm text-warm-500 dark:text-warm-500">
Save your dashboard filters and view to quickly return to them later.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{searches.map((search) => (
<div
key={search.id}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden"
>
{search.screenshotUrl ? (
<img
src={search.screenshotUrl}
alt={search.name}
className="w-full h-36 object-cover"
/>
) : (
<div className="w-full h-36 bg-gradient-to-br from-teal-600/20 to-navy-900/30 dark:from-teal-400/10 dark:to-navy-900/40 flex items-center justify-center">
<BookmarkIcon className="w-10 h-10 text-warm-300 dark:text-warm-600" />
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{searches.map((search) => (
<div
key={search.id}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden"
>
{search.screenshotUrl ? (
<img
src={search.screenshotUrl}
alt={search.name}
className="w-full h-36 object-cover"
/>
) : (
<div className="w-full h-36 bg-gradient-to-br from-teal-600/20 to-navy-900/30 dark:from-teal-400/10 dark:to-navy-900/40 flex items-center justify-center">
<BookmarkIcon className="w-10 h-10 text-warm-300 dark:text-warm-600" />
</div>
)}
<div className="p-4">
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
{search.name}
</h3>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{formatRelativeTime(search.created)}
</p>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-3">
{summarizeParams(search.params)}
</p>
<div className="p-4">
<h3 className="font-medium text-navy-950 dark:text-warm-100 truncate mb-1">
{search.name}
</h3>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">
{formatRelativeTime(search.created)}
</p>
<p className="text-xs text-warm-500 dark:text-warm-400 mb-3">
{summarizeParams(search.params)}
</p>
<div className="flex gap-2">
<button
onClick={() => onOpen(search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
Open
</button>
<button
onClick={() => handleShare(search.params, search.id)}
disabled={sharingId === search.id}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 disabled:opacity-50 disabled:cursor-wait"
>
{sharingId === search.id ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : copiedId === search.id ? 'Copied!' : 'Share'}
</button>
<button
onClick={() => setDeleteConfirmId(search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => onOpen(search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
Open
</button>
<button
onClick={() => handleShare(search.params, search.id)}
disabled={sharingId === search.id}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 disabled:opacity-50 disabled:cursor-wait"
>
{sharingId === search.id ? (
<SpinnerIcon className="w-4 h-4 animate-spin" />
) : copiedId === search.id ? 'Copied!' : 'Share'}
</button>
<button
onClick={() => setDeleteConfirmId(search.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* Delete confirmation dialog */}
{deleteConfirmId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={() => setDeleteConfirmId(null)}
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
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">Delete search</h2>
<button
onClick={() => setDeleteConfirmId(null)}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />
</button>
</div>
<p className="px-5 pb-4 text-sm text-warm-700 dark:text-warm-300">
Are you sure you want to delete this saved search? This cannot be undone.
</p>
<div className="flex gap-3 justify-end px-5 pb-5">
<button
onClick={() => setDeleteConfirmId(null)}
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
onClick={handleDeleteConfirm}
className="px-4 py-2 text-sm rounded bg-red-600 text-white font-medium hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
{deleteConfirmId && (
<DeleteDialog
title="Delete search"
message="Are you sure you want to delete this saved search? This cannot be undone."
onCancel={() => setDeleteConfirmId(null)}
onConfirm={handleDeleteConfirm}
/>
)}
</>
);
}
function SettingsContent({
user,
onRefreshAuth,
onRequestVerification,
function SavedPropertiesTab({
properties,
loading,
onDelete,
onOpen,
}: {
user: AuthUser;
onRefreshAuth: () => Promise<void>;
onRequestVerification: (email: string) => Promise<void>;
properties: SavedProperty[];
loading: boolean;
onDelete: (id: string) => Promise<void>;
onOpen: (postcode: string) => void;
}) {
const [newsletterSaving, setNewsletterSaving] = useState(false);
const [newsletterError, setNewsletterError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// Verification state
const [verificationSending, setVerificationSending] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirmId) return;
await onDelete(deleteConfirmId);
setDeleteConfirmId(null);
}, [deleteConfirmId, onDelete]);
// Invite state — keyed by invite type for admins (who can create both kinds)
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
);
}
if (properties.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<HouseIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
No saved properties yet
</p>
<p className="text-sm text-warm-500 dark:text-warm-500">
Click the bookmark icon on any property in the dashboard to save it here.
</p>
</div>
);
}
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{properties.map((prop) => {
const price = formatPropertyPrice(prop.data);
const details = formatPropertyDetails(prop.data);
return (
<div
key={prop.id}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
>
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="font-medium text-navy-950 dark:text-warm-100 leading-tight">
{prop.address}
</h3>
<BookmarkIcon className="w-4 h-4 shrink-0 text-teal-600 dark:text-teal-400 mt-0.5" filled />
</div>
<p className="text-sm text-warm-500 dark:text-warm-400 mb-1">{prop.postcode}</p>
{price && (
<p className="text-lg font-bold text-teal-700 dark:text-teal-400 mb-1">{price}</p>
)}
{details && (
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">{details}</p>
)}
<p className="text-xs text-warm-400 dark:text-warm-500 mb-3">
{formatRelativeTime(prop.created)}
</p>
<div className="flex gap-2">
<button
onClick={() => onOpen(prop.postcode)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
Open postcode
</button>
{prop.data.listingUrl && (
<a
href={prop.data.listingUrl}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
title="View listing"
>
View listing &rarr;
</a>
)}
<button
onClick={() => setDeleteConfirmId(prop.id)}
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Delete"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
{deleteConfirmId && (
<DeleteDialog
title="Delete property"
message="Are you sure you want to delete this saved property? This cannot be undone."
onCancel={() => setDeleteConfirmId(null)}
onConfirm={handleDeleteConfirm}
/>
)}
</>
);
}
export function SavedPage({
searches,
searchesLoading,
onDeleteSearch,
onOpenSearch,
savedProperties,
propertiesLoading,
onDeleteProperty,
onOpenProperty,
}: {
searches: SavedSearch[];
searchesLoading: boolean;
onDeleteSearch: (id: string) => Promise<void>;
onOpenSearch: (params: string) => void;
savedProperties: SavedProperty[];
propertiesLoading: boolean;
onDeleteProperty: (id: string) => Promise<void>;
onOpenProperty: (postcode: string) => void;
}) {
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>('searches');
const tabClass = (tab: string) =>
`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab
? 'border-teal-600 dark:border-teal-400 text-teal-600 dark:text-teal-400'
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
}`;
return (
<PageLayout>
<div className="flex border-b border-warm-200 dark:border-warm-700 mb-6">
<button className={tabClass('searches')} onClick={() => setActiveTab('searches')}>
Searches
{searches.length > 0 && (
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
{searches.length}
</span>
)}
</button>
<button className={tabClass('properties')} onClick={() => setActiveTab('properties')}>
Properties
{savedProperties.length > 0 && (
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
{savedProperties.length}
</span>
)}
</button>
</div>
{activeTab === 'searches' ? (
<SavedSearchesTab
searches={searches}
loading={searchesLoading}
onDelete={onDeleteSearch}
onOpen={onOpenSearch}
/>
) : (
<SavedPropertiesTab
properties={savedProperties}
loading={propertiesLoading}
onDelete={onDeleteProperty}
onOpen={onOpenProperty}
/>
)}
</PageLayout>
);
}
interface InviteListItem {
code: string;
url: string;
invite_type: string;
used: boolean;
created: string;
}
function InviteTable({
invites,
loading,
title,
}: {
invites: InviteListItem[];
loading: boolean;
title: string;
}) {
const [copiedCode, setCopiedCode] = useState<string | null>(null);
const handleCopy = (url: string, code: string) => {
copyToClipboard(url, () => {
setCopiedCode(code);
setTimeout(() => setCopiedCode(null), 2000);
});
};
return (
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 overflow-hidden">
<div className="px-5 py-3 border-b border-warm-200 dark:border-warm-700">
<h3 className="text-sm font-medium text-navy-950 dark:text-warm-100">{title}</h3>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
) : invites.length === 0 ? (
<p className="px-5 py-6 text-sm text-warm-500 dark:text-warm-400 text-center">
No invites generated yet
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-warm-200 dark:border-warm-700 text-left">
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Link</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Status</th>
<th className="px-5 py-2 text-warm-500 dark:text-warm-400 font-medium">Created</th>
<th className="px-5 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-warm-100 dark:divide-warm-800">
{invites.map((inv) => (
<tr key={inv.code}>
<td className="px-5 py-2.5 text-navy-950 dark:text-warm-200 font-mono text-xs truncate max-w-[200px]">
{inv.code}
</td>
<td className="px-5 py-2.5">
<span
className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full ${
inv.used
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300'
}`}
>
{inv.used ? 'Redeemed' : 'Pending'}
</span>
</td>
<td className="px-5 py-2.5 text-warm-500 dark:text-warm-400 text-xs">
{formatRelativeTime(inv.created)}
</td>
<td className="px-5 py-2.5 text-right">
<button
onClick={() => handleCopy(inv.url, inv.code)}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Copy invite link"
>
{copiedCode === inv.code ? (
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
) : (
<ClipboardIcon className="w-4 h-4" />
)}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
export function InvitesPage({ user }: { user: AuthUser }) {
const [creatingInvite, setCreatingInvite] = useState<Record<string, boolean>>({});
const [inviteUrl, setInviteUrl] = useState<Record<string, string>>({});
const [inviteError, setInviteError] = useState<Record<string, string>>({});
const [inviteCopied, setInviteCopied] = useState<Record<string, boolean>>({});
const [inviteHistory, setInviteHistory] = useState<InviteListItem[]>([]);
const [inviteHistoryLoading, setInviteHistoryLoading] = useState(false);
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
const fetchInviteHistory = useCallback(async () => {
setInviteHistoryLoading(true);
try {
const res = await fetch(apiUrl('invites'), authHeaders());
assertOk(res, 'Fetch invites');
const data = await res.json();
setInviteHistory(data.invites);
} catch {
// Silent — non-critical
} finally {
setInviteHistoryLoading(false);
}
}, []);
useEffect(() => {
if (isLicensed) fetchInviteHistory();
}, [isLicensed, fetchInviteHistory]);
const handleCreateInvite = async (type: string) => {
setCreatingInvite((prev) => ({ ...prev, [type]: true }));
setInviteError((prev) => {
@ -226,6 +552,7 @@ function SettingsContent({
assertOk(res, 'Create invite');
const data = await res.json();
setInviteUrl((prev) => ({ ...prev, [type]: data.url }));
fetchInviteHistory();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to create invite';
setInviteError((prev) => ({ ...prev, [type]: msg }));
@ -243,108 +570,29 @@ function SettingsContent({
});
};
const badgeColor =
user.subscription === 'licensed'
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
if (!isLicensed) {
return (
<PageLayout>
<div className="max-w-lg mx-auto">
<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">
Invite links are available for licensed users.
</p>
</div>
</div>
</PageLayout>
);
}
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
const adminInvites = inviteHistory.filter((i) => i.invite_type === 'admin');
const referralInvites = inviteHistory.filter((i) => i.invite_type === 'referral');
return (
<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">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
</div>
<div className="flex items-center gap-2">
{!user.verified && (
<button
onClick={async () => {
setVerificationSending(true);
try {
await onRequestVerification(user.email);
setVerificationSent(true);
setTimeout(() => setVerificationSent(false), 3000);
} catch {
// Error handled by hook
} finally {
setVerificationSending(false);
}
}}
disabled={verificationSending || verificationSent}
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 disabled:opacity-50 flex items-center gap-1"
>
{verificationSending && <SpinnerIcon className="w-3 h-3 animate-spin" />}
{verificationSent ? 'Sent!' : 'Resend verification'}
</button>
)}
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${user.verified
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{user.verified ? 'Verified' : 'Unverified'}
</span>
</div>
</div>
{/* Subscription */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
<span className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}>
{user.subscription === 'licensed' ? 'Licensed' : 'Free'}
</span>
</div>
</div>
{/* Newsletter */}
<div className="px-5 py-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={user.newsletter}
disabled={newsletterSaving}
onChange={async (e) => {
const checked = e.target.checked;
setNewsletterSaving(true);
setNewsletterError(null);
try {
const res = await fetch(apiUrl('newsletter'), {
method: 'PATCH',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newsletter: checked }),
}),
});
assertOk(res, 'Update newsletter');
await onRefreshAuth();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update newsletter';
setNewsletterError(msg);
} finally {
setNewsletterSaving(false);
}
}}
className="w-4 h-4 accent-teal-600 rounded"
/>
<span className="text-navy-950 dark:text-warm-100 text-sm">
Receive newsletter emails
</span>
{newsletterSaving && <SpinnerIcon className="w-4 h-4 animate-spin text-warm-400" />}
</label>
{newsletterError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{newsletterError}</p>
)}
</div>
{/* Invite friends */}
{isLicensed &&
(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (
<PageLayout>
<div className="max-w-lg mx-auto space-y-6">
{/* Generate invite links */}
<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">
{(user.isAdmin ? ['admin', 'referral'] : ['referral']).map((type) => (
<div key={type} className="px-5 py-4">
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
{type === 'admin' ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}
@ -384,23 +632,32 @@ function SettingsContent({
)}
</div>
))}
</div>
{/* Invite history tables */}
{user.isAdmin && (
<>
<InviteTable
invites={adminInvites}
loading={inviteHistoryLoading}
title="Admin invites (100% off)"
/>
<InviteTable
invites={referralInvites}
loading={inviteHistoryLoading}
title="Referral invites (30% off)"
/>
</>
)}
{!user.isAdmin && referralInvites.length > 0 && (
<InviteTable
invites={referralInvites}
loading={inviteHistoryLoading}
title="Your invite links"
/>
)}
</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@perfect-postcode.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@perfect-postcode.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>
</PageLayout>
);
}
@ -408,66 +665,130 @@ export default function AccountPage({
user,
onRefreshAuth,
onRequestVerification,
searches,
searchesLoading,
onDeleteSearch,
onOpenSearch,
}: {
user: AuthUser;
onRefreshAuth: () => Promise<void>;
onRequestVerification: (email: string) => Promise<void>;
searches: SavedSearch[];
searchesLoading: boolean;
onDeleteSearch: (id: string) => Promise<void>;
onOpenSearch: (params: string) => void;
}) {
const [activeTab, setActiveTab] = useState<AccountTab>(() => {
const hash = window.location.hash.slice(1);
return hash === 'settings' ? 'settings' : 'saved';
});
const [newsletterSaving, setNewsletterSaving] = useState(false);
const [newsletterError, setNewsletterError] = useState<string | null>(null);
// Sync hash with tab
useEffect(() => {
const handleHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash === 'settings') setActiveTab('settings');
else setActiveTab('saved');
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
const [verificationSending, setVerificationSending] = useState(false);
const [verificationSent, setVerificationSent] = useState(false);
const switchTab = (key: string) => {
const tab = key as AccountTab;
setActiveTab(tab);
window.history.replaceState(
window.history.state,
'',
`/account#${tab}`
);
};
const badgeColor =
user.subscription === 'licensed'
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400'
: 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300';
return (
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-warm-900 flex flex-col">
<SubNav tabs={ACCOUNT_TABS} activeTab={activeTab} onTabChange={switchTab} />
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-6 py-6">
{activeTab === 'saved' ? (
<SavedSearchesContent
searches={searches}
loading={searchesLoading}
onDelete={onDeleteSearch}
onOpen={onOpenSearch}
/>
) : (
<SettingsContent
user={user}
onRefreshAuth={onRefreshAuth}
onRequestVerification={onRequestVerification}
/>
)}
<PageLayout>
<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">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">Email</p>
<p className="text-navy-950 dark:text-warm-100 font-medium">{user.email}</p>
</div>
<div className="flex items-center gap-2">
{!user.verified && (
<button
onClick={async () => {
setVerificationSending(true);
try {
await onRequestVerification(user.email);
setVerificationSent(true);
setTimeout(() => setVerificationSent(false), 3000);
} catch {
// Error handled by hook
} finally {
setVerificationSending(false);
}
}}
disabled={verificationSending || verificationSent}
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 disabled:opacity-50 flex items-center gap-1"
>
{verificationSending && <SpinnerIcon className="w-3 h-3 animate-spin" />}
{verificationSent ? 'Sent!' : 'Resend verification'}
</button>
)}
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${user.verified
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
}`}
>
{user.verified ? 'Verified' : 'Unverified'}
</span>
</div>
</div>
{/* Subscription */}
<div className="px-5 py-4 flex items-center justify-between">
<div>
<p className="text-sm text-warm-500 dark:text-warm-400">Subscription</p>
<span className={`inline-block text-sm font-medium px-2.5 py-0.5 rounded-full mt-1 ${badgeColor}`}>
{user.subscription === 'licensed' ? 'Licensed' : 'Free'}
</span>
</div>
</div>
{/* Newsletter */}
<div className="px-5 py-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={user.newsletter}
disabled={newsletterSaving}
onChange={async (e) => {
const checked = e.target.checked;
setNewsletterSaving(true);
setNewsletterError(null);
try {
const res = await fetch(apiUrl('newsletter'), {
method: 'PATCH',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newsletter: checked }),
}),
});
assertOk(res, 'Update newsletter');
await onRefreshAuth();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update newsletter';
setNewsletterError(msg);
} finally {
setNewsletterSaving(false);
}
}}
className="w-4 h-4 accent-teal-600 rounded"
/>
<span className="text-navy-950 dark:text-warm-100 text-sm">
Receive newsletter emails
</span>
{newsletterSaving && <SpinnerIcon className="w-4 h-4 animate-spin text-warm-400" />}
</label>
{newsletterError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{newsletterError}</p>
)}
</div>
</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@perfect-postcode.co.uk"
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
>
support@perfect-postcode.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>
</div>
</PageLayout>
);
}

View file

@ -79,7 +79,7 @@ export default function HomePage({
House hunting? Make your biggest investment your smartest move.
</p>
<p className="text-lg text-warm-400 mb-8 max-w-xl">
So many options choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that
So many options - choosing the right one can feel overwhelming. Our interactive map makes it simple: select your must-haves and instantly see the areas that
fit.
</p>
<div className="flex items-center gap-4 mb-10">

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import MapComponent from '../map/Map';
import { apiUrl, assertOk, authHeaders, isAbortError } from '../../lib/api';
import { apiUrl, assertOk, authHeaders, isAbortError, logNonAbortError } from '../../lib/api';
import { formatValue } from '../../lib/format';
import { zoomToResolution } from '../../lib/map-utils';
import { DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
@ -248,7 +248,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
})
.catch((err) => {
if (!isAbortError(err)) {
console.error('Failed to fetch story hexagons:', err);
logNonAbortError('Failed to fetch story hexagons', err);
setLoading(false);
}
});

View file

@ -1,12 +1,15 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import HexCanvas from '../home/HexCanvas';
import type { AuthUser } from '../../hooks/useAuth';
interface InvitePageProps {
code: string;
user: AuthUser | null;
theme: 'light' | 'dark';
screenshotMode?: boolean;
onLoginClick: () => void;
onRegisterClick: () => void;
onLicenseGranted: () => void;
@ -16,11 +19,68 @@ interface InviteInfo {
valid: boolean;
invite_type: string;
used: boolean;
invited_by: string | null;
}
const CONFETTI_COLORS = ['#10b981', '#06b6d4', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
function Confetti() {
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
id: i,
left: Math.random() * 100,
delay: Math.random() * 2,
duration: 2 + Math.random() * 2,
color: CONFETTI_COLORS[Math.floor(Math.random() * 6)],
size: 6 + Math.random() * 6,
isCircle: Math.random() > 0.5,
})),
[]
);
return (
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ zIndex: 2 }}>
{particles.map((p) => (
<div
key={p.id}
className="absolute animate-confetti"
style={{
left: `${p.left}%`,
top: '-10px',
width: `${p.size}px`,
height: `${p.size}px`,
backgroundColor: p.color,
borderRadius: p.isCircle ? '50%' : '2px',
animationDelay: `${p.delay}s`,
animationDuration: `${p.duration}s`,
}}
/>
))}
<style>{`
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
.animate-confetti {
animation: confetti-fall linear forwards;
}
`}</style>
</div>
);
}
export default function InvitePage({
code,
user,
theme,
screenshotMode = false,
onLoginClick,
onRegisterClick,
onLicenseGranted,
@ -32,6 +92,15 @@ export default function InvitePage({
const [redeemed, setRedeemed] = useState(false);
const [pricePence, setPricePence] = useState<number | null>(null);
const isDark = theme === 'dark';
// Signal screenshot readiness once loading completes
useEffect(() => {
if (screenshotMode && !loading) {
window.__screenshot_ready = true;
}
}, [screenshotMode, loading]);
useEffect(() => {
let cancelled = false;
(async () => {
@ -86,20 +155,75 @@ export default function InvitePage({
}
}, [code, user, onLicenseGranted]);
if (screenshotMode && loading) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900" />
);
}
if (screenshotMode) {
const isAdminInvite = invite?.valid && !invite.used && invite.invite_type === 'admin';
const isValid = invite?.valid && !invite.used;
return (
<div className="h-screen w-screen flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900">
<div className="w-[85%] bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-xl overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-12 py-14 text-center">
<h2 className="text-4xl font-bold text-white mb-4">
{isValid
? isAdminInvite
? "You\u2019re invited!"
: 'Special offer!'
: 'Perfect Postcode'}
</h2>
<p className="text-warm-300 text-lg">
{isValid && invite.invited_by
? isAdminInvite
? `${invite.invited_by} has invited you to get free lifetime access.`
: `${invite.invited_by} has shared a 30% discount on lifetime access.`
: isValid
? isAdminInvite
? 'You have been invited to get free lifetime access.'
: 'A friend has shared a 30% discount on lifetime access.'
: 'Explore every neighbourhood in England'}
</p>
</div>
<div className="px-12 py-10 text-center">
{isValid && !isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="mb-6">
<span className="text-warm-400 dark:text-warm-500 line-through text-2xl mr-3">
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-5xl font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-2 text-xl">/once</span>
</div>
)}
<p className="text-warm-600 dark:text-warm-400 text-lg">
Property prices, energy ratings, crime stats, school ratings &amp; more
</p>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<SpinnerIcon className="w-8 h-8 text-teal-400 animate-spin relative z-10" />
</div>
);
}
if (error && !invite) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">Invalid invite</p>
<p className="text-warm-500 dark:text-warm-400">{error}</p>
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<div className="text-center relative z-10">
<p className="text-lg font-medium text-white mb-2">Invalid invite</p>
<p className="text-warm-400">{error}</p>
</div>
</div>
);
@ -107,12 +231,13 @@ export default function InvitePage({
if (!invite?.valid || invite.used) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<div className="text-center max-w-sm mx-4 relative z-10">
<p className="text-lg font-medium text-white mb-2">
{invite?.used ? 'Invite already used' : 'Invalid invite link'}
</p>
<p className="text-warm-500 dark:text-warm-400">
<p className="text-warm-400">
{invite?.used
? 'This invite link has already been redeemed.'
: 'This invite link is invalid or has expired.'}
@ -124,15 +249,17 @@ export default function InvitePage({
if (redeemed) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<Confetti />
<div className="text-center max-w-sm mx-4 relative z-10">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-8 h-8 text-teal-400" />
</div>
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
<p className="text-lg font-medium text-white mb-2">
License activated!
</p>
<p className="text-warm-500 dark:text-warm-400">
<p className="text-warm-400">
You now have full access to Perfect Postcode.
</p>
</div>
@ -143,25 +270,25 @@ export default function InvitePage({
const isAdminInvite = invite.invite_type === 'admin';
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
<div className="flex-1 flex items-center justify-center bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 relative overflow-hidden">
<HexCanvas isDark={isDark} />
<Confetti />
<div className="w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden relative z-10">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">
{isAdminInvite ? "You're invited!" : 'Special offer!'}
</h2>
<p className="text-warm-300 text-sm">
{isAdminInvite
? 'You have been invited to get free lifetime access.'
: 'A friend has shared a 30% discount on lifetime access.'}
{invite.invited_by
? isAdminInvite
? `${invite.invited_by} has invited you to get free lifetime access.`
: `${invite.invited_by} has shared a 30% discount on lifetime access.`
: isAdminInvite
? 'You have been invited to get free lifetime access.'
: 'A friend has shared a 30% discount on lifetime access.'}
</p>
</div>
<div className="px-6 py-6">
{isAdminInvite && (
<div className="text-center mb-4">
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">Free</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">lifetime access</span>
</div>
)}
{!isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="text-center mb-4">
<span className="text-warm-400 dark:text-warm-500 line-through text-xl mr-2">
@ -174,7 +301,15 @@ export default function InvitePage({
</div>
)}
{user ? (
{user?.subscription === 'licensed' ? (
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-6 h-6 text-teal-600 dark:text-teal-400" />
</div>
<p className="text-warm-700 dark:text-warm-300 font-medium">You already have a license</p>
<p className="text-warm-500 dark:text-warm-400 text-sm mt-1">Your account already has full access.</p>
</div>
) : user ? (
<button
onClick={handleRedeem}
disabled={redeeming}

View file

@ -23,7 +23,7 @@ const DATA_SOURCES = [
id: 'epc',
name: 'Energy Performance Certificates (EPC)',
origin: 'Ministry of Housing, Communities & Local Government',
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction age, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
use: 'Domestic Energy Performance Certificates providing floor area, number of rooms, construction year, energy ratings, property type, and built form. Fuzzy-joined with Price Paid records by address within postcode buckets. Property owners can opt out of public disclosure.',
optOutUrl: 'https://www.gov.uk/guidance/energy-performance-certificates-opt-out-of-public-disclosure',
url: 'https://epc.opendatacommunities.org/downloads/domestic',
license: 'Open Government Licence v3.0',
@ -140,7 +140,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'What is the "Estimated current price" and how is it calculated?',
answer:
'The estimated current price is an inflation-adjusted estimate of what a property might be worth today, based on its last known sale price. It works in three stages. First, a repeat-sales price index (built from properties that have sold more than once) tracks how prices have moved within each postcode sector and property type over time this adjusts the historical sale price to current levels. Second, spatial comparable sales from nearby properties of the same type are used to refine the estimate. Third, a machine learning model captures quality differences that the index alone misses (such as floor area, energy rating, local amenities, and deprivation). Properties with post-sale improvements detected from EPC records — such as extensions or renovations — also receive a renovation premium. The more recently a property sold, the closer the estimate will be to the actual sale price; older sales are adjusted more heavily.',
'The estimated current price is an inflation-adjusted estimate of what a property might be worth today, based on its last known sale price. It works in three stages. First, a repeat-sales price index (built from properties that have sold more than once) tracks how prices have moved within each postcode sector and property type over time - this adjusts the historical sale price to current levels. Second, spatial comparable sales from nearby properties of the same type are used to refine the estimate. Third, a machine learning model captures quality differences that the index alone misses (such as floor area, energy rating, local amenities, and deprivation). Properties with post-sale improvements detected from EPC records - such as extensions or renovations - also receive a renovation premium. The more recently a property sold, the closer the estimate will be to the actual sale price; older sales are adjusted more heavily.',
},
{
question: 'How are current for-sale and for-rent listings found?',
@ -150,7 +150,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'What area does this cover?',
answer:
'England. The core datasets Land Registry prices, EPC energy certificates, deprivation indices, crime statistics, school ratings, broadband speeds, noise mapping, and council tax all cover England. Points of interest from OpenStreetMap cover Great Britain, but the property-level data is England only.',
'England. The core datasets - Land Registry prices, EPC energy certificates, deprivation indices, crime statistics, school ratings, broadband speeds, noise mapping, and council tax - all cover England. Points of interest from OpenStreetMap cover Great Britain, but the property-level data is England only.',
},
{
question: 'Why is data missing for my property?',
@ -160,7 +160,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'How do I find areas that match what I\'m looking for?',
answer:
'Use the Filters panel on the left. Add filters for the features you care about for example, set a price range, require a minimum energy rating, or select "Freehold" only. All filters combine with AND logic, so every property must satisfy every filter. Use the eye icon to pin a feature as the colour source this lets you, say, colour the map by price while filtering on floor area and energy rating at the same time. The hexagons will update in real time as you adjust.',
'Use the Filters panel on the left. Add filters for the features you care about - for example, set a price range, require a minimum energy rating, or select "Freehold" only. All filters combine with AND logic, so every property must satisfy every filter. Use the eye icon to pin a feature as the colour source - this lets you, say, colour the map by price while filtering on floor area and energy rating at the same time. The hexagons will update in real time as you adjust.',
},
{
question: 'How does the travel time feature work?',
@ -180,7 +180,7 @@ const FAQ_ITEMS: FAQItem[] = [
{
question: 'How reliable is the crime data at this scale?',
answer:
'Crime figures are sourced from data.police.uk and aggregated as yearly averages (2023\u20132025) per LSOA an area of roughly 1,500 people. This means the numbers reflect a neighbourhood average, not a specific street. Crime counts are broken down by type (violence, burglary, anti-social behaviour, vehicle crime, etc.) so you can filter on the categories that matter to you. As with all area-level statistics, they are useful for comparing neighbourhoods but should not be over-interpreted for individual streets.',
'Crime figures are sourced from data.police.uk and aggregated as yearly averages (2023\u20132025) per LSOA - an area of roughly 1,500 people. This means the numbers reflect a neighbourhood average, not a specific street. Crime counts are broken down by type (violence, burglary, anti-social behaviour, vehicle crime, etc.) so you can filter on the categories that matter to you. As with all area-level statistics, they are useful for comparing neighbourhoods but should not be over-interpreted for individual streets.',
},
{
question: 'What does the school rating represent?',

View file

@ -1,15 +1,6 @@
import { memo, useState, useCallback } from 'react';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
function SparklesIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1.5a.5.5 0 0 1 .5.5v2.5H11a.5.5 0 0 1 0 1H8.5V8a.5.5 0 0 1-1 0V5.5H5a.5.5 0 0 1 0-1h2.5V2a.5.5 0 0 1 .5-.5Z" />
<path d="M12.5 8a.5.5 0 0 1 .5.5v1.5h1.5a.5.5 0 0 1 0 1H13v1.5a.5.5 0 0 1-1 0V11H10.5a.5.5 0 0 1 0-1H12V8.5a.5.5 0 0 1 .5-.5Z" opacity=".7" />
<path d="M3.5 10a.5.5 0 0 1 .5.5V12h1.5a.5.5 0 0 1 0 1H4v1.5a.5.5 0 0 1-1 0V13H1.5a.5.5 0 0 1 0-1H3v-1.5a.5.5 0 0 1 .5-.5Z" opacity=".4" />
</svg>
);
}
import { SparklesIcon } from '../ui/icons/SparklesIcon';
interface AiFilterInputProps {
loading: boolean;

View file

@ -177,7 +177,7 @@ export default function AreaPane({
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-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{isExpanded && (
<div className="px-3 py-2 space-y-3">

View file

@ -12,7 +12,7 @@ import { FeatureLabel } from '../ui/FeatureLabel';
import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons';
import type { ComponentType } from 'react';
import { IconButton } from '../ui/IconButton';
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
import { TRANSPORT_MODES, MODE_LABELS, MODE_DESCRIPTIONS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
const MODE_ICONS: Record<TransportMode, ComponentType<{ className?: string }>> = {
car: CarIcon,
@ -89,9 +89,9 @@ 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-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{TRANSPORT_MODES.length}
</span>
</CollapsibleGroupHeader>
@ -109,7 +109,7 @@ export default function FeatureBrowser({
{MODE_LABELS[mode]}
</span>
<span className="text-xs text-warm-400 dark:text-warm-500 block">
Filter by journey time to a destination
{MODE_DESCRIPTIONS[mode]}
</span>
</div>
</div>
@ -131,9 +131,9 @@ export default function FeatureBrowser({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
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 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
</CollapsibleGroupHeader>

View file

@ -313,16 +313,16 @@ 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-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{travelTimeEntries.length}
</span>
</CollapsibleGroupHeader>
{!collapsedGroups.has('Travel Time') && (
<div className="px-2 py-1 space-y-1">
{travelTimeEntries.map((entry, index) => (
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-7">
<div key={index} data-filter-name={`tt_${index}`} className="scroll-mt-10">
<TravelTimeCard
mode={entry.mode}
slug={entry.slug}
@ -357,9 +357,9 @@ 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-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-10 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length}
</span>
</CollapsibleGroupHeader>
@ -373,7 +373,7 @@ export default memo(function Filters({
<div
key={feature.name}
data-filter-name={feature.name}
className={`scroll-mt-7 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" />
@ -430,7 +430,7 @@ export default memo(function Filters({
<div
key={feature.name}
data-filter-name={feature.name}
className={`scroll-mt-7 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`scroll-mt-10 space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-center justify-between gap-1">
<FeatureLabel feature={feature} onShowInfo={setActiveInfoFeature} size="sm" className="min-w-0 shrink" />

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry, Property } from '../../types';
import type { SearchedLocation } from './LocationSearch';
import type { Page } from '../ui/Header';
import Map from './Map';
@ -56,9 +56,14 @@ interface MapPageProps {
ogMode?: boolean;
isMobile?: boolean;
initialTravelTime?: TravelTimeInitial;
initialPostcode?: string;
user?: { id: string; subscription: string } | null;
onLoginClick?: () => void;
onRegisterClick?: () => void;
onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
}
export default function MapPage({
@ -78,9 +83,14 @@ export default function MapPage({
ogMode,
isMobile = false,
initialTravelTime,
initialPostcode,
user,
onLoginClick,
onRegisterClick,
onSaveProperty,
onUnsaveProperty,
isPropertySaved,
getSavedPropertyId,
}: MapPageProps) {
const [selectedPOICategories, setSelectedPOICategories] =
useState<Set<string>>(initialPOICategories);
@ -202,6 +212,31 @@ export default function MapPage({
selection.setRightPaneTab(initialTab);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Navigate to a specific postcode on mount (e.g. from saved properties)
useEffect(() => {
if (!initialPostcode) return;
// Strip the `pc` param from the URL so it doesn't persist
const params = new URLSearchParams(window.location.search);
params.delete('pc');
const newUrl = params.toString() ? `/dashboard?${params}` : '/dashboard';
window.history.replaceState(window.history.state, '', newUrl);
// Fetch postcode geometry and fly to it
fetch(`/api/postcode/${encodeURIComponent(initialPostcode)}`, authHeaders())
.then((res) => {
if (!res.ok) throw new Error('Postcode not found');
return res.json();
})
.then((data: { postcode: string; latitude: number; longitude: number; geometry: PostcodeGeometry }) => {
mapFlyToRef.current?.(data.latitude, data.longitude, 16);
selection.handleLocationSearch(data.postcode, data.geometry);
if (isMobile) setMobileDrawerOpen(true);
})
.catch(() => {
// Silently fail — postcode might not exist
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Prevent browser back/forward navigation from horizontal trackpad swipes
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
@ -381,6 +416,10 @@ export default function MapPage({
loading={selection.loadingProperties}
hexagonId={selection.selectedHexagon?.id || null}
onLoadMore={selection.handleLoadMoreProperties}
onSaveProperty={onSaveProperty}
onUnsaveProperty={onUnsaveProperty}
isPropertySaved={isPropertySaved}
getSavedPropertyId={getSavedPropertyId}
/>
);

View file

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, useCallback } from 'react';
import { Property } from '../../types';
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
import { getNum } from '../../lib/property-fields';
@ -6,6 +6,7 @@ import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { EmptyState } from '../ui/EmptyState';
import { InfoIcon } from '../ui/icons';
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
interface PropertiesPaneProps {
properties: Property[];
@ -14,6 +15,10 @@ interface PropertiesPaneProps {
hexagonId: string | null;
onLoadMore: () => void;
onNavigateToSource?: (slug: string) => void;
onSaveProperty?: (property: Property) => void;
onUnsaveProperty?: (id: string) => void;
isPropertySaved?: (address?: string, postcode?: string) => boolean;
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
}
export function PropertiesPane({
@ -23,6 +28,10 @@ export function PropertiesPane({
hexagonId,
onLoadMore,
onNavigateToSource,
onSaveProperty,
onUnsaveProperty,
isPropertySaved,
getSavedPropertyId,
}: PropertiesPaneProps) {
const [search, setSearch] = useState('');
const [showInfo, setShowInfo] = useState(false);
@ -70,7 +79,7 @@ export function PropertiesPane({
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
Property data combines Energy Performance Certificates (EPC) with HM Land Registry Price
Paid records, fuzzy-matched by address within each postcode. Includes floor area, energy
ratings, construction age, and tenure from EPC surveys, plus the most recent sale price
ratings, construction year, and tenure from EPC surveys, plus the most recent sale price
from the Land Registry.
</p>
</InfoPopup>
@ -91,7 +100,14 @@ export function PropertiesPane({
) : (
<>
{filtered.map((property, idx) => (
<PropertyCard key={idx} property={property} />
<PropertyCard
key={idx}
property={property}
onSave={onSaveProperty}
onUnsave={onUnsaveProperty}
isSaved={isPropertySaved?.(property.address, property.postcode)}
savedId={getSavedPropertyId?.(property.address, property.postcode)}
/>
))}
{properties.length < total && (
<button
@ -135,14 +151,33 @@ function PropertyLoadingSkeleton() {
);
}
function PropertyCard({ property }: { property: Property }) {
function PropertyCard({
property,
onSave,
onUnsave,
isSaved,
savedId,
}: {
property: Property;
onSave?: (property: Property) => void;
onUnsave?: (id: string) => void;
isSaved?: boolean;
savedId?: string;
}) {
const handleToggleSave = useCallback(() => {
if (isSaved && savedId && onUnsave) {
onUnsave(savedId);
} else if (onSave) {
onSave(property);
}
}, [isSaved, savedId, onSave, onUnsave, property]);
const price = getNum(property, 'Last known price');
const estimatedPrice = getNum(property, 'Estimated current price');
const pricePerSqm = getNum(property, 'Price per sqm');
const estPricePerSqm = getNum(property, 'Est. price per sqm');
const floorArea = getNum(property, 'Total floor area (sqm)');
const rooms = getNum(property, 'Number of bedrooms & living rooms');
const age = getNum(property, 'Construction age');
const age = getNum(property, 'Construction year');
const transactionDate = getNum(property, 'Date of last transaction');
const askingPrice = getNum(property, 'Asking price');
const askingRent = getNum(property, 'Asking rent (monthly)');
@ -152,11 +187,26 @@ function PropertyCard({ property }: { property: Property }) {
return (
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
<div>
<div className="font-semibold dark:text-warm-100">
{property.address || 'Unknown Address'}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold dark:text-warm-100">
{property.address || 'Unknown Address'}
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
</div>
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
{onSave && (
<button
onClick={handleToggleSave}
className={`shrink-0 p-1 rounded ${
isSaved
? 'text-teal-600 dark:text-teal-400'
: 'text-warm-300 dark:text-warm-600 hover:text-warm-500 dark:hover:text-warm-400'
}`}
title={isSaved ? 'Unsave property' : 'Save property'}
>
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
</button>
)}
</div>
{property.property_sub_type && (

View file

@ -7,7 +7,6 @@ import InfoPopup from '../ui/InfoPopup';
import { CloseIcon } from '../ui/icons/CloseIcon';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { InfoIcon } from '../ui/icons/InfoIcon';
import { MapPinIcon } from '../ui/icons/MapPinIcon';
import { CarIcon } from '../ui/icons/CarIcon';
import { BicycleIcon } from '../ui/icons/BicycleIcon';
import { WalkingIcon } from '../ui/icons/WalkingIcon';
@ -52,6 +51,7 @@ export function TravelTimeCard({
onRemove,
}: TravelTimeCardProps) {
const { destinations, loading: destinationsLoading } = useTravelDestinations(mode);
const [showInfo, setShowInfo] = useState(false);
const [showBestInfo, setShowBestInfo] = useState(false);
const handleDestinationSelect = useCallback(
@ -78,6 +78,9 @@ export function TravelTimeCard({
</span>
</div>
<div className="flex items-center gap-0.5">
<IconButton onClick={() => setShowInfo(true)} title="Feature info">
<InfoIcon className="w-3.5 h-3.5" />
</IconButton>
{slug && (
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
@ -90,28 +93,14 @@ export function TravelTimeCard({
</div>
{/* Destination */}
{slug && label ? (
<div className="flex items-center gap-1.5 px-2 py-1 rounded border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800">
<MapPinIcon className="w-3 h-3 text-red-500 shrink-0" />
<span className="text-xs text-navy-950 dark:text-warm-200 flex-1 truncate">
{label}
</span>
<button
onClick={() => onSetDestination('', '')}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Clear destination"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
) : (
<DestinationDropdown
destinations={destinations}
loading={destinationsLoading}
onSelect={handleDestinationSelect}
placeholder="Select destination..."
/>
)}
<DestinationDropdown
destinations={destinations}
loading={destinationsLoading}
onSelect={handleDestinationSelect}
value={label || undefined}
onClear={() => onSetDestination('', '')}
placeholder="Select destination..."
/>
{/* Best-case toggle — transit only, shown when destination is set */}
{slug && mode === 'transit' && (
@ -123,10 +112,26 @@ export function TravelTimeCard({
</div>
)}
{showInfo && (
<InfoPopup title={`Travel Time (${MODE_LABELS[mode]})`} onClose={() => setShowInfo(false)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
Shows how long it takes to reach the selected destination from each area
{mode === 'transit'
? ' by public transport (bus, rail, tube). Times are computed across a typical weekday morning window.'
: mode === 'car'
? ' by car, based on typical road speeds and the road network.'
: mode === 'bicycle'
? ' by bicycle, using cycle-friendly routes.'
: ' on foot, using pedestrian paths and pavements.'}
{' '}Use the slider to filter areas within your preferred commute time.
</p>
</InfoPopup>
)}
{showBestInfo && (
<InfoPopup title="Best case travel time" onClose={() => setShowBestInfo(false)}>
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
Uses the <strong>5th percentile</strong> travel time the fastest realistic journey
Uses the <strong>5th percentile</strong> travel time - the fastest realistic journey
if you time your departure to catch optimal connections. The default uses the{' '}
<strong>median</strong>, representing a typical journey regardless of when you leave.
</p>

View file

@ -3,6 +3,7 @@ import { CheckIcon } from '../ui/icons/CheckIcon';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import type { AuthUser } from '../../hooks/useAuth';
import { useLicense } from '../../hooks/useLicense';
import { logNonAbortError } from '../../lib/api';
import { trackEvent } from '../../lib/analytics';
import { apiUrl } from '../../lib/api';
@ -70,7 +71,7 @@ export default function PricingPage({
return res.json();
})
.then(setPricing)
.catch((err) => console.error('Failed to load pricing:', err))
.catch((err) => logNonAbortError('Failed to load pricing', err))
.finally(() => setLoading(false));
}, []);
@ -123,7 +124,7 @@ export default function PricingPage({
? 'Redirecting...'
: isFree
? 'Claim free access'
: `Get started ${formatPrice(currentPrice)}`}
: `Get started - ${formatPrice(currentPrice)}`}
</button>
) : (
<button

View file

@ -16,11 +16,11 @@ export function CollapsibleGroupHeader({
children,
}: CollapsibleGroupHeaderProps) {
return (
<button onClick={onToggle} className={`w-full flex items-center justify-between ${className}`}>
<button onClick={onToggle} className={`w-full flex items-center justify-between border-b border-warm-300 dark:border-warm-700 ${className}`}>
<span>{name}</span>
<div className="flex items-center gap-1">
{children}
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-3.5 h-3.5" />
<ChevronIcon direction={expanded ? 'down' : 'right'} className="w-4 h-4" />
</div>
</button>
);

View file

@ -3,18 +3,21 @@ import {
useRef,
useEffect,
useCallback,
useLayoutEffect,
useMemo,
} from 'react';
import { createPortal } from 'react-dom';
import type { Destination } from '../../hooks/useTravelDestinations';
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
import { MapPinIcon } from './icons/MapPinIcon';
import { ChevronIcon } from './icons/ChevronIcon';
import { CloseIcon } from './icons/CloseIcon';
interface DestinationDropdownProps {
destinations: Destination[];
loading: boolean;
onSelect: (slug: string, label: string) => void;
onClear?: () => void;
value?: string;
placeholder?: string;
}
@ -22,19 +25,18 @@ export function DestinationDropdown({
destinations,
loading,
onSelect,
onClear,
value,
placeholder = 'Select destination...',
}: DestinationDropdownProps) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState('');
const [activeIndex, setActiveIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const pos = useDropdownPosition(containerRef, open);
const filtered = useMemo(() => {
if (!filter) return destinations;
@ -46,31 +48,14 @@ export function DestinationDropdown({
);
}, [destinations, filter]);
// Position the dropdown portal
const updatePos = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('scroll', updatePos, true);
window.addEventListener('resize', updatePos);
return () => {
window.removeEventListener('scroll', updatePos, true);
window.removeEventListener('resize', updatePos);
};
}, [open, updatePos]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
!containerRef.current.contains(e.target as Node) &&
!dropdownRef.current?.contains(e.target as Node)
) {
setOpen(false);
setFilter('');
@ -129,6 +114,7 @@ export function DestinationDropdown({
const dropdown = open && (
<div
ref={dropdownRef}
className="bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 overflow-hidden"
style={
pos
@ -199,22 +185,37 @@ export function DestinationDropdown({
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={handleOpen}
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-400 dark:text-warm-500 hover:border-warm-300 dark:hover:border-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
>
{loading ? (
<div className="w-3 h-3 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin shrink-0" />
<div className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs rounded border ${value ? 'border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-800' : 'border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 hover:border-warm-300 dark:hover:border-warm-500'}`}>
<button
type="button"
onClick={handleOpen}
className="flex items-center gap-1.5 flex-1 min-w-0 outline-none"
>
{loading ? (
<div className="w-3 h-3 border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin shrink-0" />
) : (
<MapPinIcon className={`w-3 h-3 shrink-0 ${value ? 'text-red-500' : 'text-warm-400 dark:text-warm-500'}`} />
)}
<span className={`flex-1 text-left truncate ${value ? 'text-navy-950 dark:text-warm-200' : 'text-warm-400 dark:text-warm-500'}`}>
{value || placeholder}
</span>
</button>
{value && onClear ? (
<button
type="button"
onClick={onClear}
className="text-warm-400 hover:text-warm-700 dark:hover:text-warm-300 shrink-0"
title="Clear destination"
>
<CloseIcon className="w-3 h-3" />
</button>
) : (
<MapPinIcon className="w-3 h-3 shrink-0" />
<ChevronIcon
direction={open ? 'up' : 'down'}
className="w-3 h-3 shrink-0 text-warm-400 dark:text-warm-500"
/>
)}
<span className="flex-1 text-left truncate">{placeholder}</span>
<ChevronIcon
direction={open ? 'up' : 'down'}
className="w-3 h-3 shrink-0"
/>
</button>
</div>
{open && createPortal(dropdown, document.body)}
</div>

View file

@ -14,7 +14,18 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
import UserMenu from './UserMenu';
import MobileMenu from './MobileMenu';
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'invite';
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'saved' | 'invites' | 'invite';
export const PAGE_PATHS: Record<Page, string> = {
home: '/',
dashboard: '/dashboard',
learn: '/learn',
pricing: '/pricing',
saved: '/saved',
invites: '/invites',
account: '/account',
invite: '/invite',
};
export default function Header({
activePage,
@ -88,6 +99,12 @@ export default function Header({
}
}, [doCopy]);
const navLink = (page: Page, e: React.MouseEvent) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onPageChange(page);
};
const tabClass = (page: Page) =>
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
activePage === page
@ -99,35 +116,41 @@ export default function Header({
<header className="h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<button
<a
href="/"
className="flex items-center gap-2 hover:opacity-80 transition-opacity"
onClick={() => onPageChange('home')}
onClick={(e) => navLink('home', e)}
>
<LogoIcon className="w-5 h-5 text-teal-400" />
<span className="font-semibold text-lg">Perfect Postcode</span>
</button>
</a>
{/* Desktop nav */}
{!isMobile && (
<nav className="flex items-center gap-2">
<button className={tabClass('dashboard')} onClick={() => onPageChange('dashboard')}>
<a href={PAGE_PATHS.dashboard} className={tabClass('dashboard')} onClick={(e) => navLink('dashboard', e)}>
Dashboard
</button>
</a>
{user && (
<button
className={tabClass('account')}
onClick={() => onPageChange('account')}
>
Account
</button>
<>
<a href={PAGE_PATHS.saved} className={tabClass('saved')} onClick={(e) => navLink('saved', e)}>
Saved
</a>
<a href={PAGE_PATHS.invites} className={tabClass('invites')} onClick={(e) => navLink('invites', e)}>
Invite
</a>
<a href={PAGE_PATHS.account} className={tabClass('account')} onClick={(e) => navLink('account', e)}>
Account
</a>
</>
)}
<button className={tabClass('learn')} onClick={() => onPageChange('learn')}>
<a href={PAGE_PATHS.learn} className={tabClass('learn')} onClick={(e) => navLink('learn', e)}>
Learn
</button>
</a>
{user?.subscription !== 'licensed' && !user?.isAdmin && (
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
<a href={PAGE_PATHS.pricing} className={tabClass('pricing')} onClick={(e) => navLink('pricing', e)}>
Pricing
</button>
</a>
)}
</nav>
)}

View file

@ -1,4 +1,5 @@
import type { Page } from './Header';
import { PAGE_PATHS } from './Header';
import type { AuthUser } from '../../hooks/useAuth';
import { DownloadIcon } from './icons/DownloadIcon';
import { BookmarkIcon } from './icons/BookmarkIcon';
@ -45,20 +46,23 @@ export default function MobileMenu({
copied,
}: MobileMenuProps) {
const mobileNavItem = (page: Page, label: string) => (
<button
<a
key={page}
className={`w-full text-left px-4 py-3 text-base font-medium rounded ${
href={PAGE_PATHS[page]}
className={`block w-full text-left px-4 py-3 text-base font-medium rounded ${
activePage === page
? 'bg-navy-700 text-white'
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`}
onClick={() => {
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
onPageChange(page);
onClose();
}}
>
{label}
</button>
</a>
);
return (
@ -82,6 +86,8 @@ export default function MobileMenu({
{mobileNavItem('dashboard', 'Dashboard')}
{mobileNavItem('learn', 'Learn')}
{user?.subscription !== 'licensed' && !user?.isAdmin && mobileNavItem('pricing', 'Pricing')}
{user && mobileNavItem('saved', 'Saved')}
{user && mobileNavItem('invites', 'Invite')}
{user && mobileNavItem('account', 'Account')}
{/* Dashboard actions */}

View file

@ -1,7 +1,8 @@
import { useRef, useCallback, useLayoutEffect, useState as useStateR } from 'react';
import { useRef } from 'react';
import { createPortal } from 'react-dom';
import type React from 'react';
import type { SearchResult } from '../../hooks/useLocationSearch';
import { useDropdownPosition } from '../../hooks/useDropdownPosition';
import { SearchIcon } from './icons/SearchIcon';
import { MapPinIcon } from './icons/MapPinIcon';
@ -31,32 +32,6 @@ interface PlaceSearchInputProps {
portal?: boolean;
}
function useDropdownPosition(
anchorRef: React.RefObject<HTMLElement | null>,
open: boolean,
) {
const [pos, setPos] = useStateR<{ top: number; left: number; width: number } | null>(null);
const update = useCallback(() => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}, [anchorRef]);
useLayoutEffect(() => {
if (!open) return;
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [open, update]);
return pos;
}
export function PlaceSearchInput({
search,
onSelect,

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { apiUrl } from '../../lib/api';
import { apiUrl, logNonAbortError } from '../../lib/api';
interface UpgradeModalProps {
isLoggedIn: boolean;
@ -28,7 +28,7 @@ export default function UpgradeModal({
.then((data) => {
if (data) setPricePence(data.current_price_pence);
})
.catch(() => {});
.catch((err) => logNonAbortError('Failed to fetch pricing', err));
}, []);
const priceLabel =

View file

@ -1,13 +1,14 @@
interface IconProps {
className?: string;
filled?: boolean;
}
export function BookmarkIcon({ className = 'w-3.5 h-3.5' }: IconProps) {
export function BookmarkIcon({ className = 'w-3.5 h-3.5', filled = false }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
fill={filled ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={2}
>

View file

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

View file

@ -1,21 +1,34 @@
export { CloseIcon } from './CloseIcon';
export { InfoIcon } from './InfoIcon';
export { EyeIcon } from './EyeIcon';
export { PlusIcon } from './PlusIcon';
export { ChevronIcon } from './ChevronIcon';
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 { BookmarkIcon } from './BookmarkIcon';
export { CarIcon } from './CarIcon';
export { ChartBarIcon } from './ChartBarIcon';
export { CheckIcon } from './CheckIcon';
export { ChevronIcon } from './ChevronIcon';
export { ClipboardIcon } from './ClipboardIcon';
export { CloseIcon } from './CloseIcon';
export { DownloadIcon } from './DownloadIcon';
export { EyeIcon } from './EyeIcon';
export { FilterIcon } from './FilterIcon';
export { GoogleIcon } from './GoogleIcon';
export { GraduationCapIcon } from './GraduationCapIcon';
export { HouseIcon } from './HouseIcon';
export { InfoIcon } from './InfoIcon';
export { LightbulbIcon } from './LightbulbIcon';
export { LogoIcon } from './LogoIcon';
export { MapPinIcon } from './MapPinIcon';
export { MenuIcon } from './MenuIcon';
export { MoonIcon } from './MoonIcon';
export { PlusIcon } from './PlusIcon';
export { RouteIcon } from './RouteIcon';
export { SearchIcon } from './SearchIcon';
export { ShieldIcon } from './ShieldIcon';
export { UsersIcon } from './UsersIcon';
export { ShoppingBagIcon } from './ShoppingBagIcon';
export { TreeIcon } from './TreeIcon';
export { SparklesIcon } from './SparklesIcon';
export { SpinnerIcon } from './SpinnerIcon';
export { SunIcon } from './SunIcon';
export { TagIcon } from './TagIcon';
export { TrashIcon } from './TrashIcon';
export { TransitIcon } from './TransitIcon';
export { TreeIcon } from './TreeIcon';
export { UsersIcon } from './UsersIcon';
export { WalkingIcon } from './WalkingIcon';

View file

@ -0,0 +1,28 @@
import { useCallback, useLayoutEffect, useState } from 'react';
import type React from 'react';
export function useDropdownPosition(
anchorRef: React.RefObject<HTMLElement | null>,
open: boolean,
) {
const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
const update = useCallback(() => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
}, [anchorRef]);
useLayoutEffect(() => {
if (!open) return;
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [open, update]);
return pos;
}

View file

@ -340,7 +340,6 @@ export function useMapData({
return {
data,
rawData,
postcodeData: effectivePostcodeData,
resolution,
bounds,

View file

@ -0,0 +1,145 @@
import { useState, useCallback, useMemo } from 'react';
import pb from '../lib/pocketbase';
import { trackEvent } from '../lib/analytics';
import type { Property } from '../types';
import { getNum } from '../lib/property-fields';
export interface SavedPropertyData {
propertyType?: string;
propertySubType?: string;
builtForm?: string;
duration?: string;
energyRating?: string;
price?: number;
estimatedPrice?: number;
askingPrice?: number;
askingRent?: number;
bedrooms?: number;
floorArea?: number;
listingUrl?: string;
}
export interface SavedProperty {
id: string;
address: string;
postcode: string;
data: SavedPropertyData;
created: string;
}
export function useSavedProperties(userId: string | null) {
const [properties, setProperties] = useState<SavedProperty[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchProperties = useCallback(async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const records = await pb.collection('saved_properties').getFullList({
sort: '-created',
filter: `user = "${userId}"`,
});
setProperties(
records.map((r) => {
const raw = r as Record<string, unknown>;
let data: SavedPropertyData = {};
try {
data = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data as SavedPropertyData) || {};
} catch {
// Invalid JSON — use empty data
}
return {
id: r.id,
address: raw.address as string,
postcode: raw.postcode as string,
data,
created: r.created,
};
})
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load saved properties');
} finally {
setLoading(false);
}
}, [userId]);
const saveProperty = useCallback(
async (property: Property) => {
if (!userId) return;
setError(null);
try {
const data: SavedPropertyData = {
propertyType: property.property_type,
propertySubType: property.property_sub_type,
builtForm: property.built_form,
duration: property.duration,
energyRating: property.current_energy_rating,
price: getNum(property, 'Last known price'),
estimatedPrice: getNum(property, 'Estimated current price'),
askingPrice: getNum(property, 'Asking price'),
askingRent: getNum(property, 'Asking rent (monthly)'),
bedrooms: getNum(property, 'Bedrooms'),
floorArea: getNum(property, 'Total floor area (sqm)'),
listingUrl: property.listing_url || undefined,
};
await pb.collection('saved_properties').create({
user: userId,
address: property.address || 'Unknown',
postcode: property.postcode || '',
data: JSON.stringify(data),
});
trackEvent('Property Save');
await fetchProperties();
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to save property';
setError(msg);
}
},
[userId, fetchProperties]
);
const deleteProperty = useCallback(async (id: string) => {
setError(null);
try {
await pb.collection('saved_properties').delete(id);
trackEvent('Property Delete');
setProperties((prev) => prev.filter((p) => p.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete property');
}
}, []);
const savedPropertyKeys = useMemo(
() => new Set(properties.map((p) => `${p.address}|${p.postcode}`)),
[properties]
);
const isPropertySaved = useCallback(
(address?: string, postcode?: string) =>
savedPropertyKeys.has(`${address || ''}|${postcode || ''}`),
[savedPropertyKeys]
);
const getSavedPropertyId = useCallback(
(address?: string, postcode?: string) => {
const key = `${address || ''}|${postcode || ''}`;
return properties.find((p) => `${p.address}|${p.postcode}` === key)?.id;
},
[properties]
);
return {
properties,
loading,
error,
fetchProperties,
saveProperty,
deleteProperty,
isPropertySaved,
getSavedPropertyId,
};
}

View file

@ -52,23 +52,32 @@ export function useSavedSearches(userId: string | null) {
try {
const params = window.location.search.replace(/^\?/, '');
// Capture a screenshot via the screenshot endpoint
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
const screenshotRes = await fetch(screenshotUrl, authHeaders());
if (!screenshotRes.ok) {
throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`);
}
const screenshotBlob = await screenshotRes.blob();
// Create record immediately without screenshot
const formData = new FormData();
formData.append('user', userId);
formData.append('name', name);
formData.append('params', params);
formData.append('screenshot', screenshotBlob, 'screenshot.png');
await pb.collection('saved_searches').create(formData);
const record = await pb.collection('saved_searches').create(formData);
trackEvent('Search Save');
await fetchSearches();
// Capture screenshot in background and attach it to the record
const screenshotParams = new URLSearchParams(params);
const screenshotUrl = apiUrl('screenshot', screenshotParams);
fetch(screenshotUrl, authHeaders())
.then((res) => {
if (!res.ok) throw new Error(`Screenshot ${res.status}`);
return res.blob();
})
.then((blob) => {
const patch = new FormData();
patch.append('screenshot', blob, 'screenshot.png');
return pb.collection('saved_searches').update(record.id, patch);
})
.catch((err) => {
console.warn('Background screenshot failed:', err);
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to save search';
setError(msg);

View file

@ -11,6 +11,13 @@ export const MODE_LABELS: Record<TransportMode, string> = {
transit: 'Transit',
};
export const MODE_DESCRIPTIONS: Record<TransportMode, string> = {
car: 'Drive time via the fastest road route',
bicycle: 'Cycling time using bike-friendly routes',
walking: 'Walking time along pedestrian paths and pavements',
transit: 'Journey time by train, tube, and bus',
};
export interface TravelTimeEntry {
mode: TransportMode;
slug: string;

View file

@ -6,7 +6,7 @@
<meta name="theme-color" content="#fafaf9" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0a0e1a" media="(prefers-color-scheme: dark)" />
<meta name="referrer" content="no-referrer" />
<title>Perfect Postcode Every neighbourhood in England</title>
<title>Perfect Postcode - Every neighbourhood in England</title>
<meta name="description" content="Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script>

View file

@ -19,7 +19,7 @@ export const FREE_ZONE_BOUNDS = { south: 51.44, west: -0.31, north: 51.59, 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: 15,
zoom: 14.5,
pitch: 0,
};

View file

@ -69,7 +69,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
<line x1="3" y1="10" x2="21" y2="10" />
</>
),
'Construction age': (
'Construction year': (
<>
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
</>
@ -365,35 +365,42 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = {
),
'% South Asian': (
<>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
<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" />
</>
),
'% East Asian': (
<>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" />
<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" />
</>
),
'% Black': (
<>
<path d="M21.21 15.89A10 10 0 118 2.83" />
<path d="M22 12A10 10 0 0012 2v10z" />
<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" />
</>
),
'% Mixed': (
<>
<circle cx="9" cy="12" r="7" />
<circle cx="15" cy="12" r="7" />
<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" />
</>
),
'% Other': (
<>
<line x1="4" y1="9" x2="20" y2="9" />
<line x1="4" y1="15" x2="20" y2="15" />
<line x1="10" y1="3" x2="8" y2="21" />
<line x1="16" y1="3" x2="14" y2="21" />
<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" />
</>
),

View file

@ -38,6 +38,7 @@ export function parseUrlState(): {
poiCategories?: Set<string>;
tab?: 'properties' | 'area';
travelTime?: TravelTimeInitial;
postcode?: string;
} {
const params = new URLSearchParams(window.location.search);
const result: ReturnType<typeof parseUrlState> = {};
@ -70,6 +71,12 @@ export function parseUrlState(): {
result.tab = tab;
}
// Navigate-to-postcode: one-time param for opening a saved property
const pc = params.get('pc');
if (pc) {
result.postcode = pc;
}
// Travel time: repeated `tt` params
// 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');