Initial commit
This commit is contained in:
22
frontend/env.example
Normal file
22
frontend/env.example
Normal 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
24
frontend/index.html
Normal 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
6166
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
4
frontend/public/lightning.svg
Normal file
4
frontend/public/lightning.svg
Normal 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
85
frontend/src/App.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
188
frontend/src/components/layouts/DashboardLayout.jsx
Normal file
188
frontend/src/components/layouts/DashboardLayout.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
115
frontend/src/components/layouts/MainLayout.jsx
Normal file
115
frontend/src/components/layouts/MainLayout.jsx
Normal 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
38
frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
|
||||
366
frontend/src/pages/Landing.jsx
Normal file
366
frontend/src/pages/Landing.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
285
frontend/src/pages/PaywallPage.jsx
Normal file
285
frontend/src/pages/PaywallPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
211
frontend/src/pages/auth/Login.jsx
Normal file
211
frontend/src/pages/auth/Login.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
243
frontend/src/pages/auth/Signup.jsx
Normal file
243
frontend/src/pages/auth/Signup.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
454
frontend/src/pages/dashboard/CreatePaywall.jsx
Normal file
454
frontend/src/pages/dashboard/CreatePaywall.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
225
frontend/src/pages/dashboard/Dashboard.jsx
Normal file
225
frontend/src/pages/dashboard/Dashboard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
210
frontend/src/pages/dashboard/Embeds.jsx
Normal file
210
frontend/src/pages/dashboard/Embeds.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
515
frontend/src/pages/dashboard/PaywallDetail.jsx
Normal file
515
frontend/src/pages/dashboard/PaywallDetail.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
261
frontend/src/pages/dashboard/Paywalls.jsx
Normal file
261
frontend/src/pages/dashboard/Paywalls.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
329
frontend/src/pages/dashboard/ProSubscription.jsx
Normal file
329
frontend/src/pages/dashboard/ProSubscription.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
179
frontend/src/pages/dashboard/Sales.jsx
Normal file
179
frontend/src/pages/dashboard/Sales.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
227
frontend/src/pages/dashboard/Settings.jsx
Normal file
227
frontend/src/pages/dashboard/Settings.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
118
frontend/src/services/api.js
Normal file
118
frontend/src/services/api.js
Normal 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),
|
||||
}
|
||||
|
||||
125
frontend/src/store/authStore.js
Normal file
125
frontend/src/store/authStore.js
Normal 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 }),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
141
frontend/src/styles/index.css
Normal file
141
frontend/src/styles/index.css
Normal 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;
|
||||
}
|
||||
|
||||
71
frontend/tailwind.config.js
Normal file
71
frontend/tailwind.config.js
Normal 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
16
frontend/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user