feat: platform monetization — listing fees for paid events & Lab profiles v1.0.62

Architecture:
- usePlatformFee hook: central fee config (featuredEvent/labProfile/featuredShop)
  5,000 sats each, 30-day validity, localStorage persistence, owner bypass
- PlatformFeePayment component: generic reusable payment gate
  auto-pay via NWC/WebLN, QR fallback, active/expired state display
  all fees route to skatespots@minibits.cash

Events (CreateEventPage):
- New optional "Entry Fee" field
- Triggers 5,000 sat platform fee when filled
- Stores entry-fee + paid tags on published event
- Free events: zero friction unchanged

The Lab (Become{Coach,Filmer,Editor}Page):
- Triggers 5,000 sat fee when "Rate" field is filled
- Submit disabled with clear CTA until fee paid
- Free/volunteer profiles: zero friction unchanged

Rule: "pay only when you earn"
- No rate set → publish instantly, no fee
- Rate set → 5,000 sats to SkateSpots, valid 30 days
- Platform owners always bypass

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 02:19:45 +07:00
parent d8e49e7f65
commit a74225fe0d
8 changed files with 484 additions and 28 deletions

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.0.61",
"version": "1.0.62",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {

View file

@ -1,23 +1,32 @@
v1.0.61 — Map UI overhaul
v1.0.62 — Platform monetization: listing fees for paid events & Lab profiles
REDESIGNED: Mobile map toolbar
- Search bar is now a slim rounded pill (not a tall boxy input)
- View toggle (Map/List) is a compact pill with icon-only buttons
- Filter button is a small circle that turns red when filters are active
- No more generic-looking default form controls
NEW: usePlatformFee hook — central fee configuration
- PLATFORM_FEES.featuredEvent = 5,000 sats (paid/ticketed events)
- PLATFORM_FEES.labProfile = 5,000 sats (coaches/filmers/editors with a rate)
- PLATFORM_FEES.featuredShop = 10,000 sats (reserved for future use)
- 30-day validity stored in localStorage per fee type
- Platform owner pubkeys always bypass fees automatically
REDESIGNED: Right-side control strip
- Layer toggles (Spots/Shops/Gear/Tours) are now small 36px circles — active = opaque, inactive = 35% opacity
- Near Me button moved into the right strip (was floating top-right separately)
- Quick Snap camera button matches the same strip style
- No more giant colored emoji circles
NEW: PlatformFeePayment component — generic, reusable payment gate
- Auto-pay via connected NWC or WebLN wallet
- QR code fallback for manual Lightning payment
- Shows "Active — X days remaining" when fee is already paid
- Branded gradient UI consistent with SkateSpots design system
- All fees go to skatespots@minibits.cash
REDESIGNED: Add Spot FAB (bottom-left)
- Single gradient pill button (red→orange), replaces the two separate large circles
- Cleaner, less cluttered
FEATURE: CreateEventPage — entry fee field
- New "Entry Fee" input field (optional)
- When filled, triggers 5,000 sat platform listing fee gate
- Entry fee stored as event tag for display on event detail page
- Free events: zero friction, no fee required
FIXED: Loading indicator
- Replaced the center-screen "Loading..." pill with a slim animated gradient bar at the top edge of the map
FEATURE: BecomeCoachPage — monetized profile fee
- When "Rate" field is filled, 5,000 sat listing fee required
- Submit button locked with clear message until fee paid
- Free/volunteer coaches: zero friction
FIXED: Map attribution
- Moved to bottom-left corner, minimal and unobtrusive
FEATURE: BecomeFilmerPage — same as coach
FEATURE: BecomeEditorPage — same as coach
RULE: "You only pay when you earn" — free profiles and free events
always publish instantly with zero fees.

View file

@ -0,0 +1,232 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { useNWC } from '@/hooks/useNWCContext';
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee';
import { cn } from '@/lib/utils';
import {
Zap,
Loader2,
CheckCircle2,
Copy,
ExternalLink,
Rocket,
ShieldCheck,
Clock,
} from 'lucide-react';
interface PlatformFeePaymentProps {
type: FeeType;
/** Called after payment confirmed */
onSuccess: () => void;
/** Optional extra context shown to user */
context?: string;
}
type PaymentState = 'idle' | 'generating' | 'waiting' | 'paying' | 'success' | 'error';
export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaymentProps) {
const { amountSats, recipient, recipientName, meta, isActive, daysRemaining, recordPayment } = usePlatformFee(type);
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const { sendPayment } = useNWC();
const [paymentState, setPaymentState] = useState<PaymentState>('idle');
const [invoice, setInvoice] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Auto-pay when wallet connected and invoice ready
useEffect(() => {
if (invoice && paymentState === 'waiting' && (webln || activeNWC)) {
handleAutoPay();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoice, paymentState]);
// Already paid and active
if (isActive) {
return (
<div className="flex items-center gap-3 p-4 rounded-2xl bg-green-500/10 border border-green-500/30">
<ShieldCheck className="h-5 w-5 text-green-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-bold text-green-400 text-sm">{meta.label} — Active</p>
<p className="text-xs text-gray-500">{daysRemaining} day{daysRemaining !== 1 ? 's' : ''} remaining</p>
</div>
</div>
);
}
const generateInvoice = async () => {
setPaymentState('generating');
setError(null);
try {
const bolt11 = await getInvoiceFromLightningAddress({
lightningAddress: recipient,
amountSats,
comment: `SkateSpots ${meta.label}`,
});
setInvoice(bolt11);
const { default: QRCode } = await import('qrcode');
const qr = await QRCode.toDataURL(bolt11.toUpperCase(), {
width: 256, margin: 2,
color: { dark: '#000000', light: '#ffffff' },
});
setQrCodeUrl(qr);
setPaymentState('waiting');
if (webln || activeNWC) {
await handleAutoPay();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate invoice');
setPaymentState('error');
}
};
const handleAutoPay = async () => {
if (!invoice) return;
setPaymentState('paying');
try {
if (activeNWC?.isConnected) {
await sendPayment(activeNWC, invoice);
} else if (webln) {
if (webln.enable) await webln.enable();
await webln.sendPayment(invoice);
} else {
setPaymentState('waiting');
return;
}
recordPayment();
setPaymentState('success');
toast({ title: '⚡ Payment received!', description: `${amountSats.toLocaleString()} sats — ${meta.label} is now active.` });
setTimeout(onSuccess, 1200);
} catch {
setPaymentState('waiting');
toast({ title: 'Auto-pay failed', description: 'Pay manually using the QR or invoice below.', variant: 'destructive' });
}
};
const copyInvoice = async () => {
if (!invoice) return;
await navigator.clipboard.writeText(invoice);
toast({ title: 'Invoice copied!' });
};
// ── Success ──
if (paymentState === 'success') {
return (
<div className="flex flex-col items-center gap-3 p-6 rounded-2xl bg-green-500/10 border border-green-500/30 text-center">
<CheckCircle2 className="h-10 w-10 text-green-400" />
<div>
<p className="font-black text-green-400 text-lg uppercase">{meta.emoji} {meta.label} Active!</p>
<p className="text-sm text-gray-400 mt-1">{meta.benefit}</p>
</div>
</div>
);
}
// ── Main payment UI ──
return (
<div className="rounded-2xl border border-[#ff6600]/30 bg-gradient-to-br from-[#ff6600]/8 to-black/60 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-white/8">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#ff6600] to-[#ff0040] flex items-center justify-center flex-shrink-0 shadow-lg shadow-[#ff6600]/25">
<Rocket className="h-5 w-5 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="font-black text-white text-sm uppercase tracking-wide">{meta.emoji} {meta.label}</p>
<p className="text-xs text-gray-500">{meta.description}</p>
</div>
</div>
</div>
<div className="p-4 space-y-4">
{/* What you get */}
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
<CheckCircle2 className="h-3.5 w-3.5 text-green-400 flex-shrink-0" />
<span className="text-xs text-gray-300 leading-snug">{meta.benefit}</span>
</div>
<div className="flex items-center gap-2 p-2.5 rounded-xl bg-white/4 border border-white/6">
<Clock className="h-3.5 w-3.5 text-[#ccff00] flex-shrink-0" />
<span className="text-xs text-gray-300">Valid for <strong className="text-white">{meta.duration}</strong></span>
</div>
</div>
{context && (
<p className="text-xs text-gray-500 italic">{context}</p>
)}
{/* Amount */}
<div className="text-center py-3 rounded-xl bg-[#ff6600]/10 border border-[#ff6600]/25">
<p className="text-3xl font-black text-[#ff6600]">⚡ {amountSats.toLocaleString()} sats</p>
<p className="text-xs text-gray-500 mt-1">
~${(amountSats * 0.0006).toFixed(2)} USD · to {recipientName} · {meta.duration}
</p>
</div>
{/* Actions */}
{paymentState === 'idle' && (
<Button
onClick={generateInvoice}
className="w-full h-11 bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 border-0 text-white font-black uppercase tracking-wide shadow-lg shadow-[#ff6600]/20"
>
<Zap className="h-4 w-4 mr-2" />
Pay {amountSats.toLocaleString()} sats to Continue
</Button>
)}
{(paymentState === 'generating' || paymentState === 'paying') && (
<div className="flex flex-col items-center gap-2 py-4">
<Loader2 className="h-7 w-7 animate-spin text-[#ff6600]" />
<p className="text-sm text-gray-400">
{paymentState === 'generating' ? 'Generating invoice…' : 'Processing payment…'}
</p>
</div>
)}
{paymentState === 'waiting' && invoice && qrCodeUrl && (
<div className="space-y-3">
<div className="flex justify-center">
<div className="bg-white p-3 rounded-xl">
<img src={qrCodeUrl} alt="Lightning invoice QR" className="w-44 h-44" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" onClick={copyInvoice} className="border-white/15 text-gray-300 hover:bg-white/6 gap-2 text-xs">
<Copy className="h-3.5 w-3.5" />Copy Invoice
</Button>
<Button variant="outline" onClick={() => window.open(`lightning:${invoice}`, '_blank')} className="border-white/15 text-gray-300 hover:bg-white/6 gap-2 text-xs">
<ExternalLink className="h-3.5 w-3.5" />Open Wallet
</Button>
</div>
{(webln || activeNWC) && (
<Button onClick={handleAutoPay} className={cn('w-full gap-2 font-bold', 'bg-gradient-to-r from-[#ff6600] to-[#ff0040] hover:opacity-90 border-0')}>
<Zap className="h-4 w-4" />Pay with Connected Wallet
</Button>
)}
<p className="text-[11px] text-gray-600 text-center">Scan with any Lightning wallet</p>
</div>
)}
{paymentState === 'error' && (
<div className="space-y-3">
<p className="text-sm text-red-400 text-center">{error}</p>
<Button variant="outline" onClick={generateInvoice} className="w-full border-white/15">
Try Again
</Button>
</div>
)}
</div>
</div>
);
}

106
src/hooks/usePlatformFee.ts Normal file
View file

@ -0,0 +1,106 @@
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { PLATFORM_OWNER_PUBKEYS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
// ─── Fee amounts (sats) ───────────────────────────────────────────────────────
export const PLATFORM_FEES = {
/** Featured event listing — paid/ticketed events · 30 days */
featuredEvent: 5000,
/** Lab profile with a rate set (coach / filmer / editor) · 30 days */
labProfile: 5000,
/** Featured shop placement on map · 30 days */
featuredShop: 10000,
} as const;
export type FeeType = keyof typeof PLATFORM_FEES;
// ─── Display metadata per fee type ───────────────────────────────────────────
export const FEE_META: Record<FeeType, {
label: string;
description: string;
duration: string;
benefit: string;
emoji: string;
}> = {
featuredEvent: {
label: 'Featured Event Listing',
description: 'List a paid or ticketed event on SkateSpots',
duration: '30 days',
benefit: 'Your event appears at the top of the events page for 30 days',
emoji: '📅',
},
labProfile: {
label: 'Lab Pro Profile',
description: 'List your services with a rate in The Lab',
duration: '30 days',
benefit: 'Your profile is discoverable by skaters looking to hire for 30 days',
emoji: '🎓',
},
featuredShop: {
label: 'Featured Shop',
description: 'Feature your skate shop on the map and directory',
duration: '30 days',
benefit: 'Verified badge + featured pin on the map for 30 days',
emoji: '🏪',
},
};
// ─── localStorage key per fee type ───────────────────────────────────────────
function storageKey(type: FeeType): string {
return `platform-fee-paid-${type}`;
}
interface FeeRecord {
paidAt: number; // unix ms
expiresAt: number; // unix ms
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function usePlatformFee(type: FeeType) {
const { user } = useCurrentUser();
const [record, setRecord] = useLocalStorage<FeeRecord | null>(storageKey(type), null);
// Platform owners never pay
const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
// 30 days in ms
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
// Is the fee currently active (paid and not expired)?
const isActive = isPlatformOwner || (
record !== null &&
Date.now() < record.expiresAt
);
// Days remaining
const daysRemaining = isActive && record
? Math.ceil((record.expiresAt - Date.now()) / (24 * 60 * 60 * 1000))
: 0;
// Record a successful payment
const recordPayment = () => {
const now = Date.now();
setRecord({
paidAt: now,
expiresAt: now + THIRTY_DAYS_MS,
});
};
return {
/** Amount in sats */
amountSats: PLATFORM_FEES[type],
/** Lightning address to pay */
recipient: PLATFORM_LIGHTNING_ADDRESS,
recipientName: 'SkateSpots',
/** Display metadata */
meta: FEE_META[type],
/** Is fee currently paid and active? */
isActive,
/** Days remaining on active fee */
daysRemaining,
/** Platform owners bypass everything */
isPlatformOwner,
/** Call this after successful payment */
recordPayment,
};
}

View file

@ -20,7 +20,9 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker';
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS } from '@/lib/types';
import { cn } from '@/lib/utils';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
@ -41,6 +43,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');
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
@ -407,10 +412,19 @@ export default function BecomeCoachPage() {
)}
</div>
{/* Platform fee gate — only when a rate is set */}
{rate.trim() && !feePaid && !isPlatformOwner && (
<PlatformFeePayment
type="labProfile"
onSuccess={() => { /* hook auto-updates */ }}
context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
/>
)}
<Button
type="submit"
className="w-full bg-red-500 hover:bg-red-600 uppercase font-black"
disabled={isPublishing || isUploading || !hasWallet}
disabled={isPublishing || isUploading || !hasWallet || (!!rate.trim() && !feePaid && !isPlatformOwner)}
>
{isPublishing ? (
<>
@ -422,6 +436,11 @@ export default function BecomeCoachPage() {
<Wallet className="h-4 w-4 mr-2" />
CONNECT WALLET TO CONTINUE
</>
) : rate.trim() && !feePaid && !isPlatformOwner ? (
<>
<Zap className="h-4 w-4 mr-2" />
PAY LISTING FEE ABOVE TO CONTINUE
</>
) : (
<>
<GraduationCap className="h-4 w-4 mr-2" />

View file

@ -25,7 +25,9 @@ import {
EDITOR_SKILL_DESCRIPTIONS,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
@ -46,6 +48,9 @@ export default function BecomeEditorPage() {
const hasWallet = !!webln || !!activeNWC;
// Platform listing fee — required when a rate is set
const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
@ -391,17 +396,33 @@ export default function BecomeEditorPage() {
</CardContent>
</Card>
{/* Platform fee gate — only when a rate is set */}
{rate.trim() && !feePaid && !isPlatformOwner && (
<div className="mt-4">
<PlatformFeePayment
type="labProfile"
onSuccess={() => { /* hook auto-updates */ }}
context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
/>
</div>
)}
<div className="mt-6 flex gap-3">
<Link to="/lab" className="flex-1">
<Button type="button" variant="outline" className="w-full border-gray-700">Cancel</Button>
</Link>
<Button
type="submit"
disabled={isPublishing || selectedServices.length === 0}
disabled={isPublishing || selectedServices.length === 0 || (!!rate.trim() && !feePaid && !isPlatformOwner)}
className="flex-1 bg-purple-600 hover:bg-purple-700 font-black uppercase gap-2"
>
{isPublishing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Scissors className="h-4 w-4" />}
{isPublishing ? 'Publishing…' : 'Publish Editor Profile'}
{isPublishing ? (
<><Loader2 className="h-4 w-4 animate-spin" />Publishing…</>
) : rate.trim() && !feePaid && !isPlatformOwner ? (
<><Zap className="h-4 w-4" />Pay Fee Above</>
) : (
<><Scissors className="h-4 w-4" />Publish Editor Profile</>
)}
</Button>
</div>
</form>

View file

@ -20,7 +20,9 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
const SKILL_ENTRIES = Object.entries(FILMING_SKILL_LABELS);
@ -42,6 +44,9 @@ export default function BecomeFilmerPage() {
// 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');
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
@ -443,10 +448,19 @@ export default function BecomeFilmerPage() {
)}
</div>
{/* Platform fee gate — only when a rate is set */}
{rate.trim() && !feePaid && !isPlatformOwner && (
<PlatformFeePayment
type="labProfile"
onSuccess={() => { /* hook auto-updates */ }}
context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
/>
)}
<Button
type="submit"
className="w-full bg-red-500 hover:bg-red-600 uppercase font-black"
disabled={isPublishing || isUploading || !hasWallet}
disabled={isPublishing || isUploading || !hasWallet || (!!rate.trim() && !feePaid && !isPlatformOwner)}
>
{isPublishing ? (
<>
@ -458,6 +472,11 @@ export default function BecomeFilmerPage() {
<Wallet className="h-4 w-4 mr-2" />
CONNECT WALLET TO CONTINUE
</>
) : rate.trim() && !feePaid && !isPlatformOwner ? (
<>
<Zap className="h-4 w-4 mr-2" />
PAY LISTING FEE ABOVE TO CONTINUE
</>
) : (
<>
<Video className="h-4 w-4 mr-2" />

View file

@ -34,7 +34,10 @@ import {
Skull,
Globe,
Map,
Zap,
} from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { Link } from 'react-router-dom';
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
@ -82,6 +85,12 @@ export default function CreateEventPage() {
const [showSpotSelector, setShowSpotSelector] = useState(false);
const [spotSearchQuery, setSpotSearchQuery] = useState('');
// 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;
// Get the selected tour (if any)
const selectedTour = tours?.find((t) => `${t.pubkey}:${t.identifier}` === linkedTourId);
@ -287,6 +296,12 @@ export default function CreateEventPage() {
tags.push(['tour-category', selectedTour.category]);
}
// Entry fee tag
if (isPaidEvent) {
tags.push(['entry-fee', entryFee.trim()]);
tags.push(['t', 'paid']);
}
// Add spot link if selected
if (selectedSpot && linkedSpotId !== 'none') {
// Use 'a' tag to reference the spot (addressable event reference)
@ -862,17 +877,52 @@ export default function CreateEventPage() {
</CollapsibleContent>
</Collapsible>
{/* Entry fee (optional) */}
<div className="space-y-2">
<Label htmlFor="entryFee" className="flex items-center gap-2">
<Zap className="h-4 w-4 text-[#ff6600]" />
Entry Fee
<span className="text-xs text-gray-500 font-normal">(optional — e.g. "500 sats" or "£10")</span>
</Label>
<Input
id="entryFee"
placeholder="Leave blank for free events"
value={entryFee}
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.`}
/>
)}
{/* Submit */}
<Button
type="submit"
className="w-full"
disabled={isPublishing || isUploading}
disabled={isPublishing || isUploading || !canSubmit}
>
{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" />