Commit graph

849 commits

Author SHA1 Message Date
SkateSpots
7969e8e9de release: v1.5.0
Sasquatch Pro (AI), in-app directions (transit/bike/moto/car), Barbers &
Hairstylists in The Lab, and a smoother app shell (no remounts, no stray
signer pop-ups).
2026-09-27 15:42:52 +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
69e1ae17b8 docs(agents): note the owner AI-balance status endpoint + card 2026-09-27 15:28:14 +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
972941d688 docs(agents): Routstr is now the AI upstream (githappens node)
Record the working node, the fund/refund recipe, the aggregator outage, the
'orangesync bad key' trap, and that Netlify snapshots env at deploy (redeploy
after env changes).
2026-09-27 15:22:37 +07:00
SkateSpots
00aab5369b feat(ai-proxy): server-authoritative models (provider switch = env only)
If the client asks for a model not in the allowlist, the proxy now uses its
configured default instead of 400-ing. So switching the upstream to a Routstr
node is purely server-env (SASQUATCH_AI_BASE/KEY/MODELS) with no client redeploy.
AGENTS.md documents Routstr discovery (kind 38421, endpoints in 'u' tags), the
live nodes found, and the fund->key recipe.
2026-09-27 15:11:25 +07:00
SkateSpots
a5a134c6c0 feat(ai-proxy): provider-agnostic upstream (enables a sats-only provider)
The proxy now reads its upstream from SASQUATCH_AI_BASE + SASQUATCH_AI_KEY
(falling back to Netlify's injected OPENROUTER_* vars), and the model allowlist
is overridable via SASQUATCH_MODELS. Default behaviour is unchanged (gateway).

This makes switching to a sats-only provider a config change, e.g. Routstr:
  SASQUATCH_AI_BASE=https://api.routstr.com/v1
  SASQUATCH_AI_KEY=cashuA… (raw token) or sk-… (session)
NOTE: api.routstr.com was returning 404 (empty models) on 2026-09-27, so we are
NOT switching yet. Documented in AGENTS.md.
2026-09-27 15:01:13 +07:00
SkateSpots
f94297b196 docs(agents): note the non-site deploy skip 2026-09-27 14:43:02 +07:00
SkateSpots
9d0c31f673 chore(deploy): skip production deploys for non-site commits
Netlify bills 15 credits per production deploy, and this cycle already spent
~315 on 21 deploys. auto-deploy.sh now skips when the change between the last
deployed SHA and origin/main touches only non-site files (AGENTS.md/*.md,
scripts/, cvm-server/, skills/) — it records the SHA without deploying, so doc
commits cost nothing.
2026-09-27 14:42:57 +07:00
SkateSpots
70440c4bce docs(agents): Pro gate is enabled in production (SASQUATCH_REQUIRE_PRO=1) 2026-09-27 14:32:30 +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
2503a9a2a3 docs(agents): note Sasquatch AI is Pro-only + tier-derived expiry 2026-09-27 14:01:36 +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
c487d9d1d2 chore(cvm): use only the community relay for data queries
Policy: the CVM server queries a single community relay
(wss://goskatespots.nostr1.com). Align .env.example (and the local .env) to it.
The local duplicate unit has been retired; the server runs on one host only.
2026-09-27 12:07:58 +07:00
SkateSpots
f7855d47e4 chore(cvm): one-command deploy to the single host + document the setup
- scripts/deploy-cvm.sh: backup remote server.ts -> upload -> restart -> confirm
  the heartbeat, with --rollback. Host/SSH values come from the never-committed
  ~/.config/skatespots/cvm.env (or CVM_HOST), so no host details ever enter this
  public repo.
- package.json: add `npm run deploy:cvm`.
- AGENTS.md: record that the CVM server runs on ONE external host (deploy via
  deploy:cvm) and that the discovery relay, not the server, is the SPOF.
2026-09-27 12:04:57 +07:00
SkateSpots
3a32dc2334 feat(cvm-server): load protection + type-check it in the gate
Load protection (public read-only endpoint — protects relays + this box):
- Per-pubkey sliding-window throttle and in-flight cap via inbound middleware.
  The transport can't return a JSON-RPC error, so over-budget callers are dropped
  and time out (logged).
- Global semaphore caps concurrent upstream relay queries.
- Short-TTL cache keyed by filter set, so repeated identical queries (e.g. a
  burst of get_community_stats) don't re-hit the relays.
- All tunable via CVM_RATE_* / CVM_MAX_* / CVM_QUERY_CACHE_MS (documented in
  .env.example); maps pruned by a janitor.

CI:
- cvm-server/tsconfig.json type-checks the standalone server; new
  typecheck:cvm / check:cvm scripts, with typecheck:cvm wired into npm test so
  the server can't rot unnoticed (it was excluded from tsc + eslint).
2026-09-27 11:36:20 +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
8893622bc3 docs(agents): note Sasquatch Lab-role coverage + CVM server tool parity 2026-09-27 11:17:09 +07:00
SkateSpots
b24c734a4d feat(cvm-server): parity with the app — all Lab roles + crews/shops/tours
- find_coaches now covers every Lab kind (35001-35014, minus 35006 crews), with
  a real role enum and label map, and matches skills against t/skill tags.
- New tools: find_crews (35006), find_shops (30017/t=skateshop), find_tours
  (30823).
- get_community_stats widened to every craft + shops/crews/tours, with lab_total.
2026-09-27 11:12:14 +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
2bc903d128 feat(cvm-server): heartbeat re-announce, wire LOG_LEVEL, fix from_date
- Heartbeat: re-publish discovery announcements every CVM_HEARTBEAT_MS (15 min)
  via the SDK's announcement manager (defensively reached), so recency-based
  health checks stay green for a long-running process.
- LOG_LEVEL is now actually passed to the transport (was documented but unused).
- find_events: from_date was a no-op (filter(() => true)); now filters by the
  kind-31923 start tag.
- .env.example: fix CVM_RELAY (.org), drop the dead wotr relay, document
  CVM_HEARTBEAT_MS.
2026-09-27 10:32:07 +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
4d00d0991b chore: untrack accidentally-committed local files (.gitignore)
git add -A from the repo root swept in assets/, resources/, bun.lock and
cvm-server locks/.bak. Untrack them + .gitignore; correct AGENTS.md junk list
(cvm-server *source* is tracked). No secrets were involved (only placeholders).
2026-09-26 23:39:48 +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
0956b1368a docs(escrow): exact Android test-build steps for on-device verification
Add 'Producing a test build' (android:init -> android:sync -> assembleDebug ->
adb install) to the ESCROW.md checklist, plus the two-account requirement. The
mint-touching paths still need a real device run.
2026-09-26 23:19:47 +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