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",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.29",
|
"version": "1.2.30",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,14 @@ interface DeleteEntityDialogProps {
|
||||||
trigger?: ReactNode;
|
trigger?: ReactNode;
|
||||||
/** Extra classes for the default trigger button. */
|
/** Extra classes for the default trigger button. */
|
||||||
className?: string;
|
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',
|
triggerText = 'Delete',
|
||||||
trigger,
|
trigger,
|
||||||
className,
|
className,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
}: DeleteEntityDialogProps) {
|
}: DeleteEntityDialogProps) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { mutateAsync: deleteEntity, isPending } = useDeleteEntity();
|
const { mutateAsync: deleteEntity, isPending } = useDeleteEntity();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
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;
|
if (!user) return null;
|
||||||
|
|
||||||
|
|
@ -91,7 +107,7 @@ export function DeleteEntityDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{trigger ? (
|
{isControlled ? null : trigger ? (
|
||||||
<span onClick={() => setOpen(true)}>{trigger}</span>
|
<span onClick={() => setOpen(true)}>{trigger}</span>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||||
|
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { isAdminPubkey } from '@/lib/admins';
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -25,12 +26,15 @@ import {
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Flag,
|
Flag,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -57,6 +61,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const author = useAuthor(item.pubkey);
|
const author = useAuthor(item.pubkey);
|
||||||
const { mutateAsync: deleteEntity, isPending: isRemoving } = useDeleteEntity();
|
const { mutateAsync: deleteEntity, isPending: isRemoving } = useDeleteEntity();
|
||||||
|
const { markSold, relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
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 isOwnListingCheck = user?.pubkey === item.pubkey;
|
||||||
const isAdmin = isAdminPubkey(user?.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 {
|
try {
|
||||||
await deleteEntity({
|
await deleteEntity({
|
||||||
event: item.event,
|
event: item.event,
|
||||||
reason: `SkateSpots admin removed listing "${item.title}"`,
|
reason: ownerDeleting
|
||||||
|
? `Deleted listing "${item.title}"`
|
||||||
|
: `SkateSpots admin removed listing "${item.title}"`,
|
||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Listing removed',
|
title: ownerDeleting ? 'Listing deleted' : 'Listing removed',
|
||||||
description: 'This listing has been taken down and will be hidden across SkateSpots.',
|
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);
|
setShowRemoveDialog(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -115,6 +127,33 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
const trustBadge = SELLER_TRUST_BADGES[trustLevel];
|
const trustBadge = SELLER_TRUST_BADGES[trustLevel];
|
||||||
|
|
||||||
const isOwnListing = user?.pubkey === item.pubkey;
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -144,6 +183,15 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
</div>
|
</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 */}
|
{/* WTB Badge */}
|
||||||
{isWTB && (
|
{isWTB && (
|
||||||
<Badge className="absolute top-2 left-2 bg-blue-500 text-white border-0 shadow-lg">
|
<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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* More options menu — shown to non-owners (Report) and to admins
|
{/* More options menu — owners manage their listing (mark sold /
|
||||||
(admin takedown). Owners manage their own listing elsewhere. */}
|
relist / delete), non-owners can report, admins can take down. */}
|
||||||
{(!isOwnListingCheck || isAdmin) && (
|
{user && (
|
||||||
<div className="absolute bottom-2 right-2">
|
<div className="absolute bottom-2 right-2 z-20">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -188,11 +236,56 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 w-7 bg-black/80 backdrop-blur-sm text-gray-400 hover:text-white"
|
className="h-7 w-7 bg-black/80 backdrop-blur-sm text-gray-400 hover:text-white"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
disabled={isUpdatingStatus}
|
||||||
|
aria-label="Listing options"
|
||||||
>
|
>
|
||||||
|
{isUpdatingStatus ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
<MoreVertical className="h-3.5 w-3.5" />
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-44">
|
<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 && (
|
{!isOwnListingCheck && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -205,7 +298,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
Report
|
Report
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && !isOwnListingCheck && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -354,11 +447,12 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="text-white flex items-center gap-2">
|
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||||
<Trash2 className="h-5 w-5 text-red-500" />
|
<Trash2 className="h-5 w-5 text-red-500" />
|
||||||
Remove this listing? (admin)
|
{ownerDeleting ? `Delete "${item.title}"?` : 'Remove this listing? (admin)'}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-gray-300">
|
<AlertDialogDescription className="text-gray-300">
|
||||||
As a SkateSpots admin you're taking down "{item.title}". It will be hidden across the
|
{ownerDeleting
|
||||||
app for everyone and a deletion request will be sent to relays.
|
? `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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -366,11 +460,11 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={handleAdminRemove}
|
onClick={handleRemove}
|
||||||
disabled={isRemoving}
|
disabled={isRemoving}
|
||||||
className="bg-red-500 hover:bg-red-600 text-white"
|
className="bg-red-500 hover:bg-red-600 text-white"
|
||||||
>
|
>
|
||||||
{isRemoving ? 'Removing…' : 'Remove Listing'}
|
{isRemoving ? (ownerDeleting ? 'Deleting…' : 'Removing…') : (ownerDeleting ? 'Delete Listing' : 'Remove Listing')}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useListingReports } from '@/hooks/useListingReports';
|
import { useListingReports } from '@/hooks/useListingReports';
|
||||||
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
import { useSellerInfo } from '@/hooks/useSellerInfo';
|
||||||
import { useTrustScore } from '@/hooks/useTrustScore';
|
import { useTrustScore } from '@/hooks/useTrustScore';
|
||||||
|
import { useMarkListingSold } from '@/hooks/useMarkListingSold';
|
||||||
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
import {
|
import {
|
||||||
|
|
@ -46,6 +48,9 @@ import {
|
||||||
Package,
|
Package,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import type { MarketplaceItem } from '@/lib/types';
|
import type { MarketplaceItem } from '@/lib/types';
|
||||||
|
|
@ -61,6 +66,7 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||||
|
const { markSold, relist, isPending: isUpdatingStatus } = useMarkListingSold();
|
||||||
const author = useAuthor(item.pubkey);
|
const author = useAuthor(item.pubkey);
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
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 { totalBalance: cashuBalance, isInitialized: hasCashu, coco } = useCashu();
|
||||||
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
|
const [showPaymentDialog, setShowPaymentDialog] = useState(false);
|
||||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [reportReason, setReportReason] = useState<string>('');
|
const [reportReason, setReportReason] = useState<string>('');
|
||||||
const [reportDetails, setReportDetails] = useState('');
|
const [reportDetails, setReportDetails] = useState('');
|
||||||
const [isReporting, setIsReporting] = useState(false);
|
const [isReporting, setIsReporting] = useState(false);
|
||||||
|
|
@ -188,6 +195,33 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwnListing = user?.pubkey === item.pubkey;
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|
@ -282,6 +316,48 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -486,6 +562,16 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Report Dialog */}
|
||||||
<Dialog open={showReportDialog} onOpenChange={setShowReportDialog}>
|
<Dialog open={showReportDialog} onOpenChange={setShowReportDialog}>
|
||||||
<DialogContent>
|
<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