import { useState, useRef, useEffect, 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 LazySignupDialog = lazy(() => import('@/components/auth/SignupDialog')); 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 { t } = useTranslation(); 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 ( {displayName.slice(0, 2).toUpperCase()} {/* Status indicator */} {t('chromeUi.inBadge')} {/* Green online indicator dot */} {/* User info header */} {displayName.slice(0, 2).toUpperCase()} {displayName} {t('chromeUi.loggedIn')} {/* Set Status - highlighted */} {t('chromeUi.setStatus')} 🛹 {t('chromeUi.addSpot')} {t('nav.viewProfile')} {t('nav.settings')} {t('chromeUi.myData')} {t('chromeUi.myStash')} {t('nav.wallet')} {t('nav.leaderboards')} {t('nav.marketplace')} {/* ── Accounts: switch between logged-in npubs + add another ── */} {t('chromeUi.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} ); })} {t('chromeUi.addAnotherAccount')} {t('chromeUi.logOut')} ); } 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 ( {/* Top hairline glow — gives the bar a lifted, premium edge */} {/* Nav bar */} {/* FIXED: Map / Shops & Spots */} {renderTab(fixedMapTab, 'fixed-map')} {/* CUSTOM: 3 middle tabs */} {resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))} {/* FIXED: Profile / Login tab. Logged in → your public profile (View Profile). Logged out → Settings (login lives there). Long-press / right-click still opens nav customization. */} {(() => { const profileHref = isLoggedIn && userPubkey ? `/${nip19.npubEncode(userPubkey)}` : '/settings'; const tabActive = isActive(profileHref); 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', tabActive ? 'text-[#ff0040]' : 'text-gray-500' )} > {isLoggedIn && userPubkey ? ( {(userName || '??').slice(0, 2).toUpperCase()} ) : ( )} {isLoggedIn ? t('chromeUi.profile') : t('chromeUi.logIn')} ); })()} ); } 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(); // Desktop, logged-out welcome: shows once per browser session (not forever — // a single ✕ used to suppress it permanently). `sessionStorage` survives a // reload but not a new tab/session, so "keep browsing" is respected without // killing the prompt for future visits. const WELCOME_LOGIN_SNOOZED = 'skatespots:desktop-welcome-snoozed'; const [showWelcomeLogin, setShowWelcomeLogin] = useState(false); const [showWelcomeSignup, setShowWelcomeSignup] = useState(false); const dismissWelcomeLogin = () => { try { sessionStorage.setItem(WELCOME_LOGIN_SNOOZED, '1'); } catch { /* ignore */ } setShowWelcomeLogin(false); }; useEffect(() => { if (user || isNativePlatform()) return; try { if (sessionStorage.getItem(WELCOME_LOGIN_SNOOZED) === '1') return; } catch { return; } if (typeof window === 'undefined' || !window.matchMedia?.('(min-width: 1024px)').matches) return; const timer = window.setTimeout(() => setShowWelcomeLogin(true), 900); return () => window.clearTimeout(timer); }, [user]); 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); }; const mainRef = useRef(null); return ( {/* Skip-to-content — visually hidden until focused. A real (not href="#main") because HashRouter treats the hash AS the route, so an anchor would navigate away instead of focusing the content. */} mainRef.current?.focus()} className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-[100] focus:rounded-lg focus:bg-[#ccff00] focus:px-4 focus:py-2 focus:text-sm focus:font-black focus:uppercase focus:tracking-wide focus:text-black focus:shadow-lg" > {t('chromeUi.skipToContent')} {/* Desktop Header */} {/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the logo never gets squeezed into (or glowed over by) the nav. */} SKATESPOTS {/* `min-w-0` + overflow lets the nav shrink and scroll internally instead of colliding with the logo when a user has several tabs. */} {/* Fixed Map tab first, then the user's chosen primary tabs */} {[desktopMapTab, ...desktopPrimaryTabs].map((item) => ( {item.labelKey ? t(item.labelKey) : item.label} ))} {/* More Menu - Desktop */} {t('nav.more')} {/* Catalog items the user hasn't pinned to the top bar. Auth-only items (stash/collections/contacts/wallet/messages) only show when logged in. */} {desktopHiddenIds .map((id) => desktopCatalog.find((c) => c.id === id)) .filter((tab): tab is NavTab => !!tab) .filter((tab) => !tab.authOnly || !!user) .map((tab) => ( {tab.labelKey ? t(tab.labelKey) : tab.label} ))} {/* Directory & account destinations that aren't part of the customizable catalog — kept here so desktop/tablet reaches parity with the mobile burger menu (nothing is ever lost). */} {t('nav.skaters')} {user && ( {t('nav.viewProfile')} )} {t('nav.settings')} {t('nav.glossary')} {t('nav.about')} {t('nav.riderCard')} {t('nav.supporters')} {t('nav.newsletter')} {/* Messages lives in the "More" menu — no standalone desktop icon. */} {/* Collections Quick Access - Desktop */} {user && ( )} {/* Wallet Quick Access - Desktop */} {user && ( {hasCashuWalletHint() && ( )} )} {/* Status Quick Access - Desktop */} {user && ( setShowStatusDialog(true)} title={t('chromeUi.setYourStatus')} > {t('chromeUi.status')} )} {/* Notifications (crew invites & membership requests) */} {user && } {/* Offline Indicator */} {/* Language Toggle */} {/* View own public profile */} {user && ( )} {/* Mobile Header */} 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)} /> )} {t('chromeUi.menu')} {/* Wallet Card - Mobile Menu */} {user && ( setMobileMenuOpen(false)} className="block" > {t('wallet.cashu.title')} {t('chromeUi.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 */} { setMobileMenuOpen(false); setShowCustomizeNav(true); }} className="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 text-muted-foreground hover:bg-muted hover:text-foreground text-left" > {t('chromeUi.customizeNavBar')} {/* Language Toggle in Mobile Menu — inline (a portaled dropdown is unclickable inside a Radix Sheet) */} {/* Login/Logout Section */} {user ? ( <> {/* User Profile Card */} {displayName.slice(0, 2).toUpperCase()} {displayName} {t('chromeUi.loggedIn')} {/* Logout Button - Big and Clear */} { removeLogin(currentAccount?.id || ''); setMobileMenuOpen(false); }} > {t('chromeUi.logOut')} > ) : ( <> {/* Not Logged In - Clear CTA */} {t('chromeUi.notLoggedIn')} {t('chromeUi.loggedOutPrompt')} > )} {/* 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 */} {/* 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)} /> {/* Desktop, logged-out, first visit — one-time sign-in offer */} { dismissWelcomeLogin(); setShowWelcomeSignup(true); }} /> setShowWelcomeSignup(false)} onUseAmber={() => { setShowWelcomeSignup(false); setShowWelcomeLogin(true); }} /> {/* Newsletter: floating pill + Monday admin reminder */} {/* Mirrors in-app activity to OS notifications — inert until opted in. */} ); }
{displayName}
{t('chromeUi.loggedIn')}
{t('wallet.cashu.title')}
{t('chromeUi.ecash')}
{t('chromeUi.notLoggedIn')}
{t('chromeUi.loggedOutPrompt')}