diff --git a/package.json b/package.json index de56bdc..a00873c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/components/DeleteEntityDialog.tsx b/src/components/DeleteEntityDialog.tsx index da5559a..31eac6f 100644 --- a/src/components/DeleteEntityDialog.tsx +++ b/src/components/DeleteEntityDialog.tsx @@ -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 ? ( setOpen(true)}>{trigger} ) : ( + {/* Owner: mark sold / relist + delete */} + {isOwnListingCheck && ( + <> + {isSold ? ( + { + e.stopPropagation(); + handleRelist(); + }} + disabled={isUpdatingStatus} + > + + Relist as active + + ) : ( + { + e.stopPropagation(); + handleMarkSold(); + }} + disabled={isUpdatingStatus} + > + + Mark as sold + + )} + + { + e.stopPropagation(); + setShowRemoveDialog(true); + }} + className="text-red-500 focus:text-red-500" + > + + Delete listing + + + )} {!isOwnListingCheck && ( { @@ -205,7 +298,7 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }: Report )} - {isAdmin && ( + {isAdmin && !isOwnListingCheck && ( { e.stopPropagation(); @@ -354,11 +447,12 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }: - Remove this listing? (admin) + {ownerDeleting ? `Delete "${item.title}"?` : 'Remove this listing? (admin)'} - 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.`} @@ -366,11 +460,11 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }: Cancel - {isRemoving ? 'Removing…' : 'Remove Listing'} + {isRemoving ? (ownerDeleting ? 'Deleting…' : 'Removing…') : (ownerDeleting ? 'Delete Listing' : 'Remove Listing')} diff --git a/src/components/MarketplaceItemCard.tsx b/src/components/MarketplaceItemCard.tsx index 02a366a..067c5f7 100644 --- a/src/components/MarketplaceItemCard.tsx +++ b/src/components/MarketplaceItemCard.tsx @@ -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(''); 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 ( )} + + {/* Owner management dropdown — mark sold / relist / delete */} + {user && isOwnListing && ( + + + + + + {isSold ? ( + + + Relist as active + + ) : ( + + + Mark as sold + + )} + + setShowDeleteDialog(true)} + className="text-red-400 focus:text-red-400" + > + + Delete listing + + + + )} @@ -486,6 +562,16 @@ export function MarketplaceItemCard({ item, compact = false }: MarketplaceItemCa + {/* Delete confirmation (controlled — trigger lives in the owner dropdown) */} + + {/* Report Dialog */} diff --git a/src/hooks/useMarkListingSold.ts b/src/hooks/useMarkListingSold.ts new file mode 100644 index 0000000..6c39483 --- /dev/null +++ b/src/hooks/useMarkListingSold.ts @@ -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 }; +}