- First-visit language auto-detect (Spanish devices land in es; stored choice wins). - Sasquatch AI replies in the app language (skate trick names kept English). - i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t() key missing from en/es or any en/es key drift. - ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace, trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters. - Policy: only skate TRICK names stay English; user-generated content untouched.
351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useWallet } from '@/hooks/useWallet';
|
|
import { useNWC } from '@/hooks/useNWCContext';
|
|
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
|
|
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
|
|
import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
|
|
import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react';
|
|
import { useTranslation } from '@/contexts/language';
|
|
// qrcode imported lazily in async function
|
|
|
|
interface SpamFeePaymentProps {
|
|
feeRecipient: string;
|
|
feeRecipientName: string;
|
|
onPaymentSuccess: () => void;
|
|
/** Offered as a FREE alternative to the sats fee: mine NIP-13 proof of work. */
|
|
onPowInstead?: () => void;
|
|
onSkip?: () => void;
|
|
}
|
|
|
|
type PaymentState = 'idle' | 'generating' | 'waiting' | 'paying' | 'success' | 'error';
|
|
|
|
export function SpamFeePayment({
|
|
feeRecipient,
|
|
feeRecipientName,
|
|
onPaymentSuccess,
|
|
onPowInstead,
|
|
}: SpamFeePaymentProps) {
|
|
const { t } = useTranslation();
|
|
const { toast } = useToast();
|
|
const { webln, activeNWC } = useWallet();
|
|
const { sendPayment } = useNWC();
|
|
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 is connected and invoice is generated
|
|
useEffect(() => {
|
|
if (invoice && paymentState === 'waiting' && (webln || activeNWC)) {
|
|
handleAutoPay();
|
|
}
|
|
// Intentionally omit handleAutoPay: it is recreated each render, so adding
|
|
// it would re-trigger auto-pay on every render. Keyed to invoice/state.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [invoice, paymentState, webln, activeNWC]);
|
|
|
|
const generateInvoice = async () => {
|
|
setPaymentState('generating');
|
|
setError(null);
|
|
|
|
try {
|
|
const bolt11 = await getInvoiceFromLightningAddress({
|
|
lightningAddress: feeRecipient,
|
|
amountSats: LISTING_FEE_SATS,
|
|
comment: t('marketUi.spamFee.invoiceComment'),
|
|
});
|
|
|
|
// SECURITY: verify the returned invoice really requests the listing fee
|
|
// (and not an inflated amount) before it can be auto-paid.
|
|
assertInvoiceAmount(bolt11, LISTING_FEE_SATS);
|
|
|
|
setInvoice(bolt11);
|
|
|
|
// Generate QR code
|
|
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 wallet is available, try auto-pay
|
|
if (webln || activeNWC) {
|
|
await handleAutoPay();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to generate invoice:', err);
|
|
setError(err instanceof Error ? err.message : t('marketUi.spamFee.generateFailed'));
|
|
setPaymentState('error');
|
|
}
|
|
};
|
|
|
|
const handleAutoPay = async () => {
|
|
if (!invoice) return;
|
|
|
|
setPaymentState('paying');
|
|
|
|
try {
|
|
// Try NWC first
|
|
if (activeNWC && activeNWC.isConnected) {
|
|
await sendPayment(activeNWC, invoice);
|
|
setPaymentState('success');
|
|
toast({
|
|
title: t('marketUi.spamFee.paymentSuccess'),
|
|
description: t('marketUi.spamFee.satsSent', { amount: LISTING_FEE_SATS.toLocaleString(), name: feeRecipientName }),
|
|
});
|
|
setTimeout(onPaymentSuccess, 1500);
|
|
return;
|
|
}
|
|
|
|
// Try WebLN
|
|
if (webln) {
|
|
if (webln.enable) {
|
|
await webln.enable();
|
|
}
|
|
await webln.sendPayment(invoice);
|
|
setPaymentState('success');
|
|
toast({
|
|
title: t('marketUi.spamFee.paymentSuccess'),
|
|
description: t('marketUi.spamFee.satsSent', { amount: LISTING_FEE_SATS.toLocaleString(), name: feeRecipientName }),
|
|
});
|
|
setTimeout(onPaymentSuccess, 1500);
|
|
return;
|
|
}
|
|
|
|
// No auto-pay available, stay in waiting state
|
|
setPaymentState('waiting');
|
|
} catch (err) {
|
|
console.error('Auto-pay failed:', err);
|
|
// Fall back to manual payment
|
|
setPaymentState('waiting');
|
|
toast({
|
|
title: t('marketUi.spamFee.autoPayFailed'),
|
|
description: t('marketUi.spamFee.autoPayFailedBody'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const copyInvoice = async () => {
|
|
if (!invoice) return;
|
|
try {
|
|
await navigator.clipboard.writeText(invoice);
|
|
toast({ title: t('marketUi.spamFee.invoiceCopied') });
|
|
} catch {
|
|
toast({ title: t('marketUi.spamFee.copyFailed'), variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
const openInWallet = () => {
|
|
if (!invoice) return;
|
|
window.open(`lightning:${invoice}`, '_blank');
|
|
};
|
|
|
|
// Show success state
|
|
if (paymentState === 'success') {
|
|
return (
|
|
<Card className="bg-green-950/30 border-2 border-green-500/50">
|
|
<CardContent className="py-8 text-center">
|
|
<CheckCircle className="h-12 w-12 text-green-400 mx-auto mb-4" />
|
|
<h3 className="font-bold text-green-400 text-lg mb-2">{t('marketUi.spamFee.received')}</h3>
|
|
<p className="text-gray-300">
|
|
{t('marketUi.spamFee.receivedBody', { amount: LISTING_FEE_SATS.toLocaleString() })}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Show payment flow
|
|
return (
|
|
<Card className="bg-yellow-950/30 border-2 border-yellow-500/50">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-yellow-400">
|
|
<ShieldAlert className="h-5 w-5" />
|
|
{t('marketUi.spamFee.title')}
|
|
</CardTitle>
|
|
<CardDescription className="text-gray-300">
|
|
{t('marketUi.spamFee.subtitle')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Why this fee exists */}
|
|
<div className="bg-black/30 rounded-lg p-4 space-y-3">
|
|
<h4 className="font-bold text-white text-sm">{t('marketUi.spamFee.whyTitle')}</h4>
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex items-start gap-2">
|
|
<ShieldAlert className="h-4 w-4 text-yellow-400 mt-0.5 flex-shrink-0" />
|
|
<p className="text-gray-300">
|
|
<span className="text-white font-medium">{t('marketUi.spamFee.spamLabel')}</span> {t('marketUi.spamFee.spamBody')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-start gap-2">
|
|
<TrendingUp className="h-4 w-4 text-green-400 mt-0.5 flex-shrink-0" />
|
|
<p className="text-gray-300">
|
|
<span className="text-white font-medium">{t('marketUi.spamFee.trustLabel')}</span> {t('marketUi.spamFee.trustBody')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-start gap-2">
|
|
<MapPin className="h-4 w-4 text-[#ff6600] mt-0.5 flex-shrink-0" />
|
|
<p className="text-gray-300">
|
|
<span className="text-white font-medium">{t('marketUi.spamFee.supportsLabel')}</span> {t('marketUi.spamFee.supportsPrefix')}{' '}
|
|
<span className="font-semibold text-yellow-400">{feeRecipientName}</span>
|
|
{feeRecipient !== PLATFORM_LIGHTNING_ADDRESS && t('marketUi.spamFee.supportsSpotOwner')}
|
|
{feeRecipient === PLATFORM_LIGHTNING_ADDRESS && t('marketUi.spamFee.supportsPlatform')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fee amount */}
|
|
<div className="text-center py-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
|
<div className="text-3xl font-black text-yellow-400">
|
|
⚡ {LISTING_FEE_SATS.toLocaleString()} {t('common.sats')}
|
|
</div>
|
|
<p className="text-sm text-gray-400 mt-1">{t('marketUi.spamFee.oneTimeFee', { usd: (LISTING_FEE_SATS * 0.0006).toFixed(2) })}</p>
|
|
</div>
|
|
|
|
{/* Initial state — pay, or prove work instead (free) */}
|
|
{paymentState === 'idle' && (
|
|
<div className="space-y-3">
|
|
<Button
|
|
onClick={generateInvoice}
|
|
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
|
|
>
|
|
<Zap className="h-4 w-4 mr-2" />
|
|
{t('marketUi.spamFee.payToContinue', { amount: LISTING_FEE_SATS.toLocaleString() })}
|
|
</Button>
|
|
{onPowInstead && (
|
|
<>
|
|
<p className="text-center text-xs uppercase tracking-widest text-gray-500">{t('common.or')}</p>
|
|
<Button
|
|
onClick={onPowInstead}
|
|
variant="outline"
|
|
className="w-full font-bold border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
|
|
>
|
|
⚡ {t('marketUi.spamFee.proveWork')}
|
|
</Button>
|
|
<p className="text-center text-xs text-gray-500">
|
|
{t('marketUi.spamFee.proveWorkBody', { amount: LISTING_FEE_SATS.toLocaleString() })}
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Generating invoice */}
|
|
{paymentState === 'generating' && (
|
|
<div className="text-center py-4">
|
|
<Loader2 className="h-8 w-8 animate-spin mx-auto text-yellow-400" />
|
|
<p className="text-gray-400 mt-2">{t('marketUi.spamFee.generating')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Paying */}
|
|
{paymentState === 'paying' && (
|
|
<div className="text-center py-4">
|
|
<Loader2 className="h-8 w-8 animate-spin mx-auto text-yellow-400" />
|
|
<p className="text-gray-400 mt-2">{t('marketUi.spamFee.processing')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Waiting for payment - show QR and manual options */}
|
|
{paymentState === 'waiting' && invoice && qrCodeUrl && (
|
|
<div className="space-y-4">
|
|
{/* QR Code */}
|
|
<div className="flex justify-center">
|
|
<div className="bg-white p-3 rounded-lg">
|
|
<img src={qrCodeUrl} alt={t('marketUi.spamFee.qrAlt')} className="w-48 h-48" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={copyInvoice}
|
|
className="border-yellow-500/50 hover:bg-yellow-500/10"
|
|
>
|
|
<Copy className="h-4 w-4 mr-2" />
|
|
{t('marketUi.spamFee.copyInvoice')}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={openInWallet}
|
|
className="border-yellow-500/50 hover:bg-yellow-500/10"
|
|
>
|
|
<ExternalLink className="h-4 w-4 mr-2" />
|
|
{t('marketUi.spamFee.openWallet')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Try again with wallet */}
|
|
{(webln || activeNWC) && (
|
|
<Button
|
|
onClick={handleAutoPay}
|
|
className="w-full bg-yellow-500 hover:bg-yellow-600 text-black font-bold"
|
|
>
|
|
<Zap className="h-4 w-4 mr-2" />
|
|
{t('marketUi.spamFee.payWithWallet')}
|
|
</Button>
|
|
)}
|
|
|
|
<p className="text-xs text-gray-400 text-center">
|
|
{t('marketUi.spamFee.scanHint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{paymentState === 'error' && error && (
|
|
<div className="space-y-4">
|
|
<div className="text-center py-2">
|
|
<p className="text-red-400">{error}</p>
|
|
</div>
|
|
<Button
|
|
onClick={generateInvoice}
|
|
variant="outline"
|
|
className="w-full border-yellow-500/50 hover:bg-yellow-500/10"
|
|
>
|
|
{t('common.tryAgain')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* How to avoid future fees */}
|
|
<div className="bg-green-500/10 rounded-lg p-3 border border-green-500/30">
|
|
<p className="text-green-400 font-semibold text-sm mb-2">
|
|
✨ {t('marketUi.spamFee.skipTitle')}
|
|
</p>
|
|
<ul className="text-gray-300 text-sm space-y-1">
|
|
<li className="flex items-center gap-2">
|
|
<span className="text-green-400">→</span>
|
|
{t('marketUi.spamFee.skip1')}
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<span className="text-green-400">→</span>
|
|
{t('marketUi.spamFee.skip2')}
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<span className="text-green-400">→</span>
|
|
{t('marketUi.spamFee.skip3')}
|
|
</li>
|
|
</ul>
|
|
<p className="text-xs text-gray-400 mt-2">
|
|
{t('marketUi.spamFee.skipNote')}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|