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