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:
parent
5c0d4715d5
commit
dcfa1fe1b2
8 changed files with 174 additions and 49 deletions
22
NIP.md
22
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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
85
src/components/FeaturedEventUpgrade.tsx
Normal file
85
src/components/FeaturedEventUpgrade.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue