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
|
the owner-only **`GrantProCard`** (Settings → Advanced), written to
|
||||||
`getWriteRelays()` and read via `querySkateContent` (community relay + user
|
`getWriteRelays()` and read via `querySkateContent` (community relay + user
|
||||||
relays); the self-signed `32671` remains advisory-only.
|
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
|
`sasquatchBrain.ts` caps each request (15 s) and the whole attempt
|
||||||
(35 s); the system prompt is a real `system` message; relay results are
|
(35 s); the system prompt is a real `system` message; relay results are
|
||||||
signature-verified (`verifyEvent`) before use. The first pass **streams**
|
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.
|
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"}}
|
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 "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 ─────────────────────────────────
|
// ─── 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). 🛹",
|
'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. 🗺️🛹",
|
'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 — 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 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.",
|
'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.",
|
'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 { user } = useCurrentUser();
|
||||||
const cvm = useCVMClient();
|
const cvm = useCVMClient();
|
||||||
const personal = usePersonalContext();
|
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
|
// Owner-only: show the AI (Routstr) balance on the panel — fetched only while
|
||||||
// the panel is open, so it never prompts at startup.
|
// the panel is open, so it never prompts at startup.
|
||||||
const isOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
|
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)) {
|
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({
|
appendMessage({
|
||||||
role: 'sasquatch',
|
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(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -1062,6 +1071,17 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
condition: typeof a.condition === 'string' ? a.condition : undefined,
|
condition: typeof a.condition === 'string' ? a.condition : undefined,
|
||||||
size: typeof a.size === 'string' ? a.size : 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) {
|
if (draft) {
|
||||||
appendMessage({
|
appendMessage({
|
||||||
|
|
@ -1541,8 +1561,11 @@ Instructions:
|
||||||
🦧 Sasquatch
|
🦧 Sasquatch
|
||||||
</span>
|
</span>
|
||||||
{isPro ? (
|
{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]">
|
<span
|
||||||
⚡ Pro
|
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>
|
</span>
|
||||||
) : user ? (
|
) : user ? (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { cn } from '@/lib/utils';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
|
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
|
||||||
import { usePurchasePro } from '@/hooks/usePurchasePro';
|
import { usePurchasePro } from '@/hooks/usePurchasePro';
|
||||||
|
import { useExchangeRate } from '@/hooks/useExchangeRate';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { PRO_TIERS, PRO_LIGHTNING_ADDRESS, type ProTier } from '@/lib/sasquatchPro';
|
import { PRO_TIERS, PRO_LIGHTNING_ADDRESS, type ProTier } from '@/lib/sasquatchPro';
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,7 +36,7 @@ import {
|
||||||
|
|
||||||
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; text: string }> = [
|
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; text: string }> = [
|
||||||
{ icon: Brain, text: 'Personal assistant — Sasquatch reads your own Stash, crews, spots & listings' },
|
{ 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: Rocket, text: 'Answers use your personal context, not just public data' },
|
||||||
{ icon: BadgeCheck, text: '⚡ Directly support SkateSpots & keep it free and ad-free' },
|
{ icon: BadgeCheck, text: '⚡ Directly support SkateSpots & keep it free and ad-free' },
|
||||||
];
|
];
|
||||||
|
|
@ -166,8 +167,8 @@ export function SasquatchProDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-400">
|
<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
|
Free Sasquatch stays free — public spot/event/coach search, trip planning
|
||||||
trick glossary. Pro adds the <span className="text-white font-semibold">personal + action</span> layer.
|
+ the full trick glossary. Pro adds the <span className="text-white font-semibold">personal + action</span> layer.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TierPicker selected={selected} onSelect={setSelected} />
|
<TierPicker selected={selected} onSelect={setSelected} />
|
||||||
|
|
@ -192,10 +193,13 @@ export function SasquatchProDialog({
|
||||||
}
|
}
|
||||||
|
|
||||||
function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: ProTier) => void }) {
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{PRO_TIERS.map((t) => {
|
{PRO_TIERS.map((t) => {
|
||||||
const active = t.id === selected.id;
|
const active = t.id === selected.id;
|
||||||
|
const usd = btcUsd ? `~$${((t.sats / 1e8) * btcUsd).toFixed(2)}` : '';
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
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-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-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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
* Sasquatch tool switch can call them the same way as the public tools.
|
* 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 { useNostr } from '@nostrify/react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useStash, type StashItem } from '@/hooks/useStash';
|
import { useStash, type StashItem } from '@/hooks/useStash';
|
||||||
|
|
@ -50,23 +50,40 @@ function dedupeAddressable<T extends { pubkey: string; kind: number; tags: strin
|
||||||
export function usePersonalContext() {
|
export function usePersonalContext() {
|
||||||
const { nostr } = useNostr();
|
const { nostr } = useNostr();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
// IMPORTANT: pass enabled:false. This hook is consumed by the Sasquatch guide,
|
// The encrypted Stash is decrypted ONLY when the AI actually reads it (see
|
||||||
// which is mounted in the GLOBAL Layout — so eagerly running useStash() here
|
// unlockStash) — never on mount. Sasquatch is mounted in the GLOBAL Layout, so
|
||||||
// fired the encrypted-stash decrypt on EVERY page. With Amber that's a popup
|
// eager decryption fired an Amber popup per navigation. This keeps that away,
|
||||||
// per navigation (the "Amber flashes when I move around" bug). We only read
|
// while making "what's in my stash?" correct when the user actually asks.
|
||||||
// the stash from cache (populated when the user actually visits the Stash
|
const [stashWanted, setStashWanted] = useState(false);
|
||||||
// page); the AI's getMyStash tool degrades gracefully to whatever is cached.
|
const { stash } = useStash({ enabled: stashWanted });
|
||||||
const { stash } = useStash({ enabled: false });
|
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;
|
const pubkey = user?.pubkey;
|
||||||
|
|
||||||
/** Whether personal tools can run at all. */
|
/** Whether personal tools can run at all. */
|
||||||
const available = !!pubkey;
|
const available = !!pubkey;
|
||||||
|
|
||||||
// ── My Stash (already decrypted client-side by useStash) ──────────────────
|
// ── My Stash (decrypted on demand by unlockStash) ─────────────────────────
|
||||||
const getMyStash = useCallback(async () => {
|
const getMyStash = useCallback(async () => {
|
||||||
if (!pubkey) return NOT_LOGGED_IN;
|
if (!pubkey) return NOT_LOGGED_IN;
|
||||||
const items = stash?.items ?? [];
|
await unlockStash();
|
||||||
|
const items = stashRef.current?.items ?? [];
|
||||||
const byCategory: Record<string, number> = {};
|
const byCategory: Record<string, number> = {};
|
||||||
for (const it of items) byCategory[it.category] = (byCategory[it.category] ?? 0) + 1;
|
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),
|
for_sale: items.filter((i) => i.willingToSell).map(summary),
|
||||||
items: items.map(summary),
|
items: items.map(summary),
|
||||||
};
|
};
|
||||||
}, [pubkey, stash]);
|
}, [pubkey, unlockStash]);
|
||||||
|
|
||||||
// ── My Crews (founder or confirmed member) ────────────────────────────────
|
// ── My Crews (founder or confirmed member) ────────────────────────────────
|
||||||
const getMyCrews = useCallback(async () => {
|
const getMyCrews = useCallback(async () => {
|
||||||
|
|
@ -168,6 +185,7 @@ export function usePersonalContext() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available,
|
available,
|
||||||
|
unlockStash,
|
||||||
getMyStash,
|
getMyStash,
|
||||||
getMyCrews,
|
getMyCrews,
|
||||||
getMyListings,
|
getMyListings,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,21 @@ export interface ListGearDraft {
|
||||||
size?: string;
|
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 ────────────────────────
|
// ─── Human-readable summary for the confirmation card ────────────────────────
|
||||||
|
|
||||||
|
|
@ -52,6 +66,15 @@ export function summarizeDraft(d: SasquatchDraft): { title: string; lines: strin
|
||||||
cta: 'Post status',
|
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[] = [];
|
const lines: string[] = [];
|
||||||
if (d.category) lines.push(`Category: ${d.category}`);
|
if (d.category) lines.push(`Category: ${d.category}`);
|
||||||
if (d.condition) lines.push(`Condition: ${d.condition}`);
|
if (d.condition) lines.push(`Condition: ${d.condition}`);
|
||||||
|
|
@ -79,6 +102,36 @@ export function useSasquatchActions() {
|
||||||
return { ok: true, label: 'Status posted 🛹' };
|
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
|
// list_gear
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const identifier = `gear-${now}-${Math.random().toString(36).slice(2, 8)}`;
|
const identifier = `gear-${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ export interface ProTier {
|
||||||
label: string;
|
label: string;
|
||||||
days: number;
|
days: number;
|
||||||
sats: number;
|
sats: number;
|
||||||
/** Approx USD at ~$60k/BTC, for display only. */
|
|
||||||
usd: string;
|
|
||||||
/** Marketing flag. */
|
/** Marketing flag. */
|
||||||
badge?: string;
|
badge?: string;
|
||||||
popular?: boolean;
|
popular?: boolean;
|
||||||
|
|
@ -37,10 +35,10 @@ export interface ProTier {
|
||||||
|
|
||||||
/** The four supporter tiers. Kept deliberately small to avoid choice paralysis. */
|
/** The four supporter tiers. Kept deliberately small to avoid choice paralysis. */
|
||||||
export const PRO_TIERS: ProTier[] = [
|
export const PRO_TIERS: ProTier[] = [
|
||||||
{ id: '7d', label: '7 days', days: 7, sats: 2000, usd: '~$1.20', badge: 'Try it' },
|
{ id: '7d', label: '7 days', days: 7, sats: 2000, badge: 'Try it' },
|
||||||
{ id: '1m', label: '1 month', days: 30, sats: 5000, usd: '~$3' },
|
{ id: '1m', label: '1 month', days: 30, sats: 5000 },
|
||||||
{ id: '3m', label: '3 months', days: 90, sats: 12000, usd: '~$7', badge: '20% off', popular: true },
|
{ id: '3m', label: '3 months', days: 90, sats: 12000, badge: '20% off', popular: true },
|
||||||
{ id: '1y', label: '1 year', days: 365, sats: 36000, usd: '~$21', badge: 'Best value · 40% off' },
|
{ id: '1y', label: '1 year', days: 365, sats: 36000, badge: 'Best value · 40% off' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function tierById(id: string): ProTier | undefined {
|
export function tierById(id: string): ProTier | undefined {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue