Customizable mobile bottom nav: fixed Map + 3 user-picked tabs + fixed Profile
- Add useNavPreferences hook (localStorage) with a curated catalog of pinnable
destinations (Lab, Events, Crews, Wallet, Stash, Collections, Contacts,
Messages, Stats), defaults, normalization/validation and login-gating
- Map (Shops & Spots) is fixed on the left; Profile/Login fixed on the right;
the 3 middle slots are user-customizable — nothing is ever lost since the
burger menu still lists everything
- CustomizeNavSheet: bottom-sheet editor with a live preview bar (locked slots
shown with 🔒), a toggle grid with a 3/3 counter + "bar full" state, up/down
reordering, and reset-to-default. Draft state so changes are cancelable
- Entry points: long-press (or right-click) any bottom tab, plus a "Customize
nav bar" item in the burger menu
- Login-gated tabs are hidden from logged-out users and gracefully fall back if
a saved layout references them
- Resolve tabs in Layout and pass to MobileNav as props (avoids any module-init
ordering issues with the hoisted MobileNav declaration); defensive ?? [] guard
Docs unchanged (client-only preference, no new Nostr kinds).
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
6848a72869
commit
d7af2626ff
3 changed files with 497 additions and 50 deletions
252
src/components/CustomizeNavSheet.tsx
Normal file
252
src/components/CustomizeNavSheet.tsx
Normal file
|
|
@ -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<string[]>(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 (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
className="max-h-[90vh] overflow-y-auto rounded-t-3xl border-t border-white/10 bg-black/95 backdrop-blur"
|
||||||
|
>
|
||||||
|
<SheetHeader className="text-left">
|
||||||
|
<SheetTitle className="flex items-center gap-2 text-xl font-black uppercase text-[#ff0040]">
|
||||||
|
<Sparkles className="h-5 w-5" /> Customize your bar
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="text-gray-400">
|
||||||
|
Pick the {MIDDLE_SLOTS} tabs you use most. Everything else stays in the menu.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{/* Live preview */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">Preview</p>
|
||||||
|
<div className="glass flex items-stretch justify-around gap-1 rounded-2xl border border-white/10 p-2">
|
||||||
|
{/* Fixed Map */}
|
||||||
|
<PreviewSlot icon={MapPin} label={t('nav.map')} locked accent="#ff0040" />
|
||||||
|
{/* Middle draft */}
|
||||||
|
{Array.from({ length: MIDDLE_SLOTS }).map((_, i) => {
|
||||||
|
const tab = draftTabs[i];
|
||||||
|
if (!tab) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${i}`}
|
||||||
|
className="flex flex-1 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-white/10 py-1.5 text-gray-700"
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none">+</span>
|
||||||
|
<span className="text-[9px] font-semibold uppercase">Empty</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
|
||||||
|
})}
|
||||||
|
{/* Fixed Profile */}
|
||||||
|
<PreviewSlot icon={User} label={isLoggedIn ? 'Profile' : 'Log In'} locked accent="#ff0040" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reorder chosen tabs */}
|
||||||
|
{draftTabs.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
||||||
|
Order ({draft.length}/{MIDDLE_SLOTS})
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{draftTabs.map((tab, i) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-[#ff0040]/30 bg-[#ff0040]/[0.06] p-2.5"
|
||||||
|
>
|
||||||
|
<tab.icon className="h-4 w-4 text-[#ff0040]" />
|
||||||
|
<span className="flex-1 text-sm font-bold text-white">{label(tab)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => move(tab.id, -1)}
|
||||||
|
disabled={i === 0}
|
||||||
|
aria-label={`Move ${label(tab)} up`}
|
||||||
|
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => move(tab.id, 1)}
|
||||||
|
disabled={i === draftTabs.length - 1}
|
||||||
|
aria-label={`Move ${label(tab)} down`}
|
||||||
|
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Catalog picker */}
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Available tabs</p>
|
||||||
|
{full && (
|
||||||
|
<span className="text-[10px] font-bold text-amber-400">
|
||||||
|
Bar full — remove one to swap
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{catalog.map((tab) => {
|
||||||
|
const selected = draft.includes(tab.id);
|
||||||
|
const gatedOut = tab.authOnly && !isLoggedIn;
|
||||||
|
const disabled = gatedOut || (!selected && full);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => !gatedOut && toggle(tab.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={cn(
|
||||||
|
'relative flex flex-col items-center justify-center gap-1.5 rounded-2xl border p-3 transition-all active:scale-95',
|
||||||
|
selected
|
||||||
|
? 'border-[#ff0040] bg-[#ff0040]/15 text-white shadow-[0_0_12px_rgba(255,0,64,0.25)]'
|
||||||
|
: 'border-white/10 bg-white/[0.03] text-gray-300',
|
||||||
|
!selected && !disabled && 'hover:border-[#ff0040]/40 hover:text-white',
|
||||||
|
disabled && 'cursor-not-allowed opacity-40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected && (
|
||||||
|
<span className="absolute right-1.5 top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#ff0040]">
|
||||||
|
<Check className="h-2.5 w-2.5 text-white" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<tab.icon className={cn('h-5 w-5', selected ? 'text-[#ff0040]' : '')} />
|
||||||
|
<span className="text-[11px] font-bold uppercase leading-tight">{label(tab)}</span>
|
||||||
|
{gatedOut && (
|
||||||
|
<span className="flex items-center gap-0.5 text-[8px] font-bold text-gray-500">
|
||||||
|
<Lock className="h-2 w-2" /> Log in
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div className="mt-6 flex items-center gap-3 pb-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="gap-2 border-white/15 text-gray-300 hover:text-white"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" /> Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={save}
|
||||||
|
className="flex-1 gap-2 bg-[#ff0040] font-black uppercase text-white hover:bg-[#ff0040]/90"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" /> Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewSlot({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
locked,
|
||||||
|
accent,
|
||||||
|
}: {
|
||||||
|
icon: NavTab['icon'];
|
||||||
|
label: string;
|
||||||
|
locked?: boolean;
|
||||||
|
accent: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-1 flex-col items-center justify-center gap-0.5 py-1.5">
|
||||||
|
{locked && (
|
||||||
|
<span className="absolute right-0.5 top-0 text-gray-600">
|
||||||
|
<Lock className="h-2.5 w-2.5" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Icon className="h-[22px] w-[22px]" style={{ color: locked ? accent : accent }} />
|
||||||
|
<span className="text-[9px] font-semibold uppercase leading-tight text-gray-300">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { CustomizeNavSheet } from '@/components/CustomizeNavSheet';
|
||||||
|
import { useNavPreferences, type NavTab } from '@/hooks/useNavPreferences';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet';
|
||||||
|
|
@ -46,6 +48,7 @@ import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Radio,
|
Radio,
|
||||||
Users,
|
Users,
|
||||||
|
SlidersHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||||
|
|
@ -233,6 +236,9 @@ function MobileNav({
|
||||||
userAvatar,
|
userAvatar,
|
||||||
userName,
|
userName,
|
||||||
t,
|
t,
|
||||||
|
onCustomize,
|
||||||
|
fixedMapTab,
|
||||||
|
middleTabs,
|
||||||
}: {
|
}: {
|
||||||
isActive: (path: string) => boolean;
|
isActive: (path: string) => boolean;
|
||||||
cashuBalance: number;
|
cashuBalance: number;
|
||||||
|
|
@ -241,16 +247,80 @@ function MobileNav({
|
||||||
userAvatar?: string;
|
userAvatar?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
|
onCustomize: () => void;
|
||||||
|
fixedMapTab: NavTab;
|
||||||
|
middleTabs: NavTab[];
|
||||||
}) {
|
}) {
|
||||||
// Decide which tabs to show — max 5 on small screens
|
// Bar model: FIXED Map · 3 user-picked middle tabs · FIXED Profile.
|
||||||
// Logged in: Home, Map, Lab, Events, Profile (wallet in profile dropdown)
|
// (tabs are resolved in Layout and passed in to avoid any module-init/TDZ
|
||||||
// Logged out: Home, Map, Lab, Events, Login
|
// ordering issues with the hoisted function declaration.)
|
||||||
const tabs = [
|
|
||||||
...navItemKeys,
|
// Middle tabs the user pinned. If a login-gated tab is pinned while logged
|
||||||
...(isLoggedIn
|
// out, fall back to a sensible public tab so the slot is never dead.
|
||||||
? [{ href: '/wallet', labelKey: 'nav.wallet', icon: Wallet }]
|
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 (
|
return (
|
||||||
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden">
|
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden">
|
||||||
|
|
@ -260,49 +330,22 @@ function MobileNav({
|
||||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)', paddingLeft: 'env(safe-area-inset-left, 0px)', paddingRight: 'env(safe-area-inset-right, 0px)' }}
|
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">
|
<div className="flex items-stretch justify-around px-1 pt-1 pb-1">
|
||||||
{tabs.map((item) => {
|
{/* FIXED: Map / Shops & Spots */}
|
||||||
const active = isActive(item.href);
|
{renderTab(fixedMapTab, 'fixed-map')}
|
||||||
const isWallet = item.href === '/wallet';
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
to={item.href}
|
|
||||||
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
|
|
||||||
? isWallet ? 'text-orange-400' : 'text-[#ff0040]'
|
|
||||||
: 'text-gray-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Active indicator dot */}
|
|
||||||
{active && (
|
|
||||||
<span className={cn(
|
|
||||||
'absolute top-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full',
|
|
||||||
isWallet ? 'bg-orange-400' : 'bg-[#ff0040]'
|
|
||||||
)} />
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
) : (
|
|
||||||
<item.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">
|
|
||||||
{t(item.labelKey)}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Profile / Login tab — always lands on Settings root */}
|
{/* CUSTOM: 3 middle tabs */}
|
||||||
|
{resolvedMiddle.map((tab, i) => renderTab(tab, `mid-${tab.id}-${i}`))}
|
||||||
|
|
||||||
|
{/* FIXED: Profile / Login tab — always lands on Settings root */}
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to="/settings"
|
||||||
|
onTouchStart={startPress}
|
||||||
|
onTouchEnd={cancelPress}
|
||||||
|
onTouchMove={cancelPress}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onCustomize();
|
||||||
|
}}
|
||||||
className={cn(
|
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',
|
'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',
|
||||||
isActive('/settings') ? 'text-[#ff0040]' : 'text-gray-500'
|
isActive('/settings') ? 'text-[#ff0040]' : 'text-gray-500'
|
||||||
|
|
@ -341,6 +384,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||||||
|
const [showCustomizeNav, setShowCustomizeNav] = useState(false);
|
||||||
const { currentMessage, dismissCurrentMessage } = useSasquatchGuide();
|
const { currentMessage, dismissCurrentMessage } = useSasquatchGuide();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { totalBalance: cashuBalance, isInitialized: hasCashu } = useCashu();
|
const { totalBalance: cashuBalance, isInitialized: hasCashu } = useCashu();
|
||||||
|
|
@ -349,6 +393,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
const author = useAuthor(user?.pubkey || '');
|
const author = useAuthor(user?.pubkey || '');
|
||||||
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
|
const displayName = author.data?.metadata?.name ?? genUserName(user?.pubkey);
|
||||||
const userAvatar = author.data?.metadata?.picture;
|
const userAvatar = author.data?.metadata?.picture;
|
||||||
|
const { fixedMapTab, middleTabs } = useNavPreferences();
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
// Both / and /map are the "spots" page
|
// Both / and /map are the "spots" page
|
||||||
|
|
@ -711,6 +756,18 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
isActive={isActive('/settings')}
|
isActive={isActive('/settings')}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
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>
|
</div>
|
||||||
{/* Language Toggle in Mobile Menu */}
|
{/* Language Toggle in Mobile Menu */}
|
||||||
<div className="py-3 border-t">
|
<div className="py-3 border-t">
|
||||||
|
|
@ -834,8 +891,14 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
userAvatar={userAvatar}
|
userAvatar={userAvatar}
|
||||||
userName={displayName}
|
userName={displayName}
|
||||||
t={t}
|
t={t}
|
||||||
|
onCustomize={() => setShowCustomizeNav(true)}
|
||||||
|
fixedMapTab={fixedMapTab}
|
||||||
|
middleTabs={middleTabs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Bottom-nav customization sheet (mobile) */}
|
||||||
|
<CustomizeNavSheet open={showCustomizeNav} onOpenChange={setShowCustomizeNav} />
|
||||||
|
|
||||||
{/* Sasquatch Guide (all screens) */}
|
{/* Sasquatch Guide (all screens) */}
|
||||||
<SasquatchGuide
|
<SasquatchGuide
|
||||||
message={currentMessage}
|
message={currentMessage}
|
||||||
|
|
|
||||||
132
src/hooks/useNavPreferences.ts
Normal file
132
src/hooks/useNavPreferences.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
MapPin,
|
||||||
|
GraduationCap,
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
Wallet,
|
||||||
|
Package,
|
||||||
|
Bookmark,
|
||||||
|
MessageCircle,
|
||||||
|
BarChart3,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom-nav customization (mobile).
|
||||||
|
*
|
||||||
|
* Model: the bar always shows a FIXED Map (Shops & Spots) on the left and a
|
||||||
|
* FIXED Profile/Login slot on the right. The user customizes the **3 middle
|
||||||
|
* slots** from a curated catalog. Anything they don't pin stays reachable in the
|
||||||
|
* burger menu, so nothing is ever lost — just re-prioritized.
|
||||||
|
*
|
||||||
|
* Stored per-device in localStorage (v1). Could later sync across devices via a
|
||||||
|
* NIP-78 app-data event without changing this interface.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface NavTab {
|
||||||
|
/** Stable id used in storage. */
|
||||||
|
id: string;
|
||||||
|
href: string;
|
||||||
|
/** Translation key when available, else falls back to `label`. */
|
||||||
|
labelKey?: string;
|
||||||
|
/** Plain label fallback / display name in the editor. */
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** Requires the user to be logged in to be useful. */
|
||||||
|
authOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Map tab is fixed on the far left and never customizable. */
|
||||||
|
export const FIXED_MAP_TAB: NavTab = {
|
||||||
|
id: 'map',
|
||||||
|
href: '/',
|
||||||
|
labelKey: 'nav.map',
|
||||||
|
label: 'Spots',
|
||||||
|
icon: MapPin,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the user can pin to the 3 middle slots.
|
||||||
|
* (Map is excluded — it's the fixed anchor. Profile is the fixed right slot.)
|
||||||
|
*/
|
||||||
|
export const NAV_CATALOG: NavTab[] = [
|
||||||
|
{ id: 'lab', href: '/lab', labelKey: 'nav.lab', label: 'The Lab', icon: GraduationCap },
|
||||||
|
{ id: 'events', href: '/events', labelKey: 'nav.events', label: 'Events', icon: Calendar },
|
||||||
|
{ id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users },
|
||||||
|
{ id: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true },
|
||||||
|
{ id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true },
|
||||||
|
{ id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true },
|
||||||
|
{ id: 'contacts', href: '/contacts', label: 'Contacts', icon: Users, authOnly: true },
|
||||||
|
{ id: 'messages', href: '/messages', label: 'Messages', icon: MessageCircle, authOnly: true },
|
||||||
|
{ id: 'stats', href: '/stats', labelKey: 'nav.stats', label: 'Stats', icon: BarChart3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Number of user-customizable middle slots. */
|
||||||
|
export const MIDDLE_SLOTS = 3;
|
||||||
|
|
||||||
|
/** Default middle tabs (matches the app's original hardcoded bar). */
|
||||||
|
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews'];
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'skatespots:nav-tabs:v1';
|
||||||
|
|
||||||
|
function lookup(id: string): NavTab | undefined {
|
||||||
|
return NAV_CATALOG.find((t) => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sanitize stored ids: keep known ids, dedupe, clamp to MIDDLE_SLOTS, backfill. */
|
||||||
|
function normalizeIds(ids: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const clean: string[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
if (lookup(id) && !seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
clean.push(id);
|
||||||
|
}
|
||||||
|
if (clean.length >= MIDDLE_SLOTS) break;
|
||||||
|
}
|
||||||
|
// Backfill from defaults if the user somehow has fewer than the slot count.
|
||||||
|
for (const id of DEFAULT_TAB_IDS) {
|
||||||
|
if (clean.length >= MIDDLE_SLOTS) break;
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
clean.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clean.slice(0, MIDDLE_SLOTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNavPreferences() {
|
||||||
|
const [storedIds, setStoredIds] = useLocalStorage<string[]>(STORAGE_KEY, DEFAULT_TAB_IDS);
|
||||||
|
|
||||||
|
const middleIds = useMemo(() => normalizeIds(storedIds), [storedIds]);
|
||||||
|
|
||||||
|
/** Resolve ids → NavTab objects (order preserved). */
|
||||||
|
const middleTabs = useMemo(
|
||||||
|
() => middleIds.map(lookup).filter((t): t is NavTab => !!t),
|
||||||
|
[middleIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setMiddleIds = useCallback(
|
||||||
|
(ids: string[]) => setStoredIds(normalizeIds(ids)),
|
||||||
|
[setStoredIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => setStoredIds(DEFAULT_TAB_IDS), [setStoredIds]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** The 3 chosen middle tab ids. */
|
||||||
|
middleIds,
|
||||||
|
/** The 3 chosen middle tabs resolved to objects. */
|
||||||
|
middleTabs,
|
||||||
|
/** Replace the whole selection (auto-normalized). */
|
||||||
|
setMiddleIds,
|
||||||
|
/** Restore defaults. */
|
||||||
|
reset,
|
||||||
|
/** Full catalog for the editor. */
|
||||||
|
catalog: NAV_CATALOG,
|
||||||
|
fixedMapTab: FIXED_MAP_TAB,
|
||||||
|
slots: MIDDLE_SLOTS,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue