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.
250 lines
8.3 KiB
TypeScript
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,
|
|
};
|
|
}
|