SkateSpots/src/components/PlatformFeePayment.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

377 lines
15 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { useNWC } from '@/hooks/useNWCContext';
import { PAYMENT_STATUS_UNKNOWN } from '@/hooks/useNWC';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
getInvoiceFromLightningAddress,
getZapInvoiceFromLightningAddress,
getZapProviderPubkey,
} from '@/lib/lightningAddress';
import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import { useTranslation } from '@/contexts/language';
import { cn } from '@/lib/utils';
import {
Zap,
Loader2,
CheckCircle2,
Copy,
ExternalLink,
Rocket,
ShieldCheck,
Clock,
} from 'lucide-react';
interface PlatformFeePaymentProps {
type: FeeType;
/** Called after payment confirmed */
onSuccess: () => void;
/** Optional extra context shown to user */
context?: string;
/**
* The addressable coordinate (`kind:pubkey:d`) of the entity being featured.
* When provided and the provider supports NIP-57, the zap request tags this
* `a` coordinate so the payment can later be verified against the entity.
*/
entityCoord?: string;
}
// Relays the provider should publish the zap receipt to (so we can verify it).
const ZAP_RECEIPT_RELAYS = [
'wss://goskatespots.nostr1.com',
'wss://relay.nostr.band',
'wss://relay.damus.io',
];
// Static maps to explicit `feesUi.*` literal keys (no template keys).
const FEE_LABEL_KEYS: Record<FeeType, string> = {
featuredEvent: 'feesUi.feeFeaturedEventLabel',
labProfile: 'feesUi.feeLabProfileLabel',
featuredShop: 'feesUi.feeFeaturedShopLabel',
featuredTour: 'feesUi.feeFeaturedTourLabel',
};
const FEE_DESC_KEYS: Record<FeeType, string> = {
featuredEvent: 'feesUi.feeFeaturedEventDesc',
labProfile: 'feesUi.feeLabProfileDesc',
featuredShop: 'feesUi.feeFeaturedShopDesc',
featuredTour: 'feesUi.feeFeaturedTourDesc',
};
const FEE_BENEFIT_KEYS: Record<FeeType, string> = {
featuredEvent: 'feesUi.feeFeaturedEventBenefit',
labProfile: 'feesUi.feeLabProfileBenefit',
featuredShop: 'feesUi.feeFeaturedShopBenefit',
featuredTour: 'feesUi.feeFeaturedTourBenefit',
};
const DURATION_LABEL_KEYS: Record<string, string> = {
'7d': 'feesUi.duration7d',
'30d': 'feesUi.duration30d',
'90d': 'feesUi.duration90d',
'365d': 'feesUi.duration365d',
};
type PaymentState = 'idle' | 'generating' | 'waiting' | 'paying' | 'success' | 'error';
export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: PlatformFeePaymentProps) {
const {
amountSats, recipient, recipientName, meta, tier, tiers, setTierId,
isActive, daysRemaining, recordPayment,
tierPriceSats: feeTierPrice, tierPerDaySats: feeTierPerDay,
} = usePlatformFee(type);
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const { sendPayment } = useNWC();
const { user } = useCurrentUser();
const { t } = useTranslation();
// Localized fee metadata (labels/descriptions/benefits live in `feesUi.*`).
const metaLabel = t(FEE_LABEL_KEYS[type]);
const metaDesc = t(FEE_DESC_KEYS[type]);
const metaBenefit = t(FEE_BENEFIT_KEYS[type]);
const tierLabel = t(DURATION_LABEL_KEYS[tier.id] ?? 'feesUi.duration30d');
const [paymentState, setPaymentState] = useState<PaymentState>('idle');
const [invoice, setInvoice] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Auto-pay when wallet connected and invoice ready
useEffect(() => {
if (invoice && paymentState === 'waiting' && (webln || activeNWC)) {
handleAutoPay();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoice, paymentState]);
// Already paid and active
if (isActive) {
return (
<div className="flex items-center gap-3 p-4 rounded-2xl bg-green-500/10 border border-green-500/30">
<ShieldCheck className="h-5 w-5 text-green-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-bold text-green-400 text-sm">{t('feesUi.metaActive', { label: metaLabel })}</p>
<p className="text-xs text-gray-500">
{daysRemaining === 1
? t('feesUi.daysRemainingOne', { count: daysRemaining })
: t('feesUi.daysRemainingMany', { count: daysRemaining })}
</p>
</div>
</div>
);
}
const generateInvoice = async () => {
setPaymentState('generating');
setError(null);
try {
let bolt11: string | null = null;
// Prefer a NIP-57 zap so the payment produces a verifiable zap receipt.
// Falls back to a plain LNURL invoice if the provider doesn't support zaps
// or the user can't sign.
try {
const providerPubkey = await getZapProviderPubkey(recipient);
if (providerPubkey && user?.signer) {
const platformPubkey = PLATFORM_OWNER_PUBKEYS[0];
const tags: string[][] = [
['relays', ...ZAP_RECEIPT_RELAYS],
['amount', String(amountSats * 1000)],
['p', platformPubkey],
];
if (entityCoord) {
tags.push(['a', entityCoord]);
}
const zapRequest = await user.signer.signEvent({
kind: 9734,
content: t('feesUi.zapRequestContent', { label: metaLabel, tier: tierLabel }),
created_at: Math.floor(Date.now() / 1000),
tags,
});
bolt11 = await getZapInvoiceFromLightningAddress({
lightningAddress: recipient,
amountSats,
zapRequest: JSON.stringify(zapRequest),
});
}
} catch {
// Zap path failed — fall back to a normal invoice below.
bolt11 = null;
}
if (!bolt11) {
bolt11 = await getInvoiceFromLightningAddress({
lightningAddress: recipient,
amountSats,
comment: t('feesUi.zapRequestContent', { label: metaLabel, tier: tierLabel }),
});
}
setInvoice(bolt11);
const { default: QRCode } = await import('qrcode');
const qr = await QRCode.toDataURL(bolt11.toUpperCase(), {
width: 256, margin: 2,
color: { dark: '#000000', light: '#ffffff' },
});
setQrCodeUrl(qr);
setPaymentState('waiting');
if (webln || activeNWC) {
await handleAutoPay();
}
} catch (err) {
setError(err instanceof Error ? err.message : t('feesUi.errGenerate'));
setPaymentState('error');
}
};
const handleAutoPay = async () => {
if (!invoice) return;
setPaymentState('paying');
try {
if (activeNWC?.isConnected) {
await sendPayment(activeNWC, invoice);
} else if (webln) {
if (webln.enable) await webln.enable();
await webln.sendPayment(invoice);
} else {
setPaymentState('waiting');
return;
}
recordPayment();
setPaymentState('success');
toast({ title: t('feesUi.paymentReceived'), description: t('feesUi.paymentReceivedDesc', { amount: amountSats.toLocaleString(), label: metaLabel }) });
setTimeout(onSuccess, 1200);
} catch (err) {
setPaymentState('waiting');
if ((err as { code?: string })?.code === PAYMENT_STATUS_UNKNOWN) {
toast({ title: t('feesUi.autoPayFailed'), description: t('feesUi.payStatusUnknown'), variant: 'destructive' });
return;
}
toast({ title: t('feesUi.autoPayFailed'), description: t('feesUi.autoPayFailedDesc'), variant: 'destructive' });
}
};
const copyInvoice = async () => {
if (!invoice) return;
await navigator.clipboard.writeText(invoice);
toast({ title: t('feesUi.invoiceCopied') });
};
// ── Success ──
if (paymentState === 'success') {
return (
<div className="flex flex-col items-center gap-3 p-6 rounded-2xl bg-green-500/10 border border-green-500/30 text-center">
<CheckCircle2 className="h-10 w-10 text-green-400" />
<div>
<p className="font-black text-green-400 text-lg uppercase">{meta.emoji} {t('feesUi.activeBang', { label: metaLabel })}</p>
<p className="text-sm text-gray-400 mt-1">{metaBenefit}</p>
</div>
</div>
);
}
// ── Main payment UI ──
return (
<div className="rounded-2xl border border-[#ff6600]/30 bg-gradient-to-br from-[#ff6600]/8 to-black/60 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-white/8">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#ff6600] to-[#ff0040] flex items-center justify-center flex-shrink-0 shadow-lg shadow-[#ff6600]/25">
<Rocket className="h-5 w-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="font-black text-white text-sm uppercase tracking-wide">{meta.emoji} {metaLabel}</p>
<p className="text-xs text-gray-500">{metaDesc}</p>
</div>
</div>
</div>
<div className="p-4 space-y-4">
{/* Duration tier selector — only before payment has started */}
{paymentState === 'idle' && (
<div>
<p className="text-[11px] font-bold uppercase tracking-wide text-gray-500 mb-2">
{t('feesUi.chooseDuration')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{tiers.map((tierOption) => {
const price = feeTierPrice(tierOption);
const perDay = feeTierPerDay(tierOption);
const selected = tierOption.id === tier.id;
return (
<button
key={tierOption.id}
type="button"
onClick={() => setTierId(tierOption.id)}
className={cn(
'relative flex flex-col items-center rounded-xl border-2 px-2 py-2.5 transition-all',
selected
? 'border-[#ff6600] bg-[#ff6600]/15'
: 'border-white/10 bg-white/[0.03] hover:border-white/25',
)}
>
{tierOption.bestValue && (
<span className="absolute -top-2 left-1/2 -translate-x-1/2 text-[8px] font-black uppercase px-1.5 py-0.5 rounded-full bg-[#ccff00] text-black whitespace-nowrap">
{t('feesUi.bestValue')}
</span>
)}
<span className={cn('text-xs font-black', selected ? 'text-[#ff6600]' : 'text-white')}>
{t(DURATION_LABEL_KEYS[tierOption.id] ?? 'feesUi.duration30d')}
</span>
<span className="text-[11px] font-bold text-gray-300 mt-0.5">
{price.toLocaleString()}
</span>
<span className="text-[9px] text-gray-500">{t('feesUi.perDay', { price: perDay })}</span>
</button>
);
})}
</div>
</div>
)}
{/* What you get */}
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
<CheckCircle2 className="h-3.5 w-3.5 text-green-400 flex-shrink-0" />
<span className="text-xs text-gray-300 leading-snug">{metaBenefit}</span>
</div>
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
<Clock className="h-3.5 w-3.5 text-[#ccff00] flex-shrink-0" />
<span className="text-xs text-gray-300">{t('feesUi.validFor')} <strong className="text-white">{tierLabel}</strong></span>
</div>
</div>
{context && (
<p className="text-xs text-gray-500 italic">{context}</p>
)}
{/* Amount */}
<div className="text-center py-3 rounded-xl bg-[#ff6600]/10 border border-[#ff6600]/25">
<p className="text-3xl font-black text-[#ff6600]">⚡ {amountSats.toLocaleString()} sats</p>
<p className="text-xs text-gray-500 mt-1">
{t('feesUi.amountLine', { usd: (amountSats * 0.0006).toFixed(2), name: recipientName, tier: tierLabel })}
</p>
</div>
{/* Actions */}
{paymentState === 'idle' && (
<Button
onClick={generateInvoice}
className="w-full h-11 bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 border-0 text-white font-black uppercase tracking-wide shadow-lg shadow-[#ff6600]/20"
>
<Zap className="h-4 w-4 mr-2" />
{t('feesUi.payToContinue', { amount: amountSats.toLocaleString() })}
</Button>
)}
{(paymentState === 'generating' || paymentState === 'paying') && (
<div className="flex flex-col items-center gap-2 py-4">
<Loader2 className="h-7 w-7 animate-spin text-[#ff6600]" />
<p className="text-sm text-gray-400">
{paymentState === 'generating' ? t('feesUi.generating') : t('feesUi.processing')}
</p>
</div>
)}
{paymentState === 'waiting' && invoice && qrCodeUrl && (
<div className="space-y-3">
<div className="flex justify-center">
<div className="bg-white p-3 rounded-xl">
<img src={qrCodeUrl} alt={t('feesUi.invoiceQrAlt')} className="w-44 h-44" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" onClick={copyInvoice} className="border-white/15 text-gray-300 hover:bg-white/6 gap-2 text-xs">
<Copy className="h-3.5 w-3.5" />{t('feesUi.copyInvoice')}
</Button>
<Button variant="outline" onClick={() => window.open(`lightning:${invoice}`, '_blank')} className="border-white/15 text-gray-300 hover:bg-white/6 gap-2 text-xs">
<ExternalLink className="h-3.5 w-3.5" />{t('feesUi.openWallet')}
</Button>
</div>
{(webln || activeNWC) && (
<Button onClick={handleAutoPay} className={cn('w-full gap-2 font-bold', 'bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 border-0')}>
<Zap className="h-4 w-4" />{t('feesUi.payConnected')}
</Button>
)}
<p className="text-[11px] text-gray-600 text-center">{t('feesUi.scanAny')}</p>
</div>
)}
{paymentState === 'error' && (
<div className="space-y-3">
<p className="text-sm text-red-400 text-center">{error}</p>
<Button variant="outline" onClick={generateInvoice} className="w-full border-white/15">
{t('feesUi.tryAgain')}
</Button>
</div>
)}
</div>
</div>
);
}