712 lines
24 KiB
TypeScript
712 lines
24 KiB
TypeScript
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 <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');
|
|
}
|
|
|
|
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<Page>('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<FeatureMeta[]>([]);
|
|
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
|
const [initialLoading, setInitialLoading] = useState(true);
|
|
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
|
const [inviteCode, setInviteCode] = useState<string | 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
|
|
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<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);
|
|
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<POICategoriesResponse>(
|
|
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<ExportState | null>(null);
|
|
|
|
if ((isScreenshotMode || isOgMode) && inviteCode) {
|
|
return (
|
|
<Suspense fallback={<PageFallback />}>
|
|
<InvitePage
|
|
code={inviteCode}
|
|
user={null}
|
|
theme={theme}
|
|
screenshotMode
|
|
onLoginClick={unavailableAuthAction}
|
|
onRegisterClick={unavailableAuthAction}
|
|
onLicenseGranted={unavailableAuthAction}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
if (isScreenshotMode) {
|
|
return (
|
|
<Suspense fallback={<PageFallback />}>
|
|
<MapPage
|
|
features={features}
|
|
poiCategoryGroups={poiCategoryGroups}
|
|
initialFilters={urlState.filters}
|
|
initialViewState={initialViewState}
|
|
initialPOICategories={urlState.poiCategories}
|
|
initialTab={urlState.tab}
|
|
initialLoading={initialLoading}
|
|
theme={theme}
|
|
pendingInfoFeature={null}
|
|
onClearPendingInfoFeature={() => {}}
|
|
onNavigateTo={() => {}}
|
|
screenshotMode
|
|
ogMode={isOgMode}
|
|
initialTravelTime={urlState.travelTime}
|
|
shareCode={urlState.share}
|
|
onLoginClick={unavailableAuthAction}
|
|
onRegisterClick={unavailableAuthAction}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<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
|
|
? editingSearch
|
|
? () => 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}
|
|
/>
|
|
<Suspense fallback={<PageFallback />}>
|
|
{activePage === 'home' ? (
|
|
<HomePage
|
|
onOpenDashboard={() => navigateTo('dashboard')}
|
|
onOpenPricing={() => navigateTo('pricing')}
|
|
theme={theme}
|
|
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
|
|
/>
|
|
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
|
|
<PricingPage
|
|
onOpenDashboard={() => navigateTo('dashboard')}
|
|
user={user}
|
|
onLoginClick={() => openAuthModal('login', 'checkout', '/pricing')}
|
|
onRegisterClick={() => openAuthModal('register', 'checkout', '/pricing')}
|
|
/>
|
|
) : activePage === 'learn' ? (
|
|
<LearnPage />
|
|
) : isSeoLandingPage(activePage) ? (
|
|
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
|
) : isSeoContentPage(activePage) ? (
|
|
<SeoContentPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
|
) : activePage === 'saved' && user ? (
|
|
<SavedPage
|
|
searches={savedSearches.searches}
|
|
searchesLoading={savedSearches.loading}
|
|
onDeleteSearch={savedSearches.deleteSearch}
|
|
onUpdateSearchNotes={savedSearches.updateSearchNotes}
|
|
onUpdateSearchName={savedSearches.updateSearchName}
|
|
onOpenSearch={handleEditSearch}
|
|
/>
|
|
) : activePage === 'account' && user ? (
|
|
<AccountPage
|
|
user={user}
|
|
onRefreshAuth={async () => {
|
|
await refreshAuth();
|
|
}}
|
|
scrollTarget={routeHash}
|
|
/>
|
|
) : isAuthRequiredPage && !user ? (
|
|
<PageFallback />
|
|
) : activePage === 'invite' && inviteCode ? (
|
|
<InvitePage
|
|
code={inviteCode}
|
|
user={user}
|
|
theme={theme}
|
|
onLoginClick={() => openAuthModal('login')}
|
|
onRegisterClick={() => openAuthModal('register')}
|
|
onLicenseGranted={() => {
|
|
setLicenseSuccessStatus('success');
|
|
refreshAuth();
|
|
}}
|
|
/>
|
|
) : (
|
|
<MapPage
|
|
key={dashboardRouteKey}
|
|
features={features}
|
|
poiCategoryGroups={poiCategoryGroups}
|
|
initialFilters={mapUrlState.filters}
|
|
initialViewState={initialViewState}
|
|
initialPOICategories={mapUrlState.poiCategories}
|
|
initialTab={mapUrlState.tab}
|
|
initialLoading={initialLoading}
|
|
theme={theme}
|
|
pendingInfoFeature={pendingInfoFeature}
|
|
onClearPendingInfoFeature={() => 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}
|
|
/>
|
|
)}
|
|
</Suspense>
|
|
<Suspense fallback={null}>
|
|
{showAuthModal && (
|
|
<AuthModal
|
|
onClose={closeAuthModal}
|
|
onAuthenticated={() => {
|
|
authCompletedRef.current = true;
|
|
}}
|
|
onLogin={login}
|
|
onRegister={register}
|
|
onOAuthLogin={loginWithOAuth}
|
|
onForgotPassword={requestPasswordReset}
|
|
loading={authLoading}
|
|
error={authError}
|
|
onClearError={clearError}
|
|
initialTab={authModalTab}
|
|
/>
|
|
)}
|
|
{showSaveModal && (
|
|
<SaveSearchModal
|
|
onClose={() => setShowSaveModal(false)}
|
|
onSave={(name) => savedSearches.saveSearch(name, dashboardParams)}
|
|
onViewSearches={() => {
|
|
setShowSaveModal(false);
|
|
navigateTo('saved');
|
|
}}
|
|
saving={savedSearches.saving}
|
|
error={savedSearches.error}
|
|
/>
|
|
)}
|
|
{licenseSuccessStatus !== 'hidden' && (
|
|
<LicenseSuccessModal
|
|
status={licenseSuccessStatus}
|
|
onClose={() => {
|
|
const shouldOpenDashboard = licenseSuccessStatus === 'success';
|
|
setLicenseSuccessStatus('hidden');
|
|
if (shouldOpenDashboard) navigateTo('dashboard');
|
|
}}
|
|
/>
|
|
)}
|
|
</Suspense>
|
|
</div>
|
|
);
|
|
}
|