- /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.
- 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.
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.
- 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.
- 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).
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.
- 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.
- 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.
- 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).
- 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.
- 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).
- 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.
'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.
- 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.
- 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.
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.
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.
- 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.
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.
- 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.
- 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.
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.
- 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.
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.
- 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.
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.
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.
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.
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)
- 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
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.
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.
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.
- 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
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.
- 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.
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).
- 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
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.
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.
- 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
- 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