Commit graph

420 commits

Author SHA1 Message Date
SkateSpots
38ae1273ff feat(card): organiser's issued check-in passes list
The event organiser can see who they've issued check-in passes to (holder +
revoked state) and revoke them — same affordance the park panel has. (Skipped
listing a holder's passes on their public profile: a pass is between a venue
and a holder, so publishing it would leak where someone has access.)
2026-10-02 13:52:16 +07:00
SkateSpots
a4ef8218e3 feat(card): 'My passes' tab — view & print passes issued to you
A holder previously had no way to see a pass a venue/organiser issued them.
New useReceivedPasses (kind 35679 #p=me) + a fourth /card tab listing them;
selecting one renders it as a card (type park) with a Print button, shows the
access level / valid-thru, and flags revoked passes. Completes the
issue -> receive -> display -> verify loop.
2026-10-02 13:36:22 +07:00
SkateSpots
e9dce47d46 feat(roles): owner/admin/organiser badges across pages + card studio
- New ControllerBadge (owner / admin / organiser / verified owner).
- Crew page hero: founder -> Owner, delegated admin -> Admin.
- Spot page: Owner; Shop page: 'Verified owner' (NIP-05 domain); Event page: Organiser.
- Card studio card-type chips show your role on each entity (Park=owner,
  Crew=owner/admin, Event=organiser) so it's clear what you control.
- i18n role.* EN/ES.
2026-10-02 13:30:04 +07:00
SkateSpots
892631e9f5 feat(card): duotone photo, park revocation, organiser check-in passes
- Duotone: desaturate + theme-accent tint the background photo so any image
  reads as a branded SkateSpots card (text stays legible). Verified visually.
- Revocation: the venue owner can revoke an issued pass from the studio list;
  revoke re-publishes under the same d with status=revoked (addressable
  supersede — works on every relay). /verify shows a red 'Pass revoked'.
- Organiser check-in: an event's organiser can issue a signed access pass (kind
  35679) bound to the event (a=31923:...); /verify confirms the issuer OWNS the
  event (generalised the venue check to spots OR events) and labels it Organiser.
- useIssuedPasses/useRevokePass; i18n EN/ES.
2026-10-02 13:10:48 +07:00
SkateSpots
a1952a31e5 feat(card): event ticket vs host + holder RSVP check on /verify
- Attendee card = 'MY TICKET', organiser card = 'EVENT · HOST' — never present a
  self-made card as organiser-issued.
- A ticket's QR now carries ?h=<holder-npub>; /verify fetches that holder's RSVP
  (kind 31925, #a = the event) and shows Ticket holder + RSVP status, so a door
  has a real check with no server.
- NIP.md + AGENTS updated; i18n EN/ES.
2026-10-02 13:04:11 +07:00
SkateSpots
9f3278f135 feat(card): Park-pass issuance logic + gate card types by availability
- Park Access is only offered if you own a venue (a spot you authored), and
  publishing REQUIRES a venue binding — only the venue owner's key can issue it.
  The pass names the holder; /verify shows holder + venue match + expiry.
- Card types are now gated by what you can actually issue/derive (Park/ Crew /
  Event), with a note explaining how to unlock more.
- Park panel shows a 'map a spot you own' prompt when you have no venue; the
  Publish button is disabled until a venue + holder are set.
- NIP.md: venue binding is required for new passes. i18n EN/ES.
2026-10-02 12:42:53 +07:00
SkateSpots
80e9552d11 feat(card): 'Finish your card' checklist (photo, name, styles/level, crew)
A card is only as good as its content, so the studio now shows what's missing
with one-tap links to fix it, instead of a subtle amber hint.
2026-10-02 12:38:44 +07:00
SkateSpots
00753b2769 polish(card): bigger/higher QR, theme-tinted scrim, profile-photo nudge
- QR 150->178px and moved up (bottom-right, above the identity block).
- Bottom scrim now fades into the THEME's dark background + an accent glow
  (was plain black) so the card reads as branded.
- Studio shows an 'add a profile photo' nudge when the account has no kind-0
  picture (the card's placeholder name was the tell).
2026-10-02 12:30:14 +07:00
SkateSpots
0dd7e9bfa9 fix(card,profile): resolve the card's profile via the app's own relays
The card showed the placeholder (now initials) even when the npub had a photo:
useAuthor missed profiles that only live on the community relay and could drop
a profile its strict schema rejected. Card pages now use a tolerant
useCardProfile() that reads kind 0 via querySkateContent (community + user
relays) and JSON.parses leniently. Also: useAuthor now queries the community
relay as its own parallel branch (a flaky relay in the group used to reject the
whole batch); and '_@domain' NIP-05 no longer renders a lone '@'.
Verified by rendering a real profile's card headlessly.
2026-10-02 12:18:49 +07:00
SkateSpots
84665adc8a feat(card): redesign as a 'Credential' + unify to ONE canvas renderer
Design: the card read like a spec sheet (16 competing elements, 5-6px type).
Rebuilt as an editorial credential:
- Left full-height portrait panel; right column = brand, ONE huge name, a single
  identity line (style / level / local), nip05, crew monograms + witnessed
  badges, and a clean QR verify stub. Dropped the tiny stats row, npub clutter,
  'Powered by Nostr' filler and the 'SKATER LICENSE' chrome.
- Same system applied to Park / Crew / Event fronts; typed-up back side.

Engine: the studio preview and /card/:npub now render the canvas PNG, so there
is a SINGLE renderer (src/lib/riderCardCanvas.ts) — no React/canvas drift. The
old React card components + CardQr are deleted. Holographic is now a canvas
sheen. Also fixed several fillText calls missing an x-coord.
2026-10-02 11:57:32 +07:00
SkateSpots
ce0e004a59 feat(card): customization depth — custom accent, logo, back side, pass expiry
- Custom accent colour (native picker) overrides the theme accent via
  --ss-accent in CSS + the canvas palette.
- Optional personal logo/crest (Blossom upload) shown next to the name.
- Printable back side (RiderCardBack React + drawCardBack canvas); Print renders
  both sides, and the preview + public card can flip.
- Park passes enforce valid-thru: passExpiry() drives an EXPIRED badge on the
  card and verify page; the input is now a date picker.
- Card schema -> v:3 (accent/logo/back). i18n; NIP.md + AGENTS.
2026-10-02 11:47:19 +07:00
SkateSpots
9891101452 feat(card): Card Studio redesign + drop self-claimed skills
- Two-pane studio: sticky live preview (desktop) + inspector column; mobile
  stacks preview-first.
- Actions decluttered to a single primary Share + a '...' menu (Save PNG /
  Print / Copy ID); removed the competing big buttons.
- Unsaved-changes state + manual Save changes button (no autosave, so an
  interactive signer like Amber isn't prompted per keystroke).
- Card type is now a compact 4-up chip row; the two toggle grids merged into
  ONE 'Show on card' list with live values.
- Skills: removed the self-claimed skill chips (marginal/unverifiable). The card
  shows witnessed badges (kind 1293) only; style + level are now DERIVED from the
  Skater profile (kind 35000) instead of a manual 'Skate Style' select.
- React card + canvas export updated; i18n cardUi.studio.*; NIP.md + AGENTS.
2026-10-02 11:24:51 +07:00
SkateSpots
7cafcc0dfa feat(card,review): Event pass/wristband template + Claims tab in /review
- /card gains a fourth type: Event. Renders an event (kind 31923) you organise
  or RSVP'd going/interested to (useMyRsvpCoords, kind 31925) as a passport/
  wristband: title, date/time, venue, cover, who it admits, going count.
  Derived from the event's own signed event — its QR verifies that event;
  /verify/:naddr renders kind 31923 too. React + canvas renderers; i18n.
- /review gains a dedicated Claims tab (pending private-spot ownership claims),
  replacing the banner that sat above the tabs.
2026-10-02 11:13:40 +07:00
SkateSpots
411f6e219a feat(spots): claim ownership of a private spot (NIP-05 + moderator confirm)
Private spots can't be domain-verified like shops, so ownership is a gated
two-step flow — never an instant takeover:
- A NIP-05-verified user files a claim (kind 1985 label in the separate
  org.skatespots.spotclaim namespace) on a spot marked access=private.
- A moderator approves by publishing claim-approved (moderation namespace,
  a=coord + p=claimant); surfaced in a pending-claims list atop /review.
- The claimant then finishes by re-publishing the spot with
  claim-reason=private-approved (existing claim mechanism).

Adds useSpotClaims/useApprovedSpotClaims/useAllSpotClaims/useSubmitSpotClaim,
useAdminApproveClaim, the private branch in SpotOwnership, i18n, and docs.
Non-private spots keep the abandoned-claim flow. Nothing is auto-transferred.
2026-10-02 11:06:58 +07:00
SkateSpots
ad75120a43 feat(card): Crew Card template (derived from the crew's signed event)
- /card gains a third type: Crew. Pick one of your crews and render a printable
  crew card (name, logo, home turf, founded, members, spots, vibes, Open call).
- No new event: the card is derived from the crew's own signed kind 35006, so its
  QR verifies that event (the founder's key IS the signature).
- Rendered in the React card + canvas export; /verify/:naddr now renders kind
  35006 with a link to the crew page.
- i18n cardUi.crew.* / verify.kindCrew; NIP.md + AGENTS.md updated.
2026-10-02 10:42:26 +07:00
SkateSpots
e348796e05 feat(card): identity facets — crew crests, local badge, Lab role chips
- Card shows crew crests (kind 35006 memberships), a local badge (skater
  profile location/home_spot), and Lab role chips (kinds 35001-35014) — all
  DERIVED LIVE, so a card can't claim a role/crew the skater doesn't have.
- Rendered in the React card + the canvas export (print/PNG); local/crews as
  text lines, roles as blue chips.
- Per-facet show toggles in the editor; card schema bumped to v2 so the new
  flags default ON for cards saved before them.
- Also fixed: verifyUrl wasn't passed to the canvas renderer (QR fell back to
  the profile path).
- i18n cardUi.facets.* / cardUi.roles.*; NIP.md + AGENTS.md updated.
2026-10-02 10:35:06 +07:00
SkateSpots
dbb9ea98c0 feat(card): make Rider Cards real & verifiable
- Publish the Rider Card as an addressable event (kind 32271, d=rider-card) and
  hydrate the editor from it, so customization persists across devices.
- New routes: /card/:npub (public read-only card) and /verify/:naddr
  (signature check + park venue binding).
- Park Access Cards can bind to the issuer's own spot (a tag 30818:...) and the
  verify page confirms the issuer owns the venue.
- QR on the card (+ canvas export) now encodes the verify URL; the real QR
  renders in the React card too (was a placeholder / broken profile path).
- Extracted shared card visuals to src/components/RiderCard.tsx; helpers in
  src/lib/riderCard.ts; hooks useRiderCard/useRiderBadges.
- Documented in NIP.md; i18n cardUi namespace (EN/ES); smoke test covers the
  new routes.
2026-10-02 10:26:54 +07:00
SkateSpots
0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
2026-10-02 10:11:06 +07:00
SkateSpots
09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- First-visit language auto-detect (Spanish devices land in es; stored choice wins).
- Sasquatch AI replies in the app language (skate trick names kept English).
- i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t()
  key missing from en/es or any en/es key drift.
- ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and
  ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace,
  trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters.
- Policy: only skate TRICK names stay English; user-generated content untouched.
2026-10-01 23:57:20 +07:00
SkateSpots
16c787d3f3 feat(marketplace): shipping flow - who-pays, distances, guided handoff
- ['shipping','buyer'|'seller'|'split'] tag + create-page selector.
- Distance from the viewer's last known location (cache only, no GPS prompt) on
  both gear cards, and a 'Nearest (local)' sort on GearSwapPage.
- HandoffHelper (buyer-only, in ListingTradeActions) opens the trade DM
  pre-filled: suggest a time & place / share delivery details / use a locker,
  with anti-scam copy. Details stay in the NIP-17 DM; the app never sees them.
- ships deals now nudge safety on all platforms, not just when escrow is ready.
2026-10-01 16:55:27 +07:00
SkateSpots
a12db574d4 feat(marketplace): NIP-13 proof of work - free CPU anti-spam
pow.ts: minePow/verifyPow/countLeadingZeroBits. PoW pays NO ONE - it is a free
CPU tax, not a fee. useNostrPublish accepts pow and mines the nonce on the
UNSIGNED event before signing (id is signature-independent -> one signer prompt).
CreateListingPage sends 16 bits per listing (~80ms) and 20 bits when a newcomer
chooses work instead of the 1,000-sat fee (SpamFeePayment.onPowInstead = the free
alternative). parseMarketplaceItem exposes powDifficulty; the card shows a PoW
badge at >=16.
2026-10-01 16:42:10 +07:00
SkateSpots
8c5b1636f7 feat(marketplace): local-first fulfilment (pickup/ships) + ring warning
- Listings carry optional ["fulfilment","pickup"|"ships"|"both"] (selling only):
  a create-page selector (default pickup), a card badge, and a Pickup/Ships filter
  on GearSwapPage. The app NEVER collects an address - a ships deal exchanges it
  privately in the NIP-17 trade chat, and ListingTradeActions nudges escrow for
  shipped deals. No KYC rail, no labels, no carrier API.
- TradeReputationCard adds a low-diversity ring warning (verified >= 5 across <= 2
  people) beyond the single-partner one.
2026-10-01 16:18:42 +07:00
SkateSpots
1a834f7b40 fix(stats): robust trade volume instead of a sybil-inflatable sum
The marketplace 'Sats Traded' was a naive sum of the self-reported amount tag on
31871, which two colluding accounts could inflate for free. tradedSats is now the
robust figure - mutual trades only (a listing attested by >=2 distinct pubkeys),
per-pair cap 3, amounts >1 BTC dropped - with tradedCount + tradedTraders, and
tradedSatsClaimed keeps the raw claim so the card shows both. Added query fields
=> PERSIST_BUSTER v5. No trade attestations exist on the relay yet (honest 0).
2026-10-01 15:53:13 +07:00
SkateSpots
38317ee2a5 feat(push): client half - subscribe flow, service worker, DM marker
- public/push-sw.js: shows the content-free push; tap focuses/opens the app.
- src/lib/pushService.ts + usePushNotifications: register the SW, subscribe with
  the service's VAPID key, sign a NIP-98 challenge proof, POST /subscribe.
- NotificationSettingsCard: a 'Background notifications' toggle (opt-in,
  per-device) + honest metadata copy; corrected the now-stale 'we run no push
  server' note.
- useSendDirectMessage: marks the RECIPIENT wrap ["skatespots","push"] (never
  the self copy) and adds the community relay as the notify relay so the push
  service sees it.
- /privacy: discloses the push server sees metadata only.
2026-10-01 15:39:04 +07:00
SkateSpots
8fd5bab3f1 fix(stats): resolve derived regions via BigDataCloud, not Nominatim
Nominatim returned 429 for our IP because the read-time derivation runs from
every viewer on every load — its usage policy forbids that, and a 429 makes
reverseGeocodeRegions() (which returns empty on !res.ok) fail silently, freezing
the resolve counter and stalling the region card.

The stats fallback now uses BigDataCloud's reverse-geocode-client (no key, CORS
on both hops, English countryName/countryCode/city), spaced ~300ms. Nominatim
stays for create/edit geocoding only (author-initiated, one call per spot).
/privacy now names both providers.
2026-10-01 14:31:49 +07:00
SkateSpots
7bad809878 feat(stats): derive region from a pin's coordinates when it has no tag
The region card only placed pins carrying a city/country tag, so legacy pins
(coords but no tag) drew on the map yet landed in 'Uncategorised' — the map and
the stats disagreed. useGrowthStats now falls back to a cached reverse geocode
of the pin's own coordinates (lib/derivedRegions, localStorage keyed by ~110m
coords). useDeriveRegions (mounted on /stats) fills cache misses at 1.1s spacing
for Nominatim's 1 req/s policy and invalidates growth-stats; the card shows a
'Resolving N...' note meanwhile. Display-only fallback; the admin backfill still
writes real tags. Read-time geocoding disclosed on /privacy.
2026-10-01 14:17:04 +07:00
SkateSpots
02b81a8b76 feat(stats): put the region backfill right on the stats page
The admin backfill lived only in Settings -> Advanced, so an admin staring at
the 'Uncategorised' row had no obvious way to fix it. Render the admin-only
RegionBackfillCard inline in the region section when uncategorised > 0, and
clarify the zero-state (remaining untagged pins belong to other mappers).
2026-10-01 14:07:14 +07:00
SkateSpots
dc0ae9bc32 feat(stats): admin backfill for legacy untagged spots/shops
Spots created before reverse geocoding have coordinates but no city/country
tag, so the map draws them while /stats can only show "Uncategorised". Counted
on the canonical relay: 54 raw -> 43 after admin-hide -> 13 tagged / 30
uncategorised, and 21 of the 30 are the two admin accounts' own pins.

useRegionBackfill finds the logged-in user's own untagged spots/shops, decodes
each pin's coords (lat/lon else geohash), reverse-geocodes via the shared
reverseGeocodeRegions(), and republishes the addressable event with the region
tags added - preserving every existing tag. Publishes to getWriteRelays()
(community + author) so the canonical stats see it, spaced 1.1s for Nominatim's
1 req/s policy. Surfaced as an admin-only RegionBackfillCard in Settings ->
Advanced.
2026-10-01 13:58:21 +07:00
SkateSpots
cc4e1cd5ca fix(spots): editing a spot no longer drops its city/country tags
EditSpotPage rebuilds eventTags from scratch, so saving a spot replaced the
addressable event WITHOUT its city/country/country_code — silently losing the
region tags it had. It also meant re-saving a legacy spot could never fix a
missing tag.

Both edit pages (and both create pages) now go through the shared
reverseGeocodeRegions()/pushRegionTags() helper, so region tags are re-derived
on every save. Re-saving a legacy spot now tags it.

Docs: AGENTS.md (any page that republishes an addressable event must
re-derive or preserve its region tags).
2026-10-01 13:37:54 +07:00
SkateSpots
6d2492d847 fix(stats): apply moderation to the stats, like the map already does
A spot the community has hidden as 'not a skate spot' no longer inflates the
community numbers.

- useGrowthStats calls useAdminModeration() and drops hidden content inside
  dedup(), so it's excluded from totalSpots, the region breakdown and the
  trusted counts (kind-agnostic: the coord is built from each event's own kind).
- The hidden-set sizes are part of the query key, so the stats recompute once
  the labels load.
- Region footnote says 'community relay only, with moderation-hidden content
  excluded'.

This narrows the map<->stats gap. The remaining deltas are legitimate and
documented in AGENTS.md (untagged pins; auto-hidden spots).
2026-10-01 13:33:51 +07:00
SkateSpots
9fe219aac6 feat(stats): reconcile the region card, and geocode shops too
Answers 'why don't the map and stats match' by making the gap visible, and
brings shops into the region breakdown.

- src/lib/geocodeRegions.ts is now the single reverse-geocode helper (spot
  create, shop create, shop edit), so the Nominatim call + Accept-Language + the
  parse rules can't drift. Never throws.
- Shops now publish city/country/country_code (previously none), so they can
  appear in the region breakdown.
- Stats: regions cover spots AND shops; a new 'Uncategorised (N)' row plus a
  '{N} of {M}' footnote let the card reconcile with the location total. Section
  retitled 'Spots & Shops by Region'.
- PERSIST_BUSTER v3 -> v4.
- /privacy: wording now 'spot or shop geocoding'.

The map deliberately still reads the community relay + the reader's relays
(forcing canonical would hide an opted-out user's own spots) - documented.
2026-10-01 13:27:44 +07:00
SkateSpots
58154bf5d5 feat(stats): stable country grouping, an Other row, and disclose Nominatim
Answers 'how are these accounted?' and fixes the privacy-page gap it exposed.

- Spot creation now also publishes ['country_code', <ISO-3166-1 alpha-2>], kept
  alongside the display 'country' name so existing search/display still work.
- Stats group countries by country_code when present, else a lowercased name;
  the label is the most common raw name in the group, so a localised spelling
  can't split one country across two rows.
- The tail beyond the top 6 is surfaced as an 'Other' row, and the section is
  relabelled 'Spots by Region' (it counts spots, not people) with a footnote.
- PERSIST_BUSTER v2 -> v3 (GrowthStats shape changed again).
- /privacy: disclose OpenStreetMap Nominatim — spot geocoding sees your IP and
  the spot's pin (never the device's GPS). It was missing from the IP list.

Docs: AGENTS.md.
2026-10-01 13:16:02 +07:00
SkateSpots
863b6a115d fix(stats): don't crash on a persisted cache from the old data shape
/stats slammed with 'Cannot read properties of undefined' for anyone whose
IndexedDB held a growth-stats cache saved before the new fields existed —
stats.topCountries.length threw.

- Bump PERSIST_BUSTER v1 -> v2 (the documented way to invalidate a shape change;
  it is wired into App.tsx).
- Read the new fields defensively (stats?.x ?? []) so a future field can't do
  this again.
- AGENTS.md: note that the smoke test cannot catch this (clean profile, no
  persisted cache).
2026-10-01 13:02:21 +07:00
SkateSpots
d32df04c79 feat(stats): honesty banner, marketplace + regions, mobile Lab collapse
Three improvements to /stats.

1. A relay outage must not read as real zeros. Every sub-query swallowed its
   error, so a failed relay rendered a full board of 0s. The queryFn now counts
   failures and returns 'partial'; the page shows 'Couldn't reach every relay —
   these numbers may be incomplete'. Badge also gains 'updated Nm ago' + Refresh.

2. Surface more of the community: Marketplace (live listings, completed trades,
   self-reported sats, cash/barter split) and Regions (top countries/cities from
   each spot's own tags). Zap receipts are counted ONLY for the top contributors
   and labelled as not-global-volume, because global zap volume isn't measurable
   on a public relay.

3. Mobile/desktop: collapse the 13 Lab cards on mobile behind 'Show all' (a
   display:contents wrapper keeps them grid items); widen max-w-6xl -> max-w-7xl;
   StatCard gains a compact prop so big sats don't overflow.

Docs: AGENTS.md community-stats note.
2026-10-01 12:54:36 +07:00
SkateSpots
c1bf6516f5 fix(map): welcome banner no longer sits under the floating search toolbar
The 'New here?' banner was a later sibling of the absolute toolbar inside the
same map container, so it became the first in-flow child at y=0 and the floating
toolbar painted over it — the search bar covered the banner.

Move the banner to be the container's first child (above the toolbar) and have
the toolbar drop into flow when the banner is shown, so it can't cover it while
still floating over the map otherwise. Desktop is unchanged (md:static).

Docs: AGENTS.md map-layout note.
2026-10-01 12:31:30 +07:00
SkateSpots
f953082217 feat(routes): give the map less of the screen; move the handoff note to /privacy
Owner feedback: the route dialog's map still dominated the phone screen, and
the 'nothing leaves to Google or Apple Maps' tagline cluttered the chrome.

- Map: 50vh -> 34vh on mobile (min-h 300 -> 220); desktop 440 -> 400. Follow
  mode 60vh -> 54vh, so the bigger map is kept only when you need it.
- Drop the visible tagline from the route header (kept sr-only for a11y); the
  promise now reads on /privacy -> 'What leaves your device' -> Directions
  ('routing stays in the app; start/destination are never handed off to Google
  or Apple Maps').

Docs: AGENTS.md map-layout note.
2026-10-01 11:21:30 +07:00
SkateSpots
d6713ba20e feat(stash): merge multi-device changes instead of clobbering
The stash is one replaceable NIP-44 blob, so a naive last-write-wins publish
silently dropped whatever another device added. Make it merge.

- StashItem.updatedAt (last edit, falls back to addedAt) and a top-level
  Stash.deleted[] of {id, at} tombstones so deletes aren't resurrected.
- export mergeStashes(): union by id, newest stamp wins, tombstones win at/after
  the last edit; tombstones pruned (180d / 500).
- fetchLatestStash() merges the live remote with the list already held (query
  cache -> closure) and toasts when the other device contributed.
- Writes now require a confirmed relay read: assertConfirmed() refuses an
  unconfirmed base so useNostrPublish can't queue a pre-signed blob that would
  overwrite a newer edit later. Reads still work offline from cached ciphertext.
- StashPage surfaces the refusal via onStashError on every mutation.

Docs: NIP.md multi-device merge section; AGENTS.md updated.
2026-10-01 09:57:19 +07:00
SkateSpots
b89136161c feat(onboarding): clearer signup steps, backup confirmation, home area
Signup and onboarding are the first impression, so tighten both.

Signup (SignupDialog):
- Add a Choose -> Profile -> Save key step indicator.
- Make backup a confirmation, not a skip: an "I've saved my recovery code"
  checkbox enables "Let's skate" (downloading the key also sets it); the old
  co-equal "Skip for now" is now a quiet link.
- Fix copy that contradicted step 3 ("nothing to remember") and warn that a
  signer may ask to approve the profile publish.
- Platform-aware signer nudge instead of implying a recommendation.

LoginArea: drop the dead SignupMethodDialog/MnemonicSignupDialog wiring (never
opened) and rename the misleading nsecSignupDialogOpen state.

Onboarding: add a "Where do you skate?" first step (Use my location caches the
fix so the map opens on the skater's area) and remove the dead disabled
"Log in first" button.
2026-10-01 09:44:47 +07:00
SkateSpots
907da9b497 feat(stash): a public want becomes a marketplace want ad
The Wishlist tab lived only in the encrypted stash, so 'I want an 8.25 deck'
was invisible to sellers. A public want (itemType wtb) now publishes a real
NIP-99 listing with listing_type=wtb (tag stash-want, price = what they'd pay),
which the Marketplace already surfaces under its WTB filter; the encrypted
Wishlist keeps the private copy.

- buildListingTags handles the want case (no showcase tag, listing_type=wtb,
  no condition); syncItem no longer skips wtb.
- StashPage drops the item.itemType !== 'wtb' showcase guards (add/edit/bulk).
- AddItemDialog defaults wants to PUBLIC (a want is meant to be seen) and the
  visibility hint + wishlist teaser explain where it goes.
2026-10-01 08:35:52 +07:00
SkateSpots
1311f699f9 feat(stash): sort + size search, public-stash preview, bulk actions
- Filter bar gains a Sort select (Newest / Value / Condition / A-Z) and search
  now matches size too.
- 'Public stash' card lists which items are public with a link to your profile
  ('see what others see').
- Bulk actions: a header Select toggle puts rows/cards into multi-select
  (checkbox + click-to-toggle, per-item actions hidden) with a fixed action bar
  -- Public / Private / For sale / Remove -- backed by new bulkSetVisibility /
  bulkSetForSale / bulkRemove mutations that each do ONE re-encrypt, plus a
  bulk-delete confirm. Showcase sync/unpublish runs per affected item.
2026-09-29 22:31:14 +07:00
SkateSpots
5a8e3688ee feat(stash): readable offline + export/import backup
The private stash was deny-listed from persistence with no local cache, so an
offline or relay-outage cold start showed an EMPTY stash even though decrypting
needs no network.

- offlineCache v2 adds a 'stash' table storing the encrypted CIPHERTEXT (never
  the decrypted list). useStash caches it on every successful fetch, and both
  the read query and fetchLatestStash (the pre-write re-fetch) fall back to it --
  so the stash loads at the park and an offline edit rebuilds from the real list
  instead of throwing.
- /stash gains a 'Backup & transfer' card (desktop sidebar + mobile) with
  Export (a JSON you control) and Import (one re-encrypt, skipping
  title+category+size duplicates).
2026-09-29 22:20:50 +07:00
SkateSpots
3f51740f6a fix(map): stop the offline banner sticking; feat(trades): notify trade confirmations
Offline banner: it keyed off a single relay probe (SKATE_RELAY) while the map
reads from several relays, so it reported 'showing saved spots' while fresh data
loaded fine - and its retry only refetched spots, never re-probed, so it appeared
impossible to clear. Now the saved-spots banner is driven by deviceOffline
(!useOnlineStatus) only, is dismissable (re-armed when the connection returns),
and both retries call refetchSpots() AND relay.recheck(). The relay probe still
gates the 'nothing at all' card.

Trade confirmations: usePendingTradeConfirmations now returns createdAt/eventId,
so 'someone confirmed your trade' becomes a trade-confirm notification - the
missing final step. Bell card + OS title cover it; confirming back is what makes
the trade mutual and builds both reputations.
2026-09-29 21:06:04 +07:00
SkateSpots
3628cd5e85 feat(routing): destination search inside the directions dialog
Tap the destination in the header to search and change where you're going:

- New DestinationSearch: searches skate spots, skate shops and BTCMap merchants
  (merchants come from a radius around the current destination - BTCMap has no
  global name search - and are labelled as such). Mounted only while the search
  is open, so opening Directions pays nothing for it; spot/shop limits match the
  map page so the cache is reused.
- RouteDialog now owns the destination internally (seeded from the prop on open,
  not on every render) so the search can change it without re-opening the dialog,
  and calls onDestinationChange so the parent stays in sync (wired in MapPage).
- The Leaflet map is no longer rebuilt when the destination changes: it's created
  from a ref once per open, and a small effect just moves the destination marker
  (the route effect re-fits the view). This avoids a teardown/recreate flash.
2026-09-29 18:10:40 +07:00
SkateSpots
51e66727d9 feat(mints): surface MintRadar's community ratings, reliability and audit data
The directory we already fetch returns review ratings, reliability, uptime and
liveness-audit stats that the client was throwing away (it only typed ~12 of ~35
fields). Now:

- MintDirectoryEntry types the community fields (rating/weighted rating/count,
  reliabilityScore, uptime 24h+7d, audit volume + recent errors, serverLocation,
  units, mint/melt methods, pubkey, discoveredAt, lastError).
- Row shows the rating (e.g. 4.6 (30)) plus reliability and 24h uptime.
- Detail sheet shows rating, reliability, uptime, location, latency, NUTs,
  units, version, plus a liveness-audit line and an honest note that written
  reviews live on MintRadar (linked), not in the directory API.

Written comments are genuinely not in MintRadar's /api/mints/known (probed a few
plausible review endpoints - all 404), so the next step for in-app comments
would be Nostr-native mint reviews.
2026-09-29 18:02:57 +07:00
SkateSpots
8394d006dd fix(map): one close button on the spot sheet
SheetContent renders a built-in close X, and SpotBottomSheet adds its own in
the header row - so tapping a spot showed two. Added an opt-in hideClose prop
to SheetContent and used it on the spot sheet (which keeps its own, placed in
the header). Also padded the new mint sheet header clear of the built-in close.
2026-09-29 17:56:01 +07:00
SkateSpots
daedf7ac57 ui: mint details sheet, crews type filter, smoother directions
- Mints: the whole row is now clickable and opens a detail sheet with every
  field the directory has (status, latency, NUTs, version, last online,
  description), Copy URL / Open / Terms actions, an Add button, and an honest
  'what is a mint + mints can rug' explainer.
- Crews: replaced the horizontally-scrolling category chips + More/Less toggle
  with one compact type control that opens a bottom sheet listing every focus
  (single-select). No more swiping the chips on a phone, and nothing that reads
  as a category when it isn't.
- Directions: the flash was three things - the dialog's scale-in animation while
  Leaflet measured itself, a bg-muted map box showing before tiles load, and an
  animated fitBounds jump when the route arrived. Fixed with a second
  invalidateSize after the animation, a dark map background, and
  animate:false on the route fit. Following is smoother too: re-centre only
  after ~6m of movement, with a gentle 0.6s pan, so GPS jitter doesn't twitch
  the map.
2026-09-29 17:47:58 +07:00
SkateSpots
e632285702 ui: compact route summary, map-first route dialog, clearer crews filter toggle
- Directions: the Distance/Time/Arrive tiles are now a compact one-line strip
  (StatTile removed), and the dialog's DOM order matches its visual order
  (header, map, vehicle row, From row, details, pinned Start) with redundant
  order-* so a dev CSS-timing hiccup can't scramble it.
- Crews: the filter expand toggle now has a divider and always shows its
  More/Less label, so it no longer reads as another crew category.
2026-09-29 17:38:01 +07:00
SkateSpots
1e0902f6b9 fix(ui): unblock the zoom control, tidy Go Pro, and make the route map bigger
Three phone issues from a screenshot review:

- Map: the floating toolbar sat on top of Leaflet's top-left zoom control.
  index.css now offsets .leaflet-top.leaflet-left by 4.25rem under max-width
  767px, scoped to the map page via a new  class
  (desktop toolbar is in flow, so no offset). Verified at 390x844.
- Sasquatch: the cramped red 8px 'Go Pro' chip is now a proper lime pill with
  the bolt, matching the panel.
- Directions: the map was 38vh and buried under the mode row. It's now 50vh
  (440px on larger screens; 60vh/62vh while following) and sits directly under
  the header, with the mode/vehicle row and 'From' row moved BELOW it. Done with
  flex order-* utilities rather than moving JSX; Start bar stays pinned.
2026-09-29 17:30:25 +07:00
SkateSpots
5bf58499ea ui(map): float the mobile toolbar so the map fills the screen
Mobile map was paying for a stacked toolbar under the header, leaving the map
~78% of the viewport.

- MapPage: the toolbar is absolute over the map on mobile (gradient fade,
  pointer-events pass-through) and static on desktop (md:static); controls get
  translucent/blurred backgrounds for legibility over the map.
- Container height now subtracts header + bottom nav only (desktop footer
  subtraction removed); Layout hides the desktop footer on full-bleed routes.
- Loading pill / offline banner nudged below the floating bar on mobile.

Measured at 390x844 (headless): map starts under the header and fills 730px =
86.5% of the viewport (was ~78%).
2026-09-29 17:05:20 +07:00
SkateSpots
ae2a1c70b8 feat(coordinator): profile discovery — publish yours, add theirs in one tap
Carrier is the existing kind-0 'cordn' field (app convention, NIP.md) — no new
kind. It now holds a coordinator *pointer* rather than only a URL.

- EditProfileForm: accepts nprofile / cordn.net link / npub / hex / http URL,
  validates via parseCoordinatorInput, normalises a bare pointer to Cordn's
  canonical add-link on save, and adds 'Use my coordinator'. Label -> Coordinator.
- NIP19Page: when the profile's cordn value parses as a pointer, render a
  Coordinator row with a one-tap Add (sets the viewer's coordinator, with toast)
  and an 'Open in Cordn' link; otherwise keep the old plain-link row.
- NIP.md: document the broadened cordn field (pointer formats + one-tap add).
- smoke test: now boots a real profile route too.
2026-09-29 16:17:16 +07:00