feat(support): reusable 'Keep the map free' element on About
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.
This commit is contained in:
parent
f233fcc8ed
commit
d44027fae9
2 changed files with 278 additions and 0 deletions
274
src/components/SupportSkateSpots.tsx
Normal file
274
src/components/SupportSkateSpots.tsx
Normal file
|
|
@ -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<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: '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 (
|
||||||
|
<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">Keep the map free</h3>
|
||||||
|
<p className="text-xs text-gray-400">No ads, no tracking, no VC. Your sats pay for hosting, the AI node and the relay.</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]">Thank you! ⚡</p>
|
||||||
|
<p className="text-xs text-gray-300">You just kept SkateSpots free for the next skater.</p>
|
||||||
|
<Button size="sm" variant="outline" className="border-white/15" onClick={reset}>Done</Button>
|
||||||
|
</div>
|
||||||
|
) : state === 'awaiting' && qr ? (
|
||||||
|
<div className="space-y-3 text-center">
|
||||||
|
<p className="text-sm text-gray-300">
|
||||||
|
Scan to pay <span className="font-black text-white">{amount.toLocaleString()} sats</span>
|
||||||
|
</p>
|
||||||
|
<img src={qr} alt="Lightning invoice QR" 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 ?? '', 'Invoice')}>
|
||||||
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} Copy invoice
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90" onClick={reset}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
Pays to <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="Custom amount in sats"
|
||||||
|
/>
|
||||||
|
<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="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 && <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 ? 'Public zap with your comment' : 'Log in to send a public zap'}
|
||||||
|
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" />}
|
||||||
|
Zap SkateSpots
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => pay('quiet')}
|
||||||
|
disabled={state === 'preparing'}
|
||||||
|
title="Anonymous — no login, no record"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 border-white/20 text-gray-200 hover:bg-white/5"
|
||||||
|
>
|
||||||
|
Pay quietly
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => copy(PLATFORM_LIGHTNING_ADDRESS, 'Address')}
|
||||||
|
className="flex items-center gap-1.5 font-mono text-[11px] text-gray-400 hover:text-[#ccff00] transition-colors"
|
||||||
|
title="Copy the lightning address"
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
Support, <strong className="text-gray-500">not a purchase</strong> — 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.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { SupportSkateSpots } from '@/components/SupportSkateSpots';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
@ -308,6 +309,9 @@ Version: v0.1.0`;
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Support the project — the honest "how it's funded" element */}
|
||||||
|
<SupportSkateSpots className="mt-8" />
|
||||||
|
|
||||||
{/* Powered By */}
|
{/* Powered By */}
|
||||||
<div className="text-center mt-8 pb-6 px-4">
|
<div className="text-center mt-8 pb-6 px-4">
|
||||||
<p className="text-gray-400 mb-3 uppercase tracking-wide text-sm">Powered by</p>
|
<p className="text-gray-400 mb-3 uppercase tracking-wide text-sm">Powered by</p>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue