SkateSpots/src/hooks/useSasquatchActions.ts
SkateSpots e357833557 i18n(es): batch 3 part 3 — shared label maps + leftover routing/Sasquatch strings
- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event
  types, gear categories/conditions, price/listings, tours, crews,
  member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds
  the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS
  copies now import it). spotStatus.statusTimeAgo() uses the locale date shim.
- formatPrice() now takes an optional t so FREE/Trade/Make Offer translate.
- Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge
  translated (routeUi.*), consumers updated.
- Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas
  translated (sasquatchUi.*).
- Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
2026-10-02 15:31:27 +07:00

174 lines
7.4 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 { useTranslation } from '@/contexts/language';
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
import { USER_STATUS_KIND } from '@/hooks/useUserStatus';
/** Minimal translation-function shape (this module is imported by components). */
type Translate = (key: string, params?: Record<string, string | number>) => string;
// ─── 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;
}
/** "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 ────────────────────────
export function summarizeDraft(d: SasquatchDraft, t: Translate): { title: string; lines: string[]; cta: string } {
if (d.type === 'post_status') {
const mins = d.expiresInMinutes ?? 120;
return {
title: t('sasquatchUi.draftPostStatusTitle'),
lines: [`"${d.text}"`, t('sasquatchUi.draftAutoExpires', { count: mins })],
cta: t('sasquatchUi.draftPostStatusCta'),
};
}
if (d.type === 'wtb_gear') {
const lines: string[] = [];
if (d.category) lines.push(t('sasquatchUi.draftCategory', { value: d.category }));
if (d.size) lines.push(t('sasquatchUi.draftSize', { value: d.size }));
if (d.condition) lines.push(t('sasquatchUi.draftCondition', { value: d.condition }));
lines.push(d.price ? t('sasquatchUi.draftMaxPrice', { price: d.price.toLocaleString(), currency: d.currency ?? 'sats' }) : t('sasquatchUi.draftPriceOpen'));
if (d.description) lines.push(d.description);
return { title: t('sasquatchUi.draftLookingForTitle', { title: d.title }), lines, cta: t('sasquatchUi.draftWantedCta') };
}
const lines: string[] = [];
if (d.category) lines.push(t('sasquatchUi.draftCategory', { value: d.category }));
if (d.condition) lines.push(t('sasquatchUi.draftCondition', { value: d.condition }));
if (d.size) lines.push(t('sasquatchUi.draftSize', { value: d.size }));
lines.push(d.price ? t('sasquatchUi.draftPrice', { price: d.price.toLocaleString(), currency: d.currency ?? 'sats' }) : t('sasquatchUi.draftPriceOffer'));
if (d.description) lines.push(d.description);
return { title: t('sasquatchUi.draftListTitle', { title: d.title }), lines, cta: t('sasquatchUi.draftPublishCta') };
}
// ─── The hook ─────────────────────────────────────────────────────────────────
export function useSasquatchActions() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { t } = useTranslation();
const publishDraft = useCallback(
async (draft: SasquatchDraft): Promise<{ ok: true; label: string }> => {
if (!user) throw new Error(t('sasquatchUi.mustBeLoggedIn'));
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: t('sasquatchUi.statusPosted') };
}
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: t('sasquatchUi.wantedPublished') };
}
// 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: t('sasquatchUi.listingPublished') };
},
[user, publishEvent, t],
);
return { publishDraft };
}