import { useState, useRef } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { cn } from '@/lib/utils'; import { CustomizeNavSheet } from '@/components/CustomizeNavSheet'; import { useNavPreferences, useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; 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 { SasquatchGuide } from '@/components/SasquatchGuide'; import { useSasquatchGuide } from '@/hooks/useSasquatchGuide'; import { BackupReminder } from '@/components/BackupReminder'; import { LanguageToggle } from '@/components/LanguageToggle'; import { useCashu } from '@/hooks/wallet/useCashu'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useTranslation } from '@/contexts/LanguageContext'; import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; import { MapPin, Calendar, Menu, Home, Wallet, Settings, GraduationCap, BarChart3, MoreHorizontal, Info, Bookmark, MessageCircle, LogOut, User, LogIn, Plus, CheckCircle2, Database, Package, Zap, Heart, CreditCard, Radio, Users, SlidersHorizontal, BookOpen, PersonStanding, } from 'lucide-react'; import { OfflineIndicator } from '@/components/OfflineIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { SetStatusDialog } from '@/components/SetStatusDialog'; 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'; interface LayoutProps { 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. */ 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 }, ]; // Format sats for display (e.g., 1234 -> "1.2k", 12345 -> "12k") function formatSatsCompact(sats: number): string { if (sats >= 1000000) { return `${(sats / 1000000).toFixed(1)}M`; } if (sats >= 1000) { return `${(sats / 1000).toFixed(sats >= 10000 ? 0 : 1)}k`; } return sats.toString(); } 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, }: { pubkey: string; onLogout: () => void; onSetStatus: () => void; }) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(pubkey); const avatar = metadata?.picture; return ( {displayName.slice(0, 2).toUpperCase()} {/* Status indicator */} In {/* Green online indicator dot */} {/* 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 Log Out ); } function MobileNav({ isActive, cashuBalance, isLoggedIn, userPubkey, userAvatar, userName, t, onCustomize, fixedMapTab, middleTabs, }: { isActive: (path: string) => boolean; cashuBalance: number; 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 dot = isWallet ? 'bg-orange-400' : 'bg-[#ff0040]'; return ( { e.preventDefault(); onCustomize(); }} className={cn( 'relative flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 rounded-xl transition-all duration-150 flex-1 touch-manipulation active:scale-90', active ? accent : 'text-gray-500', )} > {active && ( )} {isWallet ? ( {cashuBalance > 0 && ( {formatSatsCompact(cashuBalance)} )} ) : ( )} {tab.labelKey ? t(tab.labelKey) : tab.label} ); }; return ( {/* 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( 'relative flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 rounded-xl transition-all duration-150 flex-1 touch-manipulation active:scale-90', tabActive ? 'text-[#ff0040]' : 'text-gray-500' )} > {tabActive && ( )} {isLoggedIn && userPubkey ? ( {(userName || '??').slice(0, 2).toUpperCase()} ) : ( )} {isLoggedIn ? 'Profile' : 'Log In'} ); })()} ); } export function Layout({ children, noPadding }: LayoutProps) { const location = useLocation(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [showStatusDialog, setShowStatusDialog] = useState(false); const [showCustomizeNav, setShowCustomizeNav] = useState(false); const { currentMessage, dismissCurrentMessage } = useSasquatchGuide(); const { user } = useCurrentUser(); const { totalBalance: cashuBalance, isInitialized: hasCashu } = useCashu(); 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 } = 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 ( {/* Desktop Header */} SKATESPOTS {/* 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). */} Skaters {user && ( View Profile )} {t('nav.settings')} Glossary {t('nav.about')} Rider Card Supporters ⚡ Newsletter {/* Messages lives in the "More" menu — no standalone desktop icon. */} {/* Collections Quick Access - Desktop */} {user && ( )} {/* Wallet Quick Access - Desktop */} {user && ( {hasCashu && cashuBalance > 0 && ( {formatSatsCompact(cashuBalance)} )} )} {/* Status Quick Access - Desktop */} {user && ( setShowStatusDialog(true)} title="Set Your Status" > 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 */} {user ? ( removeLogin(currentAccount?.id || '')} onSetStatus={() => setShowStatusDialog(true)} /> ) : ( )} Menu {/* Wallet Card - Mobile Menu */} {user && ( setMobileMenuOpen(false)} className="block" > {t('wallet.cashu.title')} Ecash {hasCashu && ( {cashuBalance.toLocaleString()} sats )} )} {navItemKeys.map((item) => ( setMobileMenuOpen(false)} /> ))} 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" > Customize 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 */} { removeLogin(currentAccount?.id || ''); setMobileMenuOpen(false); }} > Log Out > ) : ( <> {/* Not Logged In - Clear CTA */} Not Logged In Log in to add spots, zap skaters, and save your favorites > )} {/* Backup Reminder */} {/* 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) */} {/* Sasquatch Guide (all screens) */} {/* Set Status Dialog */} {/* Newsletter: floating pill + Monday admin reminder */} ); }
{displayName}
Logged In
{t('wallet.cashu.title')}
Ecash
{cashuBalance.toLocaleString()}
sats
Not Logged In
Log in to add spots, zap skaters, and save your favorites