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:
parent
9bd639f109
commit
080785a8c5
5 changed files with 266 additions and 18 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
52
src/hooks/useMarkListingSold.ts
Normal file
52
src/hooks/useMarkListingSold.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue