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:
shakespeare.diy 2026-07-24 16:18:15 +07:00
parent dcfa1fe1b2
commit e48a5e6bc5
19 changed files with 510 additions and 84 deletions

55
NIP.md
View file

@ -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)

View file

@ -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}
/>
);
}

View file

@ -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>
)} )}

View file

@ -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;
lightningAddress: recipient,
amountSats, // Prefer a NIP-57 zap so the payment produces a verifiable zap receipt.
comment: `SkateSpots ${meta.label}`, // 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,
amountSats,
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>

View file

@ -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.'
}
> >
<Sparkles className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} /> {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'} />
)}
Pro Pro
</Badge> </Badge>
); );

View file

@ -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,14 +143,17 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
> >
{tour.title} {tour.title}
</h3> </h3>
{tourStatus && ( <div className="flex items-center gap-1 flex-shrink-0">
<Badge <ProBadge featuredUntil={tour.featuredUntil} compact />
className="flex-shrink-0 text-xs border-0 font-bold uppercase" {tourStatus && (
style={{ backgroundColor: tourStatus.color, color: tourStatus.color === '#ccff00' ? 'black' : 'white' }} <Badge
> className="text-xs border-0 font-bold uppercase"
{tourStatus.label} style={{ backgroundColor: tourStatus.color, color: tourStatus.color === '#ccff00' ? 'black' : 'white' }}
</Badge> >
)} {tourStatus.label}
</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">
@ -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"

View 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,
});
}

View file

@ -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),
}; };
} }

View file

@ -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,

View file

@ -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
*/ */

View file

@ -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,
}; };

View file

@ -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"

View file

@ -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"

View file

@ -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">

View file

@ -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"

View file

@ -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"

View file

@ -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" />

View file

@ -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"

View file

@ -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">
LOCAL TOUR <Badge className="bg-cyan-500/20 text-cyan-300 border-cyan-500/30">
</Badge> LOCAL TOUR
</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>