SkateSpots/src/components/SupportSkateSpots.tsx

274 lines
11 KiB
TypeScript

/**
* 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<PayState>('idle');
const [invoice, setInvoice] = useState<string | null>(null);
const [qr, setQr] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<string> => {
// 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 (
<Card className={cn('bg-black/90 border-2 border-[#ccff00]/40', className)}>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#ccff00]/15 text-[#ccff00]">
<Zap className="h-5 w-5" />
</span>
<div>
<h3 className="text-lg font-black uppercase text-white leading-tight">{t('supportUi.keepMapFree')}</h3>
<p className="text-xs text-gray-400">{t('supportUi.keepMapFreeDesc')}</p>
</div>
</div>
{state === 'paid' ? (
<div className="rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-4 text-center space-y-2">
<CheckCircle2 className="mx-auto h-7 w-7 text-[#ccff00]" />
<p className="font-black text-[#ccff00]">{t('supportUi.thankYou')}</p>
<p className="text-xs text-gray-300">{t('supportUi.keptFreeNext')}</p>
<Button size="sm" variant="outline" className="border-white/15" onClick={reset}>{t('common.done')}</Button>
</div>
) : state === 'awaiting' && qr ? (
<div className="space-y-3 text-center">
<p className="text-sm text-gray-300">
{t('supportUi.scanToPay')} <span className="font-black text-white">{amount.toLocaleString()} sats</span>
</p>
<img src={qr} alt={t('feesUi.invoiceQrAlt')} className="mx-auto rounded-xl bg-white p-2" width={240} height={240} />
<div className="flex gap-2">
<Button variant="outline" className="flex-1 gap-2 border-white/15" onClick={() => copy(invoice ?? '', t('supportUi.invoiceLabel'))}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} {t('walletUi.nwc.copyInvoice')}
</Button>
<Button className="flex-1 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" onClick={reset}>
{t('common.done')}
</Button>
</div>
<p className="text-[11px] text-gray-500">
{t('sasquatchUi.paysTo')} <span className="font-mono text-gray-300">⚡ {PLATFORM_LIGHTNING_ADDRESS}</span>
</p>
</div>
) : (
<>
{/* Amount */}
<div className="flex flex-wrap items-center gap-2">
{PRESETS.map((p) => (
<button
key={p}
onClick={() => setAmount(p)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-bold transition-colors',
amount === p
? 'border-[#ccff00] bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/15 text-gray-300 hover:border-white/30',
)}
>
{p.toLocaleString()}
</button>
))}
<Input
type="number"
inputMode="numeric"
value={amount}
onChange={(e) => 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')}
/>
<span className="text-xs text-gray-500">sats</span>
</div>
{/* Optional comment (public zaps only) */}
<Input
value={comment}
onChange={(e) => 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 && <p className="text-xs text-red-400">{error}</p>}
<div className="flex flex-col gap-2 sm:flex-row">
<Button
onClick={() => pay('zap')}
disabled={state === 'preparing' || !user}
title={user ? t('supportUi.publicZapTitle') : t('supportUi.loginToZapTitle')}
className="flex-1 gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
>
{state === 'preparing' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
{t('supportUi.zapSkateSpots')}
</Button>
<Button
onClick={() => pay('quiet')}
disabled={state === 'preparing'}
title={t('supportUi.anonymousTitle')}
variant="outline"
className="flex-1 border-white/20 text-gray-200 hover:bg-white/5"
>
{t('supportUi.payQuietly')}
</Button>
</div>
<div className="flex items-center justify-between gap-2">
<button
onClick={() => copy(PLATFORM_LIGHTNING_ADDRESS, t('supportUi.addressLabel'))}
className="flex items-center gap-1.5 font-mono text-[11px] text-gray-400 hover:text-[#ccff00] transition-colors"
title={t('supportUi.copyLightningTitle')}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
⚡ {PLATFORM_LIGHTNING_ADDRESS}
</button>
</div>
<p className="text-[11px] leading-relaxed text-gray-600">
{t('supportUi.notAPurchase')}
</p>
</>
)}
</CardContent>
</Card>
);
}