import { useState, useMemo } from 'react'; import { UserPlus, AlertTriangle, Loader2, Crown, Clock } 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 { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { useQueryClient } from '@tanstack/react-query'; import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types'; import { LoginArea } from '@/components/auth/LoginArea'; import { nip19 } from 'nostr-tools'; // 6 months in seconds const ABANDONED_THRESHOLD = 6 * 30 * 24 * 60 * 60; interface SpotOwnershipProps { spot: SkateSpot; } /** * Component that handles: * 1. Transfer ownership (owner can transfer to another user) * 2. Claim abandoned spot (any user can claim if owner inactive 6+ months) */ export function SpotOwnership({ spot }: SpotOwnershipProps) { 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 [recipientNpub, setRecipientNpub] = useState(''); const isOwner = user && user.pubkey === spot.pubkey; // 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} year${years > 1 ? 's' : ''} ago`; if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`; return `${days} day${days > 1 ? 's' : ''} ago`; }, [spot.createdAt]); const handleTransfer = async () => { if (!recipientNpub.trim()) { toast({ title: 'Recipient required', description: 'Please enter the npub of the new owner.', 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: 'Invalid npub', description: 'Please enter a valid npub address.', variant: 'destructive', }); return; } if (recipientPubkey === spot.pubkey) { toast({ title: 'Same owner', description: 'Cannot transfer to the current owner.', variant: 'destructive', }); return; } try { // Publish a transfer event (kind 1111 comment with transfer marker) await publishEvent({ kind: 1111, content: `Ownership transfer request for spot "${spot.title}"`, tags: [ ['a', `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`], ['K', String(SKATE_SPOT_KIND)], ['t', 'spot-transfer'], ['p', recipientPubkey, '', 'new-owner'], ], }); toast({ title: 'Transfer requested', description: 'The new owner will need to claim the spot to complete the transfer.', }); setTransferOpen(false); setRecipientNpub(''); queryClient.invalidateQueries({ queryKey: ['spot-transfers', spot.pubkey, spot.identifier] }); } catch (error) { toast({ title: 'Transfer failed', description: error instanceof Error ? error.message : 'Something went wrong', variant: 'destructive', }); } }; const handleClaim = async () => { if (!user) return; try { // Re-publish the spot under the new owner's pubkey // This creates a new addressable event with the same d-tag but new owner 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]); } // Add a single, fresh claim reference pointing at the immediate prior owner. eventTags.push(['claim-from', spot.pubkey]); eventTags.push(['claim-reason', isAbandoned ? 'abandoned' : 'transfer']); await publishEvent({ kind: SKATE_SPOT_KIND, content: spot.description, tags: eventTags, }); toast({ title: 'Spot claimed!', description: 'You are now the owner of this spot. You can edit it from your profile.', }); setClaimOpen(false); queryClient.invalidateQueries({ queryKey: ['skate-spots'] }); queryClient.invalidateQueries({ queryKey: ['skate-spot', spot.identifier] }); } catch (error) { toast({ title: 'Claim failed', description: error instanceof Error ? error.message : 'Something went wrong', variant: 'destructive', }); } }; // Don't show anything for shops if (spot.types.includes('shop')) { return null; } return ( <> {/* Owner: Transfer button */} {isOwner && ( Transfer Spot Ownership Transfer "{spot.title}" to another user. This action cannot be undone.
setRecipientNpub(e.target.value)} />

Enter the npub of the user you want to transfer this spot to.

Warning

After transfer, you will no longer be able to edit or delete this spot.

)} {/* Non-owner: Claim abandoned spot */} {!isOwner && isAbandoned && user && (

Spot May Be Abandoned

Last updated {lastUpdatedText}. If the original owner is inactive, you can claim this spot to maintain and improve it.

)} {/* Not logged in: Show login for abandoned spots */} {!isOwner && isAbandoned && !user && (

Spot May Be Abandoned

Last updated {lastUpdatedText}. Log in to claim and maintain this spot.

)} {/* Claim confirmation dialog */} Claim "{spot.title}"?

You're about to claim ownership of this spot. This will create your own version of the spot that you can edit and maintain.

What happens when you claim:

  • You become the owner and can edit the spot
  • The original spot remains (Nostr is decentralized)
  • Your version may appear alongside the original
Cancel {isPublishing ? ( <> Claiming... ) : ( 'Claim Spot' )}
); }