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:
parent
889f6311ed
commit
32a3cbca88
5 changed files with 420 additions and 19 deletions
50
NIP.md
50
NIP.md
|
|
@ -1166,9 +1166,53 @@ When decrypted, the content contains a JSON object with the user's inventory:
|
||||||
|
|
||||||
| Value | Description |
|
| Value | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `private` | Only visible to the owner. Shared manually via DM. |
|
| `private` | Only visible to the owner. Encrypted in the kind 30001 blob. |
|
||||||
| `friends` | Visible to mutual follows. Enables P2P matching. |
|
| `friends` | Reserved for future friends-only sharing (currently treated as private). |
|
||||||
| `public` | Visible to anyone who views the user's profile. |
|
| `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
|
### Categories
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ export function useMarketplaceItems(limit = 100) {
|
||||||
|
|
||||||
const items: MarketplaceItem[] = [];
|
const items: MarketplaceItem[] = [];
|
||||||
for (const event of events) {
|
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);
|
const item = parseMarketplaceItem(event);
|
||||||
if (item) items.push(item);
|
if (item) items.push(item);
|
||||||
}
|
}
|
||||||
|
|
@ -68,6 +72,8 @@ export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string,
|
||||||
|
|
||||||
const items: MarketplaceItem[] = [];
|
const items: MarketplaceItem[] = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
const isShowcaseOnly = event.tags.some((t) => t[0] === 'showcase' && t[1] === 'true');
|
||||||
|
if (isShowcaseOnly) continue;
|
||||||
const item = parseMarketplaceItem(event);
|
const item = parseMarketplaceItem(event);
|
||||||
if (item && (!item.status || item.status === 'active')) {
|
if (item && (!item.status || item.status === 'active')) {
|
||||||
items.push(item);
|
items.push(item);
|
||||||
|
|
|
||||||
218
src/hooks/useStashShowcase.ts
Normal file
218
src/hooks/useStashShowcase.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
|
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useUserShops } from '@/hooks/useShops';
|
import { useUserShops } from '@/hooks/useShops';
|
||||||
import { useUserCrews } from '@/hooks/useCrews';
|
import { useUserCrews } from '@/hooks/useCrews';
|
||||||
|
import { useUserQuiver } from '@/hooks/useStashShowcase';
|
||||||
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
|
|
@ -31,6 +32,9 @@ import {
|
||||||
Crown,
|
Crown,
|
||||||
Bot,
|
Bot,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Package,
|
||||||
|
Tag,
|
||||||
|
ArrowRightLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||||
|
|
@ -41,6 +45,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
|
const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey);
|
||||||
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
||||||
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
|
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
|
||||||
|
const { data: quiver, isLoading: quiverLoading } = useUserQuiver(pubkey);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
|
|
@ -297,6 +302,83 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* 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">
|
<Card className="bg-gradient-to-br from-[#ff0040]/10 to-[#ccff00]/10 border-2 border-[#ff0040]/30">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<h3 className="text-sm font-bold uppercase text-gray-400 mb-3">Contributions</h3>
|
<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">
|
<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-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>
|
||||||
<div className="text-center p-3 rounded-lg bg-black/40">
|
<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-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>
|
||||||
<div className="text-center p-3 rounded-lg bg-black/40">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
|
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
|
||||||
|
import { useStashShowcaseSync } from '@/hooks/useStashShowcase';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
import { useSatsToUsd } from '@/hooks/useExchangeRate';
|
||||||
|
|
@ -419,8 +420,8 @@ function AddItemDialog({ onAdd, mode = 'stash', trigger }: { onAdd: (item: Omit<
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{formData.visibility === 'private' && 'Encrypted — only visible to you.'}
|
{formData.visibility === 'private' && 'Encrypted — only visible to you.'}
|
||||||
{formData.visibility === 'friends' && 'Visible to people you mutually follow.'}
|
{formData.visibility === 'friends' && 'Kept private for now (friends-only sharing coming soon).'}
|
||||||
{formData.visibility === 'public' && 'Anyone can see this in your stash.'}
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
@ -601,6 +607,47 @@ export default function StashPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { items, stats, isLoading, addItem, updateItem, removeItem, isEncryptionAvailable } = useStash();
|
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 [searchQuery, setSearchQuery] = useState('');
|
||||||
const [filterCategory, setFilterCategory] = useState<string>('all');
|
const [filterCategory, setFilterCategory] = useState<string>('all');
|
||||||
const [filterLocation, setFilterLocation] = 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 */}
|
{/* Mobile-only quick add — desktop has sidebar buttons */}
|
||||||
<div className="flex sm:hidden">
|
<div className="flex sm:hidden">
|
||||||
<AddItemDialog onAdd={(item) => addItem.mutate(item)} mode="stash" />
|
<AddItemDialog onAdd={handleAddItem} mode="stash" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -839,7 +886,7 @@ export default function StashPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="font-black text-white text-2xl uppercase tracking-tight mb-2">Wishlist Empty</p>
|
<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>
|
<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 ? (
|
) : stashItems.length === 0 ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -851,7 +898,7 @@ export default function StashPage() {
|
||||||
</div>
|
</div>
|
||||||
<p className="font-black text-white text-3xl uppercase tracking-tight mb-3">Your Stash is Empty</p>
|
<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>
|
<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">
|
<div className="space-y-2">
|
||||||
{filteredItems.map((item) => (
|
{filteredItems.map((item) => (
|
||||||
<StashListRow key={item.id} item={item} isWTB={item.itemType === 'wtb'}
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{filteredItems.map((item) => (
|
{filteredItems.map((item) => (
|
||||||
<StashItemCard key={item.id} item={item} isWTB={item.itemType === 'wtb'}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -886,8 +933,8 @@ export default function StashPage() {
|
||||||
|
|
||||||
{/* Quick actions */}
|
{/* Quick actions */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<AddItemDialog onAdd={(item) => addItem.mutate(item)} mode="stash" />
|
<AddItemDialog onAdd={handleAddItem} mode="stash" />
|
||||||
<AddItemDialog onAdd={(item) => addItem.mutate(item)} mode="wtb" />
|
<AddItemDialog onAdd={handleAddItem} mode="wtb" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats card */}
|
{/* Stats card */}
|
||||||
|
|
@ -1063,17 +1110,17 @@ export default function StashPage() {
|
||||||
|
|
||||||
{/* Mobile FABs */}
|
{/* 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' }}>
|
<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>}
|
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>}
|
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>
|
</div>
|
||||||
|
|
||||||
{editingItem && (
|
{editingItem && (
|
||||||
<EditItemDialog item={editingItem} open={!!editingItem} onOpenChange={(open) => !open && setEditingItem(null)}
|
<EditItemDialog item={editingItem} open={!!editingItem} onOpenChange={(open) => !open && setEditingItem(null)}
|
||||||
onSave={(updatedItem) => { updateItem.mutate(updatedItem); setEditingItem(null); }} />
|
onSave={(updatedItem) => { handleUpdateItem(updatedItem); setEditingItem(null); }} />
|
||||||
)}
|
)}
|
||||||
{listingItem && (
|
{listingItem && (
|
||||||
<ListAtSpotDialog item={listingItem} open={!!listingItem} onOpenChange={(open) => !open && setListingItem(null)} onSelectSpot={handleSpotSelection} />
|
<ListAtSpotDialog item={listingItem} open={!!listingItem} onOpenChange={(open) => !open && setListingItem(null)} onSelectSpot={handleSpotSelection} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue