SkateSpots/src/components/SpamFeePayment.tsx
SkateSpots 721a5d4b67 chore(lint): clear all react-hooks/exhaustive-deps warnings
Triage per file (not a blanket sweep):
- Memoise shared getters with useCallback and depend on them:
  EventsPage/LabPage/MapPage getTrustScore+getCommentCount (+MapPage getZapTotal)
- Memoise derived values that were recreated each render: CrewChat 'messages',
  RiderCardPage 'cardData'
- Justified eslint-disable for intentional mount-only effects with a comment:
  AppProvider (seed defaults/theme), SpotMap (Leaflet init once),
  SpamFeePayment (auto-pay keyed to invoice/state, not the recreated handler)
- EventCountdown: keep the intentional 'tick' recompute dep (documented disable)

exhaustive-deps: 14 -> 0. Warnings 60 -> 46 (39 unused-vars, 7 react-refresh).
Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline updated.
2026-09-26 22:09:43 +07:00

329 lines
12 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';
// qrcode imported lazily in async function
interface SpamFeePaymentProps {
feeRecipient: string;
feeRecipientName: string;
onPaymentSuccess: () => void;
onSkip?: () => void;
}
type PaymentState = 'idle' | 'generating' | 'waiting' | 'paying' | 'success' | 'error';
export function SpamFeePayment({
feeRecipient,
feeRecipientName,
onPaymentSuccess,
}: SpamFeePaymentProps) {
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: 'SkateSpots listing fee',
});
// 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 : 'Failed to generate invoice');
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: 'Payment successful!',
description: `${LISTING_FEE_SATS} sats sent to ${feeRecipientName}`,
});
setTimeout(onPaymentSuccess, 1500);
return;
}
// Try WebLN
if (webln) {
if (webln.enable) {
await webln.enable();
}
await webln.sendPayment(invoice);
setPaymentState('success');
toast({
title: 'Payment successful!',
description: `${LISTING_FEE_SATS} sats sent to ${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: 'Auto-pay failed',
description: 'Please pay manually using the QR code or invoice',
variant: 'destructive',
});
}
};
const copyInvoice = async () => {
if (!invoice) return;
try {
await navigator.clipboard.writeText(invoice);
toast({ title: 'Invoice copied!' });
} catch {
toast({ title: 'Failed to copy', 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">Payment Received!</h3>
<p className="text-gray-300">
Your {LISTING_FEE_SATS} sat fee has been paid. You can now create your listing.
</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" />
One-Time Listing Fee
</CardTitle>
<CardDescription className="text-gray-300">
As a new member, a small fee is required for your first listing.
</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">Why do I need to pay?</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">Spam prevention:</span> This fee stops bots and scammers from flooding the marketplace with fake listings.
</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">Trust building:</span> New accounts need to prove commitment before listing. After a few successful listings, this fee is waived.
</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">Supports the community:</span> Fee goes to{' '}
<span className="font-semibold text-yellow-400">{feeRecipientName}</span>
{feeRecipient !== PLATFORM_LIGHTNING_ADDRESS && ' — the person who created this spot!'}
{feeRecipient === PLATFORM_LIGHTNING_ADDRESS && ' — helping keep SkateSpots running.'}
</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()} sats
</div>
<p className="text-sm text-gray-400 mt-1">~${(LISTING_FEE_SATS * 0.0006).toFixed(2)} USD • One-time fee</p>
</div>
{/* Initial state - generate invoice button */}
{paymentState === 'idle' && (
<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" />
Pay {LISTING_FEE_SATS} sats to Continue
</Button>
)}
{/* 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">Generating invoice...</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">Processing payment...</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="Payment QR Code" 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" />
Copy Invoice
</Button>
<Button
variant="outline"
onClick={openInWallet}
className="border-yellow-500/50 hover:bg-yellow-500/10"
>
<ExternalLink className="h-4 w-4 mr-2" />
Open Wallet
</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" />
Pay with Connected Wallet
</Button>
)}
<p className="text-xs text-gray-400 text-center">
Scan the QR code with a Lightning wallet or use the buttons above
</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"
>
Try Again
</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">
✨ How to skip this fee in the future:
</p>
<ul className="text-gray-300 text-sm space-y-1">
<li className="flex items-center gap-2">
<span className="text-green-400">→</span>
Add a skate spot to the map
</li>
<li className="flex items-center gap-2">
<span className="text-green-400">→</span>
Complete 2-3 successful listings
</li>
<li className="flex items-center gap-2">
<span className="text-green-400">→</span>
Build your community reputation
</li>
</ul>
<p className="text-xs text-gray-400 mt-2">
Trusted members get unlimited free listings!
</p>
</div>
</CardContent>
</Card>
);
}