Commit graph

699 commits

Author SHA1 Message Date
SkateSpots
a43ff47b28 feat(spots): honest quality signals, photo required, off-topic reports, admin review queue
The 'test of secret island airport' problem: a junk pin wearing an app-endorsed
'Skateability 95/100'.

- Rename the weather score to 'Conditions · weather only' (SpotDetailPage +
  the SpotMap popup). It was never a verdict on the place.
- New Community verified / Unverified badge from signals only OTHER skaters can
  produce: community photos + useSpotStatus reporterCount (kind 2259).
- CreateSpotPage now REQUIRES at least one photo (guard + disabled submit +
  a required hint on the label).
- useNotASpotReport: one-tap NIP-56 (1984) report tagged 'not-a-skate-spot'
  (NIP-56 'other' + our L/l/t taxonomy), wired into the spot's More menu and
  added to the shared ReportDialog reasons.
- useAdminHide: the MISSING WRITE SIDE for admin moderation. useAdminModeration
  always read NIP-32 kind-1985 'hidden' labels but nothing published one, so the
  only working hide was device-local. Now publishes the label (+ optimistic
  local hide). Soft and reversible — the event stays on relays.
- /review (AdminReviewPage, admin-only → 404 otherwise): review queue over
  useSkateSpots with cheap flags (suspicious title, name <=3 chars, no photo, no
  description, no geohash), sorted by flag count, one-tap Hide or Not a skate
  spot. Entry via the admin-only AdminReviewCard in Settings → Advanced.
- /review added to the smoke-test route list.

Verified: the airport spot shows UNVERIFIED with 'Conditions · weather only' and
no 'Skateability'; the review queue lists it with flags + Hide/report actions;
create-spot blocks without a photo.
2026-09-27 23:41:49 +07:00
SkateSpots
0adfbcbd29 feat(app-shop): remove the subscribe band and the honesty note
Per request, the page is now: hero -> in the works -> free to print -> how
buying works. Nothing else changed.

Honesty is still carried by the remaining copy: the COMING SOON badge, the
'IN THE WORKS / not for sale yet' label on the teasers, and the hero's 'sold
locally, on request'. No prices, no buy buttons, no stock list.

Also drops the now-unused NewsletterSubscribeButton/ArrowRight imports, and
updates release-notes + AGENTS.md (they described the removed copy).
2026-09-27 23:23:14 +07:00
SkateSpots
4341653abe feat(app-shop): add free printable sticker + poster (vector, real QR)
- public/images/print/skatespots-sticker.svg (55 x 86 mm) and
  skatespots-poster.svg (A3) — the free artwork to print and stick up. Physical
  size is baked into the SVG (mm units), so 'Actual size' printing is exact.
- Self-contained: Outfit/Barlow fonts + the logo embedded as data URIs, and a
  real vector QR. VERIFIED both QRs decode to https://skate-spots.org (decoded
  with jsQR from a rendered PNG; logo centre, error-correction H).
- New 'Free to print' section in the App Shop: previews, FREE badge, sizes,
  cutting tip, 'Open & print' + 'Save file' buttons, and a 100%/Actual-size note.
- scripts/make-print-art.mjs regenerates the artwork (committed output).
- AGENTS.md documents the assets + the re-verify-the-QR rule.
2026-09-27 22:16:43 +07:00
SkateSpots
f3d88cc2a8 feat(app-shop): drop the stock stat strip and the teaser blurbs
- Remove the 0 / None / Nothing tiles (items in stock, pre-orders, for sale
  today) — the honesty footer already says there is no stock.
- 'In the works' cards are now just icon + label + SOON badge, no small print
  ('Heavyweight...', 'Small numbered runs...', etc).

Verified in the browser: strip and blurbs gone, everything else intact.
2026-09-27 22:01:48 +07:00
SkateSpots
059d9da5c4 feat(app-shop): local/on-request sales via DM (drop shipping claims)
- Remove the 'Shipped worldwide' and 'At cost, no markup theatre' chips; the
  remaining chips are Lightning, Cashu, Local handover.
- Copy now says sold locally, on request: message me -> we sort the details ->
  pay in sats at handover. No shipping, at all.
- 'How it'll work' -> 'How buying works' (it's a DM, not a cart): Message me /
  We sort it out / Pay in sats.
- New 'Request an item' CTA in the hero that deep-links into a private DM with
  the shop owner (/messages/<PLATFORM_OWNER_PUBKEYS[0] npub>), so a buyer can
  start the conversation with zero checkout machinery.
- Honesty footer states local handover only / no shipping of any kind.
- AGENTS.md documents the model (no cart/checkout/shipping without a decision).
2026-09-27 21:52:00 +07:00
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
SkateSpots
565fdb9211 fix(newsletter): render _underscore italics_ in issue bodies
issueToMarkdown wraps each issue's vibe in _underscores_, but the in-app
markdown renderer only understood *asterisk* emphasis — so every published
archive issue showed LITERAL underscores around its intro line (both in our
reader and here). Added the underscore form to the tokenizer.

Verified on the live issue-5 naddr: the vibe now renders as <em> with no
literal underscores.
2026-09-27 20:34:31 +07:00
SkateSpots
261e4c9115 feat(newsletter): add Drop #006 to the archive (+cover support), incl. Sasquatch and Mints
- NewsletterIssue gains an optional 'image' (per-issue cover); IssueArticle renders
  it above the header and buildIssueArticle uses it as the NIP-23 'image' tag
  (falling back to the spot-of-the-week image).
- NEWSLETTER_ISSUES now leads with #006 (2026-09-27): the Nostr-Twitter-Clone
  rant, the Directions fixes (route never drew + identical per-mode numbers),
  follow mode, choose-your-start + offline saved routes, Sasquatch Pro
  (sats-paid, streamed, personal tools, one signature per session), the
  Marketplace hub + 1-10 deck ratings, Mints (directory + the native JSON-error
  fix), Amber scoping, the DM inbox-relay bug, and the polish list.
- Cover: https://skate-spots.org/images/drop-006-cover.png (hosted in
  public/images, added in e075ee8).

Verified: the archive renders the cover + #006 copy, and the 'Drop #006 is out'
pill shows on other pages for 7 days. tsc/eslint clean, 101 tests, smoke PASS.
2026-09-27 20:26:14 +07:00
SkateSpots
1ef35c14b8 fix(dms): DM inbox relays were wiped on every load, so DMs went nowhere
Root cause: AppProvider validates the persisted config with
AppConfigSchema.partial().parse(), and RelayMetadataSchema only declared
'relays' + 'updatedAt'. Zod drops unknown keys by default, so dmRelays (and
localRelays + blockedRelays) were SILENTLY STRIPPED on every page load. The DM
inbox relays (kind 10050) are usually not in a user's NIP-65 list, so DM reads
and writes fell back to NIP-65 relays - where the other side isn't listening -
and messages looked like they never went through.

- Declare localRelays/blockedRelays/dmRelays in RelayMetadataSchema.
- Send: publish gift wraps in PARALLEL with per-relay timeouts and resolve on
  the first relay that accepts (a dead relay or auth.nostr1.com no longer
  stalls the send); deliver to the recipient's NIP-65 WRITE relays when they
  have no discoverable 10050 (the old fallback was dead code because our own DM
  relays are always non-empty); discovery of 10050/10002 now runs in parallel.
- Read: query all DM relays in parallel with per-relay timeouts. Previously one
  slow/dead relay burned the shared 15s budget and then the fired abort made
  every remaining relay fail instantly.

Verified with an nsec login + a hostile relay list (dead + auth-required):
before, the wrap reached only write relays and the recipient found 0; after, it
lands on the configured inbox relays and both the recipient and our own copy are
found. Send worst case 20s -> 4.5s.
2026-09-27 19:58:54 +07:00
SkateSpots
4474cc26e9 feat(directions): bigger map + app-like layout and richer route details
- Map is now the hero: h-[38vh] mobile / 400px desktop (was 256), with a metric
  Leaflet scale control.
- New layout: flex column with header/modes/From/map pinned, the details as the
  ONLY scroll region, and Start/Stop pinned at the bottom so it's always
  reachable. Previously the dialog was a single overflow-hidden box, so once the
  map grew everything below the fold (Start, steps) was unreachable.
- Summary rebuilt as stat tiles (distance / time / arrive) plus a detail line:
  step count, honest 'uses driving roads'/'driving estimate' notes, and the
  average speed the engine assumed ('21 km/h avg'; foot keeps the skate cruise
  estimate). Steps header shows the count; step list scrolls taller (max-h-72).
- Wider dialog on desktop (sm:max-w-xl).

Verified in-app: tiles + 9/8/5/5 steps per mode, follow banner still tracks
movement, offline chip still appears, map 371px -> 321px with the new layout.
2026-09-27 19:42:06 +07:00
SkateSpots
5a819d5a31 feat(directions): follow mode, choose-your-start, offline route cache
Follow mode (explicit Start; off by default — a watcher drains battery):
watchPosition in geolocation.ts, live dot, 'NEXT · step N' banner with
distance-to-turn + distance-to-go, current step highlighted, map follows.
A transient watch error (timeout/unavailable) no longer kills it — only a
permission denial does; the watcher recovers on its own.

Choose your start: 'From → My location / Choose on map' — tapping the map sets
the origin, and the route recomputes from there.

Offline: src/lib/routeCache.ts keeps the last route per destination+mode with
the line downsampled to <=250 points. If a fetch fails (no signal / routing
server down) the dialog falls back to it with an 'Offline · saved route {age}'
badge instead of a dead error.

Also tidied the map overlays (offline badge bottom-left, Whole route
bottom-right) so nothing covers Leaflet's zoom control.

Verified with CDP: controls present; follow banner tracks movement (step 1 ->
step 7, distances update); blocking routing shows the cached route + badge;
picking a start on the map changed the route (968 m -> 922 m). Still no voice
guidance or re-routing if you leave the route (documented).
2026-09-27 19:11:14 +07:00
SkateSpots
59e5829b2a feat(directions): make the route dialog usable as guidance, not just a preview
Answering 'are we giving the best navigation experience?': no, it was a preview
with dead ends. Fixes the concrete gaps:

- ETA: 'arrive ~08:10 PM' alongside the duration.
- Mode memory: the last travel mode is remembered (skatespots:route-mode), so
  cyclists/drivers aren't reset to Skate/Walk every trip.
- Tappable steps: each step pans the map and highlights; added a 'Whole route'
  re-fit button to zoom back out.
- Taller route map on desktop (h-64 sm:h-80).
- min-h-0 on step rows: the app-wide 44px mobile touch-target rule was making
  every step a 44px slab (rows now 29px).

Still out of scope (documented, not faked): live turn-by-turn following, voice
guidance and offline routing.
2026-09-27 18:47:29 +07:00
SkateSpots
e69bace784 fix(amber,routing): scope Amber prompts to real actions; real per-mode routes + steps
AMBER — 'pops on every screen':
staleTime: Infinity only stops REFETCHING, never the FIRST decrypt. So the
encrypted useSyncedAppData blobs (read-state, preferences) each ran a queryFn on
mount (racing consumers multiplied it) and relay responses landing during
navigation made it look per-page. useSyncedAppData now has interactiveNetwork
(default false): for an interactive signer the blob is LOCAL-ONLY — query not
even enabled, patch() a no-op — no decrypt on mount, no publish on a nav-tab
change. Verified with a mocked x-amber login: network-on => Amber op at startup,
with the fix => 0. (useCrewDismissals/useStash were already gated.)

ROUTING — 'same time for every transport option':
router.project-osrm.org hosts ONE car dataset and ignores the profile in the URL,
so foot/bike/car returned identical numbers. Switched to the FOSSGIS per-profile
servers (routing.openstreetmap.de/routed-{foot,bike,car}); the demo server is now
only a last-resort fallback and its result is flagged approximate ('driving
estimate'). Request steps=true and surface turn-by-turn steps for orientation,
plus a '~X min on a board' skate estimate next to the walking time. Verified:
same trip => walk 13 min / bike 5 / moto+car 2, with 5-9 steps each.
2026-09-27 18:35:17 +07:00
SkateSpots
e98c996945 fix(newsletter): infinite render loop + issues published to the wrong relays
Audit of the newsletter section found two real bugs.

1) /newsletter rendered in an infinite loop (100% CPU, React 'Maximum update
   depth exceeded'). Chain: useSyncedAppData returned a fresh object each render
   -> useReadState.apply (dep: synced) changed identity -> markNewsletterRead /
   markAllRead changed every render -> the page effect (deps include it) re-ran
   after every render -> the mark set a NEW state object -> loop. The
   notification bell looped the same way while open.
   - useLocalStorage: setter is now referentially stable (ref + functional set).
   - useSyncedAppData: returned object is memoized.
   - useReadState: apply reads synced/interactive from refs (stable); marks are
     idempotent (same object when already marked) so React bails.
   - NewsletterPage marks read once per visit (mount-once ref guard).

2) Issues + subscription markers were published via the default pool (the
   author's own NIP-65 relays) but READ via useSkateRelays (community relay +
   reader's relays; logged-out = community relay only), so an admin without
   goskatespots in their list would publish drops nobody could see. Composer,
   backfill panel and the subscribe hook now publish to getWriteRelays() and
   fail loudly if every relay rejects (useSpotChat pattern). NIP.md untouched
   (no schema change).

Also: guard the overlay pill/reminder against an empty archive, and drop a
redundant canPublish clause.
2026-09-27 17:40:02 +07:00
SkateSpots
6a442c394f fix(mints): native app fetched /api/mints from the local shell -> HTML/JSON error
On the native app the shell is served from https://localhost, so the relative
/api/mints/known hit the local asset server and returned index.html, surfacing as
'Unexpected token < ... not valid JSON' on /mints.

- Native now fetches https://skate-spots.org/api/mints/known via CapacitorHttp
  (bundled core plugin, no WebView CORS), with a plain-fetch fallback.
- Web/dev unchanged (same-origin relative path via the Netlify redirect / Vite
  proxy).
- HTML or unparseable bodies now become a friendly 'unavailable' message instead
  of a raw JSON parse error.
2026-09-27 17:25:16 +07:00
SkateSpots
4e4c50ab71 fix(nav): desktop 'More' was clipped; mobile menu never listed Marketplace
- Desktop: the nav's overflow-x-auto clips its trailing item when the nav grows,
  which is exactly the 'More' menu. Widened the header (max-w-[1700px] vs the
  1400px page container), tightened nav padding to px-3 and nowrap. 'More' is
  fully visible at 1280/1440/2000px with Marketplace pinned (was missing).
- Mobile: the burger menu hard-coded only Map/Lab/Events/Crews + a few extras, so
  Marketplace (and Mints) were unreachable. It now appends every nav-catalog item
  not already listed, giving mobile/desktop parity (Marketplace, Mints, …).
2026-09-27 17:18:11 +07:00
SkateSpots
0b5230d4ab fix(directions): route map never rendered + dialog stacked under the spot sheet
RouteDialog was effectively broken — users got an empty dialog / 'no route'.

- Map init: the Leaflet map lives in Radix Dialog content, which mounts AFTER
  the first effect pass, so the mapDivRef guard was null, the init effect bailed,
  and the map was never created (no route line, ever). Hold the node in state
  with a callback ref and add it to the effect deps.
- Height: the global .leaflet-container{height:100%} override beats h-64; against
  a height-less parent it resolved to 0 (invisible map). Height now lives on the
  wrapper (relative h-64) and the map is h-full.
- Stacking: from the map, Directions rendered the dialog inside the spot Sheet
  (two nested Radix modals; the sheet covered the route). The map now owns the
  RouteDialog at page level and closes the sheet first.
- Routing transport: call OSRM directly (it sends CORS '*') with the cors proxy
  only as fallback; distinguish busy / no-route / unreachable errors and guard
  against NaN coordinates.
2026-09-27 17:07:44 +07:00
SkateSpots
e5e2f7d7a1 fix(nav,amber,reputation,routing): header crowding, no read-state popups, anti-collusion rep, instant directions
- Header: shrink-0 logo + whitespace-nowrap + gap, nav shrinks/scrolls so it
  can never collide with (or glow over) the SkateSpots wordmark; tighter item
  padding. Fixes the top-left logo/nav overlap.
- Amber: useReadState keeps read markers LOCAL-ONLY for interactive signers, so
  merely opening the bell/DM no longer publishes (encrypt + sign = 2 popups).
- Reputation: lead with verifiedCounterparties (distinct people), cap each
  partner at 3 in the WoT-weighted score, warn when every confirmation is with
  one account. NIP.md documents the collusion limit honestly.
- Directions: skip the live GPS call when the cached fix is <2 min old; the
  blocking 'Finding your location' overlay only shows with no seed (silent
  refine chip otherwise). Near Me also flips the sort to proximity.
- .gitignore: cvm-server/package.json (local dev copy of package.cvm.json).
2026-09-27 16:42:39 +07:00
SkateSpots
efa8b2e521 feat(marketplace): 1-10 deck ratings + seller dashboard
- Reviews: confirming a trade now lets the reviewer set a 1-10 skateboard-deck
  rating, stored as ["rating","n"] on the kind-31871 attestation (NIP.md updated).
  The Reputation tab shows each review's decks plus an average.
- New DeckRating component (self-drawn deck glyph) and TradeConfirmRow (rating
  picker + confirm), used for both seller-confirm and buyer-confirm in My Trades.
- New 'Selling' tab: your marketplace listings (active/sold) with one-tap
  Mark sold.
2026-09-27 16:26:24 +07:00
SkateSpots
14b3ee0bf2 feat(marketplace): hub with Browse / My Trades / Reputation (+ reviews)
- /marketplace is now a hub with a sticky tab bar: Browse (the gear grid),
  My Trades (existing flow), and Reputation — the trader's reputation score,
  peer recommendations, and a Reviews/trade-records list built from kind-31871
  attestations (who, sold/bought, method, amount, note, link to the listing).
- Nav: the old 'trades' entry becomes 'Marketplace' -> /marketplace (no longer
  auth-only, since browsing is public).
- /trades and /listing/... deep links keep working.
2026-09-27 16:14:47 +07:00
SkateSpots
f95e2233c8 fix(mobile-ux): Mints in the nav, instant directions, no stray signer prompt
- Nav: MIDDLE_SLOTS 3->4 and add 'mints' to the default middle + desktop tabs,
  so the Cashu mints directory is reachable on the phone out of the box (also
  still linked from Wallet; any of the 4 slots can be swapped via long-press).
- Directions: RouteDialog now seeds the origin from the last known location and
  draws immediately, then refines with a live fix — no more hanging on 'Finding
  your location…'. Shortened the web geolocation fallback 15s -> 8s.
- Amber: /api/ai/status is now public (it only reports a small balance), so
  showing the AI balance no longer signs NIP-98 / opens the signer. The signer is
  reserved for actions that actually need it.
2026-09-27 15:54:29 +07:00
SkateSpots
821522327c feat(sasquatch): show the owner AI balance on the panel header
Owner-only chip (next to the Pro/CVM chips) showing the upstream sats balance,
amber when low. useSasquatchAiStatus gains an 'enabled' option; the panel only
fetches while it is open, so the NIP-98 signature never fires at app startup.
2026-09-27 15:36:09 +07:00
SkateSpots
62b395f3d2 feat(sasquatch): align client models with the node + owner AI-balance nudge
- sasquatchBrain: PROXY_MODELS now match the Routstr node's slugs
  (gemini-3-6-flash, z-ai-glm-5-3, gemini-3-7-flash).
- ai.mjs: new owner-only POST /api/ai/status (NIP-98 + owner pubkey) returning the
  upstream balance + a 'low' flag (<500 sats); config.path now serves both routes.
- useSasquatchAiStatus + SasquatchAiStatusCard (owner-only, in Settings): shows the
  balance and warns when low — the top-up nudge, no webhook/notification key needed.
2026-09-27 15:27:32 +07:00
SkateSpots
8d272a9c76 feat(sasquatch-pro): crew agent sends Pro credentials too
useCrewAgentChat now signs the same NIP-98 auth + attaches the viewer's Pro
receipt (when they have one), so the proxy's Pro gate lets Pro users through the
gateway models; everyone else still falls back to the keyless provider.
2026-09-27 14:27:02 +07:00
SkateSpots
ccc04cc172 feat(sasquatch-pro): server-side Pro gate on the AI proxy + client credentials
- netlify/functions/ai.mjs: optional Pro enforcement (SASQUATCH_REQUIRE_PRO=1).
  Requires a NIP-98 kind-27235 auth header (x-sasquatch-auth) and, unless the
  caller is a platform owner, an active Pro pass (x-sasquatch-pro = a
  provider-authored zap receipt) verified OFFLINE — signature, user binding,
  platform tag, and paid-amount → tier. Off by default.
- src/lib/proAuth.ts: signs the NIP-98 once per session (no Amber popup per
  message) and attaches the Pro receipt.
- sasquatchBrain: threads auth headers through the proxy (streaming + non).
- useSasquatchPro: exposes the winning receipt event.
- Verified: 200 with the gate off, 401 with it on (env reset afterwards).
2026-09-27 14:18:33 +07:00
SkateSpots
6335965afc fix(pro): single-source the platform Lightning address + show it to payers
The pay-to address was duplicated (PRO_LIGHTNING_ADDRESS and
PLATFORM_LIGHTNING_ADDRESS) and hardcoded in the tour zap-split; a typo in any
copy would send money nowhere. Now one source of truth in src/lib/platform.ts,
re-exported by the fee/Pro modules.

Also surface the address in the Sasquatch Pro dialog ('Pays to ⚡ <address>') so
users can see where their sats go, in both the auto-pay and manual-QR paths.
2026-09-27 14:13:37 +07:00
SkateSpots
670a33123d feat(sasquatch-pro): enforce the paid tier; make AI Pro-only
- useSasquatchPro: derive Pro expiry from what was ACTUALLY paid (largest tier
  the bolt11 covers, run from the receipt time), instead of trusting the
  user-signed `pro-expiry` tag — a 7-day payment could previously self-assert a
  year. Provider-authored receipt + amount checks unchanged.
- SasquatchGuide: AI answers are now Pro-only. Free users get the static FAQ,
  glossary, trip planner and local relay lookups (all no-cost), plus a nudge to
  Pro when a question would otherwise need AI.
- AGENTS.md updated.
2026-09-27 13:56:52 +07:00
SkateSpots
087f490acc refactor(layout): hoist Layout into the router so the chrome mounts once
- AppRouter wraps every route (except /amber-callback and the 404) in ONE
  <Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
  navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
  a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
  fragments instead of <Layout> (structure-preserving), imports dropped.

Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
2026-09-27 13:41:42 +07:00
SkateSpots
55b4dd2e86 fix(amber): stop re-decrypting on every navigation
Root cause: Layout is NOT a router layout — ~62 pages wrap themselves in
<Layout>, so its chrome remounts on every route change. The header's
NotificationBell -> useNotificationsRead -> useReadState -> useSyncedAppData
re-decrypts an encrypted kind-30078 event on mount; with Amber (NIP-55) every
nip44.decrypt opens the signer app, so it popped on each menu click.

Fix: for interactive signers, decrypt once per session and serve remounts from
cache (staleTime/gcTime Infinity, refetchOnMount/refetchOnReconnect false) in
useSyncedAppData and useStash. Non-interactive signers refetch as before.

Follow-up (noted in AGENTS.md): hoist Layout into the router so the chrome mounts
once — removes the whole remount class (and SasquatchGuide state resets).
2026-09-27 13:28:36 +07:00
SkateSpots
1e4f3302dc feat(sasquatch): server-side AI proxy (no key in the client bundle)
- netlify/functions/ai.mjs: POST /api/ai runs against Netlify's AI Gateway —
  Netlify injects OPENROUTER_API_KEY + OPENROUTER_BASE_URL into Functions, so no
  key is managed or shipped. Curated model allowlist, best-effort per-IP
  throttle, CORS for web + Capacitor, and SSE streamed straight through.
- sasquatchBrain: call the proxy instead of OpenRouter directly; drop
  VITE_OPENROUTER_API_KEY entirely; PROXY_MODELS set to the slugs that actually
  work via the gateway (its ZDR policy blocks gemini/anthropic/gpt-4o-mini).
- netlify.toml: [functions] directory.
- Verified in production: POST https://skate-spots.org/api/ai -> 200.
2026-09-27 12:24:46 +07:00
SkateSpots
b8757eccd0 feat(directions): every Directions button now uses the in-app router
- MapPage: replace the external 'Open with' menu (Google/OSM/Waze) with the
  shared in-app RouteDialog, so the map spot sheet routes natively (incl. the
  Transitous transit mode).
- FoodPage: replace the Google Maps handoff with RouteDialog.
- RouteDialog: remove the opt-in external 'Open in' handoff and delete
  src/lib/directions.ts — no external map apps anywhere (product decision).
2026-09-27 11:25:56 +07:00
SkateSpots
cbd0287447 fix(sasquatch): cover the organizer + barber Lab roles
useCVMClient's findCoaches ROLE_KIND, self-tag set and getCommunityStats stopped
at 35012, so Sasquatch could not find the event-organizer (35013) or barber
(35014) profiles we added. Extend LabRole, ROLE_KIND, SELF_TAGS and the stats
queries/counts (lab_total) to cover all Lab kinds 35001-35014.
2026-09-27 11:12:14 +07:00
SkateSpots
d7bcc35d51 feat(sasquatch): stream the first-pass answer + drop dead Pollinations model
- sasquatchBrain: new streamSasquatchAI — OpenAI-style SSE via OpenRouter (if
  the key is set) then Pollinations; falls back to the non-streaming caller only
  when nothing at all was delivered, so blocked-stream environments still answer.
- SasquatchGuide: stream the first pass into a live bubble (raw JSON tool calls
  are suppressed so partial JSON never shows; prose-then-tool output is dropped),
  finalise the bubble in place, show a blinking cursor, and hide the generic
  'thinking...' bubble while a reply streams.
- Drop Pollinations 'mistral' (now a 404) leaving the live 'openai' slug.
2026-09-27 11:02:56 +07:00
SkateSpots
fdf33d2c45 feat(directions): public-transport mode (Transitous) + open-in-maps handoff
- routing.ts: add a 'transit' travel mode via Transitous (MOTIS 2 API, keyless,
  open CORS), with a local precision-7 polyline decoder and per-leg parsing.
  Road modes stay on keyless OSRM; moto remains ≈car and honestly labelled.
- RouteDialog: transit draws each leg (walk dashed grey, transit coloured by
  mode) and summarises depart→arrive, duration, transfers and line chips, with
  Transitous/OpenStreetMap attribution.
- directions.ts: opt-in 'Open in' handoff to Google / Apple / OpenStreetMap
  (+ Organic Maps via geo: on Android), mode-aware. In-app routing stays the
  default; the description notes transit shares the trip with Transitous.
- No scooter/moped profile (deliberate).
2026-09-27 10:52:26 +07:00
SkateSpots
18cc8218b2 fix(sasquatch): bound AI latency, separate system prompt, verify relay events
- sasquatchBrain: abort each provider request after 15s and give up the whole
  multi-provider attempt after 35s, so a hung OpenRouter/Pollinations call can no
  longer wedge the chat (it now falls through to the static/local fallbacks).
- SasquatchGuide: send the system prompt as a real system message (both the
  first pass and the tool-synthesis pass) instead of smuggling it into a user
  turn, and mark tool output as untrusted — blunts prompt injection via event
  content.
- useCVMClient: signature-verify (verifyEvent) every relay result, matching the
  standalone server and the release-notes claim.
- useCVMServerHealth: stale threshold 1h -> 45m to match the server heartbeat.
2026-09-27 10:32:07 +07:00
SkateSpots
a881a87f4d feat(lab): add Barber / Hairstylist profile (kind 35014)
A full Lab role, mirroring how Event Organizer was added:
- kinds: SKATE_BARBER_KIND 35014, self-tag 'skatebarber', BARBER_*_LABELS,
  SkateBarber + parseSkateBarber (studio, books_open).
- craft config at /become/barber (cyan accent, Sparkles icon), Lab tab
  'Barbers', filter sections (services/styles/setting), empty state, FAB item.
- Lab pipeline: useLabProfiles kind/tag/parser, LabPage ServiceType
  'barbering' + ACCENT/maps/counts, CoachDetailPage per-role maps + studio &
  books-open block, LabLinkField label, growth stats + Community Stats card.
- No crew-scene strip for barbers (LabCrewsStrip returns null).
- Documented in NIP.md; smoke test now boots /become/barber.
2026-09-27 10:15:30 +07:00
SkateSpots
b70b9c677c fix(mobile-nav): keep tab labels on one line so the row stays aligned
'Shops & Spots' wrapped to two lines and pushed its icon out of line with the
rest of the bar. Shorten nav.map to 'Spots' (en/es) and render every tab label
single-line (w-full truncate text-center), so long customised labels can't wrap
and misalign the bar either.
2026-09-27 10:15:27 +07:00
SkateSpots
19f9acd7ef feat(crews): expandable filter labels + context-aware empty states
- Add a More/Less toggle (remembered in localStorage) that expands the focus
  toggle group from short labels to full names, wrapping instead of scrolling.
- Empty states are now context-aware: a search miss offers 'Clear search'; an
  empty scene filter reads 'No <Focus> crews yet' with 'Show all crews' rather
  than wrongly claiming no crews exist; a genuinely empty board keeps the
  'Claim your local scene' recruiting loop and drops the no-op near-me CTA.
2026-09-27 10:04:21 +07:00
SkateSpots
6b925a4fc0 feat(crews): compact focus toggle group + mobile-only Near me
- Replace the eight wrapping filter chips with a single-row ToggleGroup
  (short labels; full names in tooltip/aria-label). Scrolls horizontally on
  mobile instead of wrapping to two rows.
- Near me is now mobile-only on the Crews page — it was clutter on desktop.
2026-09-27 09:56:14 +07:00
SkateSpots
f5ce4002e9 feat(lab,spots): validate contact details + drag & drop photo upload
- Lab forms: a non-empty email must be valid and website/portfolio must
  resolve to an https URL; invalid input shows an inline hint and blocks
  publish. Adds isValidEmail() (reused by sanitizeEmail). social/instagram
  are exempt since they may be bare @handles.
- New PhotoDropzone component: drag & drop plus multi-select image input,
  emitting File[] to the caller. Wired into CreateSpotPage + EditSpotPage
  (drop several photos at once) and AddPhotoToSpot, replacing the old
  one-file-at-a-time inputs.
- Spot photo uploads run sequentially and dedupe URLs.
2026-09-27 09:46:37 +07:00
SkateSpots
f73a360983 fix(lab): complete offer-a-service audit — data preservation, rendering, stats, input handling
- Preserve the geo 'g' tag and paid 'featured' tag when editing any Lab
  profile (all 7 craft roles via CraftProfileForm + 5 bespoke pages).
- Render previously collected-but-hidden fields on the Lab profile page:
  judge 'events_judged', organizer 'events_hosted', editor hardware/software,
  and spot references.
- Include Event Organizer (35013) in Community Stats + counts.
- Cap Title inputs at 100 (was 120) so profiles no longer fail the parser
  and silently vanish; MIN_TITLE_LEN 2 -> 1 for 1-char display names.
- Normalize bare-domain URLs (example.com) to https:// before storing instead
  of dropping/hiding them (new normalizeUrlInput helper; fixes extra url fields).
- No crew strip for judging/organizing (was wrongly showing Photo & Film);
  scrollable Offer Services FAB; live bio counters.
2026-09-27 09:38:03 +07:00
SkateSpots
4be5f64549 docs(lab): fix misleading 'listing fee' comment (it is the optional Pro upgrade) 2026-09-27 00:27:11 +07:00
SkateSpots
a4b561fa79 feat(lab): coach/filmer/editor/photographer/builder are editable + have a title field
Finishes the Lab audit: the 5 bespoke 'become' pages now prefill from your
existing profile (edit, not blind overwrite) and expose a 'Headline' field used
as the profile title. Also:
- submit button shows Update... when editing; success toast says updated
- removed the dead ExistingLabProfileNotice (the warning it gave is now moot)

Every one of the 12 Lab provider flows (5 bespoke + 7 craft) now edits + titles
consistently. Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-27 00:25:10 +07:00
SkateSpots
84d1ba426d feat(lab): craft profiles are now editable + add a title/headline field
Audit of the Lab 'offer a service' flows found two cross-cutting gaps:
1. Every Lab card showed the name TWICE (the card renders a 'title' line, but
   all forms wrote title = the kind-0 display name). The field is meant to be a
   self-described headline.
2. Only the Skater flow could be edited; the 7 craft roles started blank and
   silently overwrote the previous profile on submit.

Fixes (shared CraftProfileForm -> 7 roles: artist, graffiti, tattoo, trainer,
musician, judge, organizer):
- New useMyLabProfileEvent(kind, identifier) hook; the form prefills from your
  existing profile and becomes an EDIT (button: Update Profile; toast: updated).
- New 'Name / headline' field -> the  tag (defaults to your name).
- Lab ProfileCard only shows the title line when it differs from the name.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-27 00:17:05 +07:00
SkateSpots
b60b13dc18 feat(lab): add Event Organizer craft profile (kind 35013)
New data-driven Lab role 'Event Organizer / Host' — skaters who put on jams,
comps, meetups, demos and tour stops.
- types.ts: SKATE_ORGANIZER_KIND=35013, ORGANIZER_EVENT/SERVICE/SCALE label
  maps, SkateOrganizer + parseSkateOrganizer (events_hosted extra tag)
- becomeRoles: craft config (teal accent, Tent icon, 3 skill sections)
- useLabProfiles: registered kind/self-tag/parser
- LabPage: Organizers tab, accent, filter drawer section, empty state, Offer
  link, counts
- CoachDetailPage (generic Lab detail): accent/icon/kind/labels/noun
- LabCrewsStrip: union updated
- NIP.md documents kind 35013; smoke covers /lab and /become/organizer

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-27 00:02:39 +07:00
SkateSpots
e1deec21bd fix(map): hide the 'You are here' pin on desktop
Location is a mobile-only affordance now (the desktop Near Me button was removed),
so stop seeding the cached location on desktop and don't pass userLocation to the
map on desktop. Result: no 'You are here' badge and no proximity sort on desktop;
mobile is unchanged.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:49:34 +07:00
SkateSpots
9e8f8b87de refactor(settings): remove the Crew Network (nostr-vpn) screen
Poor fit for a skate app: it only funneled users to install a third-party VPN
app (QR join request + download links + platform detection) with zero in-app
function. Removed the sub-screen, its Settings menu row, the section enum entry,
the render switch, the nostr-vpn constants, and the now-unused imports.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:45:28 +07:00
SkateSpots
020fc11163 feat(settings,map,docs): polish Relays UI, drop desktop Near Me, remove NoGood Radio
- Settings -> Relays: rewritten with sectioned cards (NIP-65 / DM inbox / Local /
  Blocked), inline clickable Read/Write chips (no more Edit popover), tone-coded
  sections with icons + counts + help text, cleaner add rows and empty state.
- Map: removed the desktop 'Near Me' button (mobile floating control remains).
- Removed NoGood Radio everywhere: deleted NOGOOD_RADIO_PROPOSAL.md and
  RADIO_PLAYER_GUIDE.md, dropped the README feature line, scrubbed 'DJ NoGood'
  examples from LIVE_MUSIC_PROPOSAL.md.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:37:32 +07:00
SkateSpots
b2a6108eeb refactor: split non-component exports (react-refresh) into their own modules
Removes the last react-refresh disables by splitting exports instead:
- contexts/language.ts: Language/Translations types + LanguageContext +
  useTranslation/useT (LanguageProvider stays a component-only file); 12
  importers repointed.
- lib/sortOptions.ts: calculatePopularity + calculateDistance (out of SortDropdown)
- lib/trustFilter.ts: TrustFilterLevel + filterByTrustLevel (out of TrustFilter)
- EventCountdown: deleted the unused useEventStatus hook
- NewsletterPage: dropped the NEWSLETTER_ISSUES re-export shim
- AGENTS.md updated (lint stays clean with no blanket disables)

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:29:42 +07:00
SkateSpots
584561e390 feat(trades): trusted-trader recommendations (31873) + offer DM nudges
- TradeRecommendationsCard on profiles: shows who recommends this trader
  (kind 31873 'Attestor Recommendation') and lets a logged-in viewer recommend
  them. New useTraderRecommendations hook.
- OfferDialog sends a best-effort NIP-17 DM to the seller when an offer is made;
  ListingOffersPanel DMs the buyer when the seller accepts/declines.
- AGENTS.md notes 31873.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:24:23 +07:00
SkateSpots
7307b00edc feat(offers): structured marketplace offers (kind 30403) with accept/decline
Promotes 'Offer' from a prefilled DM to a real, queryable event:
- Buyer publishes a 30403 offer (d/a/p + optional amount + message)
- Seller sees incoming offers on the listing detail page and can Accept/Decline
  (publishes a 30403 response referencing the offer)
- Buyer sees their offer's status and can Withdraw it
- New: useMarketplaceOffers (parse + hooks), OfferDialog, ListingOffersPanel
- NIP.md documents kind 30403; AGENTS.md updated

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:17:51 +07:00
SkateSpots
2e29f12a84 perf(lists): lazy-mount cards on the Skaters, Lab, Crews and Events grids
Extends LazyMount beyond the marketplace so these long card grids no longer
mount every card (and its hooks/DOM) up front. Cards keep equal height via
h-full wrappers.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:13:37 +07:00
SkateSpots
662a14bcd2 perf(marketplace): lazy-mount grid cards + open the detail page on click
- LazyMount: mount a grid card only when it scrolls within 600px (skeleton-space
  placeholder otherwise), so a long feed no longer mounts every card + its
  hooks/DOM up front. Falls back to immediate render without IntersectionObserver.
- GearSwapItemCard: clicking the card now opens /listing/:pubkey/:identifier
  (was opening a DM). 'Contact Seller' still opens the DM. Card set to h-full so
  the lazy wrapper keeps the equal-height grid.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:08:01 +07:00
SkateSpots
269d5d6c09 feat(listing): make an offer (prefilled DM) + share listing
On the listing detail page, buyers get 'Make an offer' (opens the DM with the
seller pre-filled with an offer message about the listing) and 'Share listing'
(copies the URL). Replaces the redundant 'Message' button (Contact Seller is
already in the shared trade actions).

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:03:06 +07:00
SkateSpots
e0e41ae625 feat(trades): revoke/dispute a trade record (s=revoked)
On /trades, each authored trade record gets a 'Revoke' action (with a confirm
dialog) that republishes the 31871 with s=revoked — retracting it from
reputation for a disputed or mistaken deal. The counterparty keeps their own
attestation. Revoked records are already excluded from aggregation.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 23:00:10 +07:00
SkateSpots
5b163c632f feat(trades): My Trades page + block-filter DM relays
- New '/trades' page: action-needed (incoming 'confirm this sale' requests as
  seller, pending confirmations as buyer) with inline actions, a reputation
  card, and the user's own trade records (role/counterparty/state) linking to
  each listing. Nav entry (auth-only) + smoke route.
- DM inbox relays (kind 10050) are now block-filtered on both read and delivery
  (closes the last relay caveat).
- AGENTS.md updated.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 22:56:52 +07:00
SkateSpots
78f643d7a9 feat(mints,escrow): MintRadar polish + escrow status in shared actions
- Mints page: 'Online only' toggle + result count ('N of M mints')
- Wallet page: 'Browse Cashu mints' link to /mints
- ListingTradeActions now renders the EscrowPanel for a viewer's active escrow,
  so escrow status shows on both the grid card and the detail page
- AGENTS.md project map: marketplace detail page + shared actions

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 22:39:58 +07:00
SkateSpots
21a0390ed5 refactor(relays): route every relay consumer through useRelays()
So Local relays are used and Blocked relays are excluded EVERYWHERE, not just in
the NPool routers:
- useListingReports, useSellerInfo, useSkateRelays (read+write), useZaps
  (NIP-57 relays tag), usePurchasePro, CollectionDetailPage, CrewForm,
  useDirectMessages write fallback
- AppProvider's empty-relays defaults check is the only intentional direct reader
- AGENTS.md updated

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 22:36:14 +07:00
SkateSpots
5d1a0e0fb0 feat(marketplace): listing detail page + shared trade/escrow actions
- New '/listing/:pubkey/:identifier' page: large gallery + lightbox, full
  description, details, seller trust + trade reputation card, and full actions
  (shared) for buyers and owners (mark sold/relist/delete)
- Extract ListingTradeActions (confirm/escrow request/ask-to-confirm/refund/
  contact + escrow dialog) and use it in BOTH the grid card and the detail page
  so there is one implementation
- useMarketplaceItem(pubkey, identifier) fetch hook
- Cards link their image + title to the detail page
- Smoke test covers /listing/:pubkey/:identifier

Gate: 101 tests, 0 errors/warnings, boot smoke green.
2026-09-26 22:30:43 +07:00
SkateSpots
c0aec3d912 chore(lint): reach zero warnings (react-refresh)
The last 7 react-refresh warnings are dev-only HMR DX from intentional
component+hook/helper colocations (EventCountdown, SortDropdown, TrustFilter,
LanguageContext, NewsletterPage). Splitting them into new files is broad import
churn for no runtime benefit, so each carries a justified file-level disable.

npm test: 0 errors, 0 warnings, 101 tests; boot smoke green.
2026-09-26 22:23:45 +07:00
SkateSpots
6def78fffe chore(lint): clear all no-unused-vars warnings (position-precise)
Removed/renamed the remaining 39 unused locals/dead code, file-by-file:
- unused destructured bindings/params (remove the binding, keep the call)
- unused single-line pure consts and dead functions/interfaces removed
- deleted the dead _FeedItem component and its now-unused imports
- amber _readChunks self-call fixed; SpotMap popup call site updated

Warnings: 182 -> 7 (only react-refresh/only-export-components remain).
no-explicit-any=0, exhaustive-deps=0, no-unused-vars=0.
Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline + guidance updated.
2026-09-26 22:20:21 +07:00
SkateSpots
721a5d4b67 chore(lint): clear all react-hooks/exhaustive-deps warnings
Triage per file (not a blanket sweep):
- Memoise shared getters with useCallback and depend on them:
  EventsPage/LabPage/MapPage getTrustScore+getCommentCount (+MapPage getZapTotal)
- Memoise derived values that were recreated each render: CrewChat 'messages',
  RiderCardPage 'cardData'
- Justified eslint-disable for intentional mount-only effects with a comment:
  AppProvider (seed defaults/theme), SpotMap (Leaflet init once),
  SpamFeePayment (auto-pay keyed to invoice/state, not the recreated handler)
- EventCountdown: keep the intentional 'tick' recompute dep (documented disable)

exhaustive-deps: 14 -> 0. Warnings 60 -> 46 (39 unused-vars, 7 react-refresh).
Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline updated.
2026-09-26 22:09:43 +07:00
SkateSpots
ab87b8ab83 chore(lint): remove safely-detectable unused locals
Position-based cleanup (no name collisions) of unused catch bindings and unused
single-line pure consts across 12 files. Automated destructure/prop removals
were proven unsafe (shadowed names + typed shorthand), so those remain.

Warning backlog: 76 -> 60 (39 no-unused-vars, 14 exhaustive-deps, 7 react-refresh).
Gate green: 101 tests, 0 errors, boot smoke passing.
2026-09-26 21:58:03 +07:00
SkateSpots
d1f8870379 chore(lint): remove all explicit any + sweep dead imports
- Fix all 12 no-explicit-any (NPool, NostrEvent[], LabProfile, structural
  Cluster type, explicit SkateSpot cast) -> 0 remaining
- Remove unused imports/vars across 48 files (manual + a verified codemod for
  unused import specifiers only); tsc + tests + boot smoke all green
- Warning backlog: 182 -> 76 (remaining: ~55 no-unused-vars locals,
  14 react-hooks/exhaustive-deps, 7 react-refresh)
- AGENTS.md baseline updated
2026-09-26 21:40:42 +07:00
SkateSpots
30ef511e78 feat(relays): NIP-17 DM inbox relays (kind 10050)
- RelayMetadata.dmRelays; Relays UI to add/remove, publishing kind 10050
- NostrSync fetches the user's 10050 on login (alongside 10002)
- DMs deliver gift wraps to recipients' + own DM relays (fallback: write relays)
  and read from own DM relays + NIP-65 read relays
- useDmRelays() to look up others' DM relay lists

Also fixes a latent data-loss bug: NostrSync's 10002 fetch replaced
relayMetadata wholesale, which would wipe the new local/blocked/dm lists on
login — it now merges. 'Trusted' category intentionally skipped (no distinct
behaviour -> no empty UI); documented in AGENTS.md.
2026-09-26 21:32:53 +07:00
SkateSpots
e75c4a8355 feat(relays): Local + Blocked relay categories
Add a purposeful subset of Amethyst-style relay categories (not a full clone):
- App-local 'Local relays' (never published; added to reads+writes)
- App-local 'Blocked relays' (excluded from every read/write)
- src/lib/relays.ts: resolveRelays()/useRelays() + URL normalisation
- NostrProvider req/event routers go through resolveRelays (blocklist applied)
- RelayListManager: Local/Blocked editors; NIP-65 save now preserves the new
  lists instead of overwriting relayMetadata

Deliberately omitted (documented in AGENTS.md): KeyPackage (10051, no MLS
consumer) and proxy/broadcast/private-home (no such discovery model here).
Follow-up noted: a few hooks still read config.relayMetadata.relays directly.
2026-09-26 21:26:13 +07:00
SkateSpots
a1c30cad4e feat(mints): Cashu mint directory (MintRadar) + same-origin proxy
New '/mints' page: browse Community MintRadar mints with health (online/latency/
degraded/archived), NUT count, terms, and add-to-wallet on native.

MintRadar has no CORS, so it's fetched same-origin at /api/mints/*:
- prod: Netlify proxy redirect in public/_redirects (+ 10m cache in _headers)
- dev: Vite server.proxy in vite.config.ts
This also hides users' IPs from MintRadar. Data is unofficial/rate-limited;
the page degrades to a friendly error state. Nav entry + smoke route added.
2026-09-26 21:20:19 +07:00
SkateSpots
c37e21e860 feat(marketplace): buyer escrow refund after locktime
Completes the escrow safety loop: a buyer whose escrow token is past its
locktime can reclaim it from the listing card ('Refund escrow'), which redeems
the P2PK token with their refund key. Native-only, same as the rest of escrow.
2026-09-26 21:10:59 +07:00
SkateSpots
15901976ef feat(marketplace): wire Cashu escrow into the trade flow (native)
Completes the escrow feature on top of the tested core (src/lib/cashuEscrow.ts):
- useCashuEscrow: native-only hook wiring createEscrow (cashu-ts mint + NWC/WebLN
  funding), redeemEscrow, escrow-key derivation (coco keyring) and profile publish
- EscrowKeyCard (Wallet): advertise the kind-0 'cashu_escrow' pubkey
- EscrowRequestDialog (listing card): buyer locks funds (P2PK to seller, refundable
  after N days) and sends the 31872 carrying the cashu_token
- EscrowPanel + 'Redeem escrow' in the DM request banner (seller side); the panel
  is offline/parse-based so it renders on web too
- Buyer 'Escrow & request' supersedes the plain request when available

EXPERIMENTAL: mint-touching paths are unverified on a device (native-only wallet);
use small amounts first. Gate: 101 tests, 0 errors.
2026-09-26 21:08:36 +07:00
SkateSpots
26e951710e feat(marketplace): Cashu escrow core (time-locked P2PK) + design
Real, oracle-free escrow built on Cashu P2PK with a locktime + refund key:
buyer locks funds to the seller; seller redeems on delivery; the buyer can
reclaim after the deadline. Locked (not plain) tokens are what make the
cashu_token on a public 31872 safe.

- src/lib/cashuEscrow.ts: pubkey normalisation (x-only -> compressed), P2PK
  policy builder, offline token inspection (amount/mint/locktime/refund), and
  an injectable createEscrowToken mint flow (quote -> pay -> mintProofs{p2pk})
- 15 unit tests incl. the mint orchestration against a fake wallet
- Protocol: advertise a 'cashu_escrow' pubkey in kind 0 (NIP.md); token rides
  the existing 31872 cashu_token tag
- ESCROW.md: design, threat model, status, limitations
- AGENTS.md: project map + docs index updated

Wallet UI (cashu-ts + payment rail + profile publish) is native-only and still
needs on-device verification.
2026-09-26 21:01:31 +07:00
SkateSpots
b91df1b4b0 feat(marketplace): trade confirmation requests (kind 31872) + fix boot smoke test
Marketplace:
- Buyer can 'Ask seller to confirm' a sold listing -> publishes a 31872
  Attestation Request (d/a/p), which the seller fulfils in one tap
- Seller sees incoming requests in the DM thread ('Confirm this sale?') and the
  fulfilling 31871 carries the spec's 'request' tag
- useTradeAttestationRequest / useIncomingTradeRequests / useListingTradeRequest
- NIP.md documents 31872 and states escrow is intentionally NOT implemented
  (coco-cashu has no P2PK/HTLC locking, wallet is native-only, and release-on-
  attestation needs a witness) so nothing looks trustless that isn't

Hardening:
- scripts/smoke-test.mjs navigated to /settings etc. WITHOUT a hash, so with
  HashRouter it only ever loaded the homepage and reported PASS. It now puts the
  route in the hash, covers settings/stash/collections/stats/marketplace, and
  fails on the 'This page slammed' error boundary (not just boot failure)
2026-09-26 20:47:29 +07:00
SkateSpots
4e17852df3 fix(routes): self-healing lazy routes; document dev dep-cache crash
Symptom: in the long-running dev server some lazy routes (/settings, /stash,
/collections, /stats) hit the route error boundary ('This page slammed') while
others worked. Console: 'Failed to fetch dynamically imported module'; network:
504 for node_modules/.vite/deps/*.js. Vite's dep-optimizer cache had gone
missing after the server re-optimized on a lockfile change, leaving stale ?v=
hashes.

- Add lazyWithRetry: on a chunk-load failure, retry once, then do a single
  full reload per session (avoids reload loops); clears the flag on success
- Use it for all 53 lazy routes in AppRouter
- Document the dev dep-cache gotcha + fix in AGENTS.md
2026-09-26 20:36:14 +07:00
SkateSpots
1577ddc86f feat(marketplace): trust-weight trade reputation + in-thread trade confirmation
- Reputation now exposes per-counterparty verified counts; the profile card
  trust-weights each confirmed trade by the counterparty's web-of-trust score
  (fresh-account rings can't inflate it) and labels Highly trusted / Building /
  New trader
- usePendingTradeConfirmations finds sales a seller recorded that name you but
  you haven't mirrored; the 1:1 DM thread shows a 'Did you complete this trade?'
  banner with one-tap Confirm
2026-09-26 20:22:37 +07:00
SkateSpots
883338f060 feat(marketplace): mutual trade attestations + reputation (kind 31871)
Built on the Attestations NIP (31871); a listing is the Assertion Event:
- Seller 'Mark as sold' now opens a dialog (buyer npub + self-reported method +
  optional amount) that marks the listing sold AND publishes a trade attestation
- Buyer sees 'Confirm this trade' on the listing and publishes the mirror
  attestation -> mutual pair = a verified trade
- useTradeReputation aggregates sales/purchases/verified + counterparties
- Profile 'Trades' card; seller 'N verified trades' badge on listing cards
- NIP.md documents kind 31871 usage + our p/method/amount extensions
Payment method is self-reported metadata, never proof of payment.
2026-09-26 20:17:38 +07:00
SkateSpots
78324605c1 feat(wallet): Lightning wallet over NWC (balance, receive, send)
The Wallet page was Cashu-only and said 'App only' on web, so anyone with an
NWC wallet connected saw nothing. Adds:
- useNWC.getBalance() (millisats -> sats) and makeInvoice() via NWCClient
- NwcWalletCard: live balance + refresh, Receive (create invoice, QR, copy),
  Send (paste invoice, decoded amount shown, pay via the connected wallet)
Renders only when an NWC connection is active.
2026-09-26 20:01:59 +07:00
SkateSpots
b1540f0608 docs: group messaging — what ships (NIP-17) and the upgrade paths (Marmot/Concord/Cordn) 2026-09-26 19:56:58 +07:00
SkateSpots
adc3a20094 feat(dm): create group DMs from the New Message picker
The people picker is now multi-select: tap to toggle (checkmark), then
'Start group (n)' opens the encrypted group thread, or 'Message' for one person.
Selecting one person still behaves exactly as before.
2026-09-26 19:55:03 +07:00
SkateSpots
2a47e5c9c5 feat(dm): group DMs (NIP-17) — inbox list + group thread
Uses the existing gift-wrap primitive (kind 14 rumor, one p-tag per recipient):
- useGroupConversations: lists multi-recipient threads, bucketed by the sorted
  participant key; the inbox shows a Groups section
- GroupConversationPage (/messages/group/:participants): encrypted group thread
  with sender names, send + error feedback
- smoke test now boots the new route

Group creation UI (multi-select in NewMessageDialog) is the next step. For
stronger guarantees (forward secrecy, rosters) Marmot/MLS or Cordn would be the
protocol to adopt later; noted in the hook.
2026-09-26 19:52:33 +07:00
SkateSpots
bcd99994a9 test: cover zap helpers and newsletter data
- useZapTotals: zapTargetKey (regular id vs addressable coord) and
  zapReceiptSats (amount tag -> bolt11 -> description JSON -> 0, malformed safe)
- newsletterIssues: schema, newest-first ordering, publish hashtags
2026-09-26 19:45:48 +07:00
SkateSpots
81eeba7797 perf(bundle): lazy on-demand modals in Layout; skip the Cashu chunk without a wallet
- BackupReminder, CustomizeNavSheet, SetStatusDialog and LoginDialog are only
  needed after the user acts -> React.lazy (Layout chunk 511 KB -> 486 KB)
- The Cashu balance widget now only mounts when a wallet likely exists (web:
  localStorage hint; native: always, since the seed is in the KeyStore), so
  wallet-less visitors never download the 245 KB Cashu chunk
2026-09-26 19:41:35 +07:00
SkateSpots
8cf35cf7db perf(bundle): entry chunk 1835 KB -> 724 KB
Data-driven pass (adds rollup-plugin-visualizer behind ANALYZE=1):
- MapPage was statically imported by the router, dragging the map route AND the
  IndexedDB offline-cache layer into the entry -> now lazy()
- NewsletterOverlay imported NEWSLETTER_ISSUES from the whole NewsletterPage
  module -> types + data moved to lib/newsletterIssues.ts
- SasquatchGuide (assistant overlay) lazy-loaded in Layout
- Cashu stack is now a separate chunk (useCashu), off the critical path

Net: entry down ~61%; first paint no longer parses the map, the newsletter
page, the assistant or the wallet stack.
2026-09-26 19:37:11 +07:00
SkateSpots
e6f1fe2673 perf(bundle): lazy-load the Cashu balance; load @getalby/sdk on demand
- Layout (app shell) imported useCashu, pulling the Cashu stack into the entry
  chunk every visitor downloads. The three balance widgets now live in
  components/wallet/CashuBalance.tsx and load via React.lazy.
- useNWC statically imported @getalby/sdk; it is now a type-only import plus a
  dynamic import at call sites, so the SDK only loads when NWC is used.
Entry chunk: 1879 KB -> 1835 KB.
2026-09-26 19:31:01 +07:00
SkateSpots
fb5ebb5cd4 chore(dm): remove the unused useGroupConversations hook
It was exported but referenced nowhere (only in a comment); the 1:1 inbox never
surfaced group chats. Comment updated accordingly.
2026-09-26 19:25:40 +07:00
SkateSpots
2edf7a797e feat(ui): adopt the shared PageHeader on Wallet, CreateListing, GearSwap, Messages
PageHeader now carries the app's neon glow (accent-tinted text-shadow), so the
unified header keeps the brand look rather than flattening it. Four more plain
headers migrate (title + accent + optional subtitle/actions).
2026-09-26 19:23:01 +07:00
SkateSpots
4d2c29edb0 feat(ui): PageHeader/PageContainer foundation (piloted on Contacts); profile info-first on mobile
- New shared PageHeader + PageContainer (width tokens: narrow/list/detail/wide)
  to end the max-w drift between sections; piloted on ContactsPage
- Profile: the identity/contact sidebar now comes first on mobile (order-first /
  lg:order-none) instead of being buried under every content section
2026-09-26 19:17:55 +07:00
SkateSpots
10ab45383d chore(ridercard): delete the unused AwardBadgeDialog
showAwardDialog was never set true, so the dialog could never open; the inline
AwardBadgeInline form is the live flow. Removes ~142 lines plus its now-unused
Dialog imports, state and helper.
2026-09-26 19:14:21 +07:00
SkateSpots
bf7fdbd621 fix(ux): calendar 'Past' opens on the newest month with events
The calendar always initialised to the current month, so the Past tab (all
events in the past) opened on an empty month. It now anchors to the newest
past event, and re-anchors when the tab changes.
2026-09-26 19:09:21 +07:00
SkateSpots
0aee937880 fix(a11y): remove duplicate profile status dot; raise low-contrast text
- Profile showed the live status indicator twice (avatar + name row) -> once
- Stash/Wallet informational text bumped from gray-600/700 to gray-400/500 for
  readable contrast on the near-black background
2026-09-26 19:05:35 +07:00
SkateSpots
c0ee70b8bb fix(ux): map empty state; marketplace-aware CreateListing return
- Map view now shows a 'No spots match / Clear filters' overlay when the active
  filters hide everything (it previously looked blank/unloaded)
- 'List Gear' passes returnTo=/marketplace, so the back link and the post-publish
  redirect return to Marketplace instead of dumping users on the map
2026-09-26 19:03:29 +07:00
SkateSpots
18d2af6366 fix(ux): remove dead marketplace payment dialog; show live sats estimate
The '$40k/BTC' era confirm-payment dialog was unreachable after the Pay button
was removed — deleted it, its handler and state (and the now-unused useCashu
import). In its place, listings show '≈ N sats' under the price using the live
rate, so buyers still see the sat amount.
2026-09-26 18:59:47 +07:00
SkateSpots
760bc9b82c fix(ux): Batch F part 3 — bounty ranks, Lab tab grid, contacts search
- Bounty leaderboards no longer slice to top 50 before computing the current
  user's rank (anyone outside the top 50 never saw their position), and each row
  no longer prints the count twice
- Lab craft tabs: 4-up grid (11 tabs were laid out 5-across, leaving a lone
  'Judges' orphan row); stale comment corrected
- Contacts search placeholder now says it filters by npub (it never matched the
  displayed names)
2026-09-26 18:54:55 +07:00
SkateSpots
a464913d6f fix(ux): Batch F part 2 — shop gallery lightbox; real FAQ content
- ShopDetailPage gallery reuses ImageLightbox: photos are now zoomable and the
  whole set is reachable (it showed at most 5 and clicks did nothing)
- FAQPage gains static quick answers so the page is useful even if The
  Sasquatch guide fails to open (it previously had zero intrinsic content)
2026-09-26 18:52:05 +07:00
SkateSpots
617c6cee82 fix(ux): Batch F part 1 — false 'no spots' while loading; calendar filter leak
- SpotPicker showed 'No spots found' during the initial load (also affected
  CreateListing); now shows a loading state first
- Events: switching to the calendar view clears the date-window filter, which
  only has chips in list view — it was silently hiding calendar events
2026-09-26 18:49:14 +07:00
SkateSpots
09e1612db7 fix(a11y): Batch E part 1 — labels, valid nesting, touch targets
- EmptyState: render <Link> as the button (a <button> inside <Link> was invalid
  and double-focusable); AboutPage <a href> -> <Link> (no full reload)
- aria-label on icon-only controls: settings Back, conversation Refresh/Send,
  crew chat Send
- Crew invite Accept/Decline raised to 44px touch targets
2026-09-26 18:46:51 +07:00
SkateSpots
0468ed1c1b fix(ux): Batch D part 2 — Marketplace in the nav; Clink fallback copy
- Add Marketplace to the More menu (it had no primary-nav entry)
- CLINK: explain the copy/QR fallback when no CLINK-compatible wallet handles
  the deep link, so 'Open in CLINK wallet' is not a silent no-op
2026-09-26 18:05:15 +07:00
SkateSpots
102d6017bc fix(marketplace): live BTC/USD sats estimate; drop dead Pay button
- Sats prices used a hardcoded $1 = 2500 sats (i.e. $40k/BTC), so buyers were
  quoted wrong amounts; now uses the live useUsdToSats rate, handles sats/BTC
  directly, and shows no estimate for unsupported fiat
- Removed the 'Pay with Cashu' button, which only ever showed a 'coming soon'
  toast — a dead affordance. Buyers use Contact Seller.
2026-09-26 18:02:24 +07:00
SkateSpots
dca0c0ede1 fix(ux): Batch C part 1 — Near Me timing & reachability
- geolocation: cap the overall deadline at 15s (was 25s) and shorten the native
  plugin attempt to 8s, so a failed fix no longer hangs ~25-40s
- MapPage: only warm the location cache when permission is ALREADY granted, so
  opening /map no longer fires the browser's 'Allow location?' prompt
- MapPage: add a desktop Near Me control (it was mobile-only)
- MapPage: on native, a denial is now retryable instead of permanently opening
  the browser-only help sheet
2026-09-26 17:58:38 +07:00
SkateSpots
8e35859188 fix(ux): Batch B — errors no longer masquerade as empty/404
Adds a shared ErrorState/PageError with a retry action. Detail pages (spot,
shop, collection, coach, crew, event) now show an honest failure + Try again
instead of '404 Not Found' — a relay hiccup previously implied the content was
deleted. The zap/bounty leaderboards surface errors instead of 'no content yet',
and the DM inbox gains a retry button.
2026-09-26 17:51:30 +07:00
SkateSpots
cbd12f3e52 fix(ux): Batch A part 2 — stash, crew invites, map marker
- Stash: remove the handler-less 'Share via DM' button; action overlay stays
  visible on touch (was hover-only, so edit/delete were unreachable on mobile)
- Crew invite accept no longer claims membership is confirmed (only the founder
  ratifies the roster) — both the toast and the accepted state now say so
- Crew invite share message no longer promises a one-click join
- Map marker: don't bind the Leaflet popup when a host supplies onSpotClick
  (MapPage's bottom sheet) — both used to appear at once
2026-09-26 17:47:38 +07:00
SkateSpots
f4192e2f3d fix(ux): Batch A part 1 — dead flows
- SpotDetailPage: 'Claim This Shop' was a no-op button; wire to NIP-05 settings
  (and stop the full-page <a href> reload)
- TourDetailPage: 'Contact Guide to Book' used /messages?to= which nothing read,
  so booking silently opened an empty inbox -> /messages/:npub
- ContactsPage: row 'Message' went to the generic inbox -> the contact's thread
- SettingsPage: ?section=follows|language|stash rendered a blank dead end;
  unknown sections now fall back to the menu
- DirectMessages/Conversation: logged-out states were dead ends -> add LoginArea
- Conversation: failed sends were silent -> error toast
2026-09-26 17:43:19 +07:00
SkateSpots
d24e963fd9 fix(boot): auto-recover from stale chunks after a deploy
Deploys replace hashed chunk filenames; a tab that was already open can load a
chunk that no longer exists (404) and hit the error boundary ('THIS PAGE
SLAMMED'). Listen for Vite's vite:preloadError (and dynamic-import failures) and
reload once, guarded against loops.
2026-09-26 17:33:20 +07:00
SkateSpots
b6c33c8a9b feat(events): crowdfunding funding goals via zaps
CreateEventPage gains an optional 'Funding Goal (sats)' field that publishes a
'goal' tag. EventDetailPage shows a FundingGoalCard with live raised/target
progress, backer count and a Fund button — funded peer-to-peer via NIP-57 zaps
(no escrow).
2026-09-26 17:24:42 +07:00
SkateSpots
38c99c1777 feat(profile): zap badges (achievements) card
Derives First Zap / Regular / Generous / Patron / Top 10 from the batched
30-day supporter leaderboard and shows earned vs locked badges in the profile
sidebar. Client-side recognition only — publishes no events.
2026-09-26 17:22:06 +07:00
SkateSpots
046627b1dd feat(home): 'Top Zapped' section linking to the leaderboard
Shows the four most-zapped spots (last 30 days) on the home page, with their
sats totals, linking through to /leaderboards. Renders nothing when there is
no zap activity or no linkable spot, so the section never appears empty.
2026-09-26 17:19:55 +07:00
SkateSpots
6ae4e674dc feat(leaderboards): zap leaderboards page (top spots + top supporters)
New /leaderboards route (linked from the More menu): aggregates kind-9735
receipts into top-zapped spots and top supporters, with week/month/all-time
tabs. Reuses the batched zap helpers. Also hardens the deploy smoke test to
boot lazy routes (/, /leaderboards, /bounties), since route chunks can fail
independently.
2026-09-26 17:17:21 +07:00
SkateSpots
83a3e8ae03 feat(map): make 'Most Zapped' sort actually work + batched zap totals
SortDropdown offered 'Most Zapped' but MapPage's sort switch had no zaps case,
so picking it silently fell back to newest-first. Adds a batched useZapTotals
hook (ONE receipt query for every location) and implements the sort.
2026-09-26 17:12:14 +07:00
SkateSpots
6dc619ee91 feat(zaps): show zap totals to everyone, not just logged-in users
ZapButton already fetched zap receipts for every visitor but returned null
unless you were logged in, so visitors saw no social proof. Now it renders a
read-only '⚡ <total>' whenever there are zaps (with a zap-count tooltip) and
only requires login to OPEN the zap dialog. No extra network cost — the query
already ran unconditionally.
2026-09-26 17:06:25 +07:00
SkateSpots
8d8a46a73f feat(spot): full-screen photo lightbox for spot galleries
Gallery images were inert (click did nothing) and only the first five were
reachable. Adds ImageLightbox: click any photo to view full size, arrow-key /
button navigation across the whole gallery, position counter, caption and
community-photo attribution. Extra images show a +N overlay.
2026-09-26 17:03:30 +07:00
SkateSpots
b8bf8c8534 fix(mobile): base grid column on remaining card grids + skeleton min-w-0 consistency 2026-09-26 15:24:09 +07:00
Igor Bubelov
571257e5c3 fix(mobile): stop detail-page grids from overflowing horizontally
The spot page (and the other detail pages) used `grid lg:grid-cols-3` with
no explicit mobile column. Below the `lg` breakpoint the grid falls back to
a single implicit `auto` track, whose minimum is the min-content of its
items. A long mapper name ("Tip the Mapper" card) gave the sidebar a
~559px min-content, so the whole grid widened past the viewport and was
clipped by the global `html, body { overflow-x: hidden }` — content cut off
on the right with no way to scroll to it.

Fixes:
- Add `grid-cols-1` to detail-page grids so the mobile track is
  `minmax(0, 1fr)` and can never exceed the container. Applied to Spot,
  Shop, Event, NIP-19, Crew and Coach detail pages.
- Add `min-w-0` to their sidebar/main columns so long content can shrink
  and truncate instead of forcing the track wider.
- Make the Spot Chat header wrap (`flex-wrap` + `gap-y`) so the trust
  filter and channel badge no longer get clipped on narrow phones.
2026-09-26 15:13:59 +07:00
shakespeare.diy
800d9492ab feat: cross-device read-state + preference sync via encrypted NIP-78
Adopt PosterChan's "encrypted events are the datastore" idea for two
per-browser bits of state, so they follow the user's npub across devices.

New shared engine (src/hooks/useSyncedAppData.ts):
- Stores one encrypted kind-30078 (NIP-78) event per d-tag, NIP-44
  encrypt-to-self, modeled on the proven useStash pattern.
- Clobber-guard: re-fetch + re-decrypt the freshest remote copy and
  MERGE the local patch in before publishing (never overwrites a field
  another device just set).
- Debounced publisher (coalesces rapid writes, flushes on pagehide/
  beforeunload) so frequent read-marker changes don't spam relays.
- No-op for anon / non-NIP-44 signers.

Read-state sync (src/hooks/useReadState.ts), d-tag skatespots:read-state:
- Unifies the four previously localStorage-only markers (notification
  bell, DMs, crew chat, newsletter) into one synced blob.
- Merge = field-wise MAX (markers only move forward → commutative, safe).
- The four legacy hooks now re-export drop-in replacements with identical
  signatures, so NO consumer component changed. localStorage stays as the
  instant/offline/anon fast path; the relay copy hydrates and max-merges.
- Fixes the real bug where clearing a badge on one device left it lit on
  another.

Preference sync (src/hooks/usePreferences.ts), d-tag skatespots:preferences:
- Mobile + desktop nav layouts now sync (language field wired for later).
- Merge = last-write-wins by updatedAt (a pref replaces, not accumulates).
- useNavPreferences / useDesktopNavPreferences adopt a synced layout when
  present and mirror changes; public APIs unchanged, localStorage fallback
  intact.

Docs: NIP.md gains a "Cross-device app data (kind 30078, encrypted)"
section documenting both d-tags, their content shapes, merge policies, and
the clobber-guard/debounce/anon safety notes.

Build + typecheck pass.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-25 09:49:47 +07:00
shakespeare.diy
5c3c2f6589 test: fix incorrect bolt11 fixtures (210n = 21 sats, not 2100n)
The bolt11 amount decoder is correct — the three failing tests used the
wrong invoice fixture. Per BOLT11, `lnbc2100n` = 2100 x 1e-9 BTC x 1e8
sats = 210 sats, not 21. A 21-sat invoice is `lnbc210n`. The passing
`lnbc1000n = 100 sats` case confirms the decoder's math.

Corrected the three cases in bolt11.test.ts and verifyInvoice.test.ts
to use `lnbc210n` (21 sats) so they exercise the intended 21-sat / 1-sat
slack scenarios against the correct implementation.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 20:12:20 +07:00
shakespeare.diy
ef86d858c2 fix: make relay-probe timers and effect hooks lint-clean across ESLint versions
- Replace single-assignment `let timer` with a `timers[]` array in
  citrine.ts and relayProbe.ts so stricter prefer-const builds pass
  (the newer ESLint pulled in by `npm audit fix` flagged the let as
  never-reassigned).
- Drop two now-redundant `eslint-disable react-hooks/exhaustive-deps`
  directives in NostrProvider and MapPage. Newer react-hooks no longer
  reports those lines, so the directives were flagged as unused errors
  under reportUnusedDisableDirectives; removing them is safe in both
  old (warning only) and new (silent) ESLint.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:55:38 +07:00
shakespeare.diy
8d464a05f3 fix: resolve pre-existing tsc errors blocking the test gate
Two type errors (surfaced by `npm run test` / tsc --noEmit on a full
toolchain; the in-browser bundler does not run tsc, so they were latent):

- useCVMClient.ts: remove a malformed cast
  `spots as typeof spots & { tags; created_at }[]` feeding
  deduplicateByIdentity(). The intersection dropped pubkey/kind and broke
  the reassignment to `spots`. deduplicateAddressable() already returns
  the correct shape, so the cast was both wrong and unnecessary.

- queryPersister.ts: import PersistedClient / Persister from
  @tanstack/query-persist-client-core (their canonical source) instead of
  @tanstack/react-query-persist-client, whose `export *` re-export tsc
  won't resolve for named type imports. Added the core package (5.102.8,
  matching the pinned wrapper) as an explicit dependency.

Both are pre-existing issues on the branch, unrelated to the audit
remediation commits.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:46:27 +07:00
shakespeare.diy
a200330875 test: add unit tests for bolt11, verifyInvoice, and sanitizeUrl (audit 5.5)
Cover the three highest-value pure functions in the security/payments
path — previously untested:

- bolt11.getSatoshisFromBolt11: m/u/n/p multiplier decoding, amountless
  invoices, 21-sat zap case, case-insensitivity, testnet/regtest
  prefixes, and malformed-input throwing.
- verifyInvoice.assertInvoiceAmount: accepts matching amounts + 1-sat
  rounding slack; rejects the core attack (over-charging invoice),
  amountless-when-expected, malformed, and empty input.
- sanitizeUrl + companions: https passthrough, http→https upgrade,
  javascript:/data:/file: rejection, relative-path rejection, plus
  sanitizeCssString / urlDomain / sanitizeTel / sanitizeEmail.

Note: run `npm run test` on a full toolchain to execute these — the
in-browser builder bundles but does not run vitest.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:41:42 +07:00
shakespeare.diy
01d35823cc a11y: announce live updates for notifications, wallet balance, DMs (U3)
Add aria-live="polite" to the three dynamic surfaces screen-reader
users otherwise miss:
- Notification bell unread-count badge (with an "N unread notifications"
  label so the number is announced meaningfully, not read as bare digits)
- Cashu wallet balance value (announces the new balance after a
  send/receive completes)
- Direct-message conversation thread: role="log" + aria-live so
  incoming messages are announced as they arrive

ToastViewport already announces via the Radix default and was left
untouched.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:39:09 +07:00
shakespeare.diy
8eae0a69e4 security: verify Amber signatures + pin MCP server versions
Closes two items from the platform/completeness audit appendices.

Amber signature verification (audit "open verification item", LOW):
reconstructSignedEvent() previously trusted whatever `sig` the external
Amber signer app returned — it recomputed the event id but never
verified the Schnorr signature. A compromised or buggy signer could
therefore return a forged/garbage signature that the app would treat as
valid and publish. Now verifyEvent() is run on the reconstructed event
before it is returned; failures return null, which every caller already
handles as an "incomplete signature" error. This single choke point
covers all Amber paths: the native AmberIntent signer, AmberCallbackPage,
and AmberCallbackHandler.

F9 (LOW): pin MCP server versions in .mcp.json. Both js-dev-mcp and
nostrbook/mcp were fetched at @latest, silently pulling the newest
release into the agent tool environment every session. Pinned to the
current exact versions (0.6.3 / 0.5.3) so upgrades are deliberate.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:20:47 +07:00
shakespeare.diy
358708ee27 perf: shrink map marker payload and extend vendor chunking
C (marker): Reuse the existing ~24 KB logo.png for the "you are here"
map marker instead of the 2.9 MB user-location.png (byte-identical copy
of logo-sticker). Removed the unused 2.9 MB PNG from public/images,
cutting deploy/APK size. Same artwork, ~99% smaller payload.

B (chunking): Extend the existing manualChunks in vite.config.ts to
split the largest page-specific libraries into their own vendor chunks
(hls.js, html5-qrcode, recharts) so they load only on the routes that
use them. Capacitor single-bundle branch and React core grouping left
untouched.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:18:14 +07:00
shakespeare.diy
46cb2266ea fix: restore accidentally-removed cn import in LabPage and EventsPage
The empty-state migration removed the `cn` import from both files on the
mistaken belief it was unused. In fact each file still uses cn() ~22 times
(ProfileCard, FilterDrawer, event cards, etc.), so /lab and /events crashed
at runtime with "cn is not defined" and hit the route error boundary.
Re-added `import { cn } from '@/lib/utils'` to both.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 19:07:01 +07:00
shakespeare.diy
a23225cad4 ux: migrate more empty states + fill in missing page titles (U1/U2)
Empty states:
- LabPage and NotificationBell now use the shared <EmptyState> component.
- LabPage's logged-out "Log in to create a profile" prompt preserved via
  the hint slot; removed now-unused cn import.

Page titles (U2):
- Audited all page routes for document titles. Only 3 were missing:
  - EditTourPage: now sets "Edit <tour> - SkateSpots".
  - AmberCallbackPage: now sets "Signing in with Amber - SkateSpots".
  - NIP19Page ProfilePage: sets "<name> - SkateSpots" with about as
    description; other NIP-19 branches already delegate to NotFound /
    NewsletterIssueView which set their own titles.

ScrollToTop (U1): verified already correct — resets window scroll on every
pathname change, wired in AppRouter. No change needed.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 18:50:25 +07:00
shakespeare.diy
16e943d4ef refactor: consolidate become-* routes + migrate empty states to shared component
Empty states (P4 follow-up):
- CrewsPage and EventsPage now render the shared <EmptyState> instead of
  their own local copies, keeping spacing/actions/footer consistent.

Become-role routes (P1):
- Add src/lib/becomeRoles.tsx: single registry mapping each role slug to
  either a CraftProfileForm config (artist/graffiti/musician/judge/tattoo/
  trainer) or a lazy bespoke page (coach/filmer/editor/photographer/
  builder/skater). The 6 craft roles were already thin config wrappers, so
  their configs move into the registry and the wrapper files are deleted.
- Replace 12 hardcoded /become-* routes + 12 imports with one
  /become/:role route (BecomeRolePage) that renders from the registry.
- Legacy /become-<slug> URLs now redirect to /become/<slug> so shared/
  bookmarked links keep working.
- Update all internal links (LabPage, Index, SkatersPage, SpotDetailPage,
  NIP19Page, SkaterDirectoryCard) to the canonical /become/<slug> paths.

Note: the 5 large bespoke forms were intentionally NOT merged into one
config — they carry distinct machinery (SpotPicker, wallet/Pro, geohashing)
and forcing them together would increase complexity, not reduce it.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 18:44:53 +07:00
shakespeare.diy
e7e492c568 a11y: fix lime accent contrast (P3) + add shared EmptyState component (P4)
- Light-mode --accent-foreground changed from white (2.35:1, fails WCAG AA)
  to near-black 0 0% 10% (~9.5:1) so filled lime CTAs/badges are readable.
- Add src/components/EmptyState.tsx: a single reusable empty-state with
  icon/emoji, title, hint, action slot(s) and optional footer link, to
  consolidate the ~39 ad-hoc empty states over time. Action slot turns
  dead-ends into loops (e.g. "No crews nearby → Create one").

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 18:33:35 +07:00
shakespeare.diy
8e02c77ba1 Docs + a11y: document Cashu wallet seed storage/backup as tracked accepted-risk in SECURITY.md (F1 follow-up); add skip-to-content link and main landmark id (UX U4)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 18:22:30 +07:00
shakespeare.diy
00482fc332 Security polish (audit F5-F6): disable broad allowMixedContent on Android (loopback cleartext stays via network_security_config); document CVM client's intentional fixed data-relay set + IP-disclosure privacy note
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 18:15:19 +07:00
shakespeare.diy
f316c77806 Security hardening (audit F1-F4): secure Cashu seed in native Keychain/KeyStore with mandatory first-time backup; verify event signatures for trust scores (WoT + NIP-85 Relatr); add safeJson guard on config boot parse; delete dead CashuWallet.tsx with raw alert(mnemonic)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-24 18:13:58 +07:00
shakespeare.diy
52dc0adbc7 Refine onboarding: collapse "Why bother?" for a lighter first view; update Suan Luang Backyard tagline
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-16 10:25:47 +07:00
shakespeare.diy
76411f1666 Add Suan Luang Backyard to Supporters friends section
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-16 10:20:03 +07:00
shakespeare.diy
06cfadff33 Polish relay list UI: match CitrineCard style, add Local chip, on-brand read/write chips, and live connection dots
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-14 10:14:16 +07:00
shakespeare.diy
46fbb0df43 Map loading: replace fuzzy ghost-pin blobs with a clean top bar + pill
The cold-start loading state scattered fuzzy pink "ghost pins" across the map
(the same blob look that was disliked on real markers) which read as broken.
Removed MapSkeletonPins entirely.

New loading state, per the agreed plan (keep the map instant, never cover it):
- A slim animated top progress bar ("ss-loadbar" sweep on a faint track) that
  sits above the map while spots stream in.
- During a genuine cold start (no markers yet), a small labeled "Loading spots"
  pill with a spinner near the top — honest about what's happening (querying
  relays), not fake pins on the ocean.
- Reduced-motion respected (bar becomes static).

The map itself is untouched and still renders immediately; only the placeholder
changed. Dropped the now-unused MapSkeletonPins import. Build clean, no console
errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-14 09:54:07 +07:00
shakespeare.diy
a40c86e655 Map pins: use a single generic 🛹 glyph instead of per-category emojis
Replaced the per-category emoji map (street/park/DIY/plaza/transition) with one
generic skateboard glyph on every spot pin, per request. The pin's crisp shape,
tight halo and shelter roof-cap (covered/indoor) are unchanged. Dropped the now-
unused `category` option from createSkateSpotIcon and its call sites.

Build clean; the earlier "CATEGORY_GLYPH is not defined" console errors were
from a stale intermediate bundle and do not occur in the current build.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-10 18:50:34 +07:00
shakespeare.diy
345af97788 Map Pass 1: crisp pins, spot-type & shelter glyphs, floating loading, micro-interactions
Visual foundation for the map (ideas #2, #3, #4, #6 — features #5/#7 intentionally
deferred to a later pass):

#2 Crisp pins, not blobs:
- Reworked createSkateSpotIcon — solid neon teardrop with a TIGHT glow halo
  (inset dark ring + focused shadow) instead of the soft box-shadow that read as
  a fuzzy cloud on light tiles. Cluster badges given the same crisp treatment.

#3 Marker = spot type (a readable language):
- Category emoji inside each pin: 🛹 street / 🏞️ park / 🔧 DIY / 🏛️ plaza / 🌊 transition.
- Covered/indoor spots get a cyan roof "cap" so dry spots stand out at a glance —
  the payoff of the shelter data living on the map, not just a badge.
- createSkateSpotIcon now takes an options object (weatherColor/weatherIcon/
  category/shelter); all call sites updated. Weather-layer colouring unchanged.

#4 Less scrim, floating loading:
- Suspense map-load fallback dimming reduced (bg-black/50 → /20).
- "Finding spots" pill moved from dead-centre (top 46%) to a slim top pill so it
  never blocks the map where the spots are.

#6 Micro-interactions (CSS, GPU-friendly, reduced-motion aware):
- New pins drop in with a spring; hover/focus/tap lifts the pin and pulses its
  neon halo once.

Build clean, no console errors. No tile/theme change — the "og" light map is
untouched per request.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-10 18:46:15 +07:00
shakespeare.diy
a7b7e07e86 Add spot "Shelter" (weather protection) field
Skaters need to know if a spot is roofed/indoor so weather info isn't
misleading — a covered or indoor spot is still skateable in the rain. Added a
4-way shelter level: outdoor (default) / partial / covered / indoor.

Data model (types.ts + skateTags.ts):
- SkateSpot.shelter parsed from a `['shelter', value]` tag (validated to known
  values; absent/unknown → treated as outdoor).
- shelterOptions / DEFAULT_SHELTER / SHELTERED_VALUES + ShelterType exported.
- SHELTER_INFO display map (emoji/label/short/color) for badges.

Publishing (CreateSpotPage + EditSpotPage):
- New "Shelter" selector reusing the exact selectable-Card pattern used by
  Access Type / Spot Type (cn() borders, neon ring, corner check badge).
- Emits `['shelter', value]` always, plus a queryable `['t', value]` for
  sheltered spots (indoor/covered/partial) so "dry spots" filtering + Sasquatch
  search work at the relay level. Outdoor adds no t tag.
- EditSpotPage initialises from the existing spot and preserves the value.

Display:
- Shelter badge on SpotDetailPage, SpotCard, and the MapPage popup — only shown
  for sheltered spots (outdoor stays unbadged to avoid clutter).

Docs:
- NIP.md: shelter added to the optional-tags table, a dedicated Shelter section
  explaining values + the queryable-t-tag behaviour, and an indoor example.

Build clean, no console errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-10 11:38:43 +07:00
shakespeare.diy
993bea1b5b Sasquatch Pro: stop selling features that don't exist
Audit found the Pro paywall promised three things with no implementation:
- "Priority AI model, higher limits & longer memory" — Pro and free hit the
  exact same model/limits/history (only difference is the personal-tools prompt)
- "Proactive nudges (weather + spot tips)" — no nudge/scheduler exists anywhere
- "Pro-only Sasquatch skins" — single hardcoded avatar; no skin system
- "Supporter badge" — gating exists but no badge is rendered

Rewrote all Pro copy to promise ONLY what actually ships (personal assistant
over the user's own Stash/crews/spots/listings, confirm-before-publish action
drafts, personal-context answers, and directly funding an ad-free app):
- SasquatchProDialog PERKS list (dropped Sparkles import now unused)
- usePurchasePro receipt DM
- 6 STATIC_FAQ Pro answers in SasquatchGuide

Pricing, payment target (skatespots@minibits.cash) and npub-bound zap-receipt
verification were already accurate and left unchanged. Build clean, no console
errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-10 00:27:34 +07:00
shakespeare.diy
5506344893 Make Sasquatch tell the truth: direct relay queries, not a CVM server
The chat's data layer (useCVMClient) queries Nostr relays directly from the
browser — the CVM/ContextVM server round-trip was removed earlier for speed.
But the UI and system prompt still claimed data came "from the CVM server",
which is no longer true. Aligned every user-facing claim with reality:

- System prompt: reframed "CVM Tools / MUST route through CVM" as "Live data
  tools that query the Nostr relays directly". Tool-calling behaviour unchanged.
- Streaming status: "Querying CVM for X" → "Searching the relays for X".
- Tool-failure message: no longer blames a non-existent "CVM server"; now
  correctly frames it as relays being slow, with a retry nudge.
- Empty results: stop asserting "not added yet" as fact — an empty result can
  also mean slow relays or a misspelled place. Now offers retry + add-a-spot,
  and names the place searched. Applied to the local formatter, the system
  prompt rules, and the second-pass AI instruction.
- Chat footer: "Powered by ContextVM & Nostr Relays Data" → "Live data from
  Nostr relays" (links the primary community relay).
- About page credit: "AI powered by ContextVM" → "Trust & crew chat via
  ContextVM" (its real role: Relatr trust scores + encrypted crew chat).
- Owner CVM health panel: clarified it probes the standalone MCP server and
  that the in-app chat does not depend on it.
- Updated stale internal comments in SasquatchGuide + CreateSpotPage.

ContextVM references that ARE accurate (Relatr trust scores, crew-chat Cordn
coordinator) were left intact. Build clean, no console errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-10 00:16:59 +07:00
shakespeare.diy
ee23f3e063 Fix spot dedupe: merge same-named spots across geohash/city + name-suffix variants
The old identity dedupe keyed on `name|place` where place was a single field
(geohash OR location OR city, first non-empty). Two copies of the same spot —
one carrying a geohash, one carrying only a city — produced different keys and
both survived (the Yellow Park duplicate).

Now:
- Name is reduced to a signature that strips trailing generic words
  (skatepark/park/plaza/spot/diy/...) and leading articles, so "Yellow Park",
  "Yellow Park Skatepark" and "Yellow Park " collapse to "yellow".
- Places are compared on human-readable tokens (location/city/country) only;
  the opaque geohash is excluded. An empty place on either side is treated as a
  wildcard, so a geohash-only copy still merges with a "Bangkok" copy.
- Genuinely different spots (different name signatures, or non-empty places with
  no shared token) are still kept separate; single-word names are preserved so
  generic names don't over-collapse.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 23:56:00 +07:00
shakespeare.diy
7e7cf0a8bf Sasquatch fast-path: don't dead-end on empty/typos
The local fast-path was stricter than the AI it replaced: a typo like "bangok"
won't substring-match "Bangkok", so the fast-path returned a false "No spots
listed there yet" even though Bangkok has spots.

Two-layer fix:

1. Safety net (most important): if a finder fast-path comes back EMPTY, fall
   through to the AI path instead of rendering "nothing found". The AI normalizes
   spelling and can still find results. Stats/weather always render immediately.
   Net effect: fast when the local match works, correct-always via AI when it
   doesn't — never a false empty.

2. Typo tolerance: findSpots city matching now uses placeMatches() — substring
   OR edit-distance-1 against candidate word tokens (only for queries ≥4 chars,
   so short words can't over-match). So "bangok" matches "Bangkok" on the fast
   path too. Freeform content stays a strict substring check to avoid false hits.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 23:52:08 +07:00
shakespeare.diy
6df526bf27 Sasquatch: local fast-path for finder questions (kills ~30s AI wait)
Root cause of the ~35s "spots in bangkok" response: every live-data question
first waited on an AI round-trip just to produce a tool call, and the keyless
backup model (Pollinations) can take tens of seconds. The relay query and local
formatting were already fast — the AI was the bottleneck.

Fix: before touching the AI, try parseLocalIntent(). For clear finder questions
(spots/events/coaches/crews/shops/tours/gear + community stats + weather with a
parseable location) we now go STRAIGHT to the relays (capped at 4.5s) and render
locally — zero AI calls. Expected ~35s → ~2–4s on the most common queries.

Deliberately still routed through the AI:
- trip planning (needs the rich itinerary card)
- personal "my …" Pro queries (get_my_* tools)
- anything without a confidently-parseable intent/location

If the fast-path relay query errors, it falls through to the existing AI loop as
a backup, so nothing regresses.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 23:24:08 +07:00
shakespeare.diy
8b7d9ca4ea Fix Sasquatch spot results: honest count + collapse duplicates
Two bugs from "spots in bangkok" (said "Found 20" but showed 6, with repeats):

1. Misleading count: formatLocalResults hard-capped the visible list to 6 while
   the headline reported the full count. Now shows up to 10 and appends a
   truthful "…and N more — see them all on the map" line, so the number and the
   list always agree.

2. Duplicate spots: coordinate dedupe (kind:pubkey:d) can't catch the same
   real-world spot re-posted under a different identifier or by another account
   (e.g. "Yellow Park – Sukhumvit 50" appearing twice). Added
   deduplicateByIdentity() — a second-pass dedupe on a normalized
   name + geohash/location signature, keeping the freshest event — applied in
   findSpots so the count and list are clean.

Both fixes flow through the AI tool-call path and the offline fallback path.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 23:17:15 +07:00
shakespeare.diy
ada4bfee5e Accelerate Sasquatch custom questions (live-data queries)
Three low-risk speedups focused on the slowest path — questions that hit live
relay data:

1. Relay timeout 10s → 4.5s (6s for the fan-out community stats). The useful
   relays answer in ~2s; the old 10s just let one slow/dead relay stall the
   whole reply.

2. Results-first: for finder tools (spots/events/coaches/gear/crews/shops/
   tours/stats/weather) format the CVM result locally and SKIP the second
   "prettify" AI call. Removes a full model round-trip (~2–4s) — users want the
   list, not extra prose. New toolCallToIntent() maps a tool call to the existing
   formatLocalResults() path. Personal/Pro tools (get_my_*) still use AI synthesis.

3. Faster AI fallback: cap OpenRouter attempts to the top 3 free models before
   the keyless backup, and shorten 429 backoff (≤800ms vs ≤2500ms). Avoids the
   worst-case 15–20s grind through 8 models on a busy free tier.

Conversational/knowledge answers (single AI call) keep Sasquatch's personality
and are untouched.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 23:11:14 +07:00
shakespeare.diy
7eba7dfe22 Calendar: show session time for scheduled local tours
The shared EventsCalendar already renders tours alongside events with live
countdowns on cards/detail pages. This refines the agenda time display: local
tours now carry a specific session time, so the calendar shows that clock time
instead of a blanket "All day". Multi-day national/international/destruction
tours remain date-only ("All day" / date range), and events are unaffected.

- Add allDay flag to CalendarItem (set from tour category)
- Local sessions show their start time; other tours stay all-day

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 22:34:23 +07:00
shakespeare.diy
5d96f8dfa8 Add tour countdown badges + fix/standardize NIP-05 provider prompts
Countdown badges (upcoming tours):
- New getCountdown() in TourCard: "In 3 days" / "Tomorrow" / "In 2 weeks" /
  "Happening now" (pulsing) based on start/end dates, category-tinted
- Shown on both compact and full TourCard, and in the TourDetailPage schedule card

NIP-05 provider fix (removes obsolete nip05.social):
- New single source of truth src/lib/nip05Providers.ts with working, curated
  providers: nostrcheck.me + nostr.directory (free), Nostr Plebs + nostr.band (paid)
- New reusable Nip05Required component (full-page "gate" + inline "banner"
  variants) listing those providers and deep-linking to /settings?section=nip05
- Wired into CreateTourPage (gate), CreateListingPage (banner) and all four
  Become* pages (Coach/Filmer/Photographer/Builder) for a consistent prompt
- SettingsPage NIP-05 screen now reads from the shared provider list
- Updated internal planning docs to match

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 22:31:44 +07:00
shakespeare.diy
1c295e96e2 Improve Create Tour: consistent schedules, calendar export & punk redesign
- Add a unified Schedule section across all four tour categories:
  - Local: choose a fixed session (date+time) or flexible/by-appointment
  - National/International: required departure + optional return trip dates
  - Destruction: existing competition window, now with end>=start validation
- Reuse start-date/end-date tags for every category (parser already supported them)
- Fix datetime handling: dedicated toDateInput/inputToSecs helpers so date and
  datetime-local inputs round-trip correctly (previous mix of toLocalInput +
  type="date" was inconsistent)
- Consistent, logical mandatory-field validation with clear required markers
- Redesign the header into an immersive, category-tinted, grunge-striped banner
  and rebuild the category selector as elegant glowing cards
- New calendar utilities (.ics + Google Calendar) and AddToCalendar component
- TourDetailPage is now fully category-aware (badge, guide label, duration,
  SEO title) with a Schedule card + Add to Calendar for attendees
- TourCard shows the schedule for all categories (time for local sessions)
- Update NIP.md to document start-date/end-date across categories

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 22:16:16 +07:00
shakespeare.diy
a984084464 Instant cold-start: query persistence, optimistic geolocation, skeleton pins
Three UX wins so the app feels "already loaded" the moment it opens:

1. IndexedDB React Query persistence (lib/queryPersister.ts)
   - PersistQueryClientProvider rehydrates the last-seen spots, shops,
     events, tours, crews, coaches, listings and author profiles from
     IndexedDB on cold start, so returning users land on a populated map
     instead of a blank one; fresh data refetches quietly in the
     background.
   - Strict allow-list / deny-list: only public, JSON-serializable,
     cold-start-relevant queries are persisted. Private/encrypted data
     (stash, DMs, contacts, wallet/nwc/cashu/bitcoin, signer) is NEVER
     written to disk. 24h maxAge + version buster keep it fresh & safe.
   - Pinned @tanstack/react-query + query-core + persist-client to a
     single 5.102.8 to avoid a query-core mismatch (partialMatchKey).

2. Optimistic geolocation (lib/lastLocation.ts)
   - getCurrentPosition now caches every successful fix.
   - The map opens ALREADY centered on the user's last-known area
     (city zoom) instead of the world view; "you are here" + proximity
     sort seed from the cache instantly.
   - "Near Me" flies to the cached location immediately on tap, then
     refines to the fresh GPS fix. Silent background warm-up on web map
     mount so a later tap resolves instantly.

3. Cold-start skeleton (components/MapSkeletonPins.tsx)
   - When the map is genuinely empty + loading, show elegant pulsing
     ghost pins + a "Finding spots" pill instead of a blank map; they
     vanish the instant real (or cached) spots render. Splash now
     signals content-ready when spots actually resolve.

Respects prefers-reduced-motion throughout. Build + runtime verified
clean (no console errors).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 21:58:22 +07:00
shakespeare.diy
40e4e53400 Hardening: https-only URLs, route-level crash resilience, cleaner errors (1.4.3)
Security & reliability audit follow-up. The app was already well-hardened
(strict CSP synced across index.html/_headers/netlify.toml, careful
textContent/escapeHtml in the Leaflet DOM, broad sanitizeUrl usage,
consistent author-filtering on trust-sensitive queries). This closes the
remaining gaps:

- sanitizeUrl is now HTTPS-only: bare http:// links are upgraded to
  https:// (not silently dropped); every other scheme (javascript:,
  data:, file:, relative) is rejected. Removes a downgrade/mixed-content/
  IP-leak vector and matches the native HTTPS-only WebView. Added a
  sanitizeCssString() allowlist helper for future CSS interpolation.

- Route-level ErrorBoundary (RouteErrorBoundary) wraps the routed pages,
  keyed by pathname: a crash in one page now shows a recoverable
  "this page slammed → go back" message while the header/nav/rest of the
  app keep working, instead of the single top-level boundary blanking the
  ENTIRE app. Resets automatically on navigation.

- ErrorBoundary fallback no longer leaks stack traces to users (full
  detail still logged to console for us); redesigned on-brand ("Slammed",
  neon, reassures that account + data are safe). Updated its test.

Author-filtering spot-check across admin/newsletter/featured/crew/stash
paths found no gaps — all trust-sensitive queries correctly constrain
authors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 12:40:43 +07:00
shakespeare.diy
8d7b4f914b Elevate splash to premium, choreographed launch (1.4.2)
Rebuilt the cold-start splash to a best-in-class standard:
- Determinate progress bar driven by real milestones (mount creep →
  relays-warm nudge → 100% on ready) with a sliding shine highlight —
  reads as genuine loading, not an infinite spinner.
- Choreographed staggered entrance (logo → wordmark → bar → status rise
  in sequence) on an expressive ease curve.
- Signature logo treatment: rotating red→lime conic halo ring + a gentle
  breathing pulse, over a dual breathing glow and a masked skate-map grid
  texture for depth.
- Satisfying "ready" beat: bar fills to 100%, status swaps to a lime
  check "Ready", then a soft scale-up crossfade reveals the app.
- 8px-grid spacing, refined tracking/typography.
- Fully respects prefers-reduced-motion (all motion gated) and safe-area
  insets on all four edges; identical on phone and desktop; still capped
  hard at 2.6s so it never traps the user.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 12:19:17 +07:00
shakespeare.diy
a12342f7d1 Splash: reveal on real content + graceful shell fade-in (1.4.1)
Make the landing feel "already loaded" instead of dismissing on bare
connection:

- MapPage fires `skatespots:content-ready` once the first spots query
  settles. The splash now dismisses on that signal (populated reveal),
  falling back to a short grace window after `relays-warm` for non-map
  cold-start routes, and still capped hard at 2.2s so it never traps.
- App shell eases in under the departing splash via a pure-opacity
  `appReveal` animation (no translate, so a full-bleed map never shifts),
  respecting prefers-reduced-motion. Result is a smooth crossfade onto
  content that's already there rather than a pop-in.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 12:10:47 +07:00
shakespeare.diy
f41af8f1ca Cold-start: pre-warm relays + branded splash screen (1.4.0)
A. Relay pre-warming — NostrProvider now opens the read-relay WebSockets
   the moment it mounts (fire-and-forget limit:0 probes with a 4s abort),
   instead of NPool's lazy open on first query. Sockets are already
   connected by the time the map queries run, so first events land sooner.
   Failures are swallowed so a dead relay never delays or breaks boot.

B. Branded SplashScreen — an intentional cold-start moment (logo with a
   neon glow pulse, a red→lime progress sweep, and rotating skater-voiced
   status lines) that replaces the "is it broken?" empty-map flash. It
   dismisses the instant relays report warm (`skatespots:relays-warm`
   event fired on the first relay response) OR at a hard 1.6s cap —
   whichever comes first — with a 650ms minimum to avoid flicker. Shown
   once per session. Fully respects prefers-reduced-motion and safe-area
   insets; works identically on phone and desktop, never blocks the user.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 12:05:35 +07:00
shakespeare.diy
aede706fd3 Fix signup flow skipping profile + recovery steps (1.3.16)
Root cause: the multi-step SignupDialog is hosted inside LoginArea, but
both the mobile header and the onboarding hero only rendered LoginArea
while logged OUT ({user ? menu : <LoginArea/>}). The signup flow logs the
user in at its FIRST step (to publish the profile), which flipped `user`
truthy and instantly UNMOUNTED LoginArea — destroying the dialog before
the profile and recovery steps could show. The skater was dumped on the
map with no recovery prompt (exactly the reported bug).

Fix: keep LoginArea mounted after login and merely hide its trigger
(dialog renders in a portal, so hiding the trigger is purely cosmetic).
MobileHeaderUserMenu / logged-in CTAs render alongside. The in-progress
signup now survives login and reaches the redesigned recovery step.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 11:34:23 +07:00
shakespeare.diy
3cc3b623cc Redesign signup "done" step to lead with recovery (1.3.15)
Reworked the final signup step so saving your login key is the hero,
not an afterthought:
- Celebration shrunk to a compact bar ("You're in! One quick thing…")
  so it no longer steals focus from the safety step.
- "Save your login key" card is now the centerpiece with a loud amber
  primary CTA (Reveal & save), a skater-native framing ("keys to your
  board", "only way back in — no email, no password reset").
- Download button flips to a green "Saved ✓" state for clear feedback.
- "Skate now" is intentionally quieter and honest: reads "Skip for now,
  skate" with a reassuring note until the key is saved, then becomes a
  confident lime "Let's skate" once done. Never blocks the user.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 11:30:06 +07:00
shakespeare.diy
d8adf0746d Pin "Save your login" banner above the fold after signup (1.3.14)
New skaters landed on the onboarding page with the recovery/backup
prompt below the fold, so it read as "nothing about recovery". Moved the
critical "Save your login" banner ABOVE the hero (stronger border, glow,
uppercase headline) so it's the first thing a newly-signed-up user sees.
Removed the duplicate lower banner. Still non-blocking and stays until
backup-confirmed; the post-login bottom sheet remains as a second net.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 11:27:16 +07:00