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 { 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`;
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Support the project — the honest "how it's funded" element */}
|
||||
<SupportSkateSpots className="mt-8" />
|
||||
|
||||
{/* Powered By */}
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue