From a74225fe0d019e043f3d37543165f8ff8279c523 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 02:19:45 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20platform=20monetization=20=E2=80=94=20l?= =?UTF-8?q?isting=20fees=20for=20paid=20events=20&=20Lab=20profiles=20v1.0?= =?UTF-8?q?.62?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- release-notes.txt | 45 +++-- src/components/PlatformFeePayment.tsx | 232 ++++++++++++++++++++++++++ src/hooks/usePlatformFee.ts | 106 ++++++++++++ src/pages/BecomeCoachPage.tsx | 23 ++- src/pages/BecomeEditorPage.tsx | 29 +++- src/pages/BecomeFilmerPage.tsx | 23 ++- src/pages/CreateEventPage.tsx | 52 +++++- 8 files changed, 484 insertions(+), 28 deletions(-) create mode 100644 src/components/PlatformFeePayment.tsx create mode 100644 src/hooks/usePlatformFee.ts diff --git a/package.json b/package.json index 9520612..934b661 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/release-notes.txt b/release-notes.txt index 29fdb48..772b7cb 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -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. diff --git a/src/components/PlatformFeePayment.tsx b/src/components/PlatformFeePayment.tsx new file mode 100644 index 0000000..0f24115 --- /dev/null +++ b/src/components/PlatformFeePayment.tsx @@ -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('idle'); + const [invoice, setInvoice] = useState(null); + const [qrCodeUrl, setQrCodeUrl] = useState(null); + const [error, setError] = useState(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 ( +
+ +
+

{meta.label} — Active

+

{daysRemaining} day{daysRemaining !== 1 ? 's' : ''} remaining

+
+
+ ); + } + + 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 ( +
+ +
+

{meta.emoji} {meta.label} Active!

+

{meta.benefit}

+
+
+ ); + } + + // ── Main payment UI ── + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{meta.emoji} {meta.label}

+

{meta.description}

+
+
+
+ +
+ {/* What you get */} +
+
+ + {meta.benefit} +
+
+ + Valid for {meta.duration} +
+
+ + {context && ( +

{context}

+ )} + + {/* Amount */} +
+

⚡ {amountSats.toLocaleString()} sats

+

+ ~${(amountSats * 0.0006).toFixed(2)} USD · to {recipientName} · {meta.duration} +

+
+ + {/* Actions */} + {paymentState === 'idle' && ( + + )} + + {(paymentState === 'generating' || paymentState === 'paying') && ( +
+ +

+ {paymentState === 'generating' ? 'Generating invoice…' : 'Processing payment…'} +

+
+ )} + + {paymentState === 'waiting' && invoice && qrCodeUrl && ( +
+
+
+ Lightning invoice QR +
+
+
+ + +
+ {(webln || activeNWC) && ( + + )} +

Scan with any Lightning wallet

+
+ )} + + {paymentState === 'error' && ( +
+

{error}

+ +
+ )} +
+
+ ); +} diff --git a/src/hooks/usePlatformFee.ts b/src/hooks/usePlatformFee.ts new file mode 100644 index 0000000..bc50737 --- /dev/null +++ b/src/hooks/usePlatformFee.ts @@ -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 = { + 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(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, + }; +} diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index 54a09db..b95d838 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -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() { )} + {/* Platform fee gate — only when a rate is set */} + {rate.trim() && !feePaid && !isPlatformOwner && ( + { /* hook auto-updates */ }} + context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`} + /> + )} + diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index b6f7e87..df2f69c 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -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() { )} + {/* Platform fee gate — only when a rate is set */} + {rate.trim() && !feePaid && !isPlatformOwner && ( + { /* hook auto-updates */ }} + context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`} + /> + )} +