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.
This commit is contained in:
SkateSpots 2026-09-28 19:05:54 +07:00
parent 124cac74fa
commit 563ce33276
6 changed files with 133 additions and 28 deletions

View file

@ -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**

View file

@ -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<string, string> = {
'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
</span>
{isPro ? (
<span className="flex items-center gap-0.5 rounded bg-[#ccff00]/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-[#ccff00]">
⚡ Pro
<span
className="flex items-center gap-0.5 rounded bg-[#ccff00]/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-[#ccff00]"
title={expiresAt ? `Active until ${new Date(expiresAt * 1000).toLocaleDateString()}` : 'Platform owner'}
>
⚡ Pro{proDaysLeft !== null ? ` · ${proDaysLeft}d` : ''}
</span>
) : user ? (
<button

View file

@ -20,6 +20,7 @@ import { cn } from '@/lib/utils';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
import { usePurchasePro } from '@/hooks/usePurchasePro';
import { useExchangeRate } from '@/hooks/useExchangeRate';
import { useToast } from '@/hooks/useToast';
import { PRO_TIERS, PRO_LIGHTNING_ADDRESS, type ProTier } from '@/lib/sasquatchPro';
import {
@ -35,7 +36,7 @@ import {
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; text: string }> = [
{ icon: Brain, text: 'Personal assistant — Sasquatch reads your own Stash, crews, spots & listings' },
{ icon: Wand2, text: 'Action drafts: list gear, post a spot status, plan a trip — you confirm before anything publishes' },
{ icon: Wand2, text: 'Action drafts: list gear, post a skate status, plan a trip — you confirm before anything publishes' },
{ icon: Rocket, text: 'Answers use your personal context, not just public data' },
{ icon: BadgeCheck, text: '⚡ Directly support SkateSpots & keep it free and ad-free' },
];
@ -166,8 +167,8 @@ export function SasquatchProDialog({
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-400">
Free Sasquatch stays free — public spot/event/coach search + the full
trick glossary. Pro adds the <span className="text-white font-semibold">personal + action</span> layer.
Free Sasquatch stays free — public spot/event/coach search, trip planning
+ the full trick glossary. Pro adds the <span className="text-white font-semibold">personal + action</span> layer.
</div>
<TierPicker selected={selected} onSelect={setSelected} />
@ -192,10 +193,13 @@ export function SasquatchProDialog({
}
function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: ProTier) => void }) {
// Live BTC/USD so the fiat hint never goes stale (it used to be hardcoded).
const { data: btcUsd } = useExchangeRate();
return (
<div className="grid grid-cols-2 gap-2">
{PRO_TIERS.map((t) => {
const active = t.id === selected.id;
const usd = btcUsd ? `~$${((t.sats / 1e8) * btcUsd).toFixed(2)}` : '';
return (
<button
key={t.id}
@ -214,7 +218,7 @@ function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: P
)}
<p className="text-sm font-black text-white">{t.label}</p>
<p className="text-lg font-black text-red-400">{t.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">sats</span></p>
<p className="text-[10px] text-gray-500">{t.usd}{t.badge ? ` · ${t.badge}` : ''}</p>
<p className="text-[10px] text-gray-500">{[usd, t.badge].filter(Boolean).join(' · ')}</p>
</button>
);
})}

View file

@ -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<T extends { pubkey: string; kind: number; tags: strin
export function usePersonalContext() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
// IMPORTANT: pass enabled:false. This hook is consumed by the Sasquatch guide,
// which is mounted in the GLOBAL Layout — so eagerly running useStash() here
// fired the encrypted-stash decrypt on EVERY page. With Amber that's a popup
// per navigation (the "Amber flashes when I move around" bug). We only read
// the stash from cache (populated when the user actually visits the Stash
// page); the AI's getMyStash tool degrades gracefully to whatever is cached.
const { stash } = useStash({ enabled: false });
// The encrypted Stash is decrypted ONLY when the AI actually reads it (see
// unlockStash) — never on mount. Sasquatch is mounted in the GLOBAL Layout, so
// eager decryption fired an Amber popup per navigation. This keeps that away,
// while making "what's in my stash?" correct when the user actually asks.
const [stashWanted, setStashWanted] = useState(false);
const { stash } = useStash({ enabled: stashWanted });
const stashRef = useRef(stash);
stashRef.current = stash;
const stashWaiters = useRef<Array<() => 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<void>((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<string, number> = {};
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,

View file

@ -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)}`;

View file

@ -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 {