import { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import type { ExportState } from './components/map/MapPage'; import { getSeoContentPage, getSeoLandingPage, isSeoContentKey, isSeoLandingKey, SEO_CONTENT_PATHS, SEO_LANDING_PATHS, type SeoContentKey, type SeoLandingKey, } from './lib/seoRoutes'; import Header, { type Page } from './components/ui/Header'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; 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'; declare global { interface Window { __screenshot_ready?: boolean; __map_idle?: boolean; } } const HomePage = lazy(() => import('./components/home/HomePage')); const PricingPage = lazy(() => import('./components/pricing/PricingPage')); const LearnPage = lazy(() => import('./components/learn/LearnPage')); const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage')); const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage')); const AccountPage = lazy(() => import('./components/account/AccountPage')); const SavedPage = lazy(() => import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage })) ); const InvitePage = lazy(() => import('./components/invite/InvitePage')); const MapPage = lazy(() => import('./components/map/MapPage')); const AuthModal = lazy(() => import('./components/ui/AuthModal')); const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal')); const LicenseSuccessModal = lazy(() => import('./components/ui/LicenseSuccessModal')); function PageFallback() { return
; } 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 { 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'); } function pageToPath(page: Page, inviteCode?: string): string { switch (page) { case 'dashboard': return '/dashboard'; case 'learn': return '/learn'; case 'pricing': return '/pricing'; case 'property-price-map': case 'postcode-property-search': case 'commute-property-search': case 'school-property-search': case 'postcode-checker': return SEO_LANDING_PATHS[page]; case 'birmingham-property-search': case 'manchester-property-search': case 'bristol-property-search': case 'data-sources': case 'methodology': case 'privacy-security': return SEO_CONTENT_PATHS[page]; case 'saved': return '/saved'; case 'account': return '/account'; case 'invite': if (!inviteCode) { throw new Error('Cannot build invite path without an invite code'); } return `/invite/${inviteCode}`; default: return '/'; } } function pathToPage(pathname: string): RouteMatch | null { if (pathname === '/dashboard') return { page: 'dashboard' }; if (pathname === '/saved') return { page: 'saved' }; if (pathname === '/invites') return { page: 'account', hash: 'invites' }; if (pathname === '/learn') return { page: 'learn' }; if (pathname === '/pricing') return { page: 'pricing' }; const seoLandingPage = getSeoLandingPage(pathname); if (seoLandingPage) return { page: seoLandingPage }; const seoContentPage = getSeoContentPage(pathname); if (seoContentPage) return { page: seoContentPage }; if (pathname === '/account') return { page: 'account' }; if (pathname === '/support') return { page: 'learn' }; if (pathname.startsWith('/invite/')) { const code = pathname.slice('/invite/'.length); return { page: 'invite', inviteCode: code }; } if (pathname === '/') return { page: 'home' }; return null; } function isSeoLandingPage(page: Page): page is SeoLandingKey { return isSeoLandingKey(page); } function isSeoContentPage(page: Page): page is SeoContentKey { return isSeoContentKey(page); } 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 : '' ); const activePageRef = useRef('home'); const initialViewState = useMemo( () => mapUrlState.viewState || INITIAL_VIEW_STATE, [mapUrlState.viewState] ); const isScreenshotMode = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get('screenshot') === '1'; }, []); const isOgMode = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get('og') === '1'; }, []); const [features, setFeatures] = useState([]); const [poiCategoryGroups, setPOICategoryGroups] = useState([]); const [initialLoading, setInitialLoading] = useState(true); const [pendingInfoFeature, setPendingInfoFeature] = useState(null); const [inviteCode, setInviteCode] = useState(() => { return initialRoute?.inviteCode ?? null; }); const [routeHash, setRouteHash] = useState( () => initialRoute?.hash ?? normalizeHash(window.location.hash) ); const [activePage, setActivePage] = useState(() => { if (isScreenshotMode) return 'dashboard'; // Derive page from URL pathname if (initialRoute) return initialRoute.page; // Restore from history state (e.g. popstate) if (window.history.state?.page) return window.history.state.page; // Unknown path — track as 404 if (window.location.pathname !== '/') { trackEvent('404', { path: window.location.pathname }); } return 'home'; }); const { theme, toggleTheme } = useTheme(); const isMobile = useIsMobile(); useTelemetry(); const { user, loading: authLoading, error: authError, login, register, loginWithOAuth, logout, requestPasswordReset, refreshAuth, clearError, } = useAuth(); const { startCheckout: startPostAuthCheckout } = useLicense(); const [showAuthModal, setShowAuthModal] = useState(false); const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); const [postAuthIntent, setPostAuthIntent] = useState(null); const postAuthCheckoutReturnPathRef = useRef(null); const authCompletedRef = useRef(false); const [licenseSuccessStatus, setLicenseSuccessStatus] = useState('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); 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); } 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 [showSaveModal, setShowSaveModal] = useState(false); const [editingSearch, setEditingSearch] = useState<{ id: string; name: string } | null>(null); useEffect(() => { const controller = new AbortController(); let featuresLoaded = false; let poisLoaded = false; const checkDone = () => { if (featuresLoaded && poisLoaded) setInitialLoading(false); }; fetchWithRetry<{ groups: FeatureGroup[] }>( apiUrl('features'), (json) => { const flat: FeatureMeta[] = json.groups.flatMap((g) => g.features.map((f) => ({ ...f, group: g.name })) ); setFeatures(flat); featuresLoaded = true; checkDone(); }, controller.signal ); fetchWithRetry( apiUrl('poi-categories'), (json) => { setPOICategoryGroups(json.groups); poisLoaded = true; checkDone(); }, controller.signal ); return () => controller.abort(); }, []); 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; } if (infoFeature) { window.history.replaceState({ ...window.history.state, infoFeature }, ''); } // Restore dashboard search params when navigating back const search = page === 'dashboard' ? dashboardSearchRef.current : ''; 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); setEditingSearch(null); if (targetHash) scrollToHash(targetHash); }, [inviteCode] ); const handleEditSearch = useCallback( (id: string, name: string, params: string) => { const search = params.startsWith('?') ? params : `?${params}`; dashboardSearchRef.current = search; const url = `/dashboard${search}`; window.history.pushState({ page: 'dashboard', hash: '' }, '', url); setMapUrlState(parseUrlState()); setDashboardRouteKey(search); setRouteHash(''); setActivePage('dashboard'); setEditingSearch({ id, name }); }, [] ); const handleCancelEdit = useCallback(() => { setEditingSearch(null); }, []); const updateEditingSearch = useCallback( async (params: string) => { if (!editingSearch) return; await savedSearches.updateSearchParams(editingSearch.id, params); setEditingSearch(null); }, [editingSearch, savedSearches] ); const handleUpdateEdit = useCallback( async (params: string) => { try { await updateEditingSearch(params); navigateTo('saved'); } catch { // Error stored on savedSearches.error } }, [updateEditingSearch, navigateTo] ); 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, 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); } } else { 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); } else { setEditingSearch(null); } }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // eslint-disable-line react-hooks/exhaustive-deps const { fetchSearches } = savedSearches; useEffect(() => { if (activePage === 'saved') { fetchSearches(); } }, [activePage, fetchSearches]); const isAuthRequiredPage = activePage === 'account' || activePage === 'saved'; useEffect(() => { if (authLoading) return; if (isAuthRequiredPage && !user) { openAuthModal('login'); } if (activePage === 'pricing' && hasFullAccess(user)) { navigateTo('dashboard'); } }, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]); const [exportState, setExportState] = useState(null); if ((isScreenshotMode || isOgMode) && inviteCode) { return ( }> ); } if (isScreenshotMode) { return ( }> {}} onNavigateTo={() => {}} screenshotMode ogMode={isOgMode} initialTravelTime={urlState.travelTime} shareCode={urlState.share} onLoginClick={unavailableAuthAction} onRegisterClick={unavailableAuthAction} /> ); } return (
handleUpdateEdit(dashboardParams) : () => setShowSaveModal(true) : null } savingSearch={savedSearches.saving} editingSearch={activePage === 'dashboard' ? editingSearch : null} onCancelEdit={handleCancelEdit} onUpdateEdit={() => handleUpdateEdit(dashboardParams)} user={user} onLoginClick={() => openAuthModal('login')} onRegisterClick={() => openAuthModal('register')} onLogout={logout} isMobile={isMobile} /> }> {activePage === 'home' ? ( navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} hidePricing={user?.subscription === 'licensed' || user?.isAdmin} /> ) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? ( navigateTo('dashboard')} user={user} onLoginClick={() => openAuthModal('login', 'checkout', '/pricing')} onRegisterClick={() => openAuthModal('register', 'checkout', '/pricing')} /> ) : activePage === 'learn' ? ( ) : isSeoLandingPage(activePage) ? ( navigateTo('dashboard')} /> ) : isSeoContentPage(activePage) ? ( navigateTo('dashboard')} /> ) : activePage === 'saved' && user ? ( ) : activePage === 'account' && user ? ( { await refreshAuth(); }} scrollTarget={routeHash} /> ) : isAuthRequiredPage && !user ? ( ) : activePage === 'invite' && inviteCode ? ( openAuthModal('login')} onRegisterClick={() => openAuthModal('register')} onLicenseGranted={() => { setLicenseSuccessStatus('success'); refreshAuth(); }} /> ) : ( setPendingInfoFeature(null)} onNavigateTo={navigateTo} onExportStateChange={setExportState} onDashboardParamsChange={setDashboardParams} isMobile={isMobile} initialTravelTime={mapUrlState.travelTime} initialPostcode={mapUrlState.postcode} shareCode={mapUrlState.share} user={user} 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} editingSearch={editingSearch} onCancelEdit={handleCancelEdit} onUpdateEdit={handleUpdateEdit} onUpdateEditInPlace={updateEditingSearch} /> )} {showAuthModal && ( { authCompletedRef.current = true; }} onLogin={login} onRegister={register} onOAuthLogin={loginWithOAuth} onForgotPassword={requestPasswordReset} loading={authLoading} error={authError} onClearError={clearError} initialTab={authModalTab} /> )} {showSaveModal && ( setShowSaveModal(false)} onSave={(name) => savedSearches.saveSearch(name, dashboardParams)} onViewSearches={() => { setShowSaveModal(false); navigateTo('saved'); }} saving={savedSearches.saving} error={savedSearches.error} /> )} {licenseSuccessStatus !== 'hidden' && ( { const shouldOpenDashboard = licenseSuccessStatus === 'success'; setLicenseSuccessStatus('hidden'); if (shouldOpenDashboard) navigateTo('dashboard'); }} /> )}
); }