Fix FE
This commit is contained in:
parent
1241132095
commit
54a5c3ca9a
28 changed files with 826 additions and 422 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue