From d44027fae953914b3e18a1e1785069fe707e4c88 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 28 Sep 2026 21:54:30 +0700 Subject: [PATCH] feat(support): reusable 'Keep the map free' element on About MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds components/SupportSkateSpots — two honest paths to send sats to the project, reusing existing rails (no new deps, no backend): - Zap SkateSpots: NIP-57 zap to the platform pubkey (public + optional comment, needs a signer). - Pay quietly: plain LNURL invoice from the platform lightning address (anonymous, works logged out). Payment runs NWC -> WebLN -> QR fallback, exactly like the Pro flow. Copy is explicit that this is support, not a purchase (buys nothing, no refunds), and that merch/Pro are separate. --- src/components/SupportSkateSpots.tsx | 274 +++++++++++++++++++++++++++ src/pages/AboutPage.tsx | 4 + 2 files changed, 278 insertions(+) create mode 100644 src/components/SupportSkateSpots.tsx diff --git a/src/components/SupportSkateSpots.tsx b/src/components/SupportSkateSpots.tsx new file mode 100644 index 0000000..c00e6cb --- /dev/null +++ b/src/components/SupportSkateSpots.tsx @@ -0,0 +1,274 @@ +/** + * 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'; + +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 { 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: 'Pick an amount', description: 'Enter how many sats you want to send.', 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: 'Thank you! ⚡', description: 'Your sats keep the map free.' }); + return; + } catch { /* fall through */ } + } + if (webln) { + try { + if (typeof webln.enable === 'function') await webln.enable(); + await webln.sendPayment(pr); + setState('paid'); + toast({ title: 'Thank you! ⚡', description: 'Your sats keep the map free.' }); + return; + } catch { /* fall through */ } + } + setInvoice(pr); + setState('awaiting'); + } catch (err) { + setState('error'); + setError(err instanceof Error ? err.message : 'Could not start the payment.'); + } + }; + + const copy = async (text: string, label: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + toast({ title: `${label} copied` }); + setTimeout(() => setCopied(false), 1500); + } catch { /* clipboard blocked */ } + }; + + const reset = () => { setState('idle'); setInvoice(null); setError(null); }; + + return ( + + +
+ + + +
+

Keep the map free

+

No ads, no tracking, no VC. Your sats pay for hosting, the AI node and the relay.

+
+
+ + {state === 'paid' ? ( +
+ +

Thank you! ⚡

+

You just kept SkateSpots free for the next skater.

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

+ Scan to pay {amount.toLocaleString()} sats +

+ Lightning invoice QR +
+ + +
+

+ Pays to ⚡ {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="Custom amount in sats" + /> + sats +
+ + {/* Optional comment (public zaps only) */} + setComment(e.target.value)} + placeholder="Say something (optional — public zaps only)" + maxLength={120} + className="h-9 border-white/15 bg-black/40 text-sm text-white placeholder:text-gray-600" + /> + + {error &&

{error}

} + +
+ + +
+ +
+ +
+ +

+ Support, not a purchase — it buys nothing and + there are no refunds. Merchandise and Sasquatch Pro are separate. Your location and + identity are never involved in a quiet payment. +

+ + )} +
+
+ ); +} diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index 7bb6816..d5c7dbc 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -1,6 +1,7 @@ import { useSeoMeta } from '@unhead/react'; import { Link } from 'react-router-dom'; import { Card, CardContent } from '@/components/ui/card'; +import { SupportSkateSpots } from '@/components/SupportSkateSpots'; import { Button } from '@/components/ui/button'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useToast } from '@/hooks/useToast'; @@ -308,6 +309,9 @@ Version: v0.1.0`; + {/* Support the project — the honest "how it's funded" element */} + + {/* Powered By */}

Powered by