SkateSpots/src/components/Layout.tsx

1335 lines
57 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, 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 (
<div className="container mx-auto max-w-5xl px-4 py-6" aria-hidden="true">
<div style={{ animation: 'pageFallbackIn 180ms ease 120ms both' }}>
<div className="animate-pulse motion-reduce:animate-none space-y-5">
<div className="h-8 w-48 rounded-lg bg-white/[0.07]" />
<div className="h-4 w-3/4 rounded bg-white/[0.05]" />
<div className="grid gap-3 pt-3 sm:grid-cols-2">
<div className="h-28 rounded-xl bg-white/[0.05]" />
<div className="h-28 rounded-xl bg-white/[0.05]" />
<div className="h-28 rounded-xl bg-white/[0.05]" />
<div className="h-28 rounded-xl bg-white/[0.05]" />
</div>
</div>
</div>
</div>
);
}
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 },
{ 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 (
<Link
to={href}
onClick={onClick}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex min-w-0 items-center gap-3 px-4 py-3 rounded-xl text-left 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 shrink-0" />
<span className="min-w-0 flex-1 break-words 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 { 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 (
<DropdownMenu>
<div className="flex items-center gap-0.5">
<Link
to={`/${nip19.npubEncode(pubkey)}`}
aria-label={t('chromeUi.viewMyProfile')}
className="touch-manipulation relative flex items-center gap-1.5 rounded-l-full rounded-r-md border border-green-500/30 bg-green-500/10 px-1.5 py-1.5 transition-transform active:scale-95"
>
<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" />
{/* Green online indicator dot */}
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-black bg-green-500 xs:hidden" />
</Link>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('chromeUi.menu')}
className="flex h-9 w-5 items-center justify-center rounded-r-full text-green-400/80 transition-colors hover:text-green-300 active:scale-95"
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent align="end" className="w-56 p-2 [&_[role=menuitem]]:min-h-11">
{/* 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" />
{t('chromeUi.loggedIn')}
</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">{t('chromeUi.setStatus')}</span>
<span className="ml-auto text-xs text-green-500">🛹</span>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
<User className="h-4 w-4" />
{t('nav.viewProfile')}
</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>
{/* ── 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">
{t('chromeUi.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">{t('chromeUi.addAnotherAccount')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={onLogout}
className="flex items-center gap-2 cursor-pointer text-red-400"
>
<LogOut className="h-4 w-4" />
{t('chromeUi.logOut')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
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<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}
aria-current={active ? 'page' : undefined}
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: More — the burger menu owns profile, login, wallet,
settings and every secondary section. */}
<button
type="button"
onClick={onMore}
className="group relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-1 px-1 py-1.5 active:scale-90 transition-transform duration-150 text-gray-500"
aria-label={t('nav.more')}
>
<span className="relative flex h-9 w-12 items-center justify-center rounded-2xl bg-transparent transition-all duration-300">
<MoreHorizontal className="h-[22px] w-[22px] transition-transform duration-300 group-active:scale-95" />
</span>
<span className="block w-full truncate text-center text-[9px] xs:text-[10px] font-semibold uppercase leading-tight tracking-wide">
{t('nav.more')}
</span>
</button>
</div>
</div>
</nav>
);
}
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<HTMLElement>(null);
return (
<AuthDialogContext.Provider value={{ openLogin: requestLogin, openSignup: requestSignup }}>
<div className="min-h-screen bg-black text-white">
{/* Skip-to-content — visually hidden until focused. A real <button> (not
href="#main") because HashRouter treats the hash AS the route, so an
anchor would navigate away instead of focusing the content. */}
<button
type="button"
onClick={() => 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')}
</button>
{/* 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={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
className="flex items-center gap-3 group shrink-0"
title={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
>
<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-64 [&_[role=menuitem]]:min-h-10">
{/* 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" />
{t('nav.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" />
{t('nav.viewProfile')}
</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" />
{t('nav.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]">{t('nav.riderCard')}</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]">{t('nav.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]">{t('nav.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={t('chromeUi.myCollections')}
>
<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={t('chromeUi.setYourStatus')}
>
<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">{t('chromeUi.status')}</span>
</Button>
)}
<Button
variant="ghost"
size="icon"
className="text-gray-300 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
onClick={openGlobalSearch}
title={t('globalSearchUi.open')}
aria-label={t('globalSearchUi.open')}
>
<Search className="h-5 w-5" />
</Button>
{/* Notifications (crew invites & membership requests) */}
{user && <NotificationBellLazy />}
{/* 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={t('chromeUi.viewMyProfile')}
>
<User className="h-5 w-5" />
</Button>
</Link>
)}
<Link to="/settings">
<Button variant="ghost" size="icon" title={t('nav.settings')} aria-label={t('nav.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">
{showMobileBack && (
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-1 h-9 w-9 shrink-0 text-gray-300 hover:bg-white/5 hover:text-white"
aria-label={t('common.back')}
title={t('common.back')}
onClick={() => {
if (location.key !== 'default') navigate(-1);
else navigate('/');
}}
>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<Link
to={user ? `/${nip19.npubEncode(user.pubkey)}` : '/'}
className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0"
title={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
aria-label={user ? t('chromeUi.viewMyProfile') : t('nav.map')}
>
<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={cn('min-w-0 truncate font-display text-lg neon-red tracking-wide', showMobileBack && 'hidden')} style={{
textShadow: '0 0 8px #ff0040'
}}>SKATESPOTS</span>
</Link>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="icon"
className="text-gray-300 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
onClick={openGlobalSearch}
title={t('globalSearchUi.open')}
aria-label={t('globalSearchUi.open')}
>
<Search className="h-5 w-5" />
</Button>
{/* Notifications (crew invites & membership requests) */}
{user && <NotificationBellLazy />}
{/* 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={() => { clearTranslationMemory(); 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={(open) => {
setMobileMenuOpen(open);
if (!open) setMobileLanguageView(false);
}}
>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('chromeUi.menu')} 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">{t('chromeUi.menu')}</SheetTitle>
{mobileLanguageView ? (
<div className="flex flex-col gap-4 mt-6 pb-8">
<button
type="button"
onClick={() => setMobileLanguageView(false)}
className="flex items-center gap-2 self-start rounded-lg px-1 py-1 text-sm font-bold text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronLeft className="h-4 w-4" /> {t('common.back')}
</button>
<div>
<h2 className="text-xl font-black uppercase tracking-wide">
{t('settings.selectLanguage')}
</h2>
<LanguageToggle inline className="mt-3 w-full" variant="ghost" onSelect={() => setMobileLanguageView(false)} />
</div>
</div>
) : (
<div className="flex flex-col gap-6 mt-6 pb-8">
{/* Language picker lives in its own view — 28 languages inline
made this sheet ~3,200px tall and buried the control. */}
<button
type="button"
onClick={() => setMobileLanguageView(true)}
className="flex w-full items-center gap-3 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-left transition-colors hover:bg-white/10 active:scale-[0.99]"
>
<Languages className="h-5 w-5 shrink-0 text-teal-400" />
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-black uppercase tracking-widest text-muted-foreground">
{t('settings.selectLanguage')}
</span>
<span className="block truncate text-sm font-medium">
{currentLanguage.nativeName}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
{/* 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">{t('chromeUi.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={t('nav.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={t('nav.viewProfile')}
icon={User}
isActive={false}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/messages"
label={t('nav.messages')}
icon={MessageCircle}
isActive={isActive('/messages')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/contacts"
label={t('nav.contacts')}
icon={Users}
isActive={isActive('/contacts')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/collections"
label={t('chromeUi.myCollections')}
icon={Bookmark}
isActive={isActive('/collections')}
onClick={() => setMobileMenuOpen(false)}
/>
)}
{user && (
<NavLink
href="/stash"
label={t('chromeUi.myStash')}
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={t('nav.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={t('nav.riderCard')}
icon={CreditCard}
isActive={isActive('/card')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/supporters"
label={t('nav.supporters')}
icon={Heart}
isActive={isActive('/supporters')}
onClick={() => setMobileMenuOpen(false)}
/>
<NavLink
href="/newsletter"
label={t('nav.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">{t('chromeUi.customizeNavBar')}</span>
</button>
</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" />
{t('chromeUi.loggedIn')}
</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={() => {
clearTranslationMemory();
removeLogin(currentAccount?.id || '');
setMobileMenuOpen(false);
}}
>
<LogOut className="h-5 w-5" />
{t('chromeUi.logOut')}
</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">{t('chromeUi.notLoggedIn')}</p>
<p className="text-sm text-muted-foreground mb-4">
{t('chromeUi.loggedOutPrompt')}
</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"
ref={mainRef}
tabIndex={-1}
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
>
<Suspense fallback={<RouteLoadingFallback />}>
{children ?? <Outlet />}
</Suspense>
</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">
{t('chromeUi.madeWith')} <span className="text-red-500">❤️</span> {t('chromeUi.forSkateCommunity')}
<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]">
{t('chromeUi.beta')}
</span>
</span>
<div className="flex items-center gap-4 text-xs text-gray-500">
<Link
to="/diy"
className="hover:text-[#ccff00] transition-colors font-medium"
>
{t('nav.diy')}
</Link>
<Link
to="/newsletter"
className="hover:text-[#ccff00] transition-colors font-medium"
>
{t('nav.newsletter')}
</Link>
<Link
to="/license"
className="hover:text-[#ccff00] transition-colors font-medium"
>
{t('chromeUi.license')}
</Link>
<Link
to="/privacy"
className="hover:text-[#ccff00] transition-colors font-medium"
>
{t('chromeUi.privacy')}
</Link>
<Link
to="/supporters"
className="hover:text-[#ccff00] transition-colors font-medium"
>
{t('chromeUi.collaborators')}
</Link>
<span className="flex items-center gap-1.5">
<span>{t('chromeUi.notSponsoredBy')}</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}
onMore={() => setMobileMenuOpen(true)}
isLoggedIn={!!user}
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 */}
{!hideSasquatch && (
<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>
{/* Desktop, logged-out, first visit — one-time sign-in offer */}
<Suspense fallback={null}>
<LazyLoginDialog
isOpen={showWelcomeLogin}
onClose={dismissWelcomeLogin}
onLogin={dismissWelcomeLogin}
onSignup={() => {
dismissWelcomeLogin();
setShowWelcomeSignup(true);
}}
/>
</Suspense>
<Suspense fallback={null}>
<LazySignupDialog
isOpen={showWelcomeSignup}
onClose={() => setShowWelcomeSignup(false)}
onUseAmber={() => {
setShowWelcomeSignup(false);
setShowWelcomeLogin(true);
}}
/>
</Suspense>
{/* Header/burger auth — owned here so closing the sheet cannot unmount it
and it always sits above the sheet + welcome overlay. */}
<Suspense fallback={null}>
<LazyLoginDialog
isOpen={authDialog === 'login'}
onClose={() => setAuthDialog(null)}
onLogin={() => setAuthDialog(null)}
onSignup={() => setAuthDialog('signup')}
/>
</Suspense>
<Suspense fallback={null}>
<LazySignupDialog
isOpen={authDialog === 'signup'}
onClose={() => setAuthDialog(null)}
onUseAmber={() => setAuthDialog('login')}
/>
</Suspense>
{/* Newsletter: floating pill + Monday admin reminder */}
<GlobalSearch />
{/* Newsletter: floating pill + Monday admin reminder */}
<NewsletterOverlay />
{/* Mirrors in-app activity to OS notifications — inert until opted in. */}
<OsNotificationBridge />
</div>
</AuthDialogContext.Provider>
);
}