From 563ce33276087561ef328dde08092532192f10a6 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 28 Sep 2026 19:05:54 +0700 Subject: [PATCH] feat(pro): honest personal context, wanted-ads draft, days-left, live USD, copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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. --- AGENTS.md | 9 +++++ src/components/SasquatchGuide.tsx | 35 ++++++++++++++--- src/components/SasquatchProDialog.tsx | 12 ++++-- src/hooks/usePersonalContext.ts | 40 +++++++++++++------ src/hooks/useSasquatchActions.ts | 55 ++++++++++++++++++++++++++- src/lib/sasquatchPro.ts | 10 ++--- 6 files changed, 133 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 26512fd..31e486c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,15 @@ session with **no other memory of this project**. So: the owner-only **`GrantProCard`** (Settings → Advanced), written to `getWriteRelays()` and read via `querySkateContent` (community relay + user relays); the self-signed `32671` remains advisory-only. + - **Pro UX pass (same date):** `usePersonalContext` now decrypts the Stash + **on demand** — `unlockStash()` is called by `getMyStash` when the AI + actually reads it (`stashRef` avoids the stale-closure trap) — so "what's in + my stash?" is correct, while the globally-mounted guide still never decrypts + on mount. New self-contained action draft **`wtb_gear`** ("wanted" ads, + NIP-99 `listing_type=wtb`). The panel header shows **`⚡ Pro · Nd`** days + left; the tier picker shows **live USD** via `useExchangeRate` (the + hardcoded ~$60k/BTC `usd` field was removed from `PRO_TIERS`). Copy now says + *skate status* (not *spot status*) and states trip planning is free. `sasquatchBrain.ts` caps each request (15 s) and the whole attempt (35 s); the system prompt is a real `system` message; relay results are signature-verified (`verifyEvent`) before use. The first pass **streams** diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 9270bf3..871bd59 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -265,8 +265,13 @@ Draft a gear-for-sale listing (NIP-99). Args: action:"list_gear", title(string), Use for "list my X for sale", "sell my Y". If selling an item from their stash, fill fields from get_my_stash first. Example: {"tool":"draft_action","args":{"action":"list_gear","title":"Spitfire F4 99a 53mm","price":8000,"category":"wheels","condition":"like new"}} +### draft_action → wtb_gear +Draft a "wanted" gear ad (NIP-99, listing_type wtb). Args: action:"wtb_gear", title(string), description?(string), price?(number, MAX sats they'll pay), category?("deck"|"trucks"|"wheels"|"bearings"|"shoes"|"clothing"|"other"), condition?(string), size?(string). +Use when the user is LOOKING FOR gear ("I need…", "wanted: …", "anyone selling…", "looking for…"). +Example: {"tool":"draft_action","args":{"action":"wtb_gear","title":"8.25 deck","price":6000,"category":"deck"}} + Rule: for "my/mine" questions ALWAYS use a personal tool — never guess about the user's data. -Rule: for "post/sell/list/set my status" requests use draft_action — never claim you posted something.`; +Rule: for "post/sell/list/want/set my status" requests use draft_action — never claim you posted something.`; // ─── Detect if a question needs live CVM data ───────────────────────────────── @@ -537,7 +542,7 @@ const STATIC_FAQ: Record = { 'trip planner': "Tell me a city and I'll build a day-by-day skate trip 🗺️ — spots, events, coaches and local crews, weather-aware (rainy day = crew tip for covered spots). Try 'plan me 3 days in Lisbon'. I can save it all to a Collection, and hand you off to flights/stays on the real sites (no KYC, no middleman). 🛹", 'plan a trip': "Just say 'plan me 3 days in Barcelona' (or any city) and I'll map out spots, sessions, coaches and local crews for each day. Save it as a Collection and go shred. 🗺️🛹", // ── Sasquatch Pro — answered locally so it never depends on the AI brain ── - 'sasquatch pro': "**Sasquatch Pro** 🦧⚡ is a Lightning supporter pass that unlocks the personal side of me:\n\n• 🧠 **Personal assistant** — I read your own Stash, crews, spots & listings\n• ✨ **Action drafts** — list gear, post a spot status, plan trips (you always confirm before anything publishes)\n• 💬 **Personal context** — my answers use your own data, not just public results\n• ⚡ **Support the project** — you keep SkateSpots free and ad-free\n\nFree Sasquatch stays free: public spot/event/coach/crew search, the glossary & trip planning all work without Pro. Pro just adds the *personal* stuff. Tap **⚡ Go Pro** up top. Tiers: 7 days ~2k sats, 1 month ~5k, 3 months ~12k, 1 year ~36k.", + 'sasquatch pro': "**Sasquatch Pro** 🦧⚡ is a Lightning supporter pass that unlocks the personal side of me:\n\n• 🧠 **Personal assistant** — I read your own Stash, crews, spots & listings\n• ✨ **Action drafts** — list gear, post your skate status, plan trips (you always confirm before anything publishes)\n• 💬 **Personal context** — my answers use your own data, not just public results\n• ⚡ **Support the project** — you keep SkateSpots free and ad-free\n\nFree Sasquatch stays free: public spot/event/coach/crew search, the glossary & trip planning all work without Pro. Pro just adds the *personal* stuff. Tap **⚡ Go Pro** up top. Tiers: 7 days ~2k sats, 1 month ~5k, 3 months ~12k, 1 year ~36k.", 'pro perks': "Pro perks 🦧⚡: personal assistant (reads your Stash/crews/spots/listings), action drafts (list gear, post status, plan trips — you confirm), and answers that use your own personal context. Plus you're directly supporting SkateSpots and keeping it free + ad-free. Free stays free — Pro adds the personal layer. Tap **⚡ Go Pro** up top.", 'pro version': "The Pro version of me 🦧⚡ adds a **personal assistant**: I can read your own Stash, crews, spots & listings, draft actions for you (list gear, post a status, plan a trip — you always confirm first), and use your personal context in my answers. It also directly supports the project. Free Sasquatch (spot/event/coach/crew search, glossary, trip planner) stays free. Tap **⚡ Go Pro** up top.", 'perks': "Pro perks 🦧⚡: personal assistant (your Stash/crews/spots/listings), action drafts you confirm, and answers that use your own context — plus you keep the app free & ad-free. Tap **⚡ Go Pro** up top.", @@ -637,7 +642,11 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { const { user } = useCurrentUser(); const cvm = useCVMClient(); const personal = usePersonalContext(); - const { isPro, proof } = useSasquatchPro(); + const { isPro, expiresAt, proof } = useSasquatchPro(); + // "Days left" for the header chip (owners have no expiry → no count). + const proDaysLeft = expiresAt + ? Math.max(0, Math.ceil((expiresAt - Math.floor(Date.now() / 1000)) / 86400)) + : null; // Owner-only: show the AI (Routstr) balance on the panel — fetched only while // the panel is open, so it never prompts at startup. const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey); @@ -873,7 +882,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { if (personal.available && !isPro && /\bmy (stash|gear|crew|crews|spots?|listings?|inventory)\b|what am i selling|what do i own/i.test(question)) { appendMessage({ role: 'sasquatch', - text: "That's a **Sasquatch Pro** thing 🦧⚡ — with Pro I can read your Stash, crews, spots and listings, draft gear listings and spot updates, and plan your trips. It's a Lightning supporter pass that keeps SkateSpots free & ad-free. Tap the **⚡ Go Pro** button up top to unlock it. 🛹", + text: "That's a **Sasquatch Pro** thing 🦧⚡ — with Pro I can read your Stash, crews, spots and listings, draft gear listings, wanted ads and skate statuses, and plan your trips. It's a Lightning supporter pass that keeps SkateSpots free & ad-free. Tap the **⚡ Go Pro** button up top to unlock it. 🛹", timestamp: Date.now(), }); return; @@ -1062,6 +1071,17 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { condition: typeof a.condition === 'string' ? a.condition : undefined, size: typeof a.size === 'string' ? a.size : undefined, }; + } else if (action === 'wtb_gear' && typeof a.title === 'string') { + draft = { + type: 'wtb_gear', + title: a.title, + description: typeof a.description === 'string' ? a.description : undefined, + price: typeof a.price === 'number' ? a.price : undefined, + currency: typeof a.currency === 'string' ? a.currency : undefined, + category: typeof a.category === 'string' ? a.category : undefined, + condition: typeof a.condition === 'string' ? a.condition : undefined, + size: typeof a.size === 'string' ? a.size : undefined, + }; } if (draft) { appendMessage({ @@ -1541,8 +1561,11 @@ Instructions: 🦧 Sasquatch {isPro ? ( - - ⚡ Pro + + ⚡ Pro{proDaysLeft !== null ? ` · ${proDaysLeft}d` : ''} ) : user ? ( ); })} diff --git a/src/hooks/usePersonalContext.ts b/src/hooks/usePersonalContext.ts index b11c875..193cf60 100644 --- a/src/hooks/usePersonalContext.ts +++ b/src/hooks/usePersonalContext.ts @@ -12,7 +12,7 @@ * Sasquatch tool switch can call them the same way as the public tools. */ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useStash, type StashItem } from '@/hooks/useStash'; @@ -50,23 +50,40 @@ function dedupeAddressable void>>([]); + useEffect(() => { + if (stashWanted && stash !== undefined) { + const waiters = stashWaiters.current.splice(0); + waiters.forEach((resolve) => resolve()); + } + }, [stashWanted, stash]); + + /** Decrypt the stash on demand — a genuine, user-initiated action. */ + const unlockStash = useCallback(async () => { + if (stashRef.current !== undefined) return; + setStashWanted(true); + await new Promise((resolve) => stashWaiters.current.push(resolve)); + }, []); const pubkey = user?.pubkey; /** Whether personal tools can run at all. */ const available = !!pubkey; - // ── My Stash (already decrypted client-side by useStash) ────────────────── + // ── My Stash (decrypted on demand by unlockStash) ───────────────────────── const getMyStash = useCallback(async () => { if (!pubkey) return NOT_LOGGED_IN; - const items = stash?.items ?? []; + await unlockStash(); + const items = stashRef.current?.items ?? []; const byCategory: Record = {}; for (const it of items) byCategory[it.category] = (byCategory[it.category] ?? 0) + 1; @@ -90,7 +107,7 @@ export function usePersonalContext() { for_sale: items.filter((i) => i.willingToSell).map(summary), items: items.map(summary), }; - }, [pubkey, stash]); + }, [pubkey, unlockStash]); // ── My Crews (founder or confirmed member) ──────────────────────────────── const getMyCrews = useCallback(async () => { @@ -168,6 +185,7 @@ export function usePersonalContext() { return { available, + unlockStash, getMyStash, getMyCrews, getMyListings, diff --git a/src/hooks/useSasquatchActions.ts b/src/hooks/useSasquatchActions.ts index bf85d15..3e4d125 100644 --- a/src/hooks/useSasquatchActions.ts +++ b/src/hooks/useSasquatchActions.ts @@ -39,7 +39,21 @@ export interface ListGearDraft { size?: string; } -export type SasquatchDraft = PostStatusDraft | ListGearDraft; +/** "Wanted" ad — the skater is LOOKING FOR gear (NIP-99 listing_type=wtb). */ +export interface WtbGearDraft { + type: 'wtb_gear'; + /** What they're looking for. */ + title: string; + description?: string; + /** Maximum sats they'll pay. Omit for "open". */ + price?: number; + currency?: string; + category?: string; + condition?: string; + size?: string; +} + +export type SasquatchDraft = PostStatusDraft | ListGearDraft | WtbGearDraft; // ─── Human-readable summary for the confirmation card ──────────────────────── @@ -52,6 +66,15 @@ export function summarizeDraft(d: SasquatchDraft): { title: string; lines: strin cta: 'Post status', }; } + if (d.type === 'wtb_gear') { + const lines: string[] = []; + if (d.category) lines.push(`Category: ${d.category}`); + if (d.size) lines.push(`Size: ${d.size}`); + if (d.condition) lines.push(`Condition: ${d.condition}`); + lines.push(d.price ? `Max price: ${d.price.toLocaleString()} ${d.currency ?? 'sats'}` : 'Price: open'); + if (d.description) lines.push(d.description); + return { title: `Looking for "${d.title}"`, lines, cta: 'Post wanted ad' }; + } const lines: string[] = []; if (d.category) lines.push(`Category: ${d.category}`); if (d.condition) lines.push(`Condition: ${d.condition}`); @@ -79,6 +102,36 @@ export function useSasquatchActions() { return { ok: true, label: 'Status posted 🛹' }; } + if (draft.type === 'wtb_gear') { + const now = Math.floor(Date.now() / 1000); + const identifier = `wtb-${now}-${Math.random().toString(36).slice(2, 8)}`; + const currency = draft.currency ?? 'sats'; + const tags: string[][] = [ + ['d', identifier], + ['title', draft.title.slice(0, 120)], + ['t', 'skateboarding'], + ['listing_type', 'wtb'], + ['status', 'active'], + ['published_at', String(now)], + ['alt', `Wanted: ${draft.title}`], + ]; + if (draft.category) tags.push(['t', draft.category]); + if (typeof draft.price === 'number' && draft.price > 0) { + tags.push(['price_type', 'fixed']); + tags.push(['price', String(draft.price), currency]); + } else { + tags.push(['price_type', 'offer']); + } + if (draft.condition) tags.push(['condition', draft.condition]); + if (draft.size) tags.push(['size', draft.size]); + await publishEvent({ + kind: MARKETPLACE_ITEM_KIND, + content: (draft.description ?? '').slice(0, 2000), + tags, + }); + return { ok: true, label: 'Wanted ad published 🔎' }; + } + // list_gear const now = Math.floor(Date.now() / 1000); const identifier = `gear-${now}-${Math.random().toString(36).slice(2, 8)}`; diff --git a/src/lib/sasquatchPro.ts b/src/lib/sasquatchPro.ts index fe28cf1..2fdbba4 100644 --- a/src/lib/sasquatchPro.ts +++ b/src/lib/sasquatchPro.ts @@ -28,8 +28,6 @@ export interface ProTier { label: string; days: number; sats: number; - /** Approx USD at ~$60k/BTC, for display only. */ - usd: string; /** Marketing flag. */ badge?: string; popular?: boolean; @@ -37,10 +35,10 @@ export interface ProTier { /** The four supporter tiers. Kept deliberately small to avoid choice paralysis. */ export const PRO_TIERS: ProTier[] = [ - { id: '7d', label: '7 days', days: 7, sats: 2000, usd: '~$1.20', badge: 'Try it' }, - { id: '1m', label: '1 month', days: 30, sats: 5000, usd: '~$3' }, - { id: '3m', label: '3 months', days: 90, sats: 12000, usd: '~$7', badge: '20% off', popular: true }, - { id: '1y', label: '1 year', days: 365, sats: 36000, usd: '~$21', badge: 'Best value · 40% off' }, + { id: '7d', label: '7 days', days: 7, sats: 2000, badge: 'Try it' }, + { id: '1m', label: '1 month', days: 30, sats: 5000 }, + { id: '3m', label: '3 months', days: 90, sats: 12000, badge: '20% off', popular: true }, + { id: '1y', label: '1 year', days: 365, sats: 36000, badge: 'Best value · 40% off' }, ]; export function tierById(id: string): ProTier | undefined {