SkateSpots/src/components/SpamFeePayment.tsx
SkateSpots 09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- 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.
2026-10-01 23:57:20 +07:00

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>
);
}