SkateSpots/src/components/SasquatchProDialog.tsx
SkateSpots 6335965afc fix(pro): single-source the platform Lightning address + show it to payers
The pay-to address was duplicated (PRO_LIGHTNING_ADDRESS and
PLATFORM_LIGHTNING_ADDRESS) and hardcoded in the tour zap-split; a typo in any
copy would send money nowhere. Now one source of truth in src/lib/platform.ts,
re-exported by the fee/Pro modules.

Also surface the address in the Sasquatch Pro dialog ('Pays to ⚡ <address>') so
users can see where their sats go, in both the auto-pay and manual-QR paths.
2026-09-27 14:13:37 +07:00

248 lines
9.8 KiB
TypeScript

/**
* SasquatchProDialog — the "Upgrade to Sasquatch Pro" sheet.
*
* Shows the supporter tiers, the perk list, and handles Lightning payment
* (auto via NWC/WebLN, or a QR fallback). Framed as supporting a free,
* no-ads, community-funded app — Pro is the thank-you.
*/
import { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
import { usePurchasePro } from '@/hooks/usePurchasePro';
import { useToast } from '@/hooks/useToast';
import { PRO_TIERS, PRO_LIGHTNING_ADDRESS, type ProTier } from '@/lib/sasquatchPro';
import {
Zap,
Check,
Loader2,
Copy,
Brain,
Wand2,
Rocket,
BadgeCheck,
} from 'lucide-react';
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; text: string }> = [
{ icon: Brain, text: 'Personal assistant — Sasquatch reads your own Stash, crews, spots & listings' },
{ icon: Wand2, text: 'Action drafts: list gear, post a spot status, plan a trip — you confirm before anything publishes' },
{ icon: Rocket, text: 'Answers use your personal context, not just public data' },
{ icon: BadgeCheck, text: '⚡ Directly support SkateSpots & keep it free and ad-free' },
];
export function SasquatchProDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { user } = useCurrentUser();
const { isPro, expiresAt } = useSasquatchPro();
const { toast } = useToast();
const purchase = usePurchasePro();
const [selected, setSelected] = useState<ProTier>(
PRO_TIERS.find((t) => t.popular) ?? PRO_TIERS[1],
);
const [qr, setQr] = useState<string | null>(null);
// Render a QR whenever we fall back to manual payment.
useEffect(() => {
let active = true;
if (purchase.invoice) {
QRCode.toDataURL(purchase.invoice.toUpperCase(), { width: 280, margin: 1 })
.then((url) => { if (active) setQr(url); })
.catch(() => setQr(null));
} else {
setQr(null);
}
return () => { active = false; };
}, [purchase.invoice]);
// Toast + close on success.
useEffect(() => {
if (purchase.state === 'paid') {
toast({ title: 'Welcome to Sasquatch Pro! 🦧⚡', description: 'Your pass is active — a receipt just landed in your DMs. Thanks for keeping the streets free.' });
const t = setTimeout(() => { onOpenChange(false); purchase.reset(); }, 1400);
return () => clearTimeout(t);
}
}, [purchase.state, toast, onOpenChange, purchase]);
const copyInvoice = () => {
if (purchase.invoice) {
navigator.clipboard.writeText(purchase.invoice);
toast({ title: 'Invoice copied' });
}
};
const busy = purchase.state === 'preparing';
const manual = purchase.state === 'awaiting_payment' && !!purchase.invoice;
return (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) purchase.reset(); }}>
<DialogContent className="max-w-md bg-black border-red-500/30 text-white max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl font-black">
<span className="text-2xl">🦧</span> Sasquatch <span className="text-red-500">Pro</span>
</DialogTitle>
<DialogDescription className="text-gray-400">
Supercharge your AI skate guide. 100% funds a free, no-ads community app.
</DialogDescription>
</DialogHeader>
{isPro ? (
// ── Already Pro ─────────────────────────────────────────────────
<div className="space-y-4 py-2">
<div className="rounded-2xl border border-[#ccff00]/30 bg-[#ccff00]/10 p-4 text-center">
<BadgeCheck className="mx-auto mb-2 h-8 w-8 text-[#ccff00]" />
<p className="font-black text-[#ccff00]">You're a Pro supporter ⚡</p>
{expiresAt && (
<p className="mt-1 text-xs text-gray-300">
Active until {new Date(expiresAt * 1000).toLocaleDateString()}
</p>
)}
</div>
<div className="space-y-2">
{PERKS.map((p) => (
<div key={p.text} className="flex items-start gap-2 text-sm text-gray-300">
<Check className="mt-0.5 h-4 w-4 shrink-0 text-[#ccff00]" />
{p.text}
</div>
))}
</div>
<p className="text-center text-xs text-gray-500">
Want to extend? Pick a tier below to add more time.
</p>
<TierPicker selected={selected} onSelect={setSelected} />
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} extend />
</div>
) : manual ? (
// ── Manual QR payment ───────────────────────────────────────────
<div className="space-y-4 py-2 text-center">
<p className="text-sm text-gray-300">
Scan to pay <span className="font-black text-white">{selected.sats.toLocaleString()} sats</span> for {selected.label} of Pro
</p>
{qr && <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={copyInvoice}>
<Copy className="h-4 w-4" /> Copy invoice
</Button>
<Button
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#d4ff1a] font-bold"
onClick={() => purchase.confirmManual(selected)}
>
<Check className="h-4 w-4" /> I've paid
</Button>
</div>
<p className="text-[11px] text-gray-400">
Paying to{' '}
<span className="font-mono text-gray-200">⚡ {PRO_LIGHTNING_ADDRESS}</span>
</p>
<p className="text-[11px] text-gray-500">
Tap "I've paid" after your wallet confirms — this records your pass to your Nostr identity.
</p>
</div>
) : (
// ── Purchase flow ───────────────────────────────────────────────
<div className="space-y-4 py-2">
<div className="space-y-2">
{PERKS.map((p) => (
<div key={p.text} className="flex items-start gap-2.5 text-sm text-gray-200">
<p.icon className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
{p.text}
</div>
))}
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-400">
Free Sasquatch stays free — public spot/event/coach search + the full
trick glossary. Pro adds the <span className="text-white font-semibold">personal + action</span> layer.
</div>
<TierPicker selected={selected} onSelect={setSelected} />
{!user && (
<p className="text-center text-xs text-amber-400">Log in first to upgrade.</p>
)}
{purchase.error && (
<p className="text-center text-xs text-red-400">{purchase.error}</p>
)}
<PayButton tier={selected} busy={busy} onPay={() => purchase.purchase(selected)} disabled={!user} />
<p className="text-center text-[11px] text-gray-500">
Pays to <span className="font-mono text-gray-300">⚡ {PRO_LIGHTNING_ADDRESS}</span> over Lightning. Bound to your npub.
</p>
</div>
)}
</DialogContent>
</Dialog>
);
}
function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: ProTier) => void }) {
return (
<div className="grid grid-cols-2 gap-2">
{PRO_TIERS.map((t) => {
const active = t.id === selected.id;
return (
<button
key={t.id}
onClick={() => onSelect(t)}
className={cn(
'relative rounded-xl border p-3 text-left transition-all',
active
? 'border-red-500 bg-red-500/15'
: 'border-white/10 bg-white/[0.02] hover:border-white/25',
)}
>
{t.popular && (
<span className="absolute -top-2 right-2 rounded-full bg-red-500 px-1.5 py-0.5 text-[8px] font-black uppercase text-white">
Popular
</span>
)}
<p className="text-sm font-black text-white">{t.label}</p>
<p className="text-lg font-black text-red-400">{t.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">sats</span></p>
<p className="text-[10px] text-gray-500">{t.usd}{t.badge ? ` · ${t.badge}` : ''}</p>
</button>
);
})}
</div>
);
}
function PayButton({
tier,
busy,
disabled,
extend,
onPay,
}: {
tier: ProTier;
busy: boolean;
disabled?: boolean;
extend?: boolean;
onPay: () => void;
}) {
return (
<Button
className="w-full gap-2 bg-red-500 hover:bg-red-600 font-black uppercase tracking-wide h-12"
onClick={onPay}
disabled={busy || disabled}
>
{busy ? <Loader2 className="h-5 w-5 animate-spin" /> : <Zap className="h-5 w-5" />}
{extend ? 'Extend' : 'Go Pro'} · {tier.sats.toLocaleString()} sats
</Button>
);
}