1172 lines
50 KiB
TypeScript
1172 lines
50 KiB
TypeScript
import { useState, useRef, lazy, Suspense } from 'react';
|
||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||
import { nip19 } from 'nostr-tools';
|
||
import { cn } from '@/lib/utils';
|
||
import { isNativePlatform } from '@/lib/platform';
|
||
import { useNavPreferences, useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
DropdownMenuSeparator,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import { LoginArea } from '@/components/auth/LoginArea';
|
||
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
|
||
import { LanguageToggle } from '@/components/LanguageToggle';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useTranslation } from '@/contexts/language';
|
||
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
|
||
|
||
import {
|
||
MapPin,
|
||
Calendar,
|
||
Menu,
|
||
Wallet,
|
||
Settings,
|
||
GraduationCap,
|
||
BarChart3,
|
||
MoreHorizontal,
|
||
Info,
|
||
Bookmark,
|
||
MessageCircle,
|
||
LogOut,
|
||
User,
|
||
Plus,
|
||
CheckCircle2,
|
||
Database,
|
||
Package,
|
||
Heart,
|
||
CreditCard,
|
||
Radio,
|
||
Users,
|
||
SlidersHorizontal,
|
||
BookOpen,
|
||
PersonStanding,
|
||
} from 'lucide-react';
|
||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||
import { useAuthor } from '@/hooks/useAuthor';
|
||
import { genUserName } from '@/lib/genUserName';
|
||
import { NewsletterOverlay } from '@/components/NewsletterOverlay';
|
||
import { NotificationBell } from '@/components/NotificationBell';
|
||
import { OsNotificationBridge } from '@/components/OsNotificationBridge';
|
||
|
||
// Cashu balance widget is lazy — see components/wallet/CashuBalance.tsx for why
|
||
// (keeps the Cashu stack out of the initial bundle).
|
||
const LazyCashuBalanceText = lazy(() =>
|
||
import('@/components/wallet/CashuBalance').then((m) => ({ default: m.CashuBalanceText })),
|
||
);
|
||
const LazyCashuBalanceBadge = lazy(() =>
|
||
import('@/components/wallet/CashuBalance').then((m) => ({ default: m.CashuBalanceBadge })),
|
||
);
|
||
const LazyCashuMenuBalance = lazy(() =>
|
||
import('@/components/wallet/CashuBalance').then((m) => ({ default: m.CashuMenuBalance })),
|
||
);
|
||
// The Sasquatch assistant is a floating overlay — not needed at first paint.
|
||
const LazySasquatchGuide = lazy(() =>
|
||
import('@/components/SasquatchGuide').then((m) => ({ default: m.SasquatchGuide })),
|
||
);
|
||
// On-demand modals — only rendered after the user opens them, so keep them out
|
||
// of the always-loaded Layout chunk.
|
||
const LazyCustomizeNavSheet = lazy(() =>
|
||
import('@/components/CustomizeNavSheet').then((m) => ({ default: m.CustomizeNavSheet })),
|
||
);
|
||
const LazySetStatusDialog = lazy(() =>
|
||
import('@/components/SetStatusDialog').then((m) => ({ default: m.SetStatusDialog })),
|
||
);
|
||
const LazyLoginDialog = lazy(() =>
|
||
import('@/components/auth/LoginDialog').then((m) => ({ default: m.LoginDialog })),
|
||
);
|
||
const LazyBackupReminder = lazy(() =>
|
||
import('@/components/BackupReminder').then((m) => ({ default: m.BackupReminder })),
|
||
);
|
||
|
||
/**
|
||
* Cheap synchronous hint that a Cashu wallet MIGHT exist, used to decide
|
||
* whether to even load the (large) wallet chunk. On native the seed lives in
|
||
* the OS KeyStore which we can't query synchronously, so we always load there;
|
||
* on web `useCashu` persists the mnemonic in localStorage under this key.
|
||
*/
|
||
function hasCashuWalletHint(): boolean {
|
||
if (isNativePlatform()) return true;
|
||
try {
|
||
return !!localStorage.getItem('cashu-mnemonic');
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
interface LayoutProps {
|
||
/** Page content. Optional: when rendered as a router layout (via <Outlet/>)
|
||
* there are no children. */
|
||
children?: React.ReactNode;
|
||
/** If true, removes the bottom padding that prevents bottom nav overlap.
|
||
* Use for full-bleed pages like the map that manage their own height.
|
||
* The map routes get this automatically (see `isFullBleed`). */
|
||
noPadding?: boolean;
|
||
}
|
||
|
||
// Nav item keys that map to translation keys
|
||
// HOME removed — the map IS the homepage now
|
||
const navItemKeys = [
|
||
{ href: '/', labelKey: 'nav.map', icon: MapPin },
|
||
{ href: '/lab', labelKey: 'nav.lab', icon: GraduationCap },
|
||
{ href: '/events', labelKey: 'nav.events', icon: Calendar },
|
||
{ href: '/crews', labelKey: 'nav.crews', icon: Users },
|
||
];
|
||
|
||
/**
|
||
* Hrefs the mobile burger menu already renders by hand. Anything else in the
|
||
* nav catalog (Marketplace, Mints, …) is appended from the catalog itself, so a
|
||
* section can never be missing on mobile just because it wasn't hard-coded.
|
||
*/
|
||
const MOBILE_MENU_LISTED_HREFS = [
|
||
'/', '/lab', '/events', '/crews', '/skaters', '/messages', '/contacts',
|
||
'/collections', '/stash', '/stats', '/glossary', '/about', '/card',
|
||
'/supporters', '/newsletter', '/settings', '/wallet',
|
||
];
|
||
|
||
function NavLink({
|
||
href,
|
||
label,
|
||
icon: Icon,
|
||
isActive,
|
||
onClick,
|
||
}: {
|
||
href: string;
|
||
label: string;
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
isActive: boolean;
|
||
onClick?: () => void;
|
||
}) {
|
||
return (
|
||
<Link
|
||
to={href}
|
||
onClick={onClick}
|
||
className={cn(
|
||
'flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200',
|
||
isActive
|
||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25'
|
||
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
||
)}
|
||
>
|
||
<Icon className="h-5 w-5" />
|
||
<span className="font-medium">{label}</span>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// Mobile header user menu - shows when logged in with dropdown for actions
|
||
function MobileHeaderUserMenu({
|
||
pubkey,
|
||
onLogout,
|
||
onSetStatus,
|
||
onAddAccount,
|
||
}: {
|
||
pubkey: string;
|
||
onLogout: () => void;
|
||
onSetStatus: () => void;
|
||
onAddAccount: () => void;
|
||
}) {
|
||
const author = useAuthor(pubkey);
|
||
const { 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>
|
||
<DropdownMenuTrigger asChild>
|
||
<button className="touch-manipulation relative flex items-center gap-2 px-2 py-1.5 rounded-full bg-green-500/10 border border-green-500/30 active:scale-95 transition-transform">
|
||
<Avatar className="h-7 w-7 border-2 border-green-500/50">
|
||
<AvatarImage src={avatar} alt={displayName} />
|
||
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
|
||
{displayName.slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
{/* Status indicator */}
|
||
<UserStatusIndicator pubkey={pubkey} size="sm" />
|
||
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
|
||
<CheckCircle2 className="h-3 w-3 inline mr-1" />
|
||
{t('chromeUi.inBadge')}
|
||
</span>
|
||
{/* Green online indicator dot */}
|
||
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-56 p-2">
|
||
{/* User info header */}
|
||
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
|
||
<div className="flex items-center gap-3">
|
||
<div className="relative">
|
||
<Avatar className="h-10 w-10 border-2 border-green-500/50">
|
||
<AvatarImage src={avatar} alt={displayName} />
|
||
<AvatarFallback className="bg-green-500/20 text-green-400 text-sm font-bold">
|
||
{displayName.slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<UserStatusIndicator pubkey={pubkey} size="sm" className="absolute -bottom-1 -right-1" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-bold text-sm truncate">{displayName}</p>
|
||
<p className="text-xs text-green-500 flex items-center gap-1">
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
{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="/create-spot" className="flex items-center gap-2 cursor-pointer">
|
||
<Plus className="h-4 w-4 text-[#ccff00]" />
|
||
<span className="text-[#ccff00] font-medium">{t('chromeUi.addSpot')}</span>
|
||
</Link>
|
||
</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>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/settings?section=mydata" className="flex items-center gap-2 cursor-pointer">
|
||
<Database className="h-4 w-4 text-[#ccff00]" />
|
||
<span className="text-[#ccff00]">{t('chromeUi.myData')}</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
|
||
<Package className="h-4 w-4 text-purple-400" />
|
||
<span className="text-purple-400">{t('chromeUi.myStash')}</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
|
||
<Wallet className="h-4 w-4 text-orange-400" />
|
||
<span className="text-orange-400">{t('nav.wallet')}</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/leaderboards" className="flex items-center gap-2 cursor-pointer">
|
||
<BarChart3 className="h-4 w-4 text-[#ccff00]" />
|
||
<span className="text-[#ccff00]">{t('nav.leaderboards')}</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/marketplace" className="flex items-center gap-2 cursor-pointer">
|
||
<Package className="h-4 w-4 text-orange-400" />
|
||
<span className="text-orange-400">{t('nav.marketplace')}</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
|
||
{/* ── Accounts: switch between logged-in npubs + add another ── */}
|
||
<DropdownMenuSeparator />
|
||
<div className="px-2 py-1 text-[10px] font-black uppercase tracking-widest text-gray-500">
|
||
{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,
|
||
userPubkey,
|
||
userAvatar,
|
||
userName,
|
||
t,
|
||
onCustomize,
|
||
fixedMapTab,
|
||
middleTabs,
|
||
}: {
|
||
isActive: (path: string) => boolean;
|
||
isLoggedIn: boolean;
|
||
userPubkey?: string;
|
||
userAvatar?: string;
|
||
userName?: string;
|
||
t: (key: string) => string;
|
||
onCustomize: () => void;
|
||
fixedMapTab: NavTab;
|
||
middleTabs: NavTab[];
|
||
}) {
|
||
// Bar model: FIXED Map · 3 user-picked middle tabs · FIXED Profile.
|
||
// (tabs are resolved in Layout and passed in to avoid any module-init/TDZ
|
||
// ordering issues with the hoisted function declaration.)
|
||
|
||
// Middle tabs the user pinned. If a login-gated tab is pinned while logged
|
||
// out, fall back to a sensible public tab so the slot is never dead.
|
||
const resolvedMiddle = (middleTabs ?? []).map((tab) => {
|
||
if (tab.authOnly && !isLoggedIn) {
|
||
return { ...tab, href: '/lab', label: 'The Lab', labelKey: 'nav.lab' };
|
||
}
|
||
return tab;
|
||
});
|
||
|
||
// Long-press (touch) / right-click opens the customization sheet.
|
||
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const startPress = () => {
|
||
pressTimer.current = setTimeout(() => {
|
||
onCustomize();
|
||
pressTimer.current = null;
|
||
}, 500);
|
||
};
|
||
const cancelPress = () => {
|
||
if (pressTimer.current) {
|
||
clearTimeout(pressTimer.current);
|
||
pressTimer.current = null;
|
||
}
|
||
};
|
||
|
||
const renderTab = (tab: NavTab, key: string) => {
|
||
const active = isActive(tab.href);
|
||
const isWallet = tab.id === 'wallet';
|
||
const accent = isWallet ? 'text-orange-400' : 'text-[#ff0040]';
|
||
const glow = isWallet
|
||
? 'bg-orange-500/15 shadow-[0_0_16px_rgba(249,115,22,0.35)]'
|
||
: 'bg-[#ff0040]/15 shadow-[0_0_16px_rgba(255,0,64,0.35)]';
|
||
return (
|
||
<Link
|
||
key={key}
|
||
to={tab.href}
|
||
onTouchStart={startPress}
|
||
onTouchEnd={cancelPress}
|
||
onTouchMove={cancelPress}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
onCustomize();
|
||
}}
|
||
className={cn(
|
||
'group relative flex flex-col items-center justify-center gap-1 py-1.5 px-1 flex-1 touch-manipulation active:scale-90 transition-transform duration-150',
|
||
active ? accent : 'text-gray-500',
|
||
)}
|
||
>
|
||
{/* Glowing pill behind the active icon */}
|
||
<span
|
||
className={cn(
|
||
'relative flex items-center justify-center h-9 w-12 rounded-2xl transition-all duration-300',
|
||
active ? glow : 'bg-transparent',
|
||
)}
|
||
>
|
||
{isWallet ? (
|
||
<>
|
||
<Wallet className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} />
|
||
{hasCashuWalletHint() && (
|
||
<Suspense fallback={null}>
|
||
<LazyCashuBalanceBadge />
|
||
</Suspense>
|
||
)}
|
||
</>
|
||
) : (
|
||
<tab.icon className={cn('h-[22px] w-[22px] transition-transform duration-300', active ? 'scale-110' : 'group-active:scale-95')} />
|
||
)}
|
||
</span>
|
||
<span className={cn(
|
||
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
|
||
active ? 'font-extrabold' : 'font-semibold',
|
||
)}>
|
||
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
||
</span>
|
||
</Link>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden">
|
||
{/* Top hairline glow — gives the bar a lifted, premium edge */}
|
||
<div className="h-px w-full bg-gradient-to-r from-transparent via-[#ff0040]/50 to-transparent" />
|
||
{/* Nav bar */}
|
||
<div
|
||
className="glass border-t border-white/10 bg-gradient-to-b from-black/70 to-black/95 backdrop-blur-xl"
|
||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)', paddingLeft: 'env(safe-area-inset-left, 0px)', paddingRight: 'env(safe-area-inset-right, 0px)' }}
|
||
>
|
||
<div className="flex items-stretch justify-around px-1.5 pt-1.5 pb-1">
|
||
{/* FIXED: Map / Shops & Spots */}
|
||
{renderTab(fixedMapTab, 'fixed-map')}
|
||
|
||
{/* CUSTOM: 3 middle tabs */}
|
||
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
|
||
|
||
{/* FIXED: Profile / Login tab.
|
||
Logged in → your public profile (View Profile).
|
||
Logged out → Settings (login lives there).
|
||
Long-press / right-click still opens nav customization. */}
|
||
{(() => {
|
||
const profileHref = isLoggedIn && userPubkey ? `/${nip19.npubEncode(userPubkey)}` : '/settings';
|
||
const tabActive = isActive(profileHref);
|
||
return (
|
||
<Link
|
||
to={profileHref}
|
||
onTouchStart={startPress}
|
||
onTouchEnd={cancelPress}
|
||
onTouchMove={cancelPress}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
onCustomize();
|
||
}}
|
||
className={cn(
|
||
'group relative flex flex-col items-center justify-center gap-1 py-1.5 px-1 flex-1 touch-manipulation active:scale-90 transition-transform duration-150',
|
||
tabActive ? 'text-[#ff0040]' : 'text-gray-500'
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
'relative flex items-center justify-center h-9 w-12 rounded-2xl transition-all duration-300',
|
||
tabActive ? 'bg-[#ff0040]/15 shadow-[0_0_16px_rgba(255,0,64,0.35)]' : 'bg-transparent',
|
||
)}
|
||
>
|
||
{isLoggedIn && userPubkey ? (
|
||
<div className="relative">
|
||
<Avatar className={cn(
|
||
'h-[24px] w-[24px] border-2 transition-transform duration-300',
|
||
tabActive ? 'border-[#ff0040] scale-110' : 'border-gray-600 group-active:scale-95'
|
||
)}>
|
||
<AvatarImage src={userAvatar} alt={userName} />
|
||
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-[8px] font-bold">
|
||
{(userName || '??').slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 bg-green-500 border border-black rounded-full" />
|
||
</div>
|
||
) : (
|
||
<User className={cn('h-[22px] w-[22px] transition-transform duration-300', tabActive ? 'scale-110' : 'group-active:scale-95')} />
|
||
)}
|
||
</span>
|
||
<span className={cn(
|
||
'block w-full truncate text-center text-[9px] xs:text-[10px] leading-tight tracking-wide uppercase transition-all',
|
||
tabActive ? 'font-extrabold' : 'font-semibold',
|
||
)}>
|
||
{isLoggedIn ? t('chromeUi.profile') : t('chromeUi.logIn')}
|
||
</span>
|
||
</Link>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
export function Layout({ children, noPadding }: LayoutProps) {
|
||
const location = useLocation();
|
||
// The map is full-bleed (it manages its own height), so it gets no bottom
|
||
// padding even though it shares this one persistent layout with every route.
|
||
const isFullBleed = noPadding || location.pathname === '/' || location.pathname === '/map';
|
||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||
const [showCustomizeNav, setShowCustomizeNav] = useState(false);
|
||
const [showAddAccount, setShowAddAccount] = useState(false);
|
||
const { currentMessage, dismissCurrentMessage } = useSasquatchGuide();
|
||
const { user } = useCurrentUser();
|
||
const { t } = useTranslation();
|
||
const { currentUser: currentAccount, removeLogin } = useLoggedInAccounts();
|
||
const author = useAuthor(user?.pubkey || '');
|
||
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
|
||
const userAvatar = author.data?.metadata?.picture;
|
||
const { fixedMapTab, middleTabs, catalog: navCatalog } = useNavPreferences();
|
||
const {
|
||
primaryTabs: desktopPrimaryTabs,
|
||
hiddenIds: desktopHiddenIds,
|
||
fixedMapTab: desktopMapTab,
|
||
catalog: desktopCatalog,
|
||
} = useDesktopNavPreferences();
|
||
|
||
const isActive = (path: string) => {
|
||
// Both / and /map are the "spots" page
|
||
if (path === '/') return location.pathname === '/' || location.pathname === '/map';
|
||
return location.pathname.startsWith(path);
|
||
};
|
||
|
||
const mainRef = useRef<HTMLElement>(null);
|
||
|
||
return (
|
||
<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="/" className="flex items-center gap-3 group shrink-0">
|
||
<img
|
||
src="/images/icon-1024.png"
|
||
alt="SkateSpots"
|
||
className="w-10 h-10 shrink-0 rounded-xl shadow-lg shadow-red-500/30 group-hover:shadow-red-500/50 transition-shadow"
|
||
/>
|
||
<span className="font-display text-2xl neon-red tracking-wide whitespace-nowrap" style={{
|
||
textShadow: '0 0 10px #ff0040, 0 0 20px #ff0040'
|
||
}}>SKATESPOTS</span>
|
||
</Link>
|
||
|
||
{/* `min-w-0` + overflow lets the nav shrink and scroll internally
|
||
instead of colliding with the logo when a user has several tabs. */}
|
||
<nav className="flex items-center gap-0.5 min-w-0 overflow-x-auto scrollbar-hide">
|
||
{/* Fixed Map tab first, then the user's chosen primary tabs */}
|
||
{[desktopMapTab, ...desktopPrimaryTabs].map((item) => (
|
||
<Link
|
||
key={item.id}
|
||
to={item.href}
|
||
className={cn(
|
||
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide whitespace-nowrap',
|
||
isActive(item.href)
|
||
? 'text-red-500'
|
||
: 'text-gray-400 hover:text-red-400'
|
||
)}
|
||
>
|
||
<item.icon className="h-4 w-4" />
|
||
{item.labelKey ? t(item.labelKey) : item.label}
|
||
</Link>
|
||
))}
|
||
|
||
{/* More Menu - Desktop */}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
className={cn(
|
||
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide h-auto',
|
||
(isActive('/stats') || isActive('/about'))
|
||
? 'text-red-500'
|
||
: 'text-gray-400 hover:text-red-400'
|
||
)}
|
||
>
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
{t('nav.more')}
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-48">
|
||
{/* Catalog items the user hasn't pinned to the top bar.
|
||
Auth-only items (stash/collections/contacts/wallet/messages)
|
||
only show when logged in. */}
|
||
{desktopHiddenIds
|
||
.map((id) => desktopCatalog.find((c) => c.id === id))
|
||
.filter((tab): tab is NavTab => !!tab)
|
||
.filter((tab) => !tab.authOnly || !!user)
|
||
.map((tab) => (
|
||
<DropdownMenuItem asChild key={tab.id}>
|
||
<Link to={tab.href} className="flex items-center gap-2 cursor-pointer">
|
||
<tab.icon className="h-4 w-4" />
|
||
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
))}
|
||
{/* Directory & account destinations that aren't part of the
|
||
customizable catalog — kept here so desktop/tablet reaches
|
||
parity with the mobile burger menu (nothing is ever lost). */}
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/skaters" className="flex items-center gap-2 cursor-pointer">
|
||
<PersonStanding className="h-4 w-4" />
|
||
{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>
|
||
)}
|
||
{/* Notifications (crew invites & membership requests) */}
|
||
{user && <NotificationBell />}
|
||
{/* Offline Indicator */}
|
||
<OfflineIndicator />
|
||
{/* Language Toggle */}
|
||
<LanguageToggle compact className="text-gray-400 hover:text-white" />
|
||
{/* View own public profile */}
|
||
{user && (
|
||
<Link to={`/${nip19.npubEncode(user.pubkey)}`}>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="text-gray-400 hover:text-[#ff0040] hover:bg-[#ff0040]/10"
|
||
title={t('chromeUi.viewMyProfile')}
|
||
>
|
||
<User className="h-5 w-5" />
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
<Link to="/settings">
|
||
<Button variant="ghost" size="icon" title={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">
|
||
<Link to="/" className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0">
|
||
<img
|
||
src="/images/icon-1024.png"
|
||
alt="SkateSpots"
|
||
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
|
||
/>
|
||
<span className="font-display text-xl neon-red tracking-wide" style={{
|
||
textShadow: '0 0 8px #ff0040'
|
||
}}>SKATESPOTS</span>
|
||
</Link>
|
||
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
{/* Notifications (crew invites & membership requests) */}
|
||
{user && <NotificationBell />}
|
||
{/* User menu when logged in / Login button when not.
|
||
IMPORTANT: LoginArea hosts the SignupDialog. The signup flow logs
|
||
the user in at the *first* step (to publish their profile), then
|
||
continues to the profile + recovery steps. If we unmount LoginArea
|
||
the instant `user` becomes truthy, the dialog is destroyed and the
|
||
skater is dumped on the map with no recovery step — the exact bug
|
||
reported. So we keep LoginArea mounted always and just hide its
|
||
trigger when logged in; MobileHeaderUserMenu becomes the visible
|
||
control. The dialog renders in a portal, so hiding the trigger is
|
||
purely cosmetic and never interrupts an in-progress signup. */}
|
||
{user && (
|
||
<MobileHeaderUserMenu
|
||
pubkey={user.pubkey}
|
||
onLogout={() => removeLogin(currentAccount?.id || '')}
|
||
onSetStatus={() => setShowStatusDialog(true)}
|
||
onAddAccount={() => setShowAddAccount(true)}
|
||
/>
|
||
)}
|
||
<div className={cn(user && 'hidden')}>
|
||
<LoginArea compact className="flex flex-row gap-1" />
|
||
</div>
|
||
|
||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button variant="ghost" size="icon" 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>
|
||
<div className="flex flex-col gap-6 mt-6 pb-8">
|
||
{/* Wallet Card - Mobile Menu */}
|
||
{user && (
|
||
<Link
|
||
to="/wallet"
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
className="block"
|
||
>
|
||
<div className={cn(
|
||
"p-4 rounded-xl border-2 transition-all",
|
||
isActive('/wallet')
|
||
? "bg-orange-500/20 border-orange-500/50"
|
||
: "bg-orange-500/10 border-orange-500/30 hover:bg-orange-500/20"
|
||
)}>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||
<Wallet className="h-5 w-5 text-orange-500" />
|
||
</div>
|
||
<div>
|
||
<p className="font-bold text-sm">{t('wallet.cashu.title')}</p>
|
||
<p className="text-xs text-muted-foreground">{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>
|
||
{/* Language Toggle in Mobile Menu */}
|
||
<div className="py-3 border-t">
|
||
<LanguageToggle className="w-full justify-start" variant="ghost" />
|
||
</div>
|
||
{/* Login/Logout Section */}
|
||
<div className="pt-4 border-t space-y-3">
|
||
{user ? (
|
||
<>
|
||
{/* User Profile Card */}
|
||
<div className="p-4 rounded-xl bg-gradient-to-br from-green-500/10 to-green-500/5 border border-green-500/20">
|
||
<div className="flex items-center gap-3">
|
||
<Avatar className="h-12 w-12 border-2 border-green-500/50">
|
||
<AvatarImage src={author.data?.metadata?.picture} alt={displayName} />
|
||
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-sm font-bold">
|
||
{displayName.slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-bold text-base truncate">{displayName}</p>
|
||
<p className="text-sm text-green-500 flex items-center gap-1.5">
|
||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||
{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={() => {
|
||
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"}
|
||
>
|
||
{children ?? <Outlet />}
|
||
</main>
|
||
|
||
{/* Footer - Desktop */}
|
||
<footer className={cn(isFullBleed ? 'hidden' : 'hidden md:block', 'border-t border-gray-800 bg-black/95 py-6')}>
|
||
<div className="container px-4">
|
||
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400">
|
||
<span className="flex items-center gap-2">
|
||
{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}
|
||
isLoggedIn={!!user}
|
||
userPubkey={user?.pubkey}
|
||
userAvatar={userAvatar}
|
||
userName={displayName}
|
||
t={t}
|
||
onCustomize={() => setShowCustomizeNav(true)}
|
||
fixedMapTab={fixedMapTab}
|
||
middleTabs={middleTabs}
|
||
/>
|
||
|
||
{/* Bottom-nav customization sheet (mobile) — lazy (on-demand modal) */}
|
||
<Suspense fallback={null}>
|
||
<LazyCustomizeNavSheet open={showCustomizeNav} onOpenChange={setShowCustomizeNav} />
|
||
</Suspense>
|
||
|
||
{/* Sasquatch Guide (all screens) — lazy; not needed at first paint */}
|
||
<Suspense fallback={null}>
|
||
<LazySasquatchGuide
|
||
message={currentMessage}
|
||
onDismiss={dismissCurrentMessage}
|
||
/>
|
||
</Suspense>
|
||
|
||
{/* Set Status Dialog — lazy (on-demand modal) */}
|
||
<Suspense fallback={null}>
|
||
<LazySetStatusDialog
|
||
open={showStatusDialog}
|
||
onOpenChange={setShowStatusDialog}
|
||
/>
|
||
</Suspense>
|
||
|
||
{/* Add-another-account (multi-account switch on mobile) — lazy */}
|
||
<Suspense fallback={null}>
|
||
<LazyLoginDialog
|
||
isOpen={showAddAccount}
|
||
onClose={() => setShowAddAccount(false)}
|
||
onLogin={() => setShowAddAccount(false)}
|
||
/>
|
||
</Suspense>
|
||
|
||
{/* Newsletter: floating pill + Monday admin reminder */}
|
||
<NewsletterOverlay />
|
||
|
||
{/* Mirrors in-app activity to OS notifications — inert until opted in. */}
|
||
<OsNotificationBridge />
|
||
</div>
|
||
);
|
||
}
|