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>
185 lines
6.6 KiB
TypeScript
185 lines
6.6 KiB
TypeScript
import { useState, type ReactNode } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { Trash2, Loader2, AlertTriangle } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from '@/components/ui/alert-dialog';
|
||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { isAdminPubkey } from '@/lib/admins';
|
||
import { cn } from '@/lib/utils';
|
||
import type { NostrEvent } from '@nostrify/nostrify';
|
||
|
||
interface DeleteEntityDialogProps {
|
||
/** The addressable event to delete (crew, Lab profile, spot, …). */
|
||
event: NostrEvent;
|
||
/** Human label for the thing being deleted, e.g. "crew" or "coach profile". */
|
||
entityLabel: string;
|
||
/** The name/title shown in the confirmation copy. */
|
||
entityName: string;
|
||
/** Where to send the user after a successful delete (e.g. "/crews"). */
|
||
redirectTo?: string;
|
||
/** Verb shown on the trigger button (default "Delete"). */
|
||
triggerText?: string;
|
||
/** Optional custom trigger. If omitted a default outline button is rendered. */
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Reusable "delete / dissolve" control for any addressable event the current
|
||
* user owns. Publishes a NIP-09 deletion request (see useDeleteEntity) after an
|
||
* explicit confirmation, and is only rendered for the owner.
|
||
*/
|
||
export function DeleteEntityDialog({
|
||
event,
|
||
entityLabel,
|
||
entityName,
|
||
redirectTo,
|
||
triggerText = 'Delete',
|
||
trigger,
|
||
className,
|
||
open: controlledOpen,
|
||
onOpenChange,
|
||
}: DeleteEntityDialogProps) {
|
||
const { user } = useCurrentUser();
|
||
const { mutateAsync: deleteEntity, isPending } = useDeleteEntity();
|
||
const { toast } = useToast();
|
||
const navigate = useNavigate();
|
||
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;
|
||
|
||
const isOwner = user.pubkey === event.pubkey;
|
||
const isAdmin = isAdminPubkey(user.pubkey);
|
||
// The owner can delete their own event; a SkateSpots admin can take down any.
|
||
if (!isOwner && !isAdmin) return null;
|
||
|
||
// An admin acting on someone else's content is a moderation takedown.
|
||
const adminTakedown = isAdmin && !isOwner;
|
||
|
||
const handleDelete = async () => {
|
||
try {
|
||
await deleteEntity({
|
||
event,
|
||
reason: adminTakedown
|
||
? `SkateSpots admin removed ${entityLabel} "${entityName}"`
|
||
: `Deleted ${entityLabel} "${entityName}"`,
|
||
});
|
||
toast({
|
||
title: adminTakedown ? 'Content removed' : 'Deletion requested',
|
||
description: adminTakedown
|
||
? `This ${entityLabel} has been taken down and will be hidden across SkateSpots.`
|
||
: `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`,
|
||
});
|
||
setOpen(false);
|
||
if (redirectTo) navigate(redirectTo);
|
||
} catch (error) {
|
||
toast({
|
||
title: 'Delete failed',
|
||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{isControlled ? null : trigger ? (
|
||
<span onClick={() => setOpen(true)}>{trigger}</span>
|
||
) : (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => setOpen(true)}
|
||
className={cn(
|
||
'gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10 hover:text-red-300',
|
||
className,
|
||
)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" /> {adminTakedown ? `Remove (admin)` : triggerText}
|
||
</Button>
|
||
)}
|
||
|
||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle className="flex items-center gap-2">
|
||
<Trash2 className="h-5 w-5 text-red-500" />
|
||
Delete "{entityName}"?
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription asChild>
|
||
<div className="space-y-3">
|
||
{adminTakedown ? (
|
||
<p>
|
||
As a SkateSpots admin you’re taking down this {entityLabel}. It will be hidden
|
||
across the app for everyone and a deletion request will be sent to relays. The
|
||
original author can’t restore it from here.
|
||
</p>
|
||
) : (
|
||
<p>
|
||
This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
|
||
have to create a new one.
|
||
</p>
|
||
)}
|
||
<div className="flex gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-sm">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
|
||
<p className="text-yellow-400/90">
|
||
Nostr is decentralized, so deletion is best-effort: relays that honor NIP-09 will
|
||
drop it, but a relay that doesn’t may keep a copy. SkateSpots hides admin-removed
|
||
content in the app regardless.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
onClick={handleDelete}
|
||
disabled={isPending}
|
||
className="bg-red-500 text-white hover:bg-red-600"
|
||
>
|
||
{isPending ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Deleting…
|
||
</>
|
||
) : (
|
||
<>
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
{triggerText}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</>
|
||
);
|
||
}
|