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 DataMy StashWalletLeaderboardsMarketplace
{/* ── 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
{/* `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
{/* 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 */}
{/* 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. */}