Public Stash items: "The Quiver" showcase + marketplace hybrid

Previously the "Public" visibility on Stash items did nothing — the
whole Stash is a single NIP-44 encrypted blob, so nobody could ever see
a "public" item. This makes Public actually mean public.

Model (hybrid A + marketplace):
- Public items are now ALSO published as separate unencrypted NIP-99
  kind 30402 events tagged `t: stash-showcase`, keyed by `stash-<id>`.
- Public + not-for-sale → showcase-only (`showcase: true`, price 0):
  shows on the profile "Quiver", filtered OUT of the sellable market.
- Public + for sale/trade → full listing: shows on the profile Quiver
  (badged For Sale/Trade) AND flows into the marketplace.
- Going private or deleting an item publishes a NIP-09 deletion for its
  public event, keeping everything consistent.

Changes:
- New hook useStashShowcase (sync/unpublish + useUserQuiver query).
- StashPage wires add/edit/remove through sync handlers; honest
  visibility copy in add + edit dialogs.
- Profile page (NIP19Page) renders a new "The Quiver" grid with
  For Sale/Trade badges + prices, and a Gear count in Contributions.
- Marketplace hooks skip showcase-only events so they don't clutter
  buy/sell listings.
- NIP.md documents the showcase event schema.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-02 19:33:34 +07:00
parent 889f6311ed
commit 32a3cbca88
5 changed files with 420 additions and 19 deletions

50
NIP.md
View file

@ -1166,9 +1166,53 @@ When decrypted, the content contains a JSON object with the user's inventory:
| Value | Description |
|-------|-------------|
| `private` | Only visible to the owner. Shared manually via DM. |
| `friends` | Visible to mutual follows. Enables P2P matching. |
| `public` | Visible to anyone who views the user's profile. |
| `private` | Only visible to the owner. Encrypted in the kind 30001 blob. |
| `friends` | Reserved for future friends-only sharing (currently treated as private). |
| `public` | Published as a separate public event (see below) and shown in the owner's profile "Quiver". |
### Public Showcase ("The Quiver")
Because the Stash (kind 30001) is a single NIP-44 encrypted blob, its contents
are never readable by others. To make an item genuinely public, the client
**also** publishes it as a separate, unencrypted **NIP-99 Classified Listing
(kind 30402)** — the same kind used by the marketplace — with a
`["t", "stash-showcase"]` tag. The public event's `d` tag is `stash-<itemId>`,
mirroring the private item's stable id so it can be updated in place or removed
via a NIP-09 deletion when the item goes private or is deleted.
Two flavors (hybrid model):
| Case | Marker tags | Where it appears |
|------|-------------|------------------|
| Public, **not** for sale | `["showcase", "true"]`, `["price", "0", "SATS"]` | Profile Quiver only (filtered out of the sellable marketplace) |
| Public **and** for sale/trade | `["price", "<sats>", "SATS"]`, `["listing_type", "selling"]`, `["status", "active"]`, optional `["trade", "true"]` | Profile Quiver (badged "For Sale") **and** the marketplace |
Clients rendering the marketplace MUST skip events carrying
`["showcase", "true"]` so showcase-only items don't pollute buy/sell listings.
Example public showcase event (not for sale):
```json
{
"kind": 30402,
"content": "My daily driver deck.",
"tags": [
["d", "stash-a1b2c3d4"],
["title", "Baker Deck 8.25"],
["t", "skateboarding"],
["t", "stash-showcase"],
["t", "deck"],
["category", "deck"],
["condition", "good"],
["brand", "Baker"],
["image", "https://blossom.example/deck.jpg"],
["price", "0", "SATS"],
["showcase", "true"],
["status", "active"],
["alt", "Baker Deck 8.25 — public gear from a SkateSpots stash"]
]
}
```
### Categories

View file

@ -30,6 +30,10 @@ export function useMarketplaceItems(limit = 100) {
const items: MarketplaceItem[] = [];
for (const event of events) {
// Skip showcase-only Stash items (public gear that is NOT for sale):
// they belong on the profile Quiver, not the sellable marketplace.
const isShowcaseOnly = event.tags.some((t) => t[0] === 'showcase' && t[1] === 'true');
if (isShowcaseOnly) continue;
const item = parseMarketplaceItem(event);
if (item) items.push(item);
}
@ -68,6 +72,8 @@ export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string,
const items: MarketplaceItem[] = [];
for (const event of events) {
const isShowcaseOnly = event.tags.some((t) => t[0] === 'showcase' && t[1] === 'true');
if (isShowcaseOnly) continue;
const item = parseMarketplaceItem(event);
if (item && (!item.status || item.status === 'active')) {
items.push(item);

View file

@ -0,0 +1,218 @@
/**
* useStashShowcase
* ─────────────────────────────────────────────────────────────────────────────
* Public visibility for Stash items ("The Quiver").
*
* 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 quiver.
*
* Hybrid model:
* • Public + NOT for sale → showcase-only. Tagged `t: stash-showcase` with a
* `["showcase", "true"]` marker and `["price", "0", "SATS"]`. Shows on the
* profile Quiver, but is filtered OUT of the sellable marketplace.
* • Public + willingToSell/Trade → full listing. Shows on the profile Quiver
* (badged "For Sale") AND flows into the marketplace like any other 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 D_PREFIX = 'stash-'; // public event d-tag = `stash-<itemId>`
/** A public gear item as seen on someone's profile. */
export interface QuiverItem {
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 parseQuiverItem(event: NostrEvent): QuiverItem | 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 event from a Stash item. */
function buildListingTags(item: StashItem): string[][] {
const forSale = !!(item.willingToSell || item.willingToTrade);
const priceSats = 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', item.category],
['category', item.category],
['published_at', String(item.addedAt || Math.floor(Date.now() / 1000))],
['alt', `${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]);
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
if (item.itemType === 'wtb') return; // wishlist items are not showcased
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 quiver (public Stash items) for their profile.
* Returns showcase + for-sale items, newest first.
*/
export function useUserQuiver(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['stash-quiver', pubkey],
enabled: !!pubkey,
staleTime: 60_000,
queryFn: async ({ signal }): Promise<QuiverItem[]> => {
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: QuiverItem[] = [];
for (const ev of byD.values()) {
const status = tag(ev, 'status');
if (status === 'sold') continue;
const parsed = parseQuiverItem(ev);
if (parsed) items.push(parsed);
}
return items.sort((a, b) => b.createdAt - a.createdAt);
},
});
}

View file

@ -13,6 +13,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
import { useUserShops } from '@/hooks/useShops';
import { useUserCrews } from '@/hooks/useCrews';
import { useUserQuiver } from '@/hooks/useStashShowcase';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { genUserName } from '@/lib/genUserName';
import NotFound from './NotFound';
@ -31,6 +32,9 @@ import {
Crown,
Bot,
Pencil,
Package,
Tag,
ArrowRightLeft,
} from 'lucide-react';
import { useState } from 'react';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -41,6 +45,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
const { data: quiver, isLoading: quiverLoading } = useUserQuiver(pubkey);
const [copied, setCopied] = useState(false);
const metadata = author.data?.metadata;
@ -297,6 +302,83 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</Card>
)}
</div>
{/* The Quiver — public gear from this skater's Stash */}
{(quiverLoading || (quiver && quiver.length > 0)) && (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<Package className="h-5 w-5 text-[#ff6600]" />
The Quiver
{quiver && quiver.length > 0 && (
<Badge className="bg-[#ff6600]/20 text-[#ff6600] border-[#ff6600]/50">
{quiver.length}
</Badge>
)}
</h2>
{isOwnProfile && (
<Link to="/stash" className="text-xs font-bold uppercase tracking-wider text-[#ff6600] hover:underline">
Manage
</Link>
)}
</div>
{quiverLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-xl" />
))}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{quiver!.map((g) => (
<div
key={g.id}
className="group rounded-xl overflow-hidden border-2 border-gray-800 bg-black/60 hover:border-[#ff6600]/50 transition-all"
>
<div className="relative aspect-square bg-gradient-to-br from-[#ff6600]/10 to-[#ff0040]/5 flex items-center justify-center overflow-hidden">
{g.images[0] ? (
<img
src={g.images[0]}
alt={g.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
) : (
<Package className="h-10 w-10 text-[#ff6600]/40" />
)}
{g.forSale && (
<span className="absolute top-2 left-2 flex items-center gap-1 text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ccff00] text-black">
{g.willingToTrade && !g.priceSats ? (
<><ArrowRightLeft className="h-2.5 w-2.5" />Trade</>
) : (
<><Tag className="h-2.5 w-2.5" />For Sale</>
)}
</span>
)}
</div>
<div className="p-2.5">
<p className="font-bold text-white text-sm truncate">{g.title}</p>
<div className="flex items-center justify-between mt-0.5">
{g.brand ? (
<p className="text-xs text-gray-500 truncate">{g.brand}</p>
) : (
<p className="text-xs text-gray-600 capitalize truncate">{g.category}</p>
)}
{g.forSale && g.priceSats ? (
<span className="flex items-center gap-0.5 text-xs font-bold text-[#ccff00] flex-shrink-0">
<Zap className="h-3 w-3" />
{g.priceSats > 999 ? `${Math.floor(g.priceSats / 1000)}k` : g.priceSats}
</span>
) : null}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Sidebar */}
@ -400,18 +482,22 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
<Card className="bg-gradient-to-br from-[#ff0040]/10 to-[#ccff00]/10 border-2 border-[#ff0040]/30">
<CardContent className="p-4">
<h3 className="text-sm font-bold uppercase text-gray-400 mb-3">Contributions</h3>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-4 gap-2">
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-[#ff0040]">{spots?.length || 0}</p>
<p className="text-xs text-gray-500 uppercase">Spots</p>
<p className="text-[10px] text-gray-500 uppercase">Spots</p>
</div>
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-[#ccff00]">{shops?.length || 0}</p>
<p className="text-xs text-gray-500 uppercase">Shops</p>
<p className="text-[10px] text-gray-500 uppercase">Shops</p>
</div>
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-teal-400">{crews.length}</p>
<p className="text-xs text-gray-500 uppercase">Crews</p>
<p className="text-[10px] text-gray-500 uppercase">Crews</p>
</div>
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-[#ff6600]">{quiver?.length || 0}</p>
<p className="text-[10px] text-gray-500 uppercase">Gear</p>
</div>
</div>
</CardContent>

View file

@ -15,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { LoginArea } from '@/components/auth/LoginArea';
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
import { useStashShowcaseSync } from '@/hooks/useStashShowcase';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useSatsToUsd } from '@/hooks/useExchangeRate';
@ -419,8 +420,8 @@ function AddItemDialog({ onAdd, mode = 'stash', trigger }: { onAdd: (item: Omit<
</Select>
<p className="text-xs text-muted-foreground">
{formData.visibility === 'private' && 'Encrypted — only visible to you.'}
{formData.visibility === 'friends' && 'Visible to people you mutually follow.'}
{formData.visibility === 'public' && 'Anyone can see this in your stash.'}
{formData.visibility === 'friends' && 'Kept private for now (friends-only sharing coming soon).'}
{formData.visibility === 'public' && 'Shown on your public profile in "The Quiver". If it\u2019s also for sale/trade, it appears in the marketplace too.'}
</p>
</div>
</div>
@ -546,6 +547,11 @@ function EditItemDialog({ item, open, onOpenChange, onSave }: { item: StashItem;
<SelectItem value="public"><div className="flex items-center gap-2"><Globe className="h-3.5 w-3.5 text-green-400" /><span>Public</span></div></SelectItem>
</SelectContent>
</Select>
<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 on your public profile in "The Quiver". If it\u2019s also for sale/trade, it appears in the marketplace too.'}
</p>
</div>
</div>
<DialogFooter>
@ -601,6 +607,47 @@ export default function StashPage() {
const navigate = useNavigate();
const { user } = useCurrentUser();
const { items, stats, isLoading, addItem, updateItem, removeItem, isEncryptionAvailable } = useStash();
const { syncItem: syncShowcase, unpublishItem: unpublishShowcase } = useStashShowcaseSync();
// Add: save to encrypted stash, then publish a public event if it's public.
const handleAddItem = (item: Omit<StashItem, 'id' | 'addedAt'>) => {
addItem.mutate(item, {
onSuccess: (created) => {
if (created.visibility === 'public' && created.itemType !== 'wtb') {
syncShowcase(created).catch(() => {});
}
},
});
};
// Edit: update the encrypted stash, then reconcile the public event.
const handleUpdateItem = (updated: StashItem) => {
const previous = items.find((i) => i.id === updated.id);
updateItem.mutate(updated, {
onSuccess: () => {
const wasPublic = previous?.visibility === 'public';
const isPublic = updated.visibility === 'public' && updated.itemType !== 'wtb';
if (isPublic) {
syncShowcase(updated).catch(() => {});
} else if (wasPublic) {
// No longer public → pull the public event down.
unpublishShowcase(updated.id).catch(() => {});
}
},
});
};
// Remove: delete from stash, and clean up any public event.
const handleRemoveItem = (itemId: string) => {
const target = items.find((i) => i.id === itemId);
removeItem.mutate(itemId, {
onSuccess: () => {
if (target?.visibility === 'public') {
unpublishShowcase(itemId).catch(() => {});
}
},
});
};
const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState<string>('all');
const [filterLocation, setFilterLocation] = useState<string>('all');
@ -740,7 +787,7 @@ export default function StashPage() {
)}
{/* Mobile-only quick add — desktop has sidebar buttons */}
<div className="flex sm:hidden">
<AddItemDialog onAdd={(item) => addItem.mutate(item)} mode="stash" />
<AddItemDialog onAdd={handleAddItem} mode="stash" />
</div>
</div>
</div>
@ -839,7 +886,7 @@ export default function StashPage() {
</div>
<p className="font-black text-white text-2xl uppercase tracking-tight mb-2">Wishlist Empty</p>
<p className="text-sm text-muted-foreground mb-7 max-w-xs leading-relaxed">Add gear you're hunting for — we'll match you with friends' stashes.</p>
<AddItemDialog onAdd={(item) => addItem.mutate(item)} mode="wtb" />
<AddItemDialog onAdd={handleAddItem} mode="wtb" />
</>
) : stashItems.length === 0 ? (
<>
@ -851,7 +898,7 @@ export default function StashPage() {
</div>
<p className="font-black text-white text-3xl uppercase tracking-tight mb-3">Your Stash is Empty</p>
<p className="text-muted-foreground mb-8 max-w-sm leading-relaxed">Start tracking your gear. Decks, trucks, shoes — add it once, access it everywhere via Nostr.</p>
<AddItemDialog onAdd={(item) => addItem.mutate(item)} />
<AddItemDialog onAdd={handleAddItem} />
</>
) : (
<>
@ -868,14 +915,14 @@ export default function StashPage() {
<div className="space-y-2">
{filteredItems.map((item) => (
<StashListRow key={item.id} item={item} isWTB={item.itemType === 'wtb'}
onEdit={() => setEditingItem(item)} onDelete={() => removeItem.mutate(item.id)} onListOnMarketplace={() => setListingItem(item)} />
onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} />
))}
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredItems.map((item) => (
<StashItemCard key={item.id} item={item} isWTB={item.itemType === 'wtb'}
onEdit={() => setEditingItem(item)} onDelete={() => removeItem.mutate(item.id)} onListOnMarketplace={() => setListingItem(item)} />
onEdit={() => setEditingItem(item)} onDelete={() => handleRemoveItem(item.id)} onListOnMarketplace={() => setListingItem(item)} />
))}
</div>
)}
@ -886,8 +933,8 @@ export default function StashPage() {
{/* Quick actions */}
<div className="flex flex-col gap-2">
<AddItemDialog onAdd={(item) => addItem.mutate(item)} mode="stash" />
<AddItemDialog onAdd={(item) => addItem.mutate(item)} mode="wtb" />
<AddItemDialog onAdd={handleAddItem} mode="stash" />
<AddItemDialog onAdd={handleAddItem} mode="wtb" />
</div>
{/* Stats card */}
@ -1063,17 +1110,17 @@ export default function StashPage() {
{/* Mobile FABs */}
<div className="fixed md:hidden z-40 flex flex-col items-end gap-2" style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 0.75rem)', right: '0.75rem' }}>
<AddItemDialog onAdd={(item) => addItem.mutate(item)} mode="wtb"
<AddItemDialog onAdd={handleAddItem} mode="wtb"
trigger={<button className="h-10 w-10 rounded-full bg-cyan-500/90 shadow-lg shadow-cyan-500/20 border border-white/20 flex items-center justify-center active:scale-90 transition-transform touch-manipulation"><Heart className="h-4 w-4 text-black" /></button>}
/>
<AddItemDialog onAdd={(item) => addItem.mutate(item)} mode="stash"
<AddItemDialog onAdd={handleAddItem} mode="stash"
trigger={<button className="rounded-full bg-gradient-to-br from-[#ff6600] to-[#ff0040] shadow-xl shadow-[#ff0040]/25 border-2 border-white/20 flex items-center justify-center active:scale-90 transition-transform touch-manipulation" style={{ height: '3.25rem', width: '3.25rem' }}><Plus className="h-6 w-6 text-white" strokeWidth={2.5} /></button>}
/>
</div>
{editingItem && (
<EditItemDialog item={editingItem} open={!!editingItem} onOpenChange={(open) => !open && setEditingItem(null)}
onSave={(updatedItem) => { updateItem.mutate(updatedItem); setEditingItem(null); }} />
onSave={(updatedItem) => { handleUpdateItem(updatedItem); setEditingItem(null); }} />
)}
{listingItem && (
<ListAtSpotDialog item={listingItem} open={!!listingItem} onOpenChange={(open) => !open && setListingItem(null)} onSelectSpot={handleSpotSelection} />