- routeUi.* (123 keys): RouteDialog (incl. follow-mode banner, transit time picker + mode filters, itinerary legs), StopDepartures, TripItineraryCard. - sasquatchUi.* (81 keys): SasquatchGuide, SasquatchProDialog, SasquatchActionCard, SasquatchAiStatusCard. LLM prompts + chat bodies deliberately left English. - collectionUi.* / bootUi.*: AddToCollectionDialog example placeholders, SplashScreen rotating status lines + aria labels. - New src/i18n/batch3/* fragment files, spread into en/es so parallel passes never edit the big literal.
257 lines
10 KiB
TypeScript
257 lines
10 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 { useExchangeRate } from '@/hooks/useExchangeRate';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { PRO_TIERS, PRO_LIGHTNING_ADDRESS, type ProTier } from '@/lib/sasquatchPro';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import {
|
|
Zap,
|
|
Check,
|
|
Loader2,
|
|
Copy,
|
|
Brain,
|
|
Wand2,
|
|
Rocket,
|
|
BadgeCheck,
|
|
} from 'lucide-react';
|
|
|
|
const PERKS: Array<{ icon: React.ComponentType<{ className?: string }>; textKey: string }> = [
|
|
{ icon: Brain, textKey: 'sasquatchUi.perk1' },
|
|
{ icon: Wand2, textKey: 'sasquatchUi.perk2' },
|
|
{ icon: Rocket, textKey: 'sasquatchUi.perk3' },
|
|
{ icon: BadgeCheck, textKey: 'sasquatchUi.perk4' },
|
|
];
|
|
|
|
export function SasquatchProDialog({
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (v: boolean) => void;
|
|
}) {
|
|
const { user } = useCurrentUser();
|
|
const { t } = useTranslation();
|
|
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: t('sasquatchUi.welcomeTitle'), description: t('sasquatchUi.welcomeDesc') });
|
|
const timer = setTimeout(() => { onOpenChange(false); purchase.reset(); }, 1400);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [purchase.state, toast, onOpenChange, purchase, t]);
|
|
|
|
const copyInvoice = () => {
|
|
if (purchase.invoice) {
|
|
navigator.clipboard.writeText(purchase.invoice);
|
|
toast({ title: t('sasquatchUi.invoiceCopied') });
|
|
}
|
|
};
|
|
|
|
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">
|
|
{t('sasquatchUi.dialogDesc')}
|
|
</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]">{t('sasquatchUi.proSupporter')}</p>
|
|
{expiresAt && (
|
|
<p className="mt-1 text-xs text-gray-300">
|
|
{t('sasquatchUi.activeUntil', { date: new Date(expiresAt * 1000).toLocaleDateString() })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
{PERKS.map((p) => (
|
|
<div key={p.textKey} className="flex items-start gap-2 text-sm text-gray-300">
|
|
<Check className="mt-0.5 h-4 w-4 shrink-0 text-[#ccff00]" />
|
|
{t(p.textKey)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-center text-xs text-gray-500">
|
|
{t('sasquatchUi.extendPrompt')}
|
|
</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">
|
|
{t('sasquatchUi.scanToPayPre')}{' '}
|
|
<span className="font-black text-white">{t('sasquatchUi.scanToPayAmount', { sats: selected.sats.toLocaleString() })}</span>{' '}
|
|
{t('sasquatchUi.scanToPayPost', { label: selected.label })}
|
|
</p>
|
|
{qr && <img src={qr} alt={t('sasquatchUi.invoiceQrAlt')} 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" /> {t('sasquatchUi.copyInvoice')}
|
|
</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" /> {t('sasquatchUi.ivePaid')}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[11px] text-gray-400">
|
|
{t('sasquatchUi.paysTo')}{' '}
|
|
<span className="font-mono text-gray-200">⚡ {PRO_LIGHTNING_ADDRESS}</span>
|
|
</p>
|
|
<p className="text-[11px] text-gray-500">
|
|
{t('sasquatchUi.ivePaidHint')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
// ── Purchase flow ───────────────────────────────────────────────
|
|
<div className="space-y-4 py-2">
|
|
<div className="space-y-2">
|
|
{PERKS.map((p) => (
|
|
<div key={p.textKey} 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" />
|
|
{t(p.textKey)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-gray-400">
|
|
{t('sasquatchUi.freeStaysPre')}<span className="text-white font-semibold">{t('sasquatchUi.freeStaysEmphasis')}</span>{t('sasquatchUi.freeStaysPost')}
|
|
</div>
|
|
|
|
<TierPicker selected={selected} onSelect={setSelected} />
|
|
|
|
{!user && (
|
|
<p className="text-center text-xs text-amber-400">{t('sasquatchUi.loginFirst')}</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">
|
|
{t('sasquatchUi.paysTo')} <span className="font-mono text-gray-300">⚡ {PRO_LIGHTNING_ADDRESS}</span> {t('sasquatchUi.overLightning')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function TierPicker({ selected, onSelect }: { selected: ProTier; onSelect: (t: ProTier) => void }) {
|
|
// Live BTC/USD so the fiat hint never goes stale (it used to be hardcoded).
|
|
const { data: btcUsd } = useExchangeRate();
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{PRO_TIERS.map((tier) => {
|
|
const active = tier.id === selected.id;
|
|
const usd = btcUsd ? `~$${((tier.sats / 1e8) * btcUsd).toFixed(2)}` : '';
|
|
return (
|
|
<button
|
|
key={tier.id}
|
|
onClick={() => onSelect(tier)}
|
|
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',
|
|
)}
|
|
>
|
|
{tier.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">
|
|
{t('sasquatchUi.popular')}
|
|
</span>
|
|
)}
|
|
<p className="text-sm font-black text-white">{tier.label}</p>
|
|
<p className="text-lg font-black text-red-400">{tier.sats.toLocaleString()}<span className="ml-0.5 text-xs text-gray-500">{t('sasquatchUi.sats')}</span></p>
|
|
<p className="text-[10px] text-gray-500">{[usd, tier.badge].filter(Boolean).join(' · ')}</p>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PayButton({
|
|
tier,
|
|
busy,
|
|
disabled,
|
|
extend,
|
|
onPay,
|
|
}: {
|
|
tier: ProTier;
|
|
busy: boolean;
|
|
disabled?: boolean;
|
|
extend?: boolean;
|
|
onPay: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
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 ? t('sasquatchUi.extend') : t('sasquatchUi.goPro')} · {tier.sats.toLocaleString()} {t('sasquatchUi.sats')}
|
|
</Button>
|
|
);
|
|
}
|