diff --git a/src/components/SasquatchActionCard.tsx b/src/components/SasquatchActionCard.tsx new file mode 100644 index 0000000..be6c3f0 --- /dev/null +++ b/src/components/SasquatchActionCard.tsx @@ -0,0 +1,121 @@ +/** + * SasquatchActionCard — the "review & confirm" card for a drafted action. + * + * Sasquatch proposes a draft; the user can tweak the key field(s), then Publish + * or Cancel. Nothing is signed or published until Publish is tapped. + */ + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Loader2, Check, X, Wand2 } from 'lucide-react'; +import { + summarizeDraft, + type SasquatchDraft, +} from '@/hooks/useSasquatchActions'; + +interface Props { + draft: SasquatchDraft; + onPublish: (draft: SasquatchDraft) => Promise; + onCancel: () => void; + /** Set once resolved so the card locks into a done/cancelled state. */ + status: 'pending' | 'publishing' | 'done' | 'cancelled'; + resultLabel?: string; +} + +export function SasquatchActionCard({ draft, onPublish, onCancel, status, resultLabel }: Props) { + const [edited, setEdited] = useState(draft); + const summary = summarizeDraft(edited); + + if (status === 'done') { + return ( +
+ {resultLabel ?? 'Done!'} +
+ ); + } + if (status === 'cancelled') { + return ( +
+ Cancelled — nothing was published. +
+ ); + } + + const busy = status === 'publishing'; + + return ( +
+
+ Draft — review & confirm +
+ +

{summary.title}

+ + {/* Editable key field(s) */} + {edited.type === 'post_status' ? ( + setEdited({ ...edited, text: e.target.value })} + disabled={busy} + className="h-9 bg-black/40 border-white/10 text-white text-sm" + placeholder="Status text" + /> + ) : ( +
+ setEdited({ ...edited, title: e.target.value })} + disabled={busy} + className="col-span-2 h-9 bg-black/40 border-white/10 text-white text-sm" + placeholder="Title" + /> + setEdited({ ...edited, price: e.target.value ? Number(e.target.value) : undefined })} + disabled={busy} + type="number" + inputMode="numeric" + className="h-9 bg-black/40 border-white/10 text-white text-sm" + placeholder="Price (sats)" + /> + setEdited({ ...edited, condition: e.target.value })} + disabled={busy} + className="h-9 bg-black/40 border-white/10 text-white text-sm" + placeholder="Condition" + /> +
+ )} + + {/* Read-only summary lines */} +
    + {summary.lines.map((l, i) => ( +
  • • {l}
  • + ))} +
+ +
+ + +
+
+ ); +} diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 23b9d77..ccf9396 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -18,6 +18,8 @@ import { useCVMClient } from '@/hooks/useCVMClient'; import { usePersonalContext } from '@/hooks/usePersonalContext'; import { useSasquatchPro } from '@/hooks/useSasquatchPro'; import { SasquatchProDialog } from '@/components/SasquatchProDialog'; +import { useSasquatchActions, type SasquatchDraft } from '@/hooks/useSasquatchActions'; +import { SasquatchActionCard } from '@/components/SasquatchActionCard'; import { callSasquatchAI } from '@/lib/sasquatchBrain'; import { answerFromGlossary } from '@/lib/glossary'; import { cn } from '@/lib/utils'; @@ -92,6 +94,12 @@ interface ChatMessage { timestamp: number; isLive?: boolean; // came from CVM live data isStreaming?: boolean; + /** A drafted action (Sasquatch Pro) awaiting user confirmation. */ + draft?: SasquatchDraft; + /** Lifecycle of the drafted action's confirmation card. */ + draftStatus?: 'pending' | 'publishing' | 'done' | 'cancelled'; + /** Success label after publishing the draft. */ + draftResult?: string; } // ─── System prompt for the AI ───────────────────────────────────────────────── @@ -201,7 +209,22 @@ Example: {"tool":"get_my_listings","args":{}} Skate spots the user created/added. Args: none. Example: {"tool":"get_my_spots","args":{}} -Rule: for "my/mine" questions ALWAYS use a personal tool — never guess about the user's data.`; +## Action drafts (Pro — you DRAFT, the user confirms before anything is published) +Never publish directly. To help the user DO something, emit a draft_action tool +call. The app shows a confirmation card; the user reviews and taps Publish. + +### draft_action → post_status +Draft a skating status (NIP-38). Args: action:"post_status", text(string, include an emoji), expiresInMinutes?(number, default 120). +Use for "post that I'm skating X", "set my status", "let people know I'm at the park". +Example: {"tool":"draft_action","args":{"action":"post_status","text":"🛹 Skating Southbank — come thru","expiresInMinutes":180}} + +### draft_action → list_gear +Draft a gear-for-sale listing (NIP-99). Args: action:"list_gear", title(string), description?(string), price?(number, sats), category?("deck"|"trucks"|"wheels"|"bearings"|"shoes"|"clothing"|"other"), condition?(string), size?(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"}} + +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.`; // ─── Detect if a question needs live CVM data ───────────────────────────────── @@ -340,6 +363,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { const cvm = useCVMClient(); const personal = usePersonalContext(); const { isPro } = useSasquatchPro(); + const { publishDraft } = useSasquatchActions(); const [proDialogOpen, setProDialogOpen] = useState(false); const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false); @@ -451,6 +475,36 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { setChatMessages((prev) => [...prev, msg]); }, []); + // Update the draft lifecycle on a specific message (identified by timestamp). + const setDraftStatus = useCallback( + (ts: number, draftStatus: ChatMessage['draftStatus'], draftResult?: string) => { + setChatMessages((prev) => + prev.map((m) => (m.timestamp === ts ? { ...m, draftStatus, draftResult } : m)), + ); + }, + [], + ); + + // Publish a confirmed draft (Pro action). Signs + publishes via the app's + // normal publish path; the human already reviewed it in the card. + const confirmDraft = useCallback( + async (ts: number, draft: SasquatchDraft) => { + setDraftStatus(ts, 'publishing'); + try { + const { label } = await publishDraft(draft); + setDraftStatus(ts, 'done', label); + } catch (err) { + appendMessage({ + role: 'sasquatch', + text: `Couldn't publish that: ${err instanceof Error ? err.message : 'unknown error'}. Try again in a sec.`, + timestamp: Date.now(), + }); + setDraftStatus(ts, 'pending'); + } + }, + [publishDraft, setDraftStatus, appendMessage], + ); + // ── Core: handle a user question ─────────────────────────────────────────── const handleSendMessage = useCallback(async (questionOverride?: string) => { @@ -534,6 +588,60 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { } catch { /* not valid JSON, treat as plain answer */ } } + // ── draft_action (Pro) — build a draft card the user must confirm ── + if (toolCall && toolCall.tool === 'draft_action') { + setStreamingText(''); + if (!isPro) { + appendMessage({ + role: 'sasquatch', + text: "Drafting actions (posting a status, listing gear) is a **Sasquatch Pro** perk 🦧⚡ — tap **⚡ Go Pro** up top to unlock it.", + timestamp: Date.now(), + }); + aiSucceeded = true; + } else { + const a = toolCall.args as Record; + const action = String(a.action ?? ''); + let draft: SasquatchDraft | null = null; + if (action === 'post_status' && typeof a.text === 'string') { + draft = { + type: 'post_status', + text: a.text, + expiresInMinutes: typeof a.expiresInMinutes === 'number' ? a.expiresInMinutes : undefined, + }; + } else if (action === 'list_gear' && typeof a.title === 'string') { + draft = { + type: 'list_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({ + role: 'sasquatch', + text: "Here's a draft — review it and tap to publish. Nothing goes live until you confirm. 🛹", + timestamp: Date.now(), + draft, + draftStatus: 'pending', + }); + } else { + appendMessage({ + role: 'sasquatch', + text: "I couldn't quite build that draft — try telling me the details again (e.g. \"list my Spitfire wheels for 8000 sats\").", + timestamp: Date.now(), + }); + } + aiSucceeded = true; + } + setStreamingText(''); + setIsThinking(false); + return; + } + if (toolCall) { const { tool: toolName, args: toolArgs } = toolCall; setStreamingText(`🔍 Querying CVM for ${toolName.replace(/_/g, ' ')}...`); @@ -926,6 +1034,17 @@ Instructions: )}
+ {msg.draft && ( +
+ confirmDraft(msg.timestamp, d)} + onCancel={() => setDraftStatus(msg.timestamp, 'cancelled')} + /> +
+ )} ))} diff --git a/src/hooks/useSasquatchActions.ts b/src/hooks/useSasquatchActions.ts new file mode 100644 index 0000000..bf85d15 --- /dev/null +++ b/src/hooks/useSasquatchActions.ts @@ -0,0 +1,116 @@ +/** + * useSasquatchActions — publish the actions Sasquatch drafts (Pro). + * ───────────────────────────────────────────────────────────────────────────── + * Sasquatch never publishes on its own. The AI proposes a structured DRAFT; the + * user reviews it in a confirmation card; only on confirm do we sign & publish + * here. All actions reuse the app's existing event schemas. + * + * Supported drafts: + * • post_status — NIP-38 skating status (kind 30315), optional expiry + * • list_gear — NIP-99 marketplace listing (kind 30402), skateboarding-tagged + */ + +import { useCallback } from 'react'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { MARKETPLACE_ITEM_KIND } from '@/lib/types'; +import { USER_STATUS_KIND } from '@/hooks/useUserStatus'; + +// ─── Draft shapes ───────────────────────────────────────────────────────────── + +export interface PostStatusDraft { + type: 'post_status'; + /** Freeform status text, e.g. "🛹 Skating Southbank". */ + text: string; + /** Minutes until it auto-expires (default 120). */ + expiresInMinutes?: number; +} + +export interface ListGearDraft { + type: 'list_gear'; + title: string; + description?: string; + /** Price in the given currency (default sats). Omit for "make offer". */ + price?: number; + currency?: string; + /** deck | trucks | wheels | bearings | shoes | clothing | other */ + category?: string; + condition?: string; + size?: string; +} + +export type SasquatchDraft = PostStatusDraft | ListGearDraft; + +// ─── Human-readable summary for the confirmation card ──────────────────────── + +export function summarizeDraft(d: SasquatchDraft): { title: string; lines: string[]; cta: string } { + if (d.type === 'post_status') { + const mins = d.expiresInMinutes ?? 120; + return { + title: 'Post a skating status', + lines: [`"${d.text}"`, `Auto-expires in ${mins} min`], + cta: 'Post status', + }; + } + const lines: string[] = []; + if (d.category) lines.push(`Category: ${d.category}`); + if (d.condition) lines.push(`Condition: ${d.condition}`); + if (d.size) lines.push(`Size: ${d.size}`); + lines.push(d.price ? `Price: ${d.price.toLocaleString()} ${d.currency ?? 'sats'}` : 'Price: make offer'); + if (d.description) lines.push(d.description); + return { title: `List "${d.title}" for sale`, lines, cta: 'Publish listing' }; +} + +// ─── The hook ───────────────────────────────────────────────────────────────── + +export function useSasquatchActions() { + const { user } = useCurrentUser(); + const { mutateAsync: publishEvent } = useNostrPublish(); + + const publishDraft = useCallback( + async (draft: SasquatchDraft): Promise<{ ok: true; label: string }> => { + if (!user) throw new Error('You must be logged in.'); + + if (draft.type === 'post_status') { + const tags: string[][] = [['d', 'general']]; + const mins = draft.expiresInMinutes ?? 120; + tags.push(['expiration', String(Math.floor(Date.now() / 1000) + mins * 60)]); + await publishEvent({ kind: USER_STATUS_KIND, content: draft.text.slice(0, 140), tags }); + return { ok: true, label: 'Status posted 🛹' }; + } + + // list_gear + const now = Math.floor(Date.now() / 1000); + const identifier = `gear-${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', 'sell'], + ['status', 'active'], + ['published_at', String(now)], + ['alt', `Gear listing: ${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: 'Listing published 💸' }; + }, + [user, publishEvent], + ); + + return { publishDraft }; +}