Add Pro/Featured display + optional featured events (Option B)

Lab:
- useLabProfiles sorts featured (Pro) profiles first
- ProBadge shown on Lab cards (LabPage) + Lab detail page; featured cards
  get a subtle amber highlight

Events (same no-paywall model as Lab):
- New FeaturedEventUpgrade: every event posts for free; optional fee adds a
  featured badge + top placement (reuses existing featuredEvent fee)
- CreateEventPage: removed the paid-event fee gate; opt-in featured toggle,
  writes a `featured` tag when active
- useSkateEvents sorts featured events first; EventCard shows ProBadge
  (compact in list view, overlay on the image in full view)

Docs:
- NIP.md documents the `featured` tag (unix-seconds expiry, self-attested)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 15:39:41 +07:00
parent 5c0d4715d5
commit dcfa1fe1b2
8 changed files with 174 additions and 49 deletions

22
NIP.md
View file

@ -1457,6 +1457,28 @@ They share a common base schema and are distinguished by kind and a self-tag.
below), `p` (collective member pubkeys — see Collective Membership below),
`a` (spot references to kind 30818), `alt`.
### 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:
```json
["featured", "1767225600"]
```
Clients that see an unexpired `featured` timestamp render a gold **"Pro"** badge
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.
### Entity Type (individual vs. collective)
Every Lab profile carries an optional `entity` tag declaring whether it

View file

@ -18,6 +18,7 @@ import { genUserName } from '@/lib/genUserName';
import { ZapButton } from '@/components/ZapButton';
import { TrustBadge } from '@/components/TrustBadge';
import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge';
import { ReportDialog } from '@/components/ReportDialog';
import type { SkateEvent } from '@/lib/types';
import { EVENT_TYPE_LABELS } from '@/lib/types';
@ -97,11 +98,14 @@ export function EventCard({ event, compact = false }: EventCardProps) {
<h3 className="font-bold text-white group-hover:text-[#ccff00] transition-colors line-clamp-1 uppercase tracking-wide">
{event.title}
</h3>
{isUpcoming && (
<Badge className="flex-shrink-0 text-xs bg-[#ff0040] text-white border-0 font-bold uppercase">
{relativeTime}
</Badge>
)}
<div className="flex items-center gap-1 flex-shrink-0">
<ProBadge featuredUntil={event.featuredUntil} compact />
{isUpcoming && (
<Badge className="text-xs bg-[#ff0040] text-white border-0 font-bold uppercase">
{relativeTime}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs text-gray-400 flex-wrap">
@ -152,19 +156,18 @@ export function EventCard({ event, compact = false }: EventCardProps) {
</div>
</div>
</div>
{/* Type badges */}
{event.types.length > 0 && (
<div className="absolute top-3 right-3 flex flex-wrap gap-1">
{event.types.slice(0, 2).map((type) => (
<Badge
key={type}
className="bg-primary text-primary-foreground"
>
{EVENT_TYPE_LABELS[type] || type}
</Badge>
))}
</div>
)}
{/* Type badges + Pro */}
<div className="absolute top-3 right-3 flex flex-wrap gap-1 justify-end">
<ProBadge featuredUntil={event.featuredUntil} />
{event.types.slice(0, 2).map((type) => (
<Badge
key={type}
className="bg-primary text-primary-foreground"
>
{EVENT_TYPE_LABELS[type] || type}
</Badge>
))}
</div>
</div>
<CardHeader className="pb-2">

View file

@ -0,0 +1,85 @@
import { Sparkles, Check } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { cn } from '@/lib/utils';
interface FeaturedEventUpgradeProps {
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
className?: string;
}
/**
* Opt-in "Feature this event" 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.
*/
export function FeaturedEventUpgrade({
enabled,
onEnabledChange,
className,
}: FeaturedEventUpgradeProps) {
const { isActive, isPlatformOwner, amountSats, daysRemaining } = usePlatformFee('featuredEvent');
return (
<div
className={cn(
'rounded-2xl border border-[#ccff00]/30 bg-gradient-to-br from-[#ccff00]/8 to-black/40 overflow-hidden',
className,
)}
>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-[#ccff00] to-lime-500 flex items-center justify-center flex-shrink-0 shadow-lg shadow-[#ccff00]/25">
<Sparkles className="h-4 w-4 text-black" />
</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>
</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.
</p>
</div>
</div>
{isActive ? (
<span className="flex items-center gap-1 text-[11px] font-bold text-green-400 flex-shrink-0">
<Check className="h-3.5 w-3.5" /> Active
</span>
) : (
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
className="flex-shrink-0 data-[state=checked]:bg-[#ccff00]"
/>
)}
</div>
{isActive && (
<p className="mt-2 text-xs text-gray-500">
{isPlatformOwner
? 'Platform owner — featured is always on.'
: `Featured active · ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining.`}
</p>
)}
</div>
{enabled && !isActive && (
<div className="px-4 pb-4">
<PlatformFeePayment
type="featuredEvent"
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.`}
/>
</div>
)}
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder, type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder } from '@/lib/types';
import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder, isFeaturedActive, type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder) & {
@ -61,7 +61,13 @@ export function useLabProfiles(limit = 100) {
console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers + editors + photographers)`);
return profiles.sort((a, b) => b.createdAt - a.createdAt);
// Featured (Pro) profiles first, then newest.
return profiles.sort((a, b) => {
const aPro = isFeaturedActive(a.featuredUntil) ? 1 : 0;
const bPro = isFeaturedActive(b.featuredUntil) ? 1 : 0;
if (aPro !== bPro) return bPro - aPro;
return b.createdAt - a.createdAt;
});
},
staleTime: 30000,
refetchOnMount: 'always',

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { CALENDAR_EVENT_KIND, parseSkateEvent, type SkateEvent } from '@/lib/types';
import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
export function useSkateEvents(limit = 50) {
@ -27,8 +27,13 @@ export function useSkateEvents(limit = 50) {
if (skateEvent) skateEvents.push(skateEvent);
}
// Sort by start time
return skateEvents.sort((a, b) => a.start - b.start);
// Featured (paid) events first, then by soonest start time.
return skateEvents.sort((a, b) => {
const aF = isFeaturedActive(a.featuredUntil) ? 1 : 0;
const bF = isFeaturedActive(b.featuredUntil) ? 1 : 0;
if (aF !== bF) return bF - aF;
return a.start - b.start;
});
},
staleTime: 60000,
});
@ -63,8 +68,15 @@ export function useUpcomingSkateEvents(limit = 20) {
}
}
// Sort by start time ascending (soonest first)
return skateEvents.sort((a, b) => a.start - b.start).slice(0, limit);
// Featured (paid) events first, then soonest start time.
return skateEvents
.sort((a, b) => {
const aF = isFeaturedActive(a.featuredUntil) ? 1 : 0;
const bF = isFeaturedActive(b.featuredUntil) ? 1 : 0;
if (aF !== bF) return bF - aF;
return a.start - b.start;
})
.slice(0, limit);
},
staleTime: 60000,
});

View file

@ -18,6 +18,7 @@ import { genUserName } from '@/lib/genUserName';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS } from '@/lib/types';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge';
import NotFound from './NotFound';
import {
GraduationCap,
@ -193,6 +194,7 @@ export default function CoachDetailPage() {
)}
<TrustBadge pubkey={profile.pubkey} size="md" />
<OfficialBadge pubkey={profile.pubkey} size="md" />
<ProBadge featuredUntil={profile.featuredUntil} />
</div>
<p className="text-gray-400 flex items-center gap-2">
<Calendar className="h-4 w-4" />

View file

@ -36,7 +36,7 @@ import {
Map,
Zap,
} from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { Link } from 'react-router-dom';
@ -88,8 +88,9 @@ export default function CreateEventPage() {
// Paid event / entry fee
const [entryFee, setEntryFee] = useState('');
const isPaidEvent = entryFee.trim().length > 0 && entryFee.trim() !== '0';
const { isActive: feePaid, isPlatformOwner } = usePlatformFee('featuredEvent');
const canSubmit = !isPaidEvent || feePaid || isPlatformOwner;
// Optional "Feature this event" upgrade — every event posts for free.
const { isActive: isFeatured, featuredUntilSeconds } = usePlatformFee('featuredEvent');
const [goFeatured, setGoFeatured] = useState(false);
// Get the selected tour (if any)
const selectedTour = tours?.find((t) => `${t.pubkey}:${t.identifier}` === linkedTourId);
@ -302,6 +303,11 @@ export default function CreateEventPage() {
tags.push(['t', 'paid']);
}
// Featured listing (optional, paid)
if (isFeatured && featuredUntilSeconds) {
tags.push(['featured', String(featuredUntilSeconds)]);
}
// Add spot link if selected
if (selectedSpot && linkedSpotId !== 'none') {
// Use 'a' tag to reference the spot (addressable event reference)
@ -891,42 +897,26 @@ export default function CreateEventPage() {
onChange={(e) => setEntryFee(e.target.value)}
className="bg-black/50 border-white/20"
/>
{isPaidEvent && (
<p className="text-xs text-[#ff6600]/80">
Paid events require a 5,000 sat platform listing fee (30 days).
</p>
)}
</div>
{/* Platform fee gate for paid events */}
{isPaidEvent && !feePaid && !isPlatformOwner && (
<PlatformFeePayment
type="featuredEvent"
onSuccess={() => { /* fee hook auto-updates isActive */ }}
context={`You set an entry fee of "${entryFee}" — a 5,000 sat listing fee is required to publish paid events.`}
/>
)}
{/* Optional featured upgrade — every event posts for free */}
<FeaturedEventUpgrade enabled={goFeatured} onEnabledChange={setGoFeatured} />
{/* Submit */}
<Button
type="submit"
className="w-full"
disabled={isPublishing || isUploading || !canSubmit}
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('createEvent.publishing')}
</>
) : !canSubmit ? (
<>
<Zap className="h-4 w-4 mr-2" />
Pay listing fee above to publish
</>
) : (
<>
<Calendar className="h-4 w-4 mr-2" />
{t('events.createEvent')}
{isFeatured ? 'Publish Featured Event' : t('events.createEvent')}
</>
)}
</Button>

View file

@ -49,6 +49,8 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ProBadge } from '@/components/ProBadge';
import { isFeaturedActive } from '@/lib/types';
// ─── Constants ────────────────────────────────────────────────────────────────
@ -685,7 +687,9 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
'group relative flex gap-3 p-4 rounded-2xl bg-white/3 border border-white/8',
'hover:bg-white/5 hover:border-white/15 hover:shadow-xl',
'transition-all duration-200 active:scale-[0.99]',
'overflow-hidden'
'overflow-hidden',
isFeaturedActive(profile.featuredUntil) &&
'border-amber-500/40 bg-gradient-to-br from-amber-500/[0.07] to-transparent',
)}>
{/* Left color stripe */}
<div className={cn('absolute left-0 top-0 bottom-0 w-[3px] rounded-l-2xl', accent.stripe)} />
@ -714,6 +718,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
<div className="min-w-0">
<h3 className="font-black text-sm text-white truncate leading-tight flex items-center gap-1.5">
<span className="truncate">{displayName}</span>
<ProBadge featuredUntil={profile.featuredUntil} compact className="flex-shrink-0" />
{profile.entityType === 'collective' && (
<span
className={cn('inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold flex-shrink-0', accent.badgeSkill)}