Commit graph

301 commits

Author SHA1 Message Date
SkateSpots
e3576fcfec Freeze/hide the Deck designer (not ready for regular users)
- Remove the deckDesigner card from the DIY hub, so it is not listed publicly.
- Gate /diy/deck-designer to platform admins: DeckDesignerPage redirects to /diy
  for everyone else and shows an "Internal preview" badge to admins.

Code and i18n keys are kept. To un-freeze, remove the Navigate guard and re-add
the card in diyGuides.ts (both noted in comments and AGENTS.md).
2026-10-05 12:53:13 +07:00
SkateSpots
b247be13b0 DIY: parametric 2D deck designer with 1:1 printable template
New /diy/deck-designer tool. src/lib/deckDesign.ts holds the pure geometry
(outline, waist, rounded ends, kicks, concave) and the exporters; the component
renders a live top view, side profile and concave section with presets, saved
locally.

Exports: true-size SVG, a tiled 1:1 PDF (A4/Letter, split across sheets with
corner registration crosses), and an R12 DXF (outline polyline + 3/16" truck
holes + centreline, $INSUNITS=4). Truck patterns: New school 53.975x41.275 mm
and Old school 63.5x41.275 mm.

Deliberately 2D: this is the outline a builder traces and cuts, not a SK8CAD
clone (SK8CAD is proprietary; only its JSCAD engine is MIT, and we copied
nothing).
2026-10-05 11:16:34 +07:00
SkateSpots
ee55669625 Deck cost calculator: convert + a searchable multi-currency selector
- getBtcRates()/useBtcRates() read every BTC→fiat rate from the ONE Coinbase
  exchange-rates call the app already made, plus sats (1e8). No new endpoint.
- The calculator's currency control is now a searchable Popover: sats + every
  fiat Coinbase reports, with a search box. Selecting a currency CONVERTS every
  money field (labour, rent, utilities, subscriptions, equipment cost, material
  order/shipping, packaging materials) by the live ratio; unitless fields
  (overhead factor, board counts, minutes, margins) are untouched. Falls back to
  relabel-only if rates aren't loaded.
- Currency symbols/formats for the common fiats.
2026-10-05 10:16:50 +07:00
SkateSpots
b90fef1d2e DIY: drop the category chips; add the deck cost calculator
- Removed the sticky category-chip row from /diy (sections still group the cards).
- New in-app tool at /diy/deck-cost (components/DeckCostCalculator.tsx): a
  self-contained pricing model — labour rate + overhead, equipment amortisation,
  operation expenses, materials (with order/unload time), process time,
  packaging, profit margin and scrap → cost per board and a fair selling price
  (no-packaging / with-packaging). State persists to localStorage; reset to the
  worked example. The "Deck cost & pricing" card now links to it instead of
  showing "Coming soon".
- Smoke test walks /diy/deck-cost.
2026-10-05 10:08:01 +07:00
SkateSpots
30d46ddfed DIY: use internal links, mark the rest "coming soon"
Reworked the DIY hub per feedback: point at our own app sections where we have
relevant content, and render the topics we don't cover yet as unclickable cards
with a "Coming soon" badge — no external links.

- Live internal cards: Map your DIY spot (/create-spot), Buy parts & gear
  (/marketplace), plus the "Do it with people" actions (Builder, crews, spot,
  listing, coaching).
- Coming-soon cards: deck building, SK8CAD, cost/pricing, ledge/box, quarterpipe,
  flatbar, concrete basics, coping, regrip, bearings/trucks, 3D-printed molds,
  mold tools.
- Dropped external URLs/sanitizeUrl from the page; updated the footer copy.
2026-10-05 10:03:11 +07:00
SkateSpots
84fde55ddd Add the DIY section (resources for boards, ramps & concrete)
New /diy hub: curated resources for making your own gear, with cross-links into
the rest of the app.

- src/lib/diyGuides.ts: seed data — categories (Boards, Ramps & wooden
  obstacles, Concrete & DIY spots, Repairs & tuning, Tools & plans), resource
  cards (Open Source Skateboards / SK8CAD / Instructables / YouTube), and an
  actions block (find a Builder, find a DIY crew, map a DIY spot, sell builds,
  find a coach).
- src/pages/DiyPage.tsx renders it (category chips + card grid + actions);
  external links go through sanitizeUrl and credit the source.
- Route /diy (lazyWithRetry) + a nav-catalog entry (id diy) so it appears in the
  desktop "More" menu and the mobile burger; also linked from the site footer
  and the map links strip. Smoke test walks /diy.
2026-10-05 09:55:26 +07:00
SkateSpots
084c002c45 Fix mobile grid overflow + add a Top Shops leaderboard
The /lab profile card and /stats Top Mappers were overflowing off the right
edge: Tailwind `grid md:grid-cols-2` (no base column) renders one implicit
`auto` track on mobile that can exceed the container when a name is long, so the
whole card got pushed off-screen. Added the base `grid-cols-1` repo-wide (plus
`min-w-0` on truncating flex/grid children).

/stats also gained a "Top Shops — most shops added" leaderboard (kind 30017)
beside Top Mappers (spots): `topShops` in useGrowthStats, a generalized
MapperRow (count + unit), and EN/ES copy. PERSIST_BUSTER → v6 (new persisted
field).
2026-10-04 18:01:49 +07:00
SkateSpots
fb42f4f0d1 Add Cashu escrow settings + a "require escrow" listing flag
- useEscrowPrefs (device-local): default lock window, preferred escrow mint,
  require-escrow default for new listings.
- EscrowSettingsCard on the Wallet (native-only, beside EscrowKeyCard).
- EscrowRequestDialog now defaults its lock window + funding mint from the prefs.
- Listings gain an optional ['escrow','required'] tag: a toggle on
  CreateListingPage (defaulted from the pref) and a 🔒 badge on the card.
2026-10-04 17:54:42 +07:00
SkateSpots
c794d5fd18 Give Lab profiles a per-craft URL: /lab/<craft>/<pubkey>/<id>
Every provider lived at /coach/<pubkey>/<id> regardless of craft (a barber got
a "coach" URL). New src/lib/labRoutes.ts maps craft kind → segment and builds
the canonical path; the router adds /lab/:craft/:pubkey/:identifier (the craft
segment is cosmetic — the page still resolves by pubkey + identifier) and keeps
/coach/... working for old links. All generated links updated: offer-form
redirects, Lab cards + "Your Lab profiles", cross-links, spot/event/admin links,
the CVM client URL, the share path, and the SPA clean-link shim.
2026-10-04 11:32:15 +07:00
SkateSpots
81b5fd0fab Add a live profile preview to every Lab offer form
Filling the long form was blind until publish. LabProfilePreview renders a
faithful profile card from the current draft — title, bio, image, location,
skills, structured rate, languages/availability/payments and gallery — with a
not-published-yet note.

- Wired into CraftProfileForm (8 crafts) and the coach + filmer/editor/
  photographer/builder pages via a Preview button next to Submit.
- parseLabExtras now accepts any { tags } so a draft tag list can be previewed
  without a published event.
- i18n for the preview copy (EN/ES).

Still open: draft/autosave and a multi-step layout.
2026-10-04 11:25:58 +07:00
SkateSpots
681eab9592 Add craft-specific detail fields with matching display sections
Per-craft freeform fields, each wired end-to-end (form + detail display) so
none are stored invisibly:

- Artist: deliverables, turnaround
- Graffiti: surfaces, max_size
- Musician: set_length, tech_rider
- Judge: travel, certifications
- Organizer: insurance, permits
- Tattoo: deposit, minimum
- Trainer: modalities
- Barber: duration
- Filmer/Editor/Photographer: turnaround, deliverables (filmer adds drone)
- Builder: lead_time, warranty

Data-driven crafts declare them via CraftProfileForm's extraFields (now with
labelKey); the bespoke pages add matching state/inputs/prefill/save.
src/lib/craftFields.ts (CRAFT_DETAIL_FIELDS) maps each tag to a display label,
rendered on the shared detail page; toggle tags render as a localized Yes.
Labels are translated (EN/ES); placeholders stay as examples.
2026-10-04 10:53:57 +07:00
SkateSpots
88a3e4d1b4 Generalize the coach profile enrichment to every Lab craft
The coach portfolio/experience/payments treatment is now a shared layer instead
of 12 copies:

- src/lib/labExtras.ts: parseLabExtras + shared option sets (languages,
  availability, structured rate, payment methods, booking fee, gallery/reel).
- components/LabExtrasFields.tsx: the self-contained form block — prefills from
  the existing event, owns its state, and reports the managed tags via onChange.
- components/LabExtrasSection.tsx: the detail block (all crafts).

Wired into CraftProfileForm (the 8 data-driven crafts) and the four bespoke
pages (filmer/editor/photographer/builder); CoachDetailPage renders the shared
section for every craft. Per-craft buying units (rateUnits) are set per craft in
becomeRoles.tsx (e.g. barber 'per cut', musician 'per gig', photographer 'per
shoot'). /lab facet filters now read parseLabExtras, so availability/languages
filter across crafts.

Coach-specific formats/levels/ages/credentials remain coach-only. Money stays
custody-free: declared methods + a booking fee paid directly to the provider.
2026-10-04 10:49:48 +07:00
SkateSpots
357d042a13 Expand coach credentials with skate roles + a free-text note
- Rename 'Federation coach' -> 'National federation coach'.
- Add 'Coaching qualification' and 'Competition judge'.
- Add a free-form cert_note so a coach can name an exact cert/issuer.

The core safety chips (first aid, CPR, safeguarding, background check,
insured) stay - they're the load-bearing trust signals. cert_note is
displayed under the chips and included in the same self-attested disclaimer.
2026-10-04 10:35:23 +07:00
SkateSpots
2db13612d1 Drop PayPal from coach payment methods
Keep Cash on site / Lightning / Cashu / Bank transfer / Other. Existing
profiles that already carry a 'paypal' payment_method still render it (raw
fallback), so no data is lost. NIP.md + AGENTS.md updated.
2026-10-04 10:32:27 +07:00
SkateSpots
080a6edf8b Add declared payment methods, PWYC and a direct booking fee for coaches
Coaching payments, kept honest and custody-free:

- payment_method (cash/lightning/cashu/bank/paypal/other) + payment_note:
  a declaration of what the coach accepts, not an integration. 'Cash on site'
  lets a walletless coach list without being nagged.
- pay_what_you_can flag (sliding scale).
- booking_fee + booking_fee_currency: a small fee to confirm a booking, paid
  DIRECTLY to the coach's kind-0 lud16 via the existing PayWithSats rail
  (mounted only while the sheet is open). Only offered for sats amounts when
  the coach has a Lightning address; copy states plainly that SkateSpots does
  not hold or refund it.

Deliberately NOT added: fiat/card rails (no KYC/custody) and a refundable
deposit (needs the native-only, unverified Cashu escrow). NIP.md documents the
model; AGENTS.md records the decisions.
2026-10-04 10:27:53 +07:00
SkateSpots
fa0b9cfe1c Fix raw i18n keys on coach chips; add structured rate + form polish
The coach option chips rendered raw 'labUi.coachForm.…' keys because the 29
per-option translation keys were referenced dynamically (t(option.key)) and had
never been added — the literal-only i18n guard missed them. Add every option
key (EN/ES) and extend scripts/check-i18n.mjs to also scan data tables for
key/titleKey/labelKey/badgeKey entries so this can't recur.

Also fix the chip layout: the global mobile 'button { min-height:44px }' rule
turned every chip into a 44px slab; add min-h-0 to the coach option chips,
language chips, Lab facet chips and Lab active-filter chips.

Remaining nice-to-haves:
- Structured rate: rate_amount + rate_currency (sats/USD/EUR/GBP) + rate_unit
  (hour/session/half-day/day/camp), rendered on the detail page; legacy
  freeform 'rate' still shows and acts as an optional note.
- Section the long coach form with a FormSection divider.
- Restyle the coach identity block from a red error-looking box to a neutral
  avatar card.
2026-10-04 10:05:11 +07:00
SkateSpots
468175a0fa Add coaching facet filters to the Lab
The filter drawer now offers per-facet coaching filters (session formats,
levels, age groups, credentials, availability, languages) alongside the
existing skill chips. Options are derived from the loaded coach profiles, so
empty facets never appear; active selections show as removable chips in the
toolbar and count toward the filter badge. Filtering is client-side, matching
the fact that these tags are not relay-indexed.

Also preserve language case in parseSkateCoach (proper nouns, not slugs).
2026-10-04 09:58:41 +07:00
SkateSpots
3e534c5ffc Add coach portfolio fields and gallery to Lab coach profile
Enrich the coach (kind 35001) beyond the shared base schema with the
experience/portfolio fields that actually win a booking:

- Session formats, levels, age groups (repeated tags, client-filtered)
- Self-attested credentials/safety (first-aid, safeguarding, …) with an
  honest 'not verified' label
- Languages spoken (free-form) and availability
- Optional booking link
- Portfolio photo gallery (up to 12) reusing PhotoDropzone + ImageLightbox
- Highlight-reel video link (YouTube/Instagram)

Option sets live in src/lib/coachOptions.ts with EN/ES keys. parseSkateCoach
exposes the new fields; NIP.md documents the tags.

Also fix the pre-existing ErrorBoundary test (ErrorFallback uses
useTranslation but the test rendered no LanguageProvider).
2026-10-04 09:44:15 +07:00
SkateSpots
90cb429dc3 fix(ui): backup-reminder timing + stray X, and event spot-link layout
- BackupReminder: wait while the signup dialog is open (the key is minted
  mid-flow), so "Save your login" no longer pops before signup is finished.
  Uses a small non-reactive signup flag set by SignupDialog.
- BackupReminder: removed the custom close button — the Sheet already renders
  its own X (the sheet's close now snoozes for the session).
- CreateEventPage: the "Link to a Skate Spot" row no longer overlaps — the
  label/hint can shrink and the Select Spot button stays put.
2026-10-03 21:18:40 +07:00
SkateSpots
127819e3da feat(nip05): multiple handles for profiles, crews and shops
A NIP-05 name maps to one key, so "who owns it" decides where it lives:
- Crew: repeatable nip05 tags (up to 5), verified against the founder key.
- Shop: same on kind 30017, verified against the owner key (new field).
- Profile: kind-0 has ONE standard nip05; extra personal handles live in a
  separate NIP-78 record (kind 30078, d=skatespots:profile-handles) so another
  client re-saving the profile can't wipe them.

Shared pieces:
- components/Nip05Badges.tsx — generic verify-and-render chip row (any owner).
- components/Nip05HandlesEditor.tsx — shared add/remove editor with live
  verified/mismatch status; used by profile, crew and shop forms.
- CrewNip05Badge now delegates to Nip05Badges; CrewForm uses the shared editor.
- Profile page shows primary + extra handles, and each crew/shop chips its own
  verified handle.
- lib/profileHandles.ts + hooks/useProfileHandles.ts (read/publish the record).
- types: SkateShop gains nip05/nip05s; parseSkateShop parses repeatable nip05s.
- NIP.md + AGENTS.md + i18n (EN/ES) updated.
2026-10-03 17:26:59 +07:00
SkateSpots
2daa0a9832 feat(crews): support multiple NIP-05 handles per crew
A crew is our own tag convention (unlike a kind-0 profile, whose nip05 is a
single string), so it can now advertise up to 5 handles, each verified
independently against the founder key.

- parseSkateCrew: collect all nip05 tags -> SkateCrew.nip05s (normalised,
  deduped, regex-validated, cap 5); nip05 stays = first (back-compat).
- CrewForm: add/remove handle rows with a live verified/mismatch/loading status
  (useCrewNip05 per row); format validation + max-5 hint; Enter adds.
- CrewNip05Badge renders every handle; CrewHero passes nip05s.
- NIP.md: nip05 documented as repeatable on kind 35006. i18n EN/ES.
2026-10-03 17:13:26 +07:00
SkateSpots
0b50ce684f feat(stash): password/key-encrypted backups for Export/Import
The stash export was plaintext JSON, so a leaked/shared file exposed the whole
gear list. Export now wraps the payload in a version-2 JSON envelope:

- password mode: scrypt (NIP-49 params, 2^16/r8/p1) -> AES-256-GCM, with the
  header (mode+cipher+KDF) bound as AAD so it can't be downgraded.
- nip44 mode: NIP-44-encrypted to your own key (no password to remember; needs
  the matching key to open).
- plaintext v1 exports still import unchanged.

New src/lib/stashBackup.ts (verified round-trip, wrong-password, tamper and
KDF-downgrade rejection) + StashBackupExportDialog/StashBackupUnlockDialog.
The import picker only prompts for a password on password-encrypted files.
Password mode needs no signer (Amber-friendly); 'My key' is hidden unless the
signer supports NIP-44.
2026-10-03 11:23:49 +07:00
SkateSpots
d566af851e fix: H14 double-pay guard + H1 claim provenance
H14 (NWC double-pay): after the 15s pay() timeout, useNWC now polls
lookupInvoice to confirm settlement before reporting failure; if the status
stays unknown it throws a coded error (PAYMENT_STATUS_UNKNOWN) and the callers
that used to fall through to another rail (useZaps, usePurchasePro,
PlatformFeePayment) now STOP and tell the user to check their wallet — no
automatic second payment.

H1 (spot claim): claim-from now stores the ORIGINAL COORDINATE
(30818:pubkey:d) rather than an ambiguous bare pubkey; SkateSpot parses
claimedFrom/claimReason; a claimed listing shows a 'Claimed listing' notice so
the duplicate is understandable. NIP.md updated. Full reader-side supersede is
documented as the remaining design item (a wrong rule could hide real spots).
2026-10-03 09:34:00 +07:00
SkateSpots
a745bdbbcf fix(trust): C5b — featured ranking requires a verified provider receipt
A ['featured', expiry] tag is self-asserted, so anyone could mint one and buy
top placement (the badge verified it, ordering didn't).

- New src/lib/featuredVerification.ts resolves the provider pubkey (cached) and
  batch-checks provider-authored kind-9735 zap receipts for the featured coords.
- useTours / useSkateEvents / useUpcomingSkateEvents / useLabProfiles now rank
  only receipts-backed featured items first.
- Availability fallback: if the provider can't be resolved or the query fails,
  the self-asserted ordering is kept, so a provider outage never blanks paid
  placement.
2026-10-02 23:28:34 +07:00
SkateSpots
e016ee20bd fix: tier 3 — medium bugs & hardening
- Tour sponsor URL/logo sanitized before href/src (SponsorSlot) — same XSS
  guard EventSponsors already uses.
- Official SkateSpots badge only shows when the NIP-05 actually verifies
  (NIP19Page), not on a self-asserted handle.
- Community stats reads are null-guarded on persisted-cache fields
  (monthly/cumulativeSpots/topMappers) — no more crash on an old cache.
- Overpass response no longer assumes .elements (food layer).
- user-listings query key is memoized (was Date.now() → refetch storm).
- Pro grant requires the l=pro label (was accepted with none).
- Verify card RSVP maps accepted/tentative (was going/interested → showed
  'No RSVP' on a valid ticket).
- Expired listings can't be bought via a stale/shared URL.
- Batch spot photo/status hooks include the relay suffix and use
  querySkateContent (relay-policy parity with useSpotReports).
- Offline queue requeues rows stuck in 'publishing' after a crash.
- querySkateContent/queryCanonical dedupe replaceable events by author+kind
  (not id) so a stale profile/relay list can't win.
- Route offline cache key includes the origin (was destination+mode only).
- Internal links use <Link> (MarketplacePage); EventCountdown reacts to
  language changes.
2026-10-02 22:26:46 +07:00
SkateSpots
9189fc6584 fix(trust): tier 2 — moderation, labels & data integrity (H2–H17)
- H2 useAdminModeration now fetches the 'cleared' label too, so an admin Undo
  actually propagates (newest-verdict-wins) instead of only working on-device.
- H3 admin takedowns publish the NIP-32 'hidden' label (useAdminHide) instead of
  an author-only kind-5 relays ignore; the cross-app hide is real now.
- H5 the /stats outage banner works: queryCanonical reports relay failures via
  onError, so 'partial' can be true and zeros are never presented as truth.
- H6 useShops got its own cache namespace (was colliding with useSkateShops).
- H7 listing NIP-05 is verified against the user's key, not merely self-asserted.
- H8 NIP-22 spot comments/transfer now use uppercase root tags (A/K/P); readers
  accept legacy #a; NIP.md corrected.
- H9 Lab profile edits preserve foreign  spot refs (crew link = 35006 only).
- H10 collection edits preserve foreign NIP-51 tags.
- H11 LAB_KINDS = all 13 provider kinds (was 35001-35005).
- H16 skip-to-content is a button that focuses <main> (was href=#main, which
  HashRouter treated as a route → 404).
- H17 CustomizeNavSheet is translated; useLocalStorage re-reads on key change and
  keeps stateRef in sync cross-tab.
- Backup 'confirmed' flag is now per-account (backup-confirmed:<pubkey>).
2026-10-02 21:30:01 +07:00
SkateSpots
7347f874ad i18n(es): batch 4 — Lab option labels, Sasquatch static FAQ, fee tiers
- labOptions.* (430 keys): all ~50 role LABELS/DESCRIPTIONS maps in types.ts
  (coach/filmer/editor/photo/builder/artist/graffiti/tattoo/trainer/musician/
  judge/organizer/barber) + TOUR_SKILL_LEVELS; 51 shared key maps in
  labelKeys.ts; consumers in becomeRoles/CraftProfileForm, LabPage,
  CoachDetailPage, the /become/* pages, TourCard/TourDetailPage/CreateTourPage/
  EventsPage. Trick names + tool/brand nouns stay English.
- sasquatchChat.* (83 keys): the free-tier STATIC_FAQ and local-result/fallback
  chat bodies (live AI already replies in-language; prompts stay English).
- feesUi.* (57 keys): usePurchasePro receipt prose + errors, PlatformFeePayment
  fee/duration tiers (LabProUpgrade/FeaturedEventUpgrade were already done).
- New src/i18n/batch4/ fragments spread into en/es.
2026-10-02 15:57:44 +07:00
SkateSpots
e357833557 i18n(es): batch 3 part 3 — shared label maps + leftover routing/Sasquatch strings
- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event
  types, gear categories/conditions, price/listings, tours, crews,
  member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds
  the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS
  copies now import it). spotStatus.statusTimeAgo() uses the locale date shim.
- formatPrice() now takes an optional t so FREE/Trade/Make Offer translate.
- Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge
  translated (routeUi.*), consumers updated.
- Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas
  translated (sasquatchUi.*).
- Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
2026-10-02 15:31:27 +07:00
SkateSpots
9357386bbd i18n(es): batch 3 part 2 — locale-aware dates & relative times
- New src/lib/dateFnsI18n.ts re-exports date-fns with formatDistanceToNow /
  formatDistanceToNowStrict / formatDistance / format defaulting to the active
  locale, plus dateLocaleCode() for native toLocale{Date,Time}String calls.
- LanguageProvider keeps the locale in sync during render, so the first paint is
  already in-language.
- Codemod: 11 date-fns imports now use the shim; ~39 native date/time calls pass
  the app locale code instead of undefined/[]/hardcoded 'en-US'. Number
  .toLocaleString() calls are deliberately untouched.
2026-10-02 15:19:38 +07:00
SkateSpots
733dc5200e chore(card): audit cosmetics — zoom preview, translated title, drop legacy fields, clean URLs
- U2: CardPreview is zoomable (click for a full-screen overlay); used in the
  studio, 'My passes' and the public card.
- U4: the page title is translated (Rider Card / Carné de Rider) — was hardcoded
  English.
- L4: removed the legacy style/skills from RiderCardConfig + CardData (the card
  derives style/level and shows only witnessed badges).
- I2: cardVerifyUrl/cardPublicUrl are clean (/verify/<naddr>, /card/<npub>) and a
  startup shim rewrites those paths to the hash (old /#/ links still work).
2026-10-02 14:53:12 +07:00
SkateSpots
ec02e4fe71 fix(card): audit fixes — share link, dup passes, dead stats, public style, holo persist
- B1: Share's clipboard fallback copied https://…/<npub> (a path HashRouter
  ignores → homepage). Now /#/card/<npub> (native share text too).
- B4: useIssuedPasses didn't dedupe by d, so a revoked pass showed twice;
  now newest-per-d like useReceivedPasses.
- B2/L5: the redesign dropped the stats row, so Spots/Zaps/Events toggles +
  useRiderStats + spotsAdded/zapsReceived/eventsCreated/memberYear were dead.
  Removed across lib/riderCard, CardData, CanvasCardData, both pages.
- B3: PublicCardPage ignored derived style/level (config.style is '' now) — it
  now derives them from the Skater profile like the studio; holographic honors
  the saved config.
- B6: holographic wasn't persisted — added to the card config/tags/parse.
- B5: 'Save PNG' is front-only while Print is both sides → label it
  'Save front (PNG)' when a back exists.
2026-10-02 14:43:43 +07:00
SkateSpots
7d51fc60a5 feat(nfc): native Android tag-writer plugin (APK) + Web NFC on web
Web NFC isn't exposed in Capacitor's WebView, so the APK needs a native writer.
Adds packages/skatespots-nfc — a local Capacitor Android plugin (Kotlin,
enableReaderMode + NDEF URL record), registered BY NAME (not bundled). lib/nfc.ts
now uses the plugin on native and Web NFC on the web. iOS can't write tags.
⚠️ UNVERIFIED (needs cap sync + a device). README documents it.
2026-10-02 14:14:31 +07:00
SkateSpots
2a165c7aad feat(nfc): write-to-NFC-tag for cards & passes (Web NFC)
- lib/nfc.ts: isNfcSupported() + writeNfcUrl() via Web NFC (Chrome/Android),
  writing ONLY the verify URL (the same pointer the QR carries).
- NfcWriteButton component (renders nothing where Web NFC is unavailable — iOS,
  desktop, Capacitor WebView — so there's no dead button).
- Wired into: the venue's issued-pass list (issuer copies a tag per holder), the
  organiser's issued list, and the holder's 'My passes' view (+ Print).
- i18n nfc.* EN/ES. Tags are passive and clonable like a QR; trust still comes
  from the relay signature, and /verify already handles taps.
2026-10-02 14:10:09 +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
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
5ccc191b32 feat(card): hero full-bleed redesign (no vertical rules)
The card had a portrait panel + dashed stub divider — two vertical lines with a
dead middle. Rebuilt as one composition: full-bleed photo/cover, a scrim, the
name + identity overlaid bottom-left, and the QR floating bottom-right. No photo
falls back to a themed gradient with a monogram watermark. Applied to all four
front types; the back is a plain list. Also: DEFAULT_RIDER_CARD.style is now ''
so a profile-less card doesn't claim 'Street'. Verified by rendering a real
profile, an app-profile, and an empty npub.
2026-10-02 12:25:36 +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
9dc7e7429d polish(card): roles/badges on their own line, QR nudged to centre
Verified both sides by rendering the public card headlessly (the studio preview
and /card/:npub use the same canvas renderer).
2026-10-02 12:06:39 +07:00
SkateSpots
3b542caade fix(card): smaller name, big right-side QR, cleaner geometry
- Name 82px -> 46px (was truncating 'KEEN HAWK' and dominating).
- QR now a proper right-hand verify stub: 214px band with a dashed divider and
  a 156px QR (was a small bottom-corner stub). Applied to all front types.
- Rebalanced geometry (portrait 244, text 338, stub 214); identity/badges sit on
  the lower band; back side stays plain typographic with a small right QR.
- Fixed a string of fillText(fitText(...)) calls that were missing their x.
2026-10-02 12:03:07 +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
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
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
4df961b015 feat(push): category-aware notifications (trades-only actually means trades-only)
- /subscribe + /native/poll/register carry the enabled categories; the server
  stores them per subscription/token and only pushes matching ones (DM wrap ->
  dms; offer/counter/accept/attestation/shipment -> trades). Absent = legacy
  (all), empty array = none.
- /pending uses a per-category activity breakdown so it only moves for the
  token's categories; the smoke test proves a DM does NOT wake a trades-only token.
- usePushNotifications/useBackgroundDelivery send categories (from device prefs)
  and expose resubscribe(); NotificationSettingsCard re-sends on toggle change.
2026-10-01 20:01:07 +07:00
SkateSpots
68c12a2297 Revert "feat(push): Android FCM (Phase 2) - native device support"
This reverts commit 5f0a9537b0.
2026-10-01 19:31:24 +07:00
SkateSpots
5f0a9537b0 feat(push): Android FCM (Phase 2) - native device support
Server: /native/subscribe + /native/unsubscribe (NIP-98 proof), FCM device store,
and FCM HTTP v1 sending (service-account OAuth2, cached ~1h); /health reports
native + device count. 503 without PUSH_FIREBASE_SERVICE_ACCOUNT_JSON.
Client: @capacitor/push-notifications (lazily imported, native-only) in
usePushNotifications - request permission, register, store the FCM token, and
subscribe via /native/subscribe; tap deep-links into the app.
Needs a Firebase project: google-services.json + the Gradle plugin (android/),
and the service-account JSON in the host .env.
2026-10-01 19:28:54 +07:00
SkateSpots
4f0416c23d feat(barter): optional good-faith deposit with a verifiable receipt
A pure swap has no invoice, so ListingTradeActions now offers a 'Good-faith
deposit' input for price_type=trade listings. Paying it stores a deposit-slot
receipt (saveReceipt(coord, r, 'deposit')); the buyer's confirm attaches it as
['proof','deposit',preimage,payment_hash] with method=trade, so a barter carries
a verifiable COST without pretending the goods were proven.
2026-10-01 18:39:30 +07:00
SkateSpots
e7c6bb1ece feat(receipts): verifiable Lightning preimage receipts (money legs)
- getPaymentHashFromBolt11 (bolt11.ts, dependency-free; matches the BOLT11 spec
  test vector) + verifyPreimage (sha256(preimage) === payment_hash).
- usePayPaymentPointer captures the preimage from NWC/WebLN/melt and returns a
  PaymentReceipt; PayWithSats.onPaid carries it; stored device-locally so the
  later confirm step attaches it; publishTrade writes
  ['proof','lnpreimage',preimage,hash] (and ['proof','deposit',...] for barter).
- useTradeReputation verifies and counts it in paymentVerified.
- Honest scope: proves PAYMENT, not delivery; money legs only - a pure hardware
  swap has no invoice, stays attested; a deposit receipt gives barter real cost.
2026-10-01 17:25:16 +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
50735d1718 feat(reputation): verifiable payment tier via NIP-57 zap receipts
A trade proof of ['proof','zap','<receiptId>'] is now checked by verifyZapReceipt
(kind-9735 signature + p = seller + decodable bolt11 amount + embedded 9734
request) and counted as paymentVerified, shown on TradeReputationCard as
'payment-verified (zap receipt)' - a real tier above the self-reported claim.
Honest ceiling: proves the seller's LNURL provider acknowledged a payment, not
that goods changed hands. NIP.md documents the zap proof variant.
2026-10-01 16:14:55 +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
fe7e5cfae4 feat(spots): tag new spots/shops by default with a BigDataCloud fallback
Create/edit geocoding used Nominatim alone, so when it failed (e.g. the 429) a
new spot published with no city/country and landed in 'Uncategorised'. Now
reverseGeocodeRegions() tries Nominatim, then falls back to BigDataCloud's
client-side reverse-geocode-client, so a spot is region-tagged by default. Still
never throws (an outage can't block a publish); the spot keeps its geohash and
the stats' read-time derivation can still place it. derivedRegions reuses the
same geocodeBigDataCloud helper (one implementation).
2026-10-01 14:37:29 +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
6091a0acd8 fix(stats): tick the region-derivation counter live and merge name/code groups
Two issues from the first derived-regions pass:
- The 'Resolving N...' counter only updated when the whole pass finished, so it
  looked frozen. fillDerivedRegions now reports progress per pin, the hook ticks
  the counter down, and the card refreshes every 5 resolved pins so the numbers
  climb while you watch.
- A country could appear twice ('United Kingdom' 1 + 1) when one pin carried a
  country_code and another only a name. Learn name->code from pins that have
  both and fold name-only pins into the same bucket.
2026-10-01 14:22:02 +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
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
257ce05a25 feat(auth): NIP-49 encrypted keys + NIP-46 nostrconnect QR login
Two Nostr-native auth upgrades (read-only npub sessions were declined).

NIP-49 (src/lib/ncryptsec.ts):
- Login: the Secret Key tab gains an 'Encrypted (NIP-49)' mode — paste
  ncryptsec1… + password -> decrypt -> login.
- Signup: the done step's key card exports a password-protected backup, which
  is safe to keep in cloud storage (unlike a raw nsec).
- Verified in Node: round-trips; wrong password, non-ncryptsec and empty
  password all rejected.

NIP-46 nostrconnect (src/lib/nostrConnect.ts):
- Desktop -> phone pairing via QR. Nostrify implements only the bunker
  direction; nostrconnect is signer-initiated, so the handshake lives here
  (ephemeral client keypair + secret, subscribe kind 24133 #p:[clientPub],
  reply {id,result:secret} signed with the ephemeral key), then hands the
  derived bunker:// URI to login.bunker().
- Try NIP-44 then NIP-04 (the transport moved NIP-04 -> NIP-44; support varies).
- LoginDialog's Bunker tab leads with the QR; bunker:// paste stays a fallback.
- NOT verified against a real signer — needs an on-device Amber check.

Docs: AGENTS.md.
2026-10-01 12:00:08 +07:00
SkateSpots
5fff51c814 feat(transit): depart/arrive time picker, mode filters, and stop boards
Builds out the rest of the transit UX from data Transitous actually provides.

- Time picker: Leave now / Depart at / Arrive by (MOTIS time + arriveBy).
- Mode filter: Bus/Tram/Metro/Train/Ferry groups -> MOTIS transitModes.
- Stop departure boards: tap a stop name -> MOTIS stoptimes, with live and
  cancelled badges and a feed-provided platform hint.
- Fares: MOTIS carries NO prices (verified live: debugOutput.fares=0,
  agencyFareUrl empty), so a 'tickets' link is shown ONLY when the feed supplies
  an operator fare URL - never an invented price.

New src/lib/transitUi.ts holds the mode vocabulary (emoji/colour/groups) so the
itinerary and the board cannot drift; StopDepartures.tsx is its own dialog.

Docs: AGENTS.md transit note.
2026-10-01 11:14:34 +07:00
SkateSpots
52ed2f89bd feat(transit): real itineraries — alternatives, leg-by-leg, mode badges
Transit showed one cryptic summary line and threw away 3 of 4 itineraries.
Surface what the MOTIS/Transitous response already contains.

routing.ts: TransitLeg gains headsign, agencyName, from/to track, stop count,
realTime and distance; RouteResult gains transitOptions (all itineraries,
fastest first). fetchTransitRoute maps every itinerary (the API returns more
than numItineraries).

RouteDialog:
- Alternatives row — tap to switch; a dedicated effect redraws the map from the
  selected itinerary, so switching never re-queries the service.
- Leg-by-leg itinerary: coloured line badge, headsign, board/alight stop + clock
  times, platform track, N stops, operator, and a 'live' badge only when the
  feed is realtime.
- Summary now reflects the selected option and shows the option count.
- 'Whole route' re-fit follows the selection.

Also clears the 2 pre-existing eslint warnings (unused StatusIcon/nip19) so the
documented zero-warning baseline holds.

Docs: AGENTS.md transit-detail note (incl. Transitous 403s a generic UA).
2026-10-01 10:58:27 +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
65d824d952 feat(offline): actually wire up the offline write queue
lib/offlineCache already had a pendingPosts table, a sync hook and an
OfflineIndicator badge, but nothing ever called queuePost - so an offline action
just failed. Now it queues.

- useNostrPublish: if isOffline() (no 15s publish timeout), or every relay
  fails, queue the ALREADY-SIGNED event and resolve. Signing happened at the
  moment of the action, so Amber is never re-prompted at sync time.
- queueSignedEvent: supersedes older queued replaceable (0/3/10000+) and
  addressable (30000+) events, prunes stale ephemeral kinds (2259/24899, 15 min,
  a late 'I'm here' is worse than none) and caps at 100.
- useSyncPendingPosts: publishes the stored signature directly (never through
  useNostrPublish, which would re-queue and duplicate), re-signs only legacy
  unsigned entries, retries every 30s, and returns failed rows so one failure
  can't strand an action.
- A module-level lock keeps ONE publisher even though OfflineIndicator is
  mounted twice (mobile + desktop headers).
- Indicator copy now says 'queued actions' and lists what works offline.
2026-09-29 21:42:50 +07:00
SkateSpots
a6aeb2feb0 feat(market): counter-offer negotiations + trade notifications
Offers were one-way (buyer proposes, seller accepts/declines) and fixed-slot
addressable events, so the buyer could never counter back and the haggling left
no trace. Now it's a thread.

- useMarketplaceOffers: every message gets a UNIQUE d (append-only) with a
  'role' tag (offer = buyer / response = seller), 'e' = the message it answers,
  and status. New counterOffer() plus respondToOffer(previous, status). Parsing
  still accepts the legacy fixed-slot d form when the role tag is absent.
- useListingOffers returns OfferThread[] (oldest first) with  = whose
  turn it is; useIncomingOffers returns the threads awaiting me (across listings).
- ListingOffersPanel: shows the volley, and accept/decline/counter (inline amount)
  for whichever side's turn it is, with withdraw on your own open offer. Counter
  notifications are nudged over DM too.
- New useTradeNotifications: incoming offers, counters and 'confirm our trade'
  requests, derived from events already addressed to the user.
- Bell: trade items render first in All and count toward the badge (vs seenAt).
- OS notifications: new 'trades' category in notificationPrefs + a trades source
  in OsNotificationBridge.
- NIP.md updated for the thread model (legacy form documented).
2026-09-29 18:33:11 +07:00
SkateSpots
ee380a5c74 feat(crew): accept a phone-run Cordn coordinator in the crew form
Crew-level Cordn chat already existed (lib/cordn, useCordnChat, CrewChat,
CrewCoordinatorCard, the crew 'coordinator' tag). The real gap: the crew form
accepted a URL only, so a coordinator run from Cordn's phone app — which yields
an nprofile, not a URL — could not be entered at all.

- lib/coordinator.ts: parseCrewCoordinatorInput / crewCoordinatorInputValue —
  the same pointer formats as Settings -> Coordinator (nprofile / cordn.net
  link / npub / hex) plus a plain endpoint URL.
- CrewForm: parses the pointer, shows live valid/invalid feedback, sets
  ['coordinator', url, pubkey?] (schema unchanged), and gains a one-tap
  'Use my coordinator' prefill from Settings.
- NIP.md: note the accepted pointer formats on the coordinator tag.
- AGENTS.md: record that crew-level Cordn chat already exists (the lesson:
  grep src before building).
2026-09-29 16:02:09 +07:00
SkateSpots
7b33d544b1 feat(coordinator): Cordn client slice — pick/add a coordinator + run-your-own hand-off
SkateSpots stays a Cordn *client*: it points at a coordinator the user
chooses or runs, and hands phone-hosting off to Cordn's own app. No embedded
coordinator, no default SkateSpots coordinator (deliberate: liability).

- src/lib/coordinator.ts: Cordn's own formats — an nprofile pointer, the
  canonical add-link https://cordn.net/chat/coordinators?c=<nprofile> (from
  Cordn packages/server/src/main.ts), default relay wss://relay.contextvm.org,
  the docker one-liner. Parses nprofile/npub/hex/link; encodes an add-link.
- src/hooks/useCoordinator.ts: device-local store + presence probe. Health is
  NOT Cordn RPC — it queries the coordinator's relays for authors:[pubkey] (its
  ContextVM announcement/heartbeat), so a private/unannounced coordinator
  honestly reads 'Not seen' rather than failing.
- src/components/CoordinatorSettingsCard.tsx + Settings -> Coordinator:
  empty/connected states, add by paste or QR scan, add-link copy + QR, and a
  'Run your own' hand-off (open Cordn, scan its QR, copy the docker command).
- smoke test now boots /settings?section=coordinator.
2026-09-29 15:11:36 +07:00
SkateSpots
d966bca148 feat(notify): scheduled event reminders (Phase 1b) + v1.5.7
An OS alarm an hour before each upcoming event the user RSVP'd going or
interested to. Because the OS holds the alarm it fires with the app fully
closed — the only closed-app notification achievable without a server.

- src/lib/reminders.ts: schedule/cancel via @capacitor/local-notifications,
  tracking handed-out ids in localStorage so withdrawn RSVPs and passed events
  are cancelled without disturbing one-off notifications. Web is a no-op.
- src/hooks/useLocalReminders.ts: reuses the existing upcoming-events and
  my-RSVPs queries; no new relay destination, no signing.
- Bridge mounts the hook only on native and only when the new 'reminders'
  pref is on; unmount cancels everything (opt-out/logout).
- Skipped the Pro-expiry reminder on purpose: it would drag the heavy
  useSasquatchPro query into a nudge.

Bumps to v1.5.7 (versionCode 10507); APK rebuilt.
2026-09-29 14:12:55 +07:00
SkateSpots
63dc4bee7d feat(notify): opt-in on-device OS notifications (Phase 1)
Mirrors the activity the app already computes (bell, crews, DMs, newsletter)
to the OS notification shade, with no new relay traffic and no new network
destination.

- src/lib/osNotify.ts: one delivery API. Web uses the Notification API; native
  uses @capacitor/local-notifications (lazily imported, so the web bundle never
  pays for it). Tap-through navigates in-app.
- src/components/OsNotificationBridge.tsx: mounted once in Layout, inert until
  opted in. Per-source silent priming + a seen-id set means history is never
  replayed as a flood, and it stays quiet while the app is visible (the bell
  already covers that).
- DMs notify from the gift-wrap ENVELOPE only (src/hooks/useDmEnvelopes.ts,
  no decryption) — so Amber is never prompted just to be told you have a
  message. Preview is honestly generic.
- src/lib/notificationPrefs.ts + useNotificationPrefs: device-local, opt-in,
  default off, per-category toggles. Settings -> Notifications UI.

Deliberately local-only: a fully closed app needs a server push, which is not
built (Phase 2 = on-device background runner; Phase 3 = content-free FCM).
2026-09-29 12:59:13 +07:00
SkateSpots
53c451ccfc fix(map+badges+sasquatch): attribution contrast/overlap, dedupe 🦧, label AI balance, persist NIP-98
- Map attribution: raise contrast (grey-500/80 → grey-200/90) and add `isolate`
  to the map container so its internal z-indexes (Leaflet panes, overlays up to
  z-2000) stay inside the map — the © OpenStreetMap line was painting OVER the
  Sasquatch panel (z-60). Panel/bubble drop to z-40 so Radix dialogs (z-50) now
  sit above it instead of under it.
- Sasquatch header: remove the duplicate 🦧 (the avatar + context subtitle carry
  it), and label the owner-only chip '⚡ AI <n>' (it is the AI credit balance, not
  a wallet balance).
- proAuth: persist the NIP-98 auth in localStorage for its validity window, so
  Amber signs once per ~11h instead of on every app load (the panel itself never
  signed — this is the first-message auth).
2026-09-28 20:16:15 +07:00
SkateSpots
5f5a954ce6 feat(profile): badges — clickable chips + a SkateSpots member badge
- lib/badges.ts + ProfileBadges: a profile shows (1) the built-in SkateSpots
  badge, derived from a nip05 ending @skate-spots.org (app logo linking to
  https://skate-spots.org/ — no storage, works for existing users), and (2) user
  badges from a kind-0 badges array ({label,url?}) rendered as clickable chips.
- NIP19Page: app badge in the header, user badges in a 'Badges' card (replaces
  the plain Sponsors section).
- EditProfileForm: Sponsors editor becomes a Badges editor (label + optional
  link); saves the badges array and retires the legacy newline sponsors field.
  Existing sponsors are read back as label-only badges, so nothing disappears.
2026-09-28 19:40:19 +07:00
SkateSpots
563ce33276 feat(pro): honest personal context, wanted-ads draft, days-left, live USD, copy
- #3 usePersonalContext decrypts the Stash ON DEMAND (unlockStash, called by
  getMyStash) instead of never — 'what's in my stash?' is now correct, while the
  globally-mounted guide still never decrypts on mount. stashRef avoids the
  stale-closure bug.
- #4 new self-contained action draft wtb_gear (NIP-99 listing_type=wtb) — a
  'wanted' gear ad. SYSTEM_PROMPT + tool handler + publish branch.
- #5 panel header shows 'Pro · Nd' days left (title = exact expiry).
- #6 tier picker computes live USD via useExchangeRate; removed the hardcoded
  ~$60k/BTC usd field from PRO_TIERS.
- #7 copy: 'skate status' not 'spot status'; trip planning stated as free.
2026-09-28 19:05:54 +07:00
SkateSpots
124cac74fa fix(pro): stack Pro proofs + owner-grant fallback
#1 Extend now actually extends. useSasquatchPro collected receipts and took the
max of (receipt.created_at + tierDays), so buying again before expiry
under-delivered. It now stacks every valid proof with renewal semantics
(expiry = max(expiry, proofTime) + days), client and server alike.

#2 Owner-grant fallback. Pro can now also be granted by an owner-authored NIP-32
kind-1985 label (org.skatespots.pro, p = user, expiry) — so Pro is not solely
dependent on the Lightning provider publishing zap receipts. New owner-only
GrantProCard (Settings -> Advanced) publishes it to getWriteRelays(); the hook
reads via querySkateContent.

Protocol: the x-sasquatch-pro payload is now {receipts,grants}; BOTH ai-proxy and
the Netlify function accept the new payload and a bare receipt (old APKs). Proxy
was deployed first and verified healthy before this commit.
2026-09-28 18:58:25 +07:00
SkateSpots
356e4d5c95 feat(ai): route production AI to our own proxy on ai.skate-spots.org
- DNS: A record ai.skate-spots.org -> 185.18.221.2 (Netlify DNS).
- TLS: Caddy on the CVM host, automatic Let's Encrypt (verified live).
- Client: production builds (web + APK) now default to
  https://ai.skate-spots.org/api/ai; dev uses local /api/ai. Override with
  VITE_SASQUATCH_AI_BASE to revert to the Netlify Function, which stays
  deployed as a fallback.
2026-09-28 13:53:46 +07:00
SkateSpots
ec06a63b01 feat(ai-proxy): run the AI proxy on the CVM host, ready to switch over
Same proxy as netlify/functions/ai.mjs, rebuilt as a standalone Bun service on
the CVM host so AI calls stop spending Netlify compute/bandwidth credits and
the sats-paid Routstr key lives on our box.

- ai-proxy/server.ts: Bun.serve on 127.0.0.1:8787 — NIP-98 + Pro gate, model
  allowlist, per-IP throttle, /api/ai + /api/ai/status. Own tsconfig
  (typecheck:ai in npm test); eslint-ignored like cvm-server.
- ai-proxy/{package.json,.env.example,skatespots-ai.service,Caddyfile,DEPLOY.md}.
- scripts/deploy-ai-proxy.sh (npm run deploy:ai): scp + bun install + unit +
  restart, mirroring deploy-cvm.sh.
- Client: one place decides the AI base, src/lib/sasquatchAi.ts (also the NIP-98
  u tag). Defaults unchanged (/api/ai on Netlify); set VITE_SASQUATCH_AI_BASE to
  move it. Fully reversible.

Deployed and verified on the host: /api/ai/status returns the live Routstr
balance. Public TLS (DNS + Caddy) is the remaining step before switching the app
over, so no client behaviour changes yet.
2026-09-28 13:32:05 +07:00
SkateSpots
d9ad9e9b29 feat(offline): cache map tiles on-device + photo mirror fallback
Map tiles: new lib/tileCache.ts (Dexie SkateSpotsTilesDB, bounded to ~3000
tiles) and lib/cachedTileLayer.ts, a Leaflet TileLayer that reads through it
(cached -> fetch+cache -> plain URL fallback). Wired into both maps (SpotMap,
RouteDialog). Chosen over a service worker so it works in the Capacitor
WebView too, where a SW never sees tile requests.

Photos: useUploadFile drops from three Blossom hosts to two and records
['fallback', '<mirror>/<sha256>']. BlossomUploader signs once and Promise.any
PUTs to every server in parallel, so the old three-host list was triple-
uploading every photo (the 'tries them in order' comment was wrong). Read
side: lib/imageFallback.ts derives same-hash mirrors and ImageWithFallback
retries them before showing the placeholder - one choke point for ~20 photo
call sites, so a dead host no longer blanks a spot.
2026-09-28 13:01:27 +07:00
SkateSpots
c79373ed05 feat(offline): the map survives a dead relay (and says so) + skater@skate-spots.org
Relay outages no longer look like 'no spots'. querySkateContent swallowed every
relay error and returned [], so a dead relay would blank the map AND overwrite
good cached data with an empty list (then persist it). Now:

- querySkateContent/queryCanonical take an opt-in 'requireAny' that throws when
  NO relay answered. Only the map's own feeds use it, so the blast radius is
  small (79 other call sites keep today's behaviour).
- useSkateSpots/useSkateSpot fall back to the local IndexedDB cache on failure,
  so the map still works at the park; they only error when nothing is saved.
- The persister refuses to persist an empty array, so an outage can't wipe the
  last-known map on the next cold start.
- MapPage shows an honest 'Offline · showing saved spots' banner (reusing the
  existing relayProbe/useRelayStatus) and a 'Can't reach the relays' card
  instead of falsely claiming 'No spots mapped yet'.

Also seeds skater@skate-spots.org for the second admin (caba497f…) and reserves
the name so nobody else can claim it.
2026-09-28 12:48:54 +07:00
SkateSpots
3c7c8bd87e feat(nip05): free name@skate-spots.org handles, claimed at signup
SkateSpots now runs its own NIP-05 registry, so a new skater gets a human
handle from minute one instead of an npub.

- netlify/functions/nip05.mjs: /.well-known/nostr.json plus
  /api/nip05/{check,claim,release}, backed by Netlify Blobs (first-party,
  no new vendor). Claims are NIP-98 signed and written onlyIfNew, so only
  the owner can claim a name for themselves. Platform names are seeded in
  code and cannot be taken or released.
- src/lib/skateNip05.ts + src/hooks/useSkateNip05.ts +
  src/components/SkateNip05Input.tsx: shared validation, debounced
  availability, and the claim control.
- SignupDialog: a 'SkateSpots name' field, prefilled from the display
  name. Claimed best-effort before the profile publish; a failure never
  blocks signup, and the profile only advertises nip05 once confirmed.
- EditProfileForm: claim a free handle from the NIP-05 field.

Verified on a draft deploy: claim -> resolves -> a second key gets 409 ->
release -> gone. tsc/eslint/101 tests green.
2026-09-28 12:13:34 +07:00
SkateSpots
f1ddf5fdf1 feat(quality): review queue for every kind, soft photo gate, empty-map nudge
(a) Review queue covers spots/shops/events/tours/gear: added { includeHidden }
    (admin-tooling only) to useSkateShops/useSkateEvents/useTours/
    useMarketplaceItems; generalised the flags to contentFlags() in spotQuality.ts
    (trust/status/photo signals stay spot-only); one component per kind so only the
    OPEN tab queries. Reports + trust are kind-agnostic; the label mechanism never
    cared about kind.
(b) Photo gate softened: removed the hard block (it punished an honest mapper with
    no photo). Publish without a photo is allowed; the spot stays Unverified, the
    success toast says so, and the queue's 'no photo' flag lets a human look.
(c) Empty-map nudge: MapPage now distinguishes a FILTER empty ('No spots match')
    from a genuinely EMPTY map ('No spots mapped yet — be the first to map your
    park', with Add-spot + Near-me). Note: must key off allLocations, not mapSpots
    (which derives from filteredSpots).

Verified in the browser: the empty-map nudge renders with its CTAs when no spots
load; the review queue renders 15 spot rows and the tabs switch (Gear shows its
own empty state). tsc/eslint clean, 101 tests, smoke PASS.
2026-09-28 09:59:09 +07:00
SkateSpots
e44f8b81a7 feat(moderation): reversible verdicts (undo) + community photos as a signal
Undo (latest verdict wins):
- MODERATION_LABEL_CLEARED = 'cleared' — the neutral undo verdict.
- useAdminModeration no longer UNIONS labels (which made a hide permanent in the
  UI); it keeps the NEWEST verdict per target (created_at, with device-local
  verdicts overlaid by timestamp), so 'cleared' supersedes an earlier
  'hidden'/'verified'. useAdminClear() publishes it.
- Local mirror is now a verdict map (skatespots_local_admin_verdicts_v1: key ->
  {verdict, at}) with migration from the legacy hidden/confirmed arrays.
- useSkateSpots(limit, { includeHidden: true }) — ADMIN TOOLING ONLY: without it
  a hidden spot leaves the list, so a verdict could never be undone. The review
  queue uses it, keeps actioned rows (Hidden/Confirmed badge + Undo), and sorts
  un-actioned first.

Community photos as a signal:
- useBatchSpotPhotos: ONE #a query for every spot (the single-spot hook already
  queried by #a, so batching is free), counting only photos from people OTHER
  than the mapper. Folded into the 'no community signals' flag.

Verified by seeding local verdicts: none -> flags + Confirm/Hide; hidden ->
Undo; verified -> Undo; cleared -> back to neutral. tsc/eslint clean, 101 tests,
smoke PASS.
2026-09-28 01:06:04 +07:00
SkateSpots
0419c18810 feat(moderation): positive 'verified' verdict + trust/community signals in the review queue
- MODERATION_LABEL_VERIFIED: the positive counterpart to 'hidden'. Both labels
  are fetched in ONE query; useAdminModeration now exposes verifiedCoords/
  verifiedIds/isVerified() with a local mirror (addLocalConfirmed/
  getLocalConfirmed). useAdminVerify() publishes it (shares the publish path
  with useAdminHide).
- Review queue: a human review can now CONFIRM a spot, not just hide it. A
  confirmed spot clears from the queue and gets a 'Verified by SkateSpots' badge
  on the spot page (staff verdict, shown separately from the community badge).
- Two more signals: mapper trust (useBatchTrustScores) and whether any skater has
  filed a status report (useBatchSpotStatus reporterCount) -> 'mapper not in your
  web of trust' / 'no community signals'. The latter only fires alongside another
  flag so a brand-new real spot isn't punished for being new.
- Flag logic moved to src/lib/spotQuality.ts so the page and AdminReviewCard share it.

Verified: the queue lists the airport entry with suspicious name + no photo + no
community signals, shows mapper trust, and offers Confirm/Hide/Not a skate
spot/Look at it.
2026-09-28 00:25:12 +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
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
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
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
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
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
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
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
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