SkateSpots/src/components/SpotOwnership.tsx
SkateSpots d566af851e fix: H14 double-pay guard + H1 claim provenance
H14 (NWC double-pay): after the 15s pay() timeout, useNWC now polls
lookupInvoice to confirm settlement before reporting failure; if the status
stays unknown it throws a coded error (PAYMENT_STATUS_UNKNOWN) and the callers
that used to fall through to another rail (useZaps, usePurchasePro,
PlatformFeePayment) now STOP and tell the user to check their wallet — no
automatic second payment.

H1 (spot claim): claim-from now stores the ORIGINAL COORDINATE
(30818:pubkey:d) rather than an ambiguous bare pubkey; SkateSpot parses
claimedFrom/claimReason; a claimed listing shows a 'Claimed listing' notice so
the duplicate is understandable. NIP.md updated. Full reader-side supersede is
documented as the remaining design item (a wrong rule could hide real spots).
2026-10-03 09:34:00 +07:00

585 lines
23 KiB
TypeScript

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 && (
<Dialog open={transferOpen} onOpenChange={setTransferOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<UserPlus className="h-4 w-4" />
{t('spotUi.transferOwnership')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-[#ff0040]" />
{t('spotUi.transferSpotOwnership')}
</DialogTitle>
<DialogDescription>
{t('spotUi.transferDesc', { title: spot.title })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('spotUi.newOwnerNpub')}</label>
<Input
placeholder="npub1..."
value={recipientNpub}
onChange={(e) => setRecipientNpub(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t('spotUi.newOwnerNpubHint')}
</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">{t('spotUi.warning')}</p>
<p className="text-yellow-400/80">
{t('spotUi.transferWarningDesc')}
</p>
</div>
</div>
</CardContent>
</Card>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setTransferOpen(false)}>
{t('common.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" />
{t('spotUi.transferring')}
</>
) : (
t('spotUi.transferOwnership')
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
{/* ── PRIVATE SPOT — ownership claim (NIP-05 + moderator confirm) ────── */}
{!isOwner && isPrivate && (
<>
{/* Claim approved → finish */}
{user && amApproved && (
<Card className="bg-green-500/10 border-2 border-green-500/40">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<BadgeCheck className="h-6 w-6 text-green-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-green-400 uppercase text-sm mb-1">{t('spotUi.claim.approvedTitle')}</h3>
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.approvedDesc')}</p>
<Button
size="sm"
className="gap-2 bg-green-500 hover:bg-green-600 text-black font-bold"
onClick={() => handleClaim('private-approved')}
disabled={isPublishing}
>
{isPublishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Crown className="h-4 w-4" />}
{isPublishing ? t('spotUi.claiming') : t('spotUi.claim.complete')}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Claim pending review */}
{user && !amApproved && myClaim && (
<Card className="bg-blue-500/10 border-2 border-blue-500/40">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Hourglass className="h-6 w-6 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-blue-300 uppercase text-sm mb-1">{t('spotUi.claim.pendingTitle')}</h3>
<p className="text-xs text-gray-400">{t('spotUi.claim.pendingDesc')}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Can claim (NIP-05 present) */}
{user && !amApproved && !myClaim && myNip05 && (
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/40">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Lock className="h-6 w-6 text-[#ccff00] flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-[#ccff00] uppercase text-sm mb-1">{t('spotUi.claim.areYouOwner')}</h3>
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.privateDesc')}</p>
<Button
size="sm"
className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase"
onClick={() => setPrivateClaimOpen(true)}
>
<ShieldCheck className="h-4 w-4" />
{t('spotUi.claim.claimSpot')}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Logged in but no NIP-05 → nudge */}
{user && !amApproved && !myClaim && !myNip05 && (
<Card className="bg-yellow-500/10 border-2 border-yellow-500/30">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-6 w-6 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-yellow-400 uppercase text-sm mb-1">{t('spotUi.claim.areYouOwner')}</h3>
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.nip05Required')}</p>
<Link to="/settings?section=profile">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
{t('spotUi.claim.addNip05')}
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
)}
{/* Not logged in */}
{!user && (
<Card className="bg-[#ccff00]/10 border-2 border-[#ccff00]/40">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Lock className="h-6 w-6 text-[#ccff00] flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-[#ccff00] uppercase text-sm mb-1">{t('spotUi.claim.areYouOwner')}</h3>
<p className="text-xs text-gray-400 mb-3">{t('spotUi.claim.loginRequired')}</p>
<LoginArea className="justify-start" />
</div>
</div>
</CardContent>
</Card>
)}
</>
)}
{/* ── Admin: pending private-spot claims ─────────────────────────────── */}
{isAdmin && isPrivate && claims.length > 0 && (
<Card className="bg-purple-500/10 border-2 border-purple-500/30">
<CardContent className="p-4">
<h3 className="font-bold text-purple-300 uppercase text-sm mb-3">{t('spotUi.claim.adminTitle')}</h3>
<div className="space-y-2">
{claims.map((c) => {
const approved = !!approvals?.get(coord)?.has(c.claimant.toLowerCase());
return (
<div key={c.claimant} className="flex items-center justify-between gap-2 text-xs">
<span className="text-gray-300 truncate font-mono">
{c.nip05 || `${nip19.npubEncode(c.claimant).slice(0, 12)}…`}
</span>
{approved ? (
<span className="text-green-400 font-bold uppercase shrink-0">{t('spotUi.claim.adminApproved')}</span>
) : (
<Button
size="sm"
className="h-7 gap-1.5 bg-purple-500 hover:bg-purple-600 text-white"
onClick={() => handleApproveClaim(c.claimant)}
disabled={approvingClaim}
>
{approvingClaim ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
{t('spotUi.claim.adminApprove')}
</Button>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{/* Non-owner: Claim abandoned spot (non-private spots only) */}
{!isOwner && !isPrivate && 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">
{t('spotUi.spotMayBeAbandoned')}
</h3>
<p className="text-xs text-gray-400 mb-3">
{t('spotUi.abandonedDesc', { time: lastUpdatedText })}
</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" />
{t('spotUi.claimThisSpot')}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Not logged in: abandoned non-private spots */}
{!isOwner && !isPrivate && 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">
{t('spotUi.spotMayBeAbandoned')}
</h3>
<p className="text-xs text-gray-400 mb-3">
{t('spotUi.abandonedLoginDesc', { time: lastUpdatedText })}
</p>
<LoginArea className="justify-start" />
</div>
</div>
</CardContent>
</Card>
)}
{/* Claim confirmation dialog (abandoned) */}
<AlertDialog open={claimOpen} onOpenChange={setClaimOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Crown className="h-5 w-5 text-orange-500" />
{t('spotUi.claimTitle', { title: spot.title })}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
{t('spotUi.claimIntro')}
</p>
<div className="bg-muted p-3 rounded-lg text-sm">
<p className="font-medium mb-1">{t('spotUi.claimWhatHappens')}</p>
<ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>{t('spotUi.claimBullet1')}</li>
<li>{t('spotUi.claimBullet2')}</li>
<li>{t('spotUi.claimBullet3')}</li>
</ul>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleClaim(isAbandoned ? 'abandoned' : 'transfer')}
disabled={isPublishing}
className="bg-orange-500 hover:bg-orange-600"
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('spotUi.claiming')}
</>
) : (
t('spotUi.claimSpot')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Claim confirmation dialog (private) */}
<AlertDialog open={privateClaimOpen} onOpenChange={setPrivateClaimOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-[#ccff00]" />
{t('spotUi.claim.confirmTitle', { title: spot.title })}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>{t('spotUi.claim.confirmIntro')}</p>
<div className="bg-muted p-3 rounded-lg text-sm">
<ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>{t('spotUi.claim.confirmBullet1')}</li>
<li>{t('spotUi.claim.confirmBullet2')}</li>
<li>{t('spotUi.claim.confirmBullet3')}</li>
</ul>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmitPrivateClaim}
disabled={submittingClaim}
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90"
>
{submittingClaim ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('spotUi.claim.submitting')}
</>
) : (
t('spotUi.claim.submit')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}