diff --git a/src/components/CustomizeNavSheet.tsx b/src/components/CustomizeNavSheet.tsx new file mode 100644 index 0000000..b91a6f8 --- /dev/null +++ b/src/components/CustomizeNavSheet.tsx @@ -0,0 +1,252 @@ +import { useEffect, useState } from 'react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { + useNavPreferences, + MIDDLE_SLOTS, + type NavTab, +} from '@/hooks/useNavPreferences'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useTranslation } from '@/contexts/LanguageContext'; +import { cn } from '@/lib/utils'; +import { MapPin, User, Lock, ArrowUp, ArrowDown, RotateCcw, Check, Sparkles } from 'lucide-react'; + +interface CustomizeNavSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Bottom-sheet editor for the mobile nav bar. + * + * Layout model: Map (fixed left) · 3 user-picked · Profile (fixed right). + * The user toggles up to 3 destinations from the catalog and can reorder them. + * Draft state is local until "Done", so changes feel deliberate and cancelable. + */ +export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps) { + const { middleIds, setMiddleIds, reset, catalog } = useNavPreferences(); + const { user } = useCurrentUser(); + const { t } = useTranslation(); + + // Local draft so we can cancel without persisting. + const [draft, setDraft] = useState(middleIds); + + // Re-seed the draft whenever the sheet opens. + useEffect(() => { + if (open) setDraft(middleIds); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const isLoggedIn = !!user; + const full = draft.length >= MIDDLE_SLOTS; + + const label = (tab: NavTab) => (tab.labelKey ? t(tab.labelKey) : tab.label); + + const toggle = (id: string) => { + setDraft((prev) => { + if (prev.includes(id)) return prev.filter((x) => x !== id); + if (prev.length >= MIDDLE_SLOTS) return prev; // full — ignore + return [...prev, id]; + }); + }; + + const move = (id: string, dir: -1 | 1) => { + setDraft((prev) => { + const idx = prev.indexOf(id); + const next = idx + dir; + if (idx < 0 || next < 0 || next >= prev.length) return prev; + const copy = [...prev]; + [copy[idx], copy[next]] = [copy[next], copy[idx]]; + return copy; + }); + }; + + const save = () => { + setMiddleIds(draft); + onOpenChange(false); + }; + + const handleReset = () => { + reset(); + // reset() writes defaults; reseed draft from the same default source. + setDraft(['lab', 'events', 'crews']); + }; + + // Resolve draft ids to tabs for the preview & reorder list. + const draftTabs = draft + .map((id) => catalog.find((c) => c.id === id)) + .filter((c): c is NavTab => !!c); + + return ( + + + + + Customize your bar + + + Pick the {MIDDLE_SLOTS} tabs you use most. Everything else stays in the menu. + + + + {/* Live preview */} +
+

Preview

+
+ {/* Fixed Map */} + + {/* Middle draft */} + {Array.from({ length: MIDDLE_SLOTS }).map((_, i) => { + const tab = draftTabs[i]; + if (!tab) { + return ( +
+ + + Empty +
+ ); + } + return ; + })} + {/* Fixed Profile */} + +
+
+ + {/* Reorder chosen tabs */} + {draftTabs.length > 0 && ( +
+

+ Order ({draft.length}/{MIDDLE_SLOTS}) +

+
+ {draftTabs.map((tab, i) => ( +
+ + {label(tab)} + + +
+ ))} +
+
+ )} + + {/* Catalog picker */} +
+
+

Available tabs

+ {full && ( + + Bar full — remove one to swap + + )} +
+
+ {catalog.map((tab) => { + const selected = draft.includes(tab.id); + const gatedOut = tab.authOnly && !isLoggedIn; + const disabled = gatedOut || (!selected && full); + return ( + + ); + })} +
+
+ + {/* Footer actions */} +
+ + +
+
+
+ ); +} + +function PreviewSlot({ + icon: Icon, + label, + locked, + accent, +}: { + icon: NavTab['icon']; + label: string; + locked?: boolean; + accent: string; +}) { + return ( +
+ {locked && ( + + + + )} + + {label} +
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 22ff379..4cb1955 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,8 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { cn } from '@/lib/utils'; +import { CustomizeNavSheet } from '@/components/CustomizeNavSheet'; +import { useNavPreferences, 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'; @@ -46,6 +48,7 @@ import { CreditCard, Radio, Users, + SlidersHorizontal, } from 'lucide-react'; import { OfflineIndicator } from '@/components/OfflineIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; @@ -233,6 +236,9 @@ function MobileNav({ userAvatar, userName, t, + onCustomize, + fixedMapTab, + middleTabs, }: { isActive: (path: string) => boolean; cashuBalance: number; @@ -241,16 +247,80 @@ function MobileNav({ userAvatar?: string; userName?: string; t: (key: string) => string; + onCustomize: () => void; + fixedMapTab: NavTab; + middleTabs: NavTab[]; }) { - // Decide which tabs to show — max 5 on small screens - // Logged in: Home, Map, Lab, Events, Profile (wallet in profile dropdown) - // Logged out: Home, Map, Lab, Events, Login - const tabs = [ - ...navItemKeys, - ...(isLoggedIn - ? [{ href: '/wallet', labelKey: 'nav.wallet', icon: Wallet }] - : []), - ]; + // 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 | 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 ( + { + 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 && ( + + )} + {isWallet ? ( +
+ + {cashuBalance > 0 && ( + + {formatSatsCompact(cashuBalance)} + + )} +
+ ) : ( + + )} + + {tab.labelKey ? t(tab.labelKey) : tab.label} + + + ); + }; return (