/** * SupportSkateSpots — a reusable "send sats to the project" element. * * Two honest paths, no new dependencies and no backend: * • ⚡ Zap SkateSpots — a NIP-57 zap to the platform pubkey: public, with an * optional comment, so support shows up in zap totals. Needs a signer. * • Pay quietly — a plain LNURL invoice from the platform lightning address: * anonymous, no comment, no record, and works LOGGED OUT. * * Payment goes NWC → WebLN → QR fallback, exactly like the Pro flow. * * This is SUPPORT, not a purchase: it funds hosting, the AI node and the relay, * buys nothing, and there are no refunds. Copy says so on purpose — keep it that * way (see the pricing/product stance in AGENTS.md). */ import { useEffect, useState } from 'react'; import QRCode from 'qrcode'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent } from '@/components/ui/card'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNWC } from '@/hooks/useNWCContext'; import { useWallet } from '@/hooks/useWallet'; import { useRelays } from '@/lib/relays'; import { useToast } from '@/hooks/useToast'; import { PLATFORM_LIGHTNING_ADDRESS } from '@/lib/platform'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { getZapProviderPubkey, getZapInvoiceFromLightningAddress, getInvoiceFromLightningAddress, } from '@/lib/lightningAddress'; import { Zap, Copy, Check, Loader2, CheckCircle2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useTranslation } from '@/contexts/language'; const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0]; const PRESETS = [1000, 5000, 21000]; type PayState = 'idle' | 'preparing' | 'awaiting' | 'paid' | 'error'; export function SupportSkateSpots({ className }: { className?: string }) { const { t } = useTranslation(); const { user } = useCurrentUser(); const { getActiveConnection, sendPayment } = useNWC(); const { webln } = useWallet(); const { read, write } = useRelays(); const { toast } = useToast(); const [amount, setAmount] = useState(5000); const [comment, setComment] = useState(''); const [state, setState] = useState('idle'); const [invoice, setInvoice] = useState(null); const [qr, setQr] = useState(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); // QR for the manual (scan-to-pay) fallback. useEffect(() => { let active = true; if (invoice) { QRCode.toDataURL(invoice.toUpperCase(), { width: 260, margin: 1 }) .then((url) => { if (active) setQr(url); }) .catch(() => { if (active) setQr(null); }); } else { setQr(null); } return () => { active = false; }; }, [invoice]); const buildInvoice = async (mode: 'zap' | 'quiet'): Promise => { // Quiet path (no signer needed) — plain invoice, anonymous. if (mode === 'quiet' || !user?.signer) { return getInvoiceFromLightningAddress({ lightningAddress: PLATFORM_LIGHTNING_ADDRESS, amountSats: amount, comment: comment.trim() || undefined, }); } // Public zap — signed NIP-57 request, so it's attributed + shows in zaps. const providerPubkey = await getZapProviderPubkey(PLATFORM_LIGHTNING_ADDRESS).catch(() => null); if (!providerPubkey) { // Provider doesn't publish zap receipts → fall back to a plain invoice. return getInvoiceFromLightningAddress({ lightningAddress: PLATFORM_LIGHTNING_ADDRESS, amountSats: amount, comment: comment.trim() || undefined, }); } const relays = [...new Set([...read, ...write])]; const signed = await user.signer.signEvent({ kind: 9734, content: comment.trim() || 'Support SkateSpots 🛹⚡', created_at: Math.floor(Date.now() / 1000), tags: [ ['relays', ...relays], ['amount', String(amount * 1000)], ['p', PLATFORM_PUBKEY], ], }); return getZapInvoiceFromLightningAddress({ lightningAddress: PLATFORM_LIGHTNING_ADDRESS, amountSats: amount, zapRequest: JSON.stringify(signed), }); }; const pay = async (mode: 'zap' | 'quiet') => { if (!Number.isFinite(amount) || amount <= 0) { toast({ title: t('supportUi.pickAmount'), description: t('supportUi.enterAmount'), variant: 'destructive' }); return; } setState('preparing'); setError(null); try { const pr = await buildInvoice(mode); // Auto-pay: NWC → WebLN. Otherwise show the invoice as a QR. const nwc = getActiveConnection(); if (nwc?.connectionString && nwc.isConnected) { try { await sendPayment(nwc, pr); setState('paid'); toast({ title: t('supportUi.thankYou'), description: t('supportUi.satsKeepMapFree') }); return; } catch { /* fall through */ } } if (webln) { try { if (typeof webln.enable === 'function') await webln.enable(); await webln.sendPayment(pr); setState('paid'); toast({ title: t('supportUi.thankYou'), description: t('supportUi.satsKeepMapFree') }); return; } catch { /* fall through */ } } setInvoice(pr); setState('awaiting'); } catch (err) { setState('error'); setError(err instanceof Error ? err.message : t('supportUi.couldNotStartPayment')); } }; const copy = async (text: string, label: string) => { try { await navigator.clipboard.writeText(text); setCopied(true); toast({ title: t('supportUi.copiedSuffix', { label }) }); setTimeout(() => setCopied(false), 1500); } catch { /* clipboard blocked */ } }; const reset = () => { setState('idle'); setInvoice(null); setError(null); }; return (

{t('supportUi.keepMapFree')}

{t('supportUi.keepMapFreeDesc')}

{state === 'paid' ? (

{t('supportUi.thankYou')}

{t('supportUi.keptFreeNext')}

) : state === 'awaiting' && qr ? (

{t('supportUi.scanToPay')} {amount.toLocaleString()} sats

{t('feesUi.invoiceQrAlt')}

{t('sasquatchUi.paysTo')} ⚡ {PLATFORM_LIGHTNING_ADDRESS}

) : ( <> {/* Amount */}
{PRESETS.map((p) => ( ))} setAmount(Number(e.target.value))} className="h-9 w-28 border-white/15 bg-black/40 text-sm text-white" aria-label={t('supportUi.customAmountAria')} /> sats
{/* Optional comment (public zaps only) */} setComment(e.target.value)} placeholder={t('supportUi.commentPlaceholder')} maxLength={120} className="h-9 border-white/15 bg-black/40 text-sm text-white placeholder:text-gray-600" /> {error &&

{error}

}

{t('supportUi.notAPurchase')}

)}
); }