Sasquatch Pro Phase 3: npub-bound Lightning paywall + gating
Adds the Sasquatch Pro supporter pass with strong, forge-resistant verification bound to the paying user's npub — no backend required. Verification (useSasquatchPro): - STRONG: NIP-57 zap receipt (kind 9735). Purchase signs a zap request tagged pro-tier/pro-expiry; the receipt embeds it, cryptographically binding payment → identity (unforgeable by the user). - FALLBACK: self-signed kind-32671 receipt so paying users are never locked out if the provider emits no zap receipt. - Platform owners always Pro. Access = max unexpired expiry. Purchase (usePurchasePro): - Zap to skatespots@minibits.cash (falls back to plain invoice), pay via NWC → WebLN → manual QR. Publishes the fallback receipt on success. Pricing (sasquatchPro.ts): 4 tiers — 7d/2k, 1mo/5k, 3mo/12k (popular), 1yr/36k sats. UI (SasquatchProDialog): upgrade sheet with tiers, perk list, QR fallback, "already Pro" state, extend flow. Gating (SasquatchGuide): - Personal tools (get_my_*) now require login + Pro; prompt only exposes them to Pro users; tool switch guards with a pro_required nudge. - ⚡ Pro badge when active, ⚡ Go Pro button otherwise; local upgrade nudge for "my/mine" questions from non-Pro users. Docs: kind 32671 + verification model documented in NIP.md. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
a37224d8a2
commit
79d1d47823
7 changed files with 709 additions and 12 deletions
61
NIP.md
61
NIP.md
|
|
@ -2144,3 +2144,64 @@ private list" approach and keeps the scene network self-sovereign and private.
|
||||||
to), follows (kind 3), and recent DMs (NIP-17 conversations) as live tabs; any
|
to), follows (kind 3), and recent DMs (NIP-17 conversations) as live tabs; any
|
||||||
of those can be starred into the private list.
|
of those can be starred into the private list.
|
||||||
- The encrypted list is never merged into the public kind-3 follow list.
|
- The encrypted list is never merged into the public kind-3 follow list.
|
||||||
|
|
||||||
|
## Sasquatch Pro (kind 32671)
|
||||||
|
|
||||||
|
Sasquatch Pro is an optional Lightning "supporter pass" that unlocks the
|
||||||
|
Sasquatch AI guide's **personal assistant** features (reading the user's own
|
||||||
|
Stash, crews, spots and listings; drafting actions; priority model). It is
|
||||||
|
funded by a NIP-57 zap to the platform Lightning address
|
||||||
|
(`skatespots@minibits.cash`) and is verified **client-side, bound to the
|
||||||
|
paying user's npub** — consistent with SkateSpots' serverless architecture.
|
||||||
|
|
||||||
|
### Verification (two sources, strongest first)
|
||||||
|
|
||||||
|
1. **Zap receipt (NIP-57, kind 9735) — strong.** At purchase the client signs a
|
||||||
|
kind-9734 zap request that carries `pro-tier` and `pro-expiry` tags. When paid,
|
||||||
|
the platform's Lightning provider publishes a kind-9735 receipt embedding that
|
||||||
|
zap request (in its `description` tag). Because the request is signed by the
|
||||||
|
**user's** key, the receipt cryptographically binds payment to identity and
|
||||||
|
cannot be forged by the user. The client reads `#P`-tagged receipts for the
|
||||||
|
user, parses the embedded request, and takes `pro-expiry`.
|
||||||
|
|
||||||
|
2. **Self-signed receipt (kind 32671) — fallback.** If the provider does not emit
|
||||||
|
zap receipts, the client publishes a self-signed addressable receipt so a
|
||||||
|
paying user is never locked out. Weaker (a technical user could self-issue),
|
||||||
|
but it references the payment so a future verifier can validate it retroactively
|
||||||
|
without a schema change.
|
||||||
|
|
||||||
|
Access = the maximum unexpired expiry across either source. Platform owner
|
||||||
|
pubkeys always have Pro.
|
||||||
|
|
||||||
|
### Event Format (kind 32671 — self-signed fallback receipt)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": 32671,
|
||||||
|
"content": "Sasquatch Pro supporter pass",
|
||||||
|
"tags": [
|
||||||
|
["d", "sasquatch-pro"],
|
||||||
|
["tier", "3m"],
|
||||||
|
["expiry", "1730000000"],
|
||||||
|
["amount", "12000"],
|
||||||
|
["p", "<platform pubkey>"],
|
||||||
|
["alt", "Sasquatch Pro supporter pass (3 months)"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tiers
|
||||||
|
|
||||||
|
| id | Duration | Sats |
|
||||||
|
|----|----------|------|
|
||||||
|
| `7d` | 7 days | 2,000 |
|
||||||
|
| `1m` | 1 month | 5,000 |
|
||||||
|
| `3m` | 3 months | 12,000 |
|
||||||
|
| `1y` | 1 year | 36,000 |
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Pro reads only the user's **own** data, client-side. The encrypted Stash is
|
||||||
|
decrypted locally by the signer and never sent to any AI or server.
|
||||||
|
- Free Sasquatch remains fully usable (public spot/event/coach/gear/crew search
|
||||||
|
plus the trick glossary); Pro adds the personal + action layer.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useCVMClient } from '@/hooks/useCVMClient';
|
import { useCVMClient } from '@/hooks/useCVMClient';
|
||||||
import { usePersonalContext } from '@/hooks/usePersonalContext';
|
import { usePersonalContext } from '@/hooks/usePersonalContext';
|
||||||
|
import { useSasquatchPro } from '@/hooks/useSasquatchPro';
|
||||||
|
import { SasquatchProDialog } from '@/components/SasquatchProDialog';
|
||||||
import { callSasquatchAI } from '@/lib/sasquatchBrain';
|
import { callSasquatchAI } from '@/lib/sasquatchBrain';
|
||||||
import { answerFromGlossary } from '@/lib/glossary';
|
import { answerFromGlossary } from '@/lib/glossary';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
@ -337,6 +339,8 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const cvm = useCVMClient();
|
const cvm = useCVMClient();
|
||||||
const personal = usePersonalContext();
|
const personal = usePersonalContext();
|
||||||
|
const { isPro } = useSasquatchPro();
|
||||||
|
const [proDialogOpen, setProDialogOpen] = useState(false);
|
||||||
|
|
||||||
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
|
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
@ -486,6 +490,17 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1c. Personal ("my/mine") question but the user isn't Pro → nudge to upgrade
|
||||||
|
// locally (no API call). Pro users fall through to the AI + personal tools.
|
||||||
|
if (personal.available && !isPro && /\bmy (stash|gear|crew|crews|spots?|listings?|inventory)\b|what am i selling|what do i own/i.test(question)) {
|
||||||
|
appendMessage({
|
||||||
|
role: 'sasquatch',
|
||||||
|
text: "That's a **Sasquatch Pro** thing 🦧⚡ — with Pro I can read your Stash, crews, spots and listings, draft gear listings and spot updates, and plan your trips. It's a Lightning supporter pass that keeps SkateSpots free & ad-free. Tap the **⚡ Go Pro** button up top to unlock it. 🛹",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Build conversation history for AI
|
// 2. Build conversation history for AI
|
||||||
const history = chatMessages.map((m) => ({
|
const history = chatMessages.map((m) => ({
|
||||||
role: m.role === 'user' ? ('user' as const) : ('assistant' as const),
|
role: m.role === 'user' ? ('user' as const) : ('assistant' as const),
|
||||||
|
|
@ -498,8 +513,8 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
let usedCVM = false;
|
let usedCVM = false;
|
||||||
try {
|
try {
|
||||||
// Build messages with system prompt embedded in first user message.
|
// Build messages with system prompt embedded in first user message.
|
||||||
// Personal tools are only offered when the user is logged in.
|
// Personal tools are only offered to logged-in Pro supporters.
|
||||||
const systemPrompt = personal.available
|
const systemPrompt = personal.available && isPro
|
||||||
? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}`
|
? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}`
|
||||||
: SYSTEM_PROMPT;
|
: SYSTEM_PROMPT;
|
||||||
const historyWithContext: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
const historyWithContext: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
||||||
|
|
@ -552,19 +567,29 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
toolResult = { content: [{ type: 'text', text: weatherJson }] };
|
toolResult = { content: [{ type: 'text', text: weatherJson }] };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// ── Personal tools (Sasquatch Pro) — require login ──────────────
|
// ── Personal tools (Sasquatch Pro) — require login + Pro ───────
|
||||||
case 'get_my_stash':
|
case 'get_my_stash':
|
||||||
toolResult = await personal.getMyStash();
|
|
||||||
break;
|
|
||||||
case 'get_my_crews':
|
case 'get_my_crews':
|
||||||
toolResult = await personal.getMyCrews();
|
|
||||||
break;
|
|
||||||
case 'get_my_listings':
|
case 'get_my_listings':
|
||||||
|
case 'get_my_spots': {
|
||||||
|
if (!personal.available) {
|
||||||
|
toolResult = { error: 'not_logged_in', message: 'Log in to use personal features.' };
|
||||||
|
} else if (!isPro) {
|
||||||
|
toolResult = {
|
||||||
|
error: 'pro_required',
|
||||||
|
message: 'Personal assistant features (your Stash, crews, spots, listings) are part of Sasquatch Pro — a Lightning supporter pass. Tell the user this is Pro-only and invite them to tap the ⚡ Go Pro button.',
|
||||||
|
};
|
||||||
|
} else if (toolName === 'get_my_stash') {
|
||||||
|
toolResult = await personal.getMyStash();
|
||||||
|
} else if (toolName === 'get_my_crews') {
|
||||||
|
toolResult = await personal.getMyCrews();
|
||||||
|
} else if (toolName === 'get_my_listings') {
|
||||||
toolResult = await personal.getMyListings();
|
toolResult = await personal.getMyListings();
|
||||||
break;
|
} else {
|
||||||
case 'get_my_spots':
|
|
||||||
toolResult = await personal.getMySpots();
|
toolResult = await personal.getMySpots();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
toolResult = { error: `Unknown tool: ${toolName}` };
|
toolResult = { error: `Unknown tool: ${toolName}` };
|
||||||
}
|
}
|
||||||
|
|
@ -636,7 +661,7 @@ Instructions:
|
||||||
setIsThinking(false);
|
setIsThinking(false);
|
||||||
setStreamingText('');
|
setStreamingText('');
|
||||||
}
|
}
|
||||||
}, [userInput, isThinking, chatMessages, user, cvm, personal, appendMessage, cvmConnected]);
|
}, [userInput, isThinking, chatMessages, user, cvm, personal, isPro, appendMessage, cvmConnected]);
|
||||||
|
|
||||||
// Listen for external events from FAQ/About pages
|
// Listen for external events from FAQ/About pages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -784,6 +809,19 @@ Instructions:
|
||||||
<span className="font-black text-xs text-[#ccff00] uppercase tracking-wider" style={{ fontFamily: 'Impact, Arial Black, sans-serif' }}>
|
<span className="font-black text-xs text-[#ccff00] uppercase tracking-wider" style={{ fontFamily: 'Impact, Arial Black, sans-serif' }}>
|
||||||
🦧 Sasquatch
|
🦧 Sasquatch
|
||||||
</span>
|
</span>
|
||||||
|
{isPro ? (
|
||||||
|
<span className="flex items-center gap-0.5 rounded bg-[#ccff00]/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-[#ccff00]">
|
||||||
|
⚡ Pro
|
||||||
|
</span>
|
||||||
|
) : user ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setProDialogOpen(true)}
|
||||||
|
className="flex items-center gap-0.5 rounded bg-red-500/20 px-1 py-0.5 text-[8px] font-black uppercase tracking-wider text-red-400 hover:bg-red-500/30 transition-colors"
|
||||||
|
title="Upgrade to Sasquatch Pro"
|
||||||
|
>
|
||||||
|
⚡ Go Pro
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<CVMStatus connected={cvmConnected} />
|
<CVMStatus connected={cvmConnected} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-[#ccff00]/50 leading-none mt-0.5">
|
<div className="text-[10px] text-[#ccff00]/50 leading-none mt-0.5">
|
||||||
|
|
@ -971,6 +1009,9 @@ Instructions:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sasquatch Pro upgrade dialog */}
|
||||||
|
<SasquatchProDialog open={proDialogOpen} onOpenChange={setProDialogOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
246
src/components/SasquatchProDialog.tsx
Normal file
246
src/components/SasquatchProDialog.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
/**
|
||||||
|
* 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, type ProTier } from '@/lib/sasquatchPro';
|
||||||
|
import {
|
||||||
|
Sparkles,
|
||||||
|
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 — knows your Stash, crews, spots & listings' },
|
||||||
|
{ icon: Wand2, text: 'Drafts actions for you: list gear, post a spot status, plan a trip' },
|
||||||
|
{ icon: Rocket, text: 'Priority AI model, higher limits & longer memory' },
|
||||||
|
{ icon: Sparkles, text: 'Proactive nudges (weather + spot tips), opt-in' },
|
||||||
|
{ icon: BadgeCheck, text: '⚡ Supporter badge + Pro-only Sasquatch skins' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 supporter pass is active. 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-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 skatespots@minibits.cash 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
src/hooks/usePurchasePro.ts
Normal file
182
src/hooks/usePurchasePro.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
/**
|
||||||
|
* 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 { 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 [result, setResult] = useState<PurchaseResult>({ invoice: null, state: 'idle', error: null });
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
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);
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => setResult({ invoice: null, state: 'idle', error: null }), []);
|
||||||
|
|
||||||
|
return { ...result, purchase, confirmManual, reset };
|
||||||
|
}
|
||||||
123
src/hooks/useSasquatchPro.ts
Normal file
123
src/hooks/useSasquatchPro.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
/**
|
||||||
|
* useSasquatchPro — is the logged-in user a current Pro supporter?
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Verifies Pro access two ways, strongest first:
|
||||||
|
*
|
||||||
|
* 1. ZAP RECEIPT (NIP-57, kind 9735): the platform's Lightning provider
|
||||||
|
* publishes a receipt when the user zaps the platform. The receipt's zap
|
||||||
|
* request (kind 9734) is signed by the USER's npub and tags the SkateSpots
|
||||||
|
* Pro address, so it cryptographically binds payment → identity. A user
|
||||||
|
* cannot forge this. We read the tier/expiry from the zap request's
|
||||||
|
* `pro-tier` / `pro-expiry` tags (added at purchase time).
|
||||||
|
*
|
||||||
|
* 2. SELF-SIGNED RECEIPT (kind 32671): fallback for providers that don't emit
|
||||||
|
* zap receipts. Signed by the user, references the payment. Weaker (a
|
||||||
|
* technical user could self-issue), but never locks a paying user out.
|
||||||
|
*
|
||||||
|
* Access = the max unexpired expiry found across either source.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||||
|
import { SASQUATCH_PRO_KIND } from '@/lib/types';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
export interface ProStatus {
|
||||||
|
/** True if the user currently has active Pro access. */
|
||||||
|
isPro: boolean;
|
||||||
|
/** Unix seconds when access expires (max across sources), or null. */
|
||||||
|
expiresAt: number | null;
|
||||||
|
/** How access was granted. */
|
||||||
|
source: 'owner' | 'zap' | 'self' | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagVal(tags: string[][], name: string): string | undefined {
|
||||||
|
return tags.find((t) => t[0] === name)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract an expiry (unix seconds) from a Pro-bearing event, if valid & ours. */
|
||||||
|
function expiryFromSelfReceipt(event: NostrEvent, userPubkey: string): number | null {
|
||||||
|
if (event.pubkey !== userPubkey) return null;
|
||||||
|
const exp = Number(tagVal(event.tags, 'expiry'));
|
||||||
|
return Number.isFinite(exp) && exp > 0 ? exp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract expiry from a zap receipt whose zap-request is signed by the user. */
|
||||||
|
function expiryFromZapReceipt(receipt: NostrEvent, userPubkey: string): number | null {
|
||||||
|
// The 9735 receipt embeds the original 9734 zap request as a `description` tag.
|
||||||
|
const desc = tagVal(receipt.tags, 'description');
|
||||||
|
if (!desc) return null;
|
||||||
|
try {
|
||||||
|
const zapReq = JSON.parse(desc) as NostrEvent;
|
||||||
|
if (zapReq.pubkey !== userPubkey) return null; // must be signed by the user
|
||||||
|
const exp = Number(tagVal(zapReq.tags, 'pro-expiry'));
|
||||||
|
return Number.isFinite(exp) && exp > 0 ? exp : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSasquatchPro(): ProStatus {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const pubkey = user?.pubkey;
|
||||||
|
|
||||||
|
// Platform owners always have Pro.
|
||||||
|
const isOwner = !!pubkey && PLATFORM_OWNER_PUBKEYS.includes(pubkey);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['sasquatch-pro', pubkey],
|
||||||
|
enabled: !!pubkey && !isOwner,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: async (c): Promise<{ expiresAt: number | null; source: ProStatus['source'] }> => {
|
||||||
|
if (!pubkey) return { expiresAt: null, source: null };
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
|
||||||
|
// Fetch both sources in one round-trip:
|
||||||
|
// • self-signed receipts authored by the user (kind 32671)
|
||||||
|
// • zap receipts (9735) tagging the user as the zapper (#P)
|
||||||
|
const events = await nostr.query(
|
||||||
|
[
|
||||||
|
{ kinds: [SASQUATCH_PRO_KIND], authors: [pubkey], limit: 10 },
|
||||||
|
{ kinds: [9735], '#P': [pubkey], limit: 50 },
|
||||||
|
],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
let best = 0;
|
||||||
|
let source: ProStatus['source'] = null;
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.kind === SASQUATCH_PRO_KIND) {
|
||||||
|
const exp = expiryFromSelfReceipt(ev, pubkey);
|
||||||
|
if (exp && exp > best) { best = exp; source = 'self'; }
|
||||||
|
} else if (ev.kind === 9735) {
|
||||||
|
const exp = expiryFromZapReceipt(ev, pubkey);
|
||||||
|
// Zap receipts are the stronger source — prefer them on ties.
|
||||||
|
if (exp && exp >= best) { best = exp; source = 'zap'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { expiresAt: best || null, source: best ? source : null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return useMemo<ProStatus>(() => {
|
||||||
|
if (isOwner) {
|
||||||
|
return { isPro: true, expiresAt: null, source: 'owner', isLoading: false };
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const expiresAt = data?.expiresAt ?? null;
|
||||||
|
const isPro = !!expiresAt && expiresAt > now;
|
||||||
|
return {
|
||||||
|
isPro,
|
||||||
|
expiresAt,
|
||||||
|
source: isPro ? data?.source ?? null : null,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}, [isOwner, data, isLoading]);
|
||||||
|
}
|
||||||
43
src/lib/sasquatchPro.ts
Normal file
43
src/lib/sasquatchPro.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* Sasquatch Pro — pricing tiers + shared constants.
|
||||||
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
* Pro is a Lightning "supporter pass" that unlocks Sasquatch's personal
|
||||||
|
* assistant, action-drafting, priority model, and perks. Payment goes to the
|
||||||
|
* platform Lightning address via a NIP-57 zap, and access is verified two ways:
|
||||||
|
*
|
||||||
|
* 1. STRONG (preferred): a kind-9735 zap receipt from the platform's Lightning
|
||||||
|
* provider, bound to the paying user's npub — unforgeable by the user.
|
||||||
|
* 2. FALLBACK: a self-signed kind-32671 membership receipt (used only if the
|
||||||
|
* provider doesn't publish zap receipts), which references the payment so a
|
||||||
|
* future verifier can validate it retroactively.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Platform Lightning address that receives Pro payments. */
|
||||||
|
export const PRO_LIGHTNING_ADDRESS = 'skatespots@minibits.cash';
|
||||||
|
|
||||||
|
export interface ProTier {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
days: number;
|
||||||
|
sats: number;
|
||||||
|
/** Approx USD at ~$60k/BTC, for display only. */
|
||||||
|
usd: string;
|
||||||
|
/** Marketing flag. */
|
||||||
|
badge?: string;
|
||||||
|
popular?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The four supporter tiers. Kept deliberately small to avoid choice paralysis. */
|
||||||
|
export const PRO_TIERS: ProTier[] = [
|
||||||
|
{ id: '7d', label: '7 days', days: 7, sats: 2000, usd: '~$1.20', badge: 'Try it' },
|
||||||
|
{ id: '1m', label: '1 month', days: 30, sats: 5000, usd: '~$3' },
|
||||||
|
{ id: '3m', label: '3 months', days: 90, sats: 12000, usd: '~$7', badge: '20% off', popular: true },
|
||||||
|
{ id: '1y', label: '1 year', days: 365, sats: 36000, usd: '~$21', badge: 'Best value · 40% off' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function tierById(id: string): ProTier | undefined {
|
||||||
|
return PRO_TIERS.find((t) => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds in a day. */
|
||||||
|
export const DAY_SECONDS = 24 * 60 * 60;
|
||||||
|
|
@ -55,6 +55,7 @@ export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings
|
||||||
export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections
|
export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections
|
||||||
export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
|
export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
|
||||||
export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("Spot Chat"). SkateSpots-native.
|
export const SPOT_CHAT_KIND = 24899; // Ephemeral geohash-anchored local chat ("Spot Chat"). SkateSpots-native.
|
||||||
|
export const SASQUATCH_PRO_KIND = 32671; // Addressable "Sasquatch Pro" membership receipt (self-signed, references the zap payment). SkateSpots-native.
|
||||||
export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native.
|
export const SPOT_STATUS_KIND = 2259; // Regular event - community "spot status report" (active/bust/skate-stopped/gone/closed). SkateSpots-native.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue