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.
329 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|