SkateSpots/src/hooks/useNavPreferences.ts
SkateSpots 8dcc97fd81 feat(app-shop): add the App Shop section (empty on purpose)
New /app-shop page for SkateSpots merch, to be paid in sats only (Lightning or
Cashu) — no cards, no accounts, no middleman.

Design: gradient/glow hero ('Wear the scene' + Coming soon badge), an honest
stock strip (0 items / None pre-orders / Nothing for sale today), an 'In the
works' teaser grid (tees, decks, stickers, tools — dashed + 'soon', no prices),
a three-step 'How it'll work' (pick -> pay in sats -> ships), a newsletter
notify band (the drop lands there first) and a plain-English honesty footer.

Deliberately contains no items: no stock, no prices, no pre-orders, no buy
buttons. AGENTS.md documents that rule so nobody invents a catalogue later.

Wiring: registered in NAV_CATALOG as id 'app-shop' (appears in the desktop
'More' menu AND the mobile burger menu automatically), lazy route in AppRouter,
and added to the smoke-test route list so a broken page aborts deploys.

Verified: page renders desktop + mobile (all sections), listed in both navs,
smoke PASS with /app-shop.
2026-09-27 21:40:41 +07:00

250 lines
8.3 KiB
TypeScript

import { useCallback, useEffect, useMemo } from 'react';
import {
MapPin,
GraduationCap,
Calendar,
Users,
Wallet,
Package,
Bookmark,
MessageCircle,
BarChart3,
Coins,
Handshake,
ShoppingBag,
type LucideIcon,
} from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { usePreferences } from '@/hooks/usePreferences';
/**
* 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: 'mints', href: '/mints', label: 'Mints', icon: Coins },
{ id: 'app-shop', href: '/app-shop', label: 'App Shop', icon: ShoppingBag },
{ id: 'trades', href: '/marketplace', label: 'Marketplace', icon: Handshake },
{ 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 = 4;
/** Default middle tabs. Includes Mints (Cashu mint directory) so it's reachable
* from the phone nav out of the box; users can swap any of them via long-press. */
export const DEFAULT_TAB_IDS = ['lab', 'events', 'crews', 'mints'];
const STORAGE_KEY = 'skatespots:nav-tabs:v1';
// ─── Desktop top-nav customization (Option B) ────────────────────────────────
// Unlike mobile (fixed 5 slots), the desktop header has room. Here the user
// chooses which catalog items appear as PRIMARY tabs in the top bar and in what
// order; anything not shown still lives in the "More" dropdown, so nothing is
// ever lost. Map is always the first primary tab and cannot be hidden/moved.
/** Default desktop primary tabs. */
export const DEFAULT_DESKTOP_IDS = ['lab', 'events', 'crews', 'mints'];
const DESKTOP_STORAGE_KEY = 'skatespots:desktop-nav:v1';
/**
* Sanitize desktop ids: keep known catalog ids, dedupe. Unlike mobile there is
* no hard slot cap — the header can show as many as the user wants (they can
* always hide clutter). Falls back to defaults only when the list is empty.
*/
function normalizeDesktopIds(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);
}
}
return clean;
}
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 { preferences, setPreferences } = usePreferences();
// Cross-device: when synced preferences carry a nav layout, adopt it locally.
const syncedMiddle = preferences.navMiddleIds;
useEffect(() => {
if (!syncedMiddle) return;
setStoredIds((prev) =>
JSON.stringify(prev) === JSON.stringify(syncedMiddle) ? prev : syncedMiddle,
);
}, [syncedMiddle, setStoredIds]);
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[]) => {
const clean = normalizeIds(ids);
setStoredIds(clean);
setPreferences({ navMiddleIds: clean });
},
[setStoredIds, setPreferences],
);
const reset = useCallback(() => {
setStoredIds(DEFAULT_TAB_IDS);
setPreferences({ navMiddleIds: DEFAULT_TAB_IDS });
}, [setStoredIds, setPreferences]);
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,
};
}
/**
* Desktop top-nav customization. The user picks which catalog items show as
* primary tabs (and their order); the rest live in "More". Map is always the
* fixed first tab. Persisted per-device in localStorage.
*/
export function useDesktopNavPreferences() {
const [storedIds, setStoredIds] = useLocalStorage<string[]>(
DESKTOP_STORAGE_KEY,
DEFAULT_DESKTOP_IDS,
);
const { preferences, setPreferences } = usePreferences();
// Cross-device: adopt a synced desktop layout when present.
const syncedPrimary = preferences.desktopPrimaryIds;
useEffect(() => {
if (!syncedPrimary) return;
setStoredIds((prev) =>
JSON.stringify(prev) === JSON.stringify(syncedPrimary) ? prev : syncedPrimary,
);
}, [syncedPrimary, setStoredIds]);
const primaryIds = useMemo(() => normalizeDesktopIds(storedIds), [storedIds]);
const primaryTabs = useMemo(
() => primaryIds.map(lookup).filter((t): t is NavTab => !!t),
[primaryIds],
);
/** Catalog ids NOT shown as primary tabs (these fall into the More menu). */
const hiddenIds = useMemo(
() => NAV_CATALOG.map((t) => t.id).filter((id) => !primaryIds.includes(id)),
[primaryIds],
);
const setPrimaryIds = useCallback(
(ids: string[]) => {
const clean = normalizeDesktopIds(ids);
setStoredIds(clean);
setPreferences({ desktopPrimaryIds: clean });
},
[setStoredIds, setPreferences],
);
const reset = useCallback(() => {
setStoredIds(DEFAULT_DESKTOP_IDS);
setPreferences({ desktopPrimaryIds: DEFAULT_DESKTOP_IDS });
}, [setStoredIds, setPreferences]);
return {
/** Ordered ids shown as primary desktop tabs. */
primaryIds,
/** Resolved primary tab objects (order preserved). */
primaryTabs,
/** Catalog ids not shown as primary (live in More). */
hiddenIds,
/** Replace the whole primary selection (auto-normalized). */
setPrimaryIds,
/** Restore defaults. */
reset,
/** Full catalog for the editor. */
catalog: NAV_CATALOG,
fixedMapTab: FIXED_MAP_TAB,
};
}