This commit is contained in:
Andras Schmelczer 2026-06-10 22:25:15 +01:00
parent 1241132095
commit 54a5c3ca9a
28 changed files with 826 additions and 422 deletions

View file

@ -15,6 +15,7 @@ import type { FeatureMeta, FeatureGroup, POICategoriesResponse, POICategoryGroup
import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import pb from './lib/pocketbase';
import { INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile';
@ -40,6 +41,7 @@ const SavedPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
);
const InvitePage = lazy(() => import('./components/invite/InvitePage'));
const LegalPage = lazy(() => import('./components/legal/LegalPage'));
const MapPage = lazy(() => import('./components/map/MapPage'));
const AuthModal = lazy(() => import('./components/ui/AuthModal'));
const SaveSearchModal = lazy(() => import('./components/ui/SaveSearchModal'));
@ -77,19 +79,42 @@ function currentRelativePath(): string {
return `${window.location.pathname}${window.location.search}`;
}
const LAST_DASHBOARD_PARAMS_KEY = 'pp_last_dashboard_params';
function persistLastDashboardParams(params: string) {
try {
if (params) window.localStorage.setItem(LAST_DASHBOARD_PARAMS_KEY, params);
} catch {
// Storage unavailable (private mode/quota) — session restore is best-effort.
}
}
function readLastDashboardSearch(): string {
try {
const saved = window.localStorage.getItem(LAST_DASHBOARD_PARAMS_KEY);
return saved ? `?${saved.replace(/^\?/, '')}` : '';
} catch {
return '';
}
}
/**
* Filters and map view live only in the URL. When the dashboard is opened bare
* (no query), restore the last session's params so users pick up where they
* left off. Explicit params and shared links always win.
*/
function restoreLastDashboardSession() {
const pathname = window.location.pathname.replace(/\/+$/, '');
if (pathname !== '/dashboard' || window.location.search) return;
const saved = readLastDashboardSearch();
if (!saved) return;
window.history.replaceState(window.history.state, '', `/dashboard${saved}`);
}
function isProtectedPage(page: Page): boolean {
return page === 'account' || page === 'saved';
}
function isSharedDashboardUrl(): boolean {
const share = new URLSearchParams(window.location.search).get('share');
return !!share && /^[a-z0-9]{1,20}$/i.test(share);
}
function isAuthRequiredRoute(page: Page): boolean {
return isProtectedPage(page) || (page === 'dashboard' && !isSharedDashboardUrl());
}
function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string {
const normalizedHash = normalizeHash(hash);
return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`;
@ -126,6 +151,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
case 'methodology':
case 'privacy-security':
return SEO_CONTENT_PATHS[page];
case 'terms':
return '/terms';
case 'privacy':
return '/privacy';
case 'saved':
return '/saved';
case 'account':
@ -140,7 +169,10 @@ function pageToPath(page: Page, inviteCode?: string): string {
}
}
function pathToPage(pathname: string): RouteMatch | null {
function pathToPage(rawPathname: string): RouteMatch | null {
// Proxies 307-redirect /learn -> /learn/; treat trailing slashes as equivalent.
const pathname =
rawPathname.length > 1 ? rawPathname.replace(/\/+$/, '') || '/' : rawPathname;
if (pathname === '/dashboard') return { page: 'dashboard' };
if (pathname === '/saved') return { page: 'saved' };
if (pathname === '/invites') return { page: 'account', hash: 'invites' };
@ -152,6 +184,8 @@ function pathToPage(pathname: string): RouteMatch | null {
if (seoContentPage) return { page: seoContentPage };
if (pathname === '/account') return { page: 'account' };
if (pathname === '/support') return { page: 'learn' };
if (pathname === '/terms') return { page: 'terms' };
if (pathname === '/privacy') return { page: 'privacy' };
if (pathname.startsWith('/invite/')) {
const code = pathname.slice('/invite/'.length);
return { page: 'invite', inviteCode: code };
@ -169,7 +203,11 @@ function isSeoContentPage(page: Page): page is SeoContentKey {
}
export default function App() {
const urlState = useMemo(() => parseUrlState(), []);
const urlState = useMemo(() => {
// Must run before any reads of window.location.search below.
restoreLastDashboardSession();
return parseUrlState();
}, []);
const initialRoute = useMemo(() => pathToPage(window.location.pathname), []);
const [mapUrlState, setMapUrlState] = useState(urlState);
const [dashboardRouteKey, setDashboardRouteKey] = useState(() =>
@ -276,7 +314,9 @@ export default function App() {
if (!completed) {
setPostAuthIntent(null);
postAuthCheckoutReturnPathRef.current = null;
if (isAuthRequiredRoute(activePageRef.current)) {
// Only protected pages bounce home; the dashboard stays open in demo
// mode (server-enforced free zone) when the modal is dismissed.
if (isProtectedPage(activePageRef.current)) {
window.history.replaceState({ page: 'home', hash: '' }, '', '/');
setRouteHash('');
setActivePage('home');
@ -300,8 +340,11 @@ export default function App() {
async function refreshOnStartup() {
if (!returnedFromCheckout) {
// Always refresh auth on startup to pick up server-side subscription changes.
refreshAuthRef.current().catch(() => {});
// Refresh auth on startup to pick up server-side subscription changes,
// but only when a token exists — logged-out visitors would just 401.
if (pb.authStore.token) {
refreshAuthRef.current().catch(() => {});
}
return;
}
@ -384,8 +427,10 @@ export default function App() {
if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, '');
}
// Restore dashboard search params when navigating back
const search = page === 'dashboard' ? dashboardSearchRef.current : '';
// Restore dashboard search params when navigating back, falling back to
// the last persisted session for first visits in this tab.
const search =
page === 'dashboard' ? dashboardSearchRef.current || readLastDashboardSearch() : '';
const url = buildPageUrl(page, inviteCode ?? undefined, search, targetHash);
window.history.pushState({ page, hash: targetHash }, '', url);
if (page === 'dashboard') {
@ -527,19 +572,20 @@ export default function App() {
}
}, [activePage, fetchSearches]);
const isAuthRequiredPage =
activePage === 'account' ||
activePage === 'saved' ||
(activePage === 'dashboard' && !mapUrlState.share);
const isProtectedPageActive = isProtectedPage(activePage);
// Only protected pages (account/saved) prompt for login on entry. The
// dashboard opens straight into demo mode (server-enforced free zone) so
// visitors can try it without logging in; the upgrade modal still appears
// when they pan outside the free zone.
useEffect(() => {
if (authLoading) return;
if (isAuthRequiredPage && !user) {
if (isProtectedPageActive && !user) {
openAuthModal('login');
}
if (activePage === 'pricing' && hasFullAccess(user)) {
navigateTo('dashboard');
}
}, [activePage, authLoading, isAuthRequiredPage, navigateTo, openAuthModal, user]);
}, [activePage, authLoading, isProtectedPageActive, navigateTo, openAuthModal, user]);
const [exportState, setExportState] = useState<ExportState | null>(null);
@ -641,6 +687,8 @@ export default function App() {
/>
) : activePage === 'learn' ? (
<LearnPage />
) : activePage === 'terms' || activePage === 'privacy' ? (
<LegalPage kind={activePage} />
) : isSeoLandingPage(activePage) ? (
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
) : isSeoContentPage(activePage) ? (
@ -662,7 +710,7 @@ export default function App() {
}}
scrollTarget={routeHash}
/>
) : isAuthRequiredPage && !user ? (
) : isProtectedPageActive && !user ? (
<PageFallback />
) : activePage === 'invite' && inviteCode ? (
<InvitePage
@ -695,7 +743,10 @@ export default function App() {
onClearPendingInfoFeature={() => setPendingInfoFeature(null)}
onNavigateTo={navigateTo}
onExportStateChange={setExportState}
onDashboardParamsChange={setDashboardParams}
onDashboardParamsChange={(params) => {
setDashboardParams(params);
if (!mapUrlState.share) persistLastDashboardParams(params);
}}
onDashboardReadyChange={setDashboardReady}
isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime}