1335 lines
57 KiB
TypeScript
1335 lines
57 KiB
TypeScript
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>
|
||
);
|
||
}
|