SkateSpots/src/components/Layout.tsx
SkateSpots 5bf58499ea ui(map): float the mobile toolbar so the map fills the screen
Mobile map was paying for a stacked toolbar under the header, leaving the map
~78% of the viewport.

- MapPage: the toolbar is absolute over the map on mobile (gradient fade,
  pointer-events pass-through) and static on desktop (md:static); controls get
  translucent/blurred backgrounds for legibility over the map.
- Container height now subtracts header + bottom nav only (desktop footer
  subtraction removed); Layout hides the desktop footer on full-bleed routes.
- Loading pill / offline banner nudged below the floating bar on mobile.

Measured at 390x844 (headless): map starts under the header and fills 730px =
86.5% of the viewport (was ~78%).
2026-09-29 17:05:20 +07:00

1156 lines
49 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <Outlet/>)
* 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 (
<Link
to={href}
onClick={onClick}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200',
isActive
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
)}
>
<Icon className="h-5 w-5" />
<span className="font-medium">{label}</span>
</Link>
);
}
// 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="touch-manipulation relative flex items-center gap-2 px-2 py-1.5 rounded-full bg-green-500/10 border border-green-500/30 active:scale-95 transition-transform">
<Avatar className="h-7 w-7 border-2 border-green-500/50">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Status indicator */}
<UserStatusIndicator pubkey={pubkey} size="sm" />
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
<CheckCircle2 className="h-3 w-3 inline mr-1" />
In
</span>
{/* Green online indicator dot */}
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 p-2">
{/* User info header */}
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
<div className="flex items-center gap-3">
<div className="relative">
<Avatar className="h-10 w-10 border-2 border-green-500/50">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-green-500/20 text-green-400 text-sm font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<UserStatusIndicator pubkey={pubkey} size="sm" className="absolute -bottom-1 -right-1" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-sm truncate">{displayName}</p>
<p className="text-xs text-green-500 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Logged In
</p>
</div>
</div>
</div>
{/* Set Status - highlighted */}
<DropdownMenuItem
onClick={onSetStatus}
className="flex items-center gap-2 cursor-pointer mb-1 bg-green-500/10 border border-green-500/30 rounded-lg"
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-green-400 font-bold">Set Status</span>
<span className="ml-auto text-xs text-green-500">🛹</span>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/create-spot" className="flex items-center gap-2 cursor-pointer">
<Plus className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00] font-medium">Add Spot</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" />
View Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings" className="flex items-center gap-2 cursor-pointer">
<Settings className="h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings?section=mydata" className="flex items-center gap-2 cursor-pointer">
<Database className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">My Data</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-purple-400" />
<span className="text-purple-400">My Stash</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
<Wallet className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">Wallet</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/leaderboards" className="flex items-center gap-2 cursor-pointer">
<BarChart3 className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">Leaderboards</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/marketplace" className="flex items-center gap-2 cursor-pointer">
<Package className="h-4 w-4 text-orange-400" />
<span className="text-orange-400">Marketplace</span>
</Link>
</DropdownMenuItem>
{/* ── Accounts: switch between logged-in npubs + add another ── */}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[10px] font-black uppercase tracking-widest text-gray-500">
Accounts
</div>
{otherUsers.map((account) => {
const name = account.metadata?.name ?? genUserName(account.pubkey);
return (
<DropdownMenuItem
key={account.id}
onClick={() => setLogin(account.id)}
className="flex items-center gap-2 cursor-pointer"
>
<Avatar className="h-6 w-6">
<AvatarImage src={account.metadata?.picture} alt={name} />
<AvatarFallback className="text-[9px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="flex-1 truncate">{name}</span>
</DropdownMenuItem>
);
})}
<DropdownMenuItem
onClick={onAddAccount}
className="flex items-center gap-2 cursor-pointer"
>
<Plus className="h-4 w-4 text-teal-400" />
<span className="text-teal-400">Add another account</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onLogout}
className="flex items-center gap-2 cursor-pointer text-red-400"
>
<LogOut className="h-4 w-4" />
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
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<ReturnType<typeof setTimeout> | 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 (
<Link
key={key}
to={tab.href}
onTouchStart={startPress}
onTouchEnd={cancelPress}
onTouchMove={cancelPress}
onContextMenu={(e) => {
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 */}
<span
className={cn(
'relative flex items-center justify-center h-9 w-12 rounded-2xl transition-all duration-300',
active ? glow : 'bg-transparent',
)}
>
{isWallet ? (
<>
<Wallet className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} />
{hasCashuWalletHint() && (
<Suspense fallback={null}>
<LazyCashuBalanceBadge />
</Suspense>
)}
</>
) : (
<tab.icon className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} />
)}
</span>
<span className={cn(
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
active ? 'font-extrabold' : 'font-semibold',
)}>
{tab.labelKey ? t(tab.labelKey) : tab.label}
</span>
</Link>
);
};
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden">
{/* Top hairline glow — gives the bar a lifted, premium edge */}
<div className="h-px w-full bg-gradient-to-r from-transparent via-[#ff0040]/50 to-transparent" />
{/* Nav bar */}
<div
className="glass border-t border-white/10 bg-gradient-to-b from-black/70 to-black/95 backdrop-blur-xl"
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)', paddingLeft: 'env(safe-area-inset-left, 0px)', paddingRight: 'env(safe-area-inset-right, 0px)' }}
>
<div className="flex items-stretch justify-around px-1.5 pt-1.5 pb-1">
{/* 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 (
<Link
to={profileHref}
onTouchStart={startPress}
onTouchEnd={cancelPress}
onTouchMove={cancelPress}
onContextMenu={(e) => {
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'
)}
>
<span
className={cn(
'relative flex items-center justify-center h-9 w-12 rounded-2xl transition-all duration-300',
tabActive ? 'bg-[#ff0040]/15 shadow-[0_0_16px_rgba(255,0,64,0.35)]' : 'bg-transparent',
)}
>
{isLoggedIn && userPubkey ? (
<div className="relative">
<Avatar className={cn(
'h-[24px] w-[24px] border-2 transition-transform duration-300',
tabActive ? 'border-[#ff0040] scale-110' : 'border-gray-600 group-active:scale-95'
)}>
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-[8px] font-bold">
{(userName || '??').slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-green-500 border border-black rounded-full" />
</div>
) : (
<User className={cn('h-[22px] w-[22px] transition-transform duration-300', tabActive ? 'scale-110' : 'group-active:scale-95')} />
)}
</span>
<span className={cn(
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
tabActive ? 'font-extrabold' : 'font-semibold',
)}>
{isLoggedIn ? 'Profile' : 'Log In'}
</span>
</Link>
);
})()}
</div>
</div>
</nav>
);
}
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 (
<div className="min-h-screen bg-black text-white">
{/* Skip-to-content — visually hidden until focused, so keyboard and
screen-reader users can jump past the nav on every page. */}
<a
href="#main"
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"
>
Skip to content
</a>
{/* Desktop Header */}
<header className="hidden md:flex sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur">
<div className="mx-auto flex w-full max-w-[1700px] items-center justify-between h-16 px-4 gap-4 xl:gap-8">
{/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the
logo never gets squeezed into (or glowed over by) the nav. */}
<Link to="/" className="flex items-center gap-3 group shrink-0">
<img
src="/images/icon-1024.png"
alt="SkateSpots"
className="w-10 h-10 shrink-0 rounded-xl shadow-lg shadow-red-500/30 group-hover:shadow-red-500/50 transition-shadow"
/>
<span className="font-display text-2xl neon-red tracking-wide whitespace-nowrap" style={{
textShadow: '0 0 10px #ff0040, 0 0 20px #ff0040'
}}>SKATESPOTS</span>
</Link>
{/* `min-w-0` + overflow lets the nav shrink and scroll internally
instead of colliding with the logo when a user has several tabs. */}
<nav className="flex items-center gap-0.5 min-w-0 overflow-x-auto scrollbar-hide">
{/* Fixed Map tab first, then the user's chosen primary tabs */}
{[desktopMapTab, ...desktopPrimaryTabs].map((item) => (
<Link
key={item.id}
to={item.href}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide whitespace-nowrap',
isActive(item.href)
? 'text-red-500'
: 'text-gray-400 hover:text-red-400'
)}
>
<item.icon className="h-4 w-4" />
{item.labelKey ? t(item.labelKey) : item.label}
</Link>
))}
{/* More Menu - Desktop */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide h-auto',
(isActive('/stats') || isActive('/about'))
? 'text-red-500'
: 'text-gray-400 hover:text-red-400'
)}
>
<MoreHorizontal className="h-4 w-4" />
{t('nav.more')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{/* 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) => (
<DropdownMenuItem asChild key={tab.id}>
<Link to={tab.href} className="flex items-center gap-2 cursor-pointer">
<tab.icon className="h-4 w-4" />
{tab.labelKey ? t(tab.labelKey) : tab.label}
</Link>
</DropdownMenuItem>
))}
{/* 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). */}
<DropdownMenuItem asChild>
<Link to="/skaters" className="flex items-center gap-2 cursor-pointer">
<PersonStanding className="h-4 w-4" />
Skaters
</Link>
</DropdownMenuItem>
{user && (
<DropdownMenuItem asChild>
<Link to={`/${nip19.npubEncode(user.pubkey)}`} className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" />
View Profile
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link to="/settings" className="flex items-center gap-2 cursor-pointer">
<Settings className="h-4 w-4" />
{t('nav.settings')}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/glossary" className="flex items-center gap-2 cursor-pointer">
<BookOpen className="h-4 w-4" />
Glossary
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/about" className="flex items-center gap-2 cursor-pointer">
<Info className="h-4 w-4" />
{t('nav.about')}
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/card" className="flex items-center gap-2 cursor-pointer">
<CreditCard className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">Rider Card</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/supporters" className="flex items-center gap-2 cursor-pointer">
<Heart className="h-4 w-4 text-[#ff0040]" />
<span className="text-[#ff0040]">Supporters ⚡</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/newsletter" className="flex items-center gap-2 cursor-pointer">
<Radio className="h-4 w-4 text-[#ccff00]" />
<span className="text-[#ccff00]">Newsletter</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</nav>
<div className="flex items-center gap-3">
{/* Messages lives in the "More" menu — no standalone desktop icon. */}
{/* Collections Quick Access - Desktop */}
{user && (
<Link to="/collections">
<Button
variant="ghost"
size="icon"
className={cn(
isActive('/collections')
? "text-[#ccff00] bg-[#ccff00]/10"
: "text-gray-400 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
)}
title="My Collections"
>
<Bookmark className="h-5 w-5" />
</Button>
</Link>
)}
{/* Wallet Quick Access - Desktop */}
{user && (
<Link to="/wallet">
<Button
variant="ghost"
className={cn(
"gap-2 px-3",
isActive('/wallet')
? "text-orange-500 bg-orange-500/10"
: "text-gray-400 hover:text-orange-400 hover:bg-orange-500/10"
)}
>
<Wallet className="h-5 w-5" />
{hasCashuWalletHint() && (
<Suspense fallback={null}>
<LazyCashuBalanceText />
</Suspense>
)}
</Button>
</Link>
)}
{/* Status Quick Access - Desktop */}
{user && (
<Button
variant="outline"
className="gap-2 px-3 border-green-500/50 text-green-400 hover:text-green-300 hover:bg-green-500/10 hover:border-green-500"
onClick={() => setShowStatusDialog(true)}
title="Set Your Status"
>
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="text-sm font-bold">Status</span>
</Button>
)}
{/* Notifications (crew invites & membership requests) */}
{user && <NotificationBell />}
{/* Offline Indicator */}
<OfflineIndicator />
{/* Language Toggle */}
<LanguageToggle compact className="text-gray-400 hover:text-white" />
{/* View own public profile */}
{user && (
<Link to={`/${nip19.npubEncode(user.pubkey)}`}>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-[#ff0040] hover:bg-[#ff0040]/10"
title="View my profile"
>
<User className="h-5 w-5" />
</Button>
</Link>
)}
<Link to="/settings">
<Button variant="ghost" size="icon" title="Settings">
<Settings className="h-5 w-5" />
</Button>
</Link>
<LoginArea className="max-w-60" />
</div>
</div>
</header>
{/* Mobile Header */}
<header className="md:hidden sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur safe-area-top">
<div className="flex items-center justify-between h-14 px-3 gap-3">
<Link to="/" className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0">
<img
src="/images/icon-1024.png"
alt="SkateSpots"
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
/>
<span className="font-display text-xl neon-red tracking-wide" style={{
textShadow: '0 0 8px #ff0040'
}}>SKATESPOTS</span>
</Link>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Notifications (crew invites & membership requests) */}
{user && <NotificationBell />}
{/* 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 && (
<MobileHeaderUserMenu
pubkey={user.pubkey}
onLogout={() => removeLogin(currentAccount?.id || '')}
onSetStatus={() => setShowStatusDialog(true)}
onAddAccount={() => setShowAddAccount(true)}
/>
)}
<div className={cn(user && 'hidden')}>
<LoginArea compact className="flex flex-row gap-1" />
</div>
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 touch-manipulation active:scale-95 transition-transform">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-80 overflow-y-auto">
<SheetTitle className="sr-only">Menu</SheetTitle>
<div className="flex flex-col gap-6 mt-6 pb-8">
{/* Wallet Card - Mobile Menu */}
{user && (
<Link
to="/wallet"
onClick={() => setMobileMenuOpen(false)}
className="block"
>
<div className={cn(
"p-4 rounded-xl border-2 transition-all",
isActive('/wallet')
? "bg-orange-500/20 border-orange-500/50"
: "bg-orange-500/10 border-orange-500/30 hover:bg-orange-500/20"
)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<Wallet className="h-5 w-5 text-orange-500" />
</div>
<div>
<p className="font-bold text-sm">{t('wallet.cashu.title')}</p>
<p className="text-xs text-muted-foreground">Ecash</p>
</div>
</div>
{hasCashuWalletHint() && (
<Suspense fallback={null}>
<LazyCashuMenuBalance />
</Suspense>
)}
</div>
</div>
</Link>
)}
<div className="flex flex-col gap-2">
{navItemKeys.map((item) => (
<NavLink
key={item.href}
href={item.href}
label={t(item.labelKey)}
icon={item.icon}
isActive={isActive(item.href)}
onClick={() => setMobileMenuOpen(false)}
/>
))}
<NavLink
href="/skaters"
label="Skaters"
icon={PersonStanding}
isActive={isActive('/skaters')}
onClick={() => 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) => (
<NavLink
key={tab.id}
href={tab.href}
label={tab.labelKey ? t(tab.labelKey) : tab.label}
icon={tab.icon}
isActive={isActive(tab.href)}
onClick={() => setMobileMenuOpen(false)}
/>
))}
{user && (
<NavLink
href={`/${nip19.npubEncode(user.pubkey)}`}
label="View Profile"
icon={User}
isActive={false}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/messages"
label="Messages"
icon={MessageCircle}
isActive={isActive('/messages')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/contacts"
label="Contacts"
icon={Users}
isActive={isActive('/contacts')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/collections"
label="My Collections"
icon={Bookmark}
isActive={isActive('/collections')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/stash"
label="My Stash"
icon={Package}
isActive={isActive('/stash')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
<NavLink
href="/stats"
label={t('nav.stats')}
icon={BarChart3}
isActive={isActive('/stats')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/glossary"
label="Glossary"
icon={BookOpen}
isActive={isActive('/glossary')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/about"
label={t('nav.about')}
icon={Info}
isActive={isActive('/about')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/card"
label="Rider Card"
icon={CreditCard}
isActive={isActive('/card')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/supporters"
label="Supporters ⚡"
icon={Heart}
isActive={isActive('/supporters')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/newsletter"
label="Newsletter"
icon={Radio}
isActive={isActive('/newsletter')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/settings"
label={t('nav.settings')}
icon={Settings}
isActive={isActive('/settings')}
onClick={() => setMobileMenuOpen(false)}
/>
{/* Customize the bottom nav bar */}
<button
type="button"
onClick={() => {
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"
>
<SlidersHorizontal className="h-5 w-5" />
<span className="font-medium">Customize nav bar</span>
</button>
</div>
{/* Language Toggle in Mobile Menu */}
<div className="py-3 border-t">
<LanguageToggle className="w-full justify-start" variant="ghost" />
</div>
{/* Login/Logout Section */}
<div className="pt-4 border-t space-y-3">
{user ? (
<>
{/* User Profile Card */}
<div className="p-4 rounded-xl bg-gradient-to-br from-green-500/10 to-green-500/5 border border-green-500/20">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 border-2 border-green-500/50">
<AvatarImage src={author.data?.metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-sm font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-bold text-base truncate">{displayName}</p>
<p className="text-sm text-green-500 flex items-center gap-1.5">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Logged In
</p>
</div>
</div>
</div>
{/* Logout Button - Big and Clear */}
<Button
variant="outline"
size="lg"
className="w-full justify-center gap-3 text-red-400 border-red-500/50 hover:bg-red-500/20 hover:text-red-300 hover:border-red-500 h-12 text-base font-bold"
onClick={() => {
removeLogin(currentAccount?.id || '');
setMobileMenuOpen(false);
}}
>
<LogOut className="h-5 w-5" />
Log Out
</Button>
</>
) : (
<>
{/* Not Logged In - Clear CTA */}
<div className="p-4 rounded-xl bg-gradient-to-br from-[#ff0040]/10 to-[#ff0040]/5 border border-[#ff0040]/20 text-center">
<User className="h-10 w-10 mx-auto mb-2 text-[#ff0040]/50" />
<p className="font-bold text-base mb-1">Not Logged In</p>
<p className="text-sm text-muted-foreground mb-4">
Log in to add spots, zap skaters, and save your favorites
</p>
<LoginArea className="w-full justify-center" />
</div>
</>
)}
</div>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</header>
{/* Backup Reminder — lazy (on-demand banner) */}
<Suspense fallback={null}>
<LazyBackupReminder />
</Suspense>
{/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */}
{/* noPadding: full-bleed pages (map) manage their own height */}
<main id="main" className={isFullBleed ? undefined : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6"}>
{children ?? <Outlet />}
</main>
{/* Footer - Desktop */}
<footer className={cn(isFullBleed ? 'hidden' : 'hidden md:block', 'border-t border-gray-800 bg-black/95 py-6')}>
<div className="container px-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400">
<span className="flex items-center gap-2">
Made with <span className="text-red-500">❤️</span> for the skateboarding community
<span className="inline-flex items-center rounded-full border border-[#ff0040]/50 bg-[#ff0040]/10 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-[#ff0040]">
Beta
</span>
</span>
<div className="flex items-center gap-4 text-xs text-gray-500">
<Link
to="/newsletter"
className="hover:text-[#ccff00] transition-colors font-medium"
>
Newsletter
</Link>
<Link
to="/license"
className="hover:text-[#ccff00] transition-colors font-medium"
>
License
</Link>
<Link
to="/privacy"
className="hover:text-[#ccff00] transition-colors font-medium"
>
Privacy
</Link>
<Link
to="/supporters"
className="hover:text-[#ccff00] transition-colors font-medium"
>
Collaborators &amp; Friends
</Link>
<span className="flex items-center gap-1.5">
<span>Not sponsored by</span>
<span className="inline-flex items-center gap-1.5">
<img
src="https://opensats.org/favicon.ico"
alt="OpenSats"
className="h-4 w-4"
/>
<span className="font-medium text-gray-400">OpenSats</span>
</span>
</span>
</div>
</div>
</div>
</footer>
{/* No mobile footer - bottom nav provides navigation, keeps UI clean */}
{/* Mobile Bottom Navigation */}
<MobileNav
isActive={isActive}
isLoggedIn={!!user}
userPubkey={user?.pubkey}
userAvatar={userAvatar}
userName={displayName}
t={t}
onCustomize={() => setShowCustomizeNav(true)}
fixedMapTab={fixedMapTab}
middleTabs={middleTabs}
/>
{/* Bottom-nav customization sheet (mobile) — lazy (on-demand modal) */}
<Suspense fallback={null}>
<LazyCustomizeNavSheet open={showCustomizeNav} onOpenChange={setShowCustomizeNav} />
</Suspense>
{/* Sasquatch Guide (all screens) — lazy; not needed at first paint */}
<Suspense fallback={null}>
<LazySasquatchGuide
message={currentMessage}
onDismiss={dismissCurrentMessage}
/>
</Suspense>
{/* Set Status Dialog — lazy (on-demand modal) */}
<Suspense fallback={null}>
<LazySetStatusDialog
open={showStatusDialog}
onOpenChange={setShowStatusDialog}
/>
</Suspense>
{/* Add-another-account (multi-account switch on mobile) — lazy */}
<Suspense fallback={null}>
<LazyLoginDialog
isOpen={showAddAccount}
onClose={() => setShowAddAccount(false)}
onLogin={() => setShowAddAccount(false)}
/>
</Suspense>
{/* Newsletter: floating pill + Monday admin reminder */}
<NewsletterOverlay />
{/* Mirrors in-app activity to OS notifications — inert until opted in. */}
<OsNotificationBridge />
</div>
);
}