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)
|
||||
|
||||
Lab profiles and calendar events (kind 31923) support an optional **Pro /
|
||||
featured** upgrade. Basic listings are always free; paying the optional
|
||||
platform fee adds a `featured` tag whose value is the **unix-seconds expiry**
|
||||
of the featured window:
|
||||
Lab profiles, calendar events (kind 31923), and skate tours (kind 30823)
|
||||
support an optional **Pro / featured** upgrade. Basic listings are always free;
|
||||
paying the optional platform fee adds a `featured` tag whose value is the
|
||||
**unix-seconds expiry** of the featured window:
|
||||
|
||||
```json
|
||||
["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
|
||||
never unpublished.
|
||||
|
||||
> **Trust note:** the `featured` tag is *self-attested* — the publishing client
|
||||
> writes it after recording payment locally, rather than proving a Lightning
|
||||
> payment cryptographically. This matches the platform-fee trust model used
|
||||
> elsewhere in SkateSpots. A future revision could verify the tag against a
|
||||
> NIP-57 zap receipt to the platform Lightning address.
|
||||
#### Duration tiers
|
||||
|
||||
The featured window is purchased at one of four duration tiers, with a
|
||||
per-day discount that grows with the commitment length (price discrimination).
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,37 @@
|
|||
import { Sparkles, Check } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||
import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
|
||||
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;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
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,
|
||||
* once paid (or for platform owners), the event is published with a `featured`
|
||||
* tag that gives it a badge + top placement on the Events page.
|
||||
*
|
||||
* No hard paywall — even paid/ticketed events post for free.
|
||||
* Every listing posts for free. Toggling this on reveals the Lightning fee and,
|
||||
* once paid (or for platform owners), the item is published with a `featured`
|
||||
* tag that gives it a badge + top placement.
|
||||
*/
|
||||
export function FeaturedEventUpgrade({
|
||||
export function FeaturedUpgrade({
|
||||
type,
|
||||
noun,
|
||||
placement,
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
className,
|
||||
}: FeaturedEventUpgradeProps) {
|
||||
const { isActive, isPlatformOwner, amountSats, daysRemaining } = usePlatformFee('featuredEvent');
|
||||
}: FeaturedUpgradeProps) {
|
||||
const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee(type);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -41,11 +48,11 @@ export function FeaturedEventUpgrade({
|
|||
</div>
|
||||
<div className="min-w-0">
|
||||
<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 className="text-xs text-gray-400 mt-0.5">
|
||||
Free to post. Featured events get a <strong className="text-[#ccff00]">badge</strong> and sit
|
||||
{' '}<strong className="text-[#ccff00]">at the top</strong> of the events page.
|
||||
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 {placement}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -74,12 +81,28 @@ export function FeaturedEventUpgrade({
|
|||
{enabled && !isActive && (
|
||||
<div className="px-4 pb-4">
|
||||
<PlatformFeePayment
|
||||
type="featuredEvent"
|
||||
type={type}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** Accent color class for the entity (e.g. "text-orange-400"). */
|
||||
accentText?: string;
|
||||
/** Addressable coordinate `kind:pubkey:d` for zap-receipt verification. */
|
||||
entityCoord?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -26,9 +28,10 @@ export function LabProUpgrade({
|
|||
enabled,
|
||||
onEnabledChange,
|
||||
accentText = 'text-amber-400',
|
||||
entityCoord,
|
||||
className,
|
||||
}: LabProUpgradeProps) {
|
||||
const { isActive, isPlatformOwner, amountSats, daysRemaining } = usePlatformFee('labProfile');
|
||||
const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee('labProfile');
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -90,8 +93,9 @@ export function LabProUpgrade({
|
|||
<div className="px-4 pb-4">
|
||||
<PlatformFeePayment
|
||||
type="labProfile"
|
||||
entityCoord={entityCoord}
|
||||
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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@ import { Button } from '@/components/ui/button';
|
|||
import { useToast } from '@/hooks/useToast';
|
||||
import { useWallet } from '@/hooks/useWallet';
|
||||
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 { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Zap,
|
||||
|
|
@ -23,15 +29,33 @@ interface PlatformFeePaymentProps {
|
|||
onSuccess: () => void;
|
||||
/** Optional extra context shown to user */
|
||||
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';
|
||||
|
||||
export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaymentProps) {
|
||||
const { amountSats, recipient, recipientName, meta, isActive, daysRemaining, recordPayment } = usePlatformFee(type);
|
||||
export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: PlatformFeePaymentProps) {
|
||||
const {
|
||||
amountSats, recipient, recipientName, meta, tier, tiers, setTierId,
|
||||
isActive, daysRemaining, recordPayment,
|
||||
tierPriceSats: feeTierPrice, tierPerDaySats: feeTierPerDay,
|
||||
} = usePlatformFee(type);
|
||||
const { toast } = useToast();
|
||||
const { webln, activeNWC } = useWallet();
|
||||
const { sendPayment } = useNWC();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const [paymentState, setPaymentState] = useState<PaymentState>('idle');
|
||||
const [invoice, setInvoice] = useState<string | null>(null);
|
||||
|
|
@ -64,11 +88,47 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const bolt11 = await getInvoiceFromLightningAddress({
|
||||
lightningAddress: recipient,
|
||||
amountSats,
|
||||
comment: `SkateSpots ${meta.label}`,
|
||||
});
|
||||
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,
|
||||
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);
|
||||
|
||||
|
|
@ -150,6 +210,48 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym
|
|||
</div>
|
||||
|
||||
<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 */}
|
||||
<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">
|
||||
|
|
@ -158,7 +260,7 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym
|
|||
</div>
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
|
|
@ -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">
|
||||
<p className="text-3xl font-black text-[#ff6600]">⚡ {amountSats.toLocaleString()} sats</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Sparkles } from 'lucide-react';
|
||||
import { Sparkles, BadgeCheck } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isFeaturedActive } from '@/lib/types';
|
||||
import { useFeaturedVerification } from '@/hooks/useFeaturedVerification';
|
||||
|
||||
interface ProBadgeProps {
|
||||
/** The `featuredUntil` unix-seconds timestamp from a parsed entity. */
|
||||
|
|
@ -9,25 +10,49 @@ interface ProBadgeProps {
|
|||
className?: string;
|
||||
/** Compact = icon only (for dense card corners). */
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
|
||||
const verified = status === 'verified';
|
||||
|
||||
return (
|
||||
<Badge
|
||||
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]',
|
||||
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
|
||||
</Badge>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|||
import { genUserName } from '@/lib/genUserName';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { ProBadge } from '@/components/ProBadge';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
|
||||
import type { SkateTour, TourCategory } from '@/lib/types';
|
||||
|
|
@ -142,14 +143,17 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
|
|||
>
|
||||
{tour.title}
|
||||
</h3>
|
||||
{tourStatus && (
|
||||
<Badge
|
||||
className="flex-shrink-0 text-xs border-0 font-bold uppercase"
|
||||
style={{ backgroundColor: tourStatus.color, color: tourStatus.color === '#ccff00' ? 'black' : 'white' }}
|
||||
>
|
||||
{tourStatus.label}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<ProBadge featuredUntil={tour.featuredUntil} compact />
|
||||
{tourStatus && (
|
||||
<Badge
|
||||
className="text-xs border-0 font-bold uppercase"
|
||||
style={{ backgroundColor: tourStatus.color, color: tourStatus.color === '#ccff00' ? 'black' : 'white' }}
|
||||
>
|
||||
{tourStatus.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 */}
|
||||
<div className="absolute top-3 right-3 flex flex-col gap-1 items-end">
|
||||
<ProBadge featuredUntil={tour.featuredUntil} />
|
||||
{tourStatus && (
|
||||
<Badge
|
||||
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 { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
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 = {
|
||||
/** Featured event listing — paid/ticketed events · 30 days */
|
||||
/** Featured event listing */
|
||||
featuredEvent: 5000,
|
||||
/** Lab profile with a rate set (coach / filmer / editor) · 30 days */
|
||||
/** Lab Pro profile (coach / filmer / editor / photographer / builder) */
|
||||
labProfile: 5000,
|
||||
/** Featured shop placement on map · 30 days */
|
||||
/** Featured shop placement on map */
|
||||
featuredShop: 10000,
|
||||
/** Featured tour listing */
|
||||
featuredTour: 5000,
|
||||
} as const;
|
||||
|
||||
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 ───────────────────────────────────────────
|
||||
export const FEE_META: Record<FeeType, {
|
||||
label: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
benefit: string;
|
||||
emoji: string;
|
||||
}> = {
|
||||
featuredEvent: {
|
||||
label: 'Featured Event Listing',
|
||||
description: 'List a paid or ticketed event on SkateSpots',
|
||||
duration: '30 days',
|
||||
benefit: 'Your event appears at the top of the events page for 30 days',
|
||||
label: 'Featured Event',
|
||||
description: 'Feature your event on SkateSpots',
|
||||
benefit: 'Your event appears at the top of the events page',
|
||||
emoji: '📅',
|
||||
},
|
||||
labProfile: {
|
||||
label: 'Lab Pro Profile',
|
||||
description: 'List your services with a rate in The Lab',
|
||||
duration: '30 days',
|
||||
benefit: 'Your profile is discoverable by skaters looking to hire for 30 days',
|
||||
description: 'Feature your services in The Lab',
|
||||
benefit: 'Pro badge + featured placement when skaters browse The Lab',
|
||||
emoji: '🎓',
|
||||
},
|
||||
featuredShop: {
|
||||
label: 'Featured Shop',
|
||||
description: 'Feature your skate shop on the map and directory',
|
||||
duration: '30 days',
|
||||
benefit: 'Verified badge + featured pin on the map for 30 days',
|
||||
benefit: 'Verified badge + featured pin on the map',
|
||||
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 ───────────────────────────────────────────
|
||||
|
|
@ -60,12 +104,13 @@ export function usePlatformFee(type: FeeType) {
|
|||
const { user } = useCurrentUser();
|
||||
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
|
||||
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)?
|
||||
const isActive = isPlatformOwner || (
|
||||
record !== null &&
|
||||
|
|
@ -87,23 +132,32 @@ export function usePlatformFee(type: FeeType) {
|
|||
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 now = Date.now();
|
||||
setRecord({
|
||||
paidAt: now,
|
||||
expiresAt: now + THIRTY_DAYS_MS,
|
||||
expiresAt: now + tier.days * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
/** Amount in sats */
|
||||
amountSats: PLATFORM_FEES[type],
|
||||
/** Amount in sats for the selected tier */
|
||||
amountSats,
|
||||
/** Lightning address to pay */
|
||||
recipient: PLATFORM_LIGHTNING_ADDRESS,
|
||||
recipientName: 'SkateSpots',
|
||||
/** Display metadata */
|
||||
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? */
|
||||
isActive,
|
||||
/** Days remaining on active fee */
|
||||
|
|
@ -114,5 +168,8 @@ export function usePlatformFee(type: FeeType) {
|
|||
isPlatformOwner,
|
||||
/** Call this after successful payment */
|
||||
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 { 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';
|
||||
|
||||
/** 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 GC_TIME = 30 * 60 * 1000; // 30 min
|
||||
const TIMEOUT = 8000;
|
||||
|
|
@ -31,7 +39,7 @@ export function useTours(limit = 100) {
|
|||
if (tour) tours.push(tour);
|
||||
}
|
||||
|
||||
return tours.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return tours.sort(sortTours);
|
||||
},
|
||||
staleTime: STALE_TIME,
|
||||
gcTime: GC_TIME,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,75 @@ export interface GetInvoiceParams {
|
|||
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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1058,6 +1058,7 @@ export interface SkateTour {
|
|||
};
|
||||
// Sponsors (for future use)
|
||||
sponsors?: TourSponsor[];
|
||||
featuredUntil?: number; // Featured tour listing expiry (unix seconds), if paid
|
||||
createdAt: number;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
|
@ -1604,6 +1605,7 @@ export function parseSkateTour(event: NostrEvent): SkateTour | null {
|
|||
acceptedClipSources,
|
||||
filmRelease,
|
||||
sponsors,
|
||||
featuredUntil: parseFeaturedUntil(event),
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -530,7 +530,7 @@ export default function BecomeBuilderPage() {
|
|||
</div>
|
||||
|
||||
{/* 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
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -448,7 +448,7 @@ export default function BecomeCoachPage() {
|
|||
</div>
|
||||
|
||||
{/* 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
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -434,7 +434,7 @@ export default function BecomeEditorPage() {
|
|||
|
||||
{/* Optional Pro upgrade — free to list without it */}
|
||||
<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 className="mt-6 flex gap-3">
|
||||
|
|
|
|||
|
|
@ -506,7 +506,7 @@ export default function BecomeFilmerPage() {
|
|||
</div>
|
||||
|
||||
{/* 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
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -552,7 +552,7 @@ export default function BecomePhotographerPage() {
|
|||
</div>
|
||||
|
||||
{/* 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
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -194,7 +194,10 @@ export default function CoachDetailPage() {
|
|||
)}
|
||||
<TrustBadge 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>
|
||||
<p className="text-gray-400 flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useUploadFile } from '@/hooks/useUploadFile';
|
|||
import { useShops } from '@/hooks/useShops';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
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 {
|
||||
Route,
|
||||
|
|
@ -192,6 +193,9 @@ export default function CreateTourPage() {
|
|||
const [searchParams] = useSearchParams();
|
||||
const { user, metadata, isLoadingProfile } = useCurrentUser();
|
||||
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 { toast } = useToast();
|
||||
|
||||
|
|
@ -695,6 +699,11 @@ export default function CreateTourPage() {
|
|||
: `Skate Tour: ${title.trim()} in ${locationText}`;
|
||||
tags.push(['alt', altText]);
|
||||
|
||||
// Featured listing (optional, paid)
|
||||
if (isFeatured && featuredUntilSeconds) {
|
||||
tags.push(['featured', String(featuredUntilSeconds)]);
|
||||
}
|
||||
|
||||
console.log('Publishing tour event:', {
|
||||
kind: SKATE_TOUR_KIND,
|
||||
tags,
|
||||
|
|
@ -2181,6 +2190,15 @@ export default function CreateTourPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional featured upgrade — tour posts for free regardless */}
|
||||
<FeaturedUpgrade
|
||||
type="featuredTour"
|
||||
noun="tour"
|
||||
placement="the tours list"
|
||||
enabled={goFeatured}
|
||||
onEnabledChange={setGoFeatured}
|
||||
/>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ import { useTour } from '@/hooks/useTours';
|
|||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
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 { ProBadge } from '@/components/ProBadge';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Route,
|
||||
|
|
@ -149,9 +150,15 @@ export default function TourDetailPage() {
|
|||
<Route className="h-7 w-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Badge className="bg-cyan-500/20 text-cyan-300 border-cyan-500/30 mb-2">
|
||||
LOCAL TOUR
|
||||
</Badge>
|
||||
<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
|
||||
</Badge>
|
||||
<ProBadge
|
||||
featuredUntil={tour.featuredUntil}
|
||||
entityCoord={`${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`}
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-black text-white">
|
||||
{tour.title}
|
||||
</CardTitle>
|
||||
|
|
|
|||
Loading…
Reference in a new issue