SkateSpots/src/hooks/useSasquatchActions.ts
shakespeare.diy 5655fbc976 Sasquatch Pro Phase 2: action drafting (draft → you confirm)
Sasquatch can now DRAFT actions from natural language and publish them
only after the user reviews & confirms — it never publishes on its own.

- useSasquatchActions: publishes confirmed drafts via the app's normal
  publish path. Supports post_status (NIP-38 kind 30315, auto-expiring)
  and list_gear (NIP-99 kind 30402, skateboarding-tagged), reusing the
  existing event schemas.
- SasquatchActionCard: inline "review & confirm" card with editable key
  fields (status text / listing title, price, condition), Publish +
  Cancel, and done/cancelled states. Nothing is signed until Publish.
- SasquatchGuide: new draft_action tool (Pro-gated). The AI emits a
  draft; a card renders in-chat; confirmDraft signs & publishes. Non-Pro
  users get an upgrade nudge. Draft lifecycle tracked per message.
- Prompt teaches the model to use draft_action for "post/sell/list/set
  my status" requests and to fill gear fields from the stash first.

Examples now working (Pro): "post that I'm skating Southbank for 3 hours"
· "list my Spitfire wheels for 8000 sats".

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-29 23:41:11 +07:00

116 lines
4.7 KiB
TypeScript

/**
* 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 };
}