import { useState, useRef, useEffect, lazy, Suspense } from 'react';
import { Link, Outlet, useLocation, useNavigate } 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 { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
import { clearTranslationMemory } from '@/lib/translation';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useTranslation } from '@/contexts/language';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { AuthDialogContext } from '@/contexts/authDialog';
import {
MapPin,
Calendar,
Menu,
Wallet,
Settings,
GraduationCap,
BarChart3,
MoreHorizontal,
Info,
Bookmark,
MessageCircle,
LogOut,
User,
Plus,
CheckCircle2,
Package,
Heart,
CreditCard,
Radio,
Users,
SlidersHorizontal,
BookOpen,
PersonStanding,
Languages,
MessagesSquare,
ChevronLeft,
ChevronRight,
ChevronDown,
ArrowLeft,
Search,
} 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 { NotificationBellLazy } from '@/components/NotificationBellLazy';
import { GlobalSearch } from '@/components/GlobalSearch';
import { openGlobalSearch, GLOBAL_SEARCH_EVENT } from '@/lib/globalSearch';
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;
}
}
/**
* 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. */
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 },
{ href: '/community', labelKey: 'communityUi.link', icon: MessagesSquare },
];
/**
* 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', '/community', '/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 */}
{/* Green online indicator dot */}
{/* User info header */}
{displayName.slice(0, 2).toUpperCase()}
{displayName}
{t('chromeUi.loggedIn')}
{/* Set Status - highlighted */}
{t('chromeUi.setStatus')}🛹
{t('nav.viewProfile')}
{t('nav.settings')}
{/* ── 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,
t,
onCustomize,
onMore,
fixedMapTab,
middleTabs,
}: {
isActive: (path: string) => boolean;
isLoggedIn: boolean;
t: (key: string) => string;
onCustomize: () => void;
onMore: () => 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();
const navigate = useNavigate();
const showMobileBack = location.pathname !== '/' && location.pathname !== '/map';
// 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';
// The floating assistant must not cover form controls on long-form pages.
const hideSasquatch = /^\/(become|create|edit|supporters|onboarding)(\/|$)/.test(location.pathname);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileLanguageView, setMobileLanguageView] = useState(false);
// Back/forward, notification taps and global search can change routes without
// clicking a menu row. Never leave the burger sheet covering the new page.
useEffect(() => {
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);
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 [searchOpened, setSearchOpened] = useState(false);
const dismissWelcomeLogin = () => {
try { sessionStorage.setItem(WELCOME_LOGIN_SNOOZED, '1'); } catch { /* ignore */ }
setShowWelcomeLogin(false);
};
const requestAuth = (kind: 'login' | 'signup') => {
// Close every overlay that owns a lower z-index before opening the auth
// dialog. The burger sheet is z-2100; a dialog rendered inside it is z-50
// and becomes invisible/unclickable.
try { sessionStorage.setItem(WELCOME_LOGIN_SNOOZED, '1'); } catch { /* ignore */ }
setMobileMenuOpen(false);
setMobileLanguageView(false);
setShowWelcomeLogin(false);
setShowWelcomeSignup(false);
setAuthDialog(kind);
};
const requestLogin = () => requestAuth('login');
const requestSignup = () => requestAuth('signup');
useEffect(() => {
const onSearchOpen = () => {
setSearchOpened(true);
setShowWelcomeLogin(false);
setShowWelcomeSignup(false);
try { sessionStorage.setItem(WELCOME_LOGIN_SNOOZED, '1'); } catch { /* ignore */ }
};
window.addEventListener(GLOBAL_SEARCH_EVENT, onSearchOpen);
return () => window.removeEventListener(GLOBAL_SEARCH_EVENT, onSearchOpen);
}, []);
useEffect(() => {
if (user || authDialog || isNativePlatform() || searchOpened) 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, authDialog, searchOpened]);
const { t, language } = useTranslation();
const currentLanguage = LANGUAGE_OPTIONS.find((item) => item.code === language) ?? LANGUAGE_OPTIONS[0];
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
const author = useAuthor(user?.pubkey || '');
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
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