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",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.61",
|
"version": "1.0.62",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"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
|
NEW: usePlatformFee hook — central fee configuration
|
||||||
- Search bar is now a slim rounded pill (not a tall boxy input)
|
- PLATFORM_FEES.featuredEvent = 5,000 sats (paid/ticketed events)
|
||||||
- View toggle (Map/List) is a compact pill with icon-only buttons
|
- PLATFORM_FEES.labProfile = 5,000 sats (coaches/filmers/editors with a rate)
|
||||||
- Filter button is a small circle that turns red when filters are active
|
- PLATFORM_FEES.featuredShop = 10,000 sats (reserved for future use)
|
||||||
- No more generic-looking default form controls
|
- 30-day validity stored in localStorage per fee type
|
||||||
|
- Platform owner pubkeys always bypass fees automatically
|
||||||
|
|
||||||
REDESIGNED: Right-side control strip
|
NEW: PlatformFeePayment component — generic, reusable payment gate
|
||||||
- Layer toggles (Spots/Shops/Gear/Tours) are now small 36px circles — active = opaque, inactive = 35% opacity
|
- Auto-pay via connected NWC or WebLN wallet
|
||||||
- Near Me button moved into the right strip (was floating top-right separately)
|
- QR code fallback for manual Lightning payment
|
||||||
- Quick Snap camera button matches the same strip style
|
- Shows "Active — X days remaining" when fee is already paid
|
||||||
- No more giant colored emoji circles
|
- Branded gradient UI consistent with SkateSpots design system
|
||||||
|
- All fees go to skatespots@minibits.cash
|
||||||
|
|
||||||
REDESIGNED: Add Spot FAB (bottom-left)
|
FEATURE: CreateEventPage — entry fee field
|
||||||
- Single gradient pill button (red→orange), replaces the two separate large circles
|
- New "Entry Fee" input field (optional)
|
||||||
- Cleaner, less cluttered
|
- 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
|
FEATURE: BecomeCoachPage — monetized profile fee
|
||||||
- Replaced the center-screen "Loading..." pill with a slim animated gradient bar at the top edge of the map
|
- 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
|
FEATURE: BecomeFilmerPage — same as coach
|
||||||
- Moved to bottom-left corner, minimal and unobtrusive
|
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 { SpotPicker } from '@/components/SpotPicker';
|
||||||
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS } from '@/lib/types';
|
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS } from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
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);
|
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
|
||||||
|
|
||||||
|
|
@ -41,6 +43,9 @@ export default function BecomeCoachPage() {
|
||||||
// Check if user has any wallet connected
|
// Check if user has any wallet connected
|
||||||
const hasWallet = !!webln || !!activeNWC;
|
const hasWallet = !!webln || !!activeNWC;
|
||||||
|
|
||||||
|
// Platform listing fee — required when a rate is set
|
||||||
|
const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
|
||||||
|
|
||||||
const [bio, setBio] = useState('');
|
const [bio, setBio] = useState('');
|
||||||
const [location, setLocation] = useState('');
|
const [location, setLocation] = useState('');
|
||||||
const [latitude, setLatitude] = useState('');
|
const [latitude, setLatitude] = useState('');
|
||||||
|
|
@ -407,10 +412,19 @@ export default function BecomeCoachPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-red-500 hover:bg-red-600 uppercase font-black"
|
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 ? (
|
{isPublishing ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -422,6 +436,11 @@ export default function BecomeCoachPage() {
|
||||||
<Wallet className="h-4 w-4 mr-2" />
|
<Wallet className="h-4 w-4 mr-2" />
|
||||||
CONNECT WALLET TO CONTINUE
|
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" />
|
<GraduationCap className="h-4 w-4 mr-2" />
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ import {
|
||||||
EDITOR_SKILL_DESCRIPTIONS,
|
EDITOR_SKILL_DESCRIPTIONS,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
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 SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
|
||||||
const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
|
const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
|
||||||
|
|
@ -46,6 +48,9 @@ export default function BecomeEditorPage() {
|
||||||
|
|
||||||
const hasWallet = !!webln || !!activeNWC;
|
const hasWallet = !!webln || !!activeNWC;
|
||||||
|
|
||||||
|
// Platform listing fee — required when a rate is set
|
||||||
|
const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
|
||||||
|
|
||||||
const [bio, setBio] = useState('');
|
const [bio, setBio] = useState('');
|
||||||
const [location, setLocation] = useState('');
|
const [location, setLocation] = useState('');
|
||||||
const [latitude, setLatitude] = useState('');
|
const [latitude, setLatitude] = useState('');
|
||||||
|
|
@ -391,17 +396,33 @@ export default function BecomeEditorPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<div className="mt-6 flex gap-3">
|
||||||
<Link to="/lab" className="flex-1">
|
<Link to="/lab" className="flex-1">
|
||||||
<Button type="button" variant="outline" className="w-full border-gray-700">Cancel</Button>
|
<Button type="button" variant="outline" className="w-full border-gray-700">Cancel</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
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"
|
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 ? (
|
||||||
{isPublishing ? 'Publishing…' : 'Publish Editor Profile'}
|
<><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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { SpotPicker } from '@/components/SpotPicker';
|
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 { 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 { 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 SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
|
||||||
const SKILL_ENTRIES = Object.entries(FILMING_SKILL_LABELS);
|
const SKILL_ENTRIES = Object.entries(FILMING_SKILL_LABELS);
|
||||||
|
|
@ -42,6 +44,9 @@ export default function BecomeFilmerPage() {
|
||||||
// Check if user has any wallet connected
|
// Check if user has any wallet connected
|
||||||
const hasWallet = !!webln || !!activeNWC;
|
const hasWallet = !!webln || !!activeNWC;
|
||||||
|
|
||||||
|
// Platform listing fee — required when a rate is set
|
||||||
|
const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
|
||||||
|
|
||||||
const [bio, setBio] = useState('');
|
const [bio, setBio] = useState('');
|
||||||
const [location, setLocation] = useState('');
|
const [location, setLocation] = useState('');
|
||||||
const [latitude, setLatitude] = useState('');
|
const [latitude, setLatitude] = useState('');
|
||||||
|
|
@ -443,10 +448,19 @@ export default function BecomeFilmerPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full bg-red-500 hover:bg-red-600 uppercase font-black"
|
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 ? (
|
{isPublishing ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -458,6 +472,11 @@ export default function BecomeFilmerPage() {
|
||||||
<Wallet className="h-4 w-4 mr-2" />
|
<Wallet className="h-4 w-4 mr-2" />
|
||||||
CONNECT WALLET TO CONTINUE
|
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" />
|
<Video className="h-4 w-4 mr-2" />
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,10 @@ import {
|
||||||
Skull,
|
Skull,
|
||||||
Globe,
|
Globe,
|
||||||
Map,
|
Map,
|
||||||
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||||
|
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
|
const EVENT_TYPES = Object.entries(EVENT_TYPE_LABELS);
|
||||||
|
|
@ -82,6 +85,12 @@ export default function CreateEventPage() {
|
||||||
const [showSpotSelector, setShowSpotSelector] = useState(false);
|
const [showSpotSelector, setShowSpotSelector] = useState(false);
|
||||||
const [spotSearchQuery, setSpotSearchQuery] = useState('');
|
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)
|
// Get the selected tour (if any)
|
||||||
const selectedTour = tours?.find((t) => `${t.pubkey}:${t.identifier}` === linkedTourId);
|
const selectedTour = tours?.find((t) => `${t.pubkey}:${t.identifier}` === linkedTourId);
|
||||||
|
|
||||||
|
|
@ -287,6 +296,12 @@ export default function CreateEventPage() {
|
||||||
tags.push(['tour-category', selectedTour.category]);
|
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
|
// Add spot link if selected
|
||||||
if (selectedSpot && linkedSpotId !== 'none') {
|
if (selectedSpot && linkedSpotId !== 'none') {
|
||||||
// Use 'a' tag to reference the spot (addressable event reference)
|
// Use 'a' tag to reference the spot (addressable event reference)
|
||||||
|
|
@ -862,17 +877,52 @@ export default function CreateEventPage() {
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</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 */}
|
{/* Submit */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isPublishing || isUploading}
|
disabled={isPublishing || isUploading || !canSubmit}
|
||||||
>
|
>
|
||||||
{isPublishing ? (
|
{isPublishing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
{t('createEvent.publishing')}
|
{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" />
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue