import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import MapPage, { type ExportState } from './components/map/MapPage'; import PricingPage from './components/pricing/PricingPage'; import HomePage from './components/home/HomePage'; import LearnPage from './components/learn/LearnPage'; import AccountPage, { SavedPage, InvitesPage } from './components/account/AccountPage'; import InvitePage from './components/invite/InvitePage'; import Header, { type Page } from './components/ui/Header'; import AuthModal from './components/ui/AuthModal'; import SaveSearchModal from './components/ui/SaveSearchModal'; import LicenseSuccessModal from './components/ui/LicenseSuccessModal'; import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types'; import { fetchWithRetry, apiUrl } from './lib/api'; import { trackEvent } from './lib/analytics'; import { parseUrlState } from './lib/url-state'; import { INITIAL_VIEW_STATE } from './lib/consts'; import { useTheme } from './hooks/useTheme'; import { useIsMobile } from './hooks/useIsMobile'; import { useAuth } from './hooks/useAuth'; import { useTelemetry } from './hooks/useTelemetry'; import { useSavedSearches } from './hooks/useSavedSearches'; import { useSavedProperties } from './hooks/useSavedProperties'; declare global { interface Window { __screenshot_ready?: boolean; __map_idle?: boolean; } } function pageToPath(page: Page, inviteCode?: string): string { switch (page) { case 'dashboard': return '/dashboard'; case 'learn': return '/learn'; case 'pricing': return '/pricing'; case 'saved': return '/saved'; case 'invites': return '/invites'; case 'account': return '/account'; case 'invite': return `/invite/${inviteCode || ''}`; default: return '/'; } } function pathToPage(pathname: string): { page: Page; inviteCode?: string } | null { if (pathname === '/dashboard') return { page: 'dashboard' }; if (pathname === '/saved') return { page: 'saved' }; if (pathname === '/invites') return { page: 'invites' }; if (pathname === '/learn') return { page: 'learn' }; if (pathname === '/pricing') return { page: 'pricing' }; if (pathname === '/account') return { page: 'account' }; 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; } export default function App() { const urlState = useMemo(() => parseUrlState(), []); const [mapUrlState, setMapUrlState] = useState(urlState); 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(() => { const fromPath = pathToPage(window.location.pathname); return fromPath?.inviteCode ?? null; }); const [activePage, setActivePage] = useState(() => { if (isScreenshotMode) return 'dashboard'; // Derive page from URL pathname const fromPath = pathToPage(window.location.pathname); if (fromPath) return fromPath.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 [showAuthModal, setShowAuthModal] = useState(false); const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login'); const [showLicenseSuccess, setShowLicenseSuccess] = useState(false); useEffect(() => { const params = new URLSearchParams(window.location.search); if (params.get('license_success') === '1') { params.delete('license_success'); const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname; window.history.replaceState({}, '', newUrl); trackEvent('Purchase'); setShowLicenseSuccess(true); refreshAuth(); } }, []); // 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(() => { 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) => { // 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 }, ''); } 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); if (page === 'dashboard') { setMapUrlState(parseUrlState()); } setActivePage(page); }, [inviteCode] ); useEffect(() => { activePageRef.current = activePage; }, [activePage]); useEffect(() => { if (!window.history.state?.page) { window.history.replaceState( { page: activePage }, '', pageToPath(activePage) + window.location.search + window.location.hash ); } const handlePopState = (e: PopStateEvent) => { let page: Page; if (e.state?.page) { page = e.state.page; setActivePage(page); if (e.state.infoFeature) { setPendingInfoFeature(e.state.infoFeature); } } else { // Fall back to deriving page from pathname const parsed = pathToPage(window.location.pathname); page = parsed?.page || 'home'; setActivePage(page); if (parsed?.inviteCode) setInviteCode(parsed.inviteCode); } // Re-parse URL state when returning to dashboard via back/forward if (page === 'dashboard') { setMapUrlState(parseUrlState()); } }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // 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]); const isAuthRequiredPage = activePage === 'account' || activePage === 'saved' || activePage === 'invites'; useEffect(() => { if (authLoading) return; if (isAuthRequiredPage && !user) { setAuthModalTab('login'); setShowAuthModal(true); navigateTo('home'); } if (activePage === 'pricing' && (user?.subscription === 'licensed' || user?.isAdmin)) { navigateTo('dashboard'); } }, [activePage, isAuthRequiredPage, user, authLoading, navigateTo]); const [exportState, setExportState] = useState(null); if ((isScreenshotMode || isOgMode) && inviteCode) { return ( {}} onRegisterClick={() => {}} onLicenseGranted={() => {}} /> ); } if (isScreenshotMode) { return ( {}} onNavigateTo={() => {}} screenshotMode ogMode={isOgMode} initialTravelTime={urlState.travelTime} /> ); } return (
setShowSaveModal(true) : null} savingSearch={savedSearches.saving} user={user} onLoginClick={() => { setAuthModalTab('login'); setShowAuthModal(true); }} onRegisterClick={() => { setAuthModalTab('register'); setShowAuthModal(true); }} 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={() => { setAuthModalTab('login'); setShowAuthModal(true); }} onRegisterClick={() => { setAuthModalTab('register'); setShowAuthModal(true); }} /> ) : activePage === 'learn' ? ( ) : activePage === 'saved' && user ? ( { 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 ? ( ) : activePage === 'account' && user ? ( ) : activePage === 'invite' && inviteCode ? ( { setAuthModalTab('login'); setShowAuthModal(true); }} onRegisterClick={() => { setAuthModalTab('register'); setShowAuthModal(true); }} onLicenseGranted={() => { setShowLicenseSuccess(true); refreshAuth(); }} /> ) : ( setPendingInfoFeature(null)} onNavigateTo={navigateTo} onExportStateChange={setExportState} isMobile={isMobile} initialTravelTime={mapUrlState.travelTime} initialPostcode={mapUrlState.postcode} 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} /> )} {showAuthModal && ( setShowAuthModal(false)} onLogin={login} onRegister={register} onOAuthLogin={loginWithOAuth} onForgotPassword={requestPasswordReset} loading={authLoading} error={authError} onClearError={clearError} initialTab={authModalTab} /> )} {showSaveModal && ( setShowSaveModal(false)} onSave={savedSearches.saveSearch} onViewSearches={() => { setShowSaveModal(false); navigateTo('saved'); }} saving={savedSearches.saving} error={savedSearches.error} /> )} {showLicenseSuccess && ( { setShowLicenseSuccess(false); navigateTo('dashboard'); }} /> )}
); }