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:
parent
124cac74fa
commit
563ce33276
6 changed files with 133 additions and 28 deletions
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue