deploy
This commit is contained in:
parent
273d7a83ee
commit
084117cea8
48 changed files with 2283 additions and 890 deletions
|
|
@ -12,16 +12,16 @@ import {
|
|||
} from './lib/seoRoutes';
|
||||
import Header, { type Page } from './components/ui/Header';
|
||||
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
||||
import { fetchWithRetry, apiUrl } from './lib/api';
|
||||
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
||||
import { trackEvent } from './lib/analytics';
|
||||
import { parseUrlState } from './lib/url-state';
|
||||
import { INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { useLicense } from './hooks/useLicense';
|
||||
import { useTelemetry } from './hooks/useTelemetry';
|
||||
import { useSavedSearches } from './hooks/useSavedSearches';
|
||||
import { useSavedProperties } from './hooks/useSavedProperties';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -39,9 +39,6 @@ const AccountPage = lazy(() => import('./components/account/AccountPage'));
|
|||
const SavedPage = lazy(() =>
|
||||
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
||||
);
|
||||
const InvitesPage = lazy(() =>
|
||||
import('./components/account/AccountPage').then((module) => ({ default: module.InvitesPage }))
|
||||
);
|
||||
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
|
||||
const MapPage = lazy(() => import('./components/map/MapPage'));
|
||||
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
|
||||
|
|
@ -52,6 +49,49 @@ function PageFallback() {
|
|||
return <div className="flex-1 bg-warm-50 dark:bg-navy-950" />;
|
||||
}
|
||||
|
||||
interface RouteMatch {
|
||||
page: Page;
|
||||
inviteCode?: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
type PostAuthIntent = 'checkout';
|
||||
type LicenseSuccessStatus = 'hidden' | 'verifying' | 'success' | 'delayed';
|
||||
|
||||
const LICENSE_VERIFICATION_ATTEMPTS = 8;
|
||||
const LICENSE_VERIFICATION_DELAY_MS = 1500;
|
||||
|
||||
function hasFullAccess(user?: { subscription?: string; isAdmin?: boolean } | null): boolean {
|
||||
return user?.subscription === 'licensed' || user?.isAdmin === true;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function normalizeHash(hash?: string | null): string {
|
||||
return hash?.replace(/^#/, '') ?? '';
|
||||
}
|
||||
|
||||
function currentRelativePath(): string {
|
||||
return `${window.location.pathname}${window.location.search}`;
|
||||
}
|
||||
|
||||
function isProtectedPage(page: Page): boolean {
|
||||
return page === 'account' || page === 'saved';
|
||||
}
|
||||
|
||||
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
|
||||
const normalizedHash = normalizeHash(hash);
|
||||
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
|
||||
}
|
||||
|
||||
function scrollToHash(hash: string) {
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById(hash)?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
function unavailableAuthAction(): never {
|
||||
throw new Error('Authentication actions are not available in this render mode');
|
||||
}
|
||||
|
|
@ -79,8 +119,6 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
return SEO_CONTENT_PATHS[page];
|
||||
case 'saved':
|
||||
return '/saved';
|
||||
case 'invites':
|
||||
return '/invites';
|
||||
case 'account':
|
||||
return '/account';
|
||||
case 'invite':
|
||||
|
|
@ -93,10 +131,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null {
|
||||
function pathToPage(pathname: string): RouteMatch | null {
|
||||
if (pathname === '/dashboard') return { page: 'dashboard' };
|
||||
if (pathname === '/saved') return { page: 'saved' };
|
||||
if (pathname === '/invites') return { page: 'invites' };
|
||||
if (pathname === '/invites') return { page: 'account', hash: 'invites' };
|
||||
if (pathname === '/learn') return { page: 'learn' };
|
||||
if (pathname === '/pricing') return { page: 'pricing' };
|
||||
const seoLandingPage = getSeoLandingPage(pathname);
|
||||
|
|
@ -123,7 +161,14 @@ function isSeoContentPage(page: Page): page is SeoContentKey {
|
|||
|
||||
export default function App() {
|
||||
const urlState = useMemo(() => parseUrlState(), []);
|
||||
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
|
||||
const [mapUrlState, setMapUrlState] = useState(urlState);
|
||||
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
|
||||
window.location.pathname === '/dashboard' ? window.location.search : ''
|
||||
);
|
||||
const [dashboardParams, setDashboardParams] = useState(() =>
|
||||
window.location.pathname === '/dashboard' ? window.location.search.replace(/^\?/, '') : ''
|
||||
);
|
||||
const dashboardSearchRef = useRef(
|
||||
window.location.pathname === '/dashboard' ? window.location.search : ''
|
||||
);
|
||||
|
|
@ -147,15 +192,16 @@ export default function App() {
|
|||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
||||
const [inviteCode, setInviteCode] = useState<string | null>(() => {
|
||||
const fromPath = pathToPage(window.location.pathname);
|
||||
return fromPath?.inviteCode ?? null;
|
||||
return initialRoute?.inviteCode ?? null;
|
||||
});
|
||||
const [routeHash, setRouteHash] = useState(
|
||||
() => initialRoute?.hash ?? normalizeHash(window.location.hash)
|
||||
);
|
||||
const [activePage, setActivePage] = useState<Page>(() => {
|
||||
if (isScreenshotMode) return 'dashboard';
|
||||
|
||||
// Derive page from URL pathname
|
||||
const fromPath = pathToPage(window.location.pathname);
|
||||
if (fromPath) return fromPath.page;
|
||||
if (initialRoute) return initialRoute.page;
|
||||
|
||||
// Restore from history state (e.g. popstate)
|
||||
if (window.history.state?.page) return window.history.state.page;
|
||||
|
|
@ -182,26 +228,95 @@ export default function App() {
|
|||
refreshAuth,
|
||||
clearError,
|
||||
} = useAuth();
|
||||
const { startCheckout: startPostAuthCheckout } = useLicense();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
||||
const [showLicenseSuccess, setShowLicenseSuccess] = useState(false);
|
||||
const [postAuthIntent, setPostAuthIntent] = useState<PostAuthIntent | null>(null);
|
||||
const postAuthCheckoutReturnPathRef = useRef<string | null>(null);
|
||||
const authCompletedRef = useRef(false);
|
||||
const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden');
|
||||
|
||||
const openAuthModal = useCallback(
|
||||
(
|
||||
tab: 'login' | 'register',
|
||||
intent: PostAuthIntent | null = null,
|
||||
checkoutReturnPath?: string
|
||||
) => {
|
||||
authCompletedRef.current = false;
|
||||
postAuthCheckoutReturnPathRef.current =
|
||||
intent === 'checkout' ? (checkoutReturnPath ?? currentRelativePath()) : null;
|
||||
setPostAuthIntent(intent);
|
||||
setAuthModalTab(tab);
|
||||
setShowAuthModal(true);
|
||||
clearError();
|
||||
},
|
||||
[clearError]
|
||||
);
|
||||
|
||||
const closeAuthModal = useCallback(() => {
|
||||
setShowAuthModal(false);
|
||||
const completed = authCompletedRef.current;
|
||||
if (!completed) {
|
||||
setPostAuthIntent(null);
|
||||
postAuthCheckoutReturnPathRef.current = null;
|
||||
if (isProtectedPage(activePageRef.current)) {
|
||||
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
|
||||
setRouteHash('');
|
||||
setActivePage('home');
|
||||
}
|
||||
}
|
||||
authCompletedRef.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('license_success') === '1') {
|
||||
const returnedFromCheckout = params.get('license_success') === '1';
|
||||
let cancelled = false;
|
||||
|
||||
if (returnedFromCheckout) {
|
||||
params.delete('license_success');
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
trackEvent('Purchase');
|
||||
setShowLicenseSuccess(true);
|
||||
}
|
||||
// Always refresh auth on startup to pick up server-side subscription changes
|
||||
refreshAuth().catch(() => {});
|
||||
|
||||
async function refreshOnStartup() {
|
||||
if (!returnedFromCheckout) {
|
||||
// Always refresh auth on startup to pick up server-side subscription changes.
|
||||
refreshAuth().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
setLicenseSuccessStatus('verifying');
|
||||
for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const refreshedUser = await refreshAuth();
|
||||
if (cancelled) return;
|
||||
if (hasFullAccess(refreshedUser)) {
|
||||
trackEvent('Purchase');
|
||||
setLicenseSuccessStatus('success');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logNonAbortError('Failed to verify license activation', error);
|
||||
break;
|
||||
}
|
||||
|
||||
await delay(LICENSE_VERIFICATION_DELAY_MS);
|
||||
if (cancelled) return;
|
||||
}
|
||||
|
||||
if (!cancelled) setLicenseSuccessStatus('delayed');
|
||||
}
|
||||
|
||||
refreshOnStartup();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const savedSearches = useSavedSearches(user?.id ?? null);
|
||||
const savedProperties = useSavedProperties(user?.id ?? null);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -241,6 +356,7 @@ export default function App() {
|
|||
|
||||
const navigateTo = useCallback(
|
||||
(page: Page, hash?: string, infoFeature?: string) => {
|
||||
const targetHash = normalizeHash(hash);
|
||||
// Save dashboard search params before navigating away
|
||||
if (activePageRef.current === 'dashboard') {
|
||||
dashboardSearchRef.current = window.location.search;
|
||||
|
|
@ -248,38 +364,67 @@ export default function App() {
|
|||
if (infoFeature) {
|
||||
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
||||
}
|
||||
const path = pageToPath(page, inviteCode ?? undefined);
|
||||
// Restore dashboard search params when navigating back
|
||||
const search = page === 'dashboard' ? dashboardSearchRef.current : '';
|
||||
const url = hash ? `${path}${search}#${hash}` : `${path}${search}`;
|
||||
window.history.pushState({ page }, '', url);
|
||||
const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash);
|
||||
window.history.pushState({ page, hash: targetHash }, '', url);
|
||||
if (page === 'dashboard') {
|
||||
setMapUrlState(parseUrlState());
|
||||
setDashboardRouteKey(window.location.search);
|
||||
}
|
||||
setRouteHash(targetHash);
|
||||
setActivePage(page);
|
||||
if (targetHash) scrollToHash(targetHash);
|
||||
},
|
||||
[inviteCode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || !user || postAuthIntent !== 'checkout') return;
|
||||
|
||||
setPostAuthIntent(null);
|
||||
setShowAuthModal(false);
|
||||
const checkoutReturnPath = postAuthCheckoutReturnPathRef.current ?? undefined;
|
||||
postAuthCheckoutReturnPathRef.current = null;
|
||||
if (hasFullAccess(user)) {
|
||||
if (checkoutReturnPath?.startsWith('/dashboard')) {
|
||||
window.history.pushState({ page: 'dashboard', hash: '' }, '', checkoutReturnPath);
|
||||
setMapUrlState(parseUrlState());
|
||||
setDashboardRouteKey(window.location.search);
|
||||
setRouteHash('');
|
||||
setActivePage('dashboard');
|
||||
} else {
|
||||
navigateTo('dashboard');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
startPostAuthCheckout(checkoutReturnPath).catch((error) => {
|
||||
logNonAbortError('Failed to resume checkout after auth', error);
|
||||
navigateTo('pricing');
|
||||
});
|
||||
}, [authLoading, navigateTo, postAuthIntent, startPostAuthCheckout, user]);
|
||||
|
||||
useEffect(() => {
|
||||
activePageRef.current = activePage;
|
||||
}, [activePage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.history.state?.page) {
|
||||
const hash = routeHash || normalizeHash(window.location.hash);
|
||||
window.history.replaceState(
|
||||
{ page: activePage },
|
||||
{ page: activePage, hash },
|
||||
'',
|
||||
pageToPath(activePage, inviteCode ?? undefined) +
|
||||
window.location.search +
|
||||
window.location.hash
|
||||
buildPageUrl(activePage, inviteCode ?? undefined, window.location.search, hash)
|
||||
);
|
||||
}
|
||||
const handlePopState = (e: PopStateEvent) => {
|
||||
let page: Page;
|
||||
const hash = normalizeHash(window.location.hash);
|
||||
if (e.state?.page) {
|
||||
page = e.state.page;
|
||||
setActivePage(page);
|
||||
setRouteHash(hash || e.state.hash || '');
|
||||
if (e.state.infoFeature) {
|
||||
setPendingInfoFeature(e.state.infoFeature);
|
||||
}
|
||||
|
|
@ -287,11 +432,13 @@ export default function App() {
|
|||
const parsed = pathToPage(window.location.pathname);
|
||||
page = parsed?.page || 'home';
|
||||
setActivePage(page);
|
||||
setRouteHash(parsed?.hash ?? hash);
|
||||
if (parsed?.inviteCode) setInviteCode(parsed.inviteCode);
|
||||
}
|
||||
// Re-parse URL state when returning to dashboard via back/forward
|
||||
if (page === 'dashboard') {
|
||||
setMapUrlState(parseUrlState());
|
||||
setDashboardRouteKey(window.location.search);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
|
|
@ -299,30 +446,22 @@ export default function App() {
|
|||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { fetchSearches } = savedSearches;
|
||||
const { fetchProperties: fetchSavedProperties } = savedProperties;
|
||||
useEffect(() => {
|
||||
if (activePage === 'saved') {
|
||||
fetchSearches();
|
||||
fetchSavedProperties();
|
||||
}
|
||||
if (activePage === 'dashboard' && user) {
|
||||
fetchSavedProperties();
|
||||
}
|
||||
}, [activePage, fetchSearches, fetchSavedProperties, user]);
|
||||
}, [activePage, fetchSearches]);
|
||||
|
||||
const isAuthRequiredPage =
|
||||
activePage === 'account' || activePage === 'saved' || activePage === 'invites';
|
||||
const isAuthRequiredPage = activePage === 'account' || activePage === 'saved';
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (isAuthRequiredPage && !user) {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
navigateTo('home');
|
||||
openAuthModal('login');
|
||||
}
|
||||
if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) {
|
||||
if (activePage === 'pricing' && hasFullAccess(user)) {
|
||||
navigateTo('dashboard');
|
||||
}
|
||||
}, [activePage, isAuthRequiredPage, user, authLoading, navigateTo]);
|
||||
}, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]);
|
||||
|
||||
const [exportState, setExportState] = useState<ExportState | null>(null);
|
||||
|
||||
|
|
@ -372,21 +511,17 @@ export default function App() {
|
|||
<div className="h-full flex flex-col">
|
||||
<Header
|
||||
activePage={activePage}
|
||||
activeHash={routeHash}
|
||||
onPageChange={navigateTo}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
exportState={activePage === 'dashboard' ? exportState : null}
|
||||
dashboardParams={activePage === 'dashboard' ? dashboardParams : ''}
|
||||
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
|
||||
savingSearch={savedSearches.saving}
|
||||
user={user}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onLoginClick={() => openAuthModal('login')}
|
||||
onRegisterClick={() => openAuthModal('register')}
|
||||
onLogout={logout}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
|
@ -402,14 +537,8 @@ export default function App() {
|
|||
<PricingPage
|
||||
onOpenDashboard={() => navigateTo('dashboard')}
|
||||
user={user}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onLoginClick={() => openAuthModal('login', 'checkout', '/pricing')}
|
||||
onRegisterClick={() => openAuthModal('register', 'checkout', '/pricing')}
|
||||
/>
|
||||
) : activePage === 'learn' ? (
|
||||
<LearnPage />
|
||||
|
|
@ -427,38 +556,32 @@ export default function App() {
|
|||
onOpenSearch={(params) => {
|
||||
window.location.href = `/dashboard?${params}`;
|
||||
}}
|
||||
savedProperties={savedProperties.properties}
|
||||
propertiesLoading={savedProperties.loading}
|
||||
onDeleteProperty={savedProperties.deleteProperty}
|
||||
onUpdatePropertyNotes={savedProperties.updatePropertyNotes}
|
||||
onOpenProperty={(postcode) => {
|
||||
window.location.href = `/dashboard?pc=${encodeURIComponent(postcode)}`;
|
||||
}}
|
||||
/>
|
||||
) : activePage === 'invites' && user ? (
|
||||
<InvitesPage user={user} />
|
||||
) : activePage === 'account' && user ? (
|
||||
<AccountPage user={user} onRefreshAuth={refreshAuth} />
|
||||
<AccountPage
|
||||
user={user}
|
||||
onRefreshAuth={async () => {
|
||||
await refreshAuth();
|
||||
}}
|
||||
scrollTarget={routeHash}
|
||||
/>
|
||||
) : isAuthRequiredPage && !user ? (
|
||||
<PageFallback />
|
||||
) : activePage === 'invite' && inviteCode ? (
|
||||
<InvitePage
|
||||
code={inviteCode}
|
||||
user={user}
|
||||
theme={theme}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onLoginClick={() => openAuthModal('login')}
|
||||
onRegisterClick={() => openAuthModal('register')}
|
||||
onLicenseGranted={() => {
|
||||
setShowLicenseSuccess(true);
|
||||
setLicenseSuccessStatus('success');
|
||||
refreshAuth();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MapPage
|
||||
key={dashboardRouteKey}
|
||||
features={features}
|
||||
poiCategoryGroups={poiCategoryGroups}
|
||||
initialFilters={mapUrlState.filters}
|
||||
|
|
@ -471,24 +594,19 @@ export default function App() {
|
|||
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
||||
onNavigateTo={navigateTo}
|
||||
onExportStateChange={setExportState}
|
||||
onDashboardParamsChange={setDashboardParams}
|
||||
isMobile={isMobile}
|
||||
initialTravelTime={mapUrlState.travelTime}
|
||||
initialPostcode={mapUrlState.postcode}
|
||||
shareCode={mapUrlState.share}
|
||||
user={user}
|
||||
onLoginClick={() => {
|
||||
setAuthModalTab('login');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onRegisterClick={() => {
|
||||
setAuthModalTab('register');
|
||||
setShowAuthModal(true);
|
||||
}}
|
||||
onSaveProperty={user ? savedProperties.saveProperty : undefined}
|
||||
onUnsaveProperty={user ? savedProperties.deleteProperty : undefined}
|
||||
isPropertySaved={user ? savedProperties.isPropertySaved : undefined}
|
||||
getSavedPropertyId={user ? savedProperties.getSavedPropertyId : undefined}
|
||||
deferTutorial={showLicenseSuccess}
|
||||
onLoginClick={() => openAuthModal('login')}
|
||||
onRegisterClick={() => openAuthModal('register')}
|
||||
onCheckoutLoginClick={(returnPath) => openAuthModal('login', 'checkout', returnPath)}
|
||||
onCheckoutRegisterClick={(returnPath) =>
|
||||
openAuthModal('register', 'checkout', returnPath)
|
||||
}
|
||||
deferTutorial={licenseSuccessStatus !== 'hidden'}
|
||||
onSaveSearch={user ? savedSearches.saveSearch : undefined}
|
||||
savingSearch={savedSearches.saving}
|
||||
/>
|
||||
|
|
@ -497,7 +615,10 @@ export default function App() {
|
|||
<Suspense fallback={null}>
|
||||
{showAuthModal && (
|
||||
<AuthModal
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
onClose={closeAuthModal}
|
||||
onAuthenticated={() => {
|
||||
authCompletedRef.current = true;
|
||||
}}
|
||||
onLogin={login}
|
||||
onRegister={register}
|
||||
onOAuthLogin={loginWithOAuth}
|
||||
|
|
@ -511,7 +632,7 @@ export default function App() {
|
|||
{showSaveModal && (
|
||||
<SaveSearchModal
|
||||
onClose={() => setShowSaveModal(false)}
|
||||
onSave={savedSearches.saveSearch}
|
||||
onSave={(name) => savedSearches.saveSearch(name, dashboardParams)}
|
||||
onViewSearches={() => {
|
||||
setShowSaveModal(false);
|
||||
navigateTo('saved');
|
||||
|
|
@ -520,11 +641,13 @@ export default function App() {
|
|||
error={savedSearches.error}
|
||||
/>
|
||||
)}
|
||||
{showLicenseSuccess && (
|
||||
{licenseSuccessStatus !== 'hidden' && (
|
||||
<LicenseSuccessModal
|
||||
status={licenseSuccessStatus}
|
||||
onClose={() => {
|
||||
setShowLicenseSuccess(false);
|
||||
navigateTo('dashboard');
|
||||
const shouldOpenDashboard = licenseSuccessStatus === 'success';
|
||||
setLicenseSuccessStatus('hidden');
|
||||
if (shouldOpenDashboard) navigateTo('dashboard');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { AuthUser } from '../../hooks/useAuth';
|
||||
import type { SavedSearch } from '../../hooks/useSavedSearches';
|
||||
import type { SavedProperty, SavedPropertyData } from '../../hooks/useSavedProperties';
|
||||
import { apiUrl, authHeaders, assertOk, shortenUrl, prewarmScreenshot } from '../../lib/api';
|
||||
import { copyToClipboard } from '../../lib/clipboard';
|
||||
import { formatRelativeTime, formatNumber } from '../../lib/format';
|
||||
|
|
@ -11,7 +10,6 @@ import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
|
|||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import { ClipboardIcon } from '../ui/icons/ClipboardIcon';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
import { HouseIcon } from '../ui/icons/HouseIcon';
|
||||
import { TrashIcon } from '../ui/icons/TrashIcon';
|
||||
import { CloseIcon } from '../ui/icons/CloseIcon';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
|
|
@ -126,24 +124,6 @@ function NotesInput({ value, onSave }: { value: string; onSave: (notes: string)
|
|||
);
|
||||
}
|
||||
|
||||
function formatPropertyPrice(data: SavedPropertyData): string | null {
|
||||
if (data.estimatedPrice) return `~£${formatNumber(data.estimatedPrice)}`;
|
||||
if (data.price) return `£${formatNumber(data.price)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatPropertyDetails(
|
||||
data: SavedPropertyData,
|
||||
t: { (key: 'savedPage.bed'): string; (key: 'savedPage.epc'): string }
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (data.propertySubType) parts.push(data.propertySubType);
|
||||
else if (data.propertyType) parts.push(data.propertyType);
|
||||
if (data.floorArea) parts.push(`${formatNumber(data.floorArea)}m²`);
|
||||
if (data.energyRating) parts.push(`${t('savedPage.epc')} ${data.energyRating}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function EditableName({ value, onSave }: { value: string; onSave: (name: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
|
@ -355,115 +335,6 @@ function SavedSearchesTab({
|
|||
);
|
||||
}
|
||||
|
||||
function SavedPropertiesTab({
|
||||
properties,
|
||||
loading,
|
||||
onDelete,
|
||||
onUpdateNotes,
|
||||
onOpen,
|
||||
}: {
|
||||
properties: SavedProperty[];
|
||||
loading: boolean;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onUpdateNotes: (id: string, notes: string) => void;
|
||||
onOpen: (postcode: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
await onDelete(deleteConfirmId);
|
||||
setDeleteConfirmId(null);
|
||||
}, [deleteConfirmId, onDelete]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (properties.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<HouseIcon className="w-12 h-12 text-warm-300 dark:text-warm-600 mb-4" />
|
||||
<p className="text-lg font-medium text-warm-600 dark:text-warm-400 mb-1">
|
||||
{t('savedPage.noSavedProperties')}
|
||||
</p>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-500">
|
||||
{t('savedPage.noSavedPropertiesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{properties.map((prop) => {
|
||||
const price = formatPropertyPrice(prop.data);
|
||||
const details = formatPropertyDetails(prop.data, t);
|
||||
return (
|
||||
<div
|
||||
key={prop.id}
|
||||
className="flex flex-col bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg overflow-hidden p-4"
|
||||
>
|
||||
<div className="mb-1">
|
||||
<h3 className="font-medium text-navy-950 dark:text-warm-100 leading-tight">
|
||||
{prop.address}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-warm-500 dark:text-warm-400 mb-1">{prop.postcode}</p>
|
||||
{price && (
|
||||
<p className="text-lg font-bold text-teal-700 dark:text-teal-400 mb-1">{price}</p>
|
||||
)}
|
||||
{details && (
|
||||
<p className="text-xs text-warm-500 dark:text-warm-400 mb-1">{details}</p>
|
||||
)}
|
||||
<p className="text-xs text-warm-400 dark:text-warm-500 mb-2">
|
||||
{formatRelativeTime(prop.created)}
|
||||
</p>
|
||||
|
||||
<div className="mb-3 flex-1">
|
||||
<NotesInput value={prop.notes} onSave={(notes) => onUpdateNotes(prop.id, notes)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onOpen(prop.postcode)}
|
||||
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
|
||||
>
|
||||
{t('savedPage.openPostcode')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(prop.id)}
|
||||
className="px-3 py-1.5 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId && (
|
||||
<DeleteDialog
|
||||
title={t('savedPage.deleteProperty')}
|
||||
message={t('savedPage.deletePropertyConfirm')}
|
||||
onCancel={() => setDeleteConfirmId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SavedPage({
|
||||
searches,
|
||||
searchesLoading,
|
||||
|
|
@ -471,11 +342,6 @@ export function SavedPage({
|
|||
onUpdateSearchNotes,
|
||||
onUpdateSearchName,
|
||||
onOpenSearch,
|
||||
savedProperties,
|
||||
propertiesLoading,
|
||||
onDeleteProperty,
|
||||
onUpdatePropertyNotes,
|
||||
onOpenProperty,
|
||||
}: {
|
||||
searches: SavedSearch[];
|
||||
searchesLoading: boolean;
|
||||
|
|
@ -483,15 +349,10 @@ export function SavedPage({
|
|||
onUpdateSearchNotes: (id: string, notes: string) => void;
|
||||
onUpdateSearchName: (id: string, name: string) => void;
|
||||
onOpenSearch: (params: string) => void;
|
||||
savedProperties: SavedProperty[];
|
||||
propertiesLoading: boolean;
|
||||
onDeleteProperty: (id: string) => Promise<void>;
|
||||
onUpdatePropertyNotes: (id: string, notes: string) => void;
|
||||
onOpenProperty: (postcode: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<'searches' | 'properties'>(
|
||||
window.location.hash === '#properties' ? 'properties' : 'searches'
|
||||
const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>(
|
||||
window.location.hash === '#shared-links' ? 'shared-links' : 'searches'
|
||||
);
|
||||
|
||||
const tabClass = (tab: string) =>
|
||||
|
|
@ -512,13 +373,8 @@ export function SavedPage({
|
|||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button className={tabClass('properties')} onClick={() => setActiveTab('properties')}>
|
||||
{t('common.properties')}
|
||||
{savedProperties.length > 0 && (
|
||||
<span className="ml-1.5 text-xs bg-warm-100 dark:bg-warm-700 text-warm-600 dark:text-warm-300 rounded-full px-1.5 py-0.5">
|
||||
{savedProperties.length}
|
||||
</span>
|
||||
)}
|
||||
<button className={tabClass('shared-links')} onClick={() => setActiveTab('shared-links')}>
|
||||
{t('accountPage.shareLinksTitle')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -532,13 +388,7 @@ export function SavedPage({
|
|||
onOpen={onOpenSearch}
|
||||
/>
|
||||
) : (
|
||||
<SavedPropertiesTab
|
||||
properties={savedProperties}
|
||||
loading={propertiesLoading}
|
||||
onDelete={onDeleteProperty}
|
||||
onUpdateNotes={onUpdatePropertyNotes}
|
||||
onOpen={onOpenProperty}
|
||||
/>
|
||||
<ShareLinksSection showTitle={false} />
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
|
|
@ -655,7 +505,7 @@ function InviteTable({
|
|||
);
|
||||
}
|
||||
|
||||
function ShareLinksSection() {
|
||||
function ShareLinksSection({ showTitle = true }: { showTitle?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [links, setLinks] = useState<ShareLinkListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -698,9 +548,11 @@ function ShareLinksSection() {
|
|||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-base font-semibold text-navy-950 dark:text-warm-100">
|
||||
{t('accountPage.shareLinksTitle')}
|
||||
</h2>
|
||||
{showTitle && (
|
||||
<h2 className="text-base font-semibold text-navy-950 dark:text-warm-100">
|
||||
{t('accountPage.shareLinksTitle')}
|
||||
</h2>
|
||||
)}
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
|
|
@ -1048,8 +900,6 @@ export default function AccountPage({
|
|||
<InviteSection user={user} />
|
||||
</section>
|
||||
|
||||
<ShareLinksSection />
|
||||
|
||||
{/* Support */}
|
||||
<div className="bg-white dark:bg-warm-800 rounded-xl border border-warm-200 dark:border-warm-700 p-6 text-center">
|
||||
<p className="text-warm-600 dark:text-warm-300 mb-2">{t('accountPage.needHelp')}</p>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const HOME_SECTION_HEADING_CLASS =
|
|||
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
|
||||
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
|
||||
const HOME_PRIMARY_BUTTON_CLASS =
|
||||
'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center';
|
||||
'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center';
|
||||
|
||||
export default function HomeFinalCta({
|
||||
onOpenDashboard,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { lazy, Suspense, useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFadeInRef } from '../../hooks/useFadeIn';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import HexCanvas from './HexCanvas';
|
||||
import HomeFinalCta from './HomeFinalCta';
|
||||
import BottomIllustration from './BottomIllustration';
|
||||
|
|
@ -15,7 +16,7 @@ const HOME_SECTION_HEADING_CLASS =
|
|||
'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
|
||||
const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
|
||||
const HOME_PRIMARY_BUTTON_CLASS =
|
||||
'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center';
|
||||
'border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-base shadow-lg shadow-[#7a3905]/25 text-center';
|
||||
const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record<string, string> = {
|
||||
en: 'recording',
|
||||
de: 'recording-de',
|
||||
|
|
@ -34,9 +35,13 @@ function ProductShowcaseFallback({ className = '' }: { className?: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function getProductDemoSlug(language: string | undefined): string {
|
||||
function getProductDemoSlug(language: string | undefined, isMobile: boolean): string {
|
||||
const code = language?.toLowerCase().split('-')[0] ?? 'en';
|
||||
return PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
|
||||
const base = PRODUCT_DEMO_VIDEO_BY_LANGUAGE[code] ?? PRODUCT_DEMO_VIDEO_BY_LANGUAGE.en;
|
||||
// Mobile cuts (9:16, 540x960) are published as `<base>-mobile` alongside
|
||||
// the 16:9 desktop cuts. The recorder pipeline writes both every render —
|
||||
// see video/src/storyboard.ts.
|
||||
return isMobile ? `${base}-mobile` : base;
|
||||
}
|
||||
|
||||
function highlightBrandText(text: string) {
|
||||
|
|
@ -57,12 +62,13 @@ function highlightBrandText(text: string) {
|
|||
|
||||
function ProductDemoVideo() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const sectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const currentVideoSrcRef = useRef<string | null>(null);
|
||||
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
const productDemoSlug = getProductDemoSlug(i18n.language);
|
||||
const productDemoSlug = getProductDemoSlug(i18n.language, isMobile);
|
||||
const productDemoVideoSrc = `/video/${productDemoSlug}.mp4`;
|
||||
const productDemoPosterSrc = `/video/${productDemoSlug}.jpg`;
|
||||
|
||||
|
|
@ -123,7 +129,11 @@ function ProductDemoVideo() {
|
|||
<h2 className={`${HOME_SECTION_HEADING_CLASS} mb-5 text-center`}>
|
||||
{t('home.productDemoLabel')}
|
||||
</h2>
|
||||
<div className="relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700">
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-lg border border-warm-200 bg-navy-950 shadow-sm dark:border-warm-700 ${
|
||||
isMobile ? 'mx-auto max-w-sm' : ''
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={shouldLoadVideo ? productDemoVideoSrc : undefined}
|
||||
|
|
@ -131,7 +141,9 @@ function ProductDemoVideo() {
|
|||
controls
|
||||
playsInline
|
||||
preload={shouldLoadVideo ? 'metadata' : 'none'}
|
||||
className="block aspect-video w-full bg-navy-950 object-contain"
|
||||
className={`block w-full bg-navy-950 object-contain ${
|
||||
isMobile ? 'aspect-[9/16]' : 'aspect-video'
|
||||
}`}
|
||||
aria-label={t('home.productDemoLabel')}
|
||||
onPlay={() => setIsVideoPlaying(true)}
|
||||
onPause={() => setIsVideoPlaying(false)}
|
||||
|
|
|
|||
|
|
@ -338,7 +338,7 @@ export default function InvitePage({
|
|||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25"
|
||||
>
|
||||
{t('invitePage.registerToClaim')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,29 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import HomeFinalCta from '../home/HomeFinalCta';
|
||||
import { usePageMeta } from '../../hooks/usePageMeta';
|
||||
import {
|
||||
SEO_CONTENT_PAGES,
|
||||
getLocalizedSeoContentPage,
|
||||
type SeoContentKey,
|
||||
type SeoFaq,
|
||||
type SeoLink,
|
||||
type SeoSection,
|
||||
} from '../../lib/seoLandingPages';
|
||||
import { safeJsonLd } from '../../lib/json-ld';
|
||||
|
||||
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
||||
const ProductShowcase = lazy(() => import('../home/ProductShowcase'));
|
||||
|
||||
function ProductShowcaseFallback() {
|
||||
return (
|
||||
<div className="mx-auto min-h-[28rem] max-w-6xl rounded-lg bg-navy-900" aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
|
||||
function JsonLd({ data }: { data: unknown }) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
|
||||
/>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(data) }} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -91,9 +99,10 @@ export default function SeoContentPage({
|
|||
pageKey: SeoContentKey;
|
||||
onOpenDashboard: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const page = SEO_CONTENT_PAGES[pageKey];
|
||||
const { t, i18n } = useTranslation();
|
||||
const page = getLocalizedSeoContentPage(pageKey, i18n.language);
|
||||
const url = `${PUBLIC_URL}${page.path}`;
|
||||
usePageMeta(page.metaTitle, page.metaDescription);
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
|
||||
|
|
@ -136,7 +145,7 @@ export default function SeoContentPage({
|
|||
{page.cta && (
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="mt-8 rounded-lg bg-coral-500 px-6 py-3 font-semibold text-white shadow-lg shadow-coral-500/25 transition-colors hover:bg-coral-600"
|
||||
className="mt-8 rounded-lg border border-[#d27a11] bg-[#f09a22] px-6 py-3 font-semibold text-navy-950 shadow-lg shadow-[#7a3905]/25 transition-colors hover:bg-[#df8614]"
|
||||
>
|
||||
{page.cta}
|
||||
</button>
|
||||
|
|
@ -144,6 +153,14 @@ export default function SeoContentPage({
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-navy-950 px-6 py-12 md:px-10 md:py-16">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Suspense fallback={<ProductShowcaseFallback />}>
|
||||
<ProductShowcase className="mx-auto" />
|
||||
</Suspense>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-5xl px-6 py-14 md:px-10">
|
||||
<SectionList sections={page.sections} />
|
||||
</section>
|
||||
|
|
@ -178,6 +195,13 @@ export default function SeoContentPage({
|
|||
<RelatedLinks links={page.relatedLinks} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-5xl px-6 pb-16 md:px-10">
|
||||
<HomeFinalCta
|
||||
onOpenDashboard={onOpenDashboard}
|
||||
trackingLocation={`seo_${pageKey}_bottom`}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,30 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckIcon } from '../ui/icons/CheckIcon';
|
||||
import { LogoIcon } from '../ui/icons/LogoIcon';
|
||||
import HomeFinalCta from '../home/HomeFinalCta';
|
||||
import { usePageMeta } from '../../hooks/usePageMeta';
|
||||
import {
|
||||
SEO_LANDING_PAGES,
|
||||
getLocalizedSeoLandingPage,
|
||||
type SeoFaq,
|
||||
type SeoLandingKey,
|
||||
type SeoLink,
|
||||
type SeoSection,
|
||||
} from '../../lib/seoLandingPages';
|
||||
import { safeJsonLd } from '../../lib/json-ld';
|
||||
|
||||
const PUBLIC_URL = 'https://perfect-postcode.co.uk';
|
||||
const ProductShowcase = lazy(() => import('../home/ProductShowcase'));
|
||||
|
||||
function ProductShowcaseFallback() {
|
||||
return (
|
||||
<div className="mx-auto min-h-[28rem] max-w-6xl rounded-lg bg-navy-900" aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
|
||||
function JsonLd({ data }: { data: unknown }) {
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
|
||||
/>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safeJsonLd(data) }} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -91,9 +99,10 @@ export default function SeoLandingPage({
|
|||
pageKey: SeoLandingKey;
|
||||
onOpenDashboard: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const page = SEO_LANDING_PAGES[pageKey];
|
||||
const { t, i18n } = useTranslation();
|
||||
const page = getLocalizedSeoLandingPage(pageKey, i18n.language);
|
||||
const url = `${PUBLIC_URL}${page.path}`;
|
||||
usePageMeta(page.metaTitle, page.metaDescription);
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto bg-warm-50 text-navy-950 dark:bg-navy-950 dark:text-warm-100">
|
||||
|
|
@ -138,7 +147,7 @@ export default function SeoLandingPage({
|
|||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
onClick={onOpenDashboard}
|
||||
className="rounded-lg bg-coral-500 px-6 py-3 font-semibold text-white shadow-lg shadow-coral-500/25 transition-colors hover:bg-coral-600"
|
||||
className="rounded-lg border border-[#d27a11] bg-[#f09a22] px-6 py-3 font-semibold text-navy-950 shadow-lg shadow-[#7a3905]/25 transition-colors hover:bg-[#df8614]"
|
||||
>
|
||||
{page.cta}
|
||||
</button>
|
||||
|
|
@ -152,6 +161,14 @@ export default function SeoLandingPage({
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-navy-950 px-6 py-12 md:px-10 md:py-16">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<Suspense fallback={<ProductShowcaseFallback />}>
|
||||
<ProductShowcase className="mx-auto" />
|
||||
</Suspense>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto grid max-w-6xl gap-8 px-6 py-12 md:grid-cols-[0.85fr_1.15fr] md:px-10 md:py-16">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-teal-200 bg-teal-50 px-3 py-1.5 text-sm font-semibold text-teal-700 dark:border-teal-400/30 dark:bg-teal-400/10 dark:text-teal-200">
|
||||
|
|
@ -228,6 +245,13 @@ export default function SeoLandingPage({
|
|||
</div>
|
||||
<LinkGrid links={page.relatedLinks} />
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-6xl px-6 pb-16 md:px-10">
|
||||
<HomeFinalCta
|
||||
onOpenDashboard={onOpenDashboard}
|
||||
trackingLocation={`seo_${pageKey}_bottom`}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { tDynamic } from '../../i18n';
|
||||
import { getLocalizedSeoPages } from '../../lib/seoLandingPages';
|
||||
import { ChevronIcon } from '../ui/icons/ChevronIcon';
|
||||
import { SubNav } from '../ui/SubNav';
|
||||
|
||||
type LearnTab = 'data-sources' | 'faq' | 'support';
|
||||
type LearnTab = 'data-sources' | 'faq' | 'articles' | 'support';
|
||||
|
||||
interface DataSourceDef {
|
||||
id: string;
|
||||
|
|
@ -173,15 +174,26 @@ function FAQItemCard({ question, answer }: { question: string; answer: string })
|
|||
}
|
||||
|
||||
export default function LearnPage() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [tab, setTab] = useState<LearnTab>('faq');
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const seoPageLinks = useMemo(
|
||||
() =>
|
||||
getLocalizedSeoPages(i18n.language).map((page) => ({
|
||||
path: page.path,
|
||||
eyebrow: page.eyebrow,
|
||||
title: page.title,
|
||||
description: page.metaDescription,
|
||||
})),
|
||||
[i18n.language]
|
||||
);
|
||||
|
||||
const LEARN_TABS = [
|
||||
{ key: 'faq', label: t('learnPage.faq') },
|
||||
{ key: 'data-sources', label: t('learnPage.dataSources') },
|
||||
{ key: 'articles', label: t('learnPage.articles') },
|
||||
{ key: 'support', label: t('learnPage.support') },
|
||||
];
|
||||
|
||||
|
|
@ -239,6 +251,9 @@ export default function LearnPage() {
|
|||
if (hash === 'faq') {
|
||||
setTab('faq');
|
||||
setHighlightedId(null);
|
||||
} else if (hash === 'articles') {
|
||||
setTab('articles');
|
||||
setHighlightedId(null);
|
||||
} else if (hash === 'support') {
|
||||
setTab('support');
|
||||
setHighlightedId(null);
|
||||
|
|
@ -406,6 +421,32 @@ export default function LearnPage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : tab === 'articles' ? (
|
||||
<div className="max-w-5xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
{t('learnPage.articles')}
|
||||
</h1>
|
||||
<p className="text-warm-600 dark:text-warm-400 mb-6">{t('learnPage.articlesIntro')}</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{seoPageLinks.map((link) => (
|
||||
<a
|
||||
key={link.path}
|
||||
href={link.path}
|
||||
className="rounded-lg border border-warm-200 bg-white p-5 transition-colors hover:border-teal-300 hover:bg-teal-50 dark:border-warm-700 dark:bg-warm-800 dark:hover:border-teal-500/60 dark:hover:bg-teal-950/30"
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-teal-600 dark:text-teal-400">
|
||||
{link.eyebrow}
|
||||
</div>
|
||||
<h2 className="mt-1 text-lg font-bold text-warm-900 dark:text-warm-100">
|
||||
{link.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-warm-600 dark:text-warm-300">
|
||||
{link.description}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-2xl mx-auto px-6 py-6 w-full">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-warm-900 dark:text-warm-100 mb-3">
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ interface AreaPaneProps {
|
|||
onStatsUseFiltersChange: (useFilters: boolean) => void;
|
||||
onNavigateToSource?: (slug: string, featureName: string) => void;
|
||||
travelTimeEntries?: TravelTimeEntry[];
|
||||
shareCode?: string;
|
||||
isGroupExpanded: (name: string) => boolean;
|
||||
onToggleGroup: (name: string) => void;
|
||||
}
|
||||
|
|
@ -74,6 +75,7 @@ export default function AreaPane({
|
|||
onStatsUseFiltersChange,
|
||||
onNavigateToSource,
|
||||
travelTimeEntries,
|
||||
shareCode,
|
||||
isGroupExpanded,
|
||||
onToggleGroup,
|
||||
}: AreaPaneProps) {
|
||||
|
|
@ -226,6 +228,7 @@ export default function AreaPane({
|
|||
postcode={journeyPostcode}
|
||||
entries={travelTimeEntries}
|
||||
label={!isPostcode ? journeyPostcode : undefined}
|
||||
shareCode={shareCode}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { JourneyLeg } from '../../types';
|
||||
import type { TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import { apiUrl, logNonAbortError } from '../../lib/api';
|
||||
import { apiUrl, authHeaders, logNonAbortError } from '../../lib/api';
|
||||
import { WalkingIcon } from '../ui/icons/WalkingIcon';
|
||||
import { BicycleIcon } from '../ui/icons/BicycleIcon';
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ interface JourneyInstructionsProps {
|
|||
presetJourneys?: JourneyInstructionPreset[];
|
||||
className?: string;
|
||||
showGoogleMapsLink?: boolean;
|
||||
shareCode?: string;
|
||||
}
|
||||
|
||||
interface JourneyData {
|
||||
|
|
@ -25,6 +26,8 @@ interface JourneyData {
|
|||
minutes: number | null;
|
||||
/** Best-case (5th percentile) total travel time from R5. */
|
||||
bestMinutes: number | null;
|
||||
/** Whether the dashboard filter is currently using best-case time. */
|
||||
useBest: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +39,7 @@ export interface JourneyInstructionPreset {
|
|||
minutes: number | null;
|
||||
/** Best-case (5th percentile) total travel time. */
|
||||
bestMinutes?: number | null;
|
||||
useBest?: boolean;
|
||||
}
|
||||
|
||||
// Official TfL line colors + other known London transit
|
||||
|
|
@ -90,15 +94,15 @@ function nextMondayAt730(): number {
|
|||
return Math.floor(monday.getTime() / 1000);
|
||||
}
|
||||
|
||||
function googleMapsUrl(postcode: string, destination: string): string {
|
||||
function googleMapsUrl(origin: string, destination: string): string {
|
||||
const ts = nextMondayAt730();
|
||||
const origin = encodeURIComponent(postcode);
|
||||
const dest = encodeURIComponent(destination);
|
||||
const encodedOrigin = encodeURIComponent(origin);
|
||||
const encodedDestination = encodeURIComponent(destination);
|
||||
// The official api=1 URL scheme doesn't support departure_time.
|
||||
// Use the undocumented data= path parameter with protobuf-like encoding:
|
||||
// !3e3 = transit, !6e0 = "depart at", !7e2 = local time, !8j = timestamp
|
||||
const data = `!4m6!4m5!2m3!6e0!7e2!8j${ts}!3e3`;
|
||||
return `https://www.google.com/maps/dir/${origin}/${dest}/data=${data}`;
|
||||
return `https://www.google.com/maps/dir/${encodedOrigin}/${encodedDestination}/data=${data}`;
|
||||
}
|
||||
|
||||
function invertLegs(legs: JourneyLeg[]): JourneyLeg[] {
|
||||
|
|
@ -181,12 +185,16 @@ export default function JourneyInstructions({
|
|||
presetJourneys,
|
||||
className,
|
||||
showGoogleMapsLink = true,
|
||||
shareCode,
|
||||
}: JourneyInstructionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [journeys, setJourneys] = useState<JourneyData[]>([]);
|
||||
|
||||
// Only transit entries with a destination set
|
||||
const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== '');
|
||||
const transitEntries = useMemo(
|
||||
() => entries.filter((e) => e.mode === 'transit' && e.slug !== ''),
|
||||
[entries]
|
||||
);
|
||||
const hasPresetJourneys = Boolean(presetJourneys?.length);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -207,6 +215,7 @@ export default function JourneyInstructions({
|
|||
legs: null,
|
||||
minutes: null,
|
||||
bestMinutes: null,
|
||||
useBest: e.useBest,
|
||||
loading: true,
|
||||
}));
|
||||
setJourneys([...results]);
|
||||
|
|
@ -217,7 +226,8 @@ export default function JourneyInstructions({
|
|||
mode: 'transit',
|
||||
slug: entry.slug,
|
||||
});
|
||||
fetch(apiUrl('journey', params), { signal: controller.signal })
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
fetch(apiUrl('journey', params), authHeaders({ signal: controller.signal }))
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
|
|
@ -232,12 +242,12 @@ export default function JourneyInstructions({
|
|||
prev.map((j, i) =>
|
||||
i === idx
|
||||
? {
|
||||
...j,
|
||||
legs: data.journey,
|
||||
minutes: data.minutes,
|
||||
bestMinutes: data.best_minutes,
|
||||
loading: false,
|
||||
}
|
||||
...j,
|
||||
legs: data.journey,
|
||||
minutes: data.minutes,
|
||||
bestMinutes: data.best_minutes,
|
||||
loading: false,
|
||||
}
|
||||
: j
|
||||
)
|
||||
);
|
||||
|
|
@ -250,19 +260,20 @@ export default function JourneyInstructions({
|
|||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [postcode, hasPresetJourneys, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [postcode, hasPresetJourneys, transitEntries, shareCode]);
|
||||
|
||||
if (transitEntries.length === 0 && !hasPresetJourneys) return null;
|
||||
|
||||
const displayedJourneys: JourneyData[] = hasPresetJourneys
|
||||
? (presetJourneys ?? []).map((journey) => ({
|
||||
slug: journey.slug,
|
||||
label: journey.label,
|
||||
legs: journey.legs,
|
||||
minutes: journey.minutes,
|
||||
bestMinutes: journey.bestMinutes ?? null,
|
||||
loading: false,
|
||||
}))
|
||||
slug: journey.slug,
|
||||
label: journey.label,
|
||||
legs: journey.legs,
|
||||
minutes: journey.minutes,
|
||||
bestMinutes: journey.bestMinutes ?? null,
|
||||
useBest: journey.useBest ?? false,
|
||||
loading: false,
|
||||
}))
|
||||
: journeys;
|
||||
|
||||
return (
|
||||
|
|
@ -272,19 +283,22 @@ export default function JourneyInstructions({
|
|||
{t('areaPane.journeysFrom', { label })}
|
||||
</div>
|
||||
)}
|
||||
{displayedJourneys.map((j) => {
|
||||
const displayLegs = j.legs ? invertLegs(j.legs) : null;
|
||||
{displayedJourneys.map((j, index) => {
|
||||
const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0;
|
||||
const totalMin = j.minutes ?? legSum;
|
||||
const totalMin = j.useBest && j.bestMinutes != null ? j.bestMinutes : (j.minutes ?? legSum);
|
||||
const isBestCase = j.useBest && j.bestMinutes != null;
|
||||
const displayLegs = !isBestCase && j.legs ? invertLegs(j.legs) : null;
|
||||
const destination = j.label || j.slug;
|
||||
|
||||
return (
|
||||
<div key={j.slug} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
||||
<div key={`${j.slug}-${index}`} className="bg-warm-50 dark:bg-warm-800 rounded-lg p-2.5">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-xs font-medium text-warm-700 dark:text-warm-300">
|
||||
{t('areaPane.to', { destination: j.label || j.slug })}
|
||||
{t('areaPane.to', { destination })}
|
||||
</span>
|
||||
{!j.loading && totalMin > 0 && (
|
||||
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
|
||||
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
|
||||
{totalMin} {t('common.min')}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -303,7 +317,7 @@ export default function JourneyInstructions({
|
|||
))}
|
||||
{showGoogleMapsLink && (
|
||||
<a
|
||||
href={googleMapsUrl(postcode, j.label || j.slug)}
|
||||
href={googleMapsUrl(postcode, destination)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
|
|
@ -325,17 +339,20 @@ export default function JourneyInstructions({
|
|||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : j.minutes != null ? (
|
||||
) : totalMin > 0 ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 py-0.5">
|
||||
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
|
||||
{!isBestCase && (
|
||||
<WalkingIcon className="w-3.5 h-3.5 text-warm-500 dark:text-warm-400 shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-warm-600 dark:text-warm-300">
|
||||
{t('areaPane.walk')} · {j.minutes} {t('common.min')}
|
||||
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
|
||||
{t('common.min')}
|
||||
</span>
|
||||
</div>
|
||||
{showGoogleMapsLink && (
|
||||
<a
|
||||
href={googleMapsUrl(postcode, j.label || j.slug)}
|
||||
href={googleMapsUrl(postcode, destination)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PostcodeGeometry, Property } from '../../types';
|
||||
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
|
||||
import type { SearchedLocation } from './LocationSearch';
|
||||
import { useMapData } from '../../hooks/useMapData';
|
||||
import { usePOIData } from '../../hooks/usePOIData';
|
||||
|
|
@ -19,6 +19,7 @@ import { useFilterCounts } from '../../hooks/useFilterCounts';
|
|||
import { trackEvent } from '../../lib/analytics';
|
||||
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
|
||||
import { useLicense } from '../../hooks/useLicense';
|
||||
import { stateToParams } from '../../lib/url-state';
|
||||
import {
|
||||
AreaPane,
|
||||
Filters,
|
||||
|
|
@ -30,7 +31,7 @@ import { PaneFallback } from './map-page/Fallbacks';
|
|||
import { DesktopMapPage } from './map-page/DesktopMapPage';
|
||||
import { MobileMapPage } from './map-page/MobileMapPage';
|
||||
import { ScreenshotMapPage } from './map-page/ScreenshotMapPage';
|
||||
import { BookmarkToast, ExportToast } from './map-page/Toasts';
|
||||
import { ExportToast } from './map-page/Toasts';
|
||||
import { MobileMapLegend } from './map-page/MobileMapLegend';
|
||||
import { useExportController } from './map-page/useExportController';
|
||||
import {
|
||||
|
|
@ -66,6 +67,7 @@ export default function MapPage({
|
|||
onClearPendingInfoFeature,
|
||||
onNavigateTo,
|
||||
onExportStateChange,
|
||||
onDashboardParamsChange,
|
||||
screenshotMode,
|
||||
ogMode,
|
||||
isMobile = false,
|
||||
|
|
@ -75,10 +77,8 @@ export default function MapPage({
|
|||
user,
|
||||
onLoginClick,
|
||||
onRegisterClick,
|
||||
onSaveProperty,
|
||||
onUnsaveProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
onCheckoutLoginClick,
|
||||
onCheckoutRegisterClick,
|
||||
deferTutorial = false,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
|
|
@ -92,19 +92,6 @@ export default function MapPage({
|
|||
const [mobileBottomSheetHeight, setMobileBottomSheetHeight] = useState(0);
|
||||
const [poiPaneOpen, setPoiPaneOpen] = useState(false);
|
||||
const [currentLocation, setCurrentLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [showBookmarkToast, setShowBookmarkToast] = useState(false);
|
||||
const bookmarkToastDismissed = useRef(localStorage.getItem('bookmark_toast_dismissed') === '1');
|
||||
|
||||
const handleSavePropertyWithToast = useCallback(
|
||||
(property: Property) => {
|
||||
onSaveProperty?.(property);
|
||||
if (!bookmarkToastDismissed.current) {
|
||||
setShowBookmarkToast(true);
|
||||
bookmarkToastDismissed.current = true;
|
||||
}
|
||||
},
|
||||
[onSaveProperty]
|
||||
);
|
||||
|
||||
const {
|
||||
filters,
|
||||
|
|
@ -155,6 +142,22 @@ export default function MapPage({
|
|||
const pendingLocationSearchFlyToRef = useRef<PendingFlyTo | null>(null);
|
||||
const mobileDrawerPanelRectRef = useRef<DOMRectReadOnly | null>(null);
|
||||
|
||||
const getMobileMapFlyToOptions = useCallback((): MapFlyToOptions | undefined => {
|
||||
if (!isMobile) return undefined;
|
||||
|
||||
const panelRect = mobileDrawerPanelRectRef.current;
|
||||
if (mobileDrawerOpen && panelRect) {
|
||||
const bottomInset = Math.max(0, window.innerHeight - panelRect.top);
|
||||
if (bottomInset > 0) {
|
||||
return { visibleViewportArea: { bottom: bottomInset } };
|
||||
}
|
||||
}
|
||||
|
||||
return mobileBottomSheetHeight > 0
|
||||
? { visibleArea: { bottom: mobileBottomSheetHeight } }
|
||||
: undefined;
|
||||
}, [isMobile, mobileBottomSheetHeight, mobileDrawerOpen]);
|
||||
|
||||
const mapData = useMapData({
|
||||
filters,
|
||||
features,
|
||||
|
|
@ -209,7 +212,8 @@ export default function MapPage({
|
|||
mapFlyToRef.current?.(
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom
|
||||
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
|
||||
getMobileMapFlyToOptions()
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -220,6 +224,7 @@ export default function MapPage({
|
|||
activeEntries,
|
||||
fetchAiFilters,
|
||||
filters,
|
||||
getMobileMapFlyToOptions,
|
||||
handleSetEntries,
|
||||
handleSetFilters,
|
||||
mapData.currentView?.zoom,
|
||||
|
|
@ -251,17 +256,22 @@ export default function MapPage({
|
|||
[handleDragEndNoCommit, handleTimeRangeChange]
|
||||
);
|
||||
|
||||
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries);
|
||||
const filterCounts = useFilterCounts(filters, features, mapData.bounds, entries, shareCode);
|
||||
const license = useLicense();
|
||||
|
||||
const handleTravelTimeSetDestination = useCallback(
|
||||
(index: number, slug: string, label: string, lat: number, lon: number) => {
|
||||
handleSetDestination(index, slug, label);
|
||||
if (slug) {
|
||||
mapFlyToRef.current?.(lat, lon, mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom);
|
||||
mapFlyToRef.current?.(
|
||||
lat,
|
||||
lon,
|
||||
mapData.currentView?.zoom ?? INITIAL_VIEW_STATE.zoom,
|
||||
getMobileMapFlyToOptions()
|
||||
);
|
||||
}
|
||||
},
|
||||
[handleSetDestination, mapData.currentView?.zoom]
|
||||
[getMobileMapFlyToOptions, handleSetDestination, mapData.currentView?.zoom]
|
||||
);
|
||||
|
||||
const journeyDest = useJourneyDestination(entries);
|
||||
|
|
@ -430,9 +440,11 @@ export default function MapPage({
|
|||
useScreenshotReadySignal({
|
||||
screenshotMode,
|
||||
loading: mapData.loading,
|
||||
boundsReady: mapData.bounds != null,
|
||||
dataLength: mapData.data.length,
|
||||
postcodeDataLength: mapData.postcodeData.length,
|
||||
usePostcodeView: mapData.usePostcodeView,
|
||||
licenseRequired: mapData.licenseRequired,
|
||||
});
|
||||
|
||||
const handleMobileHexagonClick = useCallback(
|
||||
|
|
@ -462,10 +474,42 @@ export default function MapPage({
|
|||
bounds: mapData.bounds,
|
||||
filters,
|
||||
features,
|
||||
travelTimeEntries: entries,
|
||||
shareCode,
|
||||
t,
|
||||
onExportStateChange,
|
||||
});
|
||||
|
||||
const dashboardParams = useMemo(
|
||||
() =>
|
||||
stateToParams(
|
||||
mapData.currentView,
|
||||
filters,
|
||||
features,
|
||||
selectedPOICategories,
|
||||
rightPaneTab,
|
||||
entries,
|
||||
shareCode
|
||||
).toString(),
|
||||
[
|
||||
entries,
|
||||
features,
|
||||
filters,
|
||||
mapData.currentView,
|
||||
rightPaneTab,
|
||||
selectedPOICategories,
|
||||
shareCode,
|
||||
]
|
||||
);
|
||||
const checkoutReturnPath = useMemo(
|
||||
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
|
||||
[dashboardParams]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onDashboardParamsChange?.(dashboardParams);
|
||||
}, [dashboardParams, onDashboardParamsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown');
|
||||
}, [mapData.licenseRequired]);
|
||||
|
|
@ -501,6 +545,7 @@ export default function MapPage({
|
|||
statsUseFilters={areaStatsUseFilters}
|
||||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||||
travelTimeEntries={activeEntries}
|
||||
shareCode={shareCode}
|
||||
isGroupExpanded={isAreaGroupExpanded}
|
||||
onToggleGroup={toggleAreaGroup}
|
||||
/>
|
||||
|
|
@ -515,10 +560,6 @@ export default function MapPage({
|
|||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
onSaveProperty={onSaveProperty ? handleSavePropertyWithToast : undefined}
|
||||
onUnsaveProperty={onUnsaveProperty}
|
||||
isPropertySaved={isPropertySaved}
|
||||
getSavedPropertyId={getSavedPropertyId}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
@ -589,40 +630,25 @@ export default function MapPage({
|
|||
}
|
||||
};
|
||||
|
||||
const bookmarkToast = (
|
||||
<BookmarkToast
|
||||
show={showBookmarkToast}
|
||||
onViewSaved={() => {
|
||||
setShowBookmarkToast(false);
|
||||
onNavigateTo('saved', 'properties');
|
||||
}}
|
||||
onDismissForever={() => {
|
||||
setShowBookmarkToast(false);
|
||||
localStorage.setItem('bookmark_toast_dismissed', '1');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const exportToast = (
|
||||
<ExportToast
|
||||
notice={exportNotice}
|
||||
offsetForBookmark={showBookmarkToast}
|
||||
closeLabel={t('common.close')}
|
||||
onClose={clearExportNotice}
|
||||
/>
|
||||
);
|
||||
const toasts = (
|
||||
<>
|
||||
{bookmarkToast}
|
||||
{exportToast}
|
||||
</>
|
||||
);
|
||||
const toasts = exportToast;
|
||||
const upgradeModal = mapData.licenseRequired ? (
|
||||
<Suspense fallback={null}>
|
||||
<UpgradeModal
|
||||
isLoggedIn={!!user}
|
||||
onLoginClick={onLoginClick}
|
||||
onRegisterClick={onRegisterClick}
|
||||
onStartCheckout={() => license.startCheckout()}
|
||||
onLoginClick={() =>
|
||||
onCheckoutLoginClick ? onCheckoutLoginClick(checkoutReturnPath) : onLoginClick()
|
||||
}
|
||||
onRegisterClick={() =>
|
||||
onCheckoutRegisterClick ? onCheckoutRegisterClick(checkoutReturnPath) : onRegisterClick()
|
||||
}
|
||||
onStartCheckout={() => license.startCheckout(checkoutReturnPath)}
|
||||
onZoomToFreeZone={handleZoomToFreeZone}
|
||||
isShareReturn={!!shareReturnViewRef.current}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Property } from '../../types';
|
||||
import { formatDuration, formatAge, formatNumber, formatTransactionDate } from '../../lib/format';
|
||||
|
|
@ -7,7 +7,6 @@ import InfoPopup from '../ui/InfoPopup';
|
|||
import { SearchInput } from '../ui/SearchInput';
|
||||
import { EmptyState } from '../ui/EmptyState';
|
||||
import { InfoIcon } from '../ui/icons';
|
||||
import { BookmarkIcon } from '../ui/icons/BookmarkIcon';
|
||||
import { ts } from '../../i18n/server';
|
||||
|
||||
interface PropertiesPaneProps {
|
||||
|
|
@ -17,10 +16,6 @@ interface PropertiesPaneProps {
|
|||
hexagonId: string | null;
|
||||
onLoadMore: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
onUnsaveProperty?: (id: string) => void;
|
||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
||||
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
|
||||
}
|
||||
|
||||
export function PropertiesPane({
|
||||
|
|
@ -30,15 +25,15 @@ export function PropertiesPane({
|
|||
hexagonId,
|
||||
onLoadMore,
|
||||
onNavigateToSource,
|
||||
onSaveProperty,
|
||||
onUnsaveProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
}: PropertiesPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch('');
|
||||
}, [hexagonId]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return query
|
||||
|
|
@ -100,14 +95,7 @@ export function PropertiesPane({
|
|||
) : (
|
||||
<>
|
||||
{filtered.map((property, idx) => (
|
||||
<PropertyCard
|
||||
key={idx}
|
||||
property={property}
|
||||
onSave={onSaveProperty}
|
||||
onUnsave={onUnsaveProperty}
|
||||
isSaved={isPropertySaved?.(property.address, property.postcode)}
|
||||
savedId={getSavedPropertyId?.(property.address, property.postcode)}
|
||||
/>
|
||||
<PropertyCard key={idx} property={property} />
|
||||
))}
|
||||
{properties.length < total && (
|
||||
<button
|
||||
|
|
@ -151,27 +139,8 @@ function PropertyLoadingSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
function PropertyCard({
|
||||
property,
|
||||
onSave,
|
||||
onUnsave,
|
||||
isSaved,
|
||||
savedId,
|
||||
}: {
|
||||
property: Property;
|
||||
onSave?: (property: Property) => void;
|
||||
onUnsave?: (id: string) => void;
|
||||
isSaved?: boolean;
|
||||
savedId?: string;
|
||||
}) {
|
||||
function PropertyCard({ property }: { property: Property }) {
|
||||
const { t } = useTranslation();
|
||||
const handleToggleSave = useCallback(() => {
|
||||
if (isSaved && savedId && onUnsave) {
|
||||
onUnsave(savedId);
|
||||
} else if (onSave) {
|
||||
onSave(property);
|
||||
}
|
||||
}, [isSaved, savedId, onSave, onUnsave, property]);
|
||||
const price = getNum(property, 'Last known price');
|
||||
const estimatedPrice = getNum(property, 'Estimated current price');
|
||||
const pricePerSqm = getNum(property, 'Price per sqm');
|
||||
|
|
@ -197,19 +166,6 @@ function PropertyCard({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onSave && (
|
||||
<button
|
||||
onClick={handleToggleSave}
|
||||
className={`shrink-0 p-1 rounded ${
|
||||
isSaved
|
||||
? 'text-teal-600 dark:text-teal-400'
|
||||
: 'text-warm-300 dark:text-warm-600 hover:text-warm-500 dark:hover:text-warm-400'
|
||||
}`}
|
||||
title={isSaved ? t('propertyCard.unsaveProperty') : t('propertyCard.saveProperty')}
|
||||
>
|
||||
<BookmarkIcon className="w-4 h-4" filled={isSaved} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{property.property_sub_type && (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Slider } from '../ui/Slider';
|
||||
import { IconButton } from '../ui/IconButton';
|
||||
import { PillToggle } from '../ui/PillToggle';
|
||||
|
|
@ -134,10 +134,9 @@ export function TravelTimeCard({
|
|||
|
||||
{showBestInfo && (
|
||||
<InfoPopup title={t('travel.bestCaseTitle')} onClose={() => setShowBestInfo(false)}>
|
||||
<p
|
||||
className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: t('travel.bestCaseDesc') }}
|
||||
/>
|
||||
<p className="text-sm text-warm-700 dark:text-warm-300 leading-relaxed">
|
||||
<Trans i18nKey="travel.bestCaseDesc" components={{ strong: <strong /> }} />
|
||||
</p>
|
||||
</InfoPopup>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,56 +1,22 @@
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { ExportNotice } from './types';
|
||||
import { BookmarkIcon } from '../../ui/icons/BookmarkIcon';
|
||||
import { CheckIcon } from '../../ui/icons/CheckIcon';
|
||||
import { CloseIcon } from '../../ui/icons/CloseIcon';
|
||||
import { InfoIcon } from '../../ui/icons/InfoIcon';
|
||||
|
||||
interface BookmarkToastProps {
|
||||
show: boolean;
|
||||
onViewSaved: () => void;
|
||||
onDismissForever: () => void;
|
||||
}
|
||||
|
||||
export function BookmarkToast({ show, onViewSaved, onDismissForever }: BookmarkToastProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] flex items-center gap-3 px-4 py-3 rounded-lg bg-navy-900 text-white text-sm shadow-lg animate-fade-in">
|
||||
<BookmarkIcon className="w-4 h-4 text-teal-400 shrink-0" filled />
|
||||
<span>{t('toasts.propertySaved')}</span>
|
||||
<button
|
||||
onClick={onViewSaved}
|
||||
className="px-3 py-1 rounded bg-teal-600 hover:bg-teal-500 text-white text-xs font-medium whitespace-nowrap"
|
||||
>
|
||||
{t('toasts.viewSaved')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismissForever}
|
||||
className="text-warm-400 hover:text-warm-200 text-xs whitespace-nowrap"
|
||||
>
|
||||
{t('toasts.dontShowAgain')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportToastProps {
|
||||
notice: ExportNotice | null;
|
||||
offsetForBookmark: boolean;
|
||||
closeLabel: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportToast({ notice, offsetForBookmark, closeLabel, onClose }: ExportToastProps) {
|
||||
export function ExportToast({ notice, closeLabel, onClose }: ExportToastProps) {
|
||||
if (!notice) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role={notice.kind === 'error' ? 'alert' : 'status'}
|
||||
aria-live={notice.kind === 'error' ? 'assertive' : 'polite'}
|
||||
className={`fixed ${offsetForBookmark ? 'bottom-24' : 'bottom-6'} left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in`}
|
||||
className="fixed bottom-6 left-1/2 z-[60] flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center gap-3 rounded-lg bg-navy-900 px-4 py-3 text-sm text-white shadow-lg animate-fade-in"
|
||||
>
|
||||
{notice.kind === 'success' ? (
|
||||
<CheckIcon className="h-4 w-4 shrink-0 text-teal-400" />
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { MapFlyTo } from './types';
|
|||
|
||||
type MapData = ReturnType<typeof useMapData>;
|
||||
type RightPaneTab = 'properties' | 'area';
|
||||
const SCREENSHOT_MAP_IDLE_FALLBACK_MS = 1000;
|
||||
|
||||
export function useInitialMapPageView(
|
||||
mapData: MapData,
|
||||
|
|
@ -110,36 +111,72 @@ export function useMobileBackNavigationGuard(isMobile: boolean) {
|
|||
interface UseScreenshotReadySignalOptions {
|
||||
screenshotMode?: boolean;
|
||||
loading: boolean;
|
||||
boundsReady: boolean;
|
||||
dataLength: number;
|
||||
postcodeDataLength: number;
|
||||
usePostcodeView: boolean;
|
||||
licenseRequired: boolean;
|
||||
}
|
||||
|
||||
export function useScreenshotReadySignal({
|
||||
screenshotMode,
|
||||
loading,
|
||||
boundsReady,
|
||||
dataLength,
|
||||
postcodeDataLength,
|
||||
usePostcodeView,
|
||||
licenseRequired,
|
||||
}: UseScreenshotReadySignalOptions) {
|
||||
useEffect(() => {
|
||||
if (screenshotMode && !loading) {
|
||||
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
|
||||
if (hasData) {
|
||||
// Wait for both deck.gl data and MapLibre base map tile rendering.
|
||||
const waitAndSignal = () => {
|
||||
if (window.__map_idle) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
window.__screenshot_ready = true;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
requestAnimationFrame(waitAndSignal);
|
||||
}
|
||||
};
|
||||
waitAndSignal();
|
||||
if (!screenshotMode || loading || !boundsReady) return;
|
||||
|
||||
const hasData = usePostcodeView ? postcodeDataLength > 0 : dataLength > 0;
|
||||
if (!hasData && !licenseRequired) return;
|
||||
|
||||
let cancelled = false;
|
||||
let signalled = false;
|
||||
let frameId: number | null = null;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
const signalReady = () => {
|
||||
if (cancelled || signalled) return;
|
||||
signalled = true;
|
||||
if (timeoutId != null) window.clearTimeout(timeoutId);
|
||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) window.__screenshot_ready = true;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const waitAndSignal = () => {
|
||||
if (window.__map_idle) {
|
||||
signalReady();
|
||||
} else {
|
||||
frameId = requestAnimationFrame(waitAndSignal);
|
||||
}
|
||||
}
|
||||
}, [screenshotMode, loading, dataLength, postcodeDataLength, usePostcodeView]);
|
||||
};
|
||||
|
||||
// In webpack dev mode MapLibre's idle event can be delayed by the dev
|
||||
// client/HMR churn even after data has rendered. Keep production-quality
|
||||
// waiting when idle fires, but avoid forcing the screenshot service to hit
|
||||
// its much longer timeout in local development.
|
||||
timeoutId = window.setTimeout(signalReady, SCREENSHOT_MAP_IDLE_FALLBACK_MS);
|
||||
waitAndSignal();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timeoutId != null) window.clearTimeout(timeoutId);
|
||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [
|
||||
screenshotMode,
|
||||
loading,
|
||||
boundsReady,
|
||||
dataLength,
|
||||
postcodeDataLength,
|
||||
usePostcodeView,
|
||||
licenseRequired,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type {
|
|||
FeatureMeta,
|
||||
MapFlyToOptions,
|
||||
POICategoryGroup,
|
||||
Property,
|
||||
ViewState,
|
||||
} from '../../../types';
|
||||
import type { TravelTimeInitial } from '../../../hooks/useTravelTime';
|
||||
|
|
@ -33,6 +32,7 @@ export interface MapPageProps {
|
|||
onClearPendingInfoFeature: () => void;
|
||||
onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void;
|
||||
onExportStateChange?: (state: ExportState) => void;
|
||||
onDashboardParamsChange?: (params: string) => void;
|
||||
screenshotMode?: boolean;
|
||||
ogMode?: boolean;
|
||||
isMobile?: boolean;
|
||||
|
|
@ -42,10 +42,8 @@ export interface MapPageProps {
|
|||
user?: { id: string; subscription: string; isAdmin?: boolean } | null;
|
||||
onLoginClick: () => void;
|
||||
onRegisterClick: () => void;
|
||||
onSaveProperty?: (property: Property) => void;
|
||||
onUnsaveProperty?: (id: string) => void;
|
||||
isPropertySaved?: (address?: string, postcode?: string) => boolean;
|
||||
getSavedPropertyId?: (address?: string, postcode?: string) => string | undefined;
|
||||
onCheckoutLoginClick?: (returnPath?: string) => void;
|
||||
onCheckoutRegisterClick?: (returnPath?: string) => void;
|
||||
deferTutorial?: boolean;
|
||||
onSaveSearch?: (name: string) => Promise<void>;
|
||||
savingSearch?: boolean;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import type { Bounds, FeatureFilters, FeatureMeta } from '../../../types';
|
|||
import { apiUrl, authHeaders, buildFilterString, logNonAbortError } from '../../../lib/api';
|
||||
import { trackEvent } from '../../../lib/analytics';
|
||||
import type { ExportNotice, ExportState } from './types';
|
||||
import type { TravelTimeEntry } from '../../../hooks/useTravelTime';
|
||||
import { buildTravelParam } from '../../../lib/travel-params';
|
||||
|
||||
const EXPORT_FILE_NAME = 'perfect-postcode-export.xlsx';
|
||||
const EXPORT_TIMEOUT_MS = 150_000;
|
||||
|
|
@ -65,10 +67,24 @@ function triggerExportDownload(blob: Blob, fileName: string): void {
|
|||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
|
||||
function appendTravelStateParams(params: URLSearchParams, entries: TravelTimeEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
if (!entry.slug) continue;
|
||||
let value = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
|
||||
if (entry.useBest) value += ':b';
|
||||
if (entry.timeRange) {
|
||||
value += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
}
|
||||
params.append('tt', value);
|
||||
}
|
||||
}
|
||||
|
||||
interface UseExportControllerOptions {
|
||||
bounds: Bounds | null;
|
||||
filters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
travelTimeEntries: TravelTimeEntry[];
|
||||
shareCode?: string;
|
||||
t: TFunction;
|
||||
onExportStateChange?: (state: ExportState) => void;
|
||||
}
|
||||
|
|
@ -77,6 +93,8 @@ export function useExportController({
|
|||
bounds,
|
||||
filters,
|
||||
features,
|
||||
travelTimeEntries,
|
||||
shareCode,
|
||||
t,
|
||||
onExportStateChange,
|
||||
}: UseExportControllerOptions) {
|
||||
|
|
@ -126,6 +144,10 @@ export function useExportController({
|
|||
});
|
||||
const filterStr = buildFilterString(filters, features);
|
||||
if (filterStr) params.set('filters', filterStr);
|
||||
const travelParam = buildTravelParam(travelTimeEntries);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
appendTravelStateParams(params, travelTimeEntries);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
const url = apiUrl('export', params);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
|
@ -161,7 +183,17 @@ export function useExportController({
|
|||
setExporting(false);
|
||||
}
|
||||
})();
|
||||
}, [bounds, clearExportNotice, exporting, features, filters, showExportNotice, t]);
|
||||
}, [
|
||||
bounds,
|
||||
clearExportNotice,
|
||||
exporting,
|
||||
features,
|
||||
filters,
|
||||
shareCode,
|
||||
showExportNotice,
|
||||
t,
|
||||
travelTimeEntries,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onExportStateChange?.({ onExport: handleExport, exporting });
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export default function PricingPage({
|
|||
<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"
|
||||
className="w-full mt-auto px-5 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/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
|
||||
|
|
@ -129,7 +129,7 @@ export default function PricingPage({
|
|||
) : (
|
||||
<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"
|
||||
className="w-full mt-auto px-5 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors shadow-lg shadow-[#7a3905]/25"
|
||||
>
|
||||
{isFree ? t('upgrade.claimFreeAccess') : t('pricingPage.getStarted')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ type View = 'login' | 'register' | 'forgot';
|
|||
|
||||
export default function AuthModal({
|
||||
onClose,
|
||||
onAuthenticated,
|
||||
onLogin,
|
||||
onRegister,
|
||||
onOAuthLogin,
|
||||
|
|
@ -18,6 +19,7 @@ export default function AuthModal({
|
|||
initialTab = 'login',
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onAuthenticated?: () => void;
|
||||
onLogin: (email: string, password: string) => Promise<void>;
|
||||
onRegister: (email: string, password: string) => Promise<void>;
|
||||
onOAuthLogin: (provider: string) => Promise<void>;
|
||||
|
|
@ -52,9 +54,11 @@ export default function AuthModal({
|
|||
try {
|
||||
if (view === 'login') {
|
||||
await onLogin(email, password);
|
||||
onAuthenticated?.();
|
||||
onClose();
|
||||
} else if (view === 'register') {
|
||||
await onRegister(email, password);
|
||||
onAuthenticated?.();
|
||||
onClose();
|
||||
} else {
|
||||
await onForgotPassword(email);
|
||||
|
|
@ -64,19 +68,20 @@ export default function AuthModal({
|
|||
// Error is handled by the hook
|
||||
}
|
||||
},
|
||||
[view, email, password, onLogin, onRegister, onForgotPassword, onClose]
|
||||
[view, email, password, onLogin, onRegister, onForgotPassword, onAuthenticated, onClose]
|
||||
);
|
||||
|
||||
const handleOAuth = useCallback(
|
||||
async (provider: string) => {
|
||||
try {
|
||||
await onOAuthLogin(provider);
|
||||
onAuthenticated?.();
|
||||
onClose();
|
||||
} catch {
|
||||
// Error is handled by the hook
|
||||
}
|
||||
},
|
||||
[onOAuthLogin, onClose]
|
||||
[onOAuthLogin, onAuthenticated, onClose]
|
||||
);
|
||||
|
||||
const title =
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export type Page =
|
|||
| 'privacy-security'
|
||||
| 'account'
|
||||
| 'saved'
|
||||
| 'invites'
|
||||
| 'invite';
|
||||
|
||||
export interface HeaderExportState {
|
||||
|
|
@ -59,7 +58,6 @@ export const PAGE_PATHS: Record<Page, string> = {
|
|||
methodology: '/methodology',
|
||||
'privacy-security': '/privacy-security',
|
||||
saved: '/saved',
|
||||
invites: '/invites',
|
||||
account: '/account',
|
||||
invite: '/invite',
|
||||
};
|
||||
|
|
@ -68,10 +66,12 @@ const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023p
|
|||
|
||||
export default function Header({
|
||||
activePage,
|
||||
activeHash,
|
||||
onPageChange,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
exportState,
|
||||
dashboardParams,
|
||||
onSaveSearch,
|
||||
savingSearch,
|
||||
user,
|
||||
|
|
@ -81,10 +81,12 @@ export default function Header({
|
|||
isMobile,
|
||||
}: {
|
||||
activePage: Page;
|
||||
onPageChange: (page: Page) => void;
|
||||
activeHash: string;
|
||||
onPageChange: (page: Page, hash?: string) => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
exportState: HeaderExportState | null;
|
||||
dashboardParams: string;
|
||||
onSaveSearch: (() => void) | null;
|
||||
savingSearch: boolean;
|
||||
user: AuthUser | null;
|
||||
|
|
@ -132,7 +134,8 @@ export default function Header({
|
|||
}, []);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
const params = window.location.search.replace(/^\?/, '');
|
||||
const params =
|
||||
activePage === 'dashboard' ? dashboardParams : window.location.search.replace(/^\?/, '');
|
||||
if (!params) {
|
||||
doCopy(window.location.href);
|
||||
return;
|
||||
|
|
@ -147,17 +150,18 @@ export default function Header({
|
|||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
}, [doCopy]);
|
||||
}, [activePage, dashboardParams, doCopy]);
|
||||
|
||||
const navLink = (page: Page, e: React.MouseEvent) => {
|
||||
const navLink = (page: Page, e: React.MouseEvent, hash?: string) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
onPageChange(page, hash);
|
||||
};
|
||||
|
||||
const tabClass = (page: Page) =>
|
||||
const tabClass = (page: Page, hash?: string) =>
|
||||
`inline-flex cursor-pointer items-center px-4 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
activePage === page
|
||||
activePage === page &&
|
||||
(hash ? activeHash === hash : page !== 'account' || activeHash !== 'invites')
|
||||
? 'bg-navy-700 text-white'
|
||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`;
|
||||
|
|
@ -188,9 +192,9 @@ export default function Header({
|
|||
</a>
|
||||
{user && (
|
||||
<a
|
||||
href={PAGE_PATHS.invites}
|
||||
className={tabClass('invites')}
|
||||
onClick={(e) => navLink('invites', e)}
|
||||
href={`${PAGE_PATHS.account}#invites`}
|
||||
className={tabClass('account', 'invites')}
|
||||
onClick={(e) => navLink('account', e, 'invites')}
|
||||
>
|
||||
{t('header.inviteFriends')}
|
||||
</a>
|
||||
|
|
@ -354,6 +358,7 @@ export default function Header({
|
|||
{useSidebarNav && menuOpen && (
|
||||
<MobileMenu
|
||||
activePage={activePage}
|
||||
activeHash={activeHash}
|
||||
onPageChange={onPageChange}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SpinnerIcon } from './icons/SpinnerIcon';
|
||||
|
||||
interface LicenseSuccessModalProps {
|
||||
onClose: () => void;
|
||||
status?: 'verifying' | 'success' | 'delayed';
|
||||
}
|
||||
|
||||
export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProps) {
|
||||
export default function LicenseSuccessModal({
|
||||
onClose,
|
||||
status = 'success',
|
||||
}: LicenseSuccessModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isSuccess = status === 'success';
|
||||
const isVerifying = status === 'verifying';
|
||||
const particles = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 40 }, (_, i) => ({
|
||||
|
|
@ -24,47 +31,75 @@ export default function LicenseSuccessModal({ onClose }: LicenseSuccessModalProp
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess) return;
|
||||
const timer = setTimeout(onClose, 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
}, [isSuccess, onClose]);
|
||||
|
||||
const title =
|
||||
status === 'verifying'
|
||||
? t('licenseSuccess.verifyingTitle')
|
||||
: status === 'delayed'
|
||||
? t('licenseSuccess.activationDelayedTitle')
|
||||
: t('licenseSuccess.title');
|
||||
const subtitle =
|
||||
status === 'verifying'
|
||||
? t('licenseSuccess.verifyingSubtitle')
|
||||
: status === 'delayed'
|
||||
? t('licenseSuccess.activationDelayedSubtitle')
|
||||
: t('licenseSuccess.subtitle');
|
||||
const description =
|
||||
status === 'verifying'
|
||||
? t('licenseSuccess.verifyingDescription')
|
||||
: status === 'delayed'
|
||||
? t('licenseSuccess.activationDelayedDescription')
|
||||
: t('licenseSuccess.description');
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="absolute animate-confetti"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
top: '-10px',
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
backgroundColor: p.color,
|
||||
borderRadius: p.isCircle ? '50%' : '2px',
|
||||
animationDelay: `${p.delay}s`,
|
||||
animationDuration: `${p.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isSuccess && (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{particles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="absolute animate-confetti"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
top: '-10px',
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
backgroundColor: p.color,
|
||||
borderRadius: p.isCircle ? '50%' : '2px',
|
||||
animationDelay: `${p.delay}s`,
|
||||
animationDuration: `${p.duration}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">{t('licenseSuccess.title')}</h2>
|
||||
<p className="text-warm-300 text-sm mt-2">{t('licenseSuccess.subtitle')}</p>
|
||||
<div className="h-14 mb-3 flex items-center justify-center">
|
||||
{isVerifying ? (
|
||||
<SpinnerIcon className="w-10 h-10 animate-spin text-teal-300" />
|
||||
) : (
|
||||
<div className="text-5xl">{isSuccess ? '🎉' : '✓'}</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-white">{title}</h2>
|
||||
<p className="text-warm-300 text-sm mt-2">{subtitle}</p>
|
||||
</div>
|
||||
<div className="px-6 py-6">
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">
|
||||
{t('licenseSuccess.description')}
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
|
||||
>
|
||||
{t('licenseSuccess.startExploring')}
|
||||
</button>
|
||||
<p className="text-warm-600 dark:text-warm-300 text-sm mb-6">{description}</p>
|
||||
{!isVerifying && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-6 py-3 bg-teal-600 text-white rounded-lg font-semibold hover:bg-teal-700 transition-colors text-lg"
|
||||
>
|
||||
{isSuccess ? t('licenseSuccess.startExploring') : t('licenseSuccess.stayOnPricing')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import { SpinnerIcon } from './icons/SpinnerIcon';
|
|||
|
||||
interface MobileMenuProps {
|
||||
activePage: Page;
|
||||
onPageChange: (page: Page) => void;
|
||||
activeHash: string;
|
||||
onPageChange: (page: Page, hash?: string) => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
exportState: HeaderExportState | null;
|
||||
|
|
@ -32,6 +33,7 @@ interface MobileMenuProps {
|
|||
|
||||
export default function MobileMenu({
|
||||
activePage,
|
||||
activeHash,
|
||||
onPageChange,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
|
|
@ -52,25 +54,30 @@ export default function MobileMenu({
|
|||
const emailLocal = emailParts?.[0] ?? '';
|
||||
const emailDomain = emailParts && emailParts.length > 1 ? emailParts.slice(1).join('@') : '';
|
||||
|
||||
const mobileNavItem = (page: Page, label: string) => (
|
||||
<a
|
||||
key={page}
|
||||
href={PAGE_PATHS[page]}
|
||||
className={`block w-full cursor-pointer text-left px-3 py-2 text-sm font-medium rounded ${
|
||||
activePage === page
|
||||
? 'bg-navy-700 text-white'
|
||||
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
onPageChange(page);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
const mobileNavItem = (page: Page, label: string, hash?: string) => {
|
||||
const isActive =
|
||||
activePage === page &&
|
||||
(hash ? activeHash === hash : page !== 'account' || activeHash !== 'invites');
|
||||
const href = hash ? `${PAGE_PATHS[page]}#${hash}` : PAGE_PATHS[page];
|
||||
|
||||
return (
|
||||
<a
|
||||
key={hash ? `${page}-${hash}` : page}
|
||||
href={href}
|
||||
className={`block w-full cursor-pointer text-left px-3 py-2 text-sm font-medium rounded ${
|
||||
isActive ? 'bg-navy-700 text-white' : 'text-warm-300 hover:bg-navy-800 hover:text-white'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
onPageChange(page, hash);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const dashboardActionClass =
|
||||
'w-full flex cursor-pointer items-center justify-center gap-2 px-3 py-2 rounded bg-navy-800 text-sm font-semibold text-white border border-navy-700 shadow-sm hover:bg-navy-700 disabled:opacity-50 transition-colors';
|
||||
|
|
@ -169,7 +176,7 @@ export default function MobileMenu({
|
|||
{user?.subscription !== 'licensed' &&
|
||||
!user?.isAdmin &&
|
||||
mobileNavItem('pricing', t('header.pricing'))}
|
||||
{user && mobileNavItem('invites', t('header.inviteFriends'))}
|
||||
{user && mobileNavItem('account', t('header.inviteFriends'), 'invites')}
|
||||
{user && mobileNavItem('account', t('userMenu.account'))}
|
||||
{activePage !== 'dashboard' && user && mobileNavItem('saved', t('header.saved'))}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function SubNav({ tabs, activeTab, onTabChange }: SubNavProps) {
|
|||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 ${
|
||||
className={`cursor-pointer px-4 py-2 text-sm font-medium border-b-2 ${
|
||||
activeTab === tab.key
|
||||
? 'border-teal-500 text-teal-700 dark:text-teal-400'
|
||||
: 'border-transparent text-warm-500 dark:text-warm-400 hover:text-warm-700 dark:hover:text-warm-300'
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export default function UpgradeModal({
|
|||
<button
|
||||
onClick={handleUpgrade}
|
||||
disabled={loading}
|
||||
className="w-full px-6 py-3 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"
|
||||
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <SpinnerIcon className="w-5 h-5 animate-spin" />}
|
||||
{loading
|
||||
|
|
@ -110,7 +110,7 @@ export default function UpgradeModal({
|
|||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={onRegisterClick}
|
||||
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
|
||||
className="w-full px-6 py-3 border border-[#d27a11] bg-[#f09a22] text-navy-950 rounded-lg font-semibold hover:bg-[#df8614] transition-colors text-lg shadow-lg shadow-[#7a3905]/25"
|
||||
>
|
||||
{t('upgrade.registerAndUpgrade')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { buildFilterString, apiUrl, assertOk, logNonAbortError, authHeaders } fr
|
|||
import { findOverlappingSelectableHexagon } from '../lib/h3-selection';
|
||||
import { SMALLEST_VISIBLE_HEXAGON_RESOLUTION } from '../lib/consts';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import { buildTravelParam } from '../lib/travel-params';
|
||||
|
||||
interface SelectedHexagon {
|
||||
id: string;
|
||||
|
|
@ -91,19 +92,7 @@ export function useHexagonSelection({
|
|||
return propertiesRequestIdRef.current === requestId;
|
||||
}, []);
|
||||
|
||||
const travelParam = useMemo(() => {
|
||||
const segments: string[] = [];
|
||||
for (const entry of travelTimeEntries) {
|
||||
if (!entry.slug) continue;
|
||||
let segment = `${entry.mode}:${entry.slug}`;
|
||||
if (entry.useBest) segment += ':best';
|
||||
if (entry.timeRange) {
|
||||
segment += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join('|');
|
||||
}, [travelTimeEntries]);
|
||||
const travelParam = useMemo(() => buildTravelParam(travelTimeEntries), [travelTimeEntries]);
|
||||
|
||||
const fetchHexagonStats = useCallback(
|
||||
async (
|
||||
|
|
@ -165,9 +154,9 @@ export function useHexagonSelection({
|
|||
);
|
||||
|
||||
const fetchUnfilteredAreaCount = useCallback(
|
||||
async (selection: SelectedHexagon, signal?: AbortSignal) => {
|
||||
async (selection: SelectedHexagon, requestId: number, signal?: AbortSignal) => {
|
||||
if (!hasStatsFilters) {
|
||||
setUnfilteredAreaCount(null);
|
||||
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -175,9 +164,9 @@ export function useHexagonSelection({
|
|||
selection.type === 'postcode'
|
||||
? await fetchPostcodeStats(selection.id, signal, false)
|
||||
: await fetchHexagonStats(selection.id, selection.resolution, signal, undefined, false);
|
||||
setUnfilteredAreaCount(stats.count);
|
||||
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(stats.count);
|
||||
},
|
||||
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters]
|
||||
[fetchHexagonStats, fetchPostcodeStats, hasStatsFilters, isCurrentAreaRequest]
|
||||
);
|
||||
|
||||
const refreshUnfilteredAreaCount = useCallback(
|
||||
|
|
@ -185,18 +174,19 @@ export function useHexagonSelection({
|
|||
selection: SelectedHexagon,
|
||||
statsCount: number,
|
||||
includeFilters: boolean,
|
||||
requestId: number,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
if (!includeFilters || !hasStatsFilters || statsCount > 0) {
|
||||
setUnfilteredAreaCount(null);
|
||||
if (isCurrentAreaRequest(requestId)) setUnfilteredAreaCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchUnfilteredAreaCount(selection, signal).catch((error) =>
|
||||
fetchUnfilteredAreaCount(selection, requestId, signal).catch((error) =>
|
||||
logNonAbortError('Failed to fetch unfiltered area count', error)
|
||||
);
|
||||
},
|
||||
[fetchUnfilteredAreaCount, hasStatsFilters]
|
||||
[fetchUnfilteredAreaCount, hasStatsFilters, isCurrentAreaRequest]
|
||||
);
|
||||
|
||||
const fetchPostcodeLookup = useCallback(async (postcode: string, signal?: AbortSignal) => {
|
||||
|
|
@ -321,6 +311,7 @@ export function useHexagonSelection({
|
|||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
|
||||
|
|
@ -330,7 +321,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch postcode stats', error))
|
||||
.finally(() => {
|
||||
|
|
@ -342,7 +333,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch area stats', error))
|
||||
.finally(() => {
|
||||
|
|
@ -514,6 +505,7 @@ export function useHexagonSelection({
|
|||
nextSelection,
|
||||
nextStats.count,
|
||||
areaStatsUseFilters,
|
||||
requestId,
|
||||
controller.signal
|
||||
);
|
||||
refreshProperties(nextSelection);
|
||||
|
|
@ -569,6 +561,9 @@ export function useHexagonSelection({
|
|||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
invalidatePropertyRequests();
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
|
||||
setLoadingAreaStats(true);
|
||||
let cancelled = false;
|
||||
|
|
@ -589,7 +584,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (cancelled || !isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selectedHexagon, stats.count, areaStatsUseFilters, requestId);
|
||||
// Re-fetch properties if the properties tab is active and the filtered area still has matches.
|
||||
if (areaStatsUseFilters && rightPaneTab === 'properties' && stats.count > 0) {
|
||||
if (selectedHexagon.type === 'postcode') {
|
||||
|
|
@ -620,6 +615,7 @@ export function useHexagonSelection({
|
|||
fetchHexagonProperties,
|
||||
fetchPostcodeProperties,
|
||||
invalidateAreaRequests,
|
||||
invalidatePropertyRequests,
|
||||
isCurrentAreaRequest,
|
||||
refreshUnfilteredAreaCount,
|
||||
]);
|
||||
|
|
@ -656,7 +652,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||
if (openProperties && stats.count > 0) {
|
||||
fetchPostcodeProperties(postcode, 0, focusAddress);
|
||||
}
|
||||
|
|
@ -696,6 +692,7 @@ export function useHexagonSelection({
|
|||
setProperties([]);
|
||||
setPropertiesTotal(0);
|
||||
setPropertiesOffset(0);
|
||||
setAreaStats(null);
|
||||
setUnfilteredAreaCount(null);
|
||||
setRightPaneTab('area');
|
||||
setLoadingAreaStats(true);
|
||||
|
|
@ -710,7 +707,7 @@ export function useHexagonSelection({
|
|||
.then((stats) => {
|
||||
if (!isCurrentAreaRequest(requestId)) return;
|
||||
setAreaStats(stats);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters);
|
||||
refreshUnfilteredAreaCount(selection, stats.count, areaStatsUseFilters, requestId);
|
||||
})
|
||||
.catch((error) => logNonAbortError('Failed to fetch current location hex stats', error))
|
||||
.finally(() => {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
const [pois, setPois] = useState<POI[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
|
||||
if (!bounds || selectedCategories.size === 0) {
|
||||
abortControllerRef.current?.abort();
|
||||
setPois([]);
|
||||
return;
|
||||
}
|
||||
|
|
@ -40,6 +45,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
);
|
||||
if (!res.ok) throw new Error(`POIs fetch failed: HTTP ${res.status}`);
|
||||
const json: POIResponse = await res.json();
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setPois(json.pois || []);
|
||||
} catch (err) {
|
||||
logNonAbortError('Failed to fetch POIs', err);
|
||||
|
|
@ -50,6 +56,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [bounds, selectedCategories]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import pb from '../lib/pocketbase';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import type { Property } from '../types';
|
||||
import { getNum } from '../lib/property-fields';
|
||||
|
||||
export interface SavedPropertyData {
|
||||
propertyType?: string;
|
||||
propertySubType?: string;
|
||||
builtForm?: string;
|
||||
duration?: string;
|
||||
energyRating?: string;
|
||||
price?: number;
|
||||
estimatedPrice?: number;
|
||||
floorArea?: number;
|
||||
}
|
||||
|
||||
export interface SavedProperty {
|
||||
id: string;
|
||||
address: string;
|
||||
postcode: string;
|
||||
data: SavedPropertyData;
|
||||
notes: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export function useSavedProperties(userId: string | null) {
|
||||
const [properties, setProperties] = useState<SavedProperty[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchProperties = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const records = await pb.collection('saved_properties').getFullList({
|
||||
sort: '-created',
|
||||
filter: `user = "${userId}"`,
|
||||
});
|
||||
setProperties(
|
||||
records.map((r) => {
|
||||
const raw = r as Record<string, unknown>;
|
||||
let data: SavedPropertyData = {};
|
||||
try {
|
||||
data =
|
||||
typeof raw.data === 'string'
|
||||
? JSON.parse(raw.data)
|
||||
: (raw.data as SavedPropertyData) || {};
|
||||
} catch {
|
||||
// Invalid JSON — use empty data
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
address: raw.address as string,
|
||||
postcode: raw.postcode as string,
|
||||
data,
|
||||
notes: (raw.notes as string) || '',
|
||||
created: r.created,
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load saved properties');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveProperty = useCallback(
|
||||
async (property: Property) => {
|
||||
if (!userId) return;
|
||||
setError(null);
|
||||
try {
|
||||
const data: SavedPropertyData = {
|
||||
propertyType: property.property_type,
|
||||
propertySubType: property.property_sub_type,
|
||||
builtForm: property.built_form,
|
||||
duration: property.duration,
|
||||
energyRating: property.current_energy_rating,
|
||||
price: getNum(property, 'Last known price'),
|
||||
estimatedPrice: getNum(property, 'Estimated current price'),
|
||||
floorArea: getNum(property, 'Total floor area (sqm)'),
|
||||
};
|
||||
|
||||
await pb.collection('saved_properties').create({
|
||||
user: userId,
|
||||
address: property.address || 'Unknown',
|
||||
postcode: property.postcode || '',
|
||||
data: JSON.stringify(data),
|
||||
});
|
||||
trackEvent('Property Save');
|
||||
await fetchProperties();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to save property';
|
||||
setError(msg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[userId, fetchProperties]
|
||||
);
|
||||
|
||||
const deleteProperty = useCallback(async (id: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await pb.collection('saved_properties').delete(id);
|
||||
trackEvent('Property Delete');
|
||||
setProperties((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete property');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const savedPropertyKeys = useMemo(
|
||||
() => new Set(properties.map((p) => `${p.address}|${p.postcode}`)),
|
||||
[properties]
|
||||
);
|
||||
|
||||
const isPropertySaved = useCallback(
|
||||
(address?: string, postcode?: string) =>
|
||||
savedPropertyKeys.has(`${address || ''}|${postcode || ''}`),
|
||||
[savedPropertyKeys]
|
||||
);
|
||||
|
||||
const getSavedPropertyId = useCallback(
|
||||
(address?: string, postcode?: string) => {
|
||||
const key = `${address || ''}|${postcode || ''}`;
|
||||
return properties.find((p) => `${p.address}|${p.postcode}` === key)?.id;
|
||||
},
|
||||
[properties]
|
||||
);
|
||||
|
||||
const updatePropertyNotes = useCallback(async (id: string, notes: string) => {
|
||||
try {
|
||||
await pb.collection('saved_properties').update(id, { notes });
|
||||
setProperties((prev) => prev.map((p) => (p.id === id ? { ...p, notes } : p)));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update notes');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
properties,
|
||||
loading,
|
||||
error,
|
||||
fetchProperties,
|
||||
saveProperty,
|
||||
deleteProperty,
|
||||
isPropertySaved,
|
||||
getSavedPropertyId,
|
||||
updatePropertyNotes,
|
||||
};
|
||||
}
|
||||
|
|
@ -102,12 +102,12 @@ export function useSavedSearches(userId: string | null) {
|
|||
}, [userId, fetchRecords, startPolling, stopPolling]);
|
||||
|
||||
const saveSearch = useCallback(
|
||||
async (name: string) => {
|
||||
async (name: string, paramsOverride?: string) => {
|
||||
if (!userId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = window.location.search.replace(/^\?/, '');
|
||||
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
|
||||
|
||||
// Create record immediately without screenshot
|
||||
const formData = new FormData();
|
||||
|
|
|
|||
|
|
@ -842,7 +842,7 @@ const de: Translations = {
|
|||
viewPropertiesShort: 'Immobilien ansehen',
|
||||
priceHistory: 'Preisentwicklung',
|
||||
journeysFrom: 'Reisezeiten für {{label}}',
|
||||
to: 'Von {{destination}}',
|
||||
to: 'Nach {{destination}}',
|
||||
noJourneyData: 'Keine Verbindungsdaten verfügbar',
|
||||
viewOnGoogleMaps: 'Auf Google Maps ansehen',
|
||||
walk: 'Zu Fuß',
|
||||
|
|
|
|||
|
|
@ -815,7 +815,7 @@ const en = {
|
|||
viewPropertiesShort: 'View properties',
|
||||
priceHistory: 'Price History',
|
||||
journeysFrom: 'Journey times for {{label}}',
|
||||
to: 'From {{destination}}',
|
||||
to: 'To {{destination}}',
|
||||
noJourneyData: 'No journey data available',
|
||||
viewOnGoogleMaps: 'View on Google Maps',
|
||||
walk: 'Walk',
|
||||
|
|
|
|||
|
|
@ -847,7 +847,7 @@ const fr: Translations = {
|
|||
viewPropertiesShort: 'Voir les propriétés',
|
||||
priceHistory: 'Historique des prix',
|
||||
journeysFrom: 'Temps de trajet pour {{label}}',
|
||||
to: 'Depuis {{destination}}',
|
||||
to: 'Vers {{destination}}',
|
||||
noJourneyData: 'Aucune donnée de trajet disponible',
|
||||
viewOnGoogleMaps: 'Voir sur Google Maps',
|
||||
walk: 'Marche',
|
||||
|
|
|
|||
|
|
@ -804,7 +804,7 @@ const hi: Translations = {
|
|||
viewPropertiesShort: 'संपत्तियां देखें',
|
||||
priceHistory: 'कीमत इतिहास',
|
||||
journeysFrom: '{{label}} के लिए यात्रा समय',
|
||||
to: '{{destination}} से',
|
||||
to: '{{destination}} तक',
|
||||
noJourneyData: 'कोई यात्रा डेटा उपलब्ध नहीं',
|
||||
viewOnGoogleMaps: 'Google Maps पर देखें',
|
||||
walk: 'पैदल',
|
||||
|
|
|
|||
|
|
@ -828,7 +828,7 @@ const hu: Translations = {
|
|||
viewPropertiesShort: 'Ingatlanok megtekintése',
|
||||
priceHistory: 'Ártörténet',
|
||||
journeysFrom: 'Utazási idők ehhez: {{label}}',
|
||||
to: 'Innen: {{destination}}',
|
||||
to: 'Ide: {{destination}}',
|
||||
noJourneyData: 'Nincs elérhető utazási adat',
|
||||
viewOnGoogleMaps: 'Megtekintés a Google Maps-en',
|
||||
walk: 'Gyalog',
|
||||
|
|
|
|||
|
|
@ -775,7 +775,7 @@ const zh: Translations = {
|
|||
viewPropertiesShort: '查看房产',
|
||||
priceHistory: '价格历史',
|
||||
journeysFrom: '{{label}} 的出行时间',
|
||||
to: '从 {{destination}} 出发',
|
||||
to: '前往 {{destination}}',
|
||||
noJourneyData: '暂无出行数据',
|
||||
viewOnGoogleMaps: '在 Google Maps 上查看',
|
||||
walk: '步行',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue