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. +

+
+
+ {isActive ? ( + + Active + + ) : ( + + )} +
+ + {/* Benefits list */} + {!isActive && ( + + )} + + {/* Already active */} + {isActive && ( +

+ {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 */} +