Removed/renamed the remaining 39 unused locals/dead code, file-by-file: - unused destructured bindings/params (remove the binding, keep the call) - unused single-line pure consts and dead functions/interfaces removed - deleted the dead _FeedItem component and its now-unused imports - amber _readChunks self-call fixed; SpotMap popup call site updated Warnings: 182 -> 7 (only react-refresh/only-export-components remain). no-explicit-any=0, exhaustive-deps=0, no-unused-vars=0. Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline + guidance updated.
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
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 && (
|
|
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<UserPlus className="h-4 w-4" />
|
|
Transfer Ownership
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<UserPlus className="h-5 w-5 text-[#ff0040]" />
|
|
Transfer Spot Ownership
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Transfer "{spot.title}" to another user. This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">New Owner's npub</label>
|
|
<Input
|
|
placeholder="npub1..."
|
|
value={recipientNpub}
|
|
onChange={(e) => setRecipientNpub(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Enter the npub of the user you want to transfer this spot to.
|
|
</p>
|
|
</div>
|
|
|
|
<Card className="bg-yellow-500/10 border-yellow-500/30">
|
|
<CardContent className="p-3">
|
|
<div className="flex gap-2 text-yellow-500">
|
|
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
|
<div className="text-sm">
|
|
<p className="font-medium">Warning</p>
|
|
<p className="text-yellow-400/80">
|
|
After transfer, you will no longer be able to edit or delete this spot.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex gap-2 justify-end">
|
|
<Button variant="outline" onClick={() => setTransferOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleTransfer}
|
|
disabled={!recipientNpub.trim() || isPublishing}
|
|
className="bg-[#ff0040] hover:bg-[#ff0040]/90"
|
|
>
|
|
{isPublishing ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Transferring...
|
|
</>
|
|
) : (
|
|
'Transfer Ownership'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
{/* Non-owner: Claim abandoned spot */}
|
|
{!isOwner && isAbandoned && user && (
|
|
<Card className="bg-orange-500/10 border-2 border-orange-500/30">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
<Clock className="h-6 w-6 text-orange-500 flex-shrink-0 mt-0.5" />
|
|
<div className="flex-1">
|
|
<h3 className="font-bold text-orange-400 uppercase text-sm mb-1">
|
|
Spot May Be Abandoned
|
|
</h3>
|
|
<p className="text-xs text-gray-400 mb-3">
|
|
Last updated {lastUpdatedText}. If the original owner is inactive,
|
|
you can claim this spot to maintain and improve it.
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
className="gap-2 bg-orange-500 hover:bg-orange-600 text-white"
|
|
onClick={() => setClaimOpen(true)}
|
|
>
|
|
<Crown className="h-4 w-4" />
|
|
Claim This Spot
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Not logged in: Show login for abandoned spots */}
|
|
{!isOwner && isAbandoned && !user && (
|
|
<Card className="bg-orange-500/10 border-2 border-orange-500/30">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
<Clock className="h-6 w-6 text-orange-500 flex-shrink-0 mt-0.5" />
|
|
<div className="flex-1">
|
|
<h3 className="font-bold text-orange-400 uppercase text-sm mb-1">
|
|
Spot May Be Abandoned
|
|
</h3>
|
|
<p className="text-xs text-gray-400 mb-3">
|
|
Last updated {lastUpdatedText}. Log in to claim and maintain this spot.
|
|
</p>
|
|
<LoginArea className="justify-start" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Claim confirmation dialog */}
|
|
<AlertDialog open={claimOpen} onOpenChange={setClaimOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="flex items-center gap-2">
|
|
<Crown className="h-5 w-5 text-orange-500" />
|
|
Claim "{spot.title}"?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription asChild>
|
|
<div className="space-y-3">
|
|
<p>
|
|
You're about to claim ownership of this spot. This will create your own
|
|
version of the spot that you can edit and maintain.
|
|
</p>
|
|
<div className="bg-muted p-3 rounded-lg text-sm">
|
|
<p className="font-medium mb-1">What happens when you claim:</p>
|
|
<ul className="list-disc list-inside text-muted-foreground space-y-1">
|
|
<li>You become the owner and can edit the spot</li>
|
|
<li>The original spot remains (Nostr is decentralized)</li>
|
|
<li>Your version may appear alongside the original</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleClaim}
|
|
disabled={isPublishing}
|
|
className="bg-orange-500 hover:bg-orange-600"
|
|
>
|
|
{isPublishing ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Claiming...
|
|
</>
|
|
) : (
|
|
'Claim Spot'
|
|
)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|