Marketplace: give sellers real listing management (mark sold / relist / delete) (1.2.30)

Gap found: the app's data model, sales tracking (useSellerSales), and Stash
showcase all key off a listing's status='sold', and both listing cards render a
"sold" state — but there was NO way for a seller to ever set it. Owners had zero
management actions: the MarketplaceItemCard dropdown only rendered for
non-owners (Report), and GearSwapItemCard's owner branch literally said "owners
manage their listing elsewhere" — a place that didn't exist. So sold gear stayed
"active" forever (until 30-day expiry) and the whole sold/sales-count feature was
unreachable.

Added owner-facing management on BOTH listing cards (GearSwapItemCard drives the
main GearSwap grid; MarketplaceItemCard is used on spot pages):
  - Mark as sold / Relist as active
  - Delete listing (NIP-09, via the existing useDeleteEntity)

New useMarkListingSold hook flips status in place, PRESERVING every other tag
(title, price, images, size, spot ref, geohash, expiration, category t-tags) —
kind 30402 is addressable, so rebuilding from scratch would wipe the photos and
price. Same "preserve unmanaged tags" discipline as the recent event-edit fix.

Also:
  - GearSwapItemCard now shows a clear diagonal "SOLD" overlay when sold, so the
    status is actually visible to buyers and the owner (previously invisible).
  - Generalized its admin-remove flow to handle owner deletes too, with wording
    that adapts (owner "Delete" vs admin "Remove").
  - DeleteEntityDialog gained an optional controlled open/onOpenChange so it can
    be driven from inside a dropdown menu without unmounting on menu close.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-07 22:24:47 +07:00
parent 9bd639f109
commit 080785a8c5
5 changed files with 266 additions and 18 deletions

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.2.29",
"version": "1.2.30",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {

View file

@ -32,6 +32,14 @@ interface DeleteEntityDialogProps {
trigger?: ReactNode;
/** Extra classes for the default trigger button. */
className?: string;
/**
* Controlled-open mode. When `open`/`onOpenChange` are supplied, the dialog
* is driven externally and NO trigger is rendered — useful when the trigger
* lives somewhere the dialog can't (e.g. inside a dropdown menu that unmounts
* on close).
*/
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
@ -47,12 +55,20 @@ export function DeleteEntityDialog({
triggerText = 'Delete',
trigger,
className,
open: controlledOpen,
onOpenChange,
}: DeleteEntityDialogProps) {
const { user } = useCurrentUser();
const { mutateAsync: deleteEntity, isPending } = useDeleteEntity();
const { toast } = useToast();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : uncontrolledOpen;
const setOpen = (next: boolean) => {
if (isControlled) onOpenChange?.(next);
else setUncontrolledOpen(next);
};
if (!user) return null;
@ -91,7 +107,7 @@ export function DeleteEntityDialog({
return (
<>
{trigger ? (
{isControlled ? null : trigger ? (
<span onClick={() => setOpen(true)}>{trigger}</span>
) : (
<Button

View file

@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
import { useToast } from '@/hooks/useToast';
import { isAdminPubkey } from '@/lib/admins';
import { genUserName } from '@/lib/genUserName';
@ -25,12 +26,15 @@ import {
MoreVertical,
Flag,
Trash2,
RotateCcw,
Loader2,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
@ -57,6 +61,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
const navigate = useNavigate();
const author = useAuthor(item.pubkey);
const { mutateAsync: deleteEntity, isPending: isRemoving } = useDeleteEntity();
const { markSold, relist, isPending: isUpdatingStatus } = useMarkListingSold();
const { toast } = useToast();
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(item.pubkey);
@ -68,15 +73,22 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
const isOwnListingCheck = user?.pubkey === item.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey);
const handleAdminRemove = async () => {
// Used by both the owner ("Delete listing") and admin ("Remove") flows —
// the wording adapts to which one is acting.
const ownerDeleting = isOwnListingCheck;
const handleRemove = async () => {
try {
await deleteEntity({
event: item.event,
reason: `SkateSpots admin removed listing "${item.title}"`,
reason: ownerDeleting
? `Deleted listing "${item.title}"`
: `SkateSpots admin removed listing "${item.title}"`,
});
toast({
title: 'Listing removed',
description: 'This listing has been taken down and will be hidden across SkateSpots.',
title: ownerDeleting ? 'Listing deleted' : 'Listing removed',
description: ownerDeleting
? 'Your listing has been removed. Relays that honor deletions will stop serving it.'
: 'This listing has been taken down and will be hidden across SkateSpots.',
});
setShowRemoveDialog(false);
} catch (error) {
@ -115,6 +127,33 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
const trustBadge = SELLER_TRUST_BADGES[trustLevel];
const isOwnListing = user?.pubkey === item.pubkey;
const isSold = item.status === 'sold';
const handleMarkSold = async () => {
try {
await markSold(item.event);
toast({ title: 'Marked as sold', description: 'Your listing is now shown as sold.' });
} catch (error) {
toast({
title: 'Could not update',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
const handleRelist = async () => {
try {
await relist(item.event);
toast({ title: 'Relisted', description: 'Your listing is active again.' });
} catch (error) {
toast({
title: 'Could not update',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
return (
<Card
@ -144,6 +183,15 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
</div>
)}
{/* Sold overlay — dims the image and stamps it clearly */}
{isSold && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/60">
<span className="rotate-[-12deg] rounded-md border-2 border-white/90 px-4 py-1 text-2xl font-black uppercase tracking-widest text-white shadow-lg">
Sold
</span>
</div>
)}
{/* WTB Badge */}
{isWTB && (
<Badge className="absolute top-2 left-2 bg-blue-500 text-white border-0 shadow-lg">
@ -177,10 +225,10 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
</Badge>
)}
{/* More options menu — shown to non-owners (Report) and to admins
(admin takedown). Owners manage their own listing elsewhere. */}
{(!isOwnListingCheck || isAdmin) && (
<div className="absolute bottom-2 right-2">
{/* More options menu — owners manage their listing (mark sold /
relist / delete), non-owners can report, admins can take down. */}
{user && (
<div className="absolute bottom-2 right-2 z-20">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@ -188,11 +236,56 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
variant="ghost"
className="h-7 w-7 bg-black/80 backdrop-blur-sm text-gray-400 hover:text-white"
onClick={(e) => e.stopPropagation()}
disabled={isUpdatingStatus}
aria-label="Listing options"
>
<MoreVertical className="h-3.5 w-3.5" />
{isUpdatingStatus ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<MoreVertical className="h-3.5 w-3.5" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{/* Owner: mark sold / relist + delete */}
{isOwnListingCheck && (
<>
{isSold ? (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleRelist();
}}
disabled={isUpdatingStatus}
>
<RotateCcw className="h-4 w-4 mr-2" />
Relist as active
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleMarkSold();
}}
disabled={isUpdatingStatus}
>
<CheckCircle className="h-4 w-4 mr-2" />
Mark as sold
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setShowRemoveDialog(true);
}}
className="text-red-500 focus:text-red-500"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete listing
</DropdownMenuItem>
</>
)}
{!isOwnListingCheck && (
<DropdownMenuItem
onClick={(e) => {
@ -205,7 +298,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
Report
</DropdownMenuItem>
)}
{isAdmin && (
{isAdmin && !isOwnListingCheck && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@ -354,11 +447,12 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
<AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2">
<Trash2 className="h-5 w-5 text-red-500" />
Remove this listing? (admin)
{ownerDeleting ? `Delete "${item.title}"?` : 'Remove this listing? (admin)'}
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300">
As a SkateSpots admin you're taking down "{item.title}". It will be hidden across the
app for everyone and a deletion request will be sent to relays.
{ownerDeleting
? `This asks relays to remove your listing "${item.title}". It can't be undone from here — you'd have to create a new one.`
: `As a SkateSpots admin you're taking down "${item.title}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@ -366,11 +460,11 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleAdminRemove}
onClick={handleRemove}
disabled={isRemoving}
className="bg-red-500 hover:bg-red-600 text-white"
>
{isRemoving ? 'Removing…' : 'Remove Listing'}
{isRemoving ? (ownerDeleting ? 'Deleting…' : 'Removing…') : (ownerDeleting ? 'Delete Listing' : 'Remove Listing')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -31,6 +31,8 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useListingReports } from '@/hooks/useListingReports';
import { useSellerInfo } from '@/hooks/useSellerInfo';
import { useTrustScore } from '@/hooks/useTrustScore';
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { genUserName } from '@/lib/genUserName';
import { TrustBadge } from '@/components/TrustBadge';
import {
@ -46,6 +48,9 @@ import {
Package,
AlertTriangle,
CheckCircle,
RotateCcw,
Trash2,
Loader2,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import type { MarketplaceItem } from '@/lib/types';
@ -61,6 +66,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
const { toast } = useToast();
const navigate = useNavigate();
const { mutateAsync: publishEvent } = useNostrPublish();
const { markSold, relist, isPending: isUpdatingStatus } = useMarkListingSold();
const author = useAuthor(item.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(item.pubkey);
@ -81,6 +87,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
const { totalBalance: cashuBalance, isInitialized: hasCashu, coco } = useCashu();
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
const [showReportDialog, setShowReportDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [reportReason, setReportReason] = useState<string>('');
const [reportDetails, setReportDetails] = useState('');
const [isReporting, setIsReporting] = useState(false);
@ -188,6 +195,33 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
}
const isOwnListing = user?.pubkey === item.pubkey;
const isSold = item.status === 'sold';
const handleMarkSold = async () => {
try {
await markSold(item.event);
toast({ title: 'Marked as sold', description: 'Your listing is now shown as sold.' });
} catch (error) {
toast({
title: 'Could not update',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
const handleRelist = async () => {
try {
await relist(item.event);
toast({ title: 'Relisted', description: 'Your listing is active again.' });
} catch (error) {
toast({
title: 'Could not update',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
return (
<Card
@ -282,6 +316,48 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Owner management dropdown — mark sold / relist / delete */}
{user && isOwnListing && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-gray-400"
disabled={isUpdatingStatus}
aria-label="Manage listing"
>
{isUpdatingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isSold ? (
<DropdownMenuItem onClick={handleRelist} disabled={isUpdatingStatus}>
<RotateCcw className="h-4 w-4 mr-2" />
Relist as active
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={handleMarkSold} disabled={isUpdatingStatus}>
<CheckCircle className="h-4 w-4 mr-2" />
Mark as sold
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowDeleteDialog(true)}
className="text-red-400 focus:text-red-400"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete listing
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
@ -486,6 +562,16 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
</DialogContent>
</Dialog>
{/* Delete confirmation (controlled — trigger lives in the owner dropdown) */}
<DeleteEntityDialog
event={item.event}
entityLabel="listing"
entityName={item.title}
triggerText="Delete listing"
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
/>
{/* Report Dialog */}
<Dialog open={showReportDialog} onOpenChange={setShowReportDialog}>
<DialogContent>

View file

@ -0,0 +1,52 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
/**
* Flip a NIP-99 gear listing's status (active ⇄ sold).
*
* A listing (kind 30402) is addressable, so re-publishing REPLACES the whole
* tag set. We therefore start from the original event's tags and change ONLY
* the `status` tag — every other tag (title, price, images, size, spot `a`
* ref, geohash, expiration, published_at, category `t` tags…) is carried
* through untouched. Rebuilding from scratch here would wipe the listing's
* photos and price, so we deliberately don't.
*/
export function useMarkListingSold() {
const { mutateAsync: createEvent, isPending } = useNostrPublish();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const mutate = async ({
event,
status,
}: {
/** The original listing event. */
event: NostrEvent;
/** New status — 'sold' to mark sold, 'active' to relist. */
status: 'sold' | 'active';
}) => {
if (!user) throw new Error('You must be logged in.');
if (user.pubkey !== event.pubkey) throw new Error('You can only update your own listing.');
if (event.kind !== MARKETPLACE_ITEM_KIND) throw new Error('Not a gear listing.');
// Preserve every tag except `status`, then set the new status.
const tags = event.tags.filter(([name]) => name !== 'status');
tags.push(['status', status]);
await createEvent({
kind: MARKETPLACE_ITEM_KIND,
content: event.content,
tags,
});
// Refresh marketplace + seller views so the badge updates immediately.
queryClient.invalidateQueries();
};
return { markSold: (event: NostrEvent) => mutate({ event, status: 'sold' }),
relist: (event: NostrEvent) => mutate({ event, status: 'active' }),
isPending };
}