Initial commit

This commit is contained in:
Michilis
2025-12-14 23:08:45 -03:00
commit 1e1753dff3
58 changed files with 18294 additions and 0 deletions

22
frontend/env.example Normal file
View File

@@ -0,0 +1,22 @@
# ===========================================
# LNPaywall Frontend Environment Configuration
# ===========================================
# API Configuration
VITE_API_URL=http://localhost:3001/api
VITE_APP_URL=http://localhost:5173
# App Configuration
VITE_APP_NAME=LNPaywall
VITE_APP_DESCRIPTION="Turn any link into paid access in 60 seconds"
# Feature Flags (loaded dynamically from backend /api/config)
VITE_ENABLE_NOSTR_LOGIN=true
VITE_ENABLE_OAUTH=false
# Note: Platform fees and Pro pricing are fetched from the backend automatically
# Analytics (Optional)
VITE_PLAUSIBLE_DOMAIN=
VITE_UMAMI_ID=

24
frontend/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/lightning.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="LNPaywall - Turn any link into paid access in 60 seconds. Accept Lightning payments instantly." />
<meta name="theme-color" content="#0f172a" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<title>LNPaywall - Turn any link into paid access</title>
</head>
<body class="bg-dark-950 text-white antialiased">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

6166
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "lnpaywall-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.1.1",
"axios": "^1.6.2",
"chart.js": "^4.4.1",
"date-fns": "^3.0.6",
"framer-motion": "^10.17.0",
"qrcode": "^1.5.3",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.21.1",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^5.0.10"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#f7931a">
<path d="M13 2L3 14h8l-1 8 10-12h-8l1-8z"/>
</svg>

After

Width:  |  Height:  |  Size: 130 B

85
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,85 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useEffect } from 'react'
import { useAuthStore } from './store/authStore'
// Layouts
import MainLayout from './components/layouts/MainLayout'
import DashboardLayout from './components/layouts/DashboardLayout'
// Public pages
import Landing from './pages/Landing'
import Login from './pages/auth/Login'
import Signup from './pages/auth/Signup'
import PaywallPage from './pages/PaywallPage'
// Dashboard pages
import Dashboard from './pages/dashboard/Dashboard'
import Paywalls from './pages/dashboard/Paywalls'
import CreatePaywall from './pages/dashboard/CreatePaywall'
import PaywallDetail from './pages/dashboard/PaywallDetail'
import Sales from './pages/dashboard/Sales'
import Embeds from './pages/dashboard/Embeds'
import Settings from './pages/dashboard/Settings'
import ProSubscription from './pages/dashboard/ProSubscription'
// Protected Route Component
function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading, checkAuth } = useAuthStore()
useEffect(() => {
checkAuth()
}, [checkAuth])
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-dark-950">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-lightning"></div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return children
}
export default function App() {
return (
<Routes>
{/* Public routes */}
<Route element={<MainLayout />}>
<Route path="/" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
</Route>
{/* Paywall page (public) */}
<Route path="/p/:slugOrId" element={<PaywallPage />} />
{/* Dashboard routes (protected) */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardLayout />
</ProtectedRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="paywalls" element={<Paywalls />} />
<Route path="paywalls/new" element={<CreatePaywall />} />
<Route path="paywalls/:id" element={<PaywallDetail />} />
<Route path="sales" element={<Sales />} />
<Route path="embeds" element={<Embeds />} />
<Route path="settings" element={<Settings />} />
<Route path="pro" element={<ProSubscription />} />
</Route>
{/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,188 @@
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useState } from 'react'
import { useAuthStore } from '../../store/authStore'
import {
HomeIcon,
RectangleStackIcon,
CurrencyDollarIcon,
CodeBracketIcon,
Cog6ToothIcon,
Bars3Icon,
XMarkIcon,
ArrowRightOnRectangleIcon,
PlusIcon,
SparklesIcon,
} from '@heroicons/react/24/outline'
const navigation = [
{ name: 'Overview', href: '/dashboard', icon: HomeIcon },
{ name: 'Paywalls', href: '/dashboard/paywalls', icon: RectangleStackIcon },
{ name: 'Sales', href: '/dashboard/sales', icon: CurrencyDollarIcon },
{ name: 'Embeds', href: '/dashboard/embeds', icon: CodeBracketIcon },
{ name: 'Settings', href: '/dashboard/settings', icon: Cog6ToothIcon },
]
export default function DashboardLayout() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [sidebarOpen, setSidebarOpen] = useState(false)
const handleLogout = async () => {
await logout()
navigate('/login')
}
const isActive = (href) => {
if (href === '/dashboard') {
return location.pathname === '/dashboard'
}
return location.pathname.startsWith(href)
}
return (
<div className="min-h-screen bg-dark-950">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-64 bg-dark-900 border-r border-dark-800 transform transition-transform duration-200 lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center justify-between h-16 px-4 border-b border-dark-800">
<Link to="/dashboard" className="flex items-center gap-2">
<span className="text-2xl"></span>
<span className="font-display font-bold text-lg">LNPaywall</span>
</Link>
<button
onClick={() => setSidebarOpen(false)}
className="lg:hidden p-2 text-dark-400 hover:text-white"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
{/* Create button */}
<div className="p-4">
<Link
to="/dashboard/paywalls/new"
className="btn btn-primary w-full"
>
<PlusIcon className="w-5 h-5" />
Create Paywall
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 space-y-1">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
isActive(item.href)
? 'bg-lightning/10 text-lightning'
: 'text-dark-400 hover:text-white hover:bg-dark-800'
}`}
>
<item.icon className="w-5 h-5" />
{item.name}
</Link>
))}
</nav>
{/* Pro upgrade */}
{!user?.isPro && (
<div className="p-4">
<Link
to="/dashboard/pro"
onClick={() => setSidebarOpen(false)}
className="block p-4 bg-gradient-to-r from-lightning/10 to-orange-500/10 border border-lightning/30 rounded-xl hover:border-lightning/50 transition-colors"
>
<div className="flex items-center gap-2 mb-1">
<SparklesIcon className="w-4 h-4 text-lightning" />
<span className="text-sm font-medium text-lightning">Upgrade to Pro</span>
</div>
<p className="text-xs text-dark-400">0% fees Custom branding</p>
</Link>
</div>
)}
{/* User section */}
<div className="p-4 border-t border-dark-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-lightning to-orange-500 flex items-center justify-center text-white font-bold">
{user?.displayName?.[0]?.toUpperCase() || 'U'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user?.displayName}
{user?.isPro && (
<span className="ml-2 text-xs px-1.5 py-0.5 bg-lightning/10 text-lightning rounded">PRO</span>
)}
</p>
<p className="text-xs text-dark-400 truncate">{user?.email || user?.nostrPubkey?.slice(0, 16) + '...'}</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-dark-400 hover:text-white hover:bg-dark-800 rounded-xl transition-colors"
>
<ArrowRightOnRectangleIcon className="w-5 h-5" />
Log out
</button>
</div>
</div>
</aside>
{/* Main content */}
<div className="lg:pl-64">
{/* Top bar */}
<header className="sticky top-0 z-30 h-16 bg-dark-950/80 backdrop-blur-xl border-b border-dark-800/50">
<div className="flex items-center justify-between h-full px-4 lg:px-8">
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden p-2 text-dark-400 hover:text-white"
>
<Bars3Icon className="w-6 h-6" />
</button>
<div className="flex-1" />
<Link
to="/dashboard/paywalls/new"
className="btn btn-primary hidden sm:flex"
>
<PlusIcon className="w-5 h-5" />
Create Paywall
</Link>
</div>
</header>
{/* Page content */}
<motion.main
key={location.pathname}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="p-4 lg:p-8"
>
<Outlet />
</motion.main>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { Outlet, Link, useLocation } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useAuthStore } from '../../store/authStore'
export default function MainLayout() {
const location = useLocation()
const { isAuthenticated } = useAuthStore()
return (
<div className="min-h-screen bg-dark-950">
{/* Navigation */}
<nav className="fixed top-0 left-0 right-0 z-50 bg-dark-950/80 backdrop-blur-xl border-b border-dark-800/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center gap-2">
<span className="text-2xl"></span>
<span className="font-display font-bold text-xl">LNPaywall</span>
</Link>
{/* Navigation Links */}
<div className="hidden md:flex items-center gap-8">
<a href="#features" className="text-dark-300 hover:text-white transition-colors">
Features
</a>
<a href="#pricing" className="text-dark-300 hover:text-white transition-colors">
Pricing
</a>
<a href="#faq" className="text-dark-300 hover:text-white transition-colors">
FAQ
</a>
</div>
{/* Auth buttons */}
<div className="flex items-center gap-4">
{isAuthenticated ? (
<Link to="/dashboard" className="btn btn-primary">
Dashboard
</Link>
) : (
<>
<Link
to="/login"
className="text-dark-300 hover:text-white transition-colors"
>
Log in
</Link>
<Link to="/signup" className="btn btn-primary">
Get Started
</Link>
</>
)}
</div>
</div>
</div>
</nav>
{/* Main content */}
<motion.main
key={location.pathname}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="pt-16"
>
<Outlet />
</motion.main>
{/* Footer */}
<footer className="border-t border-dark-800 bg-dark-950">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="col-span-1 md:col-span-2">
<div className="flex items-center gap-2 mb-4">
<span className="text-2xl"></span>
<span className="font-display font-bold text-xl">LNPaywall</span>
</div>
<p className="text-dark-400 max-w-md">
Turn any link into paid access in 60 seconds. Accept Lightning payments instantly with no platform lock-in.
</p>
</div>
<div>
<h4 className="font-semibold mb-4">Product</h4>
<ul className="space-y-2 text-dark-400">
<li><a href="#features" className="hover:text-white transition-colors">Features</a></li>
<li><a href="#pricing" className="hover:text-white transition-colors">Pricing</a></li>
<li><Link to="/dashboard/embeds" className="hover:text-white transition-colors">Embed Docs</Link></li>
</ul>
</div>
<div>
<h4 className="font-semibold mb-4">Legal</h4>
<ul className="space-y-2 text-dark-400">
<li><a href="#" className="hover:text-white transition-colors">Privacy Policy</a></li>
<li><a href="#" className="hover:text-white transition-colors">Terms of Service</a></li>
</ul>
</div>
</div>
<div className="mt-12 pt-8 border-t border-dark-800 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-dark-500 text-sm">
© {new Date().getFullYear()} LNPaywall. All rights reserved.
</p>
<p className="text-dark-500 text-sm flex items-center gap-2">
Built with for the Bitcoin ecosystem
</p>
</div>
</div>
</footer>
</div>
)
}

38
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,38 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
<Toaster
position="bottom-right"
toastOptions={{
duration: 4000,
style: {
background: '#1e293b',
color: '#fff',
border: '1px solid rgba(255,255,255,0.1)',
},
success: {
iconTheme: {
primary: '#22c55e',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,366 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
BoltIcon,
CubeTransparentIcon,
CurrencyDollarIcon,
LockClosedIcon,
RocketLaunchIcon,
ClockIcon,
} from '@heroicons/react/24/outline'
import { configApi } from '../services/api'
const getFeatures = (feePercent) => [
{
icon: ClockIcon,
title: '60 Second Setup',
description: 'Paste a link, set your price, and start accepting payments instantly. No complex configuration.',
},
{
icon: BoltIcon,
title: 'Lightning Payments',
description: 'Receive payments in seconds with Bitcoin Lightning Network. Low fees, instant settlement.',
},
{
icon: CubeTransparentIcon,
title: 'Embed Anywhere',
description: 'Works with Webflow, WordPress, Framer, Notion, or any website. Simple copy-paste integration.',
},
{
icon: LockClosedIcon,
title: 'No Custody',
description: 'Your funds go directly to your wallet. We never hold or control your money.',
},
{
icon: RocketLaunchIcon,
title: 'Works with Any Link',
description: 'Notion, Google Docs, PDFs, Loom videos, private pages, unlisted YouTube—anything with a URL.',
},
{
icon: CurrencyDollarIcon,
title: 'Low Fees',
description: `${feePercent}% platform fee on the free plan. Pro users pay 0% fees!`,
},
]
const useCases = [
{ name: 'Notion', icon: '📝' },
{ name: 'Google Docs', icon: '📄' },
{ name: 'PDF Files', icon: '📕' },
{ name: 'Loom Videos', icon: '🎥' },
{ name: 'YouTube (Unlisted)', icon: '▶️' },
{ name: 'GitHub Repos', icon: '💻' },
]
export default function Landing() {
const [config, setConfig] = useState({ platformFeePercent: 10, proPriceSats: 50000 })
const features = getFeatures(config.platformFeePercent)
useEffect(() => {
configApi.get().then(res => setConfig(res.data)).catch(() => {})
}, [])
return (
<div className="overflow-hidden">
{/* Hero Section */}
<section className="relative min-h-screen flex items-center justify-center bg-dark-950">
{/* Background effects */}
<div className="absolute inset-0 bg-grid opacity-30" />
<div className="absolute inset-0 bg-gradient-radial from-lightning/10 via-transparent to-transparent" />
{/* Floating elements */}
<motion.div
animate={{ y: [0, -20, 0] }}
transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-1/4 left-1/4 w-64 h-64 bg-lightning/10 rounded-full blur-3xl"
/>
<motion.div
animate={{ y: [0, 20, 0] }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl"
/>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-32 text-center">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 bg-dark-800/50 border border-dark-700 rounded-full text-sm mb-8">
<span className="text-lightning"></span>
<span className="text-dark-300">Powered by Bitcoin Lightning</span>
</div>
{/* Headline */}
<h1 className="text-4xl sm:text-5xl md:text-7xl font-display font-bold mb-6 leading-tight">
Turn any link into
<br />
<span className="gradient-text">paid access</span> in 60s
</h1>
{/* Subheadline */}
<p className="text-lg sm:text-xl text-dark-300 max-w-2xl mx-auto mb-12 text-balance">
No uploads. No platform lock-in. Paste a link, set a price, share or embed, get paid.
Works with Notion, Google Docs, PDFs, videos, and any URL.
</p>
{/* CTA buttons */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link to="/signup" className="btn btn-primary text-lg px-8 py-4">
<BoltIcon className="w-5 h-5" />
Create a Paywall
</Link>
<a href="#demo" className="btn btn-secondary text-lg px-8 py-4">
See Demo
</a>
</div>
{/* Trust line */}
<p className="mt-8 text-sm text-dark-500">
No credit card required Start selling in minutes
</p>
</motion.div>
{/* Demo preview */}
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="mt-20"
id="demo"
>
<div className="relative max-w-md mx-auto">
{/* Mock paywall card */}
<div className="card p-0 overflow-hidden shadow-2xl shadow-black/50">
<div className="h-40 bg-gradient-to-br from-purple-600 to-pink-500" />
<div className="p-6">
<h3 className="text-xl font-bold mb-2">Complete Bitcoin Course</h3>
<p className="text-dark-400 text-sm mb-4">
Learn to build on Bitcoin and Lightning Network from scratch.
</p>
<div className="flex items-center gap-2 text-2xl font-bold text-lightning mb-4">
<span></span>
<span>5,000 sats</span>
<span className="text-sm font-normal text-dark-500"> $2.50</span>
</div>
<button className="btn btn-primary w-full">
<LockClosedIcon className="w-5 h-5" />
Unlock Content
</button>
</div>
</div>
{/* Glow effect */}
<div className="absolute inset-0 -z-10 blur-3xl opacity-30 bg-gradient-to-br from-lightning to-purple-500" />
</div>
</motion.div>
</div>
</section>
{/* Use Cases */}
<section className="py-24 bg-dark-900/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-display font-bold mb-4">
Works with content you already have
</h2>
<p className="text-dark-400 text-lg">
No need to migrate or upload. Keep using your favorite tools.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{useCases.map((useCase, index) => (
<motion.div
key={useCase.name}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
viewport={{ once: true }}
className="card-hover p-6 text-center"
>
<span className="text-4xl mb-3 block">{useCase.icon}</span>
<span className="text-sm font-medium">{useCase.name}</span>
</motion.div>
))}
</div>
</div>
</section>
{/* Features */}
<section className="py-24" id="features">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-display font-bold mb-4">
Everything you need to monetize
</h2>
<p className="text-dark-400 text-lg max-w-2xl mx-auto">
Simple tools that let you focus on creating great content while we handle the payments.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
viewport={{ once: true }}
className="card-hover p-8"
>
<div className="w-12 h-12 rounded-xl bg-lightning/10 text-lightning flex items-center justify-center mb-4">
<feature.icon className="w-6 h-6" />
</div>
<h3 className="text-xl font-bold mb-2">{feature.title}</h3>
<p className="text-dark-400">{feature.description}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* Pricing */}
<section className="py-24 bg-dark-900/50" id="pricing">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-display font-bold mb-4">
Simple, transparent pricing
</h2>
<p className="text-dark-400 text-lg">
Start free, upgrade when you're ready.
</p>
</div>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{/* Free tier */}
<div className="card p-8">
<h3 className="text-xl font-bold mb-2">Free</h3>
<p className="text-dark-400 mb-6">Perfect for getting started</p>
<div className="text-4xl font-bold mb-6">
{config.platformFeePercent}%
<span className="text-lg font-normal text-dark-400"> per sale</span>
</div>
<ul className="space-y-3 mb-8 text-dark-300">
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> Unlimited paywalls
</li>
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> Embed anywhere
</li>
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> Lightning payments
</li>
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> Sales dashboard
</li>
</ul>
<Link to="/signup" className="btn btn-secondary w-full">
Get Started
</Link>
</div>
{/* Pro tier */}
<div className="card p-8 border-lightning/50 relative">
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-4 py-1 bg-lightning text-dark-950 text-sm font-bold rounded-full">
BEST VALUE
</div>
<h3 className="text-xl font-bold mb-2">Pro</h3>
<p className="text-dark-400 mb-6">For serious creators</p>
<div className="text-4xl font-bold mb-1">
⚡ {config.proPriceSats?.toLocaleString() || '50,000'}
</div>
<p className="text-dark-400 mb-6">sats/month • 0% fees</p>
<ul className="space-y-3 mb-8 text-dark-300">
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> Everything in Free
</li>
<li className="flex items-center gap-2">
<span className="text-lightning font-bold">✓</span> 0% platform fee
</li>
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> Custom branding
</li>
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> Priority support
</li>
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> Detailed analytics
</li>
</ul>
<Link to="/signup" className="btn btn-primary w-full">
Get Started
</Link>
</div>
</div>
</div>
</section>
{/* FAQ */}
<section className="py-24" id="faq">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-display font-bold mb-4">
Frequently Asked Questions
</h2>
</div>
<div className="space-y-6">
{[
{
q: 'How do I receive payments?',
a: 'Payments are sent directly to your Lightning wallet or Lightning Address. We never custody your funds.',
},
{
q: 'What content can I sell?',
a: 'Any content with a URL! Notion pages, Google Docs, PDFs, unlisted YouTube videos, private web pages, GitHub repos, and more.',
},
{
q: 'Do buyers need a Bitcoin wallet?',
a: 'Yes, buyers pay with Lightning. Most wallets like Wallet of Satoshi, Phoenix, or Strike make this very easy.',
},
{
q: 'Can I embed the paywall on my website?',
a: 'Yes! We provide iframe embeds and a JavaScript button that works on any website.',
},
{
q: 'How long does access last?',
a: "You control this. Set access to never expire, or expire after 24 hours, 7 days, 30 days, or any custom duration.",
},
].map((faq, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="card p-6"
>
<h3 className="font-bold mb-2">{faq.q}</h3>
<p className="text-dark-400">{faq.a}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-24 bg-dark-900/50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl sm:text-4xl font-display font-bold mb-4">
Ready to start earning?
</h2>
<p className="text-dark-400 text-lg mb-8">
Create your first paywall in under 60 seconds.
</p>
<Link to="/signup" className="btn btn-primary text-lg px-8 py-4">
<BoltIcon className="w-5 h-5" />
Create Your First Paywall
</Link>
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,285 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { motion } from 'framer-motion'
import { QRCodeSVG } from 'qrcode.react'
import toast from 'react-hot-toast'
import { LockClosedIcon, CheckCircleIcon, ClipboardIcon } from '@heroicons/react/24/outline'
import { publicApi, checkoutApi, accessApi } from '../services/api'
export default function PaywallPage() {
const { slugOrId } = useParams()
const [paywall, setPaywall] = useState(null)
const [hasAccess, setHasAccess] = useState(false)
const [accessInfo, setAccessInfo] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [checkoutSession, setCheckoutSession] = useState(null)
const [isCheckingPayment, setIsCheckingPayment] = useState(false)
const [timeLeft, setTimeLeft] = useState(null)
// Fetch paywall data
useEffect(() => {
const fetchPaywall = async () => {
try {
const response = await publicApi.getPaywall(slugOrId)
setPaywall(response.data.paywall)
setHasAccess(response.data.hasAccess)
setAccessInfo(response.data.accessInfo)
} catch (error) {
console.error('Error fetching paywall:', error)
toast.error('Failed to load content')
} finally {
setIsLoading(false)
}
}
fetchPaywall()
}, [slugOrId])
// Poll for payment status
useEffect(() => {
let interval
if (checkoutSession && !hasAccess) {
setIsCheckingPayment(true)
interval = setInterval(async () => {
try {
const response = await checkoutApi.getStatus(checkoutSession.sessionId)
if (response.data.status === 'PAID') {
clearInterval(interval)
setHasAccess(true)
setAccessInfo({
originalUrl: response.data.originalUrl,
})
setIsCheckingPayment(false)
toast.success('Payment successful! 🎉')
} else if (response.data.status === 'EXPIRED') {
clearInterval(interval)
setCheckoutSession(null)
setIsCheckingPayment(false)
toast.error('Invoice expired. Please try again.')
}
} catch (error) {
console.error('Error checking payment:', error)
}
}, 2000)
}
return () => {
if (interval) clearInterval(interval)
}
}, [checkoutSession, hasAccess])
// Countdown timer
useEffect(() => {
let interval
if (checkoutSession?.expiresAt) {
interval = setInterval(() => {
const remaining = Math.max(0, Math.floor((new Date(checkoutSession.expiresAt) - Date.now()) / 1000))
setTimeLeft(remaining)
if (remaining <= 0) {
clearInterval(interval)
}
}, 1000)
}
return () => clearInterval(interval)
}, [checkoutSession])
const handleUnlock = async () => {
try {
const response = await checkoutApi.create(paywall.id, {})
setCheckoutSession(response.data)
} catch (error) {
console.error('Error creating checkout:', error)
toast.error('Failed to create checkout')
}
}
const handleOpenContent = () => {
if (accessInfo?.originalUrl) {
window.open(accessInfo.originalUrl, '_blank')
}
}
const copyInvoice = () => {
if (checkoutSession?.paymentRequest) {
navigator.clipboard.writeText(checkoutSession.paymentRequest)
toast.success('Invoice copied!')
}
}
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-dark-950">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-lightning"></div>
</div>
)
}
if (!paywall) {
return (
<div className="min-h-screen flex items-center justify-center bg-dark-950">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Content Not Found</h1>
<p className="text-dark-400">This paywall doesn't exist or has been removed.</p>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-dark-950">
{/* Background effects */}
<div className="fixed inset-0 bg-grid opacity-20" />
<div className="fixed inset-0 bg-gradient-radial from-lightning/5 via-transparent to-transparent" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative w-full max-w-md"
>
<div className="card overflow-hidden">
{/* Cover image */}
{paywall.coverImageUrl ? (
<img
src={paywall.coverImageUrl}
alt=""
className="w-full h-48 object-cover"
/>
) : (
<div className="w-full h-48 bg-gradient-to-br from-purple-600 to-pink-500" />
)}
<div className="p-6">
{/* Creator */}
{paywall.creator && (
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-lightning to-orange-500 flex items-center justify-center text-xs font-bold">
{paywall.creator.displayName?.[0]?.toUpperCase() || 'C'}
</div>
<span className="text-sm text-dark-400">by {paywall.creator.displayName}</span>
</div>
)}
{/* Title & Description */}
<h1 className="text-2xl font-bold mb-2">{paywall.title}</h1>
{paywall.description && (
<p className="text-dark-400 mb-6">{paywall.description}</p>
)}
{/* Unlocked state */}
{hasAccess ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-6"
>
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
<CheckCircleIcon className="w-10 h-10 text-green-500" />
</div>
<h3 className="text-xl font-bold mb-2">Access Granted!</h3>
<p className="text-dark-400 mb-6">
{paywall.customSuccessMessage || 'You now have access to this content.'}
</p>
<button
onClick={handleOpenContent}
className="btn btn-primary w-full"
>
Open Content →
</button>
</motion.div>
) : checkoutSession ? (
// Checkout state
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-4"
>
{/* QR Code */}
<div className="bg-white p-4 rounded-xl inline-block mb-4">
<QRCodeSVG
value={checkoutSession.paymentRequest}
size={200}
level="M"
/>
</div>
{/* Timer */}
{timeLeft !== null && (
<p className="text-dark-400 mb-4">
Expires in <span className="font-mono font-bold text-white">{formatTime(timeLeft)}</span>
</p>
)}
{/* Invoice */}
<div className="bg-dark-800 rounded-xl p-4 mb-4">
<p className="text-xs font-mono text-dark-400 break-all line-clamp-2">
{checkoutSession.paymentRequest}
</p>
</div>
<button
onClick={copyInvoice}
className="btn btn-secondary w-full mb-4"
>
<ClipboardIcon className="w-5 h-5" />
Copy Invoice
</button>
{/* Loading indicator */}
{isCheckingPayment && (
<div className="flex items-center justify-center gap-2 text-dark-400">
<div className="w-4 h-4 border-2 border-dark-600 border-t-lightning rounded-full animate-spin" />
<span className="text-sm">Waiting for payment...</span>
</div>
)}
</motion.div>
) : (
// Locked state
<div>
{/* Price */}
<div className="flex items-center gap-2 text-3xl font-bold text-lightning mb-6">
<span>⚡</span>
<span>{paywall.priceSats.toLocaleString()}</span>
<span className="text-base font-normal text-dark-400">sats</span>
</div>
{/* Unlock button */}
<button
onClick={handleUnlock}
className="btn btn-primary w-full text-lg py-4"
>
<LockClosedIcon className="w-5 h-5" />
Unlock Content
</button>
{/* Trust indicators */}
<div className="flex items-center justify-center gap-4 mt-6 text-xs text-dark-500">
<span className="flex items-center gap-1">
<span>⚡</span> Instant access
</span>
<span className="flex items-center gap-1">
<span>🔒</span> Secure payment
</span>
</div>
</div>
)}
</div>
</div>
{/* Powered by */}
<p className="text-center mt-6 text-sm text-dark-500">
Powered by{' '}
<a href="/" className="text-dark-400 hover:text-white">
LNPaywall
</a>
</p>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,211 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import { useAuthStore } from '../../store/authStore'
import { BoltIcon, EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
export default function Login() {
const navigate = useNavigate()
const { login, nostrLogin } = useAuthStore()
const [isLoading, setIsLoading] = useState(false)
const [isNostrLoading, setIsNostrLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [formData, setFormData] = useState({
email: '',
password: '',
})
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
try {
await login(formData.email, formData.password)
toast.success('Welcome back!')
navigate('/dashboard')
} catch (error) {
toast.error(error.response?.data?.error || 'Failed to log in')
} finally {
setIsLoading(false)
}
}
const handleNostrLogin = async () => {
if (!window.nostr) {
toast.error('No Nostr extension found. Please install Alby, nos2x, or another Nostr signer.')
return
}
setIsNostrLoading(true)
try {
// Get public key from extension
const pubkey = await window.nostr.getPublicKey()
// Create a login event (kind 27235 is NIP-98 HTTP Auth)
const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', window.location.origin + '/api/auth/nostr/verify'],
['method', 'POST'],
],
content: 'Login to LNPaywall',
pubkey,
}
// Sign the event
const signedEvent = await window.nostr.signEvent(event)
// Send to backend
await nostrLogin(pubkey, signedEvent)
toast.success('Welcome back!')
navigate('/dashboard')
} catch (error) {
console.error('Nostr login error:', error)
toast.error(error.response?.data?.error || error.message || 'Failed to login with Nostr')
} finally {
setIsNostrLoading(false)
}
}
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value })
}
return (
<div className="min-h-screen flex items-center justify-center px-4 py-16 bg-dark-950">
{/* Background effects */}
<div className="absolute inset-0 bg-grid opacity-20" />
<div className="absolute inset-0 bg-gradient-radial from-lightning/5 via-transparent to-transparent" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative w-full max-w-md"
>
{/* Logo */}
<div className="text-center mb-8">
<Link to="/" className="inline-flex items-center gap-2 mb-4">
<span className="text-3xl"></span>
<span className="font-display font-bold text-2xl">LNPaywall</span>
</Link>
<h1 className="text-2xl font-bold">Welcome back</h1>
<p className="text-dark-400 mt-2">Log in to your account</p>
</div>
{/* Form */}
<div className="card p-8">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="email" className="label">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
className="input"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="label">
Password
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
value={formData.password}
onChange={handleChange}
className="input pr-12"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-400 hover:text-white"
>
{showPassword ? (
<EyeSlashIcon className="w-5 h-5" />
) : (
<EyeIcon className="w-5 h-5" />
)}
</button>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="btn btn-primary w-full"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<BoltIcon className="w-5 h-5" />
Log in
</>
)}
</button>
</form>
{/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-dark-700" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-dark-900 text-dark-400">or continue with</span>
</div>
</div>
{/* Social logins */}
<div className="grid grid-cols-2 gap-4">
<button
type="button"
className="btn btn-secondary"
onClick={handleNostrLogin}
disabled={isNostrLoading}
>
{isNostrLoading ? (
<div className="w-5 h-5 border-2 border-purple-500/30 border-t-purple-500 rounded-full animate-spin" />
) : (
<span className="text-xl">🟣</span>
)}
Nostr
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => toast('GitHub login coming soon!')}
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
GitHub
</button>
</div>
</div>
{/* Sign up link */}
<p className="text-center mt-6 text-dark-400">
Don't have an account?{' '}
<Link to="/signup" className="text-lightning hover:underline">
Sign up
</Link>
</p>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,243 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import { useAuthStore } from '../../store/authStore'
import { BoltIcon, EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
export default function Signup() {
const navigate = useNavigate()
const { signup, nostrLogin } = useAuthStore()
const [isLoading, setIsLoading] = useState(false)
const [isNostrLoading, setIsNostrLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [formData, setFormData] = useState({
email: '',
password: '',
displayName: '',
})
const handleSubmit = async (e) => {
e.preventDefault()
if (formData.password.length < 8) {
toast.error('Password must be at least 8 characters')
return
}
setIsLoading(true)
try {
await signup(formData.email, formData.password, formData.displayName)
toast.success('Account created! Welcome to LNPaywall 🎉')
navigate('/dashboard')
} catch (error) {
toast.error(error.response?.data?.error || 'Failed to create account')
} finally {
setIsLoading(false)
}
}
const handleNostrSignup = async () => {
if (!window.nostr) {
toast.error('No Nostr extension found. Please install Alby, nos2x, or another Nostr signer.')
return
}
setIsNostrLoading(true)
try {
// Get public key from extension
const pubkey = await window.nostr.getPublicKey()
// Create a signup event
const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', window.location.origin + '/api/auth/nostr/verify'],
['method', 'POST'],
],
content: 'Signup to LNPaywall',
pubkey,
}
// Sign the event
const signedEvent = await window.nostr.signEvent(event)
// Send to backend
const result = await nostrLogin(pubkey, signedEvent)
toast.success('Account created! Welcome to LNPaywall 🎉')
navigate('/dashboard')
} catch (error) {
console.error('Nostr signup error:', error)
toast.error(error.response?.data?.error || error.message || 'Failed to signup with Nostr')
} finally {
setIsNostrLoading(false)
}
}
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value })
}
return (
<div className="min-h-screen flex items-center justify-center px-4 py-16 bg-dark-950">
{/* Background effects */}
<div className="absolute inset-0 bg-grid opacity-20" />
<div className="absolute inset-0 bg-gradient-radial from-lightning/5 via-transparent to-transparent" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative w-full max-w-md"
>
{/* Logo */}
<div className="text-center mb-8">
<Link to="/" className="inline-flex items-center gap-2 mb-4">
<span className="text-3xl"></span>
<span className="font-display font-bold text-2xl">LNPaywall</span>
</Link>
<h1 className="text-2xl font-bold">Create your account</h1>
<p className="text-dark-400 mt-2">Start monetizing your content in minutes</p>
</div>
{/* Form */}
<div className="card p-8">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="displayName" className="label">
Display name
</label>
<input
id="displayName"
name="displayName"
type="text"
value={formData.displayName}
onChange={handleChange}
className="input"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="email" className="label">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
className="input"
placeholder="you@example.com"
/>
</div>
<div>
<label htmlFor="password" className="label">
Password
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
minLength={8}
value={formData.password}
onChange={handleChange}
className="input pr-12"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-400 hover:text-white"
>
{showPassword ? (
<EyeSlashIcon className="w-5 h-5" />
) : (
<EyeIcon className="w-5 h-5" />
)}
</button>
</div>
<p className="text-xs text-dark-500 mt-1">At least 8 characters</p>
</div>
<button
type="submit"
disabled={isLoading}
className="btn btn-primary w-full"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<BoltIcon className="w-5 h-5" />
Create Account
</>
)}
</button>
</form>
{/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-dark-700" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-dark-900 text-dark-400">or continue with</span>
</div>
</div>
{/* Social logins */}
<div className="grid grid-cols-2 gap-4">
<button
type="button"
className="btn btn-secondary"
onClick={handleNostrSignup}
disabled={isNostrLoading}
>
{isNostrLoading ? (
<div className="w-5 h-5 border-2 border-purple-500/30 border-t-purple-500 rounded-full animate-spin" />
) : (
<span className="text-xl">🟣</span>
)}
Nostr
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => toast('GitHub login coming soon!')}
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
GitHub
</button>
</div>
{/* Terms */}
<p className="text-center text-xs text-dark-500 mt-6">
By signing up, you agree to our{' '}
<a href="#" className="text-dark-300 hover:underline">Terms of Service</a>
{' '}and{' '}
<a href="#" className="text-dark-300 hover:underline">Privacy Policy</a>
</p>
</div>
{/* Login link */}
<p className="text-center mt-6 text-dark-400">
Already have an account?{' '}
<Link to="/login" className="text-lightning hover:underline">
Log in
</Link>
</p>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,454 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import {
LinkIcon,
DocumentTextIcon,
CurrencyDollarIcon,
ShieldCheckIcon,
CheckIcon,
ArrowLeftIcon,
ArrowRightIcon,
SparklesIcon,
} from '@heroicons/react/24/outline'
import { paywallsApi } from '../../services/api'
const steps = [
{ id: 1, name: 'Paste Link', icon: LinkIcon },
{ id: 2, name: 'Details', icon: DocumentTextIcon },
{ id: 3, name: 'Price', icon: CurrencyDollarIcon },
{ id: 4, name: 'Access', icon: ShieldCheckIcon },
]
const expiryOptions = [
{ label: 'Never', value: null },
{ label: '24 hours', value: 86400 },
{ label: '7 days', value: 604800 },
{ label: '30 days', value: 2592000 },
]
export default function CreatePaywall() {
const navigate = useNavigate()
const [currentStep, setCurrentStep] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [isFetchingMetadata, setIsFetchingMetadata] = useState(false)
const [formData, setFormData] = useState({
originalUrl: '',
title: '',
description: '',
coverImageUrl: '',
priceSats: 1000,
accessExpirySeconds: null,
maxDevices: 3,
allowEmbed: true,
slug: '',
})
const handleChange = (e) => {
const { name, value, type, checked } = e.target
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value,
})
}
const handleFetchMetadata = async () => {
if (!formData.originalUrl) return
setIsFetchingMetadata(true)
try {
const response = await paywallsApi.fetchMetadata(formData.originalUrl)
const metadata = response.data
setFormData({
...formData,
title: metadata.title || formData.title,
description: metadata.description || formData.description,
coverImageUrl: metadata.image || formData.coverImageUrl,
})
if (metadata.title) {
toast.success('Metadata fetched!')
}
} catch (error) {
toast.error('Could not fetch metadata')
} finally {
setIsFetchingMetadata(false)
}
}
const handleNext = async () => {
if (currentStep === 1) {
if (!formData.originalUrl) {
toast.error('Please enter a URL')
return
}
try {
new URL(formData.originalUrl)
if (!formData.originalUrl.startsWith('https://')) {
toast.error('URL must use HTTPS')
return
}
} catch {
toast.error('Please enter a valid URL')
return
}
await handleFetchMetadata()
}
if (currentStep === 2) {
if (!formData.title) {
toast.error('Please enter a title')
return
}
}
if (currentStep === 3) {
if (!formData.priceSats || formData.priceSats < 1) {
toast.error('Price must be at least 1 sat')
return
}
}
if (currentStep < 4) {
setCurrentStep(currentStep + 1)
} else {
// Submit
handleSubmit()
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const handleSubmit = async () => {
setIsLoading(true)
try {
const data = {
...formData,
priceSats: parseInt(formData.priceSats),
maxDevices: parseInt(formData.maxDevices),
accessExpirySeconds: formData.accessExpirySeconds ? parseInt(formData.accessExpirySeconds) : null,
slug: formData.slug || undefined,
}
const response = await paywallsApi.create(data)
toast.success('Paywall created! 🎉')
navigate(`/dashboard/paywalls/${response.data.paywall.id}`)
} catch (error) {
console.error('Error creating paywall:', error)
toast.error(error.response?.data?.error || 'Failed to create paywall')
} finally {
setIsLoading(false)
}
}
return (
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => navigate('/dashboard/paywalls')}
className="flex items-center gap-2 text-dark-400 hover:text-white mb-4"
>
<ArrowLeftIcon className="w-4 h-4" />
Back to Paywalls
</button>
<h1 className="text-2xl font-bold">Create Paywall</h1>
<p className="text-dark-400">Turn any link into paid content</p>
</div>
{/* Steps indicator */}
<div className="flex items-center justify-between mb-8">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<div
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-colors ${
currentStep > step.id
? 'bg-green-500 border-green-500 text-white'
: currentStep === step.id
? 'border-lightning text-lightning'
: 'border-dark-700 text-dark-500'
}`}
>
{currentStep > step.id ? (
<CheckIcon className="w-5 h-5" />
) : (
<step.icon className="w-5 h-5" />
)}
</div>
{index < steps.length - 1 && (
<div
className={`w-16 sm:w-24 h-0.5 mx-2 ${
currentStep > step.id ? 'bg-green-500' : 'bg-dark-700'
}`}
/>
)}
</div>
))}
</div>
{/* Form card */}
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="card p-6"
>
{/* Step 1: URL */}
{currentStep === 1 && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-2">What content do you want to sell?</h2>
<p className="text-dark-400 text-sm mb-4">
Paste the URL of your content. Works with Notion, Google Docs, YouTube, PDFs, and any web page.
</p>
</div>
<div>
<label className="label">Content URL</label>
<div className="relative">
<input
type="url"
name="originalUrl"
value={formData.originalUrl}
onChange={handleChange}
placeholder="https://notion.so/my-guide"
className="input pl-10"
/>
<LinkIcon className="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-dark-400" />
</div>
</div>
</div>
)}
{/* Step 2: Details */}
{currentStep === 2 && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-2">Describe your content</h2>
<p className="text-dark-400 text-sm mb-4">
This information will be shown to potential buyers.
</p>
</div>
<div>
<label className="label">Title *</label>
<input
type="text"
name="title"
value={formData.title}
onChange={handleChange}
placeholder="My Premium Content"
className="input"
maxLength={200}
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
placeholder="What will buyers get access to?"
className="input min-h-[100px] resize-none"
maxLength={1000}
/>
</div>
<div>
<label className="label">Cover Image URL (optional)</label>
<input
type="url"
name="coverImageUrl"
value={formData.coverImageUrl}
onChange={handleChange}
placeholder="https://example.com/image.jpg"
className="input"
/>
{formData.coverImageUrl && (
<img
src={formData.coverImageUrl}
alt="Preview"
className="mt-2 w-full h-32 object-cover rounded-lg"
onError={(e) => e.target.style.display = 'none'}
/>
)}
</div>
</div>
)}
{/* Step 3: Price */}
{currentStep === 3 && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-2">Set your price</h2>
<p className="text-dark-400 text-sm mb-4">
How much should buyers pay for access? Price in satoshis.
</p>
</div>
<div>
<label className="label">Price (sats)</label>
<div className="relative">
<input
type="number"
name="priceSats"
value={formData.priceSats}
onChange={handleChange}
placeholder="1000"
min="1"
max="100000000"
className="input pl-10 text-2xl font-bold"
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-lightning text-xl"></span>
</div>
<p className="text-xs text-dark-500 mt-2">
${((formData.priceSats || 0) * 0.0005).toFixed(2)} USD (approximate)
</p>
</div>
{/* Quick price buttons */}
<div className="flex flex-wrap gap-2">
{[100, 500, 1000, 2100, 5000, 10000, 21000].map((price) => (
<button
key={price}
type="button"
onClick={() => setFormData({ ...formData, priceSats: price })}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
formData.priceSats === price
? 'bg-lightning text-dark-950'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{price.toLocaleString()}
</button>
))}
</div>
</div>
)}
{/* Step 4: Access */}
{currentStep === 4 && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold mb-2">Access settings</h2>
<p className="text-dark-400 text-sm mb-4">
Configure how long buyers can access your content.
</p>
</div>
<div>
<label className="label">Access Duration</label>
<div className="grid grid-cols-2 gap-2">
{expiryOptions.map((option) => (
<button
key={option.label}
type="button"
onClick={() => setFormData({ ...formData, accessExpirySeconds: option.value })}
className={`px-4 py-3 rounded-xl text-sm font-medium transition-colors ${
formData.accessExpirySeconds === option.value
? 'bg-lightning text-dark-950'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<label className="label">Max Devices</label>
<input
type="number"
name="maxDevices"
value={formData.maxDevices}
onChange={handleChange}
min="1"
max="100"
className="input"
/>
<p className="text-xs text-dark-500 mt-1">
Limit how many devices can access with one purchase
</p>
</div>
<div>
<label className="label">Custom Slug (optional)</label>
<div className="flex items-center gap-2">
<span className="text-dark-400">/p/</span>
<input
type="text"
name="slug"
value={formData.slug}
onChange={handleChange}
placeholder="my-content"
className="input flex-1"
pattern="[a-z0-9-]+"
/>
</div>
<p className="text-xs text-dark-500 mt-1">
Create a memorable URL for your paywall
</p>
</div>
<div className="flex items-center gap-3 p-4 bg-dark-800 rounded-xl">
<input
type="checkbox"
name="allowEmbed"
checked={formData.allowEmbed}
onChange={handleChange}
className="w-5 h-5 rounded border-dark-600 text-lightning focus:ring-lightning"
/>
<div>
<p className="font-medium">Allow embedding</p>
<p className="text-sm text-dark-400">Let others embed this paywall on their websites</p>
</div>
</div>
</div>
)}
{/* Navigation buttons */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-dark-800">
<button
type="button"
onClick={handleBack}
className={`btn btn-secondary ${currentStep === 1 ? 'invisible' : ''}`}
>
<ArrowLeftIcon className="w-4 h-4" />
Back
</button>
<button
type="button"
onClick={handleNext}
disabled={isLoading || isFetchingMetadata}
className="btn btn-primary"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : currentStep === 4 ? (
<>
<SparklesIcon className="w-5 h-5" />
Create Paywall
</>
) : (
<>
Next
<ArrowRightIcon className="w-4 h-4" />
</>
)}
</button>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,225 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { format } from 'date-fns'
import {
BoltIcon,
CurrencyDollarIcon,
RectangleStackIcon,
ArrowTrendingUpIcon,
PlusIcon,
} from '@heroicons/react/24/outline'
import { paywallsApi } from '../../services/api'
const StatCard = ({ title, value, icon: Icon, trend, color = 'lightning' }) => (
<div className="card p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-dark-400 text-sm mb-1">{title}</p>
<p className="text-3xl font-bold">{value}</p>
{trend && (
<p className="text-sm text-green-500 mt-1 flex items-center gap-1">
<ArrowTrendingUpIcon className="w-4 h-4" />
{trend}
</p>
)}
</div>
<div className={`w-12 h-12 rounded-xl bg-${color}/10 text-${color} flex items-center justify-center`}>
<Icon className="w-6 h-6" />
</div>
</div>
</div>
)
export default function Dashboard() {
const [stats, setStats] = useState(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const fetchStats = async () => {
try {
const response = await paywallsApi.getStats()
setStats(response.data)
} catch (error) {
console.error('Error fetching stats:', error)
} finally {
setIsLoading(false)
}
}
fetchStats()
}, [])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-lightning"></div>
</div>
)
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-dark-400">Welcome back! Here's your overview.</p>
</div>
<Link to="/dashboard/paywalls/new" className="btn btn-primary sm:hidden">
<PlusIcon className="w-5 h-5" />
</Link>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0 }}
>
<StatCard
title="Total Revenue"
value={`⚡ ${(stats?.totalRevenue || 0).toLocaleString()}`}
icon={CurrencyDollarIcon}
color="lightning"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<StatCard
title="Last 7 Days"
value={`⚡ ${(stats?.last7DaysRevenue || 0).toLocaleString()}`}
icon={BoltIcon}
color="lightning"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<StatCard
title="Last 30 Days"
value={`⚡ ${(stats?.last30DaysRevenue || 0).toLocaleString()}`}
icon={ArrowTrendingUpIcon}
color="green-500"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<StatCard
title="Total Sales"
value={stats?.totalSales || 0}
icon={RectangleStackIcon}
color="purple-500"
/>
</motion.div>
</div>
{/* Quick Actions */}
{(!stats?.totalSales || stats.totalSales === 0) && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="card p-8 text-center"
>
<div className="w-16 h-16 rounded-full bg-lightning/10 flex items-center justify-center mx-auto mb-4">
<BoltIcon className="w-8 h-8 text-lightning" />
</div>
<h2 className="text-xl font-bold mb-2">Create your first paywall</h2>
<p className="text-dark-400 mb-6 max-w-md mx-auto">
Turn any link into paid content. Paste a URL, set your price, and start earning in seconds.
</p>
<Link to="/dashboard/paywalls/new" className="btn btn-primary">
<PlusIcon className="w-5 h-5" />
Create Paywall
</Link>
</motion.div>
)}
{/* Recent Sales */}
{stats?.recentSales && stats.recentSales.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="card"
>
<div className="p-6 border-b border-dark-800">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Recent Sales</h2>
<Link to="/dashboard/sales" className="text-sm text-lightning hover:underline">
View all
</Link>
</div>
</div>
<div className="divide-y divide-dark-800">
{stats.recentSales.map((sale) => (
<div key={sale.id} className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{sale.paywall?.title}</p>
<p className="text-sm text-dark-400">
{format(new Date(sale.createdAt), 'MMM d, yyyy h:mm a')}
</p>
</div>
<div className="text-right">
<p className="font-bold text-lightning">
{sale.netSats.toLocaleString()}
</p>
<p className="text-xs text-dark-500">
-{sale.platformFeeSats} fee
</p>
</div>
</div>
))}
</div>
</motion.div>
)}
{/* Help section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="grid md:grid-cols-2 gap-4"
>
<Link to="/dashboard/embeds" className="card-hover p-6 flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 text-blue-500 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
<div>
<h3 className="font-semibold mb-1">Embed your paywalls</h3>
<p className="text-sm text-dark-400">
Learn how to add paywalls to your website with our embed code.
</p>
</div>
</Link>
<Link to="/dashboard/settings" className="card-hover p-6 flex items-start gap-4">
<div className="w-10 h-10 rounded-lg bg-purple-500/10 text-purple-500 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<h3 className="font-semibold mb-1">Set up payouts</h3>
<p className="text-sm text-dark-400">
Configure your Lightning Address to receive payments directly.
</p>
</div>
</Link>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,210 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import { ClipboardIcon, CodeBracketIcon } from '@heroicons/react/24/outline'
export default function Embeds() {
const [paywallId, setPaywallId] = useState('YOUR_PAYWALL_ID')
const baseUrl = window.location.origin
const apiUrl = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:3001'
const embedCodes = {
iframe: `<iframe
src="${baseUrl}/embed/${paywallId}"
width="100%"
height="400"
frameborder="0"
style="border-radius: 12px; max-width: 400px;"
></iframe>`,
button: `<script
src="${apiUrl}/js/paywall.js"
data-paywall="${paywallId}"
data-theme="auto"
></script>`,
link: `${baseUrl}/p/${paywallId}`,
}
const handleCopy = (text, label) => {
navigator.clipboard.writeText(text)
toast.success(`${label} copied!`)
}
return (
<div className="space-y-8 max-w-4xl">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Embed Documentation</h1>
<p className="text-dark-400">Learn how to add paywalls to your website</p>
</div>
{/* Paywall ID input */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-6"
>
<h2 className="font-semibold mb-4">Your Paywall ID</h2>
<p className="text-sm text-dark-400 mb-4">
Enter your paywall ID to generate customized embed codes. You can find this on the paywall detail page.
</p>
<input
type="text"
value={paywallId}
onChange={(e) => setPaywallId(e.target.value)}
placeholder="Enter your paywall ID"
className="input"
/>
</motion.div>
{/* Iframe embed */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="card p-6"
>
<div className="flex items-start gap-4 mb-4">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 text-blue-500 flex items-center justify-center flex-shrink-0">
<CodeBracketIcon className="w-5 h-5" />
</div>
<div>
<h2 className="font-semibold">Iframe Embed</h2>
<p className="text-sm text-dark-400">
Embed the paywall directly in your page. The iframe includes the full checkout experience.
</p>
</div>
</div>
<div className="relative">
<pre className="bg-dark-800 rounded-xl p-4 text-sm overflow-x-auto font-mono text-dark-300">
{embedCodes.iframe}
</pre>
<button
onClick={() => handleCopy(embedCodes.iframe, 'Iframe code')}
className="absolute top-2 right-2 btn btn-secondary text-xs py-1 px-2"
>
<ClipboardIcon className="w-4 h-4" />
Copy
</button>
</div>
<div className="mt-4 p-4 bg-dark-800/50 rounded-xl">
<h4 className="text-sm font-medium mb-2">Customization options:</h4>
<ul className="text-sm text-dark-400 space-y-1">
<li> Adjust <code className="text-lightning">width</code> and <code className="text-lightning">height</code> to fit your layout</li>
<li> Set <code className="text-lightning">max-width</code> to control responsive behavior</li>
<li> The iframe adapts to light/dark mode automatically</li>
</ul>
</div>
</motion.div>
{/* Button embed */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="card p-6"
>
<div className="flex items-start gap-4 mb-4">
<div className="w-10 h-10 rounded-lg bg-purple-500/10 text-purple-500 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
</svg>
</div>
<div>
<h2 className="font-semibold">Button + Modal</h2>
<p className="text-sm text-dark-400">
Add a button that opens a checkout modal when clicked. Great for inline CTAs.
</p>
</div>
</div>
<div className="relative">
<pre className="bg-dark-800 rounded-xl p-4 text-sm overflow-x-auto font-mono text-dark-300">
{embedCodes.button}
</pre>
<button
onClick={() => handleCopy(embedCodes.button, 'Button code')}
className="absolute top-2 right-2 btn btn-secondary text-xs py-1 px-2"
>
<ClipboardIcon className="w-4 h-4" />
Copy
</button>
</div>
<div className="mt-4 p-4 bg-dark-800/50 rounded-xl">
<h4 className="text-sm font-medium mb-2">Available attributes:</h4>
<ul className="text-sm text-dark-400 space-y-1">
<li> <code className="text-lightning">data-paywall</code> - Your paywall ID (required)</li>
<li> <code className="text-lightning">data-theme</code> - "dark", "light", or "auto"</li>
<li> <code className="text-lightning">data-button-text</code> - Custom button text</li>
</ul>
</div>
</motion.div>
{/* Direct link */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="card p-6"
>
<div className="flex items-start gap-4 mb-4">
<div className="w-10 h-10 rounded-lg bg-green-500/10 text-green-500 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<div>
<h2 className="font-semibold">Direct Link</h2>
<p className="text-sm text-dark-400">
Share this link directly via email, social media, or anywhere else.
</p>
</div>
</div>
<div className="flex gap-2">
<input
type="text"
value={embedCodes.link}
readOnly
className="input flex-1 font-mono text-sm"
/>
<button
onClick={() => handleCopy(embedCodes.link, 'Link')}
className="btn btn-secondary"
>
<ClipboardIcon className="w-5 h-5" />
</button>
</div>
</motion.div>
{/* Platform guides */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="card p-6"
>
<h2 className="font-semibold mb-4">Platform-Specific Guides</h2>
<div className="grid md:grid-cols-2 gap-4">
{[
{ name: 'WordPress', icon: '📝', tip: 'Use the HTML block to paste embed code' },
{ name: 'Webflow', icon: '🎨', tip: 'Add an Embed element and paste the code' },
{ name: 'Framer', icon: '🖼️', tip: 'Use the Code component for embeds' },
{ name: 'Notion', icon: '📓', tip: 'Use /embed and paste the direct link' },
].map((platform) => (
<div key={platform.name} className="p-4 bg-dark-800/50 rounded-xl">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{platform.icon}</span>
<span className="font-medium">{platform.name}</span>
</div>
<p className="text-sm text-dark-400">{platform.tip}</p>
</div>
))}
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,515 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { format } from 'date-fns'
import toast from 'react-hot-toast'
import {
ArrowLeftIcon,
ClipboardIcon,
ArrowTopRightOnSquareIcon,
PencilIcon,
ArchiveBoxIcon,
ChartBarIcon,
CheckIcon,
} from '@heroicons/react/24/outline'
import { paywallsApi } from '../../services/api'
export default function PaywallDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [paywall, setPaywall] = useState(null)
const [stats, setStats] = useState(null)
const [embedCode, setEmbedCode] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [activeTab, setActiveTab] = useState('overview')
const [editData, setEditData] = useState({})
useEffect(() => {
fetchPaywall()
fetchEmbed()
}, [id])
const fetchPaywall = async () => {
try {
const response = await paywallsApi.get(id)
setPaywall(response.data.paywall)
setStats(response.data.stats)
setEditData({
title: response.data.paywall.title || '',
description: response.data.paywall.description || '',
priceSats: response.data.paywall.priceSats || 100,
originalUrl: response.data.paywall.originalUrl || '',
accessExpirySeconds: response.data.paywall.accessExpirySeconds || null,
maxDevices: response.data.paywall.maxDevices || 3,
allowEmbed: response.data.paywall.allowEmbed ?? true,
customSuccessMessage: response.data.paywall.customSuccessMessage || '',
slug: response.data.paywall.slug || '',
})
} catch (error) {
console.error('Error fetching paywall:', error)
toast.error('Failed to load paywall')
} finally {
setIsLoading(false)
}
}
const handleEditChange = (e) => {
const { name, value, type, checked } = e.target
setEditData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : (type === 'number' ? (value === '' ? null : parseInt(value)) : value)
}))
}
const handleSaveSettings = async (e) => {
e.preventDefault()
setIsSaving(true)
try {
const updateData = {
...editData,
priceSats: parseInt(editData.priceSats),
accessExpirySeconds: editData.accessExpirySeconds ? parseInt(editData.accessExpirySeconds) : null,
maxDevices: parseInt(editData.maxDevices),
}
const response = await paywallsApi.update(id, updateData)
setPaywall(response.data.paywall)
toast.success('Paywall updated successfully!')
} catch (error) {
console.error('Error updating paywall:', error)
toast.error(error.response?.data?.error || 'Failed to update paywall')
} finally {
setIsSaving(false)
}
}
const fetchEmbed = async () => {
try {
const response = await paywallsApi.getEmbed(id)
setEmbedCode(response.data)
} catch (error) {
console.error('Error fetching embed:', error)
}
}
const handleCopy = (text, label) => {
navigator.clipboard.writeText(text)
toast.success(`${label} copied!`)
}
const handleArchive = async () => {
try {
await paywallsApi.archive(id)
toast.success('Paywall archived')
navigate('/dashboard/paywalls')
} catch (error) {
toast.error('Failed to archive paywall')
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-lightning"></div>
</div>
)
}
if (!paywall) {
return (
<div className="text-center py-12">
<h2 className="text-xl font-bold mb-2">Paywall not found</h2>
<Link to="/dashboard/paywalls" className="text-lightning hover:underline">
Back to paywalls
</Link>
</div>
)
}
const paywallUrl = embedCode?.link || `${window.location.origin}/p/${paywall.slug || paywall.id}`
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<button
onClick={() => navigate('/dashboard/paywalls')}
className="flex items-center gap-2 text-dark-400 hover:text-white self-start"
>
<ArrowLeftIcon className="w-4 h-4" />
Back
</button>
<div className="flex-1">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{paywall.title}</h1>
<p className="text-dark-400">{paywall.description}</p>
</div>
<div className="flex items-center gap-2">
<a
href={paywallUrl}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary"
>
<ArrowTopRightOnSquareIcon className="w-4 h-4" />
Preview
</a>
<button
onClick={handleArchive}
className="btn btn-secondary text-red-500 border-red-500/30 hover:bg-red-500/10"
>
<ArchiveBoxIcon className="w-4 h-4" />
Archive
</button>
</div>
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="card p-4">
<p className="text-dark-400 text-sm">Price</p>
<p className="text-2xl font-bold text-lightning"> {paywall.priceSats.toLocaleString()}</p>
</div>
<div className="card p-4">
<p className="text-dark-400 text-sm">Total Sales</p>
<p className="text-2xl font-bold">{stats?.salesCount || 0}</p>
</div>
<div className="card p-4">
<p className="text-dark-400 text-sm">Total Revenue</p>
<p className="text-2xl font-bold text-lightning"> {(stats?.totalRevenue || 0).toLocaleString()}</p>
</div>
<div className="card p-4">
<p className="text-dark-400 text-sm">Status</p>
<p className={`text-2xl font-bold ${paywall.status === 'ACTIVE' ? 'text-green-500' : 'text-dark-400'}`}>
{paywall.status}
</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 border-b border-dark-800 pb-px">
{['overview', 'embed', 'settings'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab
? 'border-lightning text-white'
: 'border-transparent text-dark-400 hover:text-white'
}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{/* Tab content */}
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
{activeTab === 'overview' && (
<div className="grid md:grid-cols-2 gap-6">
{/* Preview */}
<div className="card overflow-hidden">
{paywall.coverImageUrl ? (
<img src={paywall.coverImageUrl} alt="" className="w-full h-48 object-cover" />
) : (
<div className="w-full h-48 bg-gradient-to-br from-purple-600 to-pink-500" />
)}
<div className="p-4">
<h3 className="font-semibold mb-2">{paywall.title}</h3>
<p className="text-sm text-dark-400 mb-4">{paywall.description}</p>
<div className="flex items-center justify-between">
<span className="text-lightning font-bold"> {paywall.priceSats.toLocaleString()} sats</span>
<span className="text-xs text-dark-500">
{paywall.accessExpirySeconds
? `${Math.round(paywall.accessExpirySeconds / 86400)} day access`
: 'Lifetime access'}
</span>
</div>
</div>
</div>
{/* Quick links */}
<div className="space-y-4">
<div className="card p-4">
<h4 className="font-semibold mb-3">Share Link</h4>
<div className="flex gap-2">
<input
type="text"
value={paywallUrl}
readOnly
className="input flex-1 text-sm"
/>
<button
onClick={() => handleCopy(paywallUrl, 'Link')}
className="btn btn-secondary"
>
<ClipboardIcon className="w-5 h-5" />
</button>
</div>
</div>
<div className="card p-4">
<h4 className="font-semibold mb-3">Details</h4>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-dark-400">Created</dt>
<dd>{format(new Date(paywall.createdAt), 'MMM d, yyyy')}</dd>
</div>
<div className="flex justify-between">
<dt className="text-dark-400">Max Devices</dt>
<dd>{paywall.maxDevices}</dd>
</div>
<div className="flex justify-between">
<dt className="text-dark-400">Content Type</dt>
<dd>{paywall.originalUrlType}</dd>
</div>
<div className="flex justify-between">
<dt className="text-dark-400">Allow Embed</dt>
<dd>{paywall.allowEmbed ? 'Yes' : 'No'}</dd>
</div>
</dl>
</div>
</div>
</div>
)}
{activeTab === 'embed' && embedCode && (
<div className="space-y-6" id="embed">
<div className="card p-6">
<h3 className="font-semibold mb-4">Iframe Embed</h3>
<p className="text-sm text-dark-400 mb-4">
Add this code to your website to embed the paywall directly.
</p>
<div className="relative">
<pre className="bg-dark-800 rounded-xl p-4 text-sm overflow-x-auto font-mono">
{embedCode.iframe}
</pre>
<button
onClick={() => handleCopy(embedCode.iframe, 'Embed code')}
className="absolute top-2 right-2 btn btn-secondary text-xs py-1 px-2"
>
<ClipboardIcon className="w-4 h-4" />
Copy
</button>
</div>
</div>
<div className="card p-6">
<h3 className="font-semibold mb-4">Button Embed</h3>
<p className="text-sm text-dark-400 mb-4">
Add a button that opens a checkout modal when clicked.
</p>
<div className="relative">
<pre className="bg-dark-800 rounded-xl p-4 text-sm overflow-x-auto font-mono">
{embedCode.button}
</pre>
<button
onClick={() => handleCopy(embedCode.button, 'Button code')}
className="absolute top-2 right-2 btn btn-secondary text-xs py-1 px-2"
>
<ClipboardIcon className="w-4 h-4" />
Copy
</button>
</div>
</div>
<div className="card p-6">
<h3 className="font-semibold mb-4">Direct Link</h3>
<p className="text-sm text-dark-400 mb-4">
Share this link directly or use it in your own custom integration.
</p>
<div className="flex gap-2">
<input
type="text"
value={embedCode.link}
readOnly
className="input flex-1 font-mono text-sm"
/>
<button
onClick={() => handleCopy(embedCode.link, 'Link')}
className="btn btn-secondary"
>
<ClipboardIcon className="w-5 h-5" />
</button>
</div>
</div>
</div>
)}
{activeTab === 'settings' && (
<form onSubmit={handleSaveSettings} className="space-y-6">
<div className="card p-6">
<h3 className="font-semibold mb-4">Basic Settings</h3>
<div className="space-y-4">
<div>
<label className="label">Title</label>
<input
type="text"
name="title"
value={editData.title}
onChange={handleEditChange}
className="input"
required
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
value={editData.description}
onChange={handleEditChange}
className="input min-h-[100px]"
placeholder="What are you selling?"
/>
</div>
<div>
<label className="label">Original URL</label>
<input
type="url"
name="originalUrl"
value={editData.originalUrl}
onChange={handleEditChange}
className="input"
required
/>
</div>
<div>
<label className="label">Custom Slug (optional)</label>
<div className="flex items-center gap-2">
<span className="text-dark-400">/p/</span>
<input
type="text"
name="slug"
value={editData.slug}
onChange={handleEditChange}
className="input flex-1"
placeholder="my-awesome-content"
pattern="^[a-z0-9-]+$"
/>
</div>
<p className="text-xs text-dark-500 mt-1">Lowercase letters, numbers, and hyphens only</p>
</div>
</div>
</div>
<div className="card p-6">
<h3 className="font-semibold mb-4">Pricing & Access</h3>
<div className="space-y-4">
<div>
<label className="label">Price (sats)</label>
<input
type="number"
name="priceSats"
value={editData.priceSats}
onChange={handleEditChange}
className="input"
min="1"
required
/>
</div>
<div>
<label className="label">Access Duration</label>
<select
name="accessExpirySeconds"
value={editData.accessExpirySeconds || ''}
onChange={handleEditChange}
className="input"
>
<option value="">Lifetime access</option>
<option value="86400">24 hours</option>
<option value="604800">7 days</option>
<option value="2592000">30 days</option>
<option value="7776000">90 days</option>
<option value="31536000">1 year</option>
</select>
</div>
<div>
<label className="label">Max Devices</label>
<input
type="number"
name="maxDevices"
value={editData.maxDevices}
onChange={handleEditChange}
className="input"
min="1"
max="100"
/>
<p className="text-xs text-dark-500 mt-1">How many devices can access the content</p>
</div>
</div>
</div>
<div className="card p-6">
<h3 className="font-semibold mb-4">Advanced</h3>
<div className="space-y-4">
<div className="flex items-center gap-3">
<input
type="checkbox"
name="allowEmbed"
id="allowEmbed"
checked={editData.allowEmbed}
onChange={handleEditChange}
className="w-4 h-4 rounded border-dark-700 bg-dark-800 text-lightning focus:ring-lightning"
/>
<label htmlFor="allowEmbed" className="text-sm">Allow embedding on other websites</label>
</div>
<div>
<label className="label">Custom Success Message</label>
<textarea
name="customSuccessMessage"
value={editData.customSuccessMessage}
onChange={handleEditChange}
className="input"
placeholder="Thank you for your purchase!"
/>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<button
type="submit"
disabled={isSaving}
className="btn btn-primary"
>
{isSaving ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<CheckIcon className="w-5 h-5" />
Save Changes
</>
)}
</button>
<button
type="button"
onClick={handleArchive}
className="btn btn-secondary text-red-500 border-red-500/30 hover:bg-red-500/10"
>
<ArchiveBoxIcon className="w-4 h-4" />
Archive Paywall
</button>
</div>
</form>
)}
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,261 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { format } from 'date-fns'
import toast from 'react-hot-toast'
import {
PlusIcon,
EllipsisVerticalIcon,
ClipboardIcon,
ArchiveBoxIcon,
PencilIcon,
ChartBarIcon,
CodeBracketIcon,
} from '@heroicons/react/24/outline'
import { Menu } from '@headlessui/react'
import { paywallsApi } from '../../services/api'
const statusColors = {
ACTIVE: 'bg-green-500/10 text-green-500',
ARCHIVED: 'bg-dark-600/10 text-dark-400',
DISABLED: 'bg-red-500/10 text-red-500',
}
export default function Paywalls() {
const [paywalls, setPaywalls] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [filter, setFilter] = useState('all')
useEffect(() => {
fetchPaywalls()
}, [])
const fetchPaywalls = async () => {
try {
const response = await paywallsApi.list()
setPaywalls(response.data.paywalls)
} catch (error) {
console.error('Error fetching paywalls:', error)
toast.error('Failed to load paywalls')
} finally {
setIsLoading(false)
}
}
const handleCopyLink = (paywall) => {
const url = `${window.location.origin}/p/${paywall.slug || paywall.id}`
navigator.clipboard.writeText(url)
toast.success('Link copied!')
}
const handleArchive = async (paywall) => {
try {
await paywallsApi.archive(paywall.id)
toast.success('Paywall archived')
fetchPaywalls()
} catch (error) {
toast.error('Failed to archive paywall')
}
}
const handleActivate = async (paywall) => {
try {
await paywallsApi.activate(paywall.id)
toast.success('Paywall activated')
fetchPaywalls()
} catch (error) {
toast.error('Failed to activate paywall')
}
}
const filteredPaywalls = paywalls.filter((p) => {
if (filter === 'all') return true
return p.status === filter
})
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-lightning"></div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Paywalls</h1>
<p className="text-dark-400">Manage your paywalled content</p>
</div>
<Link to="/dashboard/paywalls/new" className="btn btn-primary">
<PlusIcon className="w-5 h-5" />
Create Paywall
</Link>
</div>
{/* Filters */}
<div className="flex gap-2">
{['all', 'ACTIVE', 'ARCHIVED'].map((status) => (
<button
key={status}
onClick={() => setFilter(status)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
filter === status
? 'bg-dark-800 text-white'
: 'text-dark-400 hover:text-white hover:bg-dark-800/50'
}`}
>
{status.charAt(0) + status.slice(1).toLowerCase()}
</button>
))}
</div>
{/* Empty state */}
{filteredPaywalls.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-12 text-center"
>
<div className="w-16 h-16 rounded-full bg-dark-800 flex items-center justify-center mx-auto mb-4">
<PlusIcon className="w-8 h-8 text-dark-400" />
</div>
<h2 className="text-xl font-bold mb-2">No paywalls yet</h2>
<p className="text-dark-400 mb-6">
Create your first paywall to start monetizing your content.
</p>
<Link to="/dashboard/paywalls/new" className="btn btn-primary">
<PlusIcon className="w-5 h-5" />
Create Paywall
</Link>
</motion.div>
) : (
/* Paywalls grid */
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPaywalls.map((paywall, index) => (
<motion.div
key={paywall.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="card-hover overflow-hidden"
>
{/* Cover image */}
{paywall.coverImageUrl ? (
<img
src={paywall.coverImageUrl}
alt=""
className="w-full h-32 object-cover"
/>
) : (
<div className="w-full h-32 bg-gradient-to-br from-purple-600/50 to-pink-500/50" />
)}
<div className="p-4">
{/* Status & Menu */}
<div className="flex items-start justify-between mb-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[paywall.status]}`}>
{paywall.status}
</span>
<Menu as="div" className="relative">
<Menu.Button className="p-1 rounded-lg hover:bg-dark-800 text-dark-400 hover:text-white">
<EllipsisVerticalIcon className="w-5 h-5" />
</Menu.Button>
<Menu.Items className="absolute right-0 mt-1 w-48 rounded-xl bg-dark-800 border border-dark-700 shadow-xl overflow-hidden z-10">
<Menu.Item>
{({ active }) => (
<Link
to={`/dashboard/paywalls/${paywall.id}`}
className={`flex items-center gap-2 px-4 py-2 text-sm ${active ? 'bg-dark-700' : ''}`}
>
<ChartBarIcon className="w-4 h-4" />
View Details
</Link>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleCopyLink(paywall)}
className={`flex items-center gap-2 px-4 py-2 text-sm w-full ${active ? 'bg-dark-700' : ''}`}
>
<ClipboardIcon className="w-4 h-4" />
Copy Link
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<Link
to={`/dashboard/paywalls/${paywall.id}#embed`}
className={`flex items-center gap-2 px-4 py-2 text-sm ${active ? 'bg-dark-700' : ''}`}
>
<CodeBracketIcon className="w-4 h-4" />
Get Embed Code
</Link>
)}
</Menu.Item>
<div className="border-t border-dark-700" />
{paywall.status === 'ACTIVE' ? (
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleArchive(paywall)}
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-red-500 ${active ? 'bg-dark-700' : ''}`}
>
<ArchiveBoxIcon className="w-4 h-4" />
Archive
</button>
)}
</Menu.Item>
) : (
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleActivate(paywall)}
className={`flex items-center gap-2 px-4 py-2 text-sm w-full text-green-500 ${active ? 'bg-dark-700' : ''}`}
>
<PlusIcon className="w-4 h-4" />
Activate
</button>
)}
</Menu.Item>
)}
</Menu.Items>
</Menu>
</div>
{/* Title */}
<Link to={`/dashboard/paywalls/${paywall.id}`}>
<h3 className="font-semibold mb-1 hover:text-lightning transition-colors line-clamp-1">
{paywall.title}
</h3>
</Link>
{/* Description */}
{paywall.description && (
<p className="text-sm text-dark-400 line-clamp-2 mb-3">
{paywall.description}
</p>
)}
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-dark-800">
<span className="text-lightning font-bold">
{paywall.priceSats.toLocaleString()}
</span>
<span className="text-xs text-dark-500">
{paywall._count?.sales || 0} sales
</span>
</div>
</div>
</motion.div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,329 @@
import { useState, useEffect, useRef } from 'react'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import { format } from 'date-fns'
import QRCode from 'qrcode'
import {
BoltIcon,
CheckIcon,
SparklesIcon,
ClipboardIcon,
} from '@heroicons/react/24/outline'
import { subscriptionApi, configApi } from '../../services/api'
import { useAuthStore } from '../../store/authStore'
export default function ProSubscription() {
const { user, refreshUser } = useAuthStore()
const [isLoading, setIsLoading] = useState(true)
const [config, setConfig] = useState(null)
const [subscriptionStatus, setSubscriptionStatus] = useState(null)
const [checkoutState, setCheckoutState] = useState(null)
const [qrCode, setQrCode] = useState(null)
const pollingRef = useRef(null)
useEffect(() => {
fetchData()
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, [])
const fetchData = async () => {
try {
const [configRes, statusRes] = await Promise.all([
configApi.get(),
subscriptionApi.getStatus(),
])
setConfig(configRes.data)
setSubscriptionStatus(statusRes.data)
} catch (error) {
console.error('Error fetching data:', error)
} finally {
setIsLoading(false)
}
}
const handlePurchase = async () => {
try {
setCheckoutState({ status: 'creating' })
const response = await subscriptionApi.createCheckout()
// Generate QR code
const qr = await QRCode.toDataURL(response.data.paymentRequest, {
width: 256,
margin: 2,
color: { dark: '#ffffff', light: '#00000000' },
})
setQrCode(qr)
setCheckoutState({
status: 'pending',
paymentRequest: response.data.paymentRequest,
paymentHash: response.data.paymentHash,
amountSats: response.data.amountSats,
})
// Start polling for payment
pollingRef.current = setInterval(async () => {
try {
const statusRes = await subscriptionApi.checkPaymentStatus(response.data.paymentHash)
if (statusRes.data.status === 'PAID') {
clearInterval(pollingRef.current)
setCheckoutState({ status: 'paid' })
await refreshUser()
toast.success('Pro subscription activated! 🎉')
fetchData()
}
} catch (error) {
console.error('Error checking payment:', error)
}
}, 2000)
// Stop polling after 10 minutes
setTimeout(() => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
if (checkoutState?.status === 'pending') {
setCheckoutState({ status: 'expired' })
}
}
}, 10 * 60 * 1000)
} catch (error) {
console.error('Error creating checkout:', error)
toast.error(error.response?.data?.error || 'Failed to create checkout')
setCheckoutState(null)
}
}
const handleCopyInvoice = () => {
navigator.clipboard.writeText(checkoutState.paymentRequest)
toast.success('Invoice copied!')
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-lightning"></div>
</div>
)
}
const isPro = user?.isPro || (subscriptionStatus?.tier === 'PRO' && subscriptionStatus?.isActive)
return (
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<div className="text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-lightning/10 border border-lightning/30 rounded-full text-sm mb-4">
<SparklesIcon className="w-4 h-4 text-lightning" />
<span className="text-lightning">Pro Subscription</span>
</div>
<h1 className="text-3xl font-bold mb-2">Upgrade to Pro</h1>
<p className="text-dark-400">
Unlock zero platform fees and premium features
</p>
</div>
{/* Current Status */}
{isPro && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-6 border-lightning/50"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-lightning/10 flex items-center justify-center">
<CheckIcon className="w-6 h-6 text-lightning" />
</div>
<div>
<h2 className="text-xl font-bold text-lightning">You're a Pro!</h2>
<p className="text-dark-400">
Your subscription is active until{' '}
{subscriptionStatus?.expiry
? format(new Date(subscriptionStatus.expiry), 'MMMM d, yyyy')
: 'forever'}
</p>
</div>
</div>
</motion.div>
)}
{/* Pricing Card */}
{!isPro && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-8"
>
<div className="grid md:grid-cols-2 gap-8">
{/* Benefits */}
<div>
<h2 className="text-xl font-bold mb-4">Pro Benefits</h2>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<CheckIcon className="w-4 h-4 text-green-500" />
</div>
<div>
<p className="font-medium">0% Platform Fee</p>
<p className="text-sm text-dark-400">Keep 100% of your earnings (vs {config?.platformFeePercent}% on free)</p>
</div>
</li>
<li className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<CheckIcon className="w-4 h-4 text-green-500" />
</div>
<div>
<p className="font-medium">Custom Branding</p>
<p className="text-sm text-dark-400">Remove LNPaywall branding from your paywalls</p>
</div>
</li>
<li className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<CheckIcon className="w-4 h-4 text-green-500" />
</div>
<div>
<p className="font-medium">Priority Support</p>
<p className="text-sm text-dark-400">Get help faster when you need it</p>
</div>
</li>
<li className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
<CheckIcon className="w-4 h-4 text-green-500" />
</div>
<div>
<p className="font-medium">Detailed Analytics</p>
<p className="text-sm text-dark-400">Advanced insights into your sales</p>
</div>
</li>
</ul>
</div>
{/* Checkout */}
<div className="border-t md:border-t-0 md:border-l border-dark-800 pt-8 md:pt-0 md:pl-8">
{!checkoutState && (
<div className="text-center">
<p className="text-dark-400 mb-2">30-day Pro subscription</p>
<div className="text-4xl font-bold mb-1">
⚡ {(config?.proPriceSats || 50000).toLocaleString()}
</div>
<p className="text-dark-500 mb-6">sats</p>
<button onClick={handlePurchase} className="btn btn-primary w-full text-lg py-3">
<BoltIcon className="w-5 h-5" />
Pay with Lightning
</button>
</div>
)}
{checkoutState?.status === 'creating' && (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-lightning mx-auto mb-4"></div>
<p className="text-dark-400">Creating invoice...</p>
</div>
)}
{checkoutState?.status === 'pending' && (
<div className="text-center">
<p className="text-dark-400 mb-4">Scan with Lightning wallet</p>
{qrCode && (
<div className="bg-white rounded-xl p-4 inline-block mb-4">
<img src={qrCode} alt="QR Code" className="w-48 h-48" />
</div>
)}
<div className="flex gap-2 mb-4">
<input
type="text"
value={checkoutState.paymentRequest.slice(0, 30) + '...'}
readOnly
className="input flex-1 text-sm"
/>
<button onClick={handleCopyInvoice} className="btn btn-secondary">
<ClipboardIcon className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-dark-500">Waiting for payment...</p>
</div>
)}
{checkoutState?.status === 'paid' && (
<div className="text-center py-8">
<div className="w-16 h-16 rounded-full bg-green-500/10 flex items-center justify-center mx-auto mb-4">
<CheckIcon className="w-8 h-8 text-green-500" />
</div>
<h3 className="text-xl font-bold text-green-500 mb-2">Payment Successful!</h3>
<p className="text-dark-400">Your Pro subscription is now active</p>
</div>
)}
{checkoutState?.status === 'expired' && (
<div className="text-center py-8">
<p className="text-dark-400 mb-4">Invoice expired</p>
<button onClick={() => setCheckoutState(null)} className="btn btn-secondary">
Try Again
</button>
</div>
)}
</div>
</div>
</motion.div>
)}
{/* Comparison */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="card overflow-hidden"
>
<div className="p-6 border-b border-dark-800">
<h2 className="text-lg font-semibold">Plan Comparison</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left">
<th className="p-4 text-dark-400 font-normal">Feature</th>
<th className="p-4 text-center">Free</th>
<th className="p-4 text-center bg-lightning/5">Pro</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-800">
<tr>
<td className="p-4">Platform Fee</td>
<td className="p-4 text-center">{config?.platformFeePercent}%</td>
<td className="p-4 text-center bg-lightning/5 text-lightning font-bold">0%</td>
</tr>
<tr>
<td className="p-4">Unlimited Paywalls</td>
<td className="p-4 text-center"><CheckIcon className="w-5 h-5 text-green-500 mx-auto" /></td>
<td className="p-4 text-center bg-lightning/5"><CheckIcon className="w-5 h-5 text-green-500 mx-auto" /></td>
</tr>
<tr>
<td className="p-4">Embed Anywhere</td>
<td className="p-4 text-center"><CheckIcon className="w-5 h-5 text-green-500 mx-auto" /></td>
<td className="p-4 text-center bg-lightning/5"><CheckIcon className="w-5 h-5 text-green-500 mx-auto" /></td>
</tr>
<tr>
<td className="p-4">Custom Branding</td>
<td className="p-4 text-center text-dark-500"></td>
<td className="p-4 text-center bg-lightning/5"><CheckIcon className="w-5 h-5 text-green-500 mx-auto" /></td>
</tr>
<tr>
<td className="p-4">Priority Support</td>
<td className="p-4 text-center text-dark-500"></td>
<td className="p-4 text-center bg-lightning/5"><CheckIcon className="w-5 h-5 text-green-500 mx-auto" /></td>
</tr>
<tr>
<td className="p-4">Detailed Analytics</td>
<td className="p-4 text-center text-dark-500"></td>
<td className="p-4 text-center bg-lightning/5"><CheckIcon className="w-5 h-5 text-green-500 mx-auto" /></td>
</tr>
</tbody>
</table>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,179 @@
import { useState, useEffect } from 'react'
import { format } from 'date-fns'
import { motion } from 'framer-motion'
import { paywallsApi } from '../../services/api'
import api from '../../services/api'
export default function Sales() {
const [sales, setSales] = useState([])
const [stats, setStats] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
useEffect(() => {
fetchData()
}, [page])
const fetchData = async () => {
try {
const [statsRes, salesRes] = await Promise.all([
paywallsApi.getStats(),
api.get('/paywalls', { params: { page, limit: 20 } }),
])
setStats(statsRes.data)
// For now we use recent sales from stats
setSales(statsRes.data.recentSales || [])
} catch (error) {
console.error('Error fetching sales:', error)
} finally {
setIsLoading(false)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-lightning"></div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Sales</h1>
<p className="text-dark-400">Track your revenue and transactions</p>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-6"
>
<p className="text-dark-400 text-sm mb-1">Total Revenue</p>
<p className="text-3xl font-bold text-lightning">
{(stats?.totalRevenue || 0).toLocaleString()}
</p>
<p className="text-xs text-dark-500 mt-1">sats</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="card p-6"
>
<p className="text-dark-400 text-sm mb-1">Last 30 Days</p>
<p className="text-3xl font-bold text-green-500">
{(stats?.last30DaysRevenue || 0).toLocaleString()}
</p>
<p className="text-xs text-dark-500 mt-1">sats</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="card p-6"
>
<p className="text-dark-400 text-sm mb-1">Total Sales</p>
<p className="text-3xl font-bold">{stats?.totalSales || 0}</p>
<p className="text-xs text-dark-500 mt-1">transactions</p>
</motion.div>
</div>
{/* Sales table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="card overflow-hidden"
>
{sales.length === 0 ? (
<div className="p-12 text-center">
<div className="w-16 h-16 rounded-full bg-dark-800 flex items-center justify-center mx-auto mb-4">
<span className="text-3xl">💰</span>
</div>
<h2 className="text-xl font-bold mb-2">No sales yet</h2>
<p className="text-dark-400">
When you make your first sale, it will appear here.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-dark-800">
<th className="text-left p-4 text-sm font-medium text-dark-400">Date</th>
<th className="text-left p-4 text-sm font-medium text-dark-400">Paywall</th>
<th className="text-right p-4 text-sm font-medium text-dark-400">Amount</th>
<th className="text-right p-4 text-sm font-medium text-dark-400">Fee</th>
<th className="text-right p-4 text-sm font-medium text-dark-400">Net</th>
<th className="text-center p-4 text-sm font-medium text-dark-400">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-800">
{sales.map((sale) => (
<tr key={sale.id} className="hover:bg-dark-800/50 transition-colors">
<td className="p-4 text-sm">
{format(new Date(sale.createdAt), 'MMM d, yyyy h:mm a')}
</td>
<td className="p-4">
<p className="font-medium truncate max-w-xs">{sale.paywall?.title}</p>
</td>
<td className="p-4 text-right font-mono">
{sale.amountSats.toLocaleString()}
</td>
<td className="p-4 text-right text-dark-400 font-mono text-sm">
-{sale.platformFeeSats.toLocaleString()}
</td>
<td className="p-4 text-right font-bold text-lightning font-mono">
{sale.netSats.toLocaleString()}
</td>
<td className="p-4 text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
sale.status === 'CONFIRMED'
? 'bg-green-500/10 text-green-500'
: 'bg-yellow-500/10 text-yellow-500'
}`}>
{sale.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="btn btn-secondary disabled:opacity-50"
>
Previous
</button>
<span className="text-dark-400">
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
className="btn btn-secondary disabled:opacity-50"
>
Next
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import { useAuthStore } from '../../store/authStore'
import { BoltIcon, UserIcon, ShieldCheckIcon } from '@heroicons/react/24/outline'
export default function Settings() {
const { user, updateProfile, logout } = useAuthStore()
const [isLoading, setIsLoading] = useState(false)
const [formData, setFormData] = useState({
displayName: user?.displayName || '',
lightningAddress: user?.lightningAddress || '',
})
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value })
}
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
try {
await updateProfile(formData)
toast.success('Settings saved!')
} catch (error) {
toast.error(error.response?.data?.error || 'Failed to save settings')
} finally {
setIsLoading(false)
}
}
return (
<div className="max-w-2xl space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-dark-400">Manage your account and preferences</p>
</div>
{/* Profile */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="card p-6"
>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-purple-500/10 text-purple-500 flex items-center justify-center">
<UserIcon className="w-5 h-5" />
</div>
<div>
<h2 className="font-semibold">Profile</h2>
<p className="text-sm text-dark-400">Update your display name and avatar</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Email</label>
<input
type="email"
value={user?.email || ''}
disabled
className="input bg-dark-800 text-dark-400 cursor-not-allowed"
/>
</div>
<div>
<label className="label">Display Name</label>
<input
type="text"
name="displayName"
value={formData.displayName}
onChange={handleChange}
className="input"
placeholder="Your name"
/>
</div>
<button type="submit" disabled={isLoading} className="btn btn-primary">
{isLoading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
'Save Changes'
)}
</button>
</form>
</motion.div>
{/* Payout settings */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="card p-6"
>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-lightning/10 text-lightning flex items-center justify-center">
<BoltIcon className="w-5 h-5" />
</div>
<div>
<h2 className="font-semibold">Payout Settings</h2>
<p className="text-sm text-dark-400">Configure where you receive payments</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Lightning Address</label>
<input
type="text"
name="lightningAddress"
value={formData.lightningAddress}
onChange={handleChange}
className="input"
placeholder="you@getalby.com"
/>
<p className="text-xs text-dark-500 mt-1">
Your Lightning Address where you'll receive payments. Get one from{' '}
<a href="https://getalby.com" target="_blank" rel="noopener noreferrer" className="text-lightning hover:underline">
Alby
</a>,{' '}
<a href="https://walletofsatoshi.com" target="_blank" rel="noopener noreferrer" className="text-lightning hover:underline">
Wallet of Satoshi
</a>, or your LN wallet.
</p>
</div>
<div className="p-4 bg-dark-800/50 rounded-xl border border-dark-700">
<div className="flex items-start gap-3">
<span className="text-xl">💡</span>
<div>
<p className="font-medium mb-1">How payouts work</p>
<p className="text-sm text-dark-400">
When a buyer pays, the funds go directly to your Lightning wallet.
We never custody your funds. A small platform fee is deducted at the time of payment.
</p>
</div>
</div>
</div>
<button type="submit" disabled={isLoading} className="btn btn-primary">
{isLoading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
'Save Changes'
)}
</button>
</form>
</motion.div>
{/* Security */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="card p-6"
>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center">
<ShieldCheckIcon className="w-5 h-5" />
</div>
<div>
<h2 className="font-semibold">Security</h2>
<p className="text-sm text-dark-400">Manage your security settings</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-dark-800/50 rounded-xl">
<div>
<p className="font-medium">Password</p>
<p className="text-sm text-dark-400">Change your account password</p>
</div>
<button className="btn btn-secondary" onClick={() => toast('Password change coming soon!')}>
Change
</button>
</div>
<div className="flex items-center justify-between p-4 bg-dark-800/50 rounded-xl">
<div>
<p className="font-medium">Two-Factor Authentication</p>
<p className="text-sm text-dark-400">Add an extra layer of security</p>
</div>
<span className="text-xs text-dark-500 bg-dark-700 px-2 py-1 rounded">Coming Soon</span>
</div>
<div className="flex items-center justify-between p-4 bg-dark-800/50 rounded-xl">
<div>
<p className="font-medium">Active Sessions</p>
<p className="text-sm text-dark-400">Manage devices logged into your account</p>
</div>
<button className="btn btn-secondary" onClick={() => toast('Session management coming soon!')}>
Manage
</button>
</div>
</div>
</motion.div>
{/* Danger zone */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="card p-6 border-red-500/30"
>
<h2 className="font-semibold text-red-500 mb-4">Danger Zone</h2>
<div className="flex items-center justify-between p-4 bg-red-500/5 rounded-xl border border-red-500/20">
<div>
<p className="font-medium">Log out everywhere</p>
<p className="text-sm text-dark-400">End all sessions on all devices</p>
</div>
<button
onClick={() => {
logout()
toast.success('Logged out successfully')
}}
className="btn bg-red-500/10 text-red-500 border border-red-500/30 hover:bg-red-500/20"
>
Log Out
</button>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,118 @@
import axios from 'axios'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
const api = axios.create({
baseURL: API_URL,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
// Platform config cache
let platformConfig = null
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
// Get token from zustand store
const authState = JSON.parse(localStorage.getItem('lnpaywall-auth') || '{}')
const token = authState?.state?.accessToken
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
// If 401 and not already retrying, try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const response = await api.post('/auth/refresh')
const { accessToken } = response.data
// Update stored token
const authState = JSON.parse(localStorage.getItem('lnpaywall-auth') || '{}')
authState.state = { ...authState.state, accessToken }
localStorage.setItem('lnpaywall-auth', JSON.stringify(authState))
// Retry original request
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return api(originalRequest)
} catch (refreshError) {
// Refresh failed, redirect to login
localStorage.removeItem('lnpaywall-auth')
window.location.href = '/login'
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
}
)
export default api
// API helper functions
export const paywallsApi = {
list: (params) => api.get('/paywalls', { params }),
get: (id) => api.get(`/paywalls/${id}`),
create: (data) => api.post('/paywalls', data),
update: (id, data) => api.patch(`/paywalls/${id}`, data),
archive: (id) => api.post(`/paywalls/${id}/archive`),
activate: (id) => api.post(`/paywalls/${id}/activate`),
getStats: () => api.get('/paywalls/stats'),
getEmbed: (id) => api.get(`/paywalls/${id}/embed`),
getSales: (id, params) => api.get(`/paywalls/${id}/sales`, { params }),
fetchMetadata: (url) => api.post('/paywalls/fetch-metadata', { url }),
}
export const checkoutApi = {
create: (paywallId, data) => api.post(`/checkout/${paywallId}`, data),
get: (sessionId) => api.get(`/checkout/${sessionId}`),
getStatus: (sessionId) => api.get(`/checkout/${sessionId}/status`),
}
export const accessApi = {
verify: (data) => api.post('/access/verify', data),
check: (paywallId) => api.get(`/access/check/${paywallId}`),
}
export const publicApi = {
getPaywall: (slugOrId) => axios.get(`${API_URL.replace('/api', '')}/p/${slugOrId}`),
}
export const configApi = {
get: async () => {
if (platformConfig) return { data: platformConfig }
const response = await axios.get(`${API_URL}/config`)
platformConfig = response.data
return response
},
}
export const subscriptionApi = {
getStatus: () => api.get('/subscription/status'),
createCheckout: () => api.post('/subscription/checkout'),
checkPaymentStatus: (paymentHash) => api.get(`/subscription/checkout/${paymentHash}/status`),
}
export const authApi = {
nostrChallenge: () => api.post('/auth/nostr/challenge'),
nostrVerify: (data) => api.post('/auth/nostr/verify', data),
}

View File

@@ -0,0 +1,125 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import api from '../services/api'
export const useAuthStore = create(
persist(
(set, get) => ({
user: null,
accessToken: null,
isAuthenticated: false,
isLoading: false,
setAuth: (user, accessToken) => {
set({ user, accessToken, isAuthenticated: true, isLoading: false })
},
logout: async () => {
try {
await api.post('/auth/logout')
} catch (error) {
console.error('Logout error:', error)
}
set({ user: null, accessToken: null, isAuthenticated: false, isLoading: false })
},
checkAuth: async () => {
const { accessToken } = get()
if (!accessToken) {
// Try to refresh token
try {
const response = await api.post('/auth/refresh')
set({
user: response.data.user,
accessToken: response.data.accessToken,
isAuthenticated: true,
isLoading: false,
})
return true
} catch (error) {
set({ user: null, accessToken: null, isAuthenticated: false, isLoading: false })
return false
}
}
// Verify token by fetching user
try {
const response = await api.get('/auth/me')
set({ user: response.data.user, isAuthenticated: true, isLoading: false })
return true
} catch (error) {
// Try refresh
try {
const response = await api.post('/auth/refresh')
set({
user: response.data.user,
accessToken: response.data.accessToken,
isAuthenticated: true,
isLoading: false,
})
return true
} catch (refreshError) {
set({ user: null, accessToken: null, isAuthenticated: false, isLoading: false })
return false
}
}
},
login: async (email, password) => {
const response = await api.post('/auth/login', { email, password })
set({
user: response.data.user,
accessToken: response.data.accessToken,
isAuthenticated: true,
isLoading: false,
})
return response.data
},
signup: async (email, password, displayName) => {
const response = await api.post('/auth/signup', { email, password, displayName })
set({
user: response.data.user,
accessToken: response.data.accessToken,
isAuthenticated: true,
isLoading: false,
})
return response.data
},
nostrLogin: async (pubkey, event) => {
const response = await api.post('/auth/nostr/verify', { pubkey, event })
set({
user: response.data.user,
accessToken: response.data.accessToken,
isAuthenticated: true,
isLoading: false,
})
return response.data
},
updateProfile: async (data) => {
const response = await api.patch('/auth/me', data)
set({ user: response.data.user })
return response.data
},
refreshUser: async () => {
try {
const response = await api.get('/auth/me')
set({ user: response.data.user })
return response.data.user
} catch (error) {
console.error('Failed to refresh user:', error)
return null
}
},
}),
{
name: 'lnpaywall-auth',
partialize: (state) => ({ accessToken: state.accessToken }),
}
)
)

View File

@@ -0,0 +1,141 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-dark-950 text-white;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
::selection {
@apply bg-lightning/30 text-white;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-dark-900;
}
::-webkit-scrollbar-thumb {
@apply bg-dark-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-dark-500;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 px-6 py-3 font-semibold rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-gradient-to-r from-lightning to-orange-500 text-white hover:shadow-lg hover:shadow-lightning/25 hover:-translate-y-0.5 active:translate-y-0;
}
.btn-secondary {
@apply bg-dark-800 text-white border border-dark-700 hover:bg-dark-700 hover:border-dark-600;
}
.btn-ghost {
@apply bg-transparent text-dark-300 hover:text-white hover:bg-dark-800/50;
}
.input {
@apply w-full px-4 py-3 bg-dark-800/50 border border-dark-700 rounded-xl text-white placeholder-dark-400 focus:outline-none focus:ring-2 focus:ring-lightning/50 focus:border-lightning transition-all duration-200;
}
.label {
@apply block text-sm font-medium text-dark-300 mb-2;
}
.card {
@apply bg-dark-900/50 border border-dark-800 rounded-2xl backdrop-blur-sm;
}
.card-hover {
@apply card hover:border-dark-700 hover:bg-dark-800/50 transition-all duration-300;
}
.gradient-text {
@apply bg-gradient-to-r from-lightning via-orange-400 to-yellow-400 bg-clip-text text-transparent;
}
.glass {
@apply bg-white/5 backdrop-blur-xl border border-white/10;
}
.animate-in {
animation: animateIn 0.5s ease-out forwards;
}
@keyframes animateIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.bg-grid {
background-image:
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 50px 50px;
}
.bg-dots {
background-image: radial-gradient(rgba(255,255,255,0.1) 1px, transparent 1px);
background-size: 20px 20px;
}
.glow {
box-shadow: 0 0 20px rgba(247, 147, 26, 0.3);
}
.glow-sm {
box-shadow: 0 0 10px rgba(247, 147, 26, 0.2);
}
}
/* Page transition animations */
.page-enter {
opacity: 0;
transform: translateY(10px);
}
.page-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 200ms, transform 200ms;
}
.page-exit {
opacity: 1;
}
.page-exit-active {
opacity: 0;
transition: opacity 200ms;
}

View File

@@ -0,0 +1,71 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
},
lightning: {
DEFAULT: '#f7931a',
dark: '#e8850f',
light: '#ffb347',
},
dark: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
850: '#172033',
900: '#0f172a',
950: '#0a0f1c',
},
},
fontFamily: {
sans: ['DM Sans', 'Inter', 'system-ui', 'sans-serif'],
display: ['Clash Display', 'DM Sans', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
animation: {
'glow': 'glow 2s ease-in-out infinite alternate',
'float': 'float 6s ease-in-out infinite',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
glow: {
'0%': { boxShadow: '0 0 5px #f7931a, 0 0 10px #f7931a, 0 0 20px #f7931a' },
'100%': { boxShadow: '0 0 10px #f7931a, 0 0 20px #f7931a, 0 0 40px #f7931a' },
},
float: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-20px)' },
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'hero-pattern': 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)',
},
},
},
plugins: [],
}

16
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})