diff --git a/AGENTS.md b/AGENTS.md index adedcb4..46c8383 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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), diff --git a/src/hooks/useStashShowcase.ts b/src/hooks/useStashShowcase.ts index 50b3fe8..06261d8 100644 --- a/src/hooks/useStashShowcase.ts +++ b/src/hooks/useStashShowcase.ts @@ -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-` /** 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 || '', diff --git a/src/pages/StashPage.tsx b/src/pages/StashPage.tsx index eb38d4b..117b949 100644 --- a/src/pages/StashPage.tsx +++ b/src/pages/StashPage.tsx @@ -294,7 +294,8 @@ function AddItemDialog({ onAdd, mode = 'stash', trigger }: { onAdd: (item: Omit< const isWTB = mode === 'wtb'; const fileInputRef = useRef(null); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); - const [formData, setFormData] = useState>({ category: 'deck', condition: 'good', visibility: 'private', willingToSell: !isWTB, willingToTrade: false, itemType: isWTB ? 'wtb' : 'stash', images: [] }); + const [formData, setFormData] = useState>({ 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) => { 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<

{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.')}

@@ -578,7 +581,9 @@ function EditItemDialog({ item, open, onOpenChange, onSave }: { item: StashItem;

{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.')}

@@ -733,11 +738,12 @@ export default function StashPage() { ); - // 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) => { 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() { {wishlistItems.length} -

We'll match your wishlist against friends' stashes automatically.

+

Public wants are posted to the Marketplace as “wanted” ads, so sellers can find you.