diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 9c35736..a7d36a4 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -6,10 +6,11 @@ import { BECOME_ROLES } from "./lib/becomeRoles";
import { DeepLinkHandler } from "./components/DeepLinkHandler";
import { RouteErrorBoundary } from "./components/RouteErrorBoundary";
import { Layout } from "./components/Layout";
+import { RoutePreloader } from "./components/RoutePreloader";
-// MapPage is eager: it is the homepage and a lazy import here used to leave a
-// blank Suspense fallback on slow native WebViews before the map appeared.
-import MapPage from "./pages/MapPage";
+// HomePage resolves to a lazy map on web (smaller first entry) and to the
+// eager MapPage via a Capacitor-only Vite alias on Android.
+import HomePage from "@/pages/HomePage";
// Lazy load everything else — each page becomes its own JS chunk
const Index = lazyWithRetry(() => import("./pages/Index"));
@@ -99,6 +100,7 @@ export function AppRouter() {
return (
+
{/* Per-route boundary: a crash in one page shows a recoverable message
instead of blanking the whole app. Resets automatically on navigate
@@ -111,9 +113,9 @@ export function AppRouter() {
navigation. Pages no longer wrap themselves in . */}
}>
{/* Map is the homepage */}
- } />
+ } />
{/* /map redirects to / */}
- } />
+ } />
{/* Legacy home page still accessible */}
} />
} />
diff --git a/src/components/FundingGoalCard.tsx b/src/components/FundingGoalCard.tsx
index e62c73c..4b966bc 100644
--- a/src/components/FundingGoalCard.tsx
+++ b/src/components/FundingGoalCard.tsx
@@ -1,5 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { ZapDialog } from '@/components/ZapDialog';
+import { LazyZapDialog } from '@/components/LazyZapDialog';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { cn } from '@/lib/utils';
@@ -64,7 +64,7 @@ export function FundingGoalCard({
)}
-
+
{reached ? 'Keep supporting' : 'Fund this event'}
-
+
);
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index f30a2cd..c01eddb 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -111,6 +111,30 @@ function hasCashuWalletHint(): boolean {
}
}
+/**
+ * Page-level Suspense fallback. Keeps the header and bottom nav mounted while a
+ * lazy route chunk loads, so tapping a menu item never blanks the whole app.
+ * The fade is delayed slightly: a fast chunk resolves before it becomes visible.
+ */
+function RouteLoadingFallback() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
interface LayoutProps {
/** Page content. Optional: when rendered as a router layout (via )
* there are no children. */
@@ -159,6 +183,7 @@ function NavLink({
{
+ setMobileMenuOpen(false);
+ setMobileLanguageView(false);
+ }, [location.pathname]);
// One auth dialog for the header + burger menu. Owning it here (not inside
// LoginArea) keeps it above the sheet and outside the sheet's unmount tree.
const [authDialog, setAuthDialog] = useState<'login' | 'signup' | null>(null);
@@ -1183,7 +1216,9 @@ export function Layout({ children, noPadding }: LayoutProps) {
tabIndex={-1}
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
>
- {children ?? }
+ }>
+ {children ?? }
+
{/* Footer - Desktop */}
diff --git a/src/components/LazyZapDialog.tsx b/src/components/LazyZapDialog.tsx
new file mode 100644
index 0000000..a0aefa0
--- /dev/null
+++ b/src/components/LazyZapDialog.tsx
@@ -0,0 +1,72 @@
+import {
+ cloneElement,
+ isValidElement,
+ lazy,
+ Suspense,
+ useState,
+ type MouseEvent,
+ type ReactElement,
+ type ReactNode,
+} from 'react';
+import type { Event } from 'nostr-tools';
+
+/**
+ * Lightweight zap trigger.
+ *
+ * The full ZapDialog pulls in the Cashu wallet stack (`useCashu`, mint repos,
+ * token operations) — several hundred KB that used to land in the initial
+ * bundle because every SpotCard rendered a ZapButton. This wrapper keeps only
+ * the trigger in the entry chunk and loads the actual dialog on first tap.
+ */
+const LoadedZapDialog = lazy(() =>
+ import('@/components/ZapDialog').then((m) => ({ default: m.ZapDialog })),
+);
+
+interface LazyZapDialogProps {
+ target: Event;
+ children: ReactNode;
+ className?: string;
+}
+
+export function LazyZapDialog({ target, children, className }: LazyZapDialogProps) {
+ const [open, setOpen] = useState(false);
+
+ const handleClick = (event: MouseEvent) => {
+ if (isValidElement(children)) {
+ const child = children as ReactElement<{ onClick?: (event: MouseEvent) => void }>;
+ child.props.onClick?.(event);
+ }
+ setOpen(true);
+ };
+
+ const trigger = isValidElement(children) ? (
+ cloneElement(
+ children as ReactElement<{ onClick?: (event: MouseEvent) => void; className?: string }>,
+ {
+ onClick: handleClick,
+ className: [
+ (children as ReactElement<{ className?: string }>).props.className,
+ 'cursor-pointer',
+ ].filter(Boolean).join(' '),
+ },
+ )
+ ) : (
+
{children}
+ );
+
+ return (
+ <>
+ {trigger}
+ {open && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/RoutePreloader.tsx b/src/components/RoutePreloader.tsx
new file mode 100644
index 0000000..0d9b0e3
--- /dev/null
+++ b/src/components/RoutePreloader.tsx
@@ -0,0 +1,82 @@
+import { useEffect } from 'react';
+import { isNativePlatform } from '@/lib/platform';
+
+/**
+ * Hover / touch prefetch for the primary navigation destinations.
+ *
+ * The route chunks stay lazy (so first paint is not blocked), but once the user
+ * shows intent — hovers a menu row, tabs into a link, or touches it — the page
+ * module starts downloading. On mobile the touchstart head start is often enough
+ * to avoid the page skeleton entirely. Only modules are fetched; no page hooks
+ * mount, so this never triggers relay work or an Amber prompt.
+ */
+const loaders: Record Promise> = {
+ '/lab': () => import('@/pages/LabPage'),
+ '/events': () => import('@/pages/EventsPage'),
+ '/crews': () => import('@/pages/CrewsPage'),
+ '/community': () => import('@/pages/CommunityPage'),
+ '/skaters': () => import('@/pages/SkatersPage'),
+ '/messages': () => import('@/pages/DirectMessagesPage'),
+ '/settings': () => import('@/pages/SettingsPage'),
+ '/wallet': () => import('@/pages/WalletPage'),
+ '/stash': () => import('@/pages/StashPage'),
+ '/marketplace': () => import('@/pages/MarketplaceHubPage'),
+ '/stats': () => import('@/pages/CommunityStatsPage'),
+ '/collections': () => import('@/pages/SpotCollectionsPage'),
+ '/glossary': () => import('@/pages/GlossaryPage'),
+ '/about': () => import('@/pages/AboutPage'),
+ '/card': () => import('@/pages/RiderCardPage'),
+ '/supporters': () => import('@/pages/SupportersPage'),
+ '/newsletter': () => import('@/pages/NewsletterPage'),
+};
+
+const preloaded = new Set();
+
+function routePath(anchor: HTMLAnchorElement): string | null {
+ const href = anchor.getAttribute('href') ?? '';
+ if (!href || href.startsWith('http') || href.startsWith('mailto:') || href.startsWith('tel:')) {
+ return null;
+ }
+ // HashRouter renders to="#/lab" as href="#/lab".
+ if (href.startsWith('#/')) return href.slice(1).split('?')[0] || '/';
+ try {
+ const url = new URL(anchor.href, window.location.href);
+ if (url.hash.startsWith('#/')) return url.hash.slice(1).split('?')[0] || '/';
+ return url.pathname;
+ } catch {
+ return null;
+ }
+}
+
+function preloadFromTarget(target: EventTarget | null): void {
+ if (!(target instanceof Element)) return;
+ const anchor = target.closest('a[href]');
+ if (!(anchor instanceof HTMLAnchorElement)) return;
+ const path = routePath(anchor);
+ if (!path || preloaded.has(path)) return;
+ const loader = loaders[path];
+ if (!loader) return;
+ preloaded.add(path);
+ void loader().catch(() => {
+ // A failed prefetch is harmless; lazyWithRetry will retry on navigation.
+ preloaded.delete(path);
+ });
+}
+
+export function RoutePreloader() {
+ useEffect(() => {
+ if (isNativePlatform()) return;
+
+ const onIntent = (event: Event) => preloadFromTarget(event.target);
+ document.addEventListener('pointerover', onIntent, { capture: true, passive: true });
+ document.addEventListener('focusin', onIntent, { capture: true, passive: true });
+ document.addEventListener('touchstart', onIntent, { capture: true, passive: true });
+ return () => {
+ document.removeEventListener('pointerover', onIntent, true);
+ document.removeEventListener('focusin', onIntent, true);
+ document.removeEventListener('touchstart', onIntent, true);
+ };
+ }, []);
+
+ return null;
+}
diff --git a/src/components/ZapButton.tsx b/src/components/ZapButton.tsx
index 3623b28..25650b4 100644
--- a/src/components/ZapButton.tsx
+++ b/src/components/ZapButton.tsx
@@ -1,4 +1,4 @@
-import { ZapDialog } from '@/components/ZapDialog';
+import { LazyZapDialog } from '@/components/LazyZapDialog';
import { useZaps } from '@/hooks/useZaps';
import { useWallet } from '@/hooks/useWallet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -65,7 +65,7 @@ export function ZapButton({
}
return (
-
+