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).
585 lines
23 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|