292 lines
9.3 KiB
TypeScript
292 lines
9.3 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import MapPage, { type ExportState } from './components/map/MapPage';
|
|
import PricingPage from './components/pricing/PricingPage';
|
|
import HomePage from './components/home/HomePage';
|
|
import SavedSearchesPage from './components/saved-searches/SavedSearchesPage';
|
|
import LearnPage from './components/learn/LearnPage';
|
|
import AccountPage from './components/account/AccountPage';
|
|
import Header, { type Page } from './components/ui/Header';
|
|
import AuthModal from './components/ui/AuthModal';
|
|
import SaveSearchModal from './components/ui/SaveSearchModal';
|
|
import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup } from './types';
|
|
import { fetchWithRetry, apiUrl } from './lib/api';
|
|
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 { useSavedSearches } from './hooks/useSavedSearches';
|
|
|
|
declare global {
|
|
interface Window {
|
|
__screenshot_ready?: boolean;
|
|
}
|
|
}
|
|
|
|
function pageToPath(page: Page): string {
|
|
switch (page) {
|
|
case 'dashboard':
|
|
return '/dashboard';
|
|
case 'saved-searches':
|
|
return '/saved';
|
|
case 'learn':
|
|
return '/learn';
|
|
case 'pricing':
|
|
return '/pricing';
|
|
case 'account':
|
|
return '/account';
|
|
default:
|
|
return '/';
|
|
}
|
|
}
|
|
|
|
function pathToPage(pathname: string): Page | null {
|
|
if (pathname === '/dashboard') return 'dashboard';
|
|
if (pathname === '/saved') return 'saved-searches';
|
|
if (pathname === '/learn') return 'learn';
|
|
if (pathname === '/pricing') return 'pricing';
|
|
if (pathname === '/account') return 'account';
|
|
if (pathname === '/') return 'home';
|
|
return null;
|
|
}
|
|
|
|
export default function App() {
|
|
const urlState = useMemo(() => parseUrlState(), []);
|
|
const initialViewState = useMemo(
|
|
() => urlState.viewState || INITIAL_VIEW_STATE,
|
|
[urlState.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';
|
|
}, []);
|
|
|
|
// Core data
|
|
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
|
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
|
const [initialLoading, setInitialLoading] = useState(true);
|
|
|
|
// UI state
|
|
const [pendingInfoFeature, setPendingInfoFeature] = useState<string | null>(null);
|
|
const [activePage, setActivePage] = useState<Page>(() => {
|
|
if (isScreenshotMode) return 'dashboard';
|
|
|
|
// Derive page from URL pathname
|
|
const fromPath = pathToPage(window.location.pathname);
|
|
if (fromPath) return fromPath;
|
|
|
|
// Restore from history state (e.g. popstate)
|
|
if (window.history.state?.page) return window.history.state.page;
|
|
|
|
return 'home';
|
|
});
|
|
|
|
const { theme, toggleTheme } = useTheme();
|
|
const isMobile = useIsMobile();
|
|
const {
|
|
user,
|
|
loading: authLoading,
|
|
error: authError,
|
|
login,
|
|
register,
|
|
logout,
|
|
requestPasswordReset,
|
|
refreshAuth,
|
|
clearError,
|
|
} = useAuth();
|
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
|
const [authModalTab, setAuthModalTab] = useState<'login' | 'register'>('login');
|
|
|
|
const savedSearches = useSavedSearches(user?.id ?? null);
|
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
|
|
|
// Load features and POI categories on mount
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
let featuresLoaded = false;
|
|
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();
|
|
}, []);
|
|
|
|
// Screenshot mode ready signal — MapPage sets __screenshot_ready once map data loads
|
|
|
|
// Navigation
|
|
const navigateTo = useCallback((page: Page, hash?: string, infoFeature?: string) => {
|
|
if (infoFeature) {
|
|
window.history.replaceState({ ...window.history.state, infoFeature }, '');
|
|
}
|
|
const path = pageToPath(page);
|
|
const url = hash ? `${path}#${hash}` : path;
|
|
window.history.pushState({ page }, '', url);
|
|
setActivePage(page);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!window.history.state?.page) {
|
|
window.history.replaceState(
|
|
{ page: activePage },
|
|
'',
|
|
pageToPath(activePage) + window.location.search + window.location.hash
|
|
);
|
|
}
|
|
const handlePopState = (e: PopStateEvent) => {
|
|
if (e.state?.page) {
|
|
setActivePage(e.state.page);
|
|
if (e.state.infoFeature) {
|
|
setPendingInfoFeature(e.state.infoFeature);
|
|
}
|
|
} else {
|
|
// Fall back to deriving page from pathname
|
|
const page = pathToPage(window.location.pathname);
|
|
setActivePage(page || 'home');
|
|
}
|
|
};
|
|
window.addEventListener('popstate', handlePopState);
|
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Fetch saved searches when page becomes active
|
|
const { fetchSearches } = savedSearches;
|
|
useEffect(() => {
|
|
if (activePage === 'saved-searches') {
|
|
fetchSearches();
|
|
}
|
|
}, [activePage, fetchSearches]);
|
|
|
|
const [exportState, setExportState] = useState<ExportState | null>(null);
|
|
|
|
if (isScreenshotMode) {
|
|
return (
|
|
<MapPage
|
|
features={features}
|
|
poiCategoryGroups={poiCategoryGroups}
|
|
initialFilters={urlState.filters || {}}
|
|
initialViewState={initialViewState}
|
|
initialPOICategories={urlState.poiCategories || new Set()}
|
|
initialTab={urlState.tab || 'area'}
|
|
initialLoading={initialLoading}
|
|
theme={theme}
|
|
pendingInfoFeature={null}
|
|
onClearPendingInfoFeature={() => {}}
|
|
onNavigateTo={() => {}}
|
|
screenshotMode
|
|
ogMode={isOgMode}
|
|
initialTravelTime={urlState.travelTime}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
<Header
|
|
activePage={activePage}
|
|
onPageChange={navigateTo}
|
|
theme={theme}
|
|
onToggleTheme={toggleTheme}
|
|
onExport={exportState?.onExport ?? null}
|
|
exporting={exportState?.exporting ?? false}
|
|
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
|
|
savingSearch={savedSearches.saving}
|
|
user={user}
|
|
onLoginClick={() => {
|
|
setAuthModalTab('login');
|
|
setShowAuthModal(true);
|
|
}}
|
|
onRegisterClick={() => {
|
|
setAuthModalTab('register');
|
|
setShowAuthModal(true);
|
|
}}
|
|
onLogout={logout}
|
|
isMobile={isMobile}
|
|
/>
|
|
{activePage === 'home' ? (
|
|
<HomePage onOpenDashboard={() => navigateTo('dashboard')} onOpenPricing={() => navigateTo('pricing')} theme={theme} features={features} />
|
|
) : activePage === 'pricing' ? (
|
|
<PricingPage onOpenDashboard={() => navigateTo('dashboard')} />
|
|
) : activePage === 'learn' ? (
|
|
<LearnPage />
|
|
) : activePage === 'account' && user ? (
|
|
<AccountPage user={user} onRefreshAuth={refreshAuth} />
|
|
) : activePage === 'saved-searches' ? (
|
|
<SavedSearchesPage
|
|
searches={savedSearches.searches}
|
|
loading={savedSearches.loading}
|
|
onDelete={savedSearches.deleteSearch}
|
|
onOpen={(params) => {
|
|
window.location.href = `/?${params}`;
|
|
}}
|
|
/>
|
|
) : (
|
|
<MapPage
|
|
features={features}
|
|
poiCategoryGroups={poiCategoryGroups}
|
|
initialFilters={urlState.filters || {}}
|
|
initialViewState={initialViewState}
|
|
initialPOICategories={urlState.poiCategories || new Set()}
|
|
initialTab={urlState.tab || 'area'}
|
|
initialLoading={initialLoading}
|
|
theme={theme}
|
|
pendingInfoFeature={pendingInfoFeature}
|
|
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
|
|
onNavigateTo={navigateTo}
|
|
onExportStateChange={setExportState}
|
|
isMobile={isMobile}
|
|
initialTravelTime={urlState.travelTime}
|
|
/>
|
|
)}
|
|
{showAuthModal && (
|
|
<AuthModal
|
|
onClose={() => setShowAuthModal(false)}
|
|
onLogin={login}
|
|
onRegister={register}
|
|
onForgotPassword={requestPasswordReset}
|
|
loading={authLoading}
|
|
error={authError}
|
|
onClearError={clearError}
|
|
initialTab={authModalTab}
|
|
/>
|
|
)}
|
|
{showSaveModal && (
|
|
<SaveSearchModal
|
|
onClose={() => setShowSaveModal(false)}
|
|
onSave={savedSearches.saveSearch}
|
|
saving={savedSearches.saving}
|
|
error={savedSearches.error}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|