diff --git a/package.json b/package.json
index fb23ffe..62c5d54 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
- "version": "1.0.71",
+ "version": "1.0.72",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {
diff --git a/src/components/DeleteEntityDialog.tsx b/src/components/DeleteEntityDialog.tsx
new file mode 100644
index 0000000..1e7a581
--- /dev/null
+++ b/src/components/DeleteEntityDialog.tsx
@@ -0,0 +1,145 @@
+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 { 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;
+}
+
+/**
+ * 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,
+}: DeleteEntityDialogProps) {
+ const { user } = useCurrentUser();
+ const { mutateAsync: deleteEntity, isPending } = useDeleteEntity();
+ const { toast } = useToast();
+ const navigate = useNavigate();
+ const [open, setOpen] = useState(false);
+
+ // Only the owner can delete their own event.
+ if (!user || user.pubkey !== event.pubkey) return null;
+
+ const handleDelete = async () => {
+ try {
+ await deleteEntity({ event, reason: `Deleted ${entityLabel} "${entityName}"` });
+ toast({
+ title: 'Deletion requested',
+ description: `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 (
+ <>
+ {trigger ? (
+ setOpen(true)}>{trigger}
+ ) : (
+
+ )}
+
+
+
+
+
+
+ Delete "{entityName}"?
+
+
+
+
+ This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
+ have to create a new one.
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts
new file mode 100644
index 0000000..2a41cec
--- /dev/null
+++ b/src/hooks/useDeleteEntity.ts
@@ -0,0 +1,64 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useNostrPublish } from '@/hooks/useNostrPublish';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import type { NostrEvent } from '@nostrify/nostrify';
+
+/**
+ * NIP-09 deletion request for an ADDRESSABLE event (kinds 30000–39999) such as
+ * a skate crew, a Lab profile, a spot, etc.
+ *
+ * How deletion works on Nostr (important to set expectations):
+ * • We publish a kind:5 "deletion request" that references the target event by
+ * its `a` coordinate (kind:pubkey:d-tag) and its `e` id + `k` kind.
+ * • Relays that honor NIP-09 stop serving the referenced event and won't
+ * return it in future queries. This is how "delete" works in a
+ * decentralized network — there's no central database row to remove.
+ * • A relay that ignores NIP-09 may still serve a cached copy, and other
+ * users' relays may retain it. So deletion is best-effort, not a guarantee.
+ *
+ * Only the ORIGINAL AUTHOR can delete their own event — a deletion request is
+ * only valid when signed by the same pubkey that created the target.
+ */
+export function useDeleteEntity() {
+ const { mutateAsync: publishEvent } = useNostrPublish();
+ const { user } = useCurrentUser();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({
+ event,
+ reason,
+ }: {
+ /** The full addressable event to delete. */
+ event: NostrEvent;
+ /** Optional human-readable reason, stored in the deletion request content. */
+ reason?: string;
+ }) => {
+ if (!user) throw new Error('You must be logged in to delete.');
+ if (user.pubkey !== event.pubkey) {
+ throw new Error('You can only delete your own listing.');
+ }
+
+ const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
+ const coord = `${event.kind}:${event.pubkey}:${dTag}`;
+
+ // NIP-09: reference by both the addressable coordinate (`a`) and the
+ // concrete event id (`e`), plus the kind (`k`), for maximum relay support.
+ const tags: string[][] = [
+ ['a', coord],
+ ['e', event.id],
+ ['k', String(event.kind)],
+ ];
+
+ return publishEvent({
+ kind: 5,
+ content: reason?.trim() || '',
+ tags,
+ });
+ },
+ onSuccess: () => {
+ // Nudge every list/detail query to refetch so the deleted entity drops out.
+ queryClient.invalidateQueries();
+ },
+ });
+}
diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx
index 0e13282..ff93d2c 100644
--- a/src/pages/CoachDetailPage.tsx
+++ b/src/pages/CoachDetailPage.tsx
@@ -14,6 +14,8 @@ import { LinkedCrewCard } from '@/components/CrossLinkCards';
import { TrustBadge } from '@/components/TrustBadge';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { genUserName } from '@/lib/genUserName';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS } from '@/lib/types';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
@@ -72,6 +74,7 @@ export default function CoachDetailPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: profile, isLoading, error } = useLabProfile(identifier || '', pubkey);
const author = useAuthor(profile?.pubkey);
+ const { user } = useCurrentUser();
const navigate = useNavigate();
const isCoach = profile?.profileType === 'coach';
@@ -383,6 +386,30 @@ export default function CoachDetailPage() {
+ {/* Owner controls — delete your own Lab profile */}
+ {user && user.pubkey === profile.pubkey && (
+
+
+
+ Manage
+
+
+
+
+ Remove your {isCoach ? 'coach' : isFilmer ? 'filmer' : isEditor ? 'video editor' : isPhotographer ? 'photographer' : 'builder'} profile from The Lab.
+
+
+
+
+ )}
+
{/* Members Card (collectives only) */}
{isCollective && (
)}
{isFounder && (
-
-
-
+
+
+
+
+
+
)}
{isAdmin && !isFounder && (