1. Pro purchase confirmation: on a paid pass, send the buyer an encrypted
NIP-17 receipt to their own inbox (tier, expiry, unlocked perks). Best-effort
so it never blocks the purchase. Success toast now points to the DM.
2. Frictionless Pro answers: add local static-FAQ entries for "sasquatch pro",
"pro perks/version", pricing, "go pro", "upgrade" — so those questions are
answered instantly and never dead-end when the free AI model is rate-limited
(the exact failure seen when asking "what perks has the pro version?").
3. FAQ matching now prefers the longest matching key so specific phrases win
over broad ones ("sasquatch pro" > "pro").
4. Refresh the free-model fallback list (add deepseek-v3, gemini-2.0-flash-exp,
qwen-2.5-72b) for better free-tier AI availability.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
222 lines
8.6 KiB
TypeScript
222 lines
8.6 KiB
TypeScript
/**
|
|
* usePurchasePro — buy a Sasquatch Pro supporter pass via Lightning.
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Flow:
|
|
* 1. Build & sign a NIP-57 zap request to the platform, tagged with the tier
|
|
* + computed expiry (`pro-tier`, `pro-expiry`). Because it's signed by the
|
|
* user's npub, the resulting zap receipt binds payment → identity.
|
|
* 2. Resolve an invoice from the platform Lightning address (as a zap if the
|
|
* provider supports NIP-57, else a plain invoice).
|
|
* 3. Pay via NWC → WebLN → else return the invoice for manual QR payment.
|
|
* 4. On confirmed payment, publish a self-signed kind-32671 receipt as a
|
|
* reliable local fallback (in case the provider emits no zap receipt).
|
|
*
|
|
* Verification is handled separately by useSasquatchPro.
|
|
*/
|
|
|
|
import { useCallback, useState } from 'react';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { nip57 } from 'nostr-tools';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNWC } from '@/hooks/useNWCContext';
|
|
import { useWallet } from '@/hooks/useWallet';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
|
|
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
|
import {
|
|
getZapProviderPubkey,
|
|
getZapInvoiceFromLightningAddress,
|
|
getInvoiceFromLightningAddress,
|
|
} from '@/lib/lightningAddress';
|
|
import { SASQUATCH_PRO_KIND } from '@/lib/types';
|
|
import { PRO_LIGHTNING_ADDRESS, DAY_SECONDS, type ProTier } from '@/lib/sasquatchPro';
|
|
|
|
export type PurchaseState = 'idle' | 'preparing' | 'awaiting_payment' | 'paid' | 'error';
|
|
|
|
interface PurchaseResult {
|
|
/** Set when we couldn't auto-pay — show this invoice as a QR for manual payment. */
|
|
invoice: string | null;
|
|
state: PurchaseState;
|
|
error: string | null;
|
|
}
|
|
|
|
const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0];
|
|
|
|
export function usePurchasePro() {
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
const { getActiveConnection, sendPayment } = useNWC();
|
|
const { webln } = useWallet();
|
|
const { config } = useAppContext();
|
|
const { mutateAsync: sendDM } = useSendDirectMessage();
|
|
|
|
const [result, setResult] = useState<PurchaseResult>({ invoice: null, state: 'idle', error: null });
|
|
|
|
/**
|
|
* Send the buyer an encrypted (NIP-17) confirmation to their own inbox — a
|
|
* durable, private receipt of what they bought. Best-effort: a failure here
|
|
* never blocks the purchase (they already paid + got the on-relay receipt).
|
|
*/
|
|
const sendConfirmationDM = useCallback(
|
|
async (tier: ProTier, expiry: number) => {
|
|
if (!user?.pubkey || !user.signer?.nip44) return;
|
|
const until = new Date(expiry * 1000).toLocaleDateString(undefined, {
|
|
year: 'numeric', month: 'long', day: 'numeric',
|
|
});
|
|
const content = [
|
|
`🦧⚡ Sasquatch Pro — you're in!`,
|
|
``,
|
|
`Pass: ${tier.label} (${tier.sats.toLocaleString()} sats)`,
|
|
`Active until: ${until}`,
|
|
``,
|
|
`What you unlocked:`,
|
|
`• Personal assistant — I read your Stash, crews, spots & listings`,
|
|
`• Action drafts — list gear, post a spot status, plan trips (you confirm)`,
|
|
`• Priority AI model, higher limits & longer memory`,
|
|
`• Opt-in weather + spot nudges`,
|
|
`• ⚡ Supporter badge + Pro-only skins`,
|
|
``,
|
|
`Thanks for keeping SkateSpots free & ad-free. Now go shred. 🛹`,
|
|
].join('\n');
|
|
try {
|
|
await sendDM({ recipients: user.pubkey, content, subject: 'Sasquatch Pro receipt' });
|
|
} catch {
|
|
/* best-effort — don't fail the purchase over a receipt DM */
|
|
}
|
|
},
|
|
[user, sendDM],
|
|
);
|
|
|
|
/** Publish the self-signed fallback receipt for a paid tier. */
|
|
const publishReceipt = useCallback(
|
|
async (tier: ProTier, expiry: number) => {
|
|
if (!user?.signer) return;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const event = await user.signer.signEvent({
|
|
kind: SASQUATCH_PRO_KIND,
|
|
content: `Sasquatch Pro — ${tier.label} supporter pass 🦧⚡`,
|
|
tags: [
|
|
['d', 'sasquatch-pro'],
|
|
['tier', tier.id],
|
|
['expiry', String(expiry)],
|
|
['amount', String(tier.sats)],
|
|
['p', PLATFORM_PUBKEY],
|
|
['alt', `Sasquatch Pro supporter pass (${tier.label}), valid until ${new Date(expiry * 1000).toISOString()}`],
|
|
],
|
|
created_at: now,
|
|
});
|
|
await nostr.event(event, { signal: AbortSignal.timeout(10000) });
|
|
},
|
|
[user, nostr],
|
|
);
|
|
|
|
const purchase = useCallback(
|
|
async (tier: ProTier) => {
|
|
if (!user?.signer) {
|
|
setResult({ invoice: null, state: 'error', error: 'Log in to upgrade to Pro.' });
|
|
return;
|
|
}
|
|
|
|
setResult({ invoice: null, state: 'preparing', error: null });
|
|
|
|
try {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const expiry = now + tier.days * DAY_SECONDS;
|
|
const amountMsats = tier.sats * 1000;
|
|
const relays = config.relayMetadata.relays.map((r) => r.url);
|
|
const comment = `Sasquatch Pro — ${tier.label} 🦧`;
|
|
|
|
// Try a NIP-57 zap first (unforgeable, npub-bound). Fall back to a plain
|
|
// invoice if the provider doesn't publish zap receipts.
|
|
const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS);
|
|
let invoice: string;
|
|
|
|
if (providerPubkey) {
|
|
const zapRequest = nip57.makeZapRequest({
|
|
profile: PLATFORM_PUBKEY,
|
|
event: null,
|
|
amount: amountMsats,
|
|
relays,
|
|
comment,
|
|
});
|
|
// Tag the purchase so verification can read tier + expiry from the receipt.
|
|
zapRequest.tags.push(['pro-tier', tier.id]);
|
|
zapRequest.tags.push(['pro-expiry', String(expiry)]);
|
|
const signed = await user.signer.signEvent(zapRequest);
|
|
invoice = await getZapInvoiceFromLightningAddress({
|
|
lightningAddress: PRO_LIGHTNING_ADDRESS,
|
|
amountSats: tier.sats,
|
|
zapRequest: JSON.stringify(signed),
|
|
});
|
|
} else {
|
|
invoice = await getInvoiceFromLightningAddress({
|
|
lightningAddress: PRO_LIGHTNING_ADDRESS,
|
|
amountSats: tier.sats,
|
|
comment,
|
|
});
|
|
}
|
|
|
|
// Attempt automatic payment: NWC → WebLN.
|
|
const nwc = getActiveConnection();
|
|
if (nwc?.connectionString && nwc.isConnected) {
|
|
try {
|
|
await sendPayment(nwc, invoice);
|
|
await publishReceipt(tier, expiry);
|
|
await sendConfirmationDM(tier, expiry);
|
|
setResult({ invoice: null, state: 'paid', error: null });
|
|
return;
|
|
} catch {
|
|
/* fall through to WebLN / manual */
|
|
}
|
|
}
|
|
|
|
if (webln) {
|
|
try {
|
|
if (typeof webln.enable === 'function') await webln.enable();
|
|
await webln.sendPayment(invoice);
|
|
await publishReceipt(tier, expiry);
|
|
await sendConfirmationDM(tier, expiry);
|
|
setResult({ invoice: null, state: 'paid', error: null });
|
|
return;
|
|
} catch {
|
|
/* fall through to manual */
|
|
}
|
|
}
|
|
|
|
// No auto-pay available — surface the invoice for manual QR payment.
|
|
// The user taps "I've paid" to publish the receipt (see confirmManual).
|
|
setResult({ invoice, state: 'awaiting_payment', error: null });
|
|
} catch (err) {
|
|
setResult({
|
|
invoice: null,
|
|
state: 'error',
|
|
error: err instanceof Error ? err.message : 'Could not start the purchase.',
|
|
});
|
|
}
|
|
},
|
|
[user, config, getActiveConnection, sendPayment, webln, publishReceipt, sendConfirmationDM],
|
|
);
|
|
|
|
/** Called after a manual (QR) payment to record the pass locally. */
|
|
const confirmManual = useCallback(
|
|
async (tier: ProTier) => {
|
|
const expiry = Math.floor(Date.now() / 1000) + tier.days * DAY_SECONDS;
|
|
try {
|
|
await publishReceipt(tier, expiry);
|
|
await sendConfirmationDM(tier, expiry);
|
|
setResult({ invoice: null, state: 'paid', error: null });
|
|
} catch (err) {
|
|
setResult({
|
|
invoice: null,
|
|
state: 'error',
|
|
error: err instanceof Error ? err.message : 'Could not record your pass.',
|
|
});
|
|
}
|
|
},
|
|
[publishReceipt, sendConfirmationDM],
|
|
);
|
|
|
|
const reset = useCallback(() => setResult({ invoice: null, state: 'idle', error: null }), []);
|
|
|
|
return { ...result, purchase, confirmManual, reset };
|
|
}
|