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:
parent
03c960456f
commit
907da9b497
3 changed files with 52 additions and 19 deletions
13
AGENTS.md
13
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),
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue