SkateSpots/src/components/SpotOwnership.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

355 lines
12 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 { useAuthor } from '@/hooks/useAuthor';
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 ownerProfile = useAuthor(spot.pubkey);
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 the ones we're changing
for (const tag of spot.event.tags) {
eventTags.push([...tag]);
}
// Add claim reference
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>
</>
);
}