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:
parent
d8e49e7f65
commit
a74225fe0d
8 changed files with 484 additions and 28 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
232
src/components/PlatformFeePayment.tsx
Normal file
232
src/components/PlatformFeePayment.tsx
Normal 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
106
src/hooks/usePlatformFee.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue