import { useState, useRef, lazy, Suspense } from 'react'; import { Link, Outlet, useLocation } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { cn } from '@/lib/utils'; import { isNativePlatform } from '@/lib/platform'; import { useNavPreferences, useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences'; import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { LoginArea } from '@/components/auth/LoginArea'; import { useSasquatchGuide } from '@/hooks/useSasquatchGuide'; import { LanguageToggle } from '@/components/LanguageToggle'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useTranslation } from '@/contexts/language'; import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; import { MapPin, Calendar, Menu, Wallet, Settings, GraduationCap, BarChart3, MoreHorizontal, Info, Bookmark, MessageCircle, LogOut, User, Plus, CheckCircle2, Database, Package, Heart, CreditCard, Radio, Users, SlidersHorizontal, BookOpen, PersonStanding, } from 'lucide-react'; import { OfflineIndicator } from '@/components/OfflineIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { NewsletterOverlay } from '@/components/NewsletterOverlay'; import { NotificationBell } from '@/components/NotificationBell'; import { OsNotificationBridge } from '@/components/OsNotificationBridge'; // Cashu balance widget is lazy — see components/wallet/CashuBalance.tsx for why // (keeps the Cashu stack out of the initial bundle). const LazyCashuBalanceText = lazy(() => import('@/components/wallet/CashuBalance').then((m) => ({ default: m.CashuBalanceText })), ); const LazyCashuBalanceBadge = lazy(() => import('@/components/wallet/CashuBalance').then((m) => ({ default: m.CashuBalanceBadge })), ); const LazyCashuMenuBalance = lazy(() => import('@/components/wallet/CashuBalance').then((m) => ({ default: m.CashuMenuBalance })), ); // The Sasquatch assistant is a floating overlay — not needed at first paint. const LazySasquatchGuide = lazy(() => import('@/components/SasquatchGuide').then((m) => ({ default: m.SasquatchGuide })), ); // On-demand modals — only rendered after the user opens them, so keep them out // of the always-loaded Layout chunk. const LazyCustomizeNavSheet = lazy(() => import('@/components/CustomizeNavSheet').then((m) => ({ default: m.CustomizeNavSheet })), ); const LazySetStatusDialog = lazy(() => import('@/components/SetStatusDialog').then((m) => ({ default: m.SetStatusDialog })), ); const LazyLoginDialog = lazy(() => import('@/components/auth/LoginDialog').then((m) => ({ default: m.LoginDialog })), ); const LazyBackupReminder = lazy(() => import('@/components/BackupReminder').then((m) => ({ default: m.BackupReminder })), ); /** * Cheap synchronous hint that a Cashu wallet MIGHT exist, used to decide * whether to even load the (large) wallet chunk. On native the seed lives in * the OS KeyStore which we can't query synchronously, so we always load there; * on web `useCashu` persists the mnemonic in localStorage under this key. */ function hasCashuWalletHint(): boolean { if (isNativePlatform()) return true; try { return !!localStorage.getItem('cashu-mnemonic'); } catch { return false; } } interface LayoutProps { /** Page content. Optional: when rendered as a router layout (via ) * there are no children. */ children?: React.ReactNode; /** If true, removes the bottom padding that prevents bottom nav overlap. * Use for full-bleed pages like the map that manage their own height. * The map routes get this automatically (see `isFullBleed`). */ noPadding?: boolean; } // Nav item keys that map to translation keys // HOME removed — the map IS the homepage now const navItemKeys = [ { href: '/', labelKey: 'nav.map', icon: MapPin }, { href: '/lab', labelKey: 'nav.lab', icon: GraduationCap }, { href: '/events', labelKey: 'nav.events', icon: Calendar }, { href: '/crews', labelKey: 'nav.crews', icon: Users }, ]; /** * Hrefs the mobile burger menu already renders by hand. Anything else in the * nav catalog (Marketplace, Mints, …) is appended from the catalog itself, so a * section can never be missing on mobile just because it wasn't hard-coded. */ const MOBILE_MENU_LISTED_HREFS = [ '/', '/lab', '/events', '/crews', '/skaters', '/messages', '/contacts', '/collections', '/stash', '/stats', '/glossary', '/about', '/card', '/supporters', '/newsletter', '/settings', '/wallet', ]; function NavLink({ href, label, icon: Icon, isActive, onClick, }: { href: string; label: string; icon: React.ComponentType<{ className?: string }>; isActive: boolean; onClick?: () => void; }) { return ( {label} ); } // Mobile header user menu - shows when logged in with dropdown for actions function MobileHeaderUserMenu({ pubkey, onLogout, onSetStatus, onAddAccount, }: { pubkey: string; onLogout: () => void; onSetStatus: () => void; onAddAccount: () => void; }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(pubkey); const avatar = metadata?.picture; // Multi-account: list other logins so the user can switch on mobile. const { otherUsers, setLogin } = useLoggedInAccounts(); return ( {/* User info header */}
{displayName.slice(0, 2).toUpperCase()}

{displayName}

Logged In

{/* Set Status - highlighted */} Set Status 🛹 Add Spot View Profile Settings My Data My Stash Wallet Leaderboards Marketplace {/* ── Accounts: switch between logged-in npubs + add another ── */}
Accounts
{otherUsers.map((account) => { const name = account.metadata?.name ?? genUserName(account.pubkey); return ( setLogin(account.id)} className="flex items-center gap-2 cursor-pointer" > {name.slice(0, 2).toUpperCase()} {name} ); })} Add another account Log Out
); } function MobileNav({ isActive, isLoggedIn, userPubkey, userAvatar, userName, t, onCustomize, fixedMapTab, middleTabs, }: { isActive: (path: string) => boolean; isLoggedIn: boolean; userPubkey?: string; userAvatar?: string; userName?: string; t: (key: string) => string; onCustomize: () => void; fixedMapTab: NavTab; middleTabs: NavTab[]; }) { // Bar model: FIXED Map · 3 user-picked middle tabs · FIXED Profile. // (tabs are resolved in Layout and passed in to avoid any module-init/TDZ // ordering issues with the hoisted function declaration.) // Middle tabs the user pinned. If a login-gated tab is pinned while logged // out, fall back to a sensible public tab so the slot is never dead. const resolvedMiddle = (middleTabs ?? []).map((tab) => { if (tab.authOnly && !isLoggedIn) { return { ...tab, href: '/lab', label: 'The Lab', labelKey: 'nav.lab' }; } return tab; }); // Long-press (touch) / right-click opens the customization sheet. const pressTimer = useRef | null>(null); const startPress = () => { pressTimer.current = setTimeout(() => { onCustomize(); pressTimer.current = null; }, 500); }; const cancelPress = () => { if (pressTimer.current) { clearTimeout(pressTimer.current); pressTimer.current = null; } }; const renderTab = (tab: NavTab, key: string) => { const active = isActive(tab.href); const isWallet = tab.id === 'wallet'; const accent = isWallet ? 'text-orange-400' : 'text-[#ff0040]'; const glow = isWallet ? 'bg-orange-500/15 shadow-[0_0_16px_rgba(249,115,22,0.35)]' : 'bg-[#ff0040]/15 shadow-[0_0_16px_rgba(255,0,64,0.35)]'; return ( { e.preventDefault(); onCustomize(); }} className={cn( 'group relative flex flex-col items-center justify-center gap-1 py-1.5 px-1 flex-1 touch-manipulation active:scale-90 transition-transform duration-150', active ? accent : 'text-gray-500', )} > {/* Glowing pill behind the active icon */} {isWallet ? ( <> {hasCashuWalletHint() && ( )} ) : ( )} {tab.labelKey ? t(tab.labelKey) : tab.label} ); }; return ( ); } export function Layout({ children, noPadding }: LayoutProps) { const location = useLocation(); // The map is full-bleed (it manages its own height), so it gets no bottom // padding even though it shares this one persistent layout with every route. const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map'; const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [showStatusDialog, setShowStatusDialog] = useState(false); const [showCustomizeNav, setShowCustomizeNav] = useState(false); const [showAddAccount, setShowAddAccount] = useState(false); const { currentMessage, dismissCurrentMessage } = useSasquatchGuide(); const { user } = useCurrentUser(); const { t } = useTranslation(); const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts(); const author = useAuthor(user?.pubkey || ''); const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey); const userAvatar = author.data?.metadata?.picture; const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences(); const { primaryTabs: desktopPrimaryTabs, hiddenIds: desktopHiddenIds, fixedMapTab: desktopMapTab, catalog: desktopCatalog, } = useDesktopNavPreferences(); const isActive = (path: string) => { // Both / and /map are the "spots" page if (path === '/') return location.pathname === '/' || location.pathname === '/map'; return location.pathname.startsWith(path); }; return (
{/* Skip-to-content — visually hidden until focused, so keyboard and screen-reader users can jump past the nav on every page. */} Skip to content {/* Desktop Header */}
{/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the logo never gets squeezed into (or glowed over by) the nav. */} SkateSpots SKATESPOTS {/* `min-w-0` + overflow lets the nav shrink and scroll internally instead of colliding with the logo when a user has several tabs. */}
{/* Messages lives in the "More" menu — no standalone desktop icon. */} {/* Collections Quick Access - Desktop */} {user && ( )} {/* Wallet Quick Access - Desktop */} {user && ( )} {/* Status Quick Access - Desktop */} {user && ( )} {/* Notifications (crew invites & membership requests) */} {user && } {/* Offline Indicator */} {/* Language Toggle */} {/* View own public profile */} {user && ( )}
{/* Mobile Header */}
SkateSpots SKATESPOTS
{/* Notifications (crew invites & membership requests) */} {user && } {/* User menu when logged in / Login button when not. IMPORTANT: LoginArea hosts the SignupDialog. The signup flow logs the user in at the *first* step (to publish their profile), then continues to the profile + recovery steps. If we unmount LoginArea the instant `user` becomes truthy, the dialog is destroyed and the skater is dumped on the map with no recovery step — the exact bug reported. So we keep LoginArea mounted always and just hide its trigger when logged in; MobileHeaderUserMenu becomes the visible control. The dialog renders in a portal, so hiding the trigger is purely cosmetic and never interrupts an in-progress signup. */} {user && ( removeLogin(currentAccount?.id || '')} onSetStatus={() => setShowStatusDialog(true)} onAddAccount={() => setShowAddAccount(true)} /> )}
Menu
{/* Wallet Card - Mobile Menu */} {user && ( setMobileMenuOpen(false)} className="block" >

{t('wallet.cashu.title')}

Ecash

{hasCashuWalletHint() && ( )}
)}
{navItemKeys.map((item) => ( setMobileMenuOpen(false)} /> ))} setMobileMenuOpen(false)} /> {/* Every catalogued section not already listed above, so nothing (Marketplace, Mints, …) is unreachable on mobile. Desktop has the same parity via its "More" menu. */} {navCatalog .filter((tab) => !MOBILE_MENU_LISTED_HREFS.includes(tab.href)) .filter((tab) => !tab.authOnly || !!user) .map((tab) => ( setMobileMenuOpen(false)} /> ))} {user && ( setMobileMenuOpen(false)} /> )} {user && ( setMobileMenuOpen(false)} /> )} {user && ( setMobileMenuOpen(false)} /> )} {user && ( setMobileMenuOpen(false)} /> )} {user && ( setMobileMenuOpen(false)} /> )} setMobileMenuOpen(false)} /> setMobileMenuOpen(false)} /> setMobileMenuOpen(false)} /> setMobileMenuOpen(false)} /> setMobileMenuOpen(false)} /> setMobileMenuOpen(false)} /> setMobileMenuOpen(false)} /> {/* Customize the bottom nav bar */}
{/* Language Toggle in Mobile Menu */}
{/* Login/Logout Section */}
{user ? ( <> {/* User Profile Card */}
{displayName.slice(0, 2).toUpperCase()}

{displayName}

Logged In

{/* Logout Button - Big and Clear */} ) : ( <> {/* Not Logged In - Clear CTA */}

Not Logged In

Log in to add spots, zap skaters, and save your favorites

)}
{/* Backup Reminder — lazy (on-demand banner) */} {/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */} {/* noPadding: full-bleed pages (map) manage their own height */}
{children ?? }
{/* Footer - Desktop */}
Made with ❤️ for the skateboarding community Beta
Newsletter License Privacy Collaborators & Friends Not sponsored by OpenSats OpenSats
{/* No mobile footer - bottom nav provides navigation, keeps UI clean */} {/* Mobile Bottom Navigation */} setShowCustomizeNav(true)} fixedMapTab={fixedMapTab} middleTabs={middleTabs} /> {/* Bottom-nav customization sheet (mobile) — lazy (on-demand modal) */} {/* Sasquatch Guide (all screens) — lazy; not needed at first paint */} {/* Set Status Dialog — lazy (on-demand modal) */} {/* Add-another-account (multi-account switch on mobile) — lazy */} setShowAddAccount(false)} onLogin={() => setShowAddAccount(false)} /> {/* Newsletter: floating pill + Monday admin reminder */} {/* Mirrors in-app activity to OS notifications — inert until opted in. */}
); }