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.
This commit is contained in:
SkateSpots 2026-09-27 21:40:41 +07:00
parent c07f857ed0
commit 8dcc97fd81
6 changed files with 256 additions and 0 deletions

View file

@ -344,6 +344,17 @@ session with **no other memory of this project**. So:
burger menu (`MOBILE_MENU_LISTED_HREFS`) now **appends catalog items that burger menu (`MOBILE_MENU_LISTED_HREFS`) now **appends catalog items that
aren't hard-coded**, so Marketplace/Mints (and any future section) are always aren't hard-coded**, so Marketplace/Mints (and any future section) are always
reachable on mobile — don't re-hardcode only a subset. reachable on mobile — don't re-hardcode only a subset.
- **App Shop** (`/app-shop`, `src/pages/AppShopPage.tsx`): SkateSpots merch, to be
paid in **sats only (Lightning or Cashu)** — no cards/accounts. Registered in
`NAV_CATALOG` as id `app-shop` (label "App Shop", `ShoppingBag`), which is what
makes it appear in **both** the desktop "More" menu and the mobile burger menu
(the mobile menu appends catalog items not in `MOBILE_MENU_LISTED_HREFS`). It is
**intentionally empty**: no stock, no prices, no pre-orders and nothing for
sale — the copy states that plainly ("an empty shop with good lighting") and
only previews what's coming. **Do not add prices/fake urgency/buy buttons until
there is real stock**; when items exist, model them as listings and take
Lightning/Cashu payment. The page reads the live stock strip as 0/None/Nothing
— keep it honest if that changes.
- **Mints directory**: `/mints` page lists Cashu mints from the community - **Mints directory**: `/mints` page lists Cashu mints from the community
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →

View file

@ -1,5 +1,12 @@
v1.5.4 v1.5.4
🛍 App Shop — a new section (empty on purpose)
- Merch is coming, and it'll be paid in sats: Lightning or Cashu, your choice.
No cards, no accounts, no processor skimming the drop.
- To be clear: we hold no stock and nothing is for sale yet. No pre-orders, no
countdown timers, no fake scarcity — the page says so, and previews what's in
the works. Subscribe to the newsletter to get the drop first.
✉️ Direct messages actually arrive now ✉️ Direct messages actually arrive now
- Your DM inbox relays were being forgotten every time the app loaded, so - Your DM inbox relays were being forgotten every time the app loaded, so
messages were sent to your normal relays instead of the ones the other person messages were sent to your normal relays instead of the ones the other person

View file

@ -140,6 +140,7 @@ const ROUTES = [
'/collections', '/collections',
'/stats', '/stats',
'/mints', '/mints',
'/app-shop',
'/messages/group/abc123', '/messages/group/abc123',
]; ];
const problems = []; const problems = [];

View file

@ -17,6 +17,7 @@ const EventsPage = lazyWithRetry(() => import("./pages/EventsPage"));
const LabPage = lazyWithRetry(() => import("./pages/LabPage")); const LabPage = lazyWithRetry(() => import("./pages/LabPage"));
const WalletPage = lazyWithRetry(() => import("./pages/WalletPage")); const WalletPage = lazyWithRetry(() => import("./pages/WalletPage"));
const MintsPage = lazyWithRetry(() => import("./pages/MintsPage")); const MintsPage = lazyWithRetry(() => import("./pages/MintsPage"));
const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage"));
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage")); const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage")); const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage")); const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
@ -134,6 +135,7 @@ export function AppRouter() {
<Route path="/food" element={<FoodPage />} /> <Route path="/food" element={<FoodPage />} />
<Route path="/wallet" element={<WalletPage />} /> <Route path="/wallet" element={<WalletPage />} />
<Route path="/mints" element={<MintsPage />} /> <Route path="/mints" element={<MintsPage />} />
<Route path="/app-shop" element={<AppShopPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/create-spot" element={<CreateSpotPage />} /> <Route path="/create-spot" element={<CreateSpotPage />} />
<Route path="/create-shop" element={<CreateShopPage />} /> <Route path="/create-shop" element={<CreateShopPage />} />

View file

@ -11,6 +11,7 @@ import {
BarChart3, BarChart3,
Coins, Coins,
Handshake, Handshake,
ShoppingBag,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
@ -60,6 +61,7 @@ export const NAV_CATALOG: NavTab[] = [
{ id: 'crews', href: '/crews', labelKey: 'nav.crews', label: 'Crews', icon: Users }, { 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: 'wallet', href: '/wallet', labelKey: 'nav.wallet', label: 'Wallet', icon: Wallet, authOnly: true },
{ id: 'mints', href: '/mints', label: 'Mints', icon: Coins }, { 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: 'trades', href: '/marketplace', label: 'Marketplace', icon: Handshake },
{ id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true }, { id: 'stash', href: '/stash', label: 'Stash', icon: Package, authOnly: true },
{ id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true }, { id: 'collections', href: '/collections', label: 'Collections', icon: Bookmark, authOnly: true },

233
src/pages/AppShopPage.tsx Normal file
View file

@ -0,0 +1,233 @@
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import {
ShoppingBag,
Zap,
Coins,
Truck,
Shirt,
Sticker,
Package,
Hammer,
Sparkles,
BadgeCheck,
ArrowRight,
} from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
import { Button } from '@/components/ui/button';
/**
* App Shop — SkateSpots merch.
*
* Deliberately a STOREFRONT WITH NOTHING IN IT: there is no stock, no
* pre-order, no price and nothing to buy. It exists so the section, the design
* language and the payment story (sats only — Lightning or Cashu) are in place
* before the first drop. When items land we can model them as listings and take
* Lightning/Cashu payment; until then this page must never imply a sale.
*/
/** What we're working on. Not products, not prices — just intent. */
const PLANNED = [
{ icon: Shirt, label: 'Tees & hoodies', note: 'Heavyweight, printed somewhere that pays people properly.' },
{ icon: Package, label: 'Decks', note: 'Small numbered runs. Ride it or hang it, your call.' },
{ icon: Sticker, label: 'Sticker packs', note: 'For the box, the rail, the laptop lid.' },
{ icon: Hammer, label: 'Shop tools', note: 'The hardware the homies actually wear out.' },
];
const PAY_CHIPS = [
{ icon: Zap, label: 'Lightning' },
{ icon: Coins, label: 'Cashu' },
{ icon: Truck, label: 'Shipped worldwide' },
{ icon: BadgeCheck, label: 'At cost, no markup theatre' },
];
export default function AppShopPage() {
useSeoMeta({
title: 'App Shop — SkateSpots',
description:
'SkateSpots merch, paid in sats over Lightning or Cashu. Nothing in stock yet — here is what is coming.',
});
return (
<div className="container mx-auto max-w-4xl px-4 py-8 pb-24 md:pb-14">
<PageHeader
icon={ShoppingBag}
title="App Shop"
accent="#ccff00"
subtitle="SkateSpots merch for the people who keep the scene rolling."
/>
{/* ── Hero ─────────────────────────────────────────────────────────── */}
<section className="relative overflow-hidden rounded-3xl border-2 border-[#ff0040]/25 bg-gradient-to-br from-[#ff0040]/12 via-black to-[#ccff00]/[0.06] px-5 py-10 sm:px-8 sm:py-14">
<div className="pointer-events-none absolute -top-28 left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-[#ff0040]/25 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -right-16 h-56 w-56 rounded-full bg-[#ccff00]/15 blur-3xl" />
<div className="relative">
<span className="inline-flex items-center gap-2 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-3 py-1 text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
<Sparkles className="h-3 w-3" />
Coming soon
</span>
<h2
className="mt-4 text-5xl font-black uppercase leading-[0.9] sm:text-7xl"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
<span className="bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00] bg-clip-text text-transparent">
Wear the
</span>
<br />
<span className="text-white">Scene</span>
</h2>
<p className="mt-4 max-w-xl text-sm leading-relaxed text-gray-300 sm:text-base">
Merch for the people who keep this thing rolling. Paid in{' '}
<strong className="font-bold text-[#f7931a]">sats</strong> — Lightning or Cashu,
whichever you already keep. No cards, no accounts, no middleman skimming the drop.
</p>
<div className="mt-6 flex flex-wrap gap-2">
{PAY_CHIPS.map(({ icon: Icon, label }) => (
<span
key={label}
className="inline-flex items-center gap-1.5 rounded-full border border-white/12 bg-white/[0.04] px-3 py-1.5 text-[11px] font-bold text-gray-300 backdrop-blur-sm"
>
<Icon className="h-3.5 w-3.5 text-[#ccff00]" />
{label}
</span>
))}
</div>
</div>
</section>
{/* ── Honest stock strip ───────────────────────────────────────────── */}
<div className="mt-4 grid grid-cols-3 gap-2 sm:gap-3">
{[
{ k: 'Items in stock', v: '0' },
{ k: 'Pre-orders', v: 'None' },
{ k: 'For sale today', v: 'Nothing' },
].map((s) => (
<div
key={s.k}
className="rounded-2xl border border-white/8 bg-white/[0.03] px-3 py-4 text-center"
>
<p className="text-xl font-black text-white sm:text-2xl">{s.v}</p>
<p className="mt-1 text-[10px] font-bold uppercase tracking-widest text-gray-500">
{s.k}
</p>
</div>
))}
</div>
{/* ── What's in the works ──────────────────────────────────────────── */}
<section className="mt-10">
<div className="mb-4 flex items-center gap-3">
<h3 className="text-sm font-black uppercase tracking-widest text-white">
In the works
</h3>
<div className="h-px flex-1 bg-white/10" />
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
not for sale yet
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{PLANNED.map(({ icon: Icon, label, note }) => (
<div
key={label}
className="group relative overflow-hidden rounded-2xl border-2 border-dashed border-white/12 bg-white/[0.02] p-4 transition-colors hover:border-[#ccff00]/30"
>
<div className="flex items-start gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[#ccff00]/10 text-[#ccff00]">
<Icon className="h-5 w-5" />
</span>
<div className="min-w-0">
<p className="font-black uppercase tracking-wide text-white">{label}</p>
<p className="mt-0.5 text-xs leading-relaxed text-gray-400">{note}</p>
</div>
</div>
<span className="absolute right-3 top-3 rounded-full border border-white/12 px-2 py-0.5 text-[9px] font-black uppercase tracking-widest text-gray-500">
soon
</span>
</div>
))}
</div>
</section>
{/* ── How it'll work ───────────────────────────────────────────────── */}
<section className="mt-10">
<div className="mb-4 flex items-center gap-3">
<h3 className="text-sm font-black uppercase tracking-widest text-white">
How it'll work
</h3>
<div className="h-px flex-1 bg-white/10" />
</div>
<ol className="grid gap-3 sm:grid-cols-3">
{[
{ n: '1', t: 'Pick your thing', d: 'One page, no logged-in pricing games, no fake urgency.' },
{ n: '2', t: 'Pay in sats', d: 'Lightning or Cashu. Straight to us — no processor in the middle.' },
{ n: '3', t: 'It ships', d: 'Posted to your door with an honest tracking number.' },
].map((s) => (
<li
key={s.n}
className="rounded-2xl border border-white/8 bg-white/[0.03] p-4"
>
<span
className="mb-3 flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-[#ff0040] to-[#ff6600] text-sm font-black text-white"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
{s.n}
</span>
<p className="font-black uppercase tracking-wide text-white">{s.t}</p>
<p className="mt-1 text-xs leading-relaxed text-gray-400">{s.d}</p>
</li>
))}
</ol>
</section>
{/* ── Notify band ──────────────────────────────────────────────────── */}
<section className="mt-10 rounded-3xl border-2 border-[#ccff00]/20 bg-[#ccff00]/[0.04] px-5 py-6 text-center sm:px-8">
<h3 className="text-lg font-black uppercase tracking-wide text-white">
Want the drop first?
</h3>
<p className="mx-auto mt-2 max-w-md text-xs leading-relaxed text-gray-400">
The newsletter is where merch lands first — sizes, colours, and the real
price in sats. Subscribe and it reaches your notification bell.
</p>
<div className="mt-4 flex justify-center">
<NewsletterSubscribeButton />
</div>
<p className="mt-4 text-[11px] text-gray-600">
Got a design you want on a deck?{' '}
<Link to="/newsletter" className="text-[#ccff00] hover:opacity-80">
Tell us in the community section
</Link>
.
</p>
</section>
{/* ── Honesty foot ─────────────────────────────────────────────────── */}
<section className="mt-8 rounded-2xl border border-white/8 bg-white/[0.02] p-4">
<p className="text-xs leading-relaxed text-gray-500">
<strong className="text-gray-300">The honest bit:</strong> we hold no stock and
nothing here is for sale. There are no pre-orders, no countdown timers and no
fake scarcity — when there is something real to buy, this page will say so and
take your sats. Until then it is just an empty shop with good lighting.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button asChild variant="outline" className="gap-2 border-white/15">
<Link to="/supporters">
Back the project <ArrowRight className="h-3.5 w-3.5" />
</Link>
</Button>
<Button asChild variant="ghost" className="gap-2 text-gray-400 hover:text-white">
<Link to="/mints">
<Coins className="h-3.5 w-3.5" /> Pick a mint for your sats
</Link>
</Button>
</div>
</section>
</div>
);
}