Add duration tiers + layered NIP-57 zap verification, extend featured/Pro to tours
- Add 4 duration tiers (7d/30d/90d/365d) with per-day price discrimination to usePlatformFee - PlatformFeePayment: tier selector UI + attempt NIP-57 zap request (a/p tags), graceful fallback to plain LNURL invoice - Add getZapProviderPubkey + getZapInvoiceFromLightningAddress helpers - Add useFeaturedVerification hook (verified/claimed/none) querying kind 9735 zap receipts - ProBadge: optional entityCoord to show green Verified state on detail pages - Wire entityCoord through all 5 Become* Lab pages + CoachDetailPage - Add featuredTour fee type; FeaturedUpgrade generalized for events + tours - CreateTourPage: featured tag on publish + FeaturedUpgrade UI - TourCard/TourDetailPage: render ProBadge; sort featured tours first - Document tiers + zap-receipt verification scheme in NIP.md Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
dcfa1fe1b2
commit
e48a5e6bc5
19 changed files with 510 additions and 84 deletions
55
NIP.md
55
NIP.md
|
|
@ -1459,10 +1459,10 @@ below), `p` (collective member pubkeys — see Collective Membership below),
|
||||||
|
|
||||||
### Pro / Featured Listings (`featured` tag)
|
### Pro / Featured Listings (`featured` tag)
|
||||||
|
|
||||||
Lab profiles and calendar events (kind 31923) support an optional **Pro /
|
Lab profiles, calendar events (kind 31923), and skate tours (kind 30823)
|
||||||
featured** upgrade. Basic listings are always free; paying the optional
|
support an optional **Pro / featured** upgrade. Basic listings are always free;
|
||||||
platform fee adds a `featured` tag whose value is the **unix-seconds expiry**
|
paying the optional platform fee adds a `featured` tag whose value is the
|
||||||
of the featured window:
|
**unix-seconds expiry** of the featured window:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
["featured", "1767225600"]
|
["featured", "1767225600"]
|
||||||
|
|
@ -1473,11 +1473,48 @@ and sort the listing ahead of non-featured ones. Once the timestamp passes, the
|
||||||
listing simply reverts to a normal free listing (no badge, no boost) — it is
|
listing simply reverts to a normal free listing (no badge, no boost) — it is
|
||||||
never unpublished.
|
never unpublished.
|
||||||
|
|
||||||
> **Trust note:** the `featured` tag is *self-attested* — the publishing client
|
#### Duration tiers
|
||||||
> writes it after recording payment locally, rather than proving a Lightning
|
|
||||||
> payment cryptographically. This matches the platform-fee trust model used
|
The featured window is purchased at one of four duration tiers, with a
|
||||||
> elsewhere in SkateSpots. A future revision could verify the tag against a
|
per-day discount that grows with the commitment length (price discrimination).
|
||||||
> NIP-57 zap receipt to the platform Lightning address.
|
The base is a 30-day monthly price per listing type; the charged amount is
|
||||||
|
`base × multiplier`:
|
||||||
|
|
||||||
|
| Tier | Days | Multiplier | Note |
|
||||||
|
|------|------|-----------|------|
|
||||||
|
| 7 days | 7 | 0.4× | short "try it" window |
|
||||||
|
| 1 month | 30 | 1.0× | baseline |
|
||||||
|
| 3 months | 90 | 2.4× | best value (~20%/day off) |
|
||||||
|
| 12 months | 365 | 8.0× | biggest discount (~33%/day off) |
|
||||||
|
|
||||||
|
The chosen tier only determines the computed expiry written into the
|
||||||
|
`featured` tag — the tag schema itself is unchanged.
|
||||||
|
|
||||||
|
#### Verification via NIP-57 zap receipts (layered / best-effort)
|
||||||
|
|
||||||
|
To make Pro tamper-proof where possible, payment is attempted as a **NIP-57
|
||||||
|
zap** rather than a plain LNURL-pay. The client signs a kind `9734` zap request
|
||||||
|
tagging the platform pubkey (`p`) and, when known, the featured entity's
|
||||||
|
addressable coordinate (`a` = `kind:pubkey:d`), and passes it to the platform
|
||||||
|
Lightning address's LNURL callback. If the provider is zap-enabled
|
||||||
|
(`allowsNostr` + `nostrPubkey`), it publishes a kind `9735` zap receipt.
|
||||||
|
|
||||||
|
Clients verify a `featured` listing by querying for a matching kind `9735`
|
||||||
|
receipt (`#a` = entity coordinate, `#p` = platform pubkey). Three states result:
|
||||||
|
|
||||||
|
| State | Meaning | Badge |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| `verified` | A matching zap receipt was found. Cryptographically backed. | Green ✓ Pro |
|
||||||
|
| `claimed` | Unexpired `featured` tag but no receipt found (provider doesn't publish receipts, or relays lacked it). Self-attested. | Gold Pro |
|
||||||
|
| `none` | Not featured / window expired. | (none) |
|
||||||
|
|
||||||
|
> **Trust note:** the fallback is **graceful** — if the platform's Lightning
|
||||||
|
> provider does not publish zap receipts (as with some custodial/ecash
|
||||||
|
> addresses), verification degrades to the older *self-attested* `claimed`
|
||||||
|
> level and Pro never silently breaks. When receipts are available the badge
|
||||||
|
> becomes genuinely verifiable. For events and tours whose `d` identifier is
|
||||||
|
> generated at publish time (non-deterministic), the zap request omits the `a`
|
||||||
|
> coordinate, so those fall back to `claimed`.
|
||||||
|
|
||||||
### Entity Type (individual vs. collective)
|
### Entity Type (individual vs. collective)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,37 @@
|
||||||
import { Sparkles, Check } from 'lucide-react';
|
import { Sparkles, Check } from 'lucide-react';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface FeaturedEventUpgradeProps {
|
interface FeaturedUpgradeProps {
|
||||||
|
/** Which platform fee this upgrade drives. */
|
||||||
|
type: Extract<FeeType, 'featuredEvent' | 'featuredTour'>;
|
||||||
|
/** Noun shown to the user, e.g. "event" or "tour". */
|
||||||
|
noun: string;
|
||||||
|
/** Where featured items appear, e.g. "events page" or "tours". */
|
||||||
|
placement: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onEnabledChange: (enabled: boolean) => void;
|
onEnabledChange: (enabled: boolean) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opt-in "Feature this event" upgrade.
|
* Opt-in "Feature this <noun>" upgrade.
|
||||||
*
|
*
|
||||||
* Every event is free to post. Toggling this on reveals the Lightning fee and,
|
* Every listing posts for free. Toggling this on reveals the Lightning fee and,
|
||||||
* once paid (or for platform owners), the event is published with a `featured`
|
* once paid (or for platform owners), the item is published with a `featured`
|
||||||
* tag that gives it a badge + top placement on the Events page.
|
* tag that gives it a badge + top placement.
|
||||||
*
|
|
||||||
* No hard paywall — even paid/ticketed events post for free.
|
|
||||||
*/
|
*/
|
||||||
export function FeaturedEventUpgrade({
|
export function FeaturedUpgrade({
|
||||||
|
type,
|
||||||
|
noun,
|
||||||
|
placement,
|
||||||
enabled,
|
enabled,
|
||||||
onEnabledChange,
|
onEnabledChange,
|
||||||
className,
|
className,
|
||||||
}: FeaturedEventUpgradeProps) {
|
}: FeaturedUpgradeProps) {
|
||||||
const { isActive, isPlatformOwner, amountSats, daysRemaining } = usePlatformFee('featuredEvent');
|
const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee(type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -41,11 +48,11 @@ export function FeaturedEventUpgrade({
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-black uppercase tracking-wide text-sm text-[#ccff00]">
|
<p className="font-black uppercase tracking-wide text-sm text-[#ccff00]">
|
||||||
Feature this event <span className="text-gray-500 font-normal normal-case">· optional</span>
|
Feature this {noun} <span className="text-gray-500 font-normal normal-case">· optional</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400 mt-0.5">
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
Free to post. Featured events get a <strong className="text-[#ccff00]">badge</strong> and sit
|
Free to post. Featured {noun}s get a <strong className="text-[#ccff00]">badge</strong> and sit
|
||||||
{' '}<strong className="text-[#ccff00]">at the top</strong> of the events page.
|
{' '}<strong className="text-[#ccff00]">at the top</strong> of {placement}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -74,12 +81,28 @@ export function FeaturedEventUpgrade({
|
||||||
{enabled && !isActive && (
|
{enabled && !isActive && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<PlatformFeePayment
|
<PlatformFeePayment
|
||||||
type="featuredEvent"
|
type={type}
|
||||||
onSuccess={() => { /* hook auto-updates isActive */ }}
|
onSuccess={() => { /* hook auto-updates isActive */ }}
|
||||||
context={`One-time ${amountSats.toLocaleString()} sat fee for 30 days of featured placement. Your event stays listed for free either way.`}
|
context={`Your ${noun} stays listed for free either way — featured just adds the badge & boost.`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convenience wrapper for events. */
|
||||||
|
export function FeaturedEventUpgrade(props: {
|
||||||
|
enabled: boolean;
|
||||||
|
onEnabledChange: (enabled: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FeaturedUpgrade
|
||||||
|
type="featuredEvent"
|
||||||
|
noun="event"
|
||||||
|
placement="the events page"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ interface LabProUpgradeProps {
|
||||||
onEnabledChange: (enabled: boolean) => void;
|
onEnabledChange: (enabled: boolean) => void;
|
||||||
/** Accent color class for the entity (e.g. "text-orange-400"). */
|
/** Accent color class for the entity (e.g. "text-orange-400"). */
|
||||||
accentText?: string;
|
accentText?: string;
|
||||||
|
/** Addressable coordinate `kind:pubkey:d` for zap-receipt verification. */
|
||||||
|
entityCoord?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,9 +28,10 @@ export function LabProUpgrade({
|
||||||
enabled,
|
enabled,
|
||||||
onEnabledChange,
|
onEnabledChange,
|
||||||
accentText = 'text-amber-400',
|
accentText = 'text-amber-400',
|
||||||
|
entityCoord,
|
||||||
className,
|
className,
|
||||||
}: LabProUpgradeProps) {
|
}: LabProUpgradeProps) {
|
||||||
const { isActive, isPlatformOwner, amountSats, daysRemaining } = usePlatformFee('labProfile');
|
const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee('labProfile');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -90,8 +93,9 @@ export function LabProUpgrade({
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<PlatformFeePayment
|
<PlatformFeePayment
|
||||||
type="labProfile"
|
type="labProfile"
|
||||||
|
entityCoord={entityCoord}
|
||||||
onSuccess={() => { /* hook auto-updates isActive */ }}
|
onSuccess={() => { /* hook auto-updates isActive */ }}
|
||||||
context={`One-time ${amountSats.toLocaleString()} sat fee for 30 days of Pro placement. Your profile stays live for free after it expires — you just lose the badge & boost.`}
|
context="Your profile stays live for free after Pro expires — you just lose the badge & boost."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,14 @@ import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { useNWC } from '@/hooks/useNWCContext';
|
import { useNWC } from '@/hooks/useNWCContext';
|
||||||
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import {
|
||||||
|
getInvoiceFromLightningAddress,
|
||||||
|
getZapInvoiceFromLightningAddress,
|
||||||
|
getZapProviderPubkey,
|
||||||
|
} from '@/lib/lightningAddress';
|
||||||
import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
|
import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
|
||||||
|
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Zap,
|
Zap,
|
||||||
|
|
@ -23,15 +29,33 @@ interface PlatformFeePaymentProps {
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
/** Optional extra context shown to user */
|
/** Optional extra context shown to user */
|
||||||
context?: string;
|
context?: string;
|
||||||
|
/**
|
||||||
|
* The addressable coordinate (`kind:pubkey:d`) of the entity being featured.
|
||||||
|
* When provided and the provider supports NIP-57, the zap request tags this
|
||||||
|
* `a` coordinate so the payment can later be verified against the entity.
|
||||||
|
*/
|
||||||
|
entityCoord?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Relays the provider should publish the zap receipt to (so we can verify it).
|
||||||
|
const ZAP_RECEIPT_RELAYS = [
|
||||||
|
'wss://goskatespots.nostr1.com',
|
||||||
|
'wss://relay.nostr.band',
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
];
|
||||||
|
|
||||||
type PaymentState = 'idle' | 'generating' | 'waiting' | 'paying' | 'success' | 'error';
|
type PaymentState = 'idle' | 'generating' | 'waiting' | 'paying' | 'success' | 'error';
|
||||||
|
|
||||||
export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaymentProps) {
|
export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: PlatformFeePaymentProps) {
|
||||||
const { amountSats, recipient, recipientName, meta, isActive, daysRemaining, recordPayment } = usePlatformFee(type);
|
const {
|
||||||
|
amountSats, recipient, recipientName, meta, tier, tiers, setTierId,
|
||||||
|
isActive, daysRemaining, recordPayment,
|
||||||
|
tierPriceSats: feeTierPrice, tierPerDaySats: feeTierPerDay,
|
||||||
|
} = usePlatformFee(type);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { webln, activeNWC } = useWallet();
|
const { webln, activeNWC } = useWallet();
|
||||||
const { sendPayment } = useNWC();
|
const { sendPayment } = useNWC();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
const [paymentState, setPaymentState] = useState<PaymentState>('idle');
|
const [paymentState, setPaymentState] = useState<PaymentState>('idle');
|
||||||
const [invoice, setInvoice] = useState<string | null>(null);
|
const [invoice, setInvoice] = useState<string | null>(null);
|
||||||
|
|
@ -64,11 +88,47 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bolt11 = await getInvoiceFromLightningAddress({
|
let bolt11: string | null = null;
|
||||||
|
|
||||||
|
// Prefer a NIP-57 zap so the payment produces a verifiable zap receipt.
|
||||||
|
// Falls back to a plain LNURL invoice if the provider doesn't support zaps
|
||||||
|
// or the user can't sign.
|
||||||
|
try {
|
||||||
|
const providerPubkey = await getZapProviderPubkey(recipient);
|
||||||
|
if (providerPubkey && user?.signer) {
|
||||||
|
const platformPubkey = PLATFORM_OWNER_PUBKEYS[0];
|
||||||
|
const tags: string[][] = [
|
||||||
|
['relays', ...ZAP_RECEIPT_RELAYS],
|
||||||
|
['amount', String(amountSats * 1000)],
|
||||||
|
['p', platformPubkey],
|
||||||
|
];
|
||||||
|
if (entityCoord) {
|
||||||
|
tags.push(['a', entityCoord]);
|
||||||
|
}
|
||||||
|
const zapRequest = await user.signer.signEvent({
|
||||||
|
kind: 9734,
|
||||||
|
content: `SkateSpots ${meta.label} (${tier.label})`,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
bolt11 = await getZapInvoiceFromLightningAddress({
|
||||||
lightningAddress: recipient,
|
lightningAddress: recipient,
|
||||||
amountSats,
|
amountSats,
|
||||||
comment: `SkateSpots ${meta.label}`,
|
zapRequest: JSON.stringify(zapRequest),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Zap path failed — fall back to a normal invoice below.
|
||||||
|
bolt11 = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bolt11) {
|
||||||
|
bolt11 = await getInvoiceFromLightningAddress({
|
||||||
|
lightningAddress: recipient,
|
||||||
|
amountSats,
|
||||||
|
comment: `SkateSpots ${meta.label} (${tier.label})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setInvoice(bolt11);
|
setInvoice(bolt11);
|
||||||
|
|
||||||
|
|
@ -150,6 +210,48 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Duration tier selector — only before payment has started */}
|
||||||
|
{paymentState === 'idle' && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-bold uppercase tracking-wide text-gray-500 mb-2">
|
||||||
|
Choose duration
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
{tiers.map((t) => {
|
||||||
|
const price = feeTierPrice(t);
|
||||||
|
const perDay = feeTierPerDay(t);
|
||||||
|
const selected = t.id === tier.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTierId(t.id)}
|
||||||
|
className={cn(
|
||||||
|
'relative flex flex-col items-center rounded-xl border-2 px-2 py-2.5 transition-all',
|
||||||
|
selected
|
||||||
|
? 'border-[#ff6600] bg-[#ff6600]/15'
|
||||||
|
: 'border-white/10 bg-white/[0.03] hover:border-white/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.bestValue && (
|
||||||
|
<span className="absolute -top-2 left-1/2 -translate-x-1/2 text-[8px] font-black uppercase px-1.5 py-0.5 rounded-full bg-[#ccff00] text-black whitespace-nowrap">
|
||||||
|
Best value
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={cn('text-xs font-black', selected ? 'text-[#ff6600]' : 'text-white')}>
|
||||||
|
{t.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-bold text-gray-300 mt-0.5">
|
||||||
|
{price.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-gray-500">{perDay}/day</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* What you get */}
|
{/* What you get */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
|
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
|
||||||
|
|
@ -158,7 +260,7 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
|
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
|
||||||
<Clock className="h-3.5 w-3.5 text-[#ccff00] flex-shrink-0" />
|
<Clock className="h-3.5 w-3.5 text-[#ccff00] flex-shrink-0" />
|
||||||
<span className="text-xs text-gray-300">Valid for <strong className="text-white">{meta.duration}</strong></span>
|
<span className="text-xs text-gray-300">Valid for <strong className="text-white">{tier.label}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -170,7 +272,7 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym
|
||||||
<div className="text-center py-3 rounded-xl bg-[#ff6600]/10 border border-[#ff6600]/25">
|
<div className="text-center py-3 rounded-xl bg-[#ff6600]/10 border border-[#ff6600]/25">
|
||||||
<p className="text-3xl font-black text-[#ff6600]">⚡ {amountSats.toLocaleString()} sats</p>
|
<p className="text-3xl font-black text-[#ff6600]">⚡ {amountSats.toLocaleString()} sats</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
~${(amountSats * 0.0006).toFixed(2)} USD · to {recipientName} · {meta.duration}
|
~${(amountSats * 0.0006).toFixed(2)} USD · to {recipientName} · {tier.label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Sparkles } from 'lucide-react';
|
import { Sparkles, BadgeCheck } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isFeaturedActive } from '@/lib/types';
|
import { isFeaturedActive } from '@/lib/types';
|
||||||
|
import { useFeaturedVerification } from '@/hooks/useFeaturedVerification';
|
||||||
|
|
||||||
interface ProBadgeProps {
|
interface ProBadgeProps {
|
||||||
/** The `featuredUntil` unix-seconds timestamp from a parsed entity. */
|
/** The `featuredUntil` unix-seconds timestamp from a parsed entity. */
|
||||||
|
|
@ -9,25 +10,49 @@ interface ProBadgeProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Compact = icon only (for dense card corners). */
|
/** Compact = icon only (for dense card corners). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/**
|
||||||
|
* When set (an addressable `kind:pubkey:d` coordinate), the badge verifies the
|
||||||
|
* featured status against a NIP-57 zap receipt and shows a "Verified" state
|
||||||
|
* when found. Use on detail pages; omit in dense lists to avoid over-fetching.
|
||||||
|
*/
|
||||||
|
entityCoord?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Pro" badge shown on featured Lab profiles and events.
|
* "Pro" badge shown on featured Lab profiles, events, and tours.
|
||||||
* Renders nothing if the entity isn't currently featured.
|
* Renders nothing if the entity isn't currently featured.
|
||||||
*/
|
*/
|
||||||
export function ProBadge({ featuredUntil, className, compact = false }: ProBadgeProps) {
|
export function ProBadge({ featuredUntil, className, compact = false, entityCoord }: ProBadgeProps) {
|
||||||
|
const { data: status } = useFeaturedVerification(
|
||||||
|
entityCoord,
|
||||||
|
entityCoord ? featuredUntil : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (!isFeaturedActive(featuredUntil)) return null;
|
if (!isFeaturedActive(featuredUntil)) return null;
|
||||||
|
|
||||||
|
const verified = status === 'verified';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
'gap-1 border-0 bg-gradient-to-r from-amber-400 to-orange-500 text-black font-black uppercase tracking-wide shadow-sm shadow-orange-500/30',
|
'gap-1 border-0 font-black uppercase tracking-wide shadow-sm',
|
||||||
|
verified
|
||||||
|
? 'bg-gradient-to-r from-emerald-400 to-green-500 text-black shadow-green-500/30'
|
||||||
|
: 'bg-gradient-to-r from-amber-400 to-orange-500 text-black shadow-orange-500/30',
|
||||||
compact ? 'px-1.5 py-0 text-[9px]' : 'text-[10px]',
|
compact ? 'px-1.5 py-0 text-[9px]' : 'text-[10px]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
title="Pro listing — this profile supports SkateSpots and is featured."
|
title={
|
||||||
|
verified
|
||||||
|
? 'Verified Pro — payment confirmed on-chain via a Nostr zap receipt.'
|
||||||
|
: 'Pro listing — this profile supports SkateSpots and is featured.'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{verified ? (
|
||||||
|
<BadgeCheck className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} />
|
||||||
|
) : (
|
||||||
<Sparkles className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} />
|
<Sparkles className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} />
|
||||||
|
)}
|
||||||
Pro
|
Pro
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
|
import { ProBadge } from '@/components/ProBadge';
|
||||||
import { ReportDialog } from '@/components/ReportDialog';
|
import { ReportDialog } from '@/components/ReportDialog';
|
||||||
|
|
||||||
import type { SkateTour, TourCategory } from '@/lib/types';
|
import type { SkateTour, TourCategory } from '@/lib/types';
|
||||||
|
|
@ -142,15 +143,18 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
||||||
>
|
>
|
||||||
{tour.title}
|
{tour.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<ProBadge featuredUntil={tour.featuredUntil} compact />
|
||||||
{tourStatus && (
|
{tourStatus && (
|
||||||
<Badge
|
<Badge
|
||||||
className="flex-shrink-0 text-xs border-0 font-bold uppercase"
|
className="text-xs border-0 font-bold uppercase"
|
||||||
style={{ backgroundColor: tourStatus.color, color: tourStatus.color === '#ccff00' ? 'black' : 'white' }}
|
style={{ backgroundColor: tourStatus.color, color: tourStatus.color === '#ccff00' ? 'black' : 'white' }}
|
||||||
>
|
>
|
||||||
{tourStatus.label}
|
{tourStatus.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-400 flex-wrap">
|
<div className="flex items-center gap-2 text-xs text-gray-400 flex-wrap">
|
||||||
<Badge
|
<Badge
|
||||||
|
|
@ -234,6 +238,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
||||||
|
|
||||||
{/* Status/Price overlay */}
|
{/* Status/Price overlay */}
|
||||||
<div className="absolute top-3 right-3 flex flex-col gap-1 items-end">
|
<div className="absolute top-3 right-3 flex flex-col gap-1 items-end">
|
||||||
|
<ProBadge featuredUntil={tour.featuredUntil} />
|
||||||
{tourStatus && (
|
{tourStatus && (
|
||||||
<Badge
|
<Badge
|
||||||
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"
|
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"
|
||||||
|
|
|
||||||
66
src/hooks/useFeaturedVerification.ts
Normal file
66
src/hooks/useFeaturedVerification.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||||
|
import { isFeaturedActive } from '@/lib/types';
|
||||||
|
|
||||||
|
export type FeaturedStatus = 'none' | 'claimed' | 'verified';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a "featured"/Pro listing against NIP-57 zap receipts.
|
||||||
|
*
|
||||||
|
* Layered / best-effort:
|
||||||
|
* - `verified`: a kind-9735 zap receipt paying the platform (p = platform pubkey)
|
||||||
|
* and referencing this entity's `a` coordinate was found on relays. Tamper-proof.
|
||||||
|
* - `claimed`: the event carries an unexpired `featured` tag but no matching zap
|
||||||
|
* receipt could be found (e.g. the Lightning provider doesn't publish receipts).
|
||||||
|
* This is the older self-attested trust level — still shown, but not verified.
|
||||||
|
* - `none`: not featured (or the featured window has expired).
|
||||||
|
*
|
||||||
|
* The graceful fallback means Pro never silently breaks when the provider
|
||||||
|
* (currently minibits) doesn't support zaps.
|
||||||
|
*/
|
||||||
|
export function useFeaturedVerification(
|
||||||
|
entityCoord: string | undefined,
|
||||||
|
featuredUntil: number | undefined,
|
||||||
|
) {
|
||||||
|
const { nostr } = useNostr();
|
||||||
|
|
||||||
|
const claimed = isFeaturedActive(featuredUntil);
|
||||||
|
|
||||||
|
return useQuery<FeaturedStatus>({
|
||||||
|
queryKey: ['featured-verification', entityCoord, featuredUntil],
|
||||||
|
queryFn: async (c) => {
|
||||||
|
// Not featured at all.
|
||||||
|
if (!claimed || !entityCoord) return 'none';
|
||||||
|
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Zap receipts paying the platform that reference this entity.
|
||||||
|
const receipts = await nostr.query(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [9735],
|
||||||
|
'#a': [entityCoord],
|
||||||
|
'#p': PLATFORM_OWNER_PUBKEYS,
|
||||||
|
limit: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Any matching receipt authorizes the featured status.
|
||||||
|
if (receipts.length > 0) return 'verified';
|
||||||
|
} catch {
|
||||||
|
// Relay error — fall through to claimed.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Featured tag present but unverifiable → self-attested.
|
||||||
|
return 'claimed';
|
||||||
|
},
|
||||||
|
enabled: claimed && !!entityCoord,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 min
|
||||||
|
gcTime: 30 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,48 +1,92 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { PLATFORM_OWNER_PUBKEYS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
|
import { PLATFORM_OWNER_PUBKEYS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
|
||||||
|
|
||||||
// ─── Fee amounts (sats) ───────────────────────────────────────────────────────
|
// ─── Base monthly (30-day) fee per type, in sats ─────────────────────────────
|
||||||
|
// Actual charge = base × the selected duration tier's multiplier.
|
||||||
export const PLATFORM_FEES = {
|
export const PLATFORM_FEES = {
|
||||||
/** Featured event listing — paid/ticketed events · 30 days */
|
/** Featured event listing */
|
||||||
featuredEvent: 5000,
|
featuredEvent: 5000,
|
||||||
/** Lab profile with a rate set (coach / filmer / editor) · 30 days */
|
/** Lab Pro profile (coach / filmer / editor / photographer / builder) */
|
||||||
labProfile: 5000,
|
labProfile: 5000,
|
||||||
/** Featured shop placement on map · 30 days */
|
/** Featured shop placement on map */
|
||||||
featuredShop: 10000,
|
featuredShop: 10000,
|
||||||
|
/** Featured tour listing */
|
||||||
|
featuredTour: 5000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FeeType = keyof typeof PLATFORM_FEES;
|
export type FeeType = keyof typeof PLATFORM_FEES;
|
||||||
|
|
||||||
|
// ─── Duration tiers ───────────────────────────────────────────────────────────
|
||||||
|
// Price discrimination: the longer the commitment, the cheaper the effective
|
||||||
|
// per-day rate. `multiplier` is applied to a type's base monthly price.
|
||||||
|
// 7d → 0.4× (short "try it" window, priced at a premium per-day)
|
||||||
|
// 30d → 1.0× (baseline monthly)
|
||||||
|
// 90d → 2.4× (season — ~20% per-day discount) ← best value
|
||||||
|
// 365d→ 8.0× (year — ~33% per-day discount)
|
||||||
|
export type DurationTierId = '7d' | '30d' | '90d' | '365d';
|
||||||
|
|
||||||
|
export interface DurationTier {
|
||||||
|
id: DurationTierId;
|
||||||
|
label: string;
|
||||||
|
days: number;
|
||||||
|
multiplier: number;
|
||||||
|
/** Cosmetic "best value" flag for UI. */
|
||||||
|
bestValue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DURATION_TIERS: DurationTier[] = [
|
||||||
|
{ id: '7d', label: '7 days', days: 7, multiplier: 0.4 },
|
||||||
|
{ id: '30d', label: '1 month', days: 30, multiplier: 1 },
|
||||||
|
{ id: '90d', label: '3 months', days: 90, multiplier: 2.4, bestValue: true },
|
||||||
|
{ id: '365d', label: '12 months', days: 365, multiplier: 8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_TIER_ID: DurationTierId = '30d';
|
||||||
|
|
||||||
|
/** Compute the sats price for a fee type at a given duration tier. */
|
||||||
|
export function tierPriceSats(type: FeeType, tier: DurationTier): number {
|
||||||
|
// Round to the nearest 100 sats for tidy amounts.
|
||||||
|
return Math.round((PLATFORM_FEES[type] * tier.multiplier) / 100) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective per-day sats (for "best value" display). */
|
||||||
|
export function tierPerDaySats(type: FeeType, tier: DurationTier): number {
|
||||||
|
return Math.round(tierPriceSats(type, tier) / tier.days);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Display metadata per fee type ───────────────────────────────────────────
|
// ─── Display metadata per fee type ───────────────────────────────────────────
|
||||||
export const FEE_META: Record<FeeType, {
|
export const FEE_META: Record<FeeType, {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
duration: string;
|
|
||||||
benefit: string;
|
benefit: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
}> = {
|
}> = {
|
||||||
featuredEvent: {
|
featuredEvent: {
|
||||||
label: 'Featured Event Listing',
|
label: 'Featured Event',
|
||||||
description: 'List a paid or ticketed event on SkateSpots',
|
description: 'Feature your event on SkateSpots',
|
||||||
duration: '30 days',
|
benefit: 'Your event appears at the top of the events page',
|
||||||
benefit: 'Your event appears at the top of the events page for 30 days',
|
|
||||||
emoji: '📅',
|
emoji: '📅',
|
||||||
},
|
},
|
||||||
labProfile: {
|
labProfile: {
|
||||||
label: 'Lab Pro Profile',
|
label: 'Lab Pro Profile',
|
||||||
description: 'List your services with a rate in The Lab',
|
description: 'Feature your services in The Lab',
|
||||||
duration: '30 days',
|
benefit: 'Pro badge + featured placement when skaters browse The Lab',
|
||||||
benefit: 'Your profile is discoverable by skaters looking to hire for 30 days',
|
|
||||||
emoji: '🎓',
|
emoji: '🎓',
|
||||||
},
|
},
|
||||||
featuredShop: {
|
featuredShop: {
|
||||||
label: 'Featured Shop',
|
label: 'Featured Shop',
|
||||||
description: 'Feature your skate shop on the map and directory',
|
description: 'Feature your skate shop on the map and directory',
|
||||||
duration: '30 days',
|
benefit: 'Verified badge + featured pin on the map',
|
||||||
benefit: 'Verified badge + featured pin on the map for 30 days',
|
|
||||||
emoji: '🏪',
|
emoji: '🏪',
|
||||||
},
|
},
|
||||||
|
featuredTour: {
|
||||||
|
label: 'Featured Tour',
|
||||||
|
description: 'Feature your tour on SkateSpots',
|
||||||
|
benefit: 'Pro badge + top placement when skaters browse tours',
|
||||||
|
emoji: '🗺️',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── localStorage key per fee type ───────────────────────────────────────────
|
// ─── localStorage key per fee type ───────────────────────────────────────────
|
||||||
|
|
@ -60,12 +104,13 @@ export function usePlatformFee(type: FeeType) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const [record, setRecord] = useLocalStorage<FeeRecord | null>(storageKey(type), null);
|
const [record, setRecord] = useLocalStorage<FeeRecord | null>(storageKey(type), null);
|
||||||
|
|
||||||
|
// Currently selected duration tier (UI state).
|
||||||
|
const [tierId, setTierId] = useState<DurationTierId>(DEFAULT_TIER_ID);
|
||||||
|
const tier = DURATION_TIERS.find((t) => t.id === tierId) ?? DURATION_TIERS[1];
|
||||||
|
|
||||||
// Platform owners never pay
|
// Platform owners never pay
|
||||||
const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
|
const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
|
||||||
|
|
||||||
// 30 days in ms
|
|
||||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// Is the fee currently active (paid and not expired)?
|
// Is the fee currently active (paid and not expired)?
|
||||||
const isActive = isPlatformOwner || (
|
const isActive = isPlatformOwner || (
|
||||||
record !== null &&
|
record !== null &&
|
||||||
|
|
@ -87,23 +132,32 @@ export function usePlatformFee(type: FeeType) {
|
||||||
return undefined;
|
return undefined;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Record a successful payment
|
// Price for the currently selected tier.
|
||||||
|
const amountSats = tierPriceSats(type, tier);
|
||||||
|
|
||||||
|
// Record a successful payment for the selected tier's duration.
|
||||||
const recordPayment = () => {
|
const recordPayment = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
setRecord({
|
setRecord({
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
expiresAt: now + THIRTY_DAYS_MS,
|
expiresAt: now + tier.days * 24 * 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Amount in sats */
|
/** Amount in sats for the selected tier */
|
||||||
amountSats: PLATFORM_FEES[type],
|
amountSats,
|
||||||
/** Lightning address to pay */
|
/** Lightning address to pay */
|
||||||
recipient: PLATFORM_LIGHTNING_ADDRESS,
|
recipient: PLATFORM_LIGHTNING_ADDRESS,
|
||||||
recipientName: 'SkateSpots',
|
recipientName: 'SkateSpots',
|
||||||
/** Display metadata */
|
/** Display metadata */
|
||||||
meta: FEE_META[type],
|
meta: FEE_META[type],
|
||||||
|
/** Available duration tiers */
|
||||||
|
tiers: DURATION_TIERS,
|
||||||
|
/** Currently selected tier */
|
||||||
|
tier,
|
||||||
|
/** Change the selected tier */
|
||||||
|
setTierId,
|
||||||
/** Is fee currently paid and active? */
|
/** Is fee currently paid and active? */
|
||||||
isActive,
|
isActive,
|
||||||
/** Days remaining on active fee */
|
/** Days remaining on active fee */
|
||||||
|
|
@ -114,5 +168,8 @@ export function usePlatformFee(type: FeeType) {
|
||||||
isPlatformOwner,
|
isPlatformOwner,
|
||||||
/** Call this after successful payment */
|
/** Call this after successful payment */
|
||||||
recordPayment,
|
recordPayment,
|
||||||
|
/** Price/per-day helpers for UI */
|
||||||
|
tierPriceSats: (t: DurationTier) => tierPriceSats(type, t),
|
||||||
|
tierPerDaySats: (t: DurationTier) => tierPerDaySats(type, t),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { SKATE_TOUR_KIND, parseSkateTour, type SkateTour } from '@/lib/types';
|
import { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
|
||||||
|
/** Sort featured (Pro) tours first, then newest. */
|
||||||
|
function sortTours(a: SkateTour, b: SkateTour): number {
|
||||||
|
const af = isFeaturedActive(a.featuredUntil) ? 1 : 0;
|
||||||
|
const bf = isFeaturedActive(b.featuredUntil) ? 1 : 0;
|
||||||
|
if (af !== bf) return bf - af;
|
||||||
|
return b.createdAt - a.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
const STALE_TIME = 5 * 60 * 1000; // 5 min
|
const STALE_TIME = 5 * 60 * 1000; // 5 min
|
||||||
const GC_TIME = 30 * 60 * 1000; // 30 min
|
const GC_TIME = 30 * 60 * 1000; // 30 min
|
||||||
const TIMEOUT = 8000;
|
const TIMEOUT = 8000;
|
||||||
|
|
@ -31,7 +39,7 @@ export function useTours(limit = 100) {
|
||||||
if (tour) tours.push(tour);
|
if (tour) tours.push(tour);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tours.sort((a, b) => b.createdAt - a.createdAt);
|
return tours.sort(sortTours);
|
||||||
},
|
},
|
||||||
staleTime: STALE_TIME,
|
staleTime: STALE_TIME,
|
||||||
gcTime: GC_TIME,
|
gcTime: GC_TIME,
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,75 @@ export interface GetInvoiceParams {
|
||||||
comment?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this Lightning address support NIP-57 zaps?
|
||||||
|
* Returns the provider's nostr pubkey (hex) if so, else null.
|
||||||
|
*/
|
||||||
|
export async function getZapProviderPubkey(
|
||||||
|
lightningAddress: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const data = await resolveLightningAddress(lightningAddress);
|
||||||
|
// `allowsNostr` + a valid `nostrPubkey` means the provider publishes zap receipts.
|
||||||
|
const withNostr = data as LNURLPayResponse & {
|
||||||
|
allowsNostr?: boolean;
|
||||||
|
nostrPubkey?: string;
|
||||||
|
};
|
||||||
|
if (withNostr.allowsNostr && /^[0-9a-f]{64}$/i.test(withNostr.nostrPubkey ?? '')) {
|
||||||
|
return withNostr.nostrPubkey!.toLowerCase();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetZapInvoiceParams {
|
||||||
|
lightningAddress: string;
|
||||||
|
amountSats: number;
|
||||||
|
/** Signed kind-9734 zap request event (JSON string via JSON.stringify). */
|
||||||
|
zapRequest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a BOLT11 invoice for a NIP-57 zap by passing a signed zap request to the
|
||||||
|
* LNURL callback. The provider will publish a kind-9735 zap receipt when paid.
|
||||||
|
*/
|
||||||
|
export async function getZapInvoiceFromLightningAddress(
|
||||||
|
params: GetZapInvoiceParams,
|
||||||
|
): Promise<string> {
|
||||||
|
const { lightningAddress, amountSats, zapRequest } = params;
|
||||||
|
|
||||||
|
if (!isValidLightningAddress(lightningAddress)) {
|
||||||
|
throw new Error('Invalid Lightning address format. Expected: username@domain.com');
|
||||||
|
}
|
||||||
|
if (amountSats <= 0) throw new Error('Amount must be greater than 0');
|
||||||
|
|
||||||
|
const lnurlData = await resolveLightningAddress(lightningAddress);
|
||||||
|
const minSats = lnurlData.minSendable / 1000;
|
||||||
|
const maxSats = lnurlData.maxSendable / 1000;
|
||||||
|
if (amountSats < minSats) throw new Error(`Amount ${amountSats} sats is below minimum ${minSats} sats`);
|
||||||
|
if (amountSats > maxSats) throw new Error(`Amount ${amountSats} sats exceeds maximum ${maxSats} sats`);
|
||||||
|
|
||||||
|
const amountMsats = amountSats * 1000;
|
||||||
|
const callbackUrl = new URL(lnurlData.callback);
|
||||||
|
callbackUrl.searchParams.set('amount', amountMsats.toString());
|
||||||
|
callbackUrl.searchParams.set('nostr', zapRequest);
|
||||||
|
|
||||||
|
const invoiceResponse = await fetch(callbackUrl.toString(), {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!invoiceResponse.ok) {
|
||||||
|
throw new Error(`Failed to get zap invoice: ${invoiceResponse.status} ${invoiceResponse.statusText}`);
|
||||||
|
}
|
||||||
|
const invoiceData: LNURLInvoiceResponse = await invoiceResponse.json();
|
||||||
|
if (invoiceData.pr) return invoiceData.pr;
|
||||||
|
if (invoiceData.status && invoiceData.status !== 'OK') {
|
||||||
|
throw new Error(`Zap invoice generation failed: ${invoiceData.reason || 'unknown'}`);
|
||||||
|
}
|
||||||
|
throw new Error('No invoice returned from provider');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get BOLT11 invoice from Lightning address
|
* Get BOLT11 invoice from Lightning address
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1058,6 +1058,7 @@ export interface SkateTour {
|
||||||
};
|
};
|
||||||
// Sponsors (for future use)
|
// Sponsors (for future use)
|
||||||
sponsors?: TourSponsor[];
|
sponsors?: TourSponsor[];
|
||||||
|
featuredUntil?: number; // Featured tour listing expiry (unix seconds), if paid
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -1604,6 +1605,7 @@ export function parseSkateTour(event: NostrEvent): SkateTour | null {
|
||||||
acceptedClipSources,
|
acceptedClipSources,
|
||||||
filmRelease,
|
filmRelease,
|
||||||
sponsors,
|
sponsors,
|
||||||
|
featuredUntil: parseFeaturedUntil(event),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -530,7 +530,7 @@ export default function BecomeBuilderPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Pro upgrade — free to list without it */}
|
{/* Optional Pro upgrade — free to list without it */}
|
||||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-amber-400" />
|
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-amber-400" entityCoord={user ? `${SKATE_BUILDER_KIND}:${user.pubkey}:builder-profile` : undefined} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -448,7 +448,7 @@ export default function BecomeCoachPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Pro upgrade — free to list without it */}
|
{/* Optional Pro upgrade — free to list without it */}
|
||||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" />
|
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_COACH_KIND}:${user.pubkey}:coach-profile` : undefined} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -434,7 +434,7 @@ export default function BecomeEditorPage() {
|
||||||
|
|
||||||
{/* Optional Pro upgrade — free to list without it */}
|
{/* Optional Pro upgrade — free to list without it */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-purple-400" />
|
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-purple-400" entityCoord={user ? `${SKATE_EDITOR_KIND}:${user.pubkey}:editor-profile` : undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex gap-3">
|
<div className="mt-6 flex gap-3">
|
||||||
|
|
|
||||||
|
|
@ -506,7 +506,7 @@ export default function BecomeFilmerPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Pro upgrade — free to list without it */}
|
{/* Optional Pro upgrade — free to list without it */}
|
||||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" />
|
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_FILMER_KIND}:${user.pubkey}:filming-profile` : undefined} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -552,7 +552,7 @@ export default function BecomePhotographerPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Pro upgrade — free to list without it */}
|
{/* Optional Pro upgrade — free to list without it */}
|
||||||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-indigo-400" />
|
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-indigo-400" entityCoord={user ? `${SKATE_PHOTOGRAPHER_KIND}:${user.pubkey}:photography-profile` : undefined} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,10 @@ export default function CoachDetailPage() {
|
||||||
)}
|
)}
|
||||||
<TrustBadge pubkey={profile.pubkey} size="md" />
|
<TrustBadge pubkey={profile.pubkey} size="md" />
|
||||||
<OfficialBadge pubkey={profile.pubkey} size="md" />
|
<OfficialBadge pubkey={profile.pubkey} size="md" />
|
||||||
<ProBadge featuredUntil={profile.featuredUntil} />
|
<ProBadge
|
||||||
|
featuredUntil={profile.featuredUntil}
|
||||||
|
entityCoord={`${profile.event.kind}:${profile.pubkey}:${profile.identifier}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 flex items-center gap-2">
|
<p className="text-gray-400 flex items-center gap-2">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useShops } from '@/hooks/useShops';
|
import { useShops } from '@/hooks/useShops';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade';
|
||||||
import { SKATE_TOUR_KIND, TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, type TourCategory, type DestructionChallenge } from '@/lib/types';
|
import { SKATE_TOUR_KIND, TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, type TourCategory, type DestructionChallenge } from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
Route,
|
Route,
|
||||||
|
|
@ -192,6 +193,9 @@ export default function CreateTourPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { user, metadata, isLoadingProfile } = useCurrentUser();
|
const { user, metadata, isLoadingProfile } = useCurrentUser();
|
||||||
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
||||||
|
// Optional "Feature this tour" upgrade — every tour posts for free.
|
||||||
|
const { isActive: isFeatured, featuredUntilSeconds } = usePlatformFee('featuredTour');
|
||||||
|
const [goFeatured, setGoFeatured] = useState(false);
|
||||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|
@ -695,6 +699,11 @@ export default function CreateTourPage() {
|
||||||
: `Skate Tour: ${title.trim()} in ${locationText}`;
|
: `Skate Tour: ${title.trim()} in ${locationText}`;
|
||||||
tags.push(['alt', altText]);
|
tags.push(['alt', altText]);
|
||||||
|
|
||||||
|
// Featured listing (optional, paid)
|
||||||
|
if (isFeatured && featuredUntilSeconds) {
|
||||||
|
tags.push(['featured', String(featuredUntilSeconds)]);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Publishing tour event:', {
|
console.log('Publishing tour event:', {
|
||||||
kind: SKATE_TOUR_KIND,
|
kind: SKATE_TOUR_KIND,
|
||||||
tags,
|
tags,
|
||||||
|
|
@ -2181,6 +2190,15 @@ export default function CreateTourPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Optional featured upgrade — tour posts for free regardless */}
|
||||||
|
<FeaturedUpgrade
|
||||||
|
type="featuredTour"
|
||||||
|
noun="tour"
|
||||||
|
placement="the tours list"
|
||||||
|
enabled={goFeatured}
|
||||||
|
onEnabledChange={setGoFeatured}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@ import { useTour } from '@/hooks/useTours';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES } from '@/lib/types';
|
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, SKATE_TOUR_KIND } from '@/lib/types';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
import { ProBadge } from '@/components/ProBadge';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Route,
|
Route,
|
||||||
|
|
@ -149,9 +150,15 @@ export default function TourDetailPage() {
|
||||||
<Route className="h-7 w-7 text-white" />
|
<Route className="h-7 w-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<Badge className="bg-cyan-500/20 text-cyan-300 border-cyan-500/30 mb-2">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<Badge className="bg-cyan-500/20 text-cyan-300 border-cyan-500/30">
|
||||||
LOCAL TOUR
|
LOCAL TOUR
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<ProBadge
|
||||||
|
featuredUntil={tour.featuredUntil}
|
||||||
|
entityCoord={`${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<CardTitle className="text-2xl font-black text-white">
|
<CardTitle className="text-2xl font-black text-white">
|
||||||
{tour.title}
|
{tour.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue