import { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { UserPlus, AlertTriangle, Loader2, Crown, Clock, ShieldCheck, Hourglass, BadgeCheck, Lock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { useSpotClaims, useApprovedSpotClaims, useSubmitSpotClaim } from '@/hooks/useSpotClaims'; import { useAdminApproveClaim } from '@/hooks/useAdminHide'; import { useQueryClient } from '@tanstack/react-query'; import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types'; import { isAdminPubkey } from '@/lib/admins'; import { LoginArea } from '@/components/auth/LoginArea'; import { useTranslation } from '@/contexts/language'; import { nip19 } from 'nostr-tools'; // 6 months in seconds const ABANDONED_THRESHOLD = 6 * 30 * 24 * 60 * 60; interface SpotOwnershipProps { spot: SkateSpot; } /** * Ownership controls for a spot: * 1. Transfer — the owner hands it to someone else. * 2. Claim an abandoned spot (owner inactive 6+ months) — any user. * 3. Claim a PRIVATE spot — a signed claim (NIP-05 required) that a SkateSpots * moderator must approve before the claimant finishes the transfer. We can't * verify a property deed, so control is never auto-transferred to a stranger. */ export function SpotOwnership({ spot }: SpotOwnershipProps) { const { t } = useTranslation(); const { user } = useCurrentUser(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { toast } = useToast(); const queryClient = useQueryClient(); const [transferOpen, setTransferOpen] = useState(false); const [claimOpen, setClaimOpen] = useState(false); const [privateClaimOpen, setPrivateClaimOpen] = useState(false); const [recipientNpub, setRecipientNpub] = useState(''); const isOwner = !!user && user.pubkey === spot.pubkey; const isPrivate = spot.accessType === 'private'; const isAdmin = isAdminPubkey(user?.pubkey); const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`; const { data: claims = [] } = useSpotClaims(isPrivate ? coord : undefined); const { data: approvals } = useApprovedSpotClaims(); const { submit: submitClaim, isPending: submittingClaim } = useSubmitSpotClaim(); const { approveClaim, isPending: approvingClaim } = useAdminApproveClaim(); const myAuthor = useAuthor(user?.pubkey ?? ''); const myNip05 = myAuthor.data?.metadata?.nip05; const myClaim = user ? claims.find((c) => c.claimant === user.pubkey) : undefined; const amApproved = !!user && !!approvals?.get(coord)?.has(user.pubkey.toLowerCase()); // Check if spot is abandoned (owner hasn't updated in 6+ months) const isAbandoned = useMemo(() => { const now = Math.floor(Date.now() / 1000); return (now - spot.createdAt) > ABANDONED_THRESHOLD; }, [spot.createdAt]); // Calculate how long ago the spot was last updated const lastUpdatedText = useMemo(() => { const now = Math.floor(Date.now() / 1000); const diff = now - spot.createdAt; const days = Math.floor(diff / (24 * 60 * 60)); const months = Math.floor(days / 30); const years = Math.floor(months / 12); if (years > 0) return years > 1 ? t('spotUi.yearsAgoPlural', { count: years }) : t('spotUi.yearsAgo', { count: years }); if (months > 0) return months > 1 ? t('spotUi.monthsAgoPlural', { count: months }) : t('spotUi.monthsAgo', { count: months }); return days > 1 ? t('spotUi.daysAgoPlural', { count: days }) : t('spotUi.daysAgo', { count: days }); }, [spot.createdAt, t]); const handleTransfer = async () => { if (!recipientNpub.trim()) { toast({ title: t('spotUi.recipientRequired'), description: t('spotUi.recipientRequiredDesc'), variant: 'destructive', }); return; } // Decode npub to get hex pubkey let recipientPubkey: string; try { const decoded = nip19.decode(recipientNpub.trim()); if (decoded.type !== 'npub') { throw new Error('Invalid npub format'); } recipientPubkey = decoded.data; } catch { toast({ title: t('spotUi.invalidNpub'), description: t('spotUi.invalidNpubDesc'), variant: 'destructive', }); return; } if (recipientPubkey === spot.pubkey) { toast({ title: t('spotUi.sameOwner'), description: t('spotUi.sameOwnerDesc'), variant: 'destructive', }); return; } try { // Publish a transfer event (kind 1111 comment with transfer marker) await publishEvent({ kind: 1111, content: t('spotUi.transferRequestContent', { title: spot.title }), tags: [ // NIP-22 ROOT scope for an addressable target uses uppercase tags. ['A', coord], ['K', String(SKATE_SPOT_KIND)], ['P', spot.pubkey], ['t', 'spot-transfer'], ['p', recipientPubkey, '', 'new-owner'], ], }); toast({ title: t('spotUi.transferRequested'), description: t('spotUi.transferRequestedDesc'), }); setTransferOpen(false); setRecipientNpub(''); queryClient.invalidateQueries({ queryKey: ['spot-transfers', spot.pubkey, spot.identifier] }); } catch (error) { toast({ title: t('spotUi.transferFailed'), description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'), variant: 'destructive', }); } }; /** Become the owner by re-publishing the spot under our own key. */ const handleClaim = async (reason: 'abandoned' | 'transfer' | 'private-approved') => { if (!user) return; try { const eventTags: string[][] = []; // Copy all original tags EXCEPT provenance markers we're about to (re)set. // Without this filter, claiming a spot that was itself a prior claim would // stack multiple claim-from/claim-reason pairs on the event. for (const tag of spot.event.tags) { if (tag[0] === 'claim-from' || tag[0] === 'claim-reason') continue; eventTags.push([...tag]); } eventTags.push(['claim-from', coord]); eventTags.push(['claim-reason', reason]); await publishEvent({ kind: SKATE_SPOT_KIND, content: spot.description, tags: eventTags, }); toast({ title: t('spotUi.spotClaimed'), description: t('spotUi.spotClaimedDesc'), }); setClaimOpen(false); queryClient.invalidateQueries({ queryKey: ['skate-spots'] }); queryClient.invalidateQueries({ queryKey: ['skate-spot', spot.identifier] }); } catch (error) { toast({ title: t('spotUi.claimFailed'), description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'), variant: 'destructive', }); } }; const handleSubmitPrivateClaim = async () => { if (!myNip05) return; try { await submitClaim({ coord, ownerPubkey: spot.pubkey, nip05: myNip05 }); toast({ title: t('spotUi.claim.submitted'), description: t('spotUi.claim.submittedDesc') }); setPrivateClaimOpen(false); } catch (error) { toast({ title: t('spotUi.claim.submitFailed'), description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'), variant: 'destructive', }); } }; const handleApproveClaim = async (claimant: string) => { try { await approveClaim({ coord, subject: claimant, reason: 'private-spot-claim' }); toast({ title: t('spotUi.claim.adminApproved'), description: t('spotUi.claim.adminApprovedDesc') }); } catch (error) { toast({ title: t('spotUi.claim.adminApproveFailed'), description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'), variant: 'destructive', }); } }; // Don't show anything for shops if (spot.types.includes('shop')) { return null; } return ( <> {/* Owner: Transfer button */} {isOwner && ( {t('spotUi.transferSpotOwnership')} {t('spotUi.transferDesc', { title: spot.title })}
setRecipientNpub(e.target.value)} />

{t('spotUi.newOwnerNpubHint')}

{t('spotUi.warning')}

{t('spotUi.transferWarningDesc')}

)} {/* ── PRIVATE SPOT — ownership claim (NIP-05 + moderator confirm) ────── */} {!isOwner && isPrivate && ( <> {/* Claim approved → finish */} {user && amApproved && (

{t('spotUi.claim.approvedTitle')}

{t('spotUi.claim.approvedDesc')}

)} {/* Claim pending review */} {user && !amApproved && myClaim && (

{t('spotUi.claim.pendingTitle')}

{t('spotUi.claim.pendingDesc')}

)} {/* Can claim (NIP-05 present) */} {user && !amApproved && !myClaim && myNip05 && (

{t('spotUi.claim.areYouOwner')}

{t('spotUi.claim.privateDesc')}

)} {/* Logged in but no NIP-05 → nudge */} {user && !amApproved && !myClaim && !myNip05 && (

{t('spotUi.claim.areYouOwner')}

{t('spotUi.claim.nip05Required')}

)} {/* Not logged in */} {!user && (

{t('spotUi.claim.areYouOwner')}

{t('spotUi.claim.loginRequired')}

)} )} {/* ── Admin: pending private-spot claims ─────────────────────────────── */} {isAdmin && isPrivate && claims.length > 0 && (

{t('spotUi.claim.adminTitle')}

{claims.map((c) => { const approved = !!approvals?.get(coord)?.has(c.claimant.toLowerCase()); return (
{c.nip05 || `${nip19.npubEncode(c.claimant).slice(0, 12)}…`} {approved ? ( {t('spotUi.claim.adminApproved')} ) : ( )}
); })}
)} {/* Non-owner: Claim abandoned spot (non-private spots only) */} {!isOwner && !isPrivate && isAbandoned && user && (

{t('spotUi.spotMayBeAbandoned')}

{t('spotUi.abandonedDesc', { time: lastUpdatedText })}

)} {/* Not logged in: abandoned non-private spots */} {!isOwner && !isPrivate && isAbandoned && !user && (

{t('spotUi.spotMayBeAbandoned')}

{t('spotUi.abandonedLoginDesc', { time: lastUpdatedText })}

)} {/* Claim confirmation dialog (abandoned) */} {t('spotUi.claimTitle', { title: spot.title })}

{t('spotUi.claimIntro')}

{t('spotUi.claimWhatHappens')}

  • {t('spotUi.claimBullet1')}
  • {t('spotUi.claimBullet2')}
  • {t('spotUi.claimBullet3')}
{t('common.cancel')} handleClaim(isAbandoned ? 'abandoned' : 'transfer')} disabled={isPublishing} className="bg-orange-500 hover:bg-orange-600" > {isPublishing ? ( <> {t('spotUi.claiming')} ) : ( t('spotUi.claimSpot') )}
{/* Claim confirmation dialog (private) */} {t('spotUi.claim.confirmTitle', { title: spot.title })}

{t('spotUi.claim.confirmIntro')}

  • {t('spotUi.claim.confirmBullet1')}
  • {t('spotUi.claim.confirmBullet2')}
  • {t('spotUi.claim.confirmBullet3')}
{t('common.cancel')} {submittingClaim ? ( <> {t('spotUi.claim.submitting')} ) : ( t('spotUi.claim.submit') )}
); }