SkateSpots/src/components/DeleteEntityDialog.tsx
shakespeare.diy 080785a8c5 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>
2026-09-07 22:24:47 +07:00

185 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
}