perfect-postcode/frontend/src/App.tsx
Andras Schmelczer d93beb9201
Some checks failed
CI / Python (lint + test) (push) Failing after 1m42s
CI / Frontend (lint + typecheck) (push) Failing after 1m45s
CI / Rust (lint + test) (push) Successful in 4m45s
Build and publish Docker image / build-and-push (push) Failing after 6m21s
Small fixes
2026-03-26 07:55:13 +00:00

459 lines
15 KiB
TypeScript

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<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>(() => {
const fromPath = pathToPage(window.location.pathname);
return fromPath?.inviteCode ?? 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.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<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) => {
// 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<ExportState | null>(null);
if ((isScreenshotMode || isOgMode) && inviteCode) {
return (
<InvitePage
code={inviteCode}
user={null}
theme={theme}
screenshotMode
onLoginClick={() => {}}
onRegisterClick={() => {}}
onLicenseGranted={() => {}}
/>
);
}
if (isScreenshotMode) {
return (
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }}
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}
hidePricing={user?.subscription === 'licensed' || user?.isAdmin}
/>
) : activePage === 'pricing' && !(user?.subscription === 'licensed' || user?.isAdmin) ? (
<PricingPage
onOpenDashboard={() => navigateTo('dashboard')}
user={user}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
/>
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'saved' && user ? (
<SavedPage
searches={savedSearches.searches}
searchesLoading={savedSearches.loading}
onDeleteSearch={savedSearches.deleteSearch}
onUpdateSearchNotes={savedSearches.updateSearchNotes}
onUpdateSearchName={savedSearches.updateSearchName}
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} />
) : activePage === 'invite' && inviteCode ? (
<InvitePage
code={inviteCode}
user={user}
theme={theme}
onLoginClick={() => {
setAuthModalTab('login');
setShowAuthModal(true);
}}
onRegisterClick={() => {
setAuthModalTab('register');
setShowAuthModal(true);
}}
onLicenseGranted={() => {
setShowLicenseSuccess(true);
refreshAuth();
}}
/>
) : (
<MapPage
features={features}
poiCategoryGroups={poiCategoryGroups}
initialFilters={mapUrlState.filters || { 'Listing status': ['Historical sale'] }}
initialViewState={initialViewState}
initialPOICategories={mapUrlState.poiCategories || new Set()}
initialTab={mapUrlState.tab || 'area'}
initialLoading={initialLoading}
theme={theme}
pendingInfoFeature={pendingInfoFeature}
onClearPendingInfoFeature={() => 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 && (
<AuthModal
onClose={() => setShowAuthModal(false)}
onLogin={login}
onRegister={register}
onOAuthLogin={loginWithOAuth}
onForgotPassword={requestPasswordReset}
loading={authLoading}
error={authError}
onClearError={clearError}
initialTab={authModalTab}
/>
)}
{showSaveModal && (
<SaveSearchModal
onClose={() => setShowSaveModal(false)}
onSave={savedSearches.saveSearch}
onViewSearches={() => {
setShowSaveModal(false);
navigateTo('saved');
}}
saving={savedSearches.saving}
error={savedSearches.error}
/>
)}
{showLicenseSuccess && (
<LicenseSuccessModal
onClose={() => {
setShowLicenseSuccess(false);
navigateTo('dashboard');
}}
/>
)}
</div>
);
}