From dcfa1fe1b254fb3dc69964d0f8bb7f49b9d6aa76 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 15:39:41 +0700 Subject: [PATCH] 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 --- NIP.md | 22 +++++++ src/components/EventCard.tsx | 39 ++++++------ src/components/FeaturedEventUpgrade.tsx | 85 +++++++++++++++++++++++++ src/hooks/useLabProfiles.ts | 10 ++- src/hooks/useSkateEvents.ts | 22 +++++-- src/pages/CoachDetailPage.tsx | 2 + src/pages/CreateEventPage.tsx | 36 ++++------- src/pages/LabPage.tsx | 7 +- 8 files changed, 174 insertions(+), 49 deletions(-) create mode 100644 src/components/FeaturedEventUpgrade.tsx diff --git a/NIP.md b/NIP.md index bd6a570..5fdd67f 100644 --- a/NIP.md +++ b/NIP.md @@ -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 diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 1a1da2f..9add509 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -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) {

{event.title}

- {isUpcoming && ( - - {relativeTime} - - )} +
+ + {isUpcoming && ( + + {relativeTime} + + )} +
@@ -152,19 +156,18 @@ export function EventCard({ event, compact = false }: EventCardProps) {
- {/* Type badges */} - {event.types.length > 0 && ( -
- {event.types.slice(0, 2).map((type) => ( - - {EVENT_TYPE_LABELS[type] || type} - - ))} -
- )} + {/* Type badges + Pro */} +
+ + {event.types.slice(0, 2).map((type) => ( + + {EVENT_TYPE_LABELS[type] || type} + + ))} +
diff --git a/src/components/FeaturedEventUpgrade.tsx b/src/components/FeaturedEventUpgrade.tsx new file mode 100644 index 0000000..98508be --- /dev/null +++ b/src/components/FeaturedEventUpgrade.tsx @@ -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 ( +
+
+
+
+
+ +
+
+

+ Feature this event · optional +

+

+ Free to post. Featured events get a badge and sit + {' '}at the top of the events page. +

+
+
+ {isActive ? ( + + Active + + ) : ( + + )} +
+ + {isActive && ( +

+ {isPlatformOwner + ? 'Platform owner — featured is always on.' + : `Featured active · ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining.`} +

+ )} +
+ + {enabled && !isActive && ( +
+ { /* 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.`} + /> +
+ )} +
+ ); +} diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index ce23b5c..210b57c 100644 --- a/src/hooks/useLabProfiles.ts +++ b/src/hooks/useLabProfiles.ts @@ -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', diff --git a/src/hooks/useSkateEvents.ts b/src/hooks/useSkateEvents.ts index 65ec16e..df39abd 100644 --- a/src/hooks/useSkateEvents.ts +++ b/src/hooks/useSkateEvents.ts @@ -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, }); diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index b3cb419..5086f86 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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() { )} +

diff --git a/src/pages/CreateEventPage.tsx b/src/pages/CreateEventPage.tsx index 568e4c5..1bb8925 100644 --- a/src/pages/CreateEventPage.tsx +++ b/src/pages/CreateEventPage.tsx @@ -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 && ( -

- Paid events require a 5,000 sat platform listing fee (30 days). -

- )} - {/* Platform fee gate for paid events */} - {isPaidEvent && !feePaid && !isPlatformOwner && ( - { /* 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 */} + {/* Submit */} diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index 421ef7b..71079e9 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -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 */}
@@ -714,6 +718,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {

{displayName} + {profile.entityType === 'collective' && (