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).
377 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|