changes
This commit is contained in:
parent
524580eb25
commit
ffe080adef
82 changed files with 2652 additions and 2956 deletions
|
|
@ -2,11 +2,9 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
|
|||
import MapPage, { type ExportState } from './components/map/MapPage';
|
||||
import PricingPage from './components/pricing/PricingPage';
|
||||
import HomePage from './components/home/HomePage';
|
||||
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
|
||||
import LearnPage from './components/learn/LearnPage';
|
||||
import AccountPage from './components/account/AccountPage';
|
||||
import InvitePage from './components/invite/InvitePage';
|
||||
import SupportPage from './components/support/SupportPage';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import AuthModal from './components/ui/AuthModal';
|
||||
import SaveSearchModal from './components/ui/SaveSearchModal';
|
||||
|
|
@ -31,8 +29,6 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
switch (page) {
|
||||
case 'dashboard':
|
||||
return '/dashboard';
|
||||
case 'saved-searches':
|
||||
return '/saved';
|
||||
case 'learn':
|
||||
return '/learn';
|
||||
case 'pricing':
|
||||
|
|
@ -41,8 +37,6 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
return '/account';
|
||||
case 'invite':
|
||||
return `/invite/${inviteCode || ''}`;
|
||||
case 'support':
|
||||
return '/support';
|
||||
default:
|
||||
return '/';
|
||||
}
|
||||
|
|
@ -50,11 +44,11 @@ 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: 'saved-searches' };
|
||||
if (pathname === '/saved') return { page: 'account' };
|
||||
if (pathname === '/learn') return { page: 'learn' };
|
||||
if (pathname === '/pricing') return { page: 'pricing' };
|
||||
if (pathname === '/account') return { page: 'account' };
|
||||
if (pathname === '/support') return { page: 'support' };
|
||||
if (pathname === '/support') return { page: 'learn' };
|
||||
if (pathname.startsWith('/invite/')) {
|
||||
const code = pathname.slice('/invite/'.length);
|
||||
return { page: 'invite', inviteCode: code };
|
||||
|
|
@ -79,12 +73,9 @@ export default function App() {
|
|||
return params.get('og') === '1';
|
||||
}, []);
|
||||
|
||||
// Core data
|
||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
// UI state
|
||||
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
||||
const [inviteCode, setInviteCode] = useState<string | null>(null);
|
||||
const [activePage, setActivePage] = useState<Page>(() => {
|
||||
|
|
@ -100,7 +91,6 @@ export default function App() {
|
|||
return 'home';
|
||||
});
|
||||
|
||||
// Initialize invite code from URL
|
||||
useEffect(() => {
|
||||
const fromPath = pathToPage(window.location.pathname);
|
||||
if (fromPath?.inviteCode) {
|
||||
|
|
@ -128,7 +118,6 @@ export default function App() {
|
|||
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
|
||||
const [verificationDismissed, setVerificationDismissed] = useState(false);
|
||||
|
||||
// Handle license_success query param (redirect from Stripe)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('license_success') === '1') {
|
||||
|
|
@ -145,7 +134,6 @@ export default function App() {
|
|||
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
|
||||
// Load features and POI categories on mount
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let featuresLoaded = false;
|
||||
|
|
@ -181,9 +169,6 @@ export default function App() {
|
|||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// Screenshot mode ready signal — MapPage sets __screenshot_ready once map data loads
|
||||
|
||||
// Navigation
|
||||
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
|
||||
if (infoFeature) {
|
||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||
|
|
@ -219,14 +204,25 @@ export default function App() {
|
|||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch saved searches when page becomes active
|
||||
const { fetchSearches } = savedSearches;
|
||||
useEffect(() => {
|
||||
if (activePage === 'saved-searches') {
|
||||
if (activePage === 'account') {
|
||||
fetchSearches();
|
||||
}
|
||||
}, [activePage, fetchSearches]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (activePage === 'account' && !user) {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
navigateTo('home');
|
||||
}
|
||||
if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) {
|
||||
navigateTo('dashboard');
|
||||
}
|
||||
}, [activePage, user, authLoading, navigateTo]);
|
||||
|
||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||
|
||||
if (isScreenshotMode) {
|
||||
|
|
@ -241,8 +237,8 @@ export default function App() {
|
|||
initialLoading={initialLoading}
|
||||
theme={theme}
|
||||
pendingInfoFeature={null}
|
||||
onClearPendingInfoFeature={() => {}}
|
||||
onNavigateTo={() => {}}
|
||||
onClearPendingInfoFeature={() => { }}
|
||||
onNavigateTo={() => { }}
|
||||
screenshotMode
|
||||
ogMode={isOgMode}
|
||||
initialTravelTime={urlState.travelTime}
|
||||
|
|
@ -273,7 +269,7 @@ export default function App() {
|
|||
onLogout={logout}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{user && !user.verified && !verificationDismissed && (
|
||||
{user && !user.verified && !verificationDismissed && activePage === 'account' && (
|
||||
<VerificationBanner
|
||||
email={user.email}
|
||||
onRequestVerification={requestVerification}
|
||||
|
|
@ -281,8 +277,8 @@ export default function App() {
|
|||
/>
|
||||
)}
|
||||
{activePage === 'home' ? (
|
||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
|
||||
) : activePage === 'pricing' ? (
|
||||
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} hidePricing={user?.subscription === 'licensed' || user?.isAdmin} />
|
||||
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
|
||||
<PricingPage
|
||||
onOpenDashboard={() => navigateTo('dashboard')}
|
||||
user={user}
|
||||
|
|
@ -298,9 +294,17 @@ export default function App() {
|
|||
) : activePage === 'learn' ? (
|
||||
<LearnPage />
|
||||
) : activePage === 'account' && user ? (
|
||||
<AccountPage user={user} onRefreshAuth={refreshAuth} onRequestVerification={requestVerification} />
|
||||
) : activePage === 'support' ? (
|
||||
<SupportPage />
|
||||
<AccountPage
|
||||
user={user}
|
||||
onRefreshAuth={refreshAuth}
|
||||
onRequestVerification={requestVerification}
|
||||
searches={savedSearches.searches}
|
||||
searchesLoading={savedSearches.loading}
|
||||
onDeleteSearch={savedSearches.deleteSearch}
|
||||
onOpenSearch={(params) => {
|
||||
window.location.href = `/?${params}`;
|
||||
}}
|
||||
/>
|
||||
) : activePage === 'invite' && inviteCode ? (
|
||||
<InvitePage
|
||||
code={inviteCode}
|
||||
|
|
@ -318,15 +322,6 @@ export default function App() {
|
|||
refreshAuth();
|
||||
}}
|
||||
/>
|
||||
) : activePage === 'saved-searches' ? (
|
||||
<SavedSearchesPage
|
||||
searches={savedSearches.searches}
|
||||
loading={savedSearches.loading}
|
||||
onDelete={savedSearches.deleteSearch}
|
||||
onOpen={(params) => {
|
||||
window.location.href = `/?${params}`;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MapPage
|
||||
features={features}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
|
||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||
import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api';
|
||||
import { formatRelativeTime } 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 { TrashIcon } from '../ui/icons/TrashIcon';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { TabButton } from '../ui/TabButton';
|
||||
|
||||
type AccountTab = 'saved' | 'settings';
|
||||
|
||||
const SUBSCRIPTION_OPTIONS = ['free', 'licensed'] as const;
|
||||
|
||||
|
|
@ -12,7 +21,180 @@ const SUBSCRIPTION_LABELS: Record<string, string> = {
|
|||
licensed: 'Licensed',
|
||||
};
|
||||
|
||||
export default function AccountPage({
|
||||
function SavedSearchesContent({
|
||||
searches,
|
||||
loading,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
searches: SavedSearch[];
|
||||
loading: boolean;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onOpen: (params: string) => void;
|
||||
}) {
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
await onDelete(deleteConfirmId);
|
||||
setDeleteConfirmId(null);
|
||||
}, [deleteConfirmId, onDelete]);
|
||||
|
||||
const copyToClipboard = useCallback((text: string, id: string) => {
|
||||
const onSuccess = () => {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
onSuccess();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleShare = useCallback(async (params: string, id: string) => {
|
||||
setSharingId(id);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
copyToClipboard(shortUrl, id);
|
||||
} catch {
|
||||
copyToClipboard(`${window.location.origin}/?${params}`, id);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
}, [copyToClipboard]);
|
||||
|
||||
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="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>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsContent({
|
||||
user,
|
||||
onRefreshAuth,
|
||||
onRequestVerification,
|
||||
|
|
@ -102,180 +284,257 @@ export default function AccountPage({
|
|||
const isLicensed = user.subscription === 'licensed' || user.isAdmin;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
|
||||
<div className="max-w-lg mx-auto px-6 py-16">
|
||||
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-8">Account</h1>
|
||||
|
||||
<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 className="max-w-lg mx-auto">
|
||||
<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>
|
||||
|
||||
{/* 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}`}>
|
||||
{SUBSCRIPTION_LABELS[user.subscription] || user.subscription || '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);
|
||||
<div className="flex items-center gap-2">
|
||||
{!user.verified && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setVerificationSending(true);
|
||||
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);
|
||||
await onRequestVerification(user.email);
|
||||
setVerificationSent(true);
|
||||
setTimeout(() => setVerificationSent(false), 3000);
|
||||
} catch {
|
||||
// Error handled by hook
|
||||
} finally {
|
||||
setNewsletterSaving(false);
|
||||
setVerificationSending(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>
|
||||
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>
|
||||
|
||||
{/* Invite friends */}
|
||||
{isLicensed && (
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||
{user.isAdmin ? 'Generate invite link (free license)' : 'Invite friends (30% off)'}
|
||||
</p>
|
||||
{inviteUrl ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteUrl}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyInvite}
|
||||
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
|
||||
>
|
||||
{inviteCopied ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="w-4 h-4" />
|
||||
)}
|
||||
{inviteCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCreateInvite}
|
||||
disabled={creatingInvite}
|
||||
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
|
||||
>
|
||||
{creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{user.isAdmin ? 'Generate invite link' : 'Generate referral link'}
|
||||
</button>
|
||||
)}
|
||||
{inviteError && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError}</p>
|
||||
)}
|
||||
</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}`}>
|
||||
{SUBSCRIPTION_LABELS[user.subscription] || user.subscription || 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin section */}
|
||||
{user.isAdmin && (
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||
Admin: Change subscription
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedSubscription}
|
||||
onChange={(e) => setSelectedSubscription(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
||||
>
|
||||
{SUBSCRIPTION_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{SUBSCRIPTION_LABELS[opt]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || selectedSubscription === user.subscription}
|
||||
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : saved ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
) : null}
|
||||
{saved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||
{user.isAdmin ? 'Generate invite link (free access)' : 'Invite friends (30% off)'}
|
||||
</p>
|
||||
{inviteUrl ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteUrl}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyInvite}
|
||||
className="px-3 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium flex items-center gap-1.5"
|
||||
>
|
||||
{inviteCopied ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<ClipboardIcon className="w-4 h-4" />
|
||||
)}
|
||||
{inviteCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCreateInvite}
|
||||
disabled={creatingInvite}
|
||||
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2"
|
||||
>
|
||||
{creatingInvite && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{user.isAdmin ? 'Generate invite link' : 'Generate referral link'}
|
||||
</button>
|
||||
)}
|
||||
{inviteError && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{inviteError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin section */}
|
||||
{user.isAdmin && (
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-3">
|
||||
Admin: Change subscription
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedSubscription}
|
||||
onChange={(e) => setSelectedSubscription(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900 text-navy-950 dark:text-warm-200 text-sm"
|
||||
>
|
||||
{SUBSCRIPTION_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{SUBSCRIPTION_LABELS[opt]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || selectedSubscription === user.subscription}
|
||||
className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="w-4 h-4 animate-spin" />
|
||||
) : saved ? (
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
) : null}
|
||||
{saved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
});
|
||||
|
||||
// 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 switchTab = (tab: AccountTab) => {
|
||||
setActiveTab(tab);
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`/account#${tab}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-warm-900">
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-6">Account</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-warm-200 dark:border-warm-700 mb-6">
|
||||
<TabButton
|
||||
label="Saved Searches"
|
||||
isActive={activeTab === 'saved'}
|
||||
onClick={() => switchTab('saved')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Settings"
|
||||
isActive={activeTab === 'settings'}
|
||||
onClick={() => switchTab('settings')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'saved' ? (
|
||||
<SavedSearchesContent
|
||||
searches={searches}
|
||||
loading={searchesLoading}
|
||||
onDelete={onDeleteSearch}
|
||||
onOpen={onOpenSearch}
|
||||
/>
|
||||
) : (
|
||||
<SettingsContent
|
||||
user={user}
|
||||
onRefreshAuth={onRefreshAuth}
|
||||
onRequestVerification={onRequestVerification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
const FAQ_ITEMS: FAQItem[] = [
|
||||
{
|
||||
question: 'What is this application?',
|
||||
answer:
|
||||
'Perfect Postcode is an interactive map that visualises property-level data across England and Wales. It combines Land Registry sale prices, EPC energy certificates, TfL journey times, deprivation indices, crime statistics, broadband speeds, school ratings, road noise levels, ethnicity demographics, and OpenStreetMap points of interest into a single explorable view.',
|
||||
},
|
||||
{
|
||||
question: 'Where does the data come from?',
|
||||
answer:
|
||||
'All data comes from open government and community sources. Property prices are from HM Land Registry, energy certificates from MHCLG, transport times from TfL, deprivation scores from the English Indices of Deprivation 2025, crime data from data.police.uk, school ratings from Ofsted, broadband from Ofcom, noise from Defra, ethnicity from the 2021 Census, and points of interest from OpenStreetMap. See the Data Sources page for full details and links.',
|
||||
},
|
||||
{
|
||||
question: 'What are the coloured hexagons on the map?',
|
||||
answer:
|
||||
'The map uses H3 hexagons to aggregate property data at different zoom levels. Each hexagon summarises the properties within it. The colour represents the value of whichever feature you have pinned or are actively filtering — for example, average price or energy rating. Zoom in to see smaller, more detailed hexagons; zoom out for a broader overview.',
|
||||
},
|
||||
{
|
||||
question: 'How do filters work?',
|
||||
answer:
|
||||
'Use the Filters panel on the left to narrow down properties. Add a filter by clicking a feature name, then drag the range slider to set minimum and maximum values. For categorical features like property type, select or deselect individual values. Only hexagons containing properties that match all active filters are shown. Filters are combined with AND logic — every property must satisfy every filter.',
|
||||
},
|
||||
{
|
||||
question: 'What does the eye icon do on a filter?',
|
||||
answer:
|
||||
"The eye icon pins a feature as the colour source for the hexagon layer. When pinned, hexagons are coloured by that feature's value range even when you are not actively dragging its slider. This lets you visualise one feature while filtering on others. Click the eye icon again to unpin.",
|
||||
},
|
||||
{
|
||||
question: 'How fresh is the data?',
|
||||
answer:
|
||||
'Property prices cover all Land Registry transactions up to the most recent quarterly release. EPC data includes certificates issued up to the latest available download. Crime data spans 2023–2025 as yearly averages. TfL journey times are computed from current timetables. Deprivation indices are from the 2025 release. School ratings reflect the latest Ofsted inspections as at April 2025. Broadband data is from Ofcom Connected Nations 2025.',
|
||||
},
|
||||
{
|
||||
question: 'How are EPC records matched to Land Registry sales?',
|
||||
answer:
|
||||
"EPC and Land Registry records don't share a common identifier, so they are fuzzy-joined by address within each postcode bucket. The pipeline uses token-sorted string similarity with special handling for numeric tokens (house numbers, flat numbers). Matches are assigned greedily from highest similarity score downward so each record is used at most once.",
|
||||
},
|
||||
{
|
||||
question: 'What are Points of Interest (POIs)?',
|
||||
answer:
|
||||
'POIs are places like cafes, schools, supermarkets, GP surgeries, parks, and train stations extracted from OpenStreetMap and the NaPTAN public transport dataset. Use the POI panel on the right to toggle categories on and off. POIs appear as markers on the map when you are zoomed in far enough.',
|
||||
},
|
||||
{
|
||||
question: 'Can I share a specific view with someone?',
|
||||
answer:
|
||||
'Yes. The URL updates automatically as you pan, zoom, and change filters. Click the Share button in the header to copy the current URL to your clipboard. Anyone who opens that link will see the same view, filters, and active POI categories.',
|
||||
},
|
||||
{
|
||||
question: 'How do I see individual properties?',
|
||||
answer:
|
||||
'Click on a hexagon to open the Properties panel on the right. It lists all matching properties within that hexagon, showing address, price, and key features. Use "Load more" at the bottom to paginate through large hexagons.',
|
||||
},
|
||||
{
|
||||
question: 'Why are some hexagons grey?',
|
||||
answer:
|
||||
'Grey hexagons contain properties that have data but fall outside the range of your currently pinned or active feature. This gives you a sense of where properties exist even when their values are outside your selected range.',
|
||||
},
|
||||
{
|
||||
question: 'Does this work on mobile?',
|
||||
answer:
|
||||
'Yes. On mobile, the dashboard uses a vertical split layout with the map on top and a tabbed panel below for filters, area stats, properties, and POIs. Tapping a hexagon opens a full-screen drawer with the details. The full desktop experience with side-by-side panels is available on screens 768px and wider.',
|
||||
},
|
||||
];
|
||||
|
||||
function FAQItemCard({ item }: { item: FAQItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-navy-800 rounded-lg border border-warm-200 dark:border-navy-700">
|
||||
<button
|
||||
className="w-full text-left px-5 py-4 flex items-center justify-between gap-4"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="font-medium text-warm-900 dark:text-warm-100">{item.question}</span>
|
||||
<ChevronIcon
|
||||
direction="down"
|
||||
className={`w-5 h-5 shrink-0 text-warm-400 dark:text-warm-500 transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-5 pb-4">
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">{item.answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FAQPage() {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
|
||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">
|
||||
Frequently Asked Questions
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
Common questions about how Perfect Postcode works, where the data comes from, and how to use the
|
||||
map.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{FAQ_ITEMS.map((item, index) => (
|
||||
<FAQItemCard key={index} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
/**
|
||||
* Decorative mini SVGs for homepage category cards.
|
||||
* Purely visual — rendered at low opacity in the corner of each card.
|
||||
*/
|
||||
export default function CategoryArt({
|
||||
category,
|
||||
className = '',
|
||||
}: {
|
||||
category: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const props = { className, width: 36, height: 36, viewBox: '0 0 36 36', fill: 'none' };
|
||||
|
||||
switch (category) {
|
||||
case 'Property':
|
||||
// Ascending bar chart
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="22" width="6" height="10" rx="1" fill="currentColor" opacity="0.5" />
|
||||
<rect x="13" y="14" width="6" height="18" rx="1" fill="currentColor" opacity="0.65" />
|
||||
<rect x="22" y="6" width="6" height="26" rx="1" fill="currentColor" opacity="0.8" />
|
||||
</svg>
|
||||
);
|
||||
case 'Transport':
|
||||
// Converging route lines
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 6 Q18 18 32 12" stroke="currentColor" strokeWidth="2" opacity="0.6" />
|
||||
<path d="M4 18 Q18 18 32 18" stroke="currentColor" strokeWidth="2" opacity="0.7" />
|
||||
<path d="M4 30 Q18 18 32 24" stroke="currentColor" strokeWidth="2" opacity="0.6" />
|
||||
<circle cx="32" cy="18" r="3" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'Crime':
|
||||
// Shield outline
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M18 4 L30 10 V20 C30 26 24 32 18 34 C12 32 6 26 6 20 V10 Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<path d="M14 18 L17 21 L23 14" stroke="currentColor" strokeWidth="2" opacity="0.5" />
|
||||
</svg>
|
||||
);
|
||||
case 'Education':
|
||||
// Mortarboard / books
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 8 L4 16 L18 24 L32 16 Z" fill="currentColor" opacity="0.5" />
|
||||
<path d="M10 19 V27 L18 31 L26 27 V19" stroke="currentColor" strokeWidth="2" opacity="0.6" />
|
||||
<line x1="30" y1="16" x2="30" y2="28" stroke="currentColor" strokeWidth="2" opacity="0.4" />
|
||||
</svg>
|
||||
);
|
||||
case 'Amenities':
|
||||
// Scattered dots (map pins)
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="10" r="3" fill="currentColor" opacity="0.5" />
|
||||
<circle cx="22" cy="7" r="2.5" fill="currentColor" opacity="0.4" />
|
||||
<circle cx="30" cy="16" r="2" fill="currentColor" opacity="0.5" />
|
||||
<circle cx="14" cy="22" r="3.5" fill="currentColor" opacity="0.6" />
|
||||
<circle cx="26" cy="28" r="2.5" fill="currentColor" opacity="0.45" />
|
||||
<circle cx="6" cy="30" r="2" fill="currentColor" opacity="0.35" />
|
||||
</svg>
|
||||
);
|
||||
case 'Demographics':
|
||||
// Pie/donut segment
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="18" cy="18" r="13" stroke="currentColor" strokeWidth="3" opacity="0.3" />
|
||||
<path
|
||||
d="M18 5 A13 13 0 0 1 30 14 L18 18 Z"
|
||||
fill="currentColor"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M18 5 A13 13 0 0 0 8 12 L18 18 Z"
|
||||
fill="currentColor"
|
||||
opacity="0.4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'Environment':
|
||||
// Terrain wave lines
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 20 Q9 12 18 18 Q27 24 34 16" stroke="currentColor" strokeWidth="2" opacity="0.6" />
|
||||
<path d="M2 26 Q9 18 18 24 Q27 30 34 22" stroke="currentColor" strokeWidth="2" opacity="0.45" />
|
||||
<path d="M2 14 Q9 6 18 12 Q27 18 34 10" stroke="currentColor" strokeWidth="2" opacity="0.35" />
|
||||
</svg>
|
||||
);
|
||||
case 'Broadband':
|
||||
// Signal waves (wifi)
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 16 Q18 4 30 16" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.4" />
|
||||
<path d="M10 21 Q18 12 26 21" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.55" />
|
||||
<path d="M14 26 Q18 20 22 26" stroke="currentColor" strokeWidth="2" fill="none" opacity="0.7" />
|
||||
<circle cx="18" cy="30" r="2.5" fill="currentColor" opacity="0.7" />
|
||||
</svg>
|
||||
);
|
||||
case 'Deprivation':
|
||||
// Scale / balance
|
||||
return (
|
||||
<svg {...props} xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="18" y1="6" x2="18" y2="30" stroke="currentColor" strokeWidth="2" opacity="0.4" />
|
||||
<line x1="6" y1="14" x2="30" y2="14" stroke="currentColor" strokeWidth="2" opacity="0.5" />
|
||||
<path d="M6 14 L3 24 H12 Z" fill="currentColor" opacity="0.4" />
|
||||
<path d="M30 14 L27 22 H33 Z" fill="currentColor" opacity="0.5" />
|
||||
<rect x="14" y="28" width="8" height="3" rx="1" fill="currentColor" opacity="0.3" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,13 +16,13 @@ interface HexConfig {
|
|||
function generateHexes(): HexConfig[] {
|
||||
const hexes: HexConfig[] = [];
|
||||
for (let i = 0; i < HEX_COUNT; i++) {
|
||||
const driftDuration = 18 + Math.random() * 35;
|
||||
const driftDuration = 40 + Math.random() * 60;
|
||||
hexes.push({
|
||||
size: 10 + Math.random() * 32,
|
||||
opacity: 0.06 + Math.random() * 0.18,
|
||||
top: Math.random() * 100,
|
||||
driftDuration,
|
||||
bobDuration: 3 + Math.random() * 5,
|
||||
bobDuration: 6 + Math.random() * 8,
|
||||
bobAmount: 8 + Math.random() * 30,
|
||||
delay: -Math.random() * driftDuration,
|
||||
reverse: Math.random() < 0.3,
|
||||
|
|
@ -49,7 +49,7 @@ export default function HexCanvas({ isDark = false }: { isDark?: boolean }) {
|
|||
className="bg-teal-500"
|
||||
style={{
|
||||
width: hex.size,
|
||||
height: hex.size,
|
||||
height: hex.size * 2 / Math.sqrt(3),
|
||||
opacity: hex.opacity * (isDark ? 0.6 : 1),
|
||||
clipPath: 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)',
|
||||
animation: `hex-bob ${hex.bobDuration}s ease-in-out infinite`,
|
||||
|
|
|
|||
|
|
@ -1,275 +0,0 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import MapComponent from '../map/Map';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { FEATURE_GRADIENT, DENSITY_GRADIENT, DENSITY_GRADIENT_DARK } from '../../lib/consts';
|
||||
import { gradientToCss } from '../../lib/utils';
|
||||
import { TickerValue } from '../ui/TickerValue';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { FeatureMeta, HexagonData } from '../../types';
|
||||
|
||||
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
|
||||
const DEMO_FEATURE_NAMES = ['Estimated current price', 'Good+ primary schools within 5km', 'Number of restaurants within 2km'];
|
||||
const DEMO_BOUNDS = '49,-9.5,57,5';
|
||||
const DEMO_RESOLUTION = 5;
|
||||
|
||||
const noop = () => {};
|
||||
const featureGradientStyle = gradientToCss(FEATURE_GRADIENT);
|
||||
|
||||
interface HomeDemoProps {
|
||||
features: FeatureMeta[];
|
||||
theme: 'light' | 'dark';
|
||||
}
|
||||
|
||||
export default function HomeDemo({ features, theme }: HomeDemoProps) {
|
||||
const [hexData, setHexData] = useState<HexagonData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [sliderValues, setSliderValues] = useState<Record<string, [number, number]>>({});
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||
const [dragHexData, setDragHexData] = useState<HexagonData[] | null>(null);
|
||||
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const abortRef = useRef<AbortController>();
|
||||
const dragAbortRef = useRef<AbortController>();
|
||||
const activeFeatureRef = useRef<string | null>(null);
|
||||
activeFeatureRef.current = activeFeature;
|
||||
|
||||
const demoFeatures = useMemo(
|
||||
() =>
|
||||
DEMO_FEATURE_NAMES.map((name) => features.find((f) => f.name === name)).filter(
|
||||
Boolean
|
||||
) as FeatureMeta[],
|
||||
[features]
|
||||
);
|
||||
|
||||
// Initialize slider values when features arrive
|
||||
useEffect(() => {
|
||||
if (demoFeatures.length === 0) return;
|
||||
const initial: Record<string, [number, number]> = {};
|
||||
for (const f of demoFeatures) {
|
||||
if (f.min != null && f.max != null) {
|
||||
initial[f.name] = [f.min, f.max];
|
||||
}
|
||||
}
|
||||
setSliderValues(initial);
|
||||
}, [demoFeatures]);
|
||||
|
||||
// Feature coloring only during drag; density (property count) otherwise
|
||||
const viewFeatureName = activeFeature;
|
||||
const viewMeta = viewFeatureName ? features.find((f) => f.name === viewFeatureName) : null;
|
||||
const colorRange: [number, number] | null =
|
||||
viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
|
||||
const filterRange: [number, number] | null = activeFeature && dragValue ? dragValue : null;
|
||||
const displayData = dragHexData ?? hexData;
|
||||
|
||||
// Fetch hexagons (debounced) — skipped while dragging
|
||||
const fetchHexagons = useCallback(() => {
|
||||
if (activeFeatureRef.current) return;
|
||||
if (features.length === 0 || Object.keys(sliderValues).length === 0) return;
|
||||
const params = new URLSearchParams({
|
||||
resolution: String(DEMO_RESOLUTION),
|
||||
bounds: DEMO_BOUNDS,
|
||||
});
|
||||
const filterParts: string[] = [];
|
||||
for (const [name, [min, max]] of Object.entries(sliderValues)) {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.min != null && meta?.max != null) {
|
||||
if (min !== meta.min || max !== meta.max) {
|
||||
filterParts.push(`${name}:${min}:${max}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filterParts.length > 0) {
|
||||
params.set('filters', filterParts.join(','));
|
||||
}
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
setFetching(true);
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: abortRef.current.signal }))
|
||||
.then((res) => {
|
||||
assertOk(res, 'hexagons');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { features: HexagonData[] }) => {
|
||||
setHexData(data.features);
|
||||
setLoading(false);
|
||||
setFetching(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
logNonAbortError('Failed to fetch demo hexagons', err);
|
||||
setFetching(false);
|
||||
});
|
||||
}, [features, sliderValues]);
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(fetchTimeoutRef.current);
|
||||
fetchTimeoutRef.current = setTimeout(fetchHexagons, 200);
|
||||
return () => clearTimeout(fetchTimeoutRef.current);
|
||||
}, [fetchHexagons]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
dragAbortRef.current?.abort();
|
||||
clearTimeout(fetchTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Drag start: fetch preview data with other filters only, fields=dragged feature
|
||||
const handleDragStart = useCallback(
|
||||
(name: string) => {
|
||||
setActiveFeature(name);
|
||||
const currentVal = sliderValues[name];
|
||||
const meta = features.find((f) => f.name === name);
|
||||
setDragValue(currentVal || (meta?.min != null ? [meta.min, meta.max!] : null));
|
||||
|
||||
const params = new URLSearchParams({
|
||||
resolution: String(DEMO_RESOLUTION),
|
||||
bounds: DEMO_BOUNDS,
|
||||
});
|
||||
const otherFilterParts: string[] = [];
|
||||
for (const [n, [min, max]] of Object.entries(sliderValues)) {
|
||||
if (n === name) continue;
|
||||
const m = features.find((f) => f.name === n);
|
||||
if (m?.min != null && m?.max != null && (min !== m.min || max !== m.max)) {
|
||||
otherFilterParts.push(`${n}:${min}:${max}`);
|
||||
}
|
||||
}
|
||||
if (otherFilterParts.length > 0) {
|
||||
params.set('filters', otherFilterParts.join(','));
|
||||
}
|
||||
params.set('fields', name);
|
||||
|
||||
dragAbortRef.current?.abort();
|
||||
dragAbortRef.current = new AbortController();
|
||||
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
|
||||
.then((res) => {
|
||||
assertOk(res, 'hexagons');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { features: HexagonData[] }) => setDragHexData(data.features))
|
||||
.catch((err) => logNonAbortError('Failed to fetch demo drag data', err));
|
||||
},
|
||||
[features, sliderValues]
|
||||
);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(name: string, value: [number, number]) => {
|
||||
setSliderValues((prev) => ({ ...prev, [name]: value }));
|
||||
if (activeFeatureRef.current === name) {
|
||||
setDragValue(value);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
setDragHexData(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Map */}
|
||||
<div className="relative rounded-xl overflow-hidden shadow-sm aspect-[4/3] md:w-3/5">
|
||||
<div className="absolute inset-0 z-50 cursor-default" />
|
||||
<div className="absolute inset-0">
|
||||
<MapComponent
|
||||
data={displayData}
|
||||
postcodeData={[]}
|
||||
usePostcodeView={false}
|
||||
pois={[]}
|
||||
onViewChange={noop}
|
||||
viewFeature={viewFeatureName}
|
||||
colorRange={colorRange}
|
||||
filterRange={filterRange}
|
||||
viewSource={activeFeature ? 'drag' : null}
|
||||
onCancelPin={noop}
|
||||
features={features}
|
||||
selectedHexagonId={null}
|
||||
hoveredHexagonId={null}
|
||||
onHexagonClick={noop}
|
||||
onHexagonHover={noop}
|
||||
initialViewState={DEMO_VIEW}
|
||||
theme={theme}
|
||||
screenshotMode={true}
|
||||
hideLegend={true}
|
||||
/>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-warm-50/80 dark:bg-navy-950/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<SpinnerIcon className="w-12 h-12 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm font-medium">
|
||||
Connecting to server...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && fetching && (
|
||||
<div className="absolute top-3 left-3 z-50 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{/* Colour spectrum legend */}
|
||||
<div className="absolute bottom-3 left-3 right-3 z-50 pointer-events-none">
|
||||
<div className="bg-white/90 dark:bg-warm-800/90 rounded-lg px-3 py-2 backdrop-blur-sm text-xs">
|
||||
<div className="font-semibold text-navy-950 dark:text-warm-100 mb-1 truncate">
|
||||
{activeFeature ? viewMeta?.name || activeFeature : 'Property density'}
|
||||
</div>
|
||||
<div
|
||||
className="h-2.5 rounded-full"
|
||||
style={{
|
||||
background: activeFeature
|
||||
? featureGradientStyle
|
||||
: gradientToCss(theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT),
|
||||
}}
|
||||
/>
|
||||
{colorRange && (
|
||||
<div className="flex justify-between mt-0.5 text-warm-500 dark:text-warm-400">
|
||||
<TickerValue text={formatValue(colorRange[0], viewMeta ?? undefined)} />
|
||||
<TickerValue text={formatValue(colorRange[1], viewMeta ?? undefined)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sliders */}
|
||||
<div className="md:w-2/5 flex flex-col justify-center space-y-6">
|
||||
{demoFeatures.map((feature) => {
|
||||
const value = sliderValues[feature.name];
|
||||
if (!value || feature.min == null || feature.max == null) return null;
|
||||
const isActive = activeFeature === feature.name;
|
||||
return (
|
||||
<div
|
||||
key={feature.name}
|
||||
className={`rounded-lg p-3 ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
|
||||
{feature.name}
|
||||
</span>
|
||||
<span className="text-sm text-warm-500 dark:text-warm-400">
|
||||
{formatValue(value[0], feature)} – {formatValue(value[1], feature)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={feature.min}
|
||||
max={feature.max}
|
||||
step={feature.step || 1}
|
||||
value={[value[0], value[1]]}
|
||||
onValueChange={([min, max]) => handleSliderChange(feature.name, [min, max])}
|
||||
onPointerDown={() => handleDragStart(feature.name)}
|
||||
onPointerUp={() => handleDragEnd()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import HexCanvas from './HexCanvas';
|
|||
import ScrollStory from './ScrollStory';
|
||||
import BottomIllustration from './BottomIllustration';
|
||||
import { TickerValue } from '../ui/TickerValue';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
import type { FeatureMeta } from '../../types';
|
||||
|
||||
export default function HomePage({
|
||||
|
|
@ -11,11 +12,13 @@ export default function HomePage({
|
|||
onOpenPricing,
|
||||
theme = 'light',
|
||||
features = [],
|
||||
hidePricing,
|
||||
}: {
|
||||
onOpenDashboard: () => void;
|
||||
onOpenPricing: () => void;
|
||||
theme?: 'light' | 'dark';
|
||||
features?: FeatureMeta[];
|
||||
hidePricing?: boolean;
|
||||
}) {
|
||||
const [statsActive, setStatsActive] = useState(false);
|
||||
useEffect(() => {
|
||||
|
|
@ -31,52 +34,68 @@ export default function HomePage({
|
|||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950 relative">
|
||||
<div className="relative" style={{ zIndex: 1 }}>
|
||||
{/* Hero */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-navy-950 via-navy-900 to-teal-900 dark:from-navy-950 dark:via-navy-900 dark:to-teal-900/60 pt-16 pb-20 md:pt-24 md:pb-28 shadow-[0_12px_50px_0px_rgba(13,148,136,0.5)] dark:shadow-[0_12px_50px_0px_rgba(13,148,136,0.4)]">
|
||||
<div className="relative overflow-hidden bg-gradient-to-b from-navy-950 via-navy-900 to-navy-900 dark:from-navy-950 dark:via-navy-900 dark:to-navy-950 min-h-[calc(100dvh-3rem)] flex flex-col">
|
||||
<HexCanvas isDark={theme === 'dark'} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-teal-500/[0.07] rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 py-6 backdrop-blur-sm bg-navy-950/30 rounded-2xl">
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
|
||||
Get more <span className="text-teal-400">home</span> for your money.
|
||||
</h1>
|
||||
<p className="text-xl text-warm-300 mb-6 leading-relaxed max-w-xl">
|
||||
Buying a home may be your most important decision. Why not ensure you make your
|
||||
best-ever decision?
|
||||
</p>
|
||||
<p className="text-lg text-warm-400 mb-8 max-w-lg">
|
||||
You have so many options. Picking the best one is daunting and stressful. It
|
||||
won't be anymore when looking at the property landscape through our dashboard.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Explore the map
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenPricing}
|
||||
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
|
||||
>
|
||||
Get a lifetime license
|
||||
</button>
|
||||
<div className="absolute top-1/3 left-1/4 w-[500px] h-[500px] bg-teal-500/[0.04] rounded-full blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[400px] h-[300px] bg-teal-600/[0.03] rounded-full blur-[100px] pointer-events-none" />
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-6 md:px-10 pt-16 md:pt-24 backdrop-blur-[2px] flex-1 flex flex-col">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1] tracking-tight">
|
||||
Get more <span className="text-teal-400">home</span> for your money.
|
||||
</h1>
|
||||
<p className="text-xl text-warm-300 mb-6 leading-relaxed max-w-xl">
|
||||
Buying a home may be your most important decision. Why not ensure you make your
|
||||
best-ever decision?
|
||||
</p>
|
||||
<p className="text-lg text-warm-400 mb-8 max-w-lg">
|
||||
You have so many options. Picking the best one is daunting and stressful. It
|
||||
won't be anymore when looking at the property landscape through our dashboard.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mb-10">
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="px-7 py-3.5 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
Explore the map
|
||||
</button>
|
||||
{hidePricing ? (
|
||||
<span className="px-[26px] py-[12px] border-2 border-teal-400/50 text-teal-400 rounded-lg font-semibold text-base">
|
||||
You have lifetime access!
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={onOpenPricing}
|
||||
className="px-[26px] py-[12px] border-2 border-teal-400 text-teal-400 rounded-lg font-semibold hover:bg-teal-400/10 transition-colors text-base"
|
||||
>
|
||||
Get lifetime access
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-12 pt-6 border-t border-white/10">
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||
<TickerValue text="13M" active={statsActive} />
|
||||
</div>
|
||||
<div className="text-sm text-warm-400">properties</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||
<TickerValue text="56" active={statsActive} />
|
||||
</div>
|
||||
<div className="text-sm text-warm-400">data layers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">Every</div>
|
||||
<div className="text-sm text-warm-400">postcode in England</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-12 pt-6 border-t border-white/10">
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||
<TickerValue text="13M" active={statsActive} />
|
||||
</div>
|
||||
<div className="text-sm text-warm-400">properties</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">
|
||||
<TickerValue text="56" active={statsActive} />
|
||||
</div>
|
||||
<div className="text-sm text-warm-400">data layers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl md:text-3xl font-bold text-white">Every</div>
|
||||
<div className="text-sm text-warm-400">postcode in England</div>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex flex-col items-center pb-8 animate-[bounce_3s_ease-in-out_infinite]">
|
||||
<p className="text-lg md:text-xl font-semibold text-warm-300 mb-2">
|
||||
Let's look at an example
|
||||
</p>
|
||||
<ChevronIcon direction="down" className="w-6 h-6 text-warm-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
||||
import MapComponent from '../map/Map';
|
||||
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { zoomToResolution } from '../../lib/map-utils';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { FeatureMeta, HexagonData } from '../../types';
|
||||
|
||||
const DEMO_VIEW = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
|
||||
const DEMO_VIEW_START = { longitude: -1.9, latitude: 52.2, zoom: 5.5, pitch: 0 };
|
||||
const DEMO_VIEW_END = { longitude: -1.9, latitude: 52.2, zoom: 12, pitch: 0 };
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
const DEMO_FEATURE_NAMES = [
|
||||
'Estimated current price',
|
||||
'Good+ primary schools within 5km',
|
||||
'Number of restaurants within 2km',
|
||||
];
|
||||
const DEMO_BOUNDS = '49,-9.5,57,5';
|
||||
const DEMO_RESOLUTION = 5;
|
||||
const noop = () => {};
|
||||
const noop = () => { };
|
||||
|
||||
// Filter fractions per stage: featureName -> [minFrac, maxFrac]
|
||||
// 0 = feature.min, 1 = feature.max
|
||||
|
|
@ -148,6 +152,9 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
const stepRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const abortRef = useRef<AbortController>();
|
||||
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
const demoFeatures = useMemo(
|
||||
() =>
|
||||
|
|
@ -188,12 +195,82 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
return () => observers.forEach((o) => o.disconnect());
|
||||
}, [demoFeatures.length]);
|
||||
|
||||
// Fetch hex data when filters change
|
||||
// Track scroll progress through the section for zoom interpolation
|
||||
useEffect(() => {
|
||||
const section = sectionRef.current;
|
||||
if (!section) return;
|
||||
let scrollParent: HTMLElement | null = section.parentElement;
|
||||
while (scrollParent) {
|
||||
const { overflow, overflowY } = getComputedStyle(scrollParent);
|
||||
if (['auto', 'scroll'].includes(overflow) || ['auto', 'scroll'].includes(overflowY)) break;
|
||||
scrollParent = scrollParent.parentElement;
|
||||
}
|
||||
if (!scrollParent) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const totalTravel = rect.height - viewportHeight;
|
||||
if (totalTravel <= 0) return;
|
||||
const scrolled = -rect.top;
|
||||
const progress = Math.max(0, Math.min(1, scrolled / totalTravel));
|
||||
setScrollProgress(progress);
|
||||
});
|
||||
};
|
||||
|
||||
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleScroll);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const demoView = useMemo(() => {
|
||||
const t = easeOutCubic(scrollProgress);
|
||||
return {
|
||||
longitude: DEMO_VIEW_START.longitude + (DEMO_VIEW_END.longitude - DEMO_VIEW_START.longitude) * t,
|
||||
latitude: DEMO_VIEW_START.latitude + (DEMO_VIEW_END.latitude - DEMO_VIEW_START.latitude) * t,
|
||||
zoom: DEMO_VIEW_START.zoom + (DEMO_VIEW_END.zoom - DEMO_VIEW_START.zoom) * t,
|
||||
pitch: 0,
|
||||
};
|
||||
}, [scrollProgress]);
|
||||
|
||||
// Derive H3 resolution from current zoom (discrete — only changes at thresholds)
|
||||
const resolution = zoomToResolution(demoView.zoom);
|
||||
|
||||
// Compute bounds string from current view, rounded to 0.5° to avoid refetching on every scroll tick
|
||||
const demoBounds = useMemo(() => {
|
||||
const { longitude, latitude, zoom } = demoView;
|
||||
const scale = Math.pow(2, zoom);
|
||||
const degreesPerPixelLng = 360 / (512 * scale);
|
||||
const halfW = (1200 / 2) * degreesPerPixelLng * 1.3;
|
||||
const latRad = (latitude * Math.PI) / 180;
|
||||
const mercY = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2;
|
||||
const worldSize = 512 * scale;
|
||||
const halfH = (800 / 2) * 1.3;
|
||||
const topY = mercY * worldSize - halfH;
|
||||
const botY = mercY * worldSize + halfH;
|
||||
const toLat = (py: number) => {
|
||||
const my = Math.max(0.001, Math.min(0.999, py / worldSize));
|
||||
return (Math.atan(Math.sinh(Math.PI * (1 - 2 * my))) * 180) / Math.PI;
|
||||
};
|
||||
const snap = (v: number) => Math.round(v * 2) / 2;
|
||||
const south = snap(Math.max(-85, toLat(botY)));
|
||||
const west = snap(Math.max(-180, longitude - halfW));
|
||||
const north = snap(Math.min(85, toLat(topY)));
|
||||
const east = snap(Math.min(180, longitude + halfW));
|
||||
return `${south},${west},${north},${east}`;
|
||||
}, [demoView]);
|
||||
|
||||
// Fetch hex data when resolution, filters, or bounds change
|
||||
useEffect(() => {
|
||||
if (features.length === 0) return;
|
||||
const params = new URLSearchParams({
|
||||
resolution: String(DEMO_RESOLUTION),
|
||||
bounds: DEMO_BOUNDS,
|
||||
resolution: String(resolution),
|
||||
bounds: demoBounds,
|
||||
});
|
||||
const filterParts: string[] = [];
|
||||
for (const [name, [min, max]] of Object.entries(stageFilters)) {
|
||||
|
|
@ -219,7 +296,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
.catch((err) => logNonAbortError('Failed to fetch story hexagons', err));
|
||||
}, 300);
|
||||
return () => clearTimeout(fetchTimeoutRef.current);
|
||||
}, [features, stageFilters, stage]);
|
||||
}, [features, stageFilters, stage, resolution, demoBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -234,13 +311,16 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
const colorRange: [number, number] | null =
|
||||
viewMeta?.min != null && viewMeta?.max != null ? [viewMeta.min, viewMeta.max] : null;
|
||||
|
||||
// Defer hex data so scroll zoom stays smooth while layer rebuilds happen in the background
|
||||
const deferredHexData = useDeferredValue(hexData);
|
||||
|
||||
return (
|
||||
<section className="relative">
|
||||
<section ref={sectionRef} className="relative">
|
||||
{/* Sticky map background */}
|
||||
<div className="sticky top-0 h-[60vh] md:h-[calc(100dvh-3rem)] z-0">
|
||||
<div className="absolute inset-0">
|
||||
<MapComponent
|
||||
data={hexData}
|
||||
data={deferredHexData}
|
||||
postcodeData={[]}
|
||||
usePostcodeView={false}
|
||||
pois={[]}
|
||||
|
|
@ -255,7 +335,7 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
hoveredHexagonId={null}
|
||||
onHexagonClick={noop}
|
||||
onHexagonHover={noop}
|
||||
initialViewState={DEMO_VIEW}
|
||||
initialViewState={demoView}
|
||||
theme={theme}
|
||||
screenshotMode={true}
|
||||
hideLegend={true}
|
||||
|
|
@ -272,9 +352,12 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter indicators */}
|
||||
<div className="absolute bottom-4 left-4 z-40 pointer-events-none w-[200px] md:w-[240px]">
|
||||
<div className="bg-white/85 dark:bg-warm-800/85 rounded-lg p-3 backdrop-blur-sm shadow-lg space-y-2.5">
|
||||
{/* Filter indicators — left sidebar */}
|
||||
<div className="absolute top-0 left-0 bottom-0 z-40 pointer-events-none w-[280px] md:w-[340px] flex items-center">
|
||||
<div className="bg-white/85 dark:bg-warm-800/85 rounded-r-xl p-5 md:p-6 backdrop-blur-sm shadow-lg space-y-5 w-full">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-warm-500 dark:text-warm-400 mb-1">
|
||||
Filters
|
||||
</div>
|
||||
{demoFeatures.map((feature) => {
|
||||
const filterVal = stageFilters[feature.name];
|
||||
const isActive = !!filterVal;
|
||||
|
|
@ -288,20 +371,20 @@ export default function ScrollStory({ features, theme }: ScrollStoryProps) {
|
|||
key={feature.name}
|
||||
className={`transition-opacity duration-700 ${isActive ? 'opacity-100' : 'opacity-30'}`}
|
||||
>
|
||||
<div className="flex justify-between items-baseline text-[11px] mb-1 gap-2">
|
||||
<div className="flex justify-between items-baseline text-sm mb-1.5 gap-2">
|
||||
<span
|
||||
className={`font-medium truncate ${isActive ? 'text-navy-950 dark:text-warm-100' : 'text-warm-400 dark:text-warm-500'}`}
|
||||
>
|
||||
{feature.name}
|
||||
</span>
|
||||
{isActive && filterVal && (
|
||||
<span className="text-teal-600 dark:text-teal-400 font-medium whitespace-nowrap">
|
||||
<span className="text-teal-600 dark:text-teal-400 font-semibold whitespace-nowrap">
|
||||
{formatValue(filterVal[0], feature)}–
|
||||
{formatValue(filterVal[1], feature)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative h-1.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
|
||||
<div className="relative h-2.5 bg-warm-200 dark:bg-warm-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full bg-teal-500 dark:bg-teal-400 rounded-full transition-all duration-700 ease-out"
|
||||
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
|
||||
|
|
|
|||
|
|
@ -151,15 +151,15 @@ export default function InvitePage({
|
|||
</h2>
|
||||
<p className="text-warm-300 text-sm">
|
||||
{isAdminInvite
|
||||
? 'You have been invited to get a free lifetime license.'
|
||||
: 'A friend has shared a 30% discount on the lifetime license.'}
|
||||
? '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 license</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-1">lifetime access</span>
|
||||
</div>
|
||||
)}
|
||||
{!isAdminInvite && pricePence !== null && pricePence > 0 && (
|
||||
|
|
@ -168,7 +168,7 @@ export default function InvitePage({
|
|||
{`\u00A3${pricePence / 100}`}
|
||||
</span>
|
||||
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
|
||||
{`\u00A3${Math.round(pricePence * 0.7) / 100}`}
|
||||
{`\u00A3${(Math.round(pricePence * 0.7) / 100).toFixed(2)}`}
|
||||
</span>
|
||||
<span className="text-warm-500 dark:text-warm-400 ml-1">/once</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
|
||||
type LearnTab = 'data-sources' | 'faq';
|
||||
type LearnTab = 'data-sources' | 'faq' | 'support';
|
||||
|
||||
const DATA_SOURCES = [
|
||||
{
|
||||
|
|
@ -206,7 +206,27 @@ const FAQ_ITEMS: FAQItem[] = [
|
|||
{
|
||||
question: 'How often is the data updated?',
|
||||
answer:
|
||||
'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source.',
|
||||
'Land Registry price data is updated quarterly. EPC records are updated as new certificates are issued. Crime data covers 2023\u20132025 as yearly averages. Deprivation indices are from the 2025 release. School ratings are as at April 2025. Broadband speeds are from Ofcom Connected Nations 2025. Council tax rates are for 2025\u201326. The map is rebuilt periodically to incorporate the latest available data from each source. All updates are included with your access at no extra cost.',
|
||||
},
|
||||
{
|
||||
question: 'What data is included?',
|
||||
answer:
|
||||
'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.',
|
||||
},
|
||||
{
|
||||
question: 'What can I access on the free tier?',
|
||||
answer:
|
||||
'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need lifetime access.',
|
||||
},
|
||||
{
|
||||
question: 'What does "lifetime" mean?',
|
||||
answer:
|
||||
'Your access never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.',
|
||||
},
|
||||
{
|
||||
question: 'Can I get a refund?',
|
||||
answer:
|
||||
'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@propertymap.co.uk within 30 days of purchase for a full refund.',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -214,7 +234,7 @@ function FAQItemCard({ item }: { item: FAQItem }) {
|
|||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-navy-800 rounded-lg border border-warm-200 dark:border-navy-700">
|
||||
<div className="bg-white dark:bg-warm-800 rounded-lg border border-warm-200 dark:border-warm-700">
|
||||
<button
|
||||
className="w-full text-left px-5 py-4 flex items-center justify-between gap-4"
|
||||
onClick={() => setOpen(!open)}
|
||||
|
|
@ -246,6 +266,9 @@ export default function LearnPage() {
|
|||
if (hash === 'faq') {
|
||||
setTab('faq');
|
||||
setHighlightedId(null);
|
||||
} else if (hash === 'support') {
|
||||
setTab('support');
|
||||
setHighlightedId(null);
|
||||
} else if (hash && DATA_SOURCES.some((s) => s.id === hash)) {
|
||||
setTab('data-sources');
|
||||
setHighlightedId(hash);
|
||||
|
|
@ -261,7 +284,6 @@ export default function LearnPage() {
|
|||
return () => window.removeEventListener('hashchange', handleHash);
|
||||
}, []);
|
||||
|
||||
// Scroll to top when switching tabs
|
||||
useEffect(() => {
|
||||
scrollContainerRef.current?.scrollTo(0, 0);
|
||||
}, [tab]);
|
||||
|
|
@ -275,19 +297,20 @@ export default function LearnPage() {
|
|||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden bg-warm-50 dark:bg-navy-950 flex flex-col">
|
||||
{/* Tab bar */}
|
||||
<div className="max-w-5xl mx-auto w-full px-6 pt-6">
|
||||
<div className="flex gap-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex gap-2 border-b border-warm-200 dark:border-warm-700">
|
||||
<button className={tabClass('data-sources')} onClick={() => setTab('data-sources')}>
|
||||
Data Sources
|
||||
</button>
|
||||
<button className={tabClass('faq')} onClick={() => setTab('faq')}>
|
||||
FAQ
|
||||
</button>
|
||||
<button className={tabClass('support')} onClick={() => setTab('support')}>
|
||||
Support
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto flex flex-col" ref={scrollContainerRef}>
|
||||
{tab === 'data-sources' ? (
|
||||
<>
|
||||
|
|
@ -308,10 +331,10 @@ export default function LearnPage() {
|
|||
ref={(el) => {
|
||||
cardRefs.current[source.id] = el;
|
||||
}}
|
||||
className={`bg-white dark:bg-navy-800 rounded-lg border p-5 ${
|
||||
className={`bg-white dark:bg-warm-800 rounded-lg border p-5 ${
|
||||
highlightedId === source.id
|
||||
? 'border-teal-400 ring-2 ring-teal-400'
|
||||
: 'border-warm-200 dark:border-navy-700'
|
||||
: 'border-warm-200 dark:border-warm-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-2">
|
||||
|
|
@ -400,7 +423,7 @@ export default function LearnPage() {
|
|||
</div>
|
||||
</footer>
|
||||
</>
|
||||
) : (
|
||||
) : tab === 'faq' ? (
|
||||
<div className="max-w-3xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">
|
||||
Frequently Asked Questions
|
||||
|
|
@ -415,6 +438,27 @@ export default function LearnPage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl font-bold text-warm-900 dark:text-warm-100 mb-2">
|
||||
Support
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">
|
||||
Have a question? Check our FAQ or reach out to us directly.
|
||||
</p>
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
|
||||
<a
|
||||
href="mailto:support@propertymap.co.uk"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
||||
>
|
||||
support@propertymap.co.uk
|
||||
</a>
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
||||
We typically respond within 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import { ChevronIcon } from '../ui/icons';
|
||||
import { LightbulbIcon } from '../ui/icons/LightbulbIcon';
|
||||
|
||||
interface AISummaryCardProps {
|
||||
summary?: string;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
}
|
||||
|
||||
export default function AISummaryCard({
|
||||
summary,
|
||||
loading,
|
||||
error,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
}: AISummaryCardProps) {
|
||||
if (!summary && !loading && !error) return null;
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<div className="bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-800/50 rounded p-2.5">
|
||||
<button
|
||||
onClick={onToggleExpanded}
|
||||
className="w-full flex items-center justify-between gap-1.5 mb-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LightbulbIcon className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400" />
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
AI Summary
|
||||
</span>
|
||||
</div>
|
||||
<ChevronIcon
|
||||
direction={expanded ? 'down' : 'right'}
|
||||
className="w-3.5 h-3.5 text-teal-600 dark:text-teal-400"
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<>
|
||||
{error ? (
|
||||
<div className="text-xs text-warm-600 dark:text-warm-400">
|
||||
Failed to generate summary.
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-full" />
|
||||
<div className="h-3 bg-teal-200/60 dark:bg-teal-800/40 rounded animate-pulse w-4/5" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-warm-700 dark:text-warm-300 leading-relaxed">{summary}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@ import { IconButton } from '../ui/IconButton';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import AISummaryCard from './AISummaryCard';
|
||||
import StreetViewEmbed from './StreetViewEmbed';
|
||||
import HistogramLegend from './HistogramLegend';
|
||||
|
||||
|
|
@ -38,9 +37,6 @@ interface AreaPaneProps {
|
|||
hexagonLocation: HexagonLocation | null;
|
||||
filters: FeatureFilters;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
aiSummary?: string;
|
||||
aiSummaryLoading?: boolean;
|
||||
aiSummaryError?: string | null;
|
||||
}
|
||||
|
||||
export default function AreaPane({
|
||||
|
|
@ -55,16 +51,11 @@ export default function AreaPane({
|
|||
hexagonLocation,
|
||||
filters,
|
||||
onNavigateToSource,
|
||||
aiSummary,
|
||||
aiSummaryLoading,
|
||||
aiSummaryError,
|
||||
}: AreaPaneProps) {
|
||||
// For postcodes, use local data for count
|
||||
const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count;
|
||||
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [collapsedGroups, toggleGroup] = useCollapsibleGroups();
|
||||
const [aiSummaryExpanded, setAiSummaryExpanded] = useState(true);
|
||||
|
||||
const numericByName = useMemo(() => {
|
||||
if (!stats) return new Map();
|
||||
|
|
@ -94,7 +85,7 @@ export default function AreaPane({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="p-3 border-b border-warm-200 dark:border-warm-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
|
|
@ -133,13 +124,6 @@ export default function AreaPane({
|
|||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AISummaryCard
|
||||
summary={aiSummary}
|
||||
loading={aiSummaryLoading}
|
||||
error={aiSummaryError}
|
||||
expanded={aiSummaryExpanded}
|
||||
onToggleExpanded={() => setAiSummaryExpanded(!aiSummaryExpanded)}
|
||||
/>
|
||||
{loading && !stats ? (
|
||||
<LoadingSkeleton />
|
||||
) : stats ? (
|
||||
|
|
@ -154,7 +138,6 @@ export default function AreaPane({
|
|||
const stackedCharts = STACKED_GROUPS[group.name];
|
||||
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
|
||||
|
||||
// Features that are part of a stacked enum config (rendered as compact charts)
|
||||
const stackedEnumFeatureNames = new Set<string>(
|
||||
stackedEnumCharts?.flatMap((c) =>
|
||||
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
|
||||
|
|
@ -173,11 +156,9 @@ export default function AreaPane({
|
|||
/>
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 space-y-3">
|
||||
{/* Price History in Property group */}
|
||||
{group.name === 'Property' &&
|
||||
stats.price_history &&
|
||||
(() => {
|
||||
// Only show chart if there are at least 2 unique years
|
||||
const uniqueYears = new Set(
|
||||
stats.price_history.map((p) => Math.floor(p.year))
|
||||
);
|
||||
|
|
@ -191,8 +172,7 @@ export default function AreaPane({
|
|||
</div>
|
||||
)}
|
||||
{stackedCharts
|
||||
? // Render stacked charts for this group
|
||||
stackedCharts.map((chart) => {
|
||||
? stackedCharts.map((chart) => {
|
||||
const segments = chart.components
|
||||
.map((name) => ({
|
||||
name,
|
||||
|
|
@ -200,7 +180,6 @@ export default function AreaPane({
|
|||
}))
|
||||
.filter((s) => s.value > 0);
|
||||
|
||||
// Use aggregate feature stats if available, otherwise sum components
|
||||
const aggregateStats = chart.feature
|
||||
? numericByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
|
@ -240,8 +219,7 @@ export default function AreaPane({
|
|||
</div>
|
||||
);
|
||||
})
|
||||
: // Default: render each feature individually (skip stacked enum features)
|
||||
group.features
|
||||
: group.features
|
||||
.filter((f) => !stackedEnumFeatureNames.has(f.name))
|
||||
.map((feature) => {
|
||||
const numericStats = numericByName.get(feature.name);
|
||||
|
|
@ -306,13 +284,11 @@ export default function AreaPane({
|
|||
|
||||
return null;
|
||||
})}
|
||||
{/* Stacked enum charts */}
|
||||
{stackedEnumCharts?.map((chart) => {
|
||||
const featureMeta = chart.feature
|
||||
? globalFeatureByName.get(chart.feature)
|
||||
: undefined;
|
||||
|
||||
// Single component: render as a stacked bar (like crime charts)
|
||||
if (chart.components.length === 1) {
|
||||
const stats = enumByName.get(chart.components[0]);
|
||||
if (!stats) return null;
|
||||
|
|
@ -355,7 +331,6 @@ export default function AreaPane({
|
|||
);
|
||||
}
|
||||
|
||||
// Multi-component: render as compact multi-row chart (like risk features)
|
||||
const components = chart.components
|
||||
.map((name) => {
|
||||
const stats = enumByName.get(name);
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export function DualHistogram({
|
|||
);
|
||||
}
|
||||
|
||||
export function SkeletonHistogram() {
|
||||
function SkeletonHistogram() {
|
||||
return (
|
||||
<div className="bg-warm-50 dark:bg-warm-800 rounded p-2 animate-pulse">
|
||||
<div className="flex justify-between items-baseline">
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import { groupFeaturesByCategory } from '../../lib/features';
|
|||
import { FeatureInfoPopup } from '../ui/FeatureInfoPopup';
|
||||
import { FeatureActions } from '../ui/FeatureIcons';
|
||||
import { FeatureLabel } from '../ui/FeatureLabel';
|
||||
import { RouteIcon, PlusIcon } from '../ui/icons';
|
||||
import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
||||
interface FeatureBrowserProps {
|
||||
availableFeatures: FeatureMeta[];
|
||||
|
|
@ -71,26 +71,58 @@ export default function FeatureBrowser({
|
|||
<SearchInput value={search} onChange={setSearch} placeholder="Search features..." />
|
||||
</div>
|
||||
<div className="md:min-h-0 md:flex-1 md:overflow-y-auto flex flex-col">
|
||||
{showTravelModes && TRANSPORT_MODES.map((mode) => (
|
||||
<div key={mode} className="shrink-0 border-b border-warm-200 dark:border-warm-700">
|
||||
<div className="flex items-start justify-between px-3 py-2 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer">
|
||||
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
|
||||
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
Travel Time ({MODE_LABELS[mode]})
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
||||
Filter by journey time to a destination
|
||||
</span>
|
||||
{showTravelModes && (
|
||||
<div className="shrink-0">
|
||||
<CollapsibleGroupHeader
|
||||
name="Travel Time"
|
||||
expanded={isSearching || expandedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] font-medium text-warm-400 dark:text-warm-500">
|
||||
{TRANSPORT_MODES.length}
|
||||
</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => {
|
||||
const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug);
|
||||
const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null;
|
||||
const isPinned = fieldKey !== null && pinnedFeature === fieldKey;
|
||||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-start justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0" onClick={() => onAddTravelTimeEntry(mode)}>
|
||||
<RouteIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
|
||||
{MODE_LABELS[mode]}
|
||||
</span>
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500 block">
|
||||
Filter by journey time to a destination
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{fieldKey && (
|
||||
<IconButton
|
||||
onClick={() => onTogglePin(fieldKey)}
|
||||
active={isPinned}
|
||||
title={isPinned ? 'Unpin color view' : 'Color map by this feature'}
|
||||
size="md"
|
||||
>
|
||||
<EyeIcon filled={isPinned} className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md">
|
||||
<PlusIcon className="w-7 h-7 md:w-3.5 md:h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<IconButton onClick={() => onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`}>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
{grouped.map((group) => {
|
||||
const isExpanded = isSearching || expandedGroups.has(group.name);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -20,8 +20,20 @@ import { TravelTimeCard } from './TravelTimeCard';
|
|||
import {
|
||||
type TransportMode,
|
||||
type TravelTimeEntry,
|
||||
travelFieldKey,
|
||||
} from '../../hooks/useTravelTime';
|
||||
|
||||
type ListingType = 'historical' | 'buy' | 'rent';
|
||||
|
||||
const MODE_RESTRICTED_FEATURES: Record<string, Set<ListingType>> = {
|
||||
'Bathrooms': new Set(['buy', 'rent']),
|
||||
};
|
||||
|
||||
function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean {
|
||||
const allowed = MODE_RESTRICTED_FEATURES[featureName];
|
||||
return !allowed || allowed.has(mode);
|
||||
}
|
||||
|
||||
function SliderLabels({
|
||||
min,
|
||||
max,
|
||||
|
|
@ -33,7 +45,6 @@ function SliderLabels({
|
|||
max: number;
|
||||
value: [number, number];
|
||||
displayValues?: [number, number];
|
||||
/** When true and slider is at max, append "+" to indicate unrestricted upper bound */
|
||||
absoluteMax?: boolean;
|
||||
}) {
|
||||
const range = max - min || 1;
|
||||
|
|
@ -72,7 +83,6 @@ interface FiltersProps {
|
|||
onDragEnd: () => void;
|
||||
pinnedFeature: string | null;
|
||||
onTogglePin: (name: string) => void;
|
||||
onCancelPin: () => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
openInfoFeature?: string | null;
|
||||
onClearOpenInfoFeature?: () => void;
|
||||
|
|
@ -102,7 +112,6 @@ export default memo(function Filters({
|
|||
onDragEnd,
|
||||
pinnedFeature,
|
||||
onTogglePin,
|
||||
onCancelPin: _onCancelPin,
|
||||
onNavigateToSource,
|
||||
openInfoFeature,
|
||||
onClearOpenInfoFeature,
|
||||
|
|
@ -117,37 +126,43 @@ export default memo(function Filters({
|
|||
aiFilterNotes,
|
||||
onAiFilterSubmit,
|
||||
}: FiltersProps) {
|
||||
const availableFeatures = features.filter((f) => !enabledFeatures.has(f.name));
|
||||
const enabledFeatureList = features.filter(
|
||||
(f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'
|
||||
);
|
||||
|
||||
const listingToggles = useMemo(() => {
|
||||
const activeListingType = useMemo((): ListingType => {
|
||||
const val = filters['Listing status'] as string[] | undefined;
|
||||
if (!val) return { historical: true, buy: true, rent: true };
|
||||
return {
|
||||
historical: val.includes('Historical sale'),
|
||||
buy: val.includes('For sale'),
|
||||
rent: val.includes('For rent'),
|
||||
};
|
||||
if (!val || val.length === 0) return 'historical';
|
||||
if (val.includes('For sale')) return 'buy';
|
||||
if (val.includes('For rent')) return 'rent';
|
||||
return 'historical';
|
||||
}, [filters]);
|
||||
|
||||
const handleListingToggle = useCallback(
|
||||
(key: 'historical' | 'buy' | 'rent') => {
|
||||
const next = { ...listingToggles, [key]: !listingToggles[key] };
|
||||
const allOn = next.historical && next.buy && next.rent;
|
||||
const allOff = !next.historical && !next.buy && !next.rent;
|
||||
if (allOn || allOff) {
|
||||
onRemoveFilter('Listing status');
|
||||
const availableFeatures = useMemo(
|
||||
() => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)),
|
||||
[features, enabledFeatures, activeListingType]
|
||||
);
|
||||
const enabledFeatureList = useMemo(
|
||||
() => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'),
|
||||
[features, enabledFeatures]
|
||||
);
|
||||
|
||||
const handleListingSelect = useCallback(
|
||||
(type: ListingType) => {
|
||||
if (type === activeListingType && !filters['Listing status']) return;
|
||||
for (const name of Object.keys(filters)) {
|
||||
if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) {
|
||||
onRemoveFilter(name);
|
||||
}
|
||||
}
|
||||
if (type === 'historical' && !filters['Listing status']) {
|
||||
onFilterChange('Listing status', ['Historical sale']);
|
||||
return;
|
||||
}
|
||||
const values: string[] = [];
|
||||
if (next.historical) values.push('Historical sale');
|
||||
if (next.buy) values.push('For sale');
|
||||
if (next.rent) values.push('For rent');
|
||||
onFilterChange('Listing status', values);
|
||||
const valueMap: Record<string, string> = {
|
||||
historical: 'Historical sale',
|
||||
buy: 'For sale',
|
||||
rent: 'For rent',
|
||||
};
|
||||
onFilterChange('Listing status', [valueMap[type]]);
|
||||
},
|
||||
[listingToggles, onFilterChange, onRemoveFilter]
|
||||
[activeListingType, filters, onFilterChange, onRemoveFilter]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -181,8 +196,7 @@ export default memo(function Filters({
|
|||
return scales;
|
||||
}, [features]);
|
||||
|
||||
const hasListingFilter = !listingToggles.historical || !listingToggles.buy || !listingToggles.rent;
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount + (hasListingFilter ? 1 : 0);
|
||||
const badgeCount = enabledFeatureList.length + activeEntryCount;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col bg-white dark:bg-navy-950 overflow-y-auto md:overflow-hidden h-full">
|
||||
|
|
@ -198,16 +212,26 @@ export default memo(function Filters({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
<span className="text-xs font-medium text-warm-500 dark:text-warm-400">Show</span>
|
||||
<PillGroup>
|
||||
<PillToggle label="Historical" active={listingToggles.historical}
|
||||
onClick={() => handleListingToggle('historical')} size="xs" />
|
||||
<PillToggle label="Buy" active={listingToggles.buy}
|
||||
onClick={() => handleListingToggle('buy')} size="xs" />
|
||||
<PillToggle label="Rent" active={listingToggles.rent}
|
||||
onClick={() => handleListingToggle('rent')} size="xs" />
|
||||
</PillGroup>
|
||||
<div className="shrink-0 px-3 py-2.5 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="flex rounded-lg bg-warm-100 dark:bg-warm-800 p-0.5">
|
||||
{(['historical', 'buy', 'rent'] as const).map((type) => {
|
||||
const labels = { historical: 'Historical', buy: 'Buy', rent: 'Rent' };
|
||||
const isActive = activeListingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleListingSelect(type)}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md cursor-pointer ${
|
||||
isActive
|
||||
? 'bg-white dark:bg-warm-700 text-teal-600 dark:text-teal-400 ring-2 ring-teal-400 shadow-sm'
|
||||
: 'text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
}`}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 md:shrink md:min-h-0 flex flex-col md:basis-[40%]">
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2 border-b border-warm-200 dark:border-navy-700">
|
||||
|
|
@ -224,20 +248,39 @@ export default memo(function Filters({
|
|||
</div>
|
||||
|
||||
<div className="md:flex-1 md:overflow-y-auto">
|
||||
{travelTimeEntries.map((entry, index) => (
|
||||
<div key={index} className="px-2 py-1">
|
||||
<TravelTimeCard
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
dataRange={travelTimeDataRanges.get(index) ?? null}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{travelTimeEntries.length > 0 && (
|
||||
<div>
|
||||
<CollapsibleGroupHeader
|
||||
name="Travel Time"
|
||||
expanded={!collapsedGroups.has('Travel Time')}
|
||||
onToggle={() => toggleGroup('Travel Time')}
|
||||
className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-[10px] 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) => (
|
||||
<TravelTimeCard
|
||||
key={index}
|
||||
mode={entry.mode}
|
||||
slug={entry.slug}
|
||||
label={entry.label}
|
||||
timeRange={entry.timeRange}
|
||||
dataRange={travelTimeDataRanges.get(index) ?? null}
|
||||
isPinned={pinnedFeature === travelFieldKey(entry)}
|
||||
onTogglePin={() => onTogglePin(travelFieldKey(entry))}
|
||||
onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)}
|
||||
onTimeRangeChange={(range) => onTravelTimeRangeChange(index, range)}
|
||||
onRemove={() => onTravelTimeRemoveEntry(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ const ZOOM_FOR_TYPE: Record<string, number> = {
|
|||
export default function LocationSearch({
|
||||
onFlyTo,
|
||||
onLocationSearched,
|
||||
onMouseEnter,
|
||||
}: {
|
||||
onFlyTo: (lat: number, lng: number, zoom: number) => void;
|
||||
onLocationSearched?: (postcode: SearchedLocation | null) => void;
|
||||
onMouseEnter?: () => void;
|
||||
}) {
|
||||
const search = useLocationSearch();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -118,8 +120,8 @@ export default function LocationSearch({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col">
|
||||
<div className="flex items-center shadow-lg rounded overflow-hidden bg-white dark:bg-warm-800">
|
||||
<div ref={containerRef} data-tutorial="search" className="absolute top-3 left-3 z-10 flex flex-col" onMouseEnter={onMouseEnter}>
|
||||
<div className="flex items-center shadow-lg rounded bg-white dark:bg-warm-800">
|
||||
<SearchIcon className="w-4 h-4 text-warm-400 dark:text-warm-500 ml-3 shrink-0" />
|
||||
<PlaceSearchInput
|
||||
search={search}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import { Map as MapGL, useControl } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type {
|
||||
|
|
@ -21,7 +20,7 @@ import MapLegend from './MapLegend';
|
|||
import HoverCard from './HoverCard';
|
||||
import type { FeatureFilters } from '../../types';
|
||||
import { useDeckLayers, osmIdToUrl } from '../../hooks/useDeckLayers';
|
||||
import { MODE_LABELS, type TravelTimeEntry, travelFieldKey } from '../../hooks/useTravelTime';
|
||||
import { MODE_LABELS, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
|
||||
interface MapProps {
|
||||
data: HexagonData[];
|
||||
|
|
@ -40,6 +39,7 @@ interface MapProps {
|
|||
onHexagonClick: (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => void;
|
||||
onHexagonHover: (h3: string | null, x?: number, y?: number) => void;
|
||||
initialViewState?: ViewState;
|
||||
flyToRef?: React.MutableRefObject<((lat: number, lng: number, zoom: number) => void) | null>;
|
||||
theme?: 'light' | 'dark';
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
|
|
@ -49,11 +49,9 @@ interface MapProps {
|
|||
bounds?: Bounds | null;
|
||||
hideLegend?: boolean;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
travelTimeColorRanges?: Map<number, [number, number]>;
|
||||
}
|
||||
|
||||
const EMPTY_TRAVEL_ENTRIES: TravelTimeEntry[] = [];
|
||||
const EMPTY_TRAVEL_RANGES = new globalThis.Map<number, [number, number]>();
|
||||
|
||||
interface Dimensions {
|
||||
width: number;
|
||||
|
|
@ -97,6 +95,7 @@ export default memo(function Map({
|
|||
onHexagonClick,
|
||||
onHexagonHover,
|
||||
initialViewState,
|
||||
flyToRef,
|
||||
theme = 'light',
|
||||
screenshotMode = false,
|
||||
ogMode = false,
|
||||
|
|
@ -106,12 +105,17 @@ export default memo(function Map({
|
|||
bounds: viewportBounds,
|
||||
hideLegend = false,
|
||||
travelTimeEntries = EMPTY_TRAVEL_ENTRIES,
|
||||
travelTimeColorRanges = EMPTY_TRAVEL_RANGES,
|
||||
}: MapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [viewState, setViewState] = useState<ViewState>(initialViewState || INITIAL_VIEW_STATE);
|
||||
const [internalViewState, setInternalViewState] = useState<ViewState>(
|
||||
initialViewState || INITIAL_VIEW_STATE
|
||||
);
|
||||
const [dimensions, setDimensions] = useState<Dimensions>({ width: 0, height: 0 });
|
||||
|
||||
// In screenshot mode, use the prop directly for instant updates (no async lag)
|
||||
const viewState =
|
||||
screenshotMode && initialViewState ? initialViewState : internalViewState;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
|
@ -130,8 +134,6 @@ export default memo(function Map({
|
|||
useEffect(() => {
|
||||
if (dimensions.width === 0 || dimensions.height === 0) return;
|
||||
|
||||
// Send exact viewport bounds - server will filter to only return
|
||||
// hexagons/postcodes that intersect this precise AABB
|
||||
const bounds = getBoundsFromViewState(viewState, dimensions.width, dimensions.height);
|
||||
const resolution = zoomToResolution(viewState.zoom);
|
||||
|
||||
|
|
@ -145,19 +147,14 @@ export default memo(function Map({
|
|||
}, [viewState, dimensions, onViewChange]);
|
||||
|
||||
const handleMove = useCallback((evt: { viewState: ViewState }) => {
|
||||
setViewState(evt.viewState);
|
||||
setInternalViewState(evt.viewState);
|
||||
}, []);
|
||||
|
||||
const handleFlyTo = useCallback((lat: number, lng: number, zoom: number) => {
|
||||
setViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||
setInternalViewState((prev) => ({ ...prev, latitude: lat, longitude: lng, zoom }));
|
||||
}, []);
|
||||
|
||||
const handleMapLoad = useCallback(
|
||||
(_evt: { target: MapRef['getMap'] extends () => infer M ? M : never }) => {
|
||||
// Road opacity is set in getMapStyle
|
||||
},
|
||||
[]
|
||||
);
|
||||
if (flyToRef) flyToRef.current = handleFlyTo;
|
||||
|
||||
const mapStyle = useMemo(() => getMapStyle(theme), [theme]);
|
||||
|
||||
|
|
@ -169,7 +166,6 @@ export default memo(function Map({
|
|||
postcodeCountRange,
|
||||
colorFeatureMeta,
|
||||
handleMouseLeave,
|
||||
primaryTravelIndex,
|
||||
} = useDeckLayers({
|
||||
data,
|
||||
postcodeData,
|
||||
|
|
@ -187,7 +183,6 @@ export default memo(function Map({
|
|||
selectedPostcodeGeometry,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries,
|
||||
travelTimeColorRanges,
|
||||
});
|
||||
|
||||
return (
|
||||
|
|
@ -195,7 +190,7 @@ export default memo(function Map({
|
|||
<MapGL
|
||||
{...viewState}
|
||||
onMove={handleMove}
|
||||
onLoad={handleMapLoad as never}
|
||||
onLoad={undefined}
|
||||
mapStyle={mapStyle}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
attributionControl={false}
|
||||
|
|
@ -222,35 +217,37 @@ export default memo(function Map({
|
|||
) : null
|
||||
) : (
|
||||
<>
|
||||
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} />
|
||||
<LocationSearch onFlyTo={handleFlyTo} onLocationSearched={onLocationSearched} onMouseEnter={handleMouseLeave} />
|
||||
{!hideLegend &&
|
||||
(primaryTravelIndex >= 0 && travelTimeColorRanges.get(primaryTravelIndex) ? (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[travelTimeEntries[primaryTravelIndex].mode]})`}
|
||||
range={travelTimeColorRanges.get(primaryTravelIndex)!}
|
||||
showCancel={false}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
suffix=" min"
|
||||
/>
|
||||
) : viewFeature && colorRange && colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
|
||||
: colorFeatureMeta.name
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
theme={theme}
|
||||
/>
|
||||
(viewFeature && colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
suffix=" min"
|
||||
/>
|
||||
) : colorFeatureMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${colorFeatureMeta.name}\u201d`
|
||||
: colorFeatureMeta.name
|
||||
}
|
||||
range={colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={onCancelPin}
|
||||
mode="feature"
|
||||
enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined}
|
||||
theme={theme}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Property density"
|
||||
featureLabel="Number of properties"
|
||||
range={
|
||||
usePostcodeView
|
||||
? [postcodeCountRange.min, postcodeCountRange.max]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, POICategoryGroup, ViewState, PostcodeGeometry } from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import type { Page } from '../ui/Header';
|
||||
|
|
@ -16,7 +16,6 @@ import { useFilters } from '../../hooks/useFilters';
|
|||
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
|
||||
import { usePaneResize } from '../../hooks/usePaneResize';
|
||||
import { useAiFilters } from '../../hooks/useAiFilters';
|
||||
import { useAreaSummary } from '../../hooks/useAreaSummary';
|
||||
import { useUrlSync } from '../../hooks/useUrlSync';
|
||||
import { useTutorial } from '../../hooks/useTutorial';
|
||||
import { getTutorialStyles } from '../../lib/tutorial-styles';
|
||||
|
|
@ -28,6 +27,7 @@ import {
|
|||
type TravelTimeInitial,
|
||||
} from '../../hooks/useTravelTime';
|
||||
import { apiUrl, assertOk, authHeaders, buildFilterString, logNonAbortError } from '../../lib/api';
|
||||
import { INITIAL_VIEW_STATE } from '../../lib/consts';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import UpgradeModal from '../ui/UpgradeModal';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
|
|
@ -87,13 +87,9 @@ export default function MapPage({
|
|||
const [leftPaneWidth, leftPaneHandlers] = usePaneResize(384, 200, 600, 'left');
|
||||
const [rightPaneWidth, rightPaneHandlers] = usePaneResize(288, 200, 500, 'right');
|
||||
|
||||
// Mobile state
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
|
||||
// POI floating panel state
|
||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
|
||||
// Initialize filters first
|
||||
const {
|
||||
filters,
|
||||
activeFeature,
|
||||
|
|
@ -112,6 +108,7 @@ export default function MapPage({
|
|||
handleDragChange,
|
||||
handleDragEnd,
|
||||
handleTogglePin,
|
||||
handleSetPin,
|
||||
handleCancelPin,
|
||||
updateBoundsInfo,
|
||||
} = useFilters({
|
||||
|
|
@ -119,7 +116,6 @@ export default function MapPage({
|
|||
features,
|
||||
});
|
||||
|
||||
// AI filters hook
|
||||
const aiFilters = useAiFilters();
|
||||
const handleAiFilterSubmit = useCallback(
|
||||
async (query: string) => {
|
||||
|
|
@ -129,13 +125,34 @@ export default function MapPage({
|
|||
[aiFilters.fetchAiFilters, handleSetFilters]
|
||||
);
|
||||
|
||||
// Travel time hook
|
||||
const travelTime = useTravelTime(initialTravelTime);
|
||||
|
||||
// License hook
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
(index: number, slug: string, label: string) => {
|
||||
travelTime.handleSetDestination(index, slug, label);
|
||||
const entry = travelTime.entries[index];
|
||||
if (entry) {
|
||||
handleSetPin(`tt_${entry.mode}_${slug}`);
|
||||
}
|
||||
},
|
||||
[travelTime.handleSetDestination, travelTime.entries, handleSetPin]
|
||||
);
|
||||
|
||||
const handleTravelTimeRemoveEntry = useCallback(
|
||||
(index: number) => {
|
||||
const entry = travelTime.entries[index];
|
||||
if (entry?.slug && pinnedFeature === travelFieldKey(entry)) {
|
||||
handleCancelPin();
|
||||
}
|
||||
travelTime.handleRemoveEntry(index);
|
||||
},
|
||||
[travelTime.handleRemoveEntry, travelTime.entries, pinnedFeature, handleCancelPin]
|
||||
);
|
||||
|
||||
const license = useLicense();
|
||||
|
||||
// Map data hook
|
||||
const mapFlyToRef = useRef<((lat: number, lng: number, zoom: number) => void) | null>(null);
|
||||
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
features,
|
||||
|
|
@ -146,19 +163,16 @@ export default function MapPage({
|
|||
travelTimeEntries: travelTime.entries,
|
||||
});
|
||||
|
||||
// Keep filter bounds in sync with map data
|
||||
useEffect(() => {
|
||||
updateBoundsInfo(mapData.bounds, mapData.resolution);
|
||||
}, [mapData.bounds, mapData.resolution, updateBoundsInfo]);
|
||||
|
||||
// Hexagon selection hook
|
||||
const selection = useHexagonSelection({
|
||||
filters,
|
||||
features,
|
||||
resolution: mapData.resolution,
|
||||
});
|
||||
|
||||
// Location search handler — selects postcode + shows stats
|
||||
const handleLocationSearchResult = useCallback(
|
||||
(result: SearchedLocation | null) => {
|
||||
if (result) {
|
||||
|
|
@ -171,10 +185,16 @@ export default function MapPage({
|
|||
[selection.handleLocationSearch, selection.handleCloseSelection, isMobile]
|
||||
);
|
||||
|
||||
// POI data
|
||||
const handleZoomToFreeZone = useCallback(() => {
|
||||
mapFlyToRef.current?.(
|
||||
INITIAL_VIEW_STATE.latitude,
|
||||
INITIAL_VIEW_STATE.longitude,
|
||||
INITIAL_VIEW_STATE.zoom
|
||||
);
|
||||
}, []);
|
||||
|
||||
const pois = usePOIData(mapData.bounds, selectedPOICategories);
|
||||
|
||||
// Compute data range for travel time slider per entry index (full min/max for slider bounds)
|
||||
const travelTimeDataRanges = useMemo((): globalThis.Map<number, [number, number]> => {
|
||||
const ranges = new globalThis.Map<number, [number, number]>();
|
||||
for (let i = 0; i < travelTime.entries.length; i++) {
|
||||
|
|
@ -193,16 +213,13 @@ export default function MapPage({
|
|||
return ranges;
|
||||
}, [travelTime.entries, mapData.data]);
|
||||
|
||||
// Sync current state to URL
|
||||
useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries);
|
||||
|
||||
// Set initial view and tab from URL state
|
||||
useEffect(() => {
|
||||
mapData.setInitialView(initialViewState);
|
||||
selection.setRightPaneTab(initialTab);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On mobile, open drawer and switch tab when hexagon is clicked
|
||||
const { handleHexagonClick } = selection;
|
||||
const handleMobileHexagonClick = useCallback(
|
||||
(id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => {
|
||||
|
|
@ -214,7 +231,6 @@ export default function MapPage({
|
|||
[handleHexagonClick]
|
||||
);
|
||||
|
||||
// Compute hexagon location for external links
|
||||
const hexagonLocation = useMemo(() => {
|
||||
const hexId = selection.selectedHexagon?.id;
|
||||
const isPostcode = selection.selectedHexagon?.type === 'postcode';
|
||||
|
|
@ -239,19 +255,8 @@ export default function MapPage({
|
|||
mapData.resolution,
|
||||
]);
|
||||
|
||||
// Tutorial
|
||||
const tutorial = useTutorial(initialLoading, isMobile);
|
||||
|
||||
// AI area summary
|
||||
const aiSummary = useAreaSummary({
|
||||
stats: selection.areaStats,
|
||||
hexagonId: selection.selectedHexagon?.id || null,
|
||||
isPostcode: selection.selectedHexagon?.type === 'postcode',
|
||||
filters,
|
||||
features,
|
||||
});
|
||||
|
||||
// Export to Excel
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const handleExport = useCallback(() => {
|
||||
if (!mapData.bounds || exporting) return;
|
||||
|
|
@ -280,12 +285,10 @@ export default function MapPage({
|
|||
.finally(() => setExporting(false));
|
||||
}, [mapData.bounds, filters, features, exporting]);
|
||||
|
||||
// Report export state to parent (Header)
|
||||
useEffect(() => {
|
||||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
}, [handleExport, exporting, onExportStateChange]);
|
||||
|
||||
// Mobile legend data (computed from API-fetched data, which is already viewport-scoped)
|
||||
const mobileLegendMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
[viewFeature, features]
|
||||
|
|
@ -305,7 +308,6 @@ export default function MapPage({
|
|||
return [min, max];
|
||||
}, [mapData.data, mapData.postcodeData, mapData.usePostcodeView]);
|
||||
|
||||
// Signal screenshot readiness once map data has loaded
|
||||
useEffect(() => {
|
||||
if (screenshotMode && !mapData.loading && mapData.data.length > 0) {
|
||||
window.__screenshot_ready = true;
|
||||
|
|
@ -337,13 +339,11 @@ export default function MapPage({
|
|||
ogMode={ogMode}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeColorRanges={mapData.travelTimeColorRanges}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shared pane content renderers
|
||||
const renderAreaPane = () => (
|
||||
<AreaPane
|
||||
stats={selection.areaStats}
|
||||
|
|
@ -362,9 +362,6 @@ export default function MapPage({
|
|||
onClose={selection.handleCloseSelection}
|
||||
hexagonLocation={hexagonLocation}
|
||||
filters={filters}
|
||||
aiSummary={aiSummary.summary}
|
||||
aiSummaryLoading={aiSummary.loading}
|
||||
aiSummaryError={aiSummary.error}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -375,7 +372,6 @@ export default function MapPage({
|
|||
loading={selection.loadingProperties}
|
||||
hexagonId={selection.selectedHexagon?.id || null}
|
||||
onLoadMore={selection.handleLoadMoreProperties}
|
||||
onClose={selection.handleCloseSelection}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -403,14 +399,13 @@ export default function MapPage({
|
|||
onDragEnd={handleDragEnd}
|
||||
pinnedFeature={pinnedFeature}
|
||||
onTogglePin={handleTogglePin}
|
||||
onCancelPin={handleCancelPin}
|
||||
openInfoFeature={pendingInfoFeature}
|
||||
onClearOpenInfoFeature={onClearPendingInfoFeature}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeDataRanges={travelTimeDataRanges}
|
||||
onTravelTimeAddEntry={travelTime.handleAddEntry}
|
||||
onTravelTimeRemoveEntry={travelTime.handleRemoveEntry}
|
||||
onTravelTimeSetDestination={travelTime.handleSetDestination}
|
||||
onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry}
|
||||
onTravelTimeSetDestination={handleTravelTimeSetDestination}
|
||||
onTravelTimeRangeChange={travelTime.handleTimeRangeChange}
|
||||
aiFilterLoading={aiFilters.loading}
|
||||
aiFilterError={aiFilters.error}
|
||||
|
|
@ -419,7 +414,6 @@ export default function MapPage({
|
|||
/>
|
||||
);
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
|
|
@ -434,7 +428,6 @@ export default function MapPage({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Map — 45% */}
|
||||
<div className="relative overflow-hidden" style={{ flex: '45 0 0' }}>
|
||||
<Map
|
||||
data={mapData.data}
|
||||
|
|
@ -453,6 +446,7 @@ export default function MapPage({
|
|||
onHexagonClick={handleMobileHexagonClick}
|
||||
onHexagonHover={selection.handleHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
flyToRef={mapFlyToRef}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
|
||||
|
|
@ -460,21 +454,21 @@ export default function MapPage({
|
|||
bounds={mapData.bounds}
|
||||
hideLegend
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeColorRanges={mapData.travelTimeColorRanges}
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute bottom-2 left-2 bg-white dark:bg-navy-800 dark:text-warm-200 px-2 py-1 rounded shadow text-xs">
|
||||
Loading...
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
|
||||
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Floating POI button */}
|
||||
<button
|
||||
onClick={() => setPoiPaneOpen((p) => !p)}
|
||||
className={`absolute bottom-2 right-2 z-10 p-2 rounded-lg shadow-lg bg-white dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 dark:text-warm-400 hover:text-teal-600 dark:hover:text-teal-400'}`}
|
||||
>
|
||||
<MapPinIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Floating POI panel */}
|
||||
{poiPaneOpen && (
|
||||
<div className="absolute bottom-12 right-2 z-10 w-[calc(100%-1rem)] max-h-[60%] overflow-hidden flex flex-col rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-900">
|
||||
{renderPOIPane()}
|
||||
|
|
@ -482,67 +476,54 @@ export default function MapPage({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom panel — 55% */}
|
||||
<div
|
||||
className="bg-white dark:bg-warm-900 border-t border-warm-200 dark:border-warm-700 overflow-hidden flex flex-col"
|
||||
style={{ flex: '55 0 0' }}
|
||||
>
|
||||
{/* Legend */}
|
||||
{(() => {
|
||||
const primaryIdx = travelTime.entries.findIndex(
|
||||
(e, i) => e.slug && mapData.travelTimeColorRanges.get(i)
|
||||
);
|
||||
if (primaryIdx >= 0) {
|
||||
return (
|
||||
<MapLegend
|
||||
featureLabel={`Travel time (${MODE_LABELS[travelTime.entries[primaryIdx].mode]})`}
|
||||
range={mapData.travelTimeColorRanges.get(primaryIdx)!}
|
||||
showCancel={false}
|
||||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
inline
|
||||
suffix=" min"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (viewFeature && mapData.colorRange && mobileLegendMeta) {
|
||||
return (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
|
||||
: mobileLegendMeta.name
|
||||
}
|
||||
range={mapData.colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||
theme={theme}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
{viewFeature && mapData.colorRange ? (
|
||||
viewFeature.startsWith('tt_') ? (
|
||||
<MapLegend
|
||||
featureLabel="Property density"
|
||||
range={mobileDensityRange}
|
||||
showCancel={false}
|
||||
featureLabel={`Travel time (${MODE_LABELS[viewFeature.split('_')[1] as keyof typeof MODE_LABELS]})`}
|
||||
range={mapData.colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={handleCancelPin}
|
||||
mode="density"
|
||||
mode="feature"
|
||||
theme={theme}
|
||||
inline
|
||||
suffix=" min"
|
||||
/>
|
||||
) : mobileLegendMeta ? (
|
||||
<MapLegend
|
||||
featureLabel={
|
||||
viewSource === 'eye'
|
||||
? `Previewing \u201c${mobileLegendMeta.name}\u201d`
|
||||
: mobileLegendMeta.name
|
||||
}
|
||||
range={mapData.colorRange}
|
||||
showCancel={viewSource === 'eye'}
|
||||
onCancel={handleCancelPin}
|
||||
mode="feature"
|
||||
enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined}
|
||||
theme={theme}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* Filters content */}
|
||||
) : null
|
||||
) : (
|
||||
<MapLegend
|
||||
featureLabel="Number of properties"
|
||||
range={mobileDensityRange}
|
||||
showCancel={false}
|
||||
onCancel={handleCancelPin}
|
||||
mode="density"
|
||||
theme={theme}
|
||||
inline
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
{renderFilters()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer for full-screen hexagon details */}
|
||||
{mobileDrawerOpen && selection.selectedHexagon && (
|
||||
<MobileDrawer
|
||||
onClose={() => setMobileDrawerOpen(false)}
|
||||
|
|
@ -557,14 +538,13 @@ export default function MapPage({
|
|||
onLoginClick={onLoginClick ?? (() => {})}
|
||||
onRegisterClick={onRegisterClick ?? (() => {})}
|
||||
onStartCheckout={() => license.startCheckout()}
|
||||
onDismiss={() => {}}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout (unchanged)
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{initialLoading && (
|
||||
|
|
@ -589,7 +569,6 @@ export default function MapPage({
|
|||
disableScrolling
|
||||
/>
|
||||
|
||||
{/* Left Pane */}
|
||||
<div
|
||||
data-tutorial="filters"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg overflow-hidden"
|
||||
|
|
@ -604,7 +583,6 @@ export default function MapPage({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div data-tutorial="map" className="flex-1 relative">
|
||||
<Map
|
||||
data={mapData.data}
|
||||
|
|
@ -623,17 +601,20 @@ export default function MapPage({
|
|||
onHexagonClick={selection.handleHexagonClick}
|
||||
onHexagonHover={selection.handleHexagonHover}
|
||||
initialViewState={initialViewState}
|
||||
flyToRef={mapFlyToRef}
|
||||
theme={theme}
|
||||
filters={filters}
|
||||
selectedPostcodeGeometry={selection.selectedPostcodeGeometry}
|
||||
onLocationSearched={handleLocationSearchResult}
|
||||
bounds={mapData.bounds}
|
||||
travelTimeEntries={travelTime.entries}
|
||||
travelTimeColorRanges={mapData.travelTimeColorRanges}
|
||||
/>
|
||||
{mapData.loading && (
|
||||
<div className="absolute bottom-4 left-4 bg-white dark:bg-navy-800 dark:text-warm-200 px-3 py-1 rounded shadow">
|
||||
Loading...
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="flex items-center gap-2 bg-white/90 dark:bg-warm-800/90 px-4 py-2.5 rounded-lg shadow-lg pointer-events-auto">
|
||||
<SpinnerIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
<span className="text-sm font-medium text-warm-700 dark:text-warm-200">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Floating POI button */}
|
||||
|
|
@ -652,7 +633,6 @@ export default function MapPage({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Pane */}
|
||||
<div
|
||||
data-tutorial="right-pane"
|
||||
className="flex bg-white dark:bg-navy-950 shadow-lg z-10"
|
||||
|
|
@ -692,7 +672,7 @@ export default function MapPage({
|
|||
onLoginClick={onLoginClick ?? (() => {})}
|
||||
onRegisterClick={onRegisterClick ?? (() => {})}
|
||||
onStartCheckout={() => license.startCheckout()}
|
||||
onDismiss={() => {}}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,15 +49,30 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
if (arr) arr.push(p.price);
|
||||
else byYear.set(yr, [p.price]);
|
||||
}
|
||||
const meds = Array.from(byYear.entries())
|
||||
const yearlyMedians = Array.from(byYear.entries())
|
||||
.map(([yr, prices]) => {
|
||||
prices.sort((a, b) => a - b);
|
||||
const mid = Math.floor(prices.length / 2);
|
||||
const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
|
||||
return { year: yr + 0.5, price: median };
|
||||
return { year: yr, price: median };
|
||||
})
|
||||
.sort((a, b) => a.year - b.year);
|
||||
|
||||
// 3-year rolling average
|
||||
const meds = yearlyMedians.map((pt, i) => {
|
||||
let sum = pt.price;
|
||||
let count = 1;
|
||||
for (let j = i - 1; j >= 0 && pt.year - yearlyMedians[j].year <= 1; j--) {
|
||||
sum += yearlyMedians[j].price;
|
||||
count++;
|
||||
}
|
||||
for (let j = i + 1; j < yearlyMedians.length && yearlyMedians[j].year - pt.year <= 1; j++) {
|
||||
sum += yearlyMedians[j].price;
|
||||
count++;
|
||||
}
|
||||
return { year: pt.year + 0.5, price: sum / count };
|
||||
});
|
||||
|
||||
const ticks = niceTicksForRange(pMin, pMax, 4);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Property } from '../../types';
|
||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
||||
import { getNum } from '../../lib/property-fields';
|
||||
|
|
@ -13,7 +13,6 @@ interface PropertiesPaneProps {
|
|||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
onLoadMore: () => void;
|
||||
onClose: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -23,7 +22,6 @@ export function PropertiesPane({
|
|||
loading,
|
||||
hexagonId,
|
||||
onLoadMore,
|
||||
onClose: _onClose,
|
||||
onNavigateToSource,
|
||||
}: PropertiesPaneProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
|
@ -123,13 +121,9 @@ function PropertyLoadingSkeleton() {
|
|||
<div className="space-y-0">
|
||||
{Array.from({ length: 5 }).map((_, idx) => (
|
||||
<div key={idx} className="p-4 border-b border-warm-100 dark:border-navy-800 animate-pulse">
|
||||
{/* Address */}
|
||||
<div className="h-5 w-3/4 bg-warm-200 dark:bg-warm-700 rounded mb-2" />
|
||||
{/* Postcode */}
|
||||
<div className="h-4 w-24 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
|
||||
{/* Price */}
|
||||
<div className="h-6 w-32 bg-warm-200 dark:bg-warm-700 rounded mb-3" />
|
||||
{/* Property details grid */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-4 bg-warm-200 dark:bg-warm-700 rounded" />
|
||||
|
|
@ -141,39 +135,93 @@ function PropertyLoadingSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
const LISTING_STATUS_STYLES: Record<string, string> = {
|
||||
'For sale': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
'For rent': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
'Historical sale': 'bg-warm-100 text-warm-600 dark:bg-warm-700 dark:text-warm-300',
|
||||
};
|
||||
|
||||
function ListingStatusBadge({ status }: { status: string }) {
|
||||
const style = LISTING_STATUS_STYLES[status] ?? LISTING_STATUS_STYLES['Historical sale'];
|
||||
return <span className={`text-xs font-medium px-1.5 py-0.5 rounded ${style}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function PropertyCard({ property }: { property: 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, 'Rooms (including bedrooms & bathrooms)');
|
||||
const age = getNum(property, 'Approximate construction age');
|
||||
const rooms = getNum(property, 'Number of bedrooms & living rooms');
|
||||
const age = getNum(property, 'Construction age');
|
||||
const transactionDate = getNum(property, 'Date of last transaction');
|
||||
const councilTax = getNum(property, 'Council tax (£/yr)');
|
||||
const councilTaxD = getNum(property, 'Council tax Band D (£/yr)');
|
||||
const askingPrice = getNum(property, 'Asking price');
|
||||
const askingRent = getNum(property, 'Asking rent (monthly)');
|
||||
const bedrooms = getNum(property, 'Bedrooms');
|
||||
const bathrooms = getNum(property, 'Bathrooms');
|
||||
const listingDate = getNum(property, 'Listing date');
|
||||
|
||||
const listingStatus = property.listing_status;
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-warm-100 dark:border-navy-800 hover:bg-warm-50 dark:hover:bg-navy-800">
|
||||
<div className="font-semibold dark:text-warm-100">
|
||||
{property.address || 'Unknown Address'}
|
||||
<div className="p-4 border-b border-warm-100 dark:border-warm-800 hover:bg-warm-50 dark:hover:bg-warm-800">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<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>
|
||||
{listingStatus && <ListingStatusBadge status={listingStatus} />}
|
||||
</div>
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400">{property.postcode}</div>
|
||||
|
||||
{price !== undefined && (
|
||||
{property.property_sub_type && (
|
||||
<div className="text-sm text-warm-600 dark:text-warm-400 mt-1">
|
||||
{property.property_sub_type}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askingPrice !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(price)}
|
||||
{transactionDate !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
({formatTransactionDate(transactionDate)})
|
||||
{property.price_qualifier && (
|
||||
<span className="text-sm font-normal text-warm-500 dark:text-warm-400">
|
||||
{property.price_qualifier}{' '}
|
||||
</span>
|
||||
)}
|
||||
{pricePerSqm !== undefined && (
|
||||
£{formatNumber(askingPrice)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{askingRent !== undefined && (
|
||||
<div className="mt-2 text-lg font-bold text-teal-700 dark:text-teal-400">
|
||||
£{formatNumber(askingRent)}
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">/mo</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{price !== undefined && (
|
||||
<div className={`${askingPrice !== undefined || askingRent !== undefined ? '' : 'mt-2 '}text-lg font-bold text-teal-700 dark:text-teal-400`}>
|
||||
{askingPrice !== undefined || askingRent !== undefined ? (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
£{formatNumber(pricePerSqm)}/m²
|
||||
Last sold: £{formatNumber(price)}
|
||||
{transactionDate !== undefined && ` (${formatTransactionDate(transactionDate)})`}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
£{formatNumber(price)}
|
||||
{transactionDate !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
({formatTransactionDate(transactionDate)})
|
||||
</span>
|
||||
)}
|
||||
{pricePerSqm !== undefined && (
|
||||
<span className="text-sm font-normal text-warm-600 dark:text-warm-400">
|
||||
{' '}
|
||||
£{formatNumber(pricePerSqm)}/m²
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -213,6 +261,18 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
{formatNumber(floorArea)}m²
|
||||
</div>
|
||||
)}
|
||||
{bedrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Bedrooms:</span>{' '}
|
||||
{formatNumber(bedrooms)}
|
||||
</div>
|
||||
)}
|
||||
{bathrooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Bathrooms:</span>{' '}
|
||||
{formatNumber(bathrooms)}
|
||||
</div>
|
||||
)}
|
||||
{rooms !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Rooms:</span> {formatNumber(rooms)}
|
||||
|
|
@ -236,19 +296,30 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
{property.potential_energy_rating}
|
||||
</div>
|
||||
)}
|
||||
{councilTax !== undefined ? (
|
||||
{listingDate !== undefined && (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Council tax:</span> £
|
||||
{formatNumber(councilTax)}/yr
|
||||
<span className="text-warm-500 dark:text-warm-400">Listed:</span>{' '}
|
||||
{formatTransactionDate(listingDate)}
|
||||
</div>
|
||||
) : councilTaxD !== undefined ? (
|
||||
<div>
|
||||
<span className="text-warm-500 dark:text-warm-400">Council tax (D):</span> £
|
||||
{formatNumber(councilTaxD)}/yr
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.listing_features && property.listing_features.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Key features</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{property.listing_features.map((feature, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs bg-warm-100 dark:bg-warm-700 text-warm-700 dark:text-warm-300 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.renovation_history && property.renovation_history.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-warm-500 dark:text-warm-400 mb-1">Renovations</div>
|
||||
|
|
@ -265,6 +336,19 @@ function PropertyCard({ property }: { property: Property }) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{property.listing_url && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={property.listing_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
View external listing →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Slider } from '../ui/Slider';
|
|||
import { IconButton } from '../ui/IconButton';
|
||||
import { PlaceSearchInput } from '../ui/PlaceSearchInput';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { MapPinIcon } from '../ui/icons/MapPinIcon';
|
||||
import { RouteIcon } from '../ui/icons/RouteIcon';
|
||||
import { formatFilterValue } from '../../lib/format';
|
||||
|
|
@ -15,6 +16,8 @@ interface TravelTimeCardProps {
|
|||
label: string;
|
||||
timeRange: [number, number] | null;
|
||||
dataRange: [number, number] | null;
|
||||
isPinned: boolean;
|
||||
onTogglePin: () => void;
|
||||
onSetDestination: (slug: string, label: string) => void;
|
||||
onTimeRangeChange: (range: [number, number]) => void;
|
||||
onRemove: () => void;
|
||||
|
|
@ -26,6 +29,8 @@ export function TravelTimeCard({
|
|||
label,
|
||||
timeRange,
|
||||
dataRange,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onSetDestination,
|
||||
onTimeRangeChange,
|
||||
onRemove,
|
||||
|
|
@ -59,7 +64,7 @@ export function TravelTimeCard({
|
|||
const displayRange = timeRange ?? [sliderMin, sliderMax];
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-2 py-2 rounded ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20">
|
||||
<div className={`space-y-2 px-2 py-2 rounded ${isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -68,9 +73,16 @@ export function TravelTimeCard({
|
|||
Travel Time ({MODE_LABELS[mode]})
|
||||
</span>
|
||||
</div>
|
||||
<IconButton onClick={() => onRemove()} title="Remove travel time">
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{slug && (
|
||||
<IconButton onClick={onTogglePin} active={isPinned} title={isPinned ? 'Stop previewing' : 'Preview on map'}>
|
||||
<EyeIcon className="w-3.5 h-3.5" filled={isPinned} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton onClick={() => onRemove()} title="Remove travel time">
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destination search */}
|
||||
|
|
@ -81,6 +93,7 @@ export function TravelTimeCard({
|
|||
placeholder={slug ? 'Change destination...' : 'Search destination...'}
|
||||
size="xs"
|
||||
inputClassName="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-white dark:bg-warm-800 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400"
|
||||
portal
|
||||
/>
|
||||
|
||||
{slug && label && (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
|
|
@ -9,7 +9,7 @@ const FEATURES = [
|
|||
'56 data layers across England',
|
||||
'Every postcode scored and filterable',
|
||||
'Unlimited map exploration and exports',
|
||||
'Historical price data back to 1995',
|
||||
'Multiple decades of historical price data',
|
||||
'Crime, schools, transport, broadband & more',
|
||||
'All future data updates included',
|
||||
];
|
||||
|
|
@ -50,6 +50,23 @@ export default function PricingPage({
|
|||
}) {
|
||||
const license = useLicense();
|
||||
const [pricing, setPricing] = useState<PricingData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scrolledLeft, setScrolledLeft] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const activeCardRef = useRef<HTMLDivElement>(null);
|
||||
const onScroll = useCallback(() => {
|
||||
if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pricing || !scrollRef.current || !activeCardRef.current) return;
|
||||
if (currentTierIndex === 0) return;
|
||||
const container = scrollRef.current;
|
||||
const card = activeCardRef.current;
|
||||
const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2;
|
||||
container.scrollLeft = Math.max(0, scrollLeft);
|
||||
setScrolledLeft(container.scrollLeft > 0);
|
||||
}, [pricing, currentTierIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(apiUrl('pricing'))
|
||||
|
|
@ -58,7 +75,8 @@ export default function PricingPage({
|
|||
return res.json();
|
||||
})
|
||||
.then(setPricing)
|
||||
.catch((err) => console.error('Failed to load pricing:', err));
|
||||
.catch((err) => console.error('Failed to load pricing:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const isLicensed = user?.subscription === 'licensed' || user?.isAdmin;
|
||||
|
|
@ -80,170 +98,254 @@ export default function PricingPage({
|
|||
}
|
||||
}
|
||||
|
||||
const ctaButton = isLicensed ? (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="w-full mt-auto px-5 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
Open dashboard
|
||||
</button>
|
||||
) : user ? (
|
||||
<button
|
||||
onClick={() => license.startCheckout()}
|
||||
disabled={license.checkingOut}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{license.checkingOut && (
|
||||
<SpinnerIcon className="w-5 h-5 animate-spin" />
|
||||
)}
|
||||
{license.checkingOut
|
||||
? 'Redirecting...'
|
||||
: isFree
|
||||
? 'Claim free access'
|
||||
: `Get started — ${formatPrice(currentPrice)}`}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full mt-auto px-5 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
{isFree ? 'Claim free access' : 'Get started'}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
|
||||
<div className="max-w-3xl mx-auto px-6 py-16">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-navy-950 dark:text-warm-100 mb-3">
|
||||
Early access pricing
|
||||
</h1>
|
||||
<p className="text-lg text-warm-500 dark:text-warm-400 max-w-lg mx-auto">
|
||||
No subscriptions, no recurring fees. Pay once and get lifetime
|
||||
access to every feature. The earlier you join, the less you pay.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto bg-navy-950 relative">
|
||||
{/* Aurora — sized divs with oklch gradients, no blur */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden">
|
||||
{/* Green curtain — top left */}
|
||||
<div
|
||||
className="absolute w-[90vw] h-[80vh] -top-[10%] -left-[15%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.72 0.19 145 / 0.18) 0%, oklch(0.55 0.15 160 / 0.08) 50%, transparent 100%)',
|
||||
animation: 'aurora-1 20s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Teal sweep — center */}
|
||||
<div
|
||||
className="absolute w-[80vw] h-[70vh] top-[5%] left-[15%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.13 175 / 0.15) 0%, oklch(0.55 0.10 195 / 0.06) 50%, transparent 100%)',
|
||||
animation: 'aurora-2 18s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Purple curtain — right */}
|
||||
<div
|
||||
className="absolute w-[85vw] h-[90vh] -top-[5%] -right-[15%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.55 0.20 290 / 0.16) 0%, oklch(0.45 0.22 275 / 0.06) 50%, transparent 100%)',
|
||||
animation: 'aurora-4 25s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Deep violet — bottom right */}
|
||||
<div
|
||||
className="absolute w-[75vw] h-[70vh] -bottom-[5%] right-[5%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.22 300 / 0.13) 0%, oklch(0.50 0.20 285 / 0.05) 50%, transparent 100%)',
|
||||
animation: 'aurora-3 22s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Emerald — bottom left */}
|
||||
<div
|
||||
className="absolute w-[80vw] h-[75vh] -bottom-[10%] -left-[10%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.65 0.17 155 / 0.14) 0%, oklch(0.55 0.14 165 / 0.05) 50%, transparent 100%)',
|
||||
animation: 'aurora-5 24s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
{/* Cyan accent — upper center */}
|
||||
<div
|
||||
className="absolute w-[70vw] h-[60vh] top-[20%] left-[20%]"
|
||||
style={{
|
||||
background: 'radial-gradient(in oklch, ellipse closest-side, oklch(0.60 0.12 200 / 0.10) 0%, oklch(0.52 0.10 185 / 0.04) 50%, transparent 100%)',
|
||||
animation: 'aurora-1 16s ease-in-out infinite reverse',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md mx-auto bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
|
||||
{/* Price header */}
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-8 py-10 text-center">
|
||||
<div className="text-sm font-semibold text-teal-400 uppercase tracking-wide mb-2">
|
||||
Lifetime License
|
||||
</div>
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span className="text-5xl font-extrabold text-white">
|
||||
{pricing ? formatPrice(currentPrice) : '...'}
|
||||
</span>
|
||||
{!isFree && (
|
||||
<span className="text-warm-400 text-lg">/once</span>
|
||||
)}
|
||||
</div>
|
||||
{spotsRemaining > 0 && pricing && (
|
||||
<p className="text-teal-300 text-sm mt-2 font-medium">
|
||||
{spotsRemaining} spot{spotsRemaining !== 1 ? 's' : ''}{' '}
|
||||
remaining at this price
|
||||
</p>
|
||||
)}
|
||||
<p className="text-warm-300 text-sm mt-1">
|
||||
{isFree
|
||||
? 'Free for early adopters'
|
||||
: 'One-time payment, no subscription'}
|
||||
</p>
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pt-16 text-center mb-12">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
|
||||
Early access pricing
|
||||
</h1>
|
||||
<p className="text-lg text-warm-300 max-w-lg mx-auto">
|
||||
No subscriptions, no recurring fees. Pay once and get lifetime
|
||||
access to every feature. The earlier you join, the less you pay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-5xl mx-auto px-6 pb-16">
|
||||
{/* Tier cards — full viewport width carousel */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<SpinnerIcon className="w-8 h-8 animate-spin text-teal-400" />
|
||||
</div>
|
||||
) : pricing ? (
|
||||
<div className="relative mb-12" style={{ marginLeft: 'calc(-50vw + 50%)', marginRight: 'calc(-50vw + 50%)', width: '100vw' }}>
|
||||
{scrolledLeft && <div className="pointer-events-none absolute inset-y-0 left-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to right, black, transparent)' }} />}
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 z-10 backdrop-blur-sm" style={{ maskImage: 'linear-gradient(to left, black, transparent)' }} />
|
||||
<div ref={scrollRef} onScroll={onScroll} className="flex gap-6 overflow-x-auto px-6 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
|
||||
{pricing.tiers.map((tier, i) => {
|
||||
const isCurrent = i === currentTierIndex;
|
||||
const isFilled =
|
||||
tier.up_to !== null &&
|
||||
pricing.licensed_count >= tier.up_to;
|
||||
const filledInTier = isCurrent
|
||||
? pricing.licensed_count -
|
||||
(i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
|
||||
: 0;
|
||||
const tierSlots = tier.slots;
|
||||
const fillPercent = isFilled
|
||||
? 100
|
||||
: isCurrent && tierSlots > 0
|
||||
? (filledInTier / tierSlots) * 100
|
||||
: 0;
|
||||
|
||||
<div className="px-8 py-8">
|
||||
{/* Tier breakdown */}
|
||||
{pricing && (
|
||||
<div className="mb-8 space-y-1.5">
|
||||
<p className="text-xs font-semibold text-warm-500 dark:text-warm-400 uppercase tracking-wide mb-3">
|
||||
Pricing tiers
|
||||
</p>
|
||||
{pricing.tiers.map((tier, i) => {
|
||||
const isCurrent = i === currentTierIndex;
|
||||
const isFilled =
|
||||
tier.up_to !== null &&
|
||||
pricing.licensed_count >= tier.up_to;
|
||||
const filledInTier = isCurrent
|
||||
? pricing.licensed_count -
|
||||
(i > 0 ? (pricing.tiers[i - 1].up_to ?? 0) : 0)
|
||||
: 0;
|
||||
const tierSlots = tier.slots;
|
||||
const fillPercent = isFilled
|
||||
? 100
|
||||
: isCurrent && tierSlots > 0
|
||||
? (filledInTier / tierSlots) * 100
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
ref={isCurrent ? activeCardRef : undefined}
|
||||
className={`relative flex flex-col rounded-2xl border overflow-hidden w-80 shrink-0 ${
|
||||
isCurrent
|
||||
? 'border-teal-400 ring-2 ring-teal-400 shadow-lg'
|
||||
: 'border-warm-700 shadow-md'
|
||||
} ${isFilled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{isCurrent && (
|
||||
<div className="bg-teal-600 text-white text-center text-xs font-semibold uppercase tracking-wide py-1.5">
|
||||
Current tier
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`relative flex items-center justify-between px-3 py-2 rounded-lg text-sm ${
|
||||
<div
|
||||
className={`px-6 py-8 text-center ${
|
||||
isCurrent
|
||||
? 'bg-gradient-to-br from-navy-950 to-teal-900'
|
||||
: 'bg-white dark:bg-warm-800'
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-sm font-semibold uppercase tracking-wide mb-3 ${
|
||||
isCurrent
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 ring-1 ring-teal-400'
|
||||
: isFilled
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
? 'text-teal-300'
|
||||
: 'text-warm-500 dark:text-warm-400'
|
||||
}`}
|
||||
>
|
||||
{tierLabel(tier, i)}
|
||||
</p>
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span
|
||||
className={`${isCurrent ? 'text-navy-950 dark:text-warm-100 font-medium' : 'text-warm-600 dark:text-warm-400'}`}
|
||||
className={`text-4xl font-extrabold ${
|
||||
isCurrent
|
||||
? 'text-white'
|
||||
: isFilled
|
||||
? 'text-warm-400 dark:text-warm-500 line-through'
|
||||
: 'text-navy-950 dark:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{tierLabel(tier, i)}
|
||||
{formatPrice(tier.price_pence)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCurrent && tierSlots > 0 && (
|
||||
<div className="w-16 h-1.5 rounded-full bg-warm-200 dark:bg-warm-700 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-teal-500"
|
||||
style={{ width: `${fillPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{tier.price_pence > 0 && (
|
||||
<span
|
||||
className={`font-semibold ${
|
||||
className={`text-lg ${
|
||||
isCurrent
|
||||
? 'text-teal-700 dark:text-teal-400'
|
||||
: isFilled
|
||||
? 'text-warm-400 dark:text-warm-500 line-through'
|
||||
: 'text-warm-600 dark:text-warm-400'
|
||||
? 'text-warm-400'
|
||||
: 'text-warm-400 dark:text-warm-500'
|
||||
}`}
|
||||
>
|
||||
{formatPrice(tier.price_pence)}
|
||||
/lifetime
|
||||
</span>
|
||||
{isFilled && (
|
||||
<CheckIcon className="w-4 h-4 text-warm-400 dark:text-warm-500" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features list */}
|
||||
<ul className="space-y-4">
|
||||
{FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-3">
|
||||
<CheckIcon className="w-5 h-5 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{isCurrent && spotsRemaining > 0 && (
|
||||
<p className="text-teal-300 text-sm mt-2 font-medium">
|
||||
{spotsRemaining} spot
|
||||
{spotsRemaining !== 1 ? 's' : ''} remaining
|
||||
</p>
|
||||
)}
|
||||
{isFilled && (
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2 flex items-center justify-center gap-1">
|
||||
<CheckIcon className="w-4 h-4" /> Filled
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLicensed ? (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="w-full mt-8 px-6 py-4 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
|
||||
>
|
||||
Open dashboard
|
||||
</button>
|
||||
) : user ? (
|
||||
<button
|
||||
onClick={() => license.startCheckout()}
|
||||
disabled={license.checkingOut}
|
||||
className="w-full mt-8 px-6 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{license.checkingOut && (
|
||||
<SpinnerIcon className="w-5 h-5 animate-spin" />
|
||||
)}
|
||||
{license.checkingOut
|
||||
? 'Redirecting...'
|
||||
: isFree
|
||||
? 'Claim free license'
|
||||
: `Get started \u2014 ${formatPrice(currentPrice)}`}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full mt-8 px-6 py-4 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
>
|
||||
{isFree ? 'Claim free license' : 'Get started'}
|
||||
</button>
|
||||
)}
|
||||
{/* Progress bar for current tier */}
|
||||
{isCurrent && tierSlots > 0 && (
|
||||
<div className="h-1.5 bg-warm-200 dark:bg-warm-700">
|
||||
<div
|
||||
className="h-full bg-teal-500"
|
||||
style={{ width: `${fillPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{license.error && (
|
||||
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">
|
||||
{license.error}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-center text-sm text-warm-400 dark:text-warm-500 mt-3">
|
||||
{isFree
|
||||
? 'No credit card required'
|
||||
: '30-day money-back guarantee'}
|
||||
</p>
|
||||
<div className="flex-1 flex flex-col px-6 py-6 bg-white dark:bg-warm-800">
|
||||
<ul className="space-y-3 mb-6 flex-1">
|
||||
{FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5 text-sm">
|
||||
<CheckIcon className="w-4 h-4 text-teal-600 dark:text-teal-400 shrink-0 mt-0.5" />
|
||||
<span className="text-warm-700 dark:text-warm-300">
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{isCurrent ? (
|
||||
<>
|
||||
{ctaButton}
|
||||
{license.error && (
|
||||
<p className="mt-2 text-center text-sm text-red-600 dark:text-red-400">
|
||||
{license.error}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-center text-xs text-warm-400 dark:text-warm-500 mt-2">
|
||||
{isFree
|
||||
? 'No credit card required'
|
||||
: '30-day money-back guarantee'}
|
||||
</p>
|
||||
</>
|
||||
) : isFilled ? (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-400 dark:text-warm-500 rounded-lg font-semibold text-center">
|
||||
Sold out
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-auto px-5 py-3 bg-warm-100 dark:bg-warm-700 text-warm-500 dark:text-warm-400 rounded-lg font-semibold text-center">
|
||||
Upcoming
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-warm-400 py-16">
|
||||
Failed to load pricing. Please try again later.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||
import { shortenUrl } from '../../lib/api';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
import { TrashIcon } from '../ui/icons/TrashIcon';
|
||||
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { formatRelativeTime } from '../../lib/format';
|
||||
import { summarizeParams } from '../../lib/url-state';
|
||||
|
||||
export default function SavedSearchesPage({
|
||||
searches,
|
||||
loading,
|
||||
onDelete,
|
||||
onOpen,
|
||||
}: {
|
||||
searches: SavedSearch[];
|
||||
loading: boolean;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onOpen: (params: string) => void;
|
||||
}) {
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [sharingId, setSharingId] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
await onDelete(deleteConfirmId);
|
||||
setDeleteConfirmId(null);
|
||||
}, [deleteConfirmId, onDelete]);
|
||||
|
||||
const copyToClipboard = useCallback((text: string, id: string) => {
|
||||
const onSuccess = () => {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(onSuccess);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
onSuccess();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleShare = useCallback(async (params: string, id: string) => {
|
||||
setSharingId(id);
|
||||
try {
|
||||
const shortUrl = await shortenUrl(params);
|
||||
copyToClipboard(shortUrl, id);
|
||||
} catch {
|
||||
copyToClipboard(`${window.location.origin}/?${params}`, id);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
}, [copyToClipboard]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto bg-warm-50 dark:bg-warm-900">
|
||||
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-navy-950 dark:text-warm-100 mb-6">Saved Searches</h1>
|
||||
|
||||
{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="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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
|
||||
const FAQ_ITEMS = [
|
||||
{
|
||||
question: 'What data is included?',
|
||||
answer:
|
||||
'Perfect Postcode includes 56 data layers covering property prices, EPC energy ratings, crime statistics, school ratings, broadband speeds, transport links, road noise, deprivation indices, ethnicity data, and nearby points of interest. All data covers England.',
|
||||
},
|
||||
{
|
||||
question: 'What can I access on the free tier?',
|
||||
answer:
|
||||
'Free users can explore property data within inner London (roughly zones 1-2). To access data for the rest of England, you need a lifetime license.',
|
||||
},
|
||||
{
|
||||
question: 'What does "lifetime" mean?',
|
||||
answer:
|
||||
'Your license never expires. You pay once and get permanent access to all current features plus all future data updates. No recurring fees, no surprise charges.',
|
||||
},
|
||||
{
|
||||
question: 'Can I get a refund?',
|
||||
answer:
|
||||
'Yes! We offer a 30-day money-back guarantee. If you are not satisfied, email us at support@propertymap.co.uk within 30 days of purchase for a full refund.',
|
||||
},
|
||||
{
|
||||
question: 'How often is the data updated?',
|
||||
answer:
|
||||
'We update the data regularly as new Land Registry, EPC, crime, and other government datasets are published. Updates are typically quarterly. All updates are included with your license at no extra cost.',
|
||||
},
|
||||
];
|
||||
|
||||
function FAQItem({ question, answer }: { question: string; answer: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-b border-warm-200 dark:border-warm-700 last:border-b-0">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 text-left"
|
||||
>
|
||||
<span className="text-navy-950 dark:text-warm-100 font-medium pr-4">{question}</span>
|
||||
<ChevronIcon
|
||||
direction="down"
|
||||
className={`w-5 h-5 text-warm-400 dark:text-warm-500 shrink-0 transition-transform ${
|
||||
open ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-5 pb-4">
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm leading-relaxed">{answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SupportPage() {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-warm-50 dark:bg-navy-950">
|
||||
<div className="max-w-2xl mx-auto px-6 py-16">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-navy-950 dark:text-warm-100 mb-3">
|
||||
Support & FAQ
|
||||
</h1>
|
||||
<p className="text-lg text-warm-500 dark:text-warm-400">
|
||||
Have a question? Check below or reach out to us directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 mb-8 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">Need help? Email us at</p>
|
||||
<a
|
||||
href="mailto:support@propertymap.co.uk"
|
||||
className="text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 font-medium text-lg"
|
||||
>
|
||||
support@propertymap.co.uk
|
||||
</a>
|
||||
<p className="text-warm-400 dark:text-warm-500 text-sm mt-2">
|
||||
We typically respond within 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<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-4 border-b border-warm-200 dark:border-warm-700">
|
||||
<h2 className="text-lg font-semibold text-navy-950 dark:text-warm-100">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
</div>
|
||||
{FAQ_ITEMS.map((item) => (
|
||||
<FAQItem key={item.question} question={item.question} answer={item.answer} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
import { AppleIcon } from './icons/AppleIcon';
|
||||
|
||||
type View = 'login' | 'register' | 'forgot';
|
||||
|
||||
|
|
@ -134,15 +133,6 @@ export default function AuthModal({
|
|||
<GoogleIcon className="w-4 h-4" />
|
||||
Continue with Google
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuth('apple')}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded bg-navy-950 dark:bg-white text-white dark:text-navy-950 text-sm font-medium hover:bg-navy-900 dark:hover:bg-warm-100 disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
<AppleIcon className="w-4 h-4" />
|
||||
Continue with Apple
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
|||
import UserMenu from './UserMenu';
|
||||
import MobileMenu from './MobileMenu';
|
||||
|
||||
export type Page = 'home' | 'dashboard' | 'saved-searches' | 'learn' | 'pricing' | 'account' | 'invite' | 'support';
|
||||
export type Page = 'home' | 'dashboard' | 'learn' | 'pricing' | 'account' | 'invite';
|
||||
|
||||
export default function Header({
|
||||
activePage,
|
||||
|
|
@ -127,21 +127,20 @@ export default function Header({
|
|||
</button>
|
||||
{user && (
|
||||
<button
|
||||
className={tabClass('saved-searches')}
|
||||
onClick={() => onPageChange('saved-searches')}
|
||||
className={tabClass('account')}
|
||||
onClick={() => onPageChange('account')}
|
||||
>
|
||||
Saved
|
||||
Account
|
||||
</button>
|
||||
)}
|
||||
<button className={tabClass('learn')} onClick={() => onPageChange('learn')}>
|
||||
Learn
|
||||
</button>
|
||||
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
|
||||
Pricing
|
||||
</button>
|
||||
<button className={tabClass('support')} onClick={() => onPageChange('support')}>
|
||||
Support
|
||||
</button>
|
||||
{user?.subscription !== 'licensed' && !user?.isAdmin && (
|
||||
<button className={tabClass('pricing')} onClick={() => onPageChange('pricing')}>
|
||||
Pricing
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -203,7 +202,7 @@ export default function Header({
|
|||
{!isMobile && (
|
||||
<>
|
||||
{user ? (
|
||||
<UserMenu user={user} onLogout={onLogout} onPageChange={onPageChange} />
|
||||
<UserMenu user={user} onLogout={onLogout} />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
interface LabelProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Label({ children, className }: LabelProps) {
|
||||
return (
|
||||
<label className={`text-sm font-medium text-warm-700 dark:text-warm-300 ${className || ''}`}>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ interface LicenseSuccessModalProps {
|
|||
}
|
||||
|
||||
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
|
||||
// Generate confetti particles once
|
||||
const particles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 40 }, (_, i) => ({
|
||||
|
|
@ -17,11 +16,11 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
|||
Math.floor(Math.random() * 6)
|
||||
],
|
||||
size: 6 + Math.random() * 6,
|
||||
isCircle: Math.random() > 0.5,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
// Auto-dismiss after 8 seconds
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 8000);
|
||||
return () => clearTimeout(timer);
|
||||
|
|
@ -29,7 +28,6 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
{/* Confetti */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
|
|
@ -41,7 +39,7 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
|||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
backgroundColor: p.color,
|
||||
borderRadius: Math.random() > 0.5 ? '50%' : '2px',
|
||||
borderRadius: p.isCircle ? '50%' : '2px',
|
||||
animationDelay: `${p.delay}s`,
|
||||
animationDuration: `${p.duration}s`,
|
||||
}}
|
||||
|
|
@ -49,13 +47,12 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
|
||||
<div className="text-5xl mb-3">🎉</div>
|
||||
<h2 className="text-2xl font-bold text-white">Welcome aboard!</h2>
|
||||
<p className="text-warm-300 text-sm mt-2">
|
||||
Your lifetime license is now active.
|
||||
Your lifetime access is now active.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
|
|
@ -71,7 +68,6 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* CSS animation for confetti */}
|
||||
<style>{`
|
||||
@keyframes confetti-fall {
|
||||
0% {
|
||||
|
|
|
|||
|
|
@ -80,10 +80,8 @@ export default function MobileMenu({
|
|||
<nav className="flex-1 flex flex-col gap-1 p-3 overflow-y-auto">
|
||||
{mobileNavItem('home', 'Home')}
|
||||
{mobileNavItem('dashboard', 'Dashboard')}
|
||||
{user && mobileNavItem('saved-searches', 'Saved')}
|
||||
{mobileNavItem('learn', 'Learn')}
|
||||
{mobileNavItem('pricing', 'Pricing')}
|
||||
{mobileNavItem('support', 'Support')}
|
||||
{user?.subscription !== 'licensed' && !user?.isAdmin && mobileNavItem('pricing', 'Pricing')}
|
||||
{user && mobileNavItem('account', 'Account')}
|
||||
|
||||
{/* Dashboard actions */}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useRef, useCallback, useLayoutEffect, useState as useStateR } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type React from 'react';
|
||||
import type { SearchResult } from '../../hooks/useLocationSearch';
|
||||
import { SearchIcon } from './icons/SearchIcon';
|
||||
|
|
@ -26,6 +28,33 @@ interface PlaceSearchInputProps {
|
|||
inputClassName?: string;
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
onInputChange?: () => void;
|
||||
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({
|
||||
|
|
@ -37,13 +66,76 @@ export function PlaceSearchInput({
|
|||
inputClassName,
|
||||
inputRef,
|
||||
onInputChange,
|
||||
portal,
|
||||
}: PlaceSearchInputProps) {
|
||||
const sm = size === 'sm';
|
||||
const iconSize = sm ? 'w-4 h-4' : 'w-3 h-3';
|
||||
const spinnerSize = sm ? 'w-4 h-4' : 'w-3 h-3';
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownPos = useDropdownPosition(wrapperRef, portal ? search.open : false);
|
||||
|
||||
const showDropdown = search.open && search.results.length > 0;
|
||||
|
||||
const dropdown = showDropdown && (
|
||||
<div
|
||||
className={`bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto`}
|
||||
style={
|
||||
portal && dropdownPos
|
||||
? { position: 'fixed', top: dropdownPos.top, left: dropdownPos.left, width: dropdownPos.width, zIndex: 50 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{search.results.map((result, idx) => (
|
||||
<button
|
||||
key={
|
||||
result.type === 'postcode'
|
||||
? `pc-${result.label}`
|
||||
: `pl-${result.name}-${result.lat}`
|
||||
}
|
||||
type="button"
|
||||
className={`w-full text-left flex items-center cursor-pointer ${
|
||||
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
|
||||
} ${
|
||||
idx === search.activeIndex
|
||||
? 'bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
|
||||
}`}
|
||||
onMouseEnter={() => search.setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(result);
|
||||
}}
|
||||
>
|
||||
{result.type === 'postcode' ? (
|
||||
<>
|
||||
<SearchIcon
|
||||
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
|
||||
/>
|
||||
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPinIcon
|
||||
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
|
||||
/>
|
||||
<span className="text-warm-700 dark:text-warm-200">
|
||||
{result.name}
|
||||
{result.city && (
|
||||
<span className="text-warm-400 dark:text-warm-500">
|
||||
{' '}
|
||||
({result.city})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<div ref={wrapperRef} className="relative flex-1 min-w-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
|
|
@ -66,57 +158,9 @@ export function PlaceSearchInput({
|
|||
/>
|
||||
)}
|
||||
|
||||
{search.open && search.results.length > 0 && (
|
||||
<div
|
||||
className={`absolute top-full left-0 right-0 mt-1 bg-white dark:bg-warm-800 rounded shadow-lg border border-warm-200 dark:border-warm-700 ${sm ? 'max-h-64' : 'max-h-48'} overflow-y-auto z-20`}
|
||||
>
|
||||
{search.results.map((result, idx) => (
|
||||
<button
|
||||
key={
|
||||
result.type === 'postcode'
|
||||
? `pc-${result.label}`
|
||||
: `pl-${result.name}-${result.lat}`
|
||||
}
|
||||
type="button"
|
||||
className={`w-full text-left flex items-center cursor-pointer ${
|
||||
sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs'
|
||||
} ${
|
||||
idx === search.activeIndex
|
||||
? 'bg-teal-50 dark:bg-teal-900/30'
|
||||
: 'hover:bg-warm-50 dark:hover:bg-warm-700'
|
||||
}`}
|
||||
onMouseEnter={() => search.setActiveIndex(idx)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(result);
|
||||
}}
|
||||
>
|
||||
{result.type === 'postcode' ? (
|
||||
<>
|
||||
<SearchIcon
|
||||
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
|
||||
/>
|
||||
<span className="text-warm-700 dark:text-warm-200">{result.label}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MapPinIcon
|
||||
className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`}
|
||||
/>
|
||||
<span className="text-warm-700 dark:text-warm-200">
|
||||
{result.name}
|
||||
{result.city && (
|
||||
<span className="text-warm-400 dark:text-warm-500">
|
||||
{' '}
|
||||
({result.city})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showDropdown && (portal
|
||||
? createPortal(dropdown, document.body)
|
||||
: <div className="absolute top-full left-0 right-0 mt-1 z-20">{dropdown}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface UpgradeModalProps {
|
|||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onStartCheckout: () => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
onZoomToFreeZone: () => void;
|
||||
}
|
||||
|
||||
export default function UpgradeModal({
|
||||
|
|
@ -16,7 +16,7 @@ export default function UpgradeModal({
|
|||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onStartCheckout,
|
||||
onDismiss,
|
||||
onZoomToFreeZone,
|
||||
}: UpgradeModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -56,7 +56,7 @@ export default function UpgradeModal({
|
|||
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
onClick={onZoomToFreeZone}
|
||||
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
>
|
||||
<CloseIcon className="w-5 h-5" />
|
||||
|
|
@ -96,7 +96,7 @@ export default function UpgradeModal({
|
|||
{loading
|
||||
? 'Redirecting...'
|
||||
: isFree
|
||||
? 'Claim free license'
|
||||
? 'Claim free access'
|
||||
: `Upgrade for ${priceLabel}`}
|
||||
</button>
|
||||
) : (
|
||||
|
|
@ -121,10 +121,10 @@ export default function UpgradeModal({
|
|||
)}
|
||||
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
onClick={onZoomToFreeZone}
|
||||
className="w-full mt-4 text-center text-sm text-warm-400 dark:text-warm-500 hover:text-warm-600 dark:hover:text-warm-400"
|
||||
>
|
||||
Or zoom back into London
|
||||
Or zoom back to demo area
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import type { Page } from './Header';
|
||||
|
||||
export default function UserMenu({
|
||||
user,
|
||||
onLogout,
|
||||
onPageChange,
|
||||
}: {
|
||||
user: AuthUser;
|
||||
onLogout: () => void;
|
||||
onPageChange: (page: Page) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -46,15 +43,6 @@ export default function UserMenu({
|
|||
</p>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onPageChange('account');
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded"
|
||||
>
|
||||
Account
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AppleIcon({ className = 'w-4 h-4' }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, HexagonStatsResponse } from '../types';
|
||||
import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
|
||||
|
||||
interface UseAreaSummaryOptions {
|
||||
stats: HexagonStatsResponse | null;
|
||||
hexagonId: string | null;
|
||||
isPostcode: boolean;
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
}
|
||||
|
||||
interface UseAreaSummaryResult {
|
||||
summary: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const FORBIDDEN_FEATURES = [
|
||||
'% White',
|
||||
'% Black',
|
||||
'% Asian',
|
||||
'% Mixed',
|
||||
'% Other',
|
||||
'Environmental risk',
|
||||
'Collapsible deposits risk',
|
||||
'Compressible ground risk',
|
||||
'Landslide risk',
|
||||
'Running sand risk',
|
||||
'Shrink-swell risk',
|
||||
'Soluble rocks risk',
|
||||
];
|
||||
|
||||
export function useAreaSummary({
|
||||
stats,
|
||||
hexagonId,
|
||||
isPostcode,
|
||||
filters,
|
||||
features,
|
||||
}: UseAreaSummaryOptions): UseAreaSummaryResult {
|
||||
const [summary, setSummary] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
if (!stats || !hexagonId) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setSummary('');
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const filterDescriptions: string[] = [];
|
||||
for (const [name, value] of Object.entries(filters)) {
|
||||
const meta = features.find((f) => f.name === name);
|
||||
if (meta?.type === 'enum') {
|
||||
filterDescriptions.push(`${name}: ${(value as string[]).join(', ')}`);
|
||||
} else {
|
||||
const [min, max] = value as [number, number];
|
||||
filterDescriptions.push(`${name}: ${min}–${max}`);
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
count: stats.count,
|
||||
location: hexagonId,
|
||||
is_postcode: isPostcode,
|
||||
filters: filterDescriptions,
|
||||
numeric_stats: stats.numeric_features
|
||||
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
mean: f.mean,
|
||||
})),
|
||||
enum_stats: stats.enum_features
|
||||
.filter((f) => !FORBIDDEN_FEATURES.includes(f.name))
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
counts: f.counts,
|
||||
})),
|
||||
};
|
||||
|
||||
const url = apiUrl('area-summary');
|
||||
const response = await fetch(
|
||||
url,
|
||||
authHeaders({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
setSummary(json.summary || '');
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
logNonAbortError('area-summary', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate summary');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stats, hexagonId, isPostcode, filters, features]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [fetchSummary]);
|
||||
|
||||
return { summary, loading, error };
|
||||
}
|
||||
|
|
@ -46,10 +46,9 @@ interface UseDeckLayersProps {
|
|||
selectedPostcodeGeometry?: PostcodeGeometry | null;
|
||||
bounds?: Bounds | null;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
travelTimeColorRanges?: Map<number, [number, number]>;
|
||||
}
|
||||
|
||||
export interface PopupInfo {
|
||||
interface PopupInfo {
|
||||
x: number;
|
||||
y: number;
|
||||
name: string;
|
||||
|
|
@ -57,17 +56,6 @@ export interface PopupInfo {
|
|||
id: string;
|
||||
}
|
||||
|
||||
/** Find the primary travel time entry: first entry with a slug and color range. */
|
||||
function getPrimaryTravelIndex(
|
||||
entries: TravelTimeEntry[],
|
||||
colorRanges: Map<number, [number, number]>
|
||||
): number {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (entries[i].slug && colorRanges.has(i)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function useDeckLayers({
|
||||
data,
|
||||
postcodeData,
|
||||
|
|
@ -85,7 +73,6 @@ export function useDeckLayers({
|
|||
selectedPostcodeGeometry,
|
||||
bounds: viewportBounds,
|
||||
travelTimeEntries = [],
|
||||
travelTimeColorRanges = new Map(),
|
||||
}: UseDeckLayersProps) {
|
||||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
|
@ -124,15 +111,6 @@ export function useDeckLayers({
|
|||
|
||||
const travelTimeEntriesRef = useRef(travelTimeEntries);
|
||||
travelTimeEntriesRef.current = travelTimeEntries;
|
||||
const travelTimeColorRangesRef = useRef(travelTimeColorRanges);
|
||||
travelTimeColorRangesRef.current = travelTimeColorRanges;
|
||||
|
||||
const primaryTravelIndex = useMemo(
|
||||
() => getPrimaryTravelIndex(travelTimeEntries, travelTimeColorRanges),
|
||||
[travelTimeEntries, travelTimeColorRanges]
|
||||
);
|
||||
const primaryTravelIndexRef = useRef(primaryTravelIndex);
|
||||
primaryTravelIndexRef.current = primaryTravelIndex;
|
||||
|
||||
const colorFeatureMeta = useMemo(
|
||||
() => (viewFeature ? features.find((f) => f.name === viewFeature) || null : null),
|
||||
|
|
@ -263,11 +241,10 @@ export function useDeckLayers({
|
|||
const parts: string[] = [];
|
||||
for (let i = 0; i < travelTimeEntries.length; i++) {
|
||||
const entry = travelTimeEntries[i];
|
||||
const cr = travelTimeColorRanges.get(i);
|
||||
parts.push(`${i}:${entry.slug}|${cr?.[0]}|${cr?.[1]}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
|
||||
parts.push(`${i}:${entry.slug}|${entry.timeRange?.[0]}|${entry.timeRange?.[1]}`);
|
||||
}
|
||||
return parts.join(';');
|
||||
}, [travelTimeEntries, travelTimeColorRanges]);
|
||||
}, [travelTimeEntries]);
|
||||
|
||||
const colorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${countRange.min}|${countRange.max}|${selectedHexagonId}|${hoveredHexagonId}|${theme}|${ttTrigger}`;
|
||||
const postcodeColorTrigger = `${viewFeature}|${colorRange?.[0]}|${colorRange?.[1]}|${filterRange?.[0]}|${filterRange?.[1]}|${postcodeCountRange.min}|${postcodeCountRange.max}|${hoveredPostcode}|${theme}|${ttTrigger}`;
|
||||
|
|
@ -281,37 +258,36 @@ export function useDeckLayers({
|
|||
getHexagon: (d) => d.h3,
|
||||
getFillColor: (d) => {
|
||||
const dark = isDarkRef.current;
|
||||
const pti = primaryTravelIndexRef.current;
|
||||
const entries = travelTimeEntriesRef.current;
|
||||
const colorRanges = travelTimeColorRangesRef.current;
|
||||
|
||||
// Travel time coloring: primary entry colors, others dim-filter
|
||||
if (pti >= 0) {
|
||||
const primaryEntry = entries[pti];
|
||||
const fieldKey = travelFieldKey(primaryEntry);
|
||||
const ttVal = d[`avg_${fieldKey}`];
|
||||
const ttClr = colorRanges.get(pti);
|
||||
if (ttVal == null) {
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
// Dim-filter: all travel entries with timeRange dim hexagons outside range
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry.timeRange || !entry.slug) continue;
|
||||
const fk = travelFieldKey(entry);
|
||||
const modeVal = d[`avg_${fk}`];
|
||||
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
|
||||
}
|
||||
}
|
||||
|
||||
// Check all entries with time ranges as filters
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry.timeRange || !entry.slug) continue;
|
||||
const fk = travelFieldKey(entry);
|
||||
const modeVal = d[`avg_${fk}`];
|
||||
if (modeVal == null || (modeVal as number) < entry.timeRange[0] || (modeVal as number) > entry.timeRange[1]) {
|
||||
return (dark ? [60, 55, 50, 60] : [180, 180, 180, 60]) as [number, number, number, number];
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
|
||||
if (vf && clr) {
|
||||
// Travel time feature: dim hexagons with no data
|
||||
if (vf.startsWith('tt_')) {
|
||||
const ttVal = d[`avg_${vf}`];
|
||||
if (ttVal == null) {
|
||||
return (dark ? [80, 70, 65, 80] : [128, 128, 128, 80]) as [number, number, number, number];
|
||||
}
|
||||
}
|
||||
|
||||
if (ttClr) {
|
||||
return getFeatureFillColor(
|
||||
ttVal as number,
|
||||
ttVal as number,
|
||||
ttVal as number,
|
||||
ttClr,
|
||||
clr,
|
||||
null,
|
||||
0,
|
||||
densityGradientRef.current,
|
||||
|
|
@ -319,27 +295,27 @@ export function useDeckLayers({
|
|||
255
|
||||
);
|
||||
}
|
||||
|
||||
// Regular feature
|
||||
if (cfm) {
|
||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||
const minVal = d[`min_${vf}`] as number | undefined;
|
||||
const maxVal = d[`max_${vf}`] as number | undefined;
|
||||
return getFeatureFillColor(
|
||||
val as number | null | undefined,
|
||||
minVal,
|
||||
maxVal,
|
||||
clr,
|
||||
fr,
|
||||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
255
|
||||
);
|
||||
}
|
||||
}
|
||||
const vf = viewFeatureRef.current;
|
||||
const clr = colorRangeRef.current;
|
||||
const fr = filterRangeRef.current;
|
||||
const cfm = colorFeatureMetaRef.current;
|
||||
if (vf && clr && cfm) {
|
||||
const val = d[`avg_${vf}`] ?? d[`min_${vf}`];
|
||||
const minVal = d[`min_${vf}`] as number | undefined;
|
||||
const maxVal = d[`max_${vf}`] as number | undefined;
|
||||
return getFeatureFillColor(
|
||||
val as number | null | undefined,
|
||||
minVal,
|
||||
maxVal,
|
||||
clr,
|
||||
fr,
|
||||
0,
|
||||
densityGradientRef.current,
|
||||
dark,
|
||||
255
|
||||
);
|
||||
}
|
||||
|
||||
// Density fallback
|
||||
const cr = countRangeRef.current;
|
||||
const c = d.count as number;
|
||||
const t = (c - cr.min) / (cr.max - cr.min);
|
||||
|
|
@ -560,6 +536,5 @@ export function useDeckLayers({
|
|||
colorFeatureMeta,
|
||||
handleMouseLeave,
|
||||
hoveredPostcode,
|
||||
primaryTravelIndex,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,10 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
setPinnedFeature((prev) => (prev === name ? null : name));
|
||||
}, []);
|
||||
|
||||
const handleSetPin = useCallback((name: string) => {
|
||||
setPinnedFeature(name);
|
||||
}, []);
|
||||
|
||||
const handleCancelPin = useCallback(() => {
|
||||
setPinnedFeature(null);
|
||||
}, []);
|
||||
|
|
@ -158,6 +162,7 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
handleDragChange,
|
||||
handleDragEnd,
|
||||
handleTogglePin,
|
||||
handleSetPin,
|
||||
handleCancelPin,
|
||||
updateBoundsInfo,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { authHeaders, logNonAbortError } from '../lib/api';
|
|||
|
||||
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
|
||||
|
||||
export function looksLikePostcode(s: string) {
|
||||
function looksLikePostcode(s: string) {
|
||||
return POSTCODE_RE.test(s.trim());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
|
||||
import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts';
|
||||
import { type TravelTimeEntry, travelFieldKey } from './useTravelTime';
|
||||
import { type TravelTimeEntry } from './useTravelTime';
|
||||
|
||||
/** Return the p-th percentile (0–100) from a sorted array via linear interpolation. */
|
||||
function percentile(sorted: number[], p: number): number {
|
||||
|
|
@ -107,7 +107,7 @@ export function useMapData({
|
|||
if (usePostcodeView) {
|
||||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', viewFeature || '');
|
||||
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
||||
const res = await fetch(
|
||||
apiUrl('postcodes', params),
|
||||
authHeaders({
|
||||
|
|
@ -133,7 +133,7 @@ export function useMapData({
|
|||
bounds: boundsStr,
|
||||
});
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
params.set('fields', viewFeature || '');
|
||||
params.set('fields', viewFeature && !viewFeature.startsWith('tt_') ? viewFeature : '');
|
||||
if (travelParam) {
|
||||
params.set('travel', travelParam);
|
||||
}
|
||||
|
|
@ -176,14 +176,18 @@ export function useMapData({
|
|||
// Compute p5/p95 from visible data for the viewed feature
|
||||
const dataRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta || meta.type === 'enum') return null;
|
||||
|
||||
if (activeFeature && !dragData) return null;
|
||||
const isTravelTime = viewFeature.startsWith('tt_');
|
||||
|
||||
if (!isTravelTime) {
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta || meta.type === 'enum') return null;
|
||||
if (activeFeature && !dragData) return null;
|
||||
}
|
||||
|
||||
const vals: number[] = [];
|
||||
|
||||
if (usePostcodeView) {
|
||||
if (usePostcodeView && !isTravelTime) {
|
||||
if (postcodeData.length === 0) return null;
|
||||
for (const feat of postcodeData) {
|
||||
if (bounds) {
|
||||
|
|
@ -218,6 +222,12 @@ export function useMapData({
|
|||
// Color range for the legend and hex coloring
|
||||
const colorRange = useMemo((): [number, number] | null => {
|
||||
if (!viewFeature) return null;
|
||||
|
||||
// Travel time keys: use dataRange directly (no FeatureMeta)
|
||||
if (viewFeature.startsWith('tt_')) {
|
||||
return dataRange;
|
||||
}
|
||||
|
||||
const meta = features.find((f) => f.name === viewFeature);
|
||||
if (!meta) return null;
|
||||
if (meta.type === 'enum' && meta.values && meta.values.length > 0) {
|
||||
|
|
@ -229,33 +239,6 @@ export function useMapData({
|
|||
return null;
|
||||
}, [viewFeature, features, dataRange, activeFeature, dragValue]);
|
||||
|
||||
// Color ranges for travel time per entry (computed from response data)
|
||||
const travelTimeColorRanges = useMemo((): Map<number, [number, number]> => {
|
||||
const ranges = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < travelTimeEntries.length; i++) {
|
||||
const entry = travelTimeEntries[i];
|
||||
if (!entry.slug) continue;
|
||||
const fieldName = `avg_${travelFieldKey(entry)}`;
|
||||
const vals: number[] = [];
|
||||
for (const item of data) {
|
||||
if (bounds) {
|
||||
const { lat, lon } = item;
|
||||
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
|
||||
continue;
|
||||
}
|
||||
const val = item[fieldName];
|
||||
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
|
||||
}
|
||||
if (vals.length === 0) continue;
|
||||
vals.sort((a, b) => a - b);
|
||||
ranges.set(i, [
|
||||
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
|
||||
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
|
||||
]);
|
||||
}
|
||||
return ranges;
|
||||
}, [travelTimeEntries, data, bounds]);
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
({
|
||||
resolution: newRes,
|
||||
|
|
@ -295,7 +278,6 @@ export function useMapData({
|
|||
currentView,
|
||||
usePostcodeView,
|
||||
colorRange,
|
||||
travelTimeColorRanges,
|
||||
handleViewChange,
|
||||
setInitialView,
|
||||
licenseRequired,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
import { apiUrl } from '../lib/api';
|
||||
import { apiUrl, authHeaders } from '../lib/api';
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
|
|
@ -53,7 +53,7 @@ export function useSavedSearches(userId: string | null) {
|
|||
|
||||
// Capture a screenshot via the screenshot endpoint
|
||||
const screenshotUrl = apiUrl('screenshot', new URLSearchParams(params));
|
||||
const screenshotRes = await fetch(screenshotUrl);
|
||||
const screenshotRes = await fetch(screenshotUrl, authHeaders());
|
||||
if (!screenshotRes.ok) {
|
||||
throw new Error(`Screenshot failed: ${screenshotRes.status} ${screenshotRes.statusText}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,6 @@ export interface TravelTimeEntry {
|
|||
timeRange: [number, number] | null;
|
||||
}
|
||||
|
||||
/** Unique key for a travel time entry */
|
||||
export function travelEntryKey(entry: TravelTimeEntry): string {
|
||||
return `${entry.mode}:${entry.slug}`;
|
||||
}
|
||||
|
||||
/** Field key matching the backend response: tt_{mode}_{slug} */
|
||||
export function travelFieldKey(entry: TravelTimeEntry): string {
|
||||
return `tt_${entry.mode}_${entry.slug}`;
|
||||
|
|
|
|||
|
|
@ -124,6 +124,36 @@ h3 {
|
|||
transition-delay: 0.2s, 0s;
|
||||
}
|
||||
|
||||
/* Aurora gradient animation for pricing hero */
|
||||
@keyframes aurora-1 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(30px, -20px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 15px) scale(0.9); }
|
||||
}
|
||||
|
||||
@keyframes aurora-2 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(-40px, 20px) scale(1.15); }
|
||||
66% { transform: translate(25px, -30px) scale(0.95); }
|
||||
}
|
||||
|
||||
@keyframes aurora-3 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
|
||||
50% { transform: translate(20px, 25px) scale(1.1) rotate(3deg); }
|
||||
}
|
||||
|
||||
@keyframes aurora-4 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
|
||||
40% { transform: translate(-35px, -15px) scale(1.2) rotate(-2deg); }
|
||||
70% { transform: translate(15px, 20px) scale(0.9) rotate(1deg); }
|
||||
}
|
||||
|
||||
@keyframes aurora-5 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
30% { transform: translate(25px, 30px) scale(1.15); }
|
||||
60% { transform: translate(-30px, -10px) scale(0.95); }
|
||||
}
|
||||
|
||||
/* Hide scrollbar for pill groups on mobile */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
|
|
|
|||
|
|
@ -13,10 +13,13 @@ export const MAP_MIN_ZOOM = 5.5;
|
|||
|
||||
export const BUFFER_MULTIPLIER = 1.5;
|
||||
|
||||
/** Inner London free zone bounds (south, west, north, east) — must match server FREE_ZONE_BOUNDS */
|
||||
export const FREE_ZONE_BOUNDS = { south: 51.48, west: -0.18, north: 51.54, east: -0.02 };
|
||||
|
||||
export const INITIAL_VIEW_STATE: ViewState = {
|
||||
longitude: -1.5,
|
||||
latitude: 53.5,
|
||||
zoom: 6,
|
||||
longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2,
|
||||
latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2,
|
||||
zoom: 14,
|
||||
pitch: 0,
|
||||
};
|
||||
|
||||
|
|
@ -42,7 +45,7 @@ export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[]
|
|||
{ t: 1, color: [142, 68, 173] },
|
||||
];
|
||||
|
||||
/** Property density gradient — light mode (cream → orange) */
|
||||
/** Number of properties gradient — light mode (cream → orange) */
|
||||
export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [255, 255, 255] },
|
||||
{ t: 0.1, color: [248, 233, 211] },
|
||||
|
|
@ -51,7 +54,7 @@ export const DENSITY_GRADIENT: { t: number; color: [number, number, number] }[]
|
|||
{ t: 1, color: [255, 162, 31] },
|
||||
];
|
||||
|
||||
/** Property density gradient — dark mode (dark warm → bright amber) */
|
||||
/** Number of properties gradient — dark mode (dark warm → bright amber) */
|
||||
export const DENSITY_GRADIENT_DARK: { t: number; color: [number, number, number] }[] = [
|
||||
{ t: 0, color: [55, 45, 35] },
|
||||
{ t: 0.1, color: [85, 65, 40] },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export interface ValueFormat {
|
||||
interface ValueFormat {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
/** Show full integer (no k/M abbreviation) */
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function getMapStyle(theme: 'light' | 'dark'): StyleSpecification {
|
|||
} as StyleSpecification;
|
||||
}
|
||||
|
||||
export type GradientStop = { t: number; color: [number, number, number] };
|
||||
type GradientStop = { t: number; color: [number, number, number] };
|
||||
|
||||
// Oklab color space for perceptually uniform interpolation
|
||||
function srgbToLinear(c: number): number {
|
||||
|
|
@ -130,11 +130,11 @@ function interpolateGradient(t: number, gradient: GradientStop[]): [number, numb
|
|||
return gradient[gradient.length - 1].color;
|
||||
}
|
||||
|
||||
export function normalizedToColor(t: number): [number, number, number] {
|
||||
function normalizedToColor(t: number): [number, number, number] {
|
||||
return interpolateGradient(t, FEATURE_GRADIENT);
|
||||
}
|
||||
|
||||
export function countToColor(
|
||||
function countToColor(
|
||||
t: number,
|
||||
gradient: GradientStop[] = DENSITY_GRADIENT
|
||||
): [number, number, number] {
|
||||
|
|
|
|||
|
|
@ -123,6 +123,10 @@ export interface Property {
|
|||
duration?: string;
|
||||
current_energy_rating?: string;
|
||||
potential_energy_rating?: string;
|
||||
listing_status?: string;
|
||||
listing_url?: string;
|
||||
property_sub_type?: string;
|
||||
price_qualifier?: string;
|
||||
|
||||
// Numeric fields
|
||||
lat: number;
|
||||
|
|
@ -130,9 +134,10 @@ export interface Property {
|
|||
|
||||
is_construction_date_approximate?: boolean;
|
||||
renovation_history?: RenovationEvent[];
|
||||
listing_features?: string[];
|
||||
|
||||
// All other numeric features (dynamic, including construction_age_band)
|
||||
[key: string]: string | number | boolean | RenovationEvent[] | undefined;
|
||||
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface HexagonPropertiesResponse {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ module.exports = (env, argv) => {
|
|||
},
|
||||
proxy: [
|
||||
{
|
||||
context: ['/api'],
|
||||
context: ['/api', '/s'],
|
||||
target: process.env.API_PROXY_TARGET || 'http://localhost:8001',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue