The desktop "More" dropdown was only populated from the customizable nav catalog, so directory/account destinations that live only in the mobile burger menu (notably Skaters) never appeared. Add Skaters, View Profile (logged in), and Settings to the desktop More menu so nothing is lost on larger screens. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
993 lines
40 KiB
TypeScript
993 lines
40 KiB
TypeScript
import { useState, useRef } from 'react';
|
||
import { Link, useLocation } from 'react-router-dom';
|
||
import { nip19 } from 'nostr-tools';
|
||
import { cn } from '@/lib/utils';
|
||
import { CustomizeNavSheet } from '@/components/CustomizeNavSheet';
|
||
import { useNavPreferences, useDesktopNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
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 { SasquatchGuide } from '@/components/SasquatchGuide';
|
||
import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
|
||
import { BackupReminder } from '@/components/BackupReminder';
|
||
import { LanguageToggle } from '@/components/LanguageToggle';
|
||
import { useCashu } from '@/hooks/wallet/useCashu';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useTranslation } from '@/contexts/LanguageContext';
|
||
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
|
||
|
||
import {
|
||
MapPin,
|
||
Calendar,
|
||
Menu,
|
||
Home,
|
||
Wallet,
|
||
Settings,
|
||
GraduationCap,
|
||
BarChart3,
|
||
MoreHorizontal,
|
||
Info,
|
||
Bookmark,
|
||
MessageCircle,
|
||
LogOut,
|
||
User,
|
||
LogIn,
|
||
Plus,
|
||
CheckCircle2,
|
||
Database,
|
||
Package,
|
||
Zap,
|
||
Heart,
|
||
CreditCard,
|
||
Radio,
|
||
Users,
|
||
SlidersHorizontal,
|
||
BookOpen,
|
||
PersonStanding,
|
||
} from 'lucide-react';
|
||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||
import { SetStatusDialog } from '@/components/SetStatusDialog';
|
||
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';
|
||
|
||
interface LayoutProps {
|
||
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. */
|
||
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 },
|
||
];
|
||
|
||
// Format sats for display (e.g., 1234 -> "1.2k", 12345 -> "12k")
|
||
function formatSatsCompact(sats: number): string {
|
||
if (sats >= 1000000) {
|
||
return `${(sats / 1000000).toFixed(1)}M`;
|
||
}
|
||
if (sats >= 1000) {
|
||
return `${(sats / 1000).toFixed(sats >= 10000 ? 0 : 1)}k`;
|
||
}
|
||
return sats.toString();
|
||
}
|
||
|
||
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,
|
||
}: {
|
||
pubkey: string;
|
||
onLogout: () => void;
|
||
onSetStatus: () => void;
|
||
}) {
|
||
const author = useAuthor(pubkey);
|
||
const metadata = author.data?.metadata;
|
||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||
const avatar = metadata?.picture;
|
||
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button className="touch-manipulation relative flex items-center gap-2 px-2 py-1.5 rounded-full bg-green-500/10 border border-green-500/30 active:scale-95 transition-transform">
|
||
<Avatar className="h-7 w-7 border-2 border-green-500/50">
|
||
<AvatarImage src={avatar} alt={displayName} />
|
||
<AvatarFallback className="bg-green-500/20 text-green-400 text-[10px] font-bold">
|
||
{displayName.slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
{/* Status indicator */}
|
||
<UserStatusIndicator pubkey={pubkey} size="sm" />
|
||
<span className="text-green-400 text-xs font-bold uppercase tracking-wide hidden xs:inline">
|
||
<CheckCircle2 className="h-3 w-3 inline mr-1" />
|
||
In
|
||
</span>
|
||
{/* Green online indicator dot */}
|
||
<span className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-green-500 border-2 border-black rounded-full xs:hidden" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-56 p-2">
|
||
{/* User info header */}
|
||
<div className="px-2 py-3 mb-2 rounded-lg bg-green-500/10 border border-green-500/20">
|
||
<div className="flex items-center gap-3">
|
||
<div className="relative">
|
||
<Avatar className="h-10 w-10 border-2 border-green-500/50">
|
||
<AvatarImage src={avatar} alt={displayName} />
|
||
<AvatarFallback className="bg-green-500/20 text-green-400 text-sm font-bold">
|
||
{displayName.slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<UserStatusIndicator pubkey={pubkey} size="sm" className="absolute -bottom-1 -right-1" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-bold text-sm truncate">{displayName}</p>
|
||
<p className="text-xs text-green-500 flex items-center gap-1">
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
Logged In
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Set Status - highlighted */}
|
||
<DropdownMenuItem
|
||
onClick={onSetStatus}
|
||
className="flex items-center gap-2 cursor-pointer mb-1 bg-green-500/10 border border-green-500/30 rounded-lg"
|
||
>
|
||
<span className="relative flex h-2.5 w-2.5">
|
||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||
</span>
|
||
<span className="text-green-400 font-bold">Set Status</span>
|
||
<span className="ml-auto text-xs text-green-500">🛹</span>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/create-spot" className="flex items-center gap-2 cursor-pointer">
|
||
<Plus className="h-4 w-4 text-[#ccff00]" />
|
||
<span className="text-[#ccff00] font-medium">Add Spot</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to={`/${nip19.npubEncode(pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
||
<User className="h-4 w-4" />
|
||
View Profile
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/settings" className="flex items-center gap-2 cursor-pointer">
|
||
<Settings className="h-4 w-4" />
|
||
Settings
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/settings?section=mydata" className="flex items-center gap-2 cursor-pointer">
|
||
<Database className="h-4 w-4 text-[#ccff00]" />
|
||
<span className="text-[#ccff00]">My Data</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/stash" className="flex items-center gap-2 cursor-pointer">
|
||
<Package className="h-4 w-4 text-purple-400" />
|
||
<span className="text-purple-400">My Stash</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/wallet" className="flex items-center gap-2 cursor-pointer">
|
||
<Wallet className="h-4 w-4 text-orange-400" />
|
||
<span className="text-orange-400">Wallet</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
onClick={onLogout}
|
||
className="flex items-center gap-2 cursor-pointer text-red-400"
|
||
>
|
||
<LogOut className="h-4 w-4" />
|
||
Log Out
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
}
|
||
|
||
function MobileNav({
|
||
isActive,
|
||
cashuBalance,
|
||
isLoggedIn,
|
||
userPubkey,
|
||
userAvatar,
|
||
userName,
|
||
t,
|
||
onCustomize,
|
||
fixedMapTab,
|
||
middleTabs,
|
||
}: {
|
||
isActive: (path: string) => boolean;
|
||
cashuBalance: number;
|
||
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 dot = isWallet ? 'bg-orange-400' : 'bg-[#ff0040]';
|
||
return (
|
||
<Link
|
||
key={key}
|
||
to={tab.href}
|
||
onTouchStart={startPress}
|
||
onTouchEnd={cancelPress}
|
||
onTouchMove={cancelPress}
|
||
onContextMenu={(e) => {
|
||
e.preventDefault();
|
||
onCustomize();
|
||
}}
|
||
className={cn(
|
||
'relative flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 rounded-xl transition-all duration-150 flex-1 touch-manipulation active:scale-90',
|
||
active ? accent : 'text-gray-500',
|
||
)}
|
||
>
|
||
{active && (
|
||
<span className={cn('absolute top-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full', dot)} />
|
||
)}
|
||
{isWallet ? (
|
||
<div className="relative">
|
||
<Wallet className={cn('h-[22px] w-[22px] transition-transform', active && 'scale-110')} />
|
||
{cashuBalance > 0 && (
|
||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-4 px-1 text-[8px] font-bold bg-orange-500 text-white rounded-full flex items-center justify-center leading-none">
|
||
{formatSatsCompact(cashuBalance)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<tab.icon className={cn('h-[22px] w-[22px] transition-transform', active && 'scale-110')} />
|
||
)}
|
||
<span className="text-[9px] xs:text-[10px] font-semibold leading-tight tracking-wide uppercase">
|
||
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
||
</span>
|
||
</Link>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden">
|
||
{/* Nav bar */}
|
||
<div
|
||
className="glass border-t border-white/10"
|
||
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 pt-1 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(
|
||
'relative flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 rounded-xl transition-all duration-150 flex-1 touch-manipulation active:scale-90',
|
||
tabActive ? 'text-[#ff0040]' : 'text-gray-500'
|
||
)}
|
||
>
|
||
{tabActive && (
|
||
<span className="absolute top-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-[#ff0040]" />
|
||
)}
|
||
{isLoggedIn && userPubkey ? (
|
||
<div className="relative">
|
||
<Avatar className={cn(
|
||
'h-[22px] w-[22px] border-2 transition-transform',
|
||
tabActive ? 'border-[#ff0040] scale-110' : 'border-gray-600'
|
||
)}>
|
||
<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="h-[22px] w-[22px]" />
|
||
)}
|
||
<span className="text-[9px] xs:text-[10px] font-semibold leading-tight tracking-wide uppercase">
|
||
{isLoggedIn ? 'Profile' : 'Log In'}
|
||
</span>
|
||
</Link>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
export function Layout({ children, noPadding }: LayoutProps) {
|
||
const location = useLocation();
|
||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||
const [showCustomizeNav, setShowCustomizeNav] = useState(false);
|
||
const { currentMessage, dismissCurrentMessage } = useSasquatchGuide();
|
||
const { user } = useCurrentUser();
|
||
const { totalBalance: cashuBalance, isInitialized: hasCashu } = useCashu();
|
||
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 } = useNavPreferences();
|
||
const {
|
||
primaryTabs: desktopPrimaryTabs,
|
||
hiddenIds: desktopHiddenIds,
|
||
fixedMapTab: desktopMapTab,
|
||
catalog: desktopCatalog,
|
||
} = useDesktopNavPreferences();
|
||
|
||
const isActive = (path: string) => {
|
||
// Both / and /map are the "spots" page
|
||
if (path === '/') return location.pathname === '/' || location.pathname === '/map';
|
||
return location.pathname.startsWith(path);
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen bg-black text-white">
|
||
{/* 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="container flex items-center justify-between h-16 px-4">
|
||
<Link to="/" className="flex items-center gap-3 group">
|
||
<img
|
||
src="/images/icon-1024.png"
|
||
alt="SkateSpots"
|
||
className="w-10 h-10 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" style={{
|
||
textShadow: '0 0 10px #ff0040, 0 0 20px #ff0040'
|
||
}}>SKATESPOTS</span>
|
||
</Link>
|
||
|
||
<nav className="flex items-center gap-1">
|
||
{/* 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-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide',
|
||
isActive(item.href)
|
||
? 'text-red-500'
|
||
: 'text-gray-400 hover:text-red-400'
|
||
)}
|
||
>
|
||
<item.icon className="h-4 w-4" />
|
||
{item.labelKey ? t(item.labelKey) : item.label}
|
||
</Link>
|
||
))}
|
||
|
||
{/* More Menu - Desktop */}
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="ghost"
|
||
className={cn(
|
||
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide h-auto',
|
||
(isActive('/stats') || isActive('/about'))
|
||
? 'text-red-500'
|
||
: 'text-gray-400 hover:text-red-400'
|
||
)}
|
||
>
|
||
<MoreHorizontal className="h-4 w-4" />
|
||
{t('nav.more')}
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-48">
|
||
{/* Catalog items the user hasn't pinned to the top bar.
|
||
Auth-only items (stash/collections/contacts/wallet/messages)
|
||
only show when logged in. */}
|
||
{desktopHiddenIds
|
||
.map((id) => desktopCatalog.find((c) => c.id === id))
|
||
.filter((tab): tab is NavTab => !!tab)
|
||
.filter((tab) => !tab.authOnly || !!user)
|
||
.map((tab) => (
|
||
<DropdownMenuItem asChild key={tab.id}>
|
||
<Link to={tab.href} className="flex items-center gap-2 cursor-pointer">
|
||
<tab.icon className="h-4 w-4" />
|
||
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
))}
|
||
{/* Directory & account destinations that aren't part of the
|
||
customizable catalog — kept here so desktop/tablet reaches
|
||
parity with the mobile burger menu (nothing is ever lost). */}
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/skaters" className="flex items-center gap-2 cursor-pointer">
|
||
<PersonStanding className="h-4 w-4" />
|
||
Skaters
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
{user && (
|
||
<DropdownMenuItem asChild>
|
||
<Link to={`/${nip19.npubEncode(user.pubkey)}`} className="flex items-center gap-2 cursor-pointer">
|
||
<User className="h-4 w-4" />
|
||
View Profile
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/settings" className="flex items-center gap-2 cursor-pointer">
|
||
<Settings className="h-4 w-4" />
|
||
{t('nav.settings')}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/glossary" className="flex items-center gap-2 cursor-pointer">
|
||
<BookOpen className="h-4 w-4" />
|
||
Glossary
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/about" className="flex items-center gap-2 cursor-pointer">
|
||
<Info className="h-4 w-4" />
|
||
{t('nav.about')}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/card" className="flex items-center gap-2 cursor-pointer">
|
||
<CreditCard className="h-4 w-4 text-[#ccff00]" />
|
||
<span className="text-[#ccff00]">Rider Card</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/supporters" className="flex items-center gap-2 cursor-pointer">
|
||
<Heart className="h-4 w-4 text-[#ff0040]" />
|
||
<span className="text-[#ff0040]">Supporters ⚡</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem asChild>
|
||
<Link to="/newsletter" className="flex items-center gap-2 cursor-pointer">
|
||
<Radio className="h-4 w-4 text-[#ccff00]" />
|
||
<span className="text-[#ccff00]">Newsletter</span>
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</nav>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{/* Messages lives in the "More" menu — no standalone desktop icon. */}
|
||
{/* Collections Quick Access - Desktop */}
|
||
{user && (
|
||
<Link to="/collections">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className={cn(
|
||
isActive('/collections')
|
||
? "text-[#ccff00] bg-[#ccff00]/10"
|
||
: "text-gray-400 hover:text-[#ccff00] hover:bg-[#ccff00]/10"
|
||
)}
|
||
title="My Collections"
|
||
>
|
||
<Bookmark className="h-5 w-5" />
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
{/* Wallet Quick Access - Desktop */}
|
||
{user && (
|
||
<Link to="/wallet">
|
||
<Button
|
||
variant="ghost"
|
||
className={cn(
|
||
"gap-2 px-3",
|
||
isActive('/wallet')
|
||
? "text-orange-500 bg-orange-500/10"
|
||
: "text-gray-400 hover:text-orange-400 hover:bg-orange-500/10"
|
||
)}
|
||
>
|
||
<Wallet className="h-5 w-5" />
|
||
{hasCashu && cashuBalance > 0 && (
|
||
<span className="text-sm font-bold">
|
||
{formatSatsCompact(cashuBalance)}
|
||
</span>
|
||
)}
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
{/* Status Quick Access - Desktop */}
|
||
{user && (
|
||
<Button
|
||
variant="outline"
|
||
className="gap-2 px-3 border-green-500/50 text-green-400 hover:text-green-300 hover:bg-green-500/10 hover:border-green-500"
|
||
onClick={() => setShowStatusDialog(true)}
|
||
title="Set Your Status"
|
||
>
|
||
<span className="relative flex h-2.5 w-2.5">
|
||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
|
||
</span>
|
||
<span className="text-sm font-bold">Status</span>
|
||
</Button>
|
||
)}
|
||
{/* Notifications (crew invites & membership requests) */}
|
||
{user && <NotificationBell />}
|
||
{/* Offline Indicator */}
|
||
<OfflineIndicator />
|
||
{/* Language Toggle */}
|
||
<LanguageToggle compact className="text-gray-400 hover:text-white" />
|
||
{/* View own public profile */}
|
||
{user && (
|
||
<Link to={`/${nip19.npubEncode(user.pubkey)}`}>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="text-gray-400 hover:text-[#ff0040] hover:bg-[#ff0040]/10"
|
||
title="View my profile"
|
||
>
|
||
<User className="h-5 w-5" />
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
<Link to="/settings">
|
||
<Button variant="ghost" size="icon" title="Settings">
|
||
<Settings className="h-5 w-5" />
|
||
</Button>
|
||
</Link>
|
||
<LoginArea className="max-w-60" />
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Mobile Header */}
|
||
<header className="md:hidden sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur safe-area-top">
|
||
<div className="flex items-center justify-between h-14 px-3 gap-3">
|
||
<Link to="/" className="flex items-center gap-2 touch-manipulation active:opacity-80 flex-1 min-w-0">
|
||
<img
|
||
src="/images/icon-1024.png"
|
||
alt="SkateSpots"
|
||
className="w-9 h-9 rounded-lg flex-shrink-0 shadow-lg shadow-red-500/30"
|
||
/>
|
||
<span className="font-display text-xl neon-red tracking-wide" style={{
|
||
textShadow: '0 0 8px #ff0040'
|
||
}}>SKATESPOTS</span>
|
||
</Link>
|
||
|
||
<div className="flex items-center gap-1 flex-shrink-0">
|
||
{/* Notifications (crew invites & membership requests) */}
|
||
{user && <NotificationBell />}
|
||
{/* User menu when logged in / Login button when not */}
|
||
{user ? (
|
||
<MobileHeaderUserMenu
|
||
pubkey={user.pubkey}
|
||
onLogout={() => removeLogin(currentAccount?.id || '')}
|
||
onSetStatus={() => setShowStatusDialog(true)}
|
||
/>
|
||
) : (
|
||
<LoginArea compact className="flex flex-row gap-1" />
|
||
)}
|
||
|
||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="h-10 w-10 touch-manipulation active:scale-95 transition-transform">
|
||
<Menu className="h-5 w-5" />
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side="right" className="w-80 overflow-y-auto">
|
||
<SheetTitle className="sr-only">Menu</SheetTitle>
|
||
<div className="flex flex-col gap-6 mt-6 pb-8">
|
||
{/* Wallet Card - Mobile Menu */}
|
||
{user && (
|
||
<Link
|
||
to="/wallet"
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
className="block"
|
||
>
|
||
<div className={cn(
|
||
"p-4 rounded-xl border-2 transition-all",
|
||
isActive('/wallet')
|
||
? "bg-orange-500/20 border-orange-500/50"
|
||
: "bg-orange-500/10 border-orange-500/30 hover:bg-orange-500/20"
|
||
)}>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||
<Wallet className="h-5 w-5 text-orange-500" />
|
||
</div>
|
||
<div>
|
||
<p className="font-bold text-sm">{t('wallet.cashu.title')}</p>
|
||
<p className="text-xs text-muted-foreground">Ecash</p>
|
||
</div>
|
||
</div>
|
||
{hasCashu && (
|
||
<div className="text-right">
|
||
<p className="font-bold text-orange-500">
|
||
{cashuBalance.toLocaleString()}
|
||
</p>
|
||
<p className="text-xs text-muted-foreground">sats</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-2">
|
||
{navItemKeys.map((item) => (
|
||
<NavLink
|
||
key={item.href}
|
||
href={item.href}
|
||
label={t(item.labelKey)}
|
||
icon={item.icon}
|
||
isActive={isActive(item.href)}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
))}
|
||
<NavLink
|
||
href="/skaters"
|
||
label="Skaters"
|
||
icon={PersonStanding}
|
||
isActive={isActive('/skaters')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
{user && (
|
||
<NavLink
|
||
href={`/${nip19.npubEncode(user.pubkey)}`}
|
||
label="View Profile"
|
||
icon={User}
|
||
isActive={false}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
)}
|
||
{user && (
|
||
<NavLink
|
||
href="/messages"
|
||
label="Messages"
|
||
icon={MessageCircle}
|
||
isActive={isActive('/messages')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
)}
|
||
{user && (
|
||
<NavLink
|
||
href="/contacts"
|
||
label="Contacts"
|
||
icon={Users}
|
||
isActive={isActive('/contacts')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
)}
|
||
{user && (
|
||
<NavLink
|
||
href="/collections"
|
||
label="My Collections"
|
||
icon={Bookmark}
|
||
isActive={isActive('/collections')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
)}
|
||
{user && (
|
||
<NavLink
|
||
href="/stash"
|
||
label="My Stash"
|
||
icon={Package}
|
||
isActive={isActive('/stash')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
)}
|
||
<NavLink
|
||
href="/stats"
|
||
label={t('nav.stats')}
|
||
icon={BarChart3}
|
||
isActive={isActive('/stats')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
<NavLink
|
||
href="/glossary"
|
||
label="Glossary"
|
||
icon={BookOpen}
|
||
isActive={isActive('/glossary')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
<NavLink
|
||
href="/about"
|
||
label={t('nav.about')}
|
||
icon={Info}
|
||
isActive={isActive('/about')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
<NavLink
|
||
href="/card"
|
||
label="Rider Card"
|
||
icon={CreditCard}
|
||
isActive={isActive('/card')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
<NavLink
|
||
href="/supporters"
|
||
label="Supporters ⚡"
|
||
icon={Heart}
|
||
isActive={isActive('/supporters')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
<NavLink
|
||
href="/newsletter"
|
||
label="Newsletter"
|
||
icon={Radio}
|
||
isActive={isActive('/newsletter')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
<NavLink
|
||
href="/settings"
|
||
label={t('nav.settings')}
|
||
icon={Settings}
|
||
isActive={isActive('/settings')}
|
||
onClick={() => setMobileMenuOpen(false)}
|
||
/>
|
||
{/* Customize the bottom nav bar */}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMobileMenuOpen(false);
|
||
setShowCustomizeNav(true);
|
||
}}
|
||
className="flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 text-muted-foreground hover:bg-muted hover:text-foreground text-left"
|
||
>
|
||
<SlidersHorizontal className="h-5 w-5" />
|
||
<span className="font-medium">Customize nav bar</span>
|
||
</button>
|
||
</div>
|
||
{/* Language Toggle in Mobile Menu */}
|
||
<div className="py-3 border-t">
|
||
<LanguageToggle className="w-full justify-start" variant="ghost" />
|
||
</div>
|
||
{/* Login/Logout Section */}
|
||
<div className="pt-4 border-t space-y-3">
|
||
{user ? (
|
||
<>
|
||
{/* User Profile Card */}
|
||
<div className="p-4 rounded-xl bg-gradient-to-br from-green-500/10 to-green-500/5 border border-green-500/20">
|
||
<div className="flex items-center gap-3">
|
||
<Avatar className="h-12 w-12 border-2 border-green-500/50">
|
||
<AvatarImage src={author.data?.metadata?.picture} alt={displayName} />
|
||
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-sm font-bold">
|
||
{displayName.slice(0, 2).toUpperCase()}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="font-bold text-base truncate">{displayName}</p>
|
||
<p className="text-sm text-green-500 flex items-center gap-1.5">
|
||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||
Logged In
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Logout Button - Big and Clear */}
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
className="w-full justify-center gap-3 text-red-400 border-red-500/50 hover:bg-red-500/20 hover:text-red-300 hover:border-red-500 h-12 text-base font-bold"
|
||
onClick={() => {
|
||
removeLogin(currentAccount?.id || '');
|
||
setMobileMenuOpen(false);
|
||
}}
|
||
>
|
||
<LogOut className="h-5 w-5" />
|
||
Log Out
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* Not Logged In - Clear CTA */}
|
||
<div className="p-4 rounded-xl bg-gradient-to-br from-[#ff0040]/10 to-[#ff0040]/5 border border-[#ff0040]/20 text-center">
|
||
<User className="h-10 w-10 mx-auto mb-2 text-[#ff0040]/50" />
|
||
<p className="font-bold text-base mb-1">Not Logged In</p>
|
||
<p className="text-sm text-muted-foreground mb-4">
|
||
Log in to add spots, zap skaters, and save your favorites
|
||
</p>
|
||
<LoginArea className="w-full justify-center" />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Backup Reminder */}
|
||
<BackupReminder />
|
||
|
||
{/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */}
|
||
{/* noPadding: full-bleed pages (map) manage their own height */}
|
||
<main className={noPadding ? undefined : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6"}>
|
||
{children}
|
||
</main>
|
||
|
||
{/* Footer - Desktop */}
|
||
<footer className="hidden md:block border-t border-gray-800 bg-black/95 py-6">
|
||
<div className="container px-4">
|
||
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400">
|
||
<span className="flex items-center gap-2">
|
||
Made with <span className="text-red-500">❤️</span> for the skateboarding community
|
||
<span className="inline-flex items-center rounded-full border border-[#ff0040]/50 bg-[#ff0040]/10 px-2 py-0.5 text-[10px] font-black uppercase tracking-wider text-[#ff0040]">
|
||
Beta
|
||
</span>
|
||
</span>
|
||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||
<Link
|
||
to="/newsletter"
|
||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||
>
|
||
Newsletter
|
||
</Link>
|
||
<Link
|
||
to="/license"
|
||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||
>
|
||
License
|
||
</Link>
|
||
<Link
|
||
to="/privacy"
|
||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||
>
|
||
Privacy
|
||
</Link>
|
||
<Link
|
||
to="/supporters"
|
||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||
>
|
||
Collaborators & Friends
|
||
</Link>
|
||
<span className="flex items-center gap-1.5">
|
||
<span>Not sponsored by</span>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<img
|
||
src="https://opensats.org/favicon.ico"
|
||
alt="OpenSats"
|
||
className="h-4 w-4"
|
||
/>
|
||
<span className="font-medium text-gray-400">OpenSats</span>
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
{/* No mobile footer - bottom nav provides navigation, keeps UI clean */}
|
||
|
||
{/* Mobile Bottom Navigation */}
|
||
<MobileNav
|
||
isActive={isActive}
|
||
cashuBalance={cashuBalance}
|
||
isLoggedIn={!!user}
|
||
userPubkey={user?.pubkey}
|
||
userAvatar={userAvatar}
|
||
userName={displayName}
|
||
t={t}
|
||
onCustomize={() => setShowCustomizeNav(true)}
|
||
fixedMapTab={fixedMapTab}
|
||
middleTabs={middleTabs}
|
||
/>
|
||
|
||
{/* Bottom-nav customization sheet (mobile) */}
|
||
<CustomizeNavSheet open={showCustomizeNav} onOpenChange={setShowCustomizeNav} />
|
||
|
||
{/* Sasquatch Guide (all screens) */}
|
||
<SasquatchGuide
|
||
message={currentMessage}
|
||
onDismiss={dismissCurrentMessage}
|
||
/>
|
||
|
||
{/* Set Status Dialog */}
|
||
<SetStatusDialog
|
||
open={showStatusDialog}
|
||
onOpenChange={setShowStatusDialog}
|
||
/>
|
||
|
||
{/* Newsletter: floating pill + Monday admin reminder */}
|
||
<NewsletterOverlay />
|
||
</div>
|
||
);
|
||
}
|