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.
This commit is contained in:
SkateSpots 2026-10-01 08:35:52 +07:00
parent 03c960456f
commit 907da9b497
3 changed files with 52 additions and 19 deletions

View file

@ -802,9 +802,16 @@ session with **no other memory of this project**. So:
actions hidden) with a fixed action bar: **Public / Private / For sale /
Remove** (`bulkSetVisibility`/`bulkSetForSale`/`bulkRemove`, each ONE
re-encrypt) + a bulk-delete AlertDialog; showcase sync/unpublish runs per
affected item. **Still open:** the multi-device conflict guard (the stash is
still one last-write-wins blob — per-item events are the real fix) and WTB
unification (stash Want tab vs marketplace `wtb` listings).
affected item. **WTB is unified (2026-10-01):** a **public** want
(`itemType: 'wtb'`, now defaulting to public in `AddItemDialog`) publishes a
real **NIP-99 `wtb` listing** (`useStashShowcaseSync.buildListingTags` — the
`stash-want` tag instead of `stash-showcase`, `listing_type=wtb`, `price` =
what they'd PAY), so sellers find it in the **Marketplace** (which already
filters `listing_type=wtb`), while the encrypted Wishlist tab keeps the
private copy. The old `item.itemType !== 'wtb'` showcase guards were removed
so the same sync/unpublish path covers wants. **Still open:** the multi-device
conflict guard (the stash is still one last-write-wins blob — per-item events
are the real fix).
- **Offline map tiles (done 2026-09-28):** `lib/tileCache.ts` (Dexie
`SkateSpotsTilesDB`, bounded: ≤3000 tiles, ≤500 KB each, oldest-used pruned)
+ `lib/cachedTileLayer.ts` (an `L.TileLayer` subclass that reads through it),

View file

@ -30,6 +30,7 @@ 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). */
@ -95,27 +96,45 @@ export function parsePublicStashItem(event: NostrEvent): PublicStashItem | null
};
}
/** Build the tag set for a public showcase/listing event from a Stash item. */
/** 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);
const priceSats = item.minPrice && item.minPrice > 0 ? item.minPrice : 0;
// 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', SHOWCASE_TAG],
['t', isWtb ? WANT_TAG : SHOWCASE_TAG],
['t', item.category],
['category', item.category],
['published_at', String(item.addedAt || Math.floor(Date.now() / 1000))],
['alt', `${item.title} — public gear from a SkateSpots stash`],
['alt', isWtb
? `${item.title} — gear wanted by a SkateSpots skater`
: `${item.title} — public gear from a SkateSpots stash`],
];
if (item.condition) tags.push(['condition', item.condition]);
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.
@ -148,7 +167,8 @@ export function useStashShowcaseSync() {
async (item: StashItem) => {
if (!user?.pubkey) return;
if (item.visibility !== 'public') return; // only public items get a public event
if (item.itemType === 'wtb') return; // wishlist items are not showcased
// 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 || '',

View file

@ -294,7 +294,8 @@ function AddItemDialog({ onAdd, mode = 'stash', trigger }: { onAdd: (item: Omit<
const isWTB = mode === 'wtb';
const fileInputRef = useRef<HTMLInputElement>(null);
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const [formData, setFormData] = useState<Partial<StashItem>>({ category: 'deck', condition: 'good', visibility: 'private', willingToSell: !isWTB, willingToTrade: false, itemType: isWTB ? 'wtb' : 'stash', images: [] });
const [formData, setFormData] = useState<Partial<StashItem>>({ category: 'deck', condition: 'good', // A want is meant to be seen — default it public so it reaches the marketplace.
visibility: isWTB ? 'public' : 'private', willingToSell: !isWTB, willingToTrade: false, itemType: isWTB ? 'wtb' : 'stash', images: [] });
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
@ -329,7 +330,7 @@ function AddItemDialog({ onAdd, mode = 'stash', trigger }: { onAdd: (item: Omit<
willingToTrade: formData.willingToTrade, images: formData.images,
itemType: isWTB ? 'wtb' : 'stash', storageLocation: isWTB ? undefined : formData.storageLocation,
});
setFormData({ category: 'deck', condition: 'good', visibility: 'private', willingToSell: !isWTB, willingToTrade: false, itemType: isWTB ? 'wtb' : 'stash', images: [] });
setFormData({ category: 'deck', condition: 'good', visibility: isWTB ? 'public' : 'private', willingToSell: !isWTB, willingToTrade: false, itemType: isWTB ? 'wtb' : 'stash', images: [] });
setOpen(false);
};
@ -449,7 +450,9 @@ function AddItemDialog({ onAdd, mode = 'stash', trigger }: { onAdd: (item: Omit<
<p className="text-xs text-muted-foreground">
{formData.visibility === 'private' && 'Encrypted — only visible to you.'}
{formData.visibility === 'friends' && 'Kept private for now (friends-only sharing coming soon).'}
{formData.visibility === 'public' && 'Shown in your public Stash on your profile. If it\u2019s also for sale/trade, it appears in the marketplace too.'}
{formData.visibility === 'public' && (isWTB
? 'Appears in the Marketplace as a “wanted” ad, so sellers can find you.'
: 'Shown in your public Stash on your profile. If it\u2019s also for sale/trade, it appears in the marketplace too.')}
</p>
</div>
</div>
@ -578,7 +581,9 @@ function EditItemDialog({ item, open, onOpenChange, onSave }: { item: StashItem;
<p className="text-xs text-muted-foreground">
{formData.visibility === 'private' && 'Encrypted — only visible to you.'}
{formData.visibility === 'friends' && 'Kept private for now (friends-only sharing coming soon).'}
{formData.visibility === 'public' && 'Shown in your public Stash on your profile. If it\u2019s also for sale/trade, it appears in the marketplace too.'}
{formData.visibility === 'public' && (formData.itemType === 'wtb'
? 'Appears in the Marketplace as a “wanted” ad, so sellers can find you.'
: 'Shown in your public Stash on your profile. If it\u2019s also for sale/trade, it appears in the marketplace too.')}
</p>
</div>
</div>
@ -733,11 +738,12 @@ export default function StashPage() {
</div>
);
// Add: save to encrypted stash, then publish a public event if it's public.
// Add: save to encrypted stash, then publish a public event if it's public
// (gear → showcase/listing; a WANT → a marketplace want ad).
const handleAddItem = (item: Omit<StashItem, 'id' | 'addedAt'>) => {
addItem.mutate(item, {
onSuccess: (created) => {
if (created.visibility === 'public' && created.itemType !== 'wtb') {
if (created.visibility === 'public') {
syncShowcase(created).catch(() => {});
}
},
@ -750,7 +756,7 @@ export default function StashPage() {
updateItem.mutate(updated, {
onSuccess: () => {
const wasPublic = previous?.visibility === 'public';
const isPublic = updated.visibility === 'public' && updated.itemType !== 'wtb';
const isPublic = updated.visibility === 'public';
if (isPublic) {
syncShowcase(updated).catch(() => {});
} else if (wasPublic) {
@ -814,7 +820,7 @@ export default function StashPage() {
onSuccess: ({ changed }) => {
// Keep the public showcase in step with the new visibility.
for (const it of changed) {
if (it.visibility === 'public' && it.itemType !== 'wtb') syncShowcase(it).catch(() => {});
if (it.visibility === 'public') syncShowcase(it).catch(() => {});
else unpublishShowcase(it.id).catch(() => {});
}
toast({ title: `Updated ${changed.length} item${changed.length === 1 ? '' : 's'}` });
@ -832,7 +838,7 @@ export default function StashPage() {
{
onSuccess: ({ changed }) => {
for (const it of changed) {
if (it.visibility === 'public' && it.itemType !== 'wtb') syncShowcase(it).catch(() => {});
if (it.visibility === 'public') syncShowcase(it).catch(() => {});
}
toast({ title: forSale ? `Marked ${changed.length} for sale` : `Unmarked ${changed.length}` });
exitSelecting();
@ -1347,7 +1353,7 @@ export default function StashPage() {
</div>
<span className="text-2xl font-black text-cyan-400">{wishlistItems.length}</span>
</div>
<p className="text-[11px] text-gray-500 leading-relaxed">We'll match your wishlist against friends' stashes automatically.</p>
<p className="text-[11px] text-gray-500 leading-relaxed">Public wants are posted to the Marketplace as “wanted” ads, so sellers can find you.</p>
<button onClick={() => setActiveTab('wishlist')} className="w-full text-xs font-black text-cyan-400 hover:text-cyan-300 transition-colors py-1.5 rounded-lg bg-cyan-500/8 hover:bg-cyan-500/15 border border-cyan-500/20 uppercase tracking-wider">
View Wishlist
</button>