diff --git a/src/components/LabProUpgrade.tsx b/src/components/LabProUpgrade.tsx
new file mode 100644
index 0000000..43ca92f
--- /dev/null
+++ b/src/components/LabProUpgrade.tsx
@@ -0,0 +1,100 @@
+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 LabProUpgradeProps {
+ /** Whether the user opted in to Pro. */
+ enabled: boolean;
+ onEnabledChange: (enabled: boolean) => void;
+ /** Accent color class for the entity (e.g. "text-orange-400"). */
+ accentText?: string;
+ className?: string;
+}
+
+/**
+ * Opt-in "Go Pro" upgrade for Lab profiles.
+ *
+ * Free profiles publish without this. Toggling it on reveals the Lightning fee
+ * payment; once paid (or for platform owners), the profile is published with a
+ * `featured` tag that unlocks the Pro badge + featured placement in The Lab.
+ *
+ * There is NO hard paywall — a basic profile is always free.
+ */
+export function LabProUpgrade({
+ enabled,
+ onEnabledChange,
+ accentText = 'text-amber-400',
+ className,
+}: LabProUpgradeProps) {
+ const { isActive, isPlatformOwner, amountSats, daysRemaining } = usePlatformFee('labProfile');
+
+ return (
+
+
+
+
+
+
+
+
+
+ Go Pro · optional
+
+
+ Free to list. Pro adds a Pro badge and
+ {' '}featured placement at the top of The Lab.
+
+ {isPlatformOwner
+ ? 'Platform owner — Pro is always on.'
+ : `Pro active · ${daysRemaining} day${daysRemaining !== 1 ? 's' : ''} remaining. Your profile will be featured.`}
+
+ )}
+
+
+ {/* Payment appears once opted in and not already active */}
+ {enabled && !isActive && (
+
+ { /* 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.`}
+ />
+
+ )}
+
+ );
+}
diff --git a/src/components/ProBadge.tsx b/src/components/ProBadge.tsx
new file mode 100644
index 0000000..1f33aff
--- /dev/null
+++ b/src/components/ProBadge.tsx
@@ -0,0 +1,34 @@
+import { Sparkles } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { cn } from '@/lib/utils';
+import { isFeaturedActive } from '@/lib/types';
+
+interface ProBadgeProps {
+ /** The `featuredUntil` unix-seconds timestamp from a parsed entity. */
+ featuredUntil?: number;
+ className?: string;
+ /** Compact = icon only (for dense card corners). */
+ compact?: boolean;
+}
+
+/**
+ * "Pro" badge shown on featured Lab profiles and events.
+ * Renders nothing if the entity isn't currently featured.
+ */
+export function ProBadge({ featuredUntil, className, compact = false }: ProBadgeProps) {
+ if (!isFeaturedActive(featuredUntil)) return null;
+
+ return (
+
+
+ Pro
+
+ );
+}
diff --git a/src/hooks/usePlatformFee.ts b/src/hooks/usePlatformFee.ts
index bc50737..5812e4d 100644
--- a/src/hooks/usePlatformFee.ts
+++ b/src/hooks/usePlatformFee.ts
@@ -77,6 +77,16 @@ export function usePlatformFee(type: FeeType) {
? Math.ceil((record.expiresAt - Date.now()) / (24 * 60 * 60 * 1000))
: 0;
+ // Expiry as unix SECONDS, for writing into a `featured` event tag.
+ // Platform owners get a long-lived (1 year) featured window.
+ const featuredUntilSeconds = (() => {
+ if (isPlatformOwner) {
+ return Math.floor((Date.now() + 365 * 24 * 60 * 60 * 1000) / 1000);
+ }
+ if (record) return Math.floor(record.expiresAt / 1000);
+ return undefined;
+ })();
+
// Record a successful payment
const recordPayment = () => {
const now = Date.now();
@@ -98,6 +108,8 @@ export function usePlatformFee(type: FeeType) {
isActive,
/** Days remaining on active fee */
daysRemaining,
+ /** Expiry in unix seconds — write into a `featured` tag when publishing */
+ featuredUntilSeconds,
/** Platform owners bypass everything */
isPlatformOwner,
/** Call this after successful payment */
diff --git a/src/lib/types.ts b/src/lib/types.ts
index b287acb..fae35fd 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -88,6 +88,30 @@ export function parseMemberPubkeys(event: NostrEvent): string[] {
/** All Lab provider kinds (The Lab service profiles). */
export const LAB_KINDS = [35001, 35002, 35003, 35004, 35005] as const;
+/**
+ * "Pro" / featured status for Lab profiles and events.
+ *
+ * When a user pays the optional Pro/featured listing fee, the published event
+ * carries a `["featured", ""]` tag. This is *self-attested*
+ * (the app records payment and writes the tag) rather than cryptographically
+ * verified against a real Lightning payment — the same trust level the rest of
+ * the platform-fee flow already uses. A future enhancement could verify against
+ * NIP-57 zap receipts to the platform Lightning address.
+ */
+export function parseFeaturedUntil(event: NostrEvent): number | undefined {
+ const raw = event.tags.find(([name]) => name === 'featured')?.[1];
+ if (!raw) return undefined;
+ const ts = parseInt(raw, 10);
+ if (isNaN(ts) || ts <= 0) return undefined;
+ return ts; // unix seconds
+}
+
+/** Is a `featuredUntil` timestamp (unix seconds) still active right now? */
+export function isFeaturedActive(featuredUntil: number | undefined): boolean {
+ if (!featuredUntil) return false;
+ return featuredUntil * 1000 > Date.now();
+}
+
/**
* A parsed addressable coordinate: `::`.
*/
@@ -198,6 +222,7 @@ export interface SkateEvent {
location?: string;
image?: string;
types: string[];
+ featuredUntil?: number; // Featured event listing expiry (unix seconds), if paid
createdAt: number;
event: NostrEvent;
}
@@ -389,6 +414,7 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null {
location,
image,
types,
+ featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at,
event,
};
@@ -847,6 +873,7 @@ export interface SkateBuilder {
id: string;
pubkey: string;
identifier: string;
+ featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid
title: string;
bio: string;
geohash?: string;
@@ -876,6 +903,7 @@ export interface SkateCoach {
id: string;
pubkey: string;
identifier: string;
+ featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid
title: string;
bio: string;
geohash?: string;
@@ -901,6 +929,7 @@ export interface SkateFilmer {
id: string;
pubkey: string;
identifier: string;
+ featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid
title: string;
bio: string;
geohash?: string;
@@ -928,6 +957,7 @@ export interface SkateEditor {
id: string;
pubkey: string;
identifier: string;
+ featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid
title: string;
bio: string;
geohash?: string;
@@ -955,6 +985,7 @@ export interface SkatePhotographer {
id: string;
pubkey: string;
identifier: string;
+ featuredUntil?: number; // "Pro" listing expiry (unix seconds), if paid
title: string;
bio: string;
geohash?: string;
@@ -1089,6 +1120,7 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
crewRef: parseCrewRef(event),
+ featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at,
event,
};
@@ -1147,6 +1179,7 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
crewRef: parseCrewRef(event),
+ featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at,
event,
};
@@ -1202,6 +1235,7 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
entityType,
members,
crewRef: parseCrewRef(event),
+ featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at,
event,
};
@@ -1259,6 +1293,7 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
crewRef: parseCrewRef(event),
+ featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at,
event,
};
@@ -1320,6 +1355,7 @@ export function parseSkateBuilder(event: NostrEvent): SkateBuilder | null {
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
crewRef: parseCrewRef(event),
+ featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at,
event,
};
diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx
index a27a565..d8fc8e2 100644
--- a/src/pages/BecomeBuilderPage.tsx
+++ b/src/pages/BecomeBuilderPage.tsx
@@ -33,8 +33,8 @@ import {
type EntityType,
} from '@/lib/types';
import { cn } from '@/lib/utils';
-import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap, Hammer } from 'lucide-react';
-import { PlatformFeePayment } from '@/components/PlatformFeePayment';
+import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react';
+import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS);
@@ -86,7 +86,8 @@ export default function BecomeBuilderPage() {
const { webln, activeNWC } = useWallet();
const hasWallet = !!webln || !!activeNWC;
- const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
+ const { isActive: isPro, featuredUntilSeconds } = usePlatformFee('labProfile');
+ const [goPro, setGoPro] = useState(false);
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
@@ -221,6 +222,11 @@ export default function BecomeBuilderPage() {
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
+ // Pro / featured listing (optional, paid)
+ if (isPro && featuredUntilSeconds) {
+ tags.push(['featured', String(featuredUntilSeconds)]);
+ }
+
await createEvent({
kind: SKATE_BUILDER_KIND,
content: bio.trim(),
@@ -523,28 +529,18 @@ export default function BecomeBuilderPage() {
)}
- {/* Platform fee gate — only when a rate is set */}
- {rate.trim() && !feePaid && !isPlatformOwner && (
- { /* hook auto-updates */ }}
- context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
- />
- )}
+ {/* Optional Pro upgrade — free to list without it */}
+
diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx
index 92f6709..840c91f 100644
--- a/src/pages/BecomeCoachPage.tsx
+++ b/src/pages/BecomeCoachPage.tsx
@@ -23,8 +23,8 @@ import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
-import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
-import { PlatformFeePayment } from '@/components/PlatformFeePayment';
+import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
+import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
@@ -46,8 +46,9 @@ export default function BecomeCoachPage() {
// Check if user has any wallet connected
const hasWallet = !!webln || !!activeNWC;
- // Platform listing fee — required when a rate is set
- const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
+ // Optional "Pro" upgrade — free to list; Pro adds a badge + featured placement
+ const { isActive: isPro, featuredUntilSeconds } = usePlatformFee('labProfile');
+ const [goPro, setGoPro] = useState(false);
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
@@ -172,6 +173,11 @@ export default function BecomeCoachPage() {
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
+ // Pro / featured listing (optional, paid)
+ if (isPro && featuredUntilSeconds) {
+ tags.push(['featured', String(featuredUntilSeconds)]);
+ }
+
await createEvent({
kind: SKATE_COACH_KIND,
content: bio.trim(),
@@ -441,39 +447,23 @@ export default function BecomeCoachPage() {
)}
- {/* Platform fee gate — only when a rate is set */}
- {rate.trim() && !feePaid && !isPlatformOwner && (
- { /* hook auto-updates */ }}
- context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
- />
- )}
+ {/* Optional Pro upgrade — free to list without it */}
+
diff --git a/src/pages/BecomeEditorPage.tsx b/src/pages/BecomeEditorPage.tsx
index be0df92..66a61a7 100644
--- a/src/pages/BecomeEditorPage.tsx
+++ b/src/pages/BecomeEditorPage.tsx
@@ -30,8 +30,8 @@ import {
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { cn } from '@/lib/utils';
-import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
-import { PlatformFeePayment } from '@/components/PlatformFeePayment';
+import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
+import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
@@ -54,7 +54,8 @@ export default function BecomeEditorPage() {
const hasWallet = !!webln || !!activeNWC;
// Platform listing fee — required when a rate is set
- const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
+ const { isActive: isPro, featuredUntilSeconds } = usePlatformFee('labProfile');
+ const [goPro, setGoPro] = useState(false);
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
@@ -171,6 +172,11 @@ export default function BecomeEditorPage() {
if (hardware.trim()) tags.push(['hardware', hardware.trim()]);
if (software.trim()) tags.push(['software', software.trim()]);
+ // Pro / featured listing (optional, paid)
+ if (isPro && featuredUntilSeconds) {
+ tags.push(['featured', String(featuredUntilSeconds)]);
+ }
+
await createEvent({ kind: SKATE_EDITOR_KIND, content: bio.trim(), tags });
toast({ title: 'Editor profile created!', description: 'Your profile has been published to SkateSpots.' });
@@ -426,16 +432,10 @@ export default function BecomeEditorPage() {
- {/* Platform fee gate — only when a rate is set */}
- {rate.trim() && !feePaid && !isPlatformOwner && (
-
- { /* hook auto-updates */ }}
- context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
- />
-
- )}
+ {/* Optional Pro upgrade — free to list without it */}
+
+
+
@@ -443,15 +443,13 @@ export default function BecomeEditorPage() {
diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx
index 0ba8ea2..4fedf0e 100644
--- a/src/pages/BecomeFilmerPage.tsx
+++ b/src/pages/BecomeFilmerPage.tsx
@@ -23,8 +23,8 @@ import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
-import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
-import { PlatformFeePayment } from '@/components/PlatformFeePayment';
+import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
+import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
@@ -48,7 +48,8 @@ export default function BecomeFilmerPage() {
const hasWallet = !!webln || !!activeNWC;
// Platform listing fee — required when a rate is set
- const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
+ const { isActive: isPro, featuredUntilSeconds } = usePlatformFee('labProfile');
+ const [goPro, setGoPro] = useState(false);
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
@@ -188,6 +189,11 @@ export default function BecomeFilmerPage() {
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
+ // Pro / featured listing (optional, paid)
+ if (isPro && featuredUntilSeconds) {
+ tags.push(['featured', String(featuredUntilSeconds)]);
+ }
+
await createEvent({
kind: SKATE_FILMER_KIND,
content: bio.trim(),
@@ -499,39 +505,23 @@ export default function BecomeFilmerPage() {
)}
- {/* Platform fee gate — only when a rate is set */}
- {rate.trim() && !feePaid && !isPlatformOwner && (
- { /* hook auto-updates */ }}
- context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
- />
- )}
+ {/* Optional Pro upgrade — free to list without it */}
+
diff --git a/src/pages/BecomePhotographerPage.tsx b/src/pages/BecomePhotographerPage.tsx
index a5f587e..80aee70 100644
--- a/src/pages/BecomePhotographerPage.tsx
+++ b/src/pages/BecomePhotographerPage.tsx
@@ -35,8 +35,8 @@ import {
isValidNoffer,
} from '@/lib/types';
import { cn } from '@/lib/utils';
-import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap, Aperture } from 'lucide-react';
-import { PlatformFeePayment } from '@/components/PlatformFeePayment';
+import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react';
+import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(PHOTO_SERVICE_LABELS);
@@ -92,7 +92,8 @@ export default function BecomePhotographerPage() {
const { webln, activeNWC } = useWallet();
const hasWallet = !!webln || !!activeNWC;
- const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
+ const { isActive: isPro, featuredUntilSeconds } = usePlatformFee('labProfile');
+ const [goPro, setGoPro] = useState(false);
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
@@ -238,6 +239,11 @@ export default function BecomePhotographerPage() {
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
+ // Pro / featured listing (optional, paid)
+ if (isPro && featuredUntilSeconds) {
+ tags.push(['featured', String(featuredUntilSeconds)]);
+ }
+
await createEvent({
kind: SKATE_PHOTOGRAPHER_KIND,
content: bio.trim(),
@@ -545,28 +551,18 @@ export default function BecomePhotographerPage() {
)}
- {/* Platform fee gate — only when a rate is set */}
- {rate.trim() && !feePaid && !isPlatformOwner && (
- { /* hook auto-updates */ }}
- context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
- />
- )}
+ {/* Optional Pro upgrade — free to list without it */}
+