SkateSpots/src/hooks/useStashShowcase.ts
SkateSpots 907da9b497 feat(stash): a public want becomes a marketplace want ad
The Wishlist tab lived only in the encrypted stash, so 'I want an 8.25 deck'
was invisible to sellers. A public want (itemType wtb) now publishes a real
NIP-99 listing with listing_type=wtb (tag stash-want, price = what they'd pay),
which the Marketplace already surfaces under its WTB filter; the encrypted
Wishlist keeps the private copy.

- buildListingTags handles the want case (no showcase tag, listing_type=wtb,
  no condition); syncItem no longer skips wtb.
- StashPage drops the item.itemType !== 'wtb' showcase guards (add/edit/bulk).
- AddItemDialog defaults wants to PUBLIC (a want is meant to be seen) and the
  visibility hint + wishlist teaser explain where it goes.
2026-10-01 08:35:52 +07:00

238 lines
8.9 KiB
TypeScript

/**
* useStashShowcase
* ─────────────────────────────────────────────────────────────────────────────
* Public visibility for Stash items (the public Stash shown on a profile).
*
* The private Stash (kind 30001) is a single NIP-44 encrypted blob — nothing in
* it is ever readable by others. To make a Stash item genuinely *public*, we
* publish it as a SEPARATE, unencrypted NIP-99 classified listing (kind 30402),
* exactly like the marketplace. This hook keeps those public events in sync with
* the encrypted Stash and lets any profile query another user's public Stash.
*
* Hybrid model:
* • Public + NOT for sale → showcase-only. Tagged `t: stash-showcase` with a
* `["showcase", "true"]` marker and `["price", "0", "SATS"]`. Shows in the
* profile's public Stash, but is filtered OUT of the sellable marketplace.
* • Public + willingToSell/Trade → full listing. Shows in the profile's public
* Stash (badged "For Sale") AND flows into the marketplace like any listing.
*
* Each public event's `d` tag is the Stash item's stable `id`, so re-publishing
* updates in place and we can delete (NIP-09) when an item goes private/removed.
*/
import { useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import type { StashItem } from './useStash';
import type { NostrEvent } from '@nostrify/nostrify';
const LISTING_KIND = 30402; // NIP-99 Classified Listing
const SHOWCASE_TAG = 'stash-showcase';
const WANT_TAG = 'stash-want';
const D_PREFIX = 'stash-'; // public event d-tag = `stash-<itemId>`
/** A public gear item as seen on someone's profile (their public Stash). */
export interface PublicStashItem {
id: string;
identifier: string; // d-tag
pubkey: string;
title: string;
category: string;
condition?: string;
brand?: string;
size?: string;
description?: string;
images: string[];
/** true when the owner marked it for sale/trade (has a real price or trade flag) */
forSale: boolean;
willingToTrade: boolean;
priceSats?: number;
createdAt: number;
event: NostrEvent;
}
function tag(event: NostrEvent, name: string): string | undefined {
return event.tags.find((t) => t[0] === name)?.[1];
}
/** Parse a kind-30402 event that originated from a Stash item. */
export function parsePublicStashItem(event: NostrEvent): PublicStashItem | null {
if (event.kind !== LISTING_KIND) return null;
// Must be one of our showcase events.
const isShowcase = event.tags.some((t) => t[0] === 't' && t[1] === SHOWCASE_TAG);
if (!isShowcase) return null;
const identifier = tag(event, 'd');
const title = tag(event, 'title');
if (!identifier || !title) return null;
const showcaseOnly = tag(event, 'showcase') === 'true';
const willingToTrade = tag(event, 'trade') === 'true';
const priceTag = event.tags.find((t) => t[0] === 'price');
const priceSats = priceTag ? parseInt(priceTag[1] || '0', 10) : 0;
const images = event.tags.filter((t) => t[0] === 'image').map((t) => t[1]).filter(Boolean);
const categoryTags = event.tags.filter((t) => t[0] === 't' && t[1] !== SHOWCASE_TAG && t[1] !== 'skateboarding');
const category = tag(event, 'category') || categoryTags[0]?.[1] || 'other';
return {
id: event.id,
identifier,
pubkey: event.pubkey,
title,
category,
condition: tag(event, 'condition'),
brand: tag(event, 'brand'),
size: tag(event, 'size'),
description: event.content || undefined,
images,
forSale: !showcaseOnly,
willingToTrade,
priceSats: priceSats > 0 ? priceSats : undefined,
createdAt: event.created_at,
event,
};
}
/** Build the tag set for a public showcase/listing/want event from a Stash item. */
function buildListingTags(item: StashItem): string[][] {
const isWtb = item.itemType === 'wtb';
const forSale = !!(item.willingToSell || item.willingToTrade);
// A want advertises the most they'd PAY (maxPrice); a sale the least they'd TAKE.
const priceSats = isWtb
? (item.maxPrice && item.maxPrice > 0 ? item.maxPrice : 0)
: (item.minPrice && item.minPrice > 0 ? item.minPrice : 0);
const tags: string[][] = [
['d', `${D_PREFIX}${item.id}`],
['title', item.title],
['t', 'skateboarding'],
['t', isWtb ? WANT_TAG : SHOWCASE_TAG],
['t', item.category],
['category', item.category],
['published_at', String(item.addedAt || Math.floor(Date.now() / 1000))],
['alt', isWtb
? `${item.title} — gear wanted by a SkateSpots skater`
: `${item.title} — public gear from a SkateSpots stash`],
];
if (item.brand) tags.push(['brand', item.brand]);
if (item.size) tags.push(['size', item.size]);
if (item.geohash) tags.push(['g', item.geohash]);
for (const img of item.images || []) tags.push(['image', img]);
// A "condition" is meaningless for something you're looking for.
if (!isWtb && item.condition) tags.push(['condition', item.condition]);
if (isWtb) {
// Public WANT → a real NIP-99 `wtb` listing, so sellers find it in the
// Marketplace (the stash Wishlist tab keeps the private copy).
tags.push(['listing_type', 'wtb']);
tags.push(['price', String(priceSats), 'SATS']);
tags.push(['price_type', priceSats > 0 ? 'fixed' : item.willingToTrade ? 'trade' : 'offer']);
tags.push(['status', 'active']);
if (item.willingToTrade) tags.push(['trade', 'true']);
return tags;
}
if (forSale) {
// Real, sellable listing → appears in the marketplace.
tags.push(['price', String(priceSats), 'SATS']);
tags.push(['price_type', priceSats > 0 ? 'fixed' : (item.willingToTrade ? 'trade' : 'offer')]);
tags.push(['listing_type', 'selling']);
tags.push(['status', 'active']);
if (item.willingToTrade) tags.push(['trade', 'true']);
} else {
// Showcase only → visible on profile, filtered out of the sellable market.
tags.push(['price', '0', 'SATS']);
tags.push(['showcase', 'true']);
tags.push(['status', 'active']);
}
return tags;
}
/**
* Actions to keep public events in sync with the encrypted Stash.
* Call syncItem whenever an item is added/edited, and unpublishItem when it
* goes private or is removed.
*/
export function useStashShowcaseSync() {
const { user } = useCurrentUser();
const { mutateAsync: createEvent } = useNostrPublish();
/** Publish (or refresh) an item's public event when visibility === 'public'. */
const syncItem = useCallback(
async (item: StashItem) => {
if (!user?.pubkey) return;
if (item.visibility !== 'public') return; // only public items get a public event
// Public gear → profile showcase / marketplace listing.
// Public WANT → a NIP-99 `wtb` listing so sellers find it in the Marketplace.
await createEvent({
kind: LISTING_KIND,
content: item.description || '',
tags: buildListingTags(item),
});
},
[user, createEvent],
);
/** Remove an item's public event (NIP-09) when it goes private or is deleted. */
const unpublishItem = useCallback(
async (itemId: string) => {
if (!user?.pubkey) return;
const dTag = `${D_PREFIX}${itemId}`;
await createEvent({
kind: 5, // NIP-09 deletion request
content: 'Removed from public showcase',
tags: [['a', `${LISTING_KIND}:${user.pubkey}:${dTag}`]],
});
},
[user, createEvent],
);
return { syncItem, unpublishItem };
}
/**
* Query a user's public Stash items for their profile.
* Returns showcase + for-sale items, newest first.
*/
export function useUserPublicStash(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['public-stash', pubkey],
enabled: !!pubkey,
staleTime: 60_000,
queryFn: async ({ signal }): Promise<PublicStashItem[]> => {
if (!pubkey) return [];
const timeout = AbortSignal.any([signal, AbortSignal.timeout(6000)]);
const events = await nostr.query(
[{ kinds: [LISTING_KIND], authors: [pubkey], '#t': [SHOWCASE_TAG], limit: 100 }],
{ signal: timeout },
);
// Deduplicate addressable events by d-tag (keep newest), drop deleted/sold.
const byD = new Map<string, NostrEvent>();
for (const ev of events) {
const d = tag(ev, 'd');
if (!d) continue;
const existing = byD.get(d);
if (!existing || ev.created_at > existing.created_at) byD.set(d, ev);
}
const items: PublicStashItem[] = [];
for (const ev of byD.values()) {
const status = tag(ev, 'status');
if (status === 'sold') continue;
const parsed = parsePublicStashItem(ev);
if (parsed) items.push(parsed);
}
return items.sort((a, b) => b.createdAt - a.createdAt);
},
});
}