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.
248 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|