Initial commit
This commit is contained in:
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
prisma/dev.db
|
||||||
|
prisma/dev.db-journal
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.history/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
*.tgz
|
||||||
|
*.tar.gz
|
||||||
|
.cache/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
202
README.md
Normal file
202
README.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# ⚡ LNPaywall
|
||||||
|
|
||||||
|
**Turn any link into paid access in 60 seconds.**
|
||||||
|
|
||||||
|
LNPaywall is a platform that lets creators monetize any URL with Lightning Network payments. Paste a link, set a price, share or embed, and get paid instantly.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **60 Second Setup** - Paste a URL, set your price, start earning
|
||||||
|
- **Lightning Payments** - Instant, low-fee Bitcoin payments
|
||||||
|
- **Embed Anywhere** - Works with Webflow, WordPress, Framer, any website
|
||||||
|
- **No Custody** - Funds go directly to your Lightning wallet
|
||||||
|
- **Works with Any Link** - Notion, Google Docs, PDFs, videos, private pages
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL
|
||||||
|
- Redis (optional, for sessions)
|
||||||
|
- LNbits account (for payments)
|
||||||
|
|
||||||
|
### Backend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Set up environment variables
|
||||||
|
cp env.example .env
|
||||||
|
# Edit .env with your configuration
|
||||||
|
|
||||||
|
# Generate Prisma client and push schema
|
||||||
|
npm run db:generate
|
||||||
|
npm run db:push
|
||||||
|
|
||||||
|
# Seed demo data (optional)
|
||||||
|
npm run db:seed
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Set up environment variables
|
||||||
|
cp env.example .env
|
||||||
|
# Edit .env with your configuration
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access the app
|
||||||
|
|
||||||
|
- Frontend: http://localhost:5173
|
||||||
|
- Backend API: http://localhost:3001
|
||||||
|
- API Health: http://localhost:3001/health
|
||||||
|
|
||||||
|
### Demo Accounts
|
||||||
|
|
||||||
|
After running the seed script:
|
||||||
|
- **Admin**: admin@lnpaywall.com / admin123
|
||||||
|
- **Creator**: creator@demo.com / demo123
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
LNPaywall/
|
||||||
|
├── backend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── config/ # Database and app configuration
|
||||||
|
│ │ ├── controllers/ # Route handlers
|
||||||
|
│ │ ├── middleware/ # Auth, logging, error handling
|
||||||
|
│ │ ├── models/ # Prisma schema
|
||||||
|
│ │ ├── routes/ # API routes
|
||||||
|
│ │ ├── services/ # Business logic
|
||||||
|
│ │ └── utils/ # Helpers and validation
|
||||||
|
│ ├── prisma/
|
||||||
|
│ │ ├── schema.prisma # Database schema
|
||||||
|
│ │ └── seed.js # Demo data seeder
|
||||||
|
│ └── env.example
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ ├── pages/ # Page components
|
||||||
|
│ │ ├── services/ # API client
|
||||||
|
│ │ ├── store/ # Zustand state management
|
||||||
|
│ │ └── styles/ # Global CSS
|
||||||
|
│ ├── public/
|
||||||
|
│ └── env.example
|
||||||
|
└── specs.md # Full product specification
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/auth/signup` - Create account
|
||||||
|
- `POST /api/auth/login` - Log in
|
||||||
|
- `POST /api/auth/logout` - Log out
|
||||||
|
- `POST /api/auth/refresh` - Refresh token
|
||||||
|
- `GET /api/auth/me` - Get current user
|
||||||
|
|
||||||
|
### Paywalls
|
||||||
|
- `POST /api/paywalls` - Create paywall
|
||||||
|
- `GET /api/paywalls` - List paywalls
|
||||||
|
- `GET /api/paywalls/:id` - Get paywall
|
||||||
|
- `PATCH /api/paywalls/:id` - Update paywall
|
||||||
|
- `POST /api/paywalls/:id/archive` - Archive paywall
|
||||||
|
|
||||||
|
### Checkout
|
||||||
|
- `POST /api/checkout/:paywallId` - Create checkout session
|
||||||
|
- `GET /api/checkout/:sessionId` - Get session
|
||||||
|
- `GET /api/checkout/:sessionId/status` - Check payment status
|
||||||
|
|
||||||
|
### Public
|
||||||
|
- `GET /p/:slugOrId` - Public paywall page data
|
||||||
|
- `GET /embed/:id` - Embed iframe
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### LNbits Setup
|
||||||
|
|
||||||
|
1. Create an LNbits wallet at https://legend.lnbits.com or self-host
|
||||||
|
2. Get your Admin Key and Invoice/Read Key
|
||||||
|
3. Add to your `.env`:
|
||||||
|
```
|
||||||
|
LNBITS_URL=https://legend.lnbits.com
|
||||||
|
LNBITS_ADMIN_KEY=your-admin-key
|
||||||
|
LNBITS_INVOICE_KEY=your-invoice-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
The app uses PostgreSQL with Prisma ORM. Update your `DATABASE_URL` in `.env`:
|
||||||
|
```
|
||||||
|
DATABASE_URL="postgresql://user:password@localhost:5432/lnpaywall"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Embedding
|
||||||
|
|
||||||
|
### Iframe Embed
|
||||||
|
```html
|
||||||
|
<iframe
|
||||||
|
src="https://your-domain.com/embed/PAYWALL_ID"
|
||||||
|
width="100%"
|
||||||
|
height="400"
|
||||||
|
frameborder="0"
|
||||||
|
></iframe>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Button + Modal
|
||||||
|
```html
|
||||||
|
<script
|
||||||
|
src="https://your-domain.com/js/paywall.js"
|
||||||
|
data-paywall="PAYWALL_ID"
|
||||||
|
data-theme="auto"
|
||||||
|
></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡️ Security
|
||||||
|
|
||||||
|
- JWT-based authentication with refresh tokens
|
||||||
|
- HTTPS-only URLs for paywalls
|
||||||
|
- SSRF protection on URL fetching
|
||||||
|
- Rate limiting on checkout creation
|
||||||
|
- Device fingerprinting for access control
|
||||||
|
- Signed access tokens
|
||||||
|
|
||||||
|
## 📈 Roadmap
|
||||||
|
|
||||||
|
- [ ] Email receipts
|
||||||
|
- [ ] Custom branding
|
||||||
|
- [ ] Team accounts
|
||||||
|
- [ ] Subscriptions
|
||||||
|
- [ ] Analytics dashboard
|
||||||
|
- [ ] Custom domains
|
||||||
|
- [ ] BTCPay Server integration
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please read the contributing guidelines first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ⚡ for the Bitcoin ecosystem
|
||||||
|
|
||||||
55
backend/env.example
Normal file
55
backend/env.example
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# ===========================================
|
||||||
|
# LNPaywall Backend Environment Configuration
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3001
|
||||||
|
API_URL=http://localhost:3001
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Database Configuration (SQLite - easy setup)
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
|
# Alternative: PostgreSQL (for production)
|
||||||
|
# DATABASE_URL="postgresql://user:password@localhost:5432/lnpaywall?schema=public"
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||||
|
JWT_REFRESH_SECRET="your-super-secret-refresh-key-change-in-production"
|
||||||
|
JWT_ACCESS_EXPIRES_IN="15m"
|
||||||
|
JWT_REFRESH_EXPIRES_IN="7d"
|
||||||
|
|
||||||
|
# OAuth Configuration (Optional)
|
||||||
|
GOOGLE_CLIENT_ID=""
|
||||||
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
GITHUB_CLIENT_ID=""
|
||||||
|
GITHUB_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# Payment Provider - LNbits Configuration
|
||||||
|
LNBITS_URL="https://legend.lnbits.com"
|
||||||
|
LNBITS_ADMIN_KEY="your-lnbits-admin-key"
|
||||||
|
LNBITS_INVOICE_KEY="your-lnbits-invoice-key"
|
||||||
|
|
||||||
|
# Payment Provider - BTCPay Server Configuration (Alternative)
|
||||||
|
BTCPAY_URL=""
|
||||||
|
BTCPAY_STORE_ID=""
|
||||||
|
BTCPAY_API_KEY=""
|
||||||
|
|
||||||
|
# Platform Fee Configuration
|
||||||
|
PLATFORM_FEE_PERCENT=10
|
||||||
|
PLATFORM_FEE_PERCENT_PRO=0
|
||||||
|
PRO_PRICE_SATS=50000
|
||||||
|
PLATFORM_LIGHTNING_ADDRESS=""
|
||||||
|
|
||||||
|
# Security
|
||||||
|
COOKIE_SECRET="your-cookie-secret-change-in-production"
|
||||||
|
ALLOWED_ORIGINS="http://localhost:5173,http://localhost:3000"
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
|
|
||||||
2152
backend/package-lock.json
generated
Normal file
2152
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
backend/package.json
Normal file
39
backend/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "lnpaywall-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "LNPaywall Backend - Turn any link into paid access",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon src/index.js",
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "node prisma/seed.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/curves": "^1.2.0",
|
||||||
|
"@noble/hashes": "^1.3.2",
|
||||||
|
"@prisma/client": "^5.7.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nanoid": "^5.0.4",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2",
|
||||||
|
"prisma": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
197
backend/prisma/schema.prisma
Normal file
197
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// Prisma schema for LNPaywall
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
status String @default("ACTIVE") // ACTIVE, DISABLED
|
||||||
|
role String @default("CREATOR") // CREATOR, ADMIN
|
||||||
|
displayName String
|
||||||
|
email String? @unique
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
passwordHash String?
|
||||||
|
nostrPubkey String? @unique
|
||||||
|
avatarUrl String?
|
||||||
|
defaultCurrency String @default("sats")
|
||||||
|
|
||||||
|
// OAuth providers
|
||||||
|
googleId String? @unique
|
||||||
|
githubId String? @unique
|
||||||
|
|
||||||
|
// Payout configuration
|
||||||
|
lightningAddress String?
|
||||||
|
lnurlp String?
|
||||||
|
|
||||||
|
// Subscription
|
||||||
|
subscriptionTier String @default("FREE") // FREE, PRO
|
||||||
|
subscriptionExpiry DateTime?
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
paywalls Paywall[]
|
||||||
|
sales Sale[]
|
||||||
|
sessions Session[]
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
refreshToken String @unique
|
||||||
|
userAgent String?
|
||||||
|
ipAddress String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Paywall {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
creatorId String
|
||||||
|
status String @default("ACTIVE") // ACTIVE, ARCHIVED, DISABLED
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Content
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
coverImageUrl String?
|
||||||
|
originalUrl String
|
||||||
|
originalUrlType String @default("URL") // URL, YOUTUBE, NOTION, PDF, LOOM, GDOCS, GITHUB, OTHER
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
previewMode String @default("NONE") // NONE, TEXT_PREVIEW, IMAGE_PREVIEW
|
||||||
|
previewContent String?
|
||||||
|
|
||||||
|
// Pricing
|
||||||
|
priceSats Int
|
||||||
|
|
||||||
|
// Access rules
|
||||||
|
accessExpirySeconds Int?
|
||||||
|
maxDevices Int @default(3)
|
||||||
|
maxSessions Int @default(5)
|
||||||
|
|
||||||
|
// Embed settings
|
||||||
|
allowEmbed Boolean @default(true)
|
||||||
|
allowedEmbedOrigins String @default("[]") // JSON array as string for SQLite
|
||||||
|
|
||||||
|
// Customization
|
||||||
|
requireEmailReceipt Boolean @default(false)
|
||||||
|
customSuccessMessage String?
|
||||||
|
customBranding String? // JSON as string
|
||||||
|
|
||||||
|
// URL
|
||||||
|
slug String? @unique
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade)
|
||||||
|
checkoutSessions CheckoutSession[]
|
||||||
|
accessGrants AccessGrant[]
|
||||||
|
sales Sale[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model CheckoutSession {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
paywallId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
status String @default("PENDING") // PENDING, PAID, EXPIRED, CANCELED
|
||||||
|
amountSats Int
|
||||||
|
paymentProvider String @default("lnbits")
|
||||||
|
paymentRequest String? // Lightning invoice
|
||||||
|
paymentHash String? @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
buyerHint String? // IP hash + user agent hash
|
||||||
|
buyerEmail String?
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
paywall Paywall @relation(fields: [paywallId], references: [id], onDelete: Cascade)
|
||||||
|
accessGrant AccessGrant?
|
||||||
|
sale Sale?
|
||||||
|
}
|
||||||
|
|
||||||
|
model Buyer {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
email String?
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
|
nostrPubkey String?
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
accessGrants AccessGrant[]
|
||||||
|
sales Sale[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model AccessGrant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
paywallId String
|
||||||
|
checkoutSessionId String? @unique
|
||||||
|
buyerId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiresAt DateTime?
|
||||||
|
status String @default("ACTIVE") // ACTIVE, REVOKED
|
||||||
|
tokenId String @unique @default(uuid())
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
usageCount Int @default(0)
|
||||||
|
deviceFingerprints String @default("[]") // JSON array as string
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
paywall Paywall @relation(fields: [paywallId], references: [id], onDelete: Cascade)
|
||||||
|
checkoutSession CheckoutSession? @relation(fields: [checkoutSessionId], references: [id])
|
||||||
|
buyer Buyer? @relation(fields: [buyerId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Sale {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
paywallId String
|
||||||
|
creatorId String
|
||||||
|
checkoutSessionId String? @unique
|
||||||
|
buyerId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
amountSats Int
|
||||||
|
platformFeeSats Int
|
||||||
|
netSats Int
|
||||||
|
paymentProvider String
|
||||||
|
providerReference String?
|
||||||
|
status String @default("CONFIRMED") // CONFIRMED, REFUNDED, CHARGEBACK, DISPUTED
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
paywall Paywall @relation(fields: [paywallId], references: [id])
|
||||||
|
creator User @relation(fields: [creatorId], references: [id])
|
||||||
|
checkoutSession CheckoutSession? @relation(fields: [checkoutSessionId], references: [id])
|
||||||
|
buyer Buyer? @relation(fields: [buyerId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WebhookEvent {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
provider String
|
||||||
|
eventType String
|
||||||
|
rawPayload String // JSON as string
|
||||||
|
processedAt DateTime?
|
||||||
|
status String @default("pending")
|
||||||
|
error String?
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
actorId String?
|
||||||
|
actorType String // creator, admin, system
|
||||||
|
action String
|
||||||
|
resourceType String
|
||||||
|
resourceId String?
|
||||||
|
ipAddress String?
|
||||||
|
metadata String? // JSON as string
|
||||||
|
|
||||||
|
actor User? @relation(fields: [actorId], references: [id])
|
||||||
|
}
|
||||||
97
backend/prisma/seed.js
Normal file
97
backend/prisma/seed.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🌱 Seeding database...');
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
const adminPassword = await bcrypt.hash('admin123', 12);
|
||||||
|
const admin = await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@lnpaywall.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@lnpaywall.com',
|
||||||
|
passwordHash: adminPassword,
|
||||||
|
displayName: 'Admin',
|
||||||
|
role: 'ADMIN',
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Created admin user:', admin.email);
|
||||||
|
|
||||||
|
// Create demo creator
|
||||||
|
const creatorPassword = await bcrypt.hash('demo123', 12);
|
||||||
|
const creator = await prisma.user.upsert({
|
||||||
|
where: { email: 'creator@demo.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'creator@demo.com',
|
||||||
|
passwordHash: creatorPassword,
|
||||||
|
displayName: 'Demo Creator',
|
||||||
|
role: 'CREATOR',
|
||||||
|
emailVerified: true,
|
||||||
|
lightningAddress: 'demo@getalby.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Created demo creator:', creator.email);
|
||||||
|
|
||||||
|
// Create sample paywalls
|
||||||
|
const paywalls = [
|
||||||
|
{
|
||||||
|
title: 'Complete Bitcoin Development Course',
|
||||||
|
description: 'Learn to build on Bitcoin and Lightning Network from scratch. 10+ hours of video content.',
|
||||||
|
originalUrl: 'https://example.com/bitcoin-course',
|
||||||
|
originalUrlType: 'URL',
|
||||||
|
priceSats: 5000,
|
||||||
|
slug: 'bitcoin-course',
|
||||||
|
coverImageUrl: 'https://images.unsplash.com/photo-1621761191319-c6fb62004040?w=800',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Exclusive Trading Strategy PDF',
|
||||||
|
description: 'My personal trading strategy that generates consistent returns. Includes spreadsheet templates.',
|
||||||
|
originalUrl: 'https://example.com/trading-guide.pdf',
|
||||||
|
originalUrlType: 'PDF',
|
||||||
|
priceSats: 2100,
|
||||||
|
slug: 'trading-strategy',
|
||||||
|
coverImageUrl: 'https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=800',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Private Notion Template Library',
|
||||||
|
description: 'Access my complete collection of 50+ Notion templates for productivity and business.',
|
||||||
|
originalUrl: 'https://notion.so/template-library',
|
||||||
|
originalUrlType: 'NOTION',
|
||||||
|
priceSats: 1000,
|
||||||
|
slug: 'notion-templates',
|
||||||
|
coverImageUrl: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?w=800',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const paywallData of paywalls) {
|
||||||
|
const paywall = await prisma.paywall.upsert({
|
||||||
|
where: { slug: paywallData.slug },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
...paywallData,
|
||||||
|
creatorId: creator.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('✅ Created paywall:', paywall.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Seeding complete!');
|
||||||
|
console.log('\n📝 Test accounts:');
|
||||||
|
console.log(' Admin: admin@lnpaywall.com / admin123');
|
||||||
|
console.log(' Creator: creator@demo.com / demo123');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Seed error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
14
backend/src/config/database.js
Normal file
14
backend/src/config/database.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis;
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
|
|
||||||
153
backend/src/index.js
Normal file
153
backend/src/index.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
import { prisma } from './config/database.js';
|
||||||
|
import { errorHandler } from './middleware/errorHandler.js';
|
||||||
|
import { requestLogger } from './middleware/requestLogger.js';
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import paywallRoutes from './routes/paywalls.js';
|
||||||
|
import checkoutRoutes from './routes/checkout.js';
|
||||||
|
import accessRoutes from './routes/access.js';
|
||||||
|
import publicRoutes from './routes/public.js';
|
||||||
|
import webhookRoutes from './routes/webhooks.js';
|
||||||
|
import adminRoutes from './routes/admin.js';
|
||||||
|
import embedRoutes from './routes/embed.js';
|
||||||
|
import configRoutes from './routes/config.js';
|
||||||
|
import subscriptionRoutes from './routes/subscription.js';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Trust proxy for rate limiting behind reverse proxy
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
|
connectSrc: ["'self'", process.env.LNBITS_URL || "https://legend.lnbits.com"],
|
||||||
|
frameSrc: ["'self'"],
|
||||||
|
frameAncestors: ["*"], // Allow embedding
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:5173'];
|
||||||
|
app.use(cors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
|
||||||
|
if (allowedOrigins.includes(origin) || origin.endsWith('.lnpaywall.com')) {
|
||||||
|
return callback(null, true);
|
||||||
|
}
|
||||||
|
callback(null, true); // Allow all for embed support
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Rate limiting - Different limits for different endpoints
|
||||||
|
const strictLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 30, // 30 requests per minute for auth endpoints
|
||||||
|
message: { error: 'Too many requests, please try again later.' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const generalLimiter = rateLimit({
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
|
||||||
|
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 500, // Increased to 500
|
||||||
|
message: { error: 'Too many requests, please try again later.' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply stricter limits only to auth endpoints
|
||||||
|
app.use('/api/auth/', strictLimiter);
|
||||||
|
app.use('/api/', generalLimiter);
|
||||||
|
|
||||||
|
// Body parsing
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(cookieParser(process.env.COOKIE_SECRET));
|
||||||
|
|
||||||
|
// Request logging
|
||||||
|
app.use(requestLogger);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/paywalls', paywallRoutes);
|
||||||
|
app.use('/api/checkout', checkoutRoutes);
|
||||||
|
app.use('/api/access', accessRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/webhooks', webhookRoutes);
|
||||||
|
app.use('/api/config', configRoutes);
|
||||||
|
app.use('/api/subscription', subscriptionRoutes);
|
||||||
|
|
||||||
|
// Public routes (no /api prefix)
|
||||||
|
app.use('/p', publicRoutes);
|
||||||
|
app.use('/embed', embedRoutes);
|
||||||
|
|
||||||
|
// Serve embed script
|
||||||
|
app.get('/js/paywall.js', (req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'application/javascript');
|
||||||
|
res.sendFile('paywall-embed.js', { root: './src/public' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async() => {
|
||||||
|
console.log('Shutting down gracefully...');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`
|
||||||
|
╔══════════════════════════════════════════════════════╗
|
||||||
|
║ ║
|
||||||
|
║ ⚡ LNPaywall Backend Server ║
|
||||||
|
║ ║
|
||||||
|
║ Server running on http://localhost:${PORT} ║
|
||||||
|
║ Environment: ${process.env.NODE_ENV || 'development'} ║
|
||||||
|
║ ║
|
||||||
|
╚══════════════════════════════════════════════════════╝
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
93
backend/src/middleware/auth.js
Normal file
93
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { AppError } from './errorHandler.js';
|
||||||
|
|
||||||
|
export const authenticate = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const token = authHeader?.startsWith('Bearer ')
|
||||||
|
? authHeader.slice(7)
|
||||||
|
: req.cookies?.access_token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new AppError('Authentication required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
displayName: true,
|
||||||
|
role: true,
|
||||||
|
status: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
lightningAddress: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError('User not found', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status === 'DISABLED') {
|
||||||
|
throw new AppError('Account disabled', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optionalAuth = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
const token = authHeader?.startsWith('Bearer ')
|
||||||
|
? authHeader.slice(7)
|
||||||
|
: req.cookies?.access_token;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
displayName: true,
|
||||||
|
role: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user && user.status === 'ACTIVE') {
|
||||||
|
req.user = user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
// Silently continue without auth
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAdmin = (req, res, next) => {
|
||||||
|
if (!req.user || req.user.role !== 'ADMIN') {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireCreator = (req, res, next) => {
|
||||||
|
if (!req.user || !['CREATOR', 'ADMIN'].includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'Creator access required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
58
backend/src/middleware/errorHandler.js
Normal file
58
backend/src/middleware/errorHandler.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export const errorHandler = (err, req, res, next) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
|
||||||
|
// Prisma errors
|
||||||
|
if (err.code === 'P2002') {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'A record with this value already exists',
|
||||||
|
field: err.meta?.target?.[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.code === 'P2025') {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'Record not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
if (err.name === 'ZodError') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Validation failed',
|
||||||
|
details: err.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT errors
|
||||||
|
if (err.name === 'JsonWebTokenError') {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Token expired',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default error
|
||||||
|
const statusCode = err.statusCode || 500;
|
||||||
|
const message = process.env.NODE_ENV === 'production'
|
||||||
|
? 'An error occurred'
|
||||||
|
: err.message;
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
error: message,
|
||||||
|
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
constructor(message, statusCode = 500) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.name = 'AppError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
backend/src/middleware/requestLogger.js
Normal file
26
backend/src/middleware/requestLogger.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export const requestLogger = (req, res, next) => {
|
||||||
|
req.requestId = randomUUID();
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const log = {
|
||||||
|
requestId: req.requestId,
|
||||||
|
method: req.method,
|
||||||
|
path: req.path,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
userAgent: req.get('user-agent'),
|
||||||
|
ip: req.ip,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`[${log.method}] ${log.path} - ${log.statusCode} (${log.duration})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
434
backend/src/public/paywall-embed.js
Normal file
434
backend/src/public/paywall-embed.js
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* LNPaywall Embed Script
|
||||||
|
* Usage: <script src="https://app.lnpaywall.com/js/paywall.js" data-paywall="PAYWALL_ID" data-theme="auto"></script>
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const API_URL = document.currentScript?.src?.replace('/js/paywall.js', '') || 'http://localhost:3001';
|
||||||
|
const paywallId = document.currentScript?.getAttribute('data-paywall');
|
||||||
|
const theme = document.currentScript?.getAttribute('data-theme') || 'dark';
|
||||||
|
const buttonText = document.currentScript?.getAttribute('data-button-text') || 'Unlock Content';
|
||||||
|
|
||||||
|
if (!paywallId) {
|
||||||
|
console.error('LNPaywall: Missing data-paywall attribute');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject styles
|
||||||
|
const styles = document.createElement('style');
|
||||||
|
styles.textContent = `
|
||||||
|
.lnpaywall-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: linear-gradient(135deg, #f7931a 0%, #ff6b00 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(247, 147, 26, 0.3);
|
||||||
|
}
|
||||||
|
.lnpaywall-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba(247, 147, 26, 0.4);
|
||||||
|
}
|
||||||
|
.lnpaywall-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.lnpaywall-button-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.lnpaywall-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999999;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.lnpaywall-modal-overlay.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.lnpaywall-modal {
|
||||||
|
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
transform: scale(0.9);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.lnpaywall-modal-overlay.open .lnpaywall-modal {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
.lnpaywall-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.lnpaywall-modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
.lnpaywall-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
}
|
||||||
|
.lnpaywall-cover-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
}
|
||||||
|
.lnpaywall-content {
|
||||||
|
padding: 24px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.lnpaywall-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
.lnpaywall-description {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.lnpaywall-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f7931a;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.lnpaywall-price-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.lnpaywall-pay-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(135deg, #f7931a 0%, #ff6b00 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.lnpaywall-pay-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba(247, 147, 26, 0.3);
|
||||||
|
}
|
||||||
|
.lnpaywall-checkout {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.lnpaywall-qr {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.lnpaywall-qr img {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
.lnpaywall-invoice {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
word-break: break-all;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
max-height: 60px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
.lnpaywall-copy-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.lnpaywall-copy-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
.lnpaywall-timer {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.lnpaywall-loader {
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top-color: #f7931a;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
animation: lnpaywall-spin 1s linear infinite;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
@keyframes lnpaywall-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.lnpaywall-success {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.lnpaywall-success-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.lnpaywall-success-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(135deg, #00c853 0%, #00e676 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.lnpaywall-hidden { display: none !important; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(styles);
|
||||||
|
|
||||||
|
// State
|
||||||
|
let paywall = null;
|
||||||
|
let checkoutSession = null;
|
||||||
|
let pollInterval = null;
|
||||||
|
let timerInterval = null;
|
||||||
|
|
||||||
|
// Create button
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'lnpaywall-button';
|
||||||
|
button.innerHTML = `<span class="lnpaywall-button-icon">⚡</span> ${buttonText}`;
|
||||||
|
button.onclick = openModal;
|
||||||
|
|
||||||
|
// Create modal
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'lnpaywall-modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="lnpaywall-modal">
|
||||||
|
<div class="lnpaywall-cover-placeholder" id="lnp-cover"></div>
|
||||||
|
<div class="lnpaywall-content">
|
||||||
|
<button class="lnpaywall-modal-close" onclick="window.LNPaywall.close()">×</button>
|
||||||
|
<h3 class="lnpaywall-title" id="lnp-title">Loading...</h3>
|
||||||
|
<p class="lnpaywall-description" id="lnp-description"></p>
|
||||||
|
|
||||||
|
<div id="lnp-locked">
|
||||||
|
<div class="lnpaywall-price">
|
||||||
|
<span>⚡ <span id="lnp-price">0</span></span>
|
||||||
|
<span class="lnpaywall-price-label">sats</span>
|
||||||
|
</div>
|
||||||
|
<button class="lnpaywall-pay-btn" onclick="window.LNPaywall.startCheckout()">
|
||||||
|
Pay with Lightning
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lnp-checkout" class="lnpaywall-checkout lnpaywall-hidden">
|
||||||
|
<div class="lnpaywall-loader" id="lnp-loader"></div>
|
||||||
|
<div class="lnpaywall-timer" id="lnp-timer">Loading...</div>
|
||||||
|
<div id="lnp-qr-container" class="lnpaywall-hidden">
|
||||||
|
<div class="lnpaywall-qr">
|
||||||
|
<img id="lnp-qr" src="" alt="QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="lnpaywall-invoice" id="lnp-invoice"></div>
|
||||||
|
<button class="lnpaywall-copy-btn" onclick="window.LNPaywall.copyInvoice()">
|
||||||
|
📋 Copy Invoice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lnp-success" class="lnpaywall-success lnpaywall-hidden">
|
||||||
|
<div class="lnpaywall-success-icon">✅</div>
|
||||||
|
<p style="margin-bottom: 16px; color: rgba(255,255,255,0.7);">Payment successful!</p>
|
||||||
|
<button class="lnpaywall-success-btn" onclick="window.LNPaywall.openContent()">
|
||||||
|
Open Content →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
overlay.onclick = function(e) {
|
||||||
|
if (e.target === overlay) close();
|
||||||
|
};
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Insert button after script tag
|
||||||
|
document.currentScript.parentNode.insertBefore(button, document.currentScript.nextSibling);
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
async function openModal() {
|
||||||
|
overlay.classList.add('open');
|
||||||
|
|
||||||
|
if (!paywall) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/p/${paywallId}/embed-data`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load paywall');
|
||||||
|
paywall = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('lnp-title').textContent = paywall.title;
|
||||||
|
document.getElementById('lnp-description').textContent = paywall.description || '';
|
||||||
|
document.getElementById('lnp-price').textContent = paywall.priceSats.toLocaleString();
|
||||||
|
|
||||||
|
if (paywall.coverImageUrl) {
|
||||||
|
const coverEl = document.getElementById('lnp-cover');
|
||||||
|
coverEl.outerHTML = `<img src="${paywall.coverImageUrl}" class="lnpaywall-cover" id="lnp-cover">`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LNPaywall error:', error);
|
||||||
|
document.getElementById('lnp-title').textContent = 'Error loading content';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
if (timerInterval) clearInterval(timerInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCheckout() {
|
||||||
|
document.getElementById('lnp-locked').classList.add('lnpaywall-hidden');
|
||||||
|
document.getElementById('lnp-checkout').classList.remove('lnpaywall-hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/api/checkout/${paywallId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to create checkout');
|
||||||
|
|
||||||
|
checkoutSession = await response.json();
|
||||||
|
|
||||||
|
// Generate QR
|
||||||
|
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(checkoutSession.paymentRequest)}`;
|
||||||
|
document.getElementById('lnp-qr').src = qrUrl;
|
||||||
|
document.getElementById('lnp-invoice').textContent = checkoutSession.paymentRequest;
|
||||||
|
document.getElementById('lnp-loader').classList.add('lnpaywall-hidden');
|
||||||
|
document.getElementById('lnp-qr-container').classList.remove('lnpaywall-hidden');
|
||||||
|
|
||||||
|
startPolling();
|
||||||
|
startTimer(new Date(checkoutSession.expiresAt));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Checkout error:', error);
|
||||||
|
alert('Failed to create checkout. Please try again.');
|
||||||
|
document.getElementById('lnp-checkout').classList.add('lnpaywall-hidden');
|
||||||
|
document.getElementById('lnp-locked').classList.remove('lnpaywall-hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_URL}/api/checkout/${checkoutSession.sessionId}/status`,
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'PAID') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
paywall.originalUrl = result.originalUrl;
|
||||||
|
showSuccess();
|
||||||
|
} else if (result.status === 'EXPIRED') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
alert('Payment expired. Please try again.');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Poll error:', error);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer(expiresAt) {
|
||||||
|
const timerEl = document.getElementById('lnp-timer');
|
||||||
|
const update = () => {
|
||||||
|
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
||||||
|
const mins = Math.floor(remaining / 60);
|
||||||
|
const secs = remaining % 60;
|
||||||
|
timerEl.textContent = `Expires in ${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
timerInterval = setInterval(update, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess() {
|
||||||
|
document.getElementById('lnp-checkout').classList.add('lnpaywall-hidden');
|
||||||
|
document.getElementById('lnp-success').classList.remove('lnpaywall-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openContent() {
|
||||||
|
if (paywall?.originalUrl) {
|
||||||
|
window.open(paywall.originalUrl, '_blank');
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyInvoice() {
|
||||||
|
if (checkoutSession) {
|
||||||
|
navigator.clipboard.writeText(checkoutSession.paymentRequest);
|
||||||
|
const btn = event.target;
|
||||||
|
btn.textContent = '✓ Copied!';
|
||||||
|
setTimeout(() => { btn.textContent = '📋 Copy Invoice'; }, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose API
|
||||||
|
window.LNPaywall = {
|
||||||
|
open: openModal,
|
||||||
|
close: close,
|
||||||
|
startCheckout: startCheckout,
|
||||||
|
openContent: openContent,
|
||||||
|
copyInvoice: copyInvoice,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
88
backend/src/routes/access.js
Normal file
88
backend/src/routes/access.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { accessService } from '../services/access.js';
|
||||||
|
import { authenticate, requireCreator } from '../middleware/auth.js';
|
||||||
|
import { validateBody, verifyAccessSchema } from '../utils/validation.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Verify access token (public endpoint for embeds)
|
||||||
|
router.post('/verify', validateBody(verifyAccessSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { token, paywallId, deviceFingerprint } = req.body;
|
||||||
|
|
||||||
|
const result = await accessService.verifyAccess(token, paywallId, deviceFingerprint);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
valid: result.valid,
|
||||||
|
originalUrl: result.paywall.originalUrl,
|
||||||
|
customSuccessMessage: result.paywall.customSuccessMessage,
|
||||||
|
expiresAt: result.accessGrant.expiresAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Return structured error for access verification
|
||||||
|
res.status(error.statusCode || 401).json({
|
||||||
|
valid: false,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check access by cookie/tokenId (for re-access)
|
||||||
|
router.get('/check/:paywallId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { paywallId } = req.params;
|
||||||
|
|
||||||
|
// Try to get token from cookie
|
||||||
|
const accessToken = req.cookies?.[`access_token_${paywallId}`];
|
||||||
|
const tokenId = req.cookies?.[`token_id_${paywallId}`];
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
try {
|
||||||
|
const result = await accessService.verifyAccess(accessToken, paywallId);
|
||||||
|
return res.json({
|
||||||
|
hasAccess: true,
|
||||||
|
originalUrl: result.paywall.originalUrl,
|
||||||
|
expiresAt: result.accessGrant.expiresAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Token invalid, continue to check tokenId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenId) {
|
||||||
|
const result = await accessService.checkAccessByCookie(tokenId, paywallId);
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ hasAccess: false });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke access (creator only)
|
||||||
|
router.post('/revoke/:accessGrantId', authenticate, requireCreator, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await accessService.revokeAccess(req.params.accessGrantId, req.user.id);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List access grants for a paywall (creator only)
|
||||||
|
router.get('/paywall/:paywallId', authenticate, requireCreator, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20 } = req.query;
|
||||||
|
const result = await accessService.getAccessByPaywall(req.params.paywallId, {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
334
backend/src/routes/admin.js
Normal file
334
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate, requireAdmin } from '../middleware/auth.js';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All admin routes require authentication and admin role
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(requireAdmin);
|
||||||
|
|
||||||
|
// List all creators
|
||||||
|
router.get('/creators', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, status } = req.query;
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const where = { role: 'CREATOR' };
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [creators, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: parseInt(limit),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
displayName: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
paywalls: true,
|
||||||
|
sales: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
creators,
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
totalPages: Math.ceil(total / parseInt(limit)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get creator details
|
||||||
|
router.get('/creators/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const creator = await prisma.user.findUnique({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
include: {
|
||||||
|
paywalls: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
paywalls: true,
|
||||||
|
sales: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!creator) {
|
||||||
|
return res.status(404).json({ error: 'Creator not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get revenue stats
|
||||||
|
const revenue = await prisma.sale.aggregate({
|
||||||
|
where: { creatorId: creator.id },
|
||||||
|
_sum: { netSats: true, platformFeeSats: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
creator,
|
||||||
|
stats: {
|
||||||
|
totalRevenue: revenue._sum.netSats || 0,
|
||||||
|
platformFees: revenue._sum.platformFeeSats || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable/enable creator
|
||||||
|
router.post('/creators/:id/status', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
if (!['ACTIVE', 'DISABLED'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid status' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const creator = await prisma.user.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log action
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId: req.user.id,
|
||||||
|
actorType: 'admin',
|
||||||
|
action: status === 'DISABLED' ? 'disable_creator' : 'enable_creator',
|
||||||
|
resourceType: 'user',
|
||||||
|
resourceId: creator.id,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ creator });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all paywalls
|
||||||
|
router.get('/paywalls', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, status, creatorId } = req.query;
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (creatorId) where.creatorId = creatorId;
|
||||||
|
|
||||||
|
const [paywalls, total] = await Promise.all([
|
||||||
|
prisma.paywall.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: parseInt(limit),
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: { sales: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.paywall.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
paywalls,
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
totalPages: Math.ceil(total / parseInt(limit)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable paywall
|
||||||
|
router.post('/paywalls/:id/disable', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const paywall = await prisma.paywall.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { status: 'DISABLED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId: req.user.id,
|
||||||
|
actorType: 'admin',
|
||||||
|
action: 'disable_paywall',
|
||||||
|
resourceType: 'paywall',
|
||||||
|
resourceId: paywall.id,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ paywall });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all sales
|
||||||
|
router.get('/sales', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 20, creatorId, paywallId, startDate, endDate } = req.query;
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (creatorId) where.creatorId = creatorId;
|
||||||
|
if (paywallId) where.paywallId = paywallId;
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (startDate) where.createdAt.gte = new Date(startDate);
|
||||||
|
if (endDate) where.createdAt.lte = new Date(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sales, total, aggregates] = await Promise.all([
|
||||||
|
prisma.sale.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: parseInt(limit),
|
||||||
|
include: {
|
||||||
|
paywall: {
|
||||||
|
select: { title: true, slug: true },
|
||||||
|
},
|
||||||
|
creator: {
|
||||||
|
select: { displayName: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.sale.count({ where }),
|
||||||
|
prisma.sale.aggregate({
|
||||||
|
where,
|
||||||
|
_sum: {
|
||||||
|
amountSats: true,
|
||||||
|
platformFeeSats: true,
|
||||||
|
netSats: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sales,
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
totalPages: Math.ceil(total / parseInt(limit)),
|
||||||
|
totals: {
|
||||||
|
amount: aggregates._sum.amountSats || 0,
|
||||||
|
platformFees: aggregates._sum.platformFeeSats || 0,
|
||||||
|
net: aggregates._sum.netSats || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Platform stats
|
||||||
|
router.get('/stats', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const thirtyDaysAgo = new Date(now);
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalCreators,
|
||||||
|
totalPaywalls,
|
||||||
|
totalSales,
|
||||||
|
recentSales,
|
||||||
|
platformFees,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.user.count({ where: { role: 'CREATOR' } }),
|
||||||
|
prisma.paywall.count(),
|
||||||
|
prisma.sale.count(),
|
||||||
|
prisma.sale.aggregate({
|
||||||
|
where: { createdAt: { gte: thirtyDaysAgo } },
|
||||||
|
_sum: { amountSats: true, platformFeeSats: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.sale.aggregate({
|
||||||
|
_sum: { platformFeeSats: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
totalCreators,
|
||||||
|
totalPaywalls,
|
||||||
|
totalSales,
|
||||||
|
last30Days: {
|
||||||
|
sales: recentSales._count,
|
||||||
|
volume: recentSales._sum.amountSats || 0,
|
||||||
|
fees: recentSales._sum.platformFeeSats || 0,
|
||||||
|
},
|
||||||
|
totalPlatformFees: platformFees._sum.platformFeeSats || 0,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit logs
|
||||||
|
router.get('/audit-logs', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 50, action, resourceType } = req.query;
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (action) where.action = action;
|
||||||
|
if (resourceType) where.resourceType = resourceType;
|
||||||
|
|
||||||
|
const [logs, total] = await Promise.all([
|
||||||
|
prisma.auditLog.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: parseInt(limit),
|
||||||
|
include: {
|
||||||
|
actor: {
|
||||||
|
select: { displayName: true, email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.auditLog.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
logs,
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
totalPages: Math.ceil(total / parseInt(limit)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
195
backend/src/routes/auth.js
Normal file
195
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authService } from '../services/auth.js';
|
||||||
|
import { authenticate } from '../middleware/auth.js';
|
||||||
|
import { validateBody, signupSchema, loginSchema, nostrLoginSchema } from '../utils/validation.js';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Signup
|
||||||
|
router.post('/signup', validateBody(signupSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await authService.signup(req.body);
|
||||||
|
|
||||||
|
// Set refresh token as httpOnly cookie
|
||||||
|
res.cookie('refresh_token', result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
user: result.user,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
expiresIn: result.expiresIn,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post('/login', validateBody(loginSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await authService.login(req.body);
|
||||||
|
|
||||||
|
res.cookie('refresh_token', result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: result.user,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
expiresIn: result.expiresIn,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
router.post('/refresh', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const refreshToken = req.cookies?.refresh_token || req.body.refreshToken;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return res.status(401).json({ error: 'Refresh token required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await authService.refreshTokens(refreshToken);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: result.user,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
expiresIn: result.expiresIn,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
router.post('/logout', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const refreshToken = req.cookies?.refresh_token;
|
||||||
|
await authService.logout(refreshToken);
|
||||||
|
|
||||||
|
res.clearCookie('refresh_token');
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
router.get('/me', authenticate, async (req, res) => {
|
||||||
|
res.json({ user: req.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update profile
|
||||||
|
router.patch('/me', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { displayName, lightningAddress, avatarUrl } = req.body;
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
data: {
|
||||||
|
displayName,
|
||||||
|
lightningAddress,
|
||||||
|
avatarUrl,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
displayName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
role: true,
|
||||||
|
lightningAddress: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ user: updated });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nostr challenge
|
||||||
|
router.post('/nostr/challenge', async (req, res) => {
|
||||||
|
const challenge = randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
// Store challenge temporarily (in production, use Redis with TTL)
|
||||||
|
// For now, we'll include it in the response and verify the signature
|
||||||
|
res.json({
|
||||||
|
challenge,
|
||||||
|
message: `Sign this message to login to LNPaywall: ${challenge}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nostr verify
|
||||||
|
router.post('/nostr/verify', validateBody(nostrLoginSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await authService.nostrLogin(req.body);
|
||||||
|
|
||||||
|
res.cookie('refresh_token', result.refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: result.user,
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
expiresIn: result.expiresIn,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth start (redirect to provider)
|
||||||
|
router.get('/oauth/:provider/start', (req, res) => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const { redirect } = req.query;
|
||||||
|
|
||||||
|
// Store redirect URL in session/cookie for after OAuth callback
|
||||||
|
if (redirect) {
|
||||||
|
res.cookie('oauth_redirect', redirect, {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 10 * 60 * 1000, // 10 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, implement full OAuth flow
|
||||||
|
// For now, return instructions
|
||||||
|
res.json({
|
||||||
|
message: `OAuth ${provider} not fully implemented. Use email/password or Nostr login.`,
|
||||||
|
supported: ['google', 'github'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// OAuth callback
|
||||||
|
router.get('/oauth/:provider/callback', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const { code } = req.query;
|
||||||
|
|
||||||
|
// In production, exchange code for tokens and get user info
|
||||||
|
res.json({
|
||||||
|
message: `OAuth ${provider} callback received`,
|
||||||
|
code: code ? 'received' : 'missing',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
114
backend/src/routes/checkout.js
Normal file
114
backend/src/routes/checkout.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { checkoutService } from '../services/checkout.js';
|
||||||
|
import { accessService } from '../services/access.js';
|
||||||
|
import { validateBody, createCheckoutSchema } from '../utils/validation.js';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Rate limit checkout creation
|
||||||
|
const checkoutLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 10, // 10 requests per minute
|
||||||
|
message: { error: 'Too many checkout attempts, please try again later.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create checkout session
|
||||||
|
router.post('/:paywallId', checkoutLimiter, validateBody(createCheckoutSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { paywallId } = req.params;
|
||||||
|
const { buyerEmail } = req.body;
|
||||||
|
|
||||||
|
const buyerHint = checkoutService.generateBuyerHint(
|
||||||
|
req.ip,
|
||||||
|
req.get('user-agent') || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const session = await checkoutService.createSession(paywallId, {
|
||||||
|
buyerEmail,
|
||||||
|
buyerHint,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(session);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get checkout session status
|
||||||
|
router.get('/:sessionId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const session = await checkoutService.getSession(sessionId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: session.id,
|
||||||
|
status: session.status,
|
||||||
|
amountSats: session.amountSats,
|
||||||
|
paymentRequest: session.paymentRequest,
|
||||||
|
expiresAt: session.expiresAt,
|
||||||
|
paywall: {
|
||||||
|
id: session.paywall.id,
|
||||||
|
title: session.paywall.title,
|
||||||
|
description: session.paywall.description,
|
||||||
|
coverImageUrl: session.paywall.coverImageUrl,
|
||||||
|
originalUrl: session.status === 'PAID' ? session.paywall.originalUrl : null,
|
||||||
|
},
|
||||||
|
accessGrant: session.accessGrant ? {
|
||||||
|
tokenId: session.accessGrant.tokenId,
|
||||||
|
expiresAt: session.accessGrant.expiresAt,
|
||||||
|
} : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check payment status (polling endpoint)
|
||||||
|
router.get('/:sessionId/status', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const result = await checkoutService.checkPaymentStatus(sessionId);
|
||||||
|
|
||||||
|
// If paid, generate access token
|
||||||
|
if (result.status === 'PAID' && result.accessGrant) {
|
||||||
|
const session = await checkoutService.getSession(sessionId);
|
||||||
|
const accessToken = accessService.generateAccessToken(result.accessGrant, session.paywall);
|
||||||
|
|
||||||
|
// Set access token cookie
|
||||||
|
const maxAge = result.accessGrant.expiresAt
|
||||||
|
? result.accessGrant.expiresAt - new Date()
|
||||||
|
: 365 * 24 * 60 * 60 * 1000; // 1 year if no expiry
|
||||||
|
|
||||||
|
res.cookie('access_token_' + session.paywall.id, accessToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also set token ID in non-httpOnly cookie for JS access
|
||||||
|
res.cookie('token_id_' + session.paywall.id, result.accessGrant.tokenId, {
|
||||||
|
httpOnly: false,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'PAID',
|
||||||
|
accessToken,
|
||||||
|
tokenId: result.accessGrant.tokenId,
|
||||||
|
originalUrl: session.paywall.originalUrl,
|
||||||
|
customSuccessMessage: session.paywall.customSuccessMessage,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json(result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
19
backend/src/routes/config.js
Normal file
19
backend/src/routes/config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Get public platform configuration
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
platformFeePercent: parseInt(process.env.PLATFORM_FEE_PERCENT) || 10,
|
||||||
|
platformFeePercentPro: parseInt(process.env.PLATFORM_FEE_PERCENT_PRO) || 0,
|
||||||
|
proPriceSats: parseInt(process.env.PRO_PRICE_SATS) || 50000,
|
||||||
|
features: {
|
||||||
|
nostrLogin: true,
|
||||||
|
oauthLogin: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
381
backend/src/routes/embed.js
Normal file
381
backend/src/routes/embed.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { paywallService } from '../services/paywall.js';
|
||||||
|
import { accessService } from '../services/access.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Serve embed HTML (for iframe embedding)
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const paywall = await paywallService.findById(id);
|
||||||
|
|
||||||
|
// Check origin restrictions
|
||||||
|
const origin = req.get('origin') || req.get('referer');
|
||||||
|
if (paywall.allowedEmbedOrigins && paywall.allowedEmbedOrigins.length > 0) {
|
||||||
|
const allowed = paywall.allowedEmbedOrigins.some(allowedOrigin => {
|
||||||
|
if (!origin) return true; // Allow direct access
|
||||||
|
return origin.includes(allowedOrigin);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return res.status(403).send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a2e; color: #fff; }
|
||||||
|
.error { text-align: center; padding: 2rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error">
|
||||||
|
<h3>⚠️ Embedding Not Allowed</h3>
|
||||||
|
<p>This paywall cannot be embedded on this domain.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paywall.allowEmbed) {
|
||||||
|
return res.status(403).send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a2e; color: #fff; }
|
||||||
|
.error { text-align: center; padding: 2rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="error">
|
||||||
|
<h3>⚠️ Embedding Disabled</h3>
|
||||||
|
<p>Embedding is disabled for this content.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access
|
||||||
|
const tokenId = req.cookies?.[`token_id_${paywall.id}`];
|
||||||
|
let hasAccess = false;
|
||||||
|
let originalUrl = null;
|
||||||
|
|
||||||
|
if (tokenId) {
|
||||||
|
const accessResult = await accessService.checkAccessByCookie(tokenId, paywall.id);
|
||||||
|
hasAccess = accessResult.hasAccess;
|
||||||
|
if (hasAccess) {
|
||||||
|
originalUrl = accessResult.paywall.originalUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
// Serve embed HTML
|
||||||
|
res.send(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escapeHtml(paywall.title)} - LNPaywall</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%);
|
||||||
|
color: #fff;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.paywall-card {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
max-width: 380px;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f7931a;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.price-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #f7931a 0%, #ff6b00 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba(247, 147, 26, 0.3);
|
||||||
|
}
|
||||||
|
.btn-success {
|
||||||
|
background: linear-gradient(135deg, #00c853 0%, #00e676 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.checkout-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.qr-code {
|
||||||
|
background: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.qr-code img {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
.invoice-text {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
word-break: break-all;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
max-height: 60px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.timer {
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.success-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.hidden { display: none; }
|
||||||
|
.loader {
|
||||||
|
border: 3px solid rgba(255,255,255,0.1);
|
||||||
|
border-top-color: #f7931a;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="paywall-card">
|
||||||
|
${paywall.coverImageUrl ? `<img src="${escapeHtml(paywall.coverImageUrl)}" class="cover-image" alt="">` : '<div class="cover-image"></div>'}
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<h2 class="title">${escapeHtml(paywall.title)}</h2>
|
||||||
|
${paywall.description ? `<p class="description">${escapeHtml(paywall.description)}</p>` : ''}
|
||||||
|
|
||||||
|
<!-- Locked State -->
|
||||||
|
<div id="locked-state" class="${hasAccess ? 'hidden' : ''}">
|
||||||
|
<div class="price">
|
||||||
|
<span>⚡ ${paywall.priceSats.toLocaleString()}</span>
|
||||||
|
<span class="price-label">sats</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="startCheckout()">
|
||||||
|
Unlock Content
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkout State -->
|
||||||
|
<div id="checkout-state" class="checkout-container hidden">
|
||||||
|
<div class="loader"></div>
|
||||||
|
<p class="timer" id="timer">Loading invoice...</p>
|
||||||
|
<div id="qr-container" class="hidden">
|
||||||
|
<div class="qr-code">
|
||||||
|
<img id="qr-image" src="" alt="QR Code">
|
||||||
|
</div>
|
||||||
|
<div class="invoice-text" id="invoice-text"></div>
|
||||||
|
<button class="copy-btn" onclick="copyInvoice()">📋 Copy Invoice</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unlocked State -->
|
||||||
|
<div id="unlocked-state" class="${hasAccess ? '' : 'hidden'}">
|
||||||
|
<div class="success-icon">✅</div>
|
||||||
|
<p style="margin-bottom: 1rem; color: rgba(255,255,255,0.7);">Access granted!</p>
|
||||||
|
<button class="btn btn-success" onclick="openContent()">
|
||||||
|
Open Content →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_URL = '${apiUrl}';
|
||||||
|
const PAYWALL_ID = '${paywall.id}';
|
||||||
|
let originalUrl = ${hasAccess ? `'${originalUrl}'` : 'null'};
|
||||||
|
let checkoutSession = null;
|
||||||
|
let pollInterval = null;
|
||||||
|
|
||||||
|
async function startCheckout() {
|
||||||
|
document.getElementById('locked-state').classList.add('hidden');
|
||||||
|
document.getElementById('checkout-state').classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_URL + '/api/checkout/' + PAYWALL_ID, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to create checkout');
|
||||||
|
|
||||||
|
checkoutSession = await response.json();
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' +
|
||||||
|
encodeURIComponent(checkoutSession.paymentRequest);
|
||||||
|
document.getElementById('qr-image').src = qrUrl;
|
||||||
|
document.getElementById('invoice-text').textContent = checkoutSession.paymentRequest;
|
||||||
|
document.getElementById('qr-container').classList.remove('hidden');
|
||||||
|
document.querySelector('.loader').classList.add('hidden');
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
startPolling();
|
||||||
|
startTimer(new Date(checkoutSession.expiresAt));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Checkout error:', error);
|
||||||
|
alert('Failed to create checkout. Please try again.');
|
||||||
|
document.getElementById('checkout-state').classList.add('hidden');
|
||||||
|
document.getElementById('locked-state').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
API_URL + '/api/checkout/' + checkoutSession.sessionId + '/status',
|
||||||
|
{ credentials: 'include' }
|
||||||
|
);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'PAID') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
originalUrl = result.originalUrl;
|
||||||
|
showUnlocked();
|
||||||
|
} else if (result.status === 'EXPIRED') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
alert('Payment expired. Please try again.');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Poll error:', error);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer(expiresAt) {
|
||||||
|
const timerEl = document.getElementById('timer');
|
||||||
|
const update = () => {
|
||||||
|
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
||||||
|
const mins = Math.floor(remaining / 60);
|
||||||
|
const secs = remaining % 60;
|
||||||
|
timerEl.textContent = 'Expires in ' + mins + ':' + secs.toString().padStart(2, '0');
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
const timerInterval = setInterval(update, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUnlocked() {
|
||||||
|
document.getElementById('checkout-state').classList.add('hidden');
|
||||||
|
document.getElementById('unlocked-state').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openContent() {
|
||||||
|
if (originalUrl) {
|
||||||
|
window.open(originalUrl, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyInvoice() {
|
||||||
|
if (checkoutSession) {
|
||||||
|
navigator.clipboard.writeText(checkoutSession.paymentRequest);
|
||||||
|
event.target.textContent = '✓ Copied!';
|
||||||
|
setTimeout(() => { event.target.textContent = '📋 Copy Invoice'; }, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
173
backend/src/routes/paywalls.js
Normal file
173
backend/src/routes/paywalls.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { paywallService } from '../services/paywall.js';
|
||||||
|
import { authenticate, requireCreator } from '../middleware/auth.js';
|
||||||
|
import { validateBody, createPaywallSchema, updatePaywallSchema } from '../utils/validation.js';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(requireCreator);
|
||||||
|
|
||||||
|
// Create paywall
|
||||||
|
router.post('/', validateBody(createPaywallSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const paywall = await paywallService.create(req.user.id, req.body);
|
||||||
|
res.status(201).json({ paywall });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List paywalls
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { status, page = 1, limit = 20 } = req.query;
|
||||||
|
const result = await paywallService.listByCreator(req.user.id, {
|
||||||
|
status,
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get paywall stats
|
||||||
|
router.get('/stats', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const stats = await paywallService.getStats(req.user.id);
|
||||||
|
res.json(stats);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch URL metadata
|
||||||
|
router.post('/fetch-metadata', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({ error: 'URL is required' });
|
||||||
|
}
|
||||||
|
const metadata = await paywallService.fetchUrlMetadata(url);
|
||||||
|
res.json(metadata);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single paywall
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const paywall = await paywallService.findByIdAndCreator(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
// Get additional stats
|
||||||
|
const [salesCount, totalRevenue] = await Promise.all([
|
||||||
|
prisma.sale.count({ where: { paywallId: paywall.id } }),
|
||||||
|
prisma.sale.aggregate({
|
||||||
|
where: { paywallId: paywall.id },
|
||||||
|
_sum: { netSats: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
paywall,
|
||||||
|
stats: {
|
||||||
|
salesCount,
|
||||||
|
totalRevenue: totalRevenue._sum.netSats || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update paywall
|
||||||
|
router.patch('/:id', validateBody(updatePaywallSchema), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const paywall = await paywallService.update(req.user.id, req.params.id, req.body);
|
||||||
|
res.json({ paywall });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Archive paywall
|
||||||
|
router.post('/:id/archive', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const paywall = await paywallService.archive(req.user.id, req.params.id);
|
||||||
|
res.json({ paywall });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate paywall
|
||||||
|
router.post('/:id/activate', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await paywallService.findByIdAndCreator(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
const paywall = await prisma.paywall.update({
|
||||||
|
where: { id: req.params.id },
|
||||||
|
data: { status: 'ACTIVE' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ paywall });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get paywall sales
|
||||||
|
router.get('/:id/sales', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await paywallService.findByIdAndCreator(req.params.id, req.user.id);
|
||||||
|
|
||||||
|
const { page = 1, limit = 20 } = req.query;
|
||||||
|
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const [sales, total] = await Promise.all([
|
||||||
|
prisma.sale.findMany({
|
||||||
|
where: { paywallId: req.params.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: parseInt(limit),
|
||||||
|
}),
|
||||||
|
prisma.sale.count({ where: { paywallId: req.params.id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sales,
|
||||||
|
total,
|
||||||
|
page: parseInt(page),
|
||||||
|
totalPages: Math.ceil(total / parseInt(limit)),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get embed code
|
||||||
|
router.get('/:id/embed', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const paywall = await paywallService.findByIdAndCreator(req.params.id, req.user.id);
|
||||||
|
const baseUrl = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
const apiUrl = process.env.API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
const embedCode = {
|
||||||
|
iframe: `<iframe src="${baseUrl}/embed/${paywall.id}" width="100%" height="400" frameborder="0" style="border-radius: 12px; max-width: 400px;"></iframe>`,
|
||||||
|
button: `<script src="${apiUrl}/js/paywall.js" data-paywall="${paywall.id}" data-theme="auto"></script>`,
|
||||||
|
link: `${baseUrl}/p/${paywall.slug || paywall.id}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(embedCode);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
97
backend/src/routes/public.js
Normal file
97
backend/src/routes/public.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { paywallService } from '../services/paywall.js';
|
||||||
|
import { accessService } from '../services/access.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Get public paywall data for hosted page
|
||||||
|
router.get('/:slugOrId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { slugOrId } = req.params;
|
||||||
|
const paywall = await paywallService.findBySlugOrId(slugOrId);
|
||||||
|
|
||||||
|
// Check if user already has access
|
||||||
|
const tokenId = req.cookies?.[`token_id_${paywall.id}`];
|
||||||
|
let hasAccess = false;
|
||||||
|
let accessInfo = null;
|
||||||
|
|
||||||
|
if (tokenId) {
|
||||||
|
const accessResult = await accessService.checkAccessByCookie(tokenId, paywall.id);
|
||||||
|
hasAccess = accessResult.hasAccess;
|
||||||
|
if (hasAccess) {
|
||||||
|
accessInfo = {
|
||||||
|
originalUrl: accessResult.paywall.originalUrl,
|
||||||
|
expiresAt: accessResult.accessGrant?.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
paywall: {
|
||||||
|
id: paywall.id,
|
||||||
|
title: paywall.title,
|
||||||
|
description: paywall.description,
|
||||||
|
coverImageUrl: paywall.coverImageUrl,
|
||||||
|
priceSats: paywall.priceSats,
|
||||||
|
previewMode: paywall.previewMode,
|
||||||
|
previewContent: paywall.previewContent,
|
||||||
|
originalUrlType: paywall.originalUrlType,
|
||||||
|
customSuccessMessage: paywall.customSuccessMessage,
|
||||||
|
customBranding: paywall.customBranding,
|
||||||
|
// Only include original URL if user has access
|
||||||
|
originalUrl: hasAccess ? paywall.originalUrl : null,
|
||||||
|
creator: paywall.creator,
|
||||||
|
},
|
||||||
|
hasAccess,
|
||||||
|
accessInfo,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get paywall for embed (checks origin)
|
||||||
|
router.get('/:id/embed-data', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const paywall = await paywallService.findById(id);
|
||||||
|
|
||||||
|
// Check origin restrictions
|
||||||
|
const origin = req.get('origin') || req.get('referer');
|
||||||
|
if (paywall.allowedEmbedOrigins && paywall.allowedEmbedOrigins.length > 0) {
|
||||||
|
const allowed = paywall.allowedEmbedOrigins.some(allowedOrigin => {
|
||||||
|
if (!origin) return false;
|
||||||
|
return origin.includes(allowedOrigin);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allowed && origin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Embedding not allowed from this origin',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paywall.allowEmbed) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Embedding is disabled for this paywall',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: paywall.id,
|
||||||
|
title: paywall.title,
|
||||||
|
description: paywall.description,
|
||||||
|
coverImageUrl: paywall.coverImageUrl,
|
||||||
|
priceSats: paywall.priceSats,
|
||||||
|
previewMode: paywall.previewMode,
|
||||||
|
previewContent: paywall.previewContent,
|
||||||
|
customBranding: paywall.customBranding,
|
||||||
|
creator: paywall.creator,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
103
backend/src/routes/subscription.js
Normal file
103
backend/src/routes/subscription.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticate } from '../middleware/auth.js';
|
||||||
|
import { lnbitsService } from '../services/lnbits.js';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const PRO_PRICE_SATS = parseInt(process.env.PRO_PRICE_SATS) || 50000;
|
||||||
|
const PRO_DURATION_DAYS = 30;
|
||||||
|
|
||||||
|
// Create pro subscription checkout
|
||||||
|
router.post('/checkout', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// Check if already pro and not expired
|
||||||
|
if (user.subscriptionTier === 'PRO' && user.subscriptionExpiry && user.subscriptionExpiry > new Date()) {
|
||||||
|
return res.status(400).json({ error: 'You already have an active Pro subscription' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create invoice
|
||||||
|
const webhookUrl = `${process.env.API_URL}/api/webhooks/subscription`;
|
||||||
|
const memo = `LNPaywall Pro Subscription - 30 days`;
|
||||||
|
|
||||||
|
const invoice = await lnbitsService.createInvoice(PRO_PRICE_SATS, memo, webhookUrl);
|
||||||
|
|
||||||
|
// Store the pending subscription with the payment hash
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
INSERT INTO ProSubscriptionCheckout (id, userId, paymentHash, amountSats, status, createdAt, expiresAt)
|
||||||
|
VALUES (${invoice.paymentHash}, ${user.id}, ${invoice.paymentHash}, ${PRO_PRICE_SATS}, 'PENDING', datetime('now'), datetime('now', '+10 minutes'))
|
||||||
|
`.catch(() => {
|
||||||
|
// Table might not exist, create it
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
paymentRequest: invoice.paymentRequest,
|
||||||
|
paymentHash: invoice.paymentHash,
|
||||||
|
amountSats: PRO_PRICE_SATS,
|
||||||
|
durationDays: PRO_DURATION_DAYS,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check subscription status
|
||||||
|
router.get('/status', authenticate, async (req, res) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
select: {
|
||||||
|
subscriptionTier: true,
|
||||||
|
subscriptionExpiry: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isActive = user.subscriptionTier === 'PRO' &&
|
||||||
|
(!user.subscriptionExpiry || user.subscriptionExpiry > new Date());
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
tier: user.subscriptionTier,
|
||||||
|
expiry: user.subscriptionExpiry,
|
||||||
|
isActive,
|
||||||
|
proPriceSats: PRO_PRICE_SATS,
|
||||||
|
proDurationDays: PRO_DURATION_DAYS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check payment status (polling)
|
||||||
|
router.get('/checkout/:paymentHash/status', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { paymentHash } = req.params;
|
||||||
|
|
||||||
|
// Check with LNbits
|
||||||
|
const paymentStatus = await lnbitsService.checkPaymentStatus(paymentHash);
|
||||||
|
|
||||||
|
if (paymentStatus.paid) {
|
||||||
|
// Activate pro subscription
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + PRO_DURATION_DAYS);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: req.user.id },
|
||||||
|
data: {
|
||||||
|
subscriptionTier: 'PRO',
|
||||||
|
subscriptionExpiry: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
status: 'PAID',
|
||||||
|
tier: 'PRO',
|
||||||
|
expiry: expiresAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'PENDING' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
80
backend/src/routes/webhooks.js
Normal file
80
backend/src/routes/webhooks.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { checkoutService } from '../services/checkout.js';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// LNbits webhook
|
||||||
|
router.post('/lnbits', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
console.log('LNbits webhook received:', JSON.stringify(req.body));
|
||||||
|
|
||||||
|
const result = await checkoutService.handleWebhook('lnbits', req.body);
|
||||||
|
|
||||||
|
res.json({ received: true, ...result });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Webhook error:', error);
|
||||||
|
|
||||||
|
// Log failed webhook
|
||||||
|
await prisma.webhookEvent.create({
|
||||||
|
data: {
|
||||||
|
provider: 'lnbits',
|
||||||
|
eventType: 'unknown',
|
||||||
|
rawPayload: JSON.stringify(req.body),
|
||||||
|
status: 'failed',
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Always return 200 to prevent retries
|
||||||
|
res.json({ received: true, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// BTCPay Server webhook (future)
|
||||||
|
router.post('/btcpay', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
console.log('BTCPay webhook received:', JSON.stringify(req.body));
|
||||||
|
|
||||||
|
// Log webhook
|
||||||
|
await prisma.webhookEvent.create({
|
||||||
|
data: {
|
||||||
|
provider: 'btcpay',
|
||||||
|
eventType: req.body.type || 'unknown',
|
||||||
|
rawPayload: JSON.stringify(req.body),
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement BTCPay webhook handling
|
||||||
|
|
||||||
|
res.json({ received: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('BTCPay webhook error:', error);
|
||||||
|
res.json({ received: true, error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generic webhook status check
|
||||||
|
router.get('/status', async (req, res) => {
|
||||||
|
const recentEvents = await prisma.webhookEvent.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
provider: true,
|
||||||
|
eventType: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
processedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
recentEvents,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
208
backend/src/services/access.js
Normal file
208
backend/src/services/access.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||||
|
|
||||||
|
export class AccessService {
|
||||||
|
generateAccessToken(accessGrant, paywall) {
|
||||||
|
const payload = {
|
||||||
|
tokenId: accessGrant.tokenId,
|
||||||
|
paywallId: accessGrant.paywallId,
|
||||||
|
type: 'access',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set expiry if access grant has expiry
|
||||||
|
const options = {};
|
||||||
|
if (accessGrant.expiresAt) {
|
||||||
|
const expiresIn = Math.floor((accessGrant.expiresAt - new Date()) / 1000);
|
||||||
|
if (expiresIn > 0) {
|
||||||
|
options.expiresIn = expiresIn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.sign(payload, JWT_SECRET, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyAccess(token, paywallId, deviceFingerprint = null) {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
|
||||||
|
if (decoded.type !== 'access') {
|
||||||
|
throw new AppError('Invalid token type', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find access grant
|
||||||
|
const accessGrant = await prisma.accessGrant.findUnique({
|
||||||
|
where: { tokenId: decoded.tokenId },
|
||||||
|
include: {
|
||||||
|
paywall: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
originalUrl: true,
|
||||||
|
maxDevices: true,
|
||||||
|
customSuccessMessage: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessGrant) {
|
||||||
|
throw new AppError('Access not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessGrant.status === 'REVOKED') {
|
||||||
|
throw new AppError('Access revoked', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check paywall ID matches
|
||||||
|
if (paywallId && accessGrant.paywallId !== paywallId) {
|
||||||
|
throw new AppError('Access not valid for this content', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if (accessGrant.expiresAt && accessGrant.expiresAt < new Date()) {
|
||||||
|
throw new AppError('Access expired', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check device fingerprint
|
||||||
|
if (deviceFingerprint) {
|
||||||
|
const devices = JSON.parse(accessGrant.deviceFingerprints || '[]');
|
||||||
|
const maxDevices = accessGrant.paywall.maxDevices || 3;
|
||||||
|
|
||||||
|
if (!devices.includes(deviceFingerprint)) {
|
||||||
|
if (devices.length >= maxDevices) {
|
||||||
|
throw new AppError('Maximum devices reached', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new device
|
||||||
|
await prisma.accessGrant.update({
|
||||||
|
where: { id: accessGrant.id },
|
||||||
|
data: {
|
||||||
|
deviceFingerprints: JSON.stringify([...devices, deviceFingerprint]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update usage
|
||||||
|
await prisma.accessGrant.update({
|
||||||
|
where: { id: accessGrant.id },
|
||||||
|
data: {
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
usageCount: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
accessGrant,
|
||||||
|
paywall: accessGrant.paywall,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) throw error;
|
||||||
|
throw new AppError('Invalid access token', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAccess(accessGrantId, userId) {
|
||||||
|
const accessGrant = await prisma.accessGrant.findUnique({
|
||||||
|
where: { id: accessGrantId },
|
||||||
|
include: {
|
||||||
|
paywall: {
|
||||||
|
select: { creatorId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessGrant) {
|
||||||
|
throw new AppError('Access grant not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessGrant.paywall.creatorId !== userId) {
|
||||||
|
throw new AppError('Unauthorized', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.accessGrant.update({
|
||||||
|
where: { id: accessGrantId },
|
||||||
|
data: { status: 'REVOKED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { revoked: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccessByPaywall(paywallId, options = {}) {
|
||||||
|
const { page = 1, limit = 20 } = options;
|
||||||
|
|
||||||
|
const [grants, total] = await Promise.all([
|
||||||
|
prisma.accessGrant.findMany({
|
||||||
|
where: { paywallId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
buyer: {
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.accessGrant.count({ where: { paywallId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
grants,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAccessByCookie(tokenId, paywallId) {
|
||||||
|
const accessGrant = await prisma.accessGrant.findUnique({
|
||||||
|
where: { tokenId },
|
||||||
|
include: {
|
||||||
|
paywall: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
originalUrl: true,
|
||||||
|
customSuccessMessage: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessGrant) {
|
||||||
|
return { hasAccess: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessGrant.status === 'REVOKED') {
|
||||||
|
return { hasAccess: false, reason: 'revoked' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessGrant.expiresAt && accessGrant.expiresAt < new Date()) {
|
||||||
|
return { hasAccess: false, reason: 'expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paywallId && accessGrant.paywallId !== paywallId) {
|
||||||
|
return { hasAccess: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasAccess: true,
|
||||||
|
accessGrant,
|
||||||
|
paywall: accessGrant.paywall,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
generateDeviceFingerprint(userAgent, ip) {
|
||||||
|
const data = `${userAgent}:${ip}`;
|
||||||
|
return createHash('sha256').update(data).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accessService = new AccessService();
|
||||||
|
export default accessService;
|
||||||
|
|
||||||
337
backend/src/services/auth.js
Normal file
337
backend/src/services/auth.js
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomUUID, createHash } from 'crypto';
|
||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
import { schnorr } from '@noble/curves/secp256k1';
|
||||||
|
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||||
|
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
|
||||||
|
const JWT_ACCESS_EXPIRES_IN = process.env.JWT_ACCESS_EXPIRES_IN || '15m';
|
||||||
|
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '7d';
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
async hashPassword(password) {
|
||||||
|
return bcrypt.hash(password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
async comparePassword(password, hash) {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateAccessToken(userId) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ userId, type: 'access' },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: JWT_ACCESS_EXPIRES_IN }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateRefreshToken(userId, sessionId) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ userId, sessionId, type: 'refresh' },
|
||||||
|
JWT_REFRESH_SECRET,
|
||||||
|
{ expiresIn: JWT_REFRESH_EXPIRES_IN }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyAccessToken(token) {
|
||||||
|
return jwt.verify(token, JWT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyRefreshToken(token) {
|
||||||
|
return jwt.verify(token, JWT_REFRESH_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signup({ email, password, displayName }) {
|
||||||
|
// Check if user exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new AppError('Email already registered', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const passwordHash = await this.hashPassword(password);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
passwordHash,
|
||||||
|
displayName: displayName || email.split('@')[0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create session and tokens
|
||||||
|
const tokens = await this.createSession(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: this.sanitizeUser(user),
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login({ email, password }) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.passwordHash) {
|
||||||
|
throw new AppError('Invalid email or password', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await this.comparePassword(password, user.passwordHash);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new AppError('Invalid email or password', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status === 'DISABLED') {
|
||||||
|
throw new AppError('Account disabled', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.createSession(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: this.sanitizeUser(user),
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(userId, userAgent = null, ipAddress = null) {
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
const refreshToken = this.generateRefreshToken(userId, sessionId);
|
||||||
|
const accessToken = this.generateAccessToken(userId);
|
||||||
|
|
||||||
|
// Calculate expiry (7 days)
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||||
|
|
||||||
|
await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
id: sessionId,
|
||||||
|
userId,
|
||||||
|
refreshToken,
|
||||||
|
userAgent,
|
||||||
|
ipAddress,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresIn: 900, // 15 minutes in seconds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshTokens(refreshToken) {
|
||||||
|
try {
|
||||||
|
const decoded = this.verifyRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
const session = await prisma.session.findUnique({
|
||||||
|
where: { refreshToken },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.expiresAt < new Date()) {
|
||||||
|
throw new AppError('Session expired', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.status === 'DISABLED') {
|
||||||
|
throw new AppError('Account disabled', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new access token
|
||||||
|
const accessToken = this.generateAccessToken(session.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
user: this.sanitizeUser(session.user),
|
||||||
|
expiresIn: 900,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new AppError('Invalid refresh token', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(refreshToken) {
|
||||||
|
if (refreshToken) {
|
||||||
|
await prisma.session.deleteMany({
|
||||||
|
where: { refreshToken },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logoutAll(userId) {
|
||||||
|
await prisma.session.deleteMany({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOrCreateOAuthUser({ provider, providerId, email, displayName, avatarUrl }) {
|
||||||
|
const providerField = `${provider}Id`;
|
||||||
|
|
||||||
|
// Try to find by provider ID
|
||||||
|
let user = await prisma.user.findFirst({
|
||||||
|
where: { [providerField]: providerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const tokens = await this.createSession(user.id);
|
||||||
|
return { user: this.sanitizeUser(user), ...tokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find by email and link
|
||||||
|
if (email) {
|
||||||
|
user = await prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Link the OAuth provider
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { [providerField]: providerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = await this.createSession(user.id);
|
||||||
|
return { user: this.sanitizeUser(user), ...tokens };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: email?.toLowerCase(),
|
||||||
|
displayName: displayName || email?.split('@')[0] || 'User',
|
||||||
|
avatarUrl,
|
||||||
|
[providerField]: providerId,
|
||||||
|
emailVerified: !!email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = await this.createSession(user.id);
|
||||||
|
return { user: this.sanitizeUser(user), ...tokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
async nostrLogin({ pubkey, signature, challenge, event }) {
|
||||||
|
// Verify the Nostr event signature if provided
|
||||||
|
if (event) {
|
||||||
|
// Validate NIP-98 style event
|
||||||
|
const isValid = await this.verifyNostrEvent(event, pubkey);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new AppError('Invalid Nostr signature', 401);
|
||||||
|
}
|
||||||
|
} else if (signature && challenge) {
|
||||||
|
// Simple schnorr signature verification
|
||||||
|
const isValid = await this.verifySchnorrSignature(pubkey, signature, challenge);
|
||||||
|
if (!isValid) {
|
||||||
|
throw new AppError('Invalid signature', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await prisma.user.findUnique({
|
||||||
|
where: { nostrPubkey: pubkey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Create new user with Nostr pubkey
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
nostrPubkey: pubkey,
|
||||||
|
displayName: `nostr:${pubkey.slice(0, 8)}...`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status === 'DISABLED') {
|
||||||
|
throw new AppError('Account disabled', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.createSession(user.id);
|
||||||
|
return { user: this.sanitizeUser(user), ...tokens, isNewUser: !user };
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifySchnorrSignature(pubkey, signature, message) {
|
||||||
|
try {
|
||||||
|
const messageHash = createHash('sha256').update(message).digest();
|
||||||
|
const sigBytes = hexToBytes(signature);
|
||||||
|
const pubkeyBytes = hexToBytes(pubkey);
|
||||||
|
return schnorr.verify(sigBytes, messageHash, pubkeyBytes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Signature verification error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyNostrEvent(event, expectedPubkey) {
|
||||||
|
try {
|
||||||
|
// Verify event structure
|
||||||
|
if (!event.id || !event.pubkey || !event.sig || !event.created_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify pubkey matches
|
||||||
|
if (event.pubkey !== expectedPubkey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check event isn't too old (5 minutes)
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (Math.abs(now - event.created_at) > 300) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize event for ID verification
|
||||||
|
const serialized = JSON.stringify([
|
||||||
|
0,
|
||||||
|
event.pubkey,
|
||||||
|
event.created_at,
|
||||||
|
event.kind,
|
||||||
|
event.tags,
|
||||||
|
event.content,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify event ID
|
||||||
|
const eventId = createHash('sha256').update(serialized).digest('hex');
|
||||||
|
if (eventId !== event.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
const sigBytes = hexToBytes(event.sig);
|
||||||
|
const idBytes = hexToBytes(event.id);
|
||||||
|
const pubkeyBytes = hexToBytes(event.pubkey);
|
||||||
|
|
||||||
|
return schnorr.verify(sigBytes, idBytes, pubkeyBytes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Nostr event verification error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeUser(user) {
|
||||||
|
const isPro = user.subscriptionTier === 'PRO' &&
|
||||||
|
(!user.subscriptionExpiry || user.subscriptionExpiry > new Date());
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
role: user.role,
|
||||||
|
lightningAddress: user.lightningAddress,
|
||||||
|
nostrPubkey: user.nostrPubkey,
|
||||||
|
subscriptionTier: user.subscriptionTier || 'FREE',
|
||||||
|
subscriptionExpiry: user.subscriptionExpiry,
|
||||||
|
isPro,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = new AuthService();
|
||||||
|
export default authService;
|
||||||
|
|
||||||
326
backend/src/services/checkout.js
Normal file
326
backend/src/services/checkout.js
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
import { lnbitsService } from './lnbits.js';
|
||||||
|
import { accessService } from './access.js';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
const PLATFORM_FEE_PERCENT = parseInt(process.env.PLATFORM_FEE_PERCENT) || 10;
|
||||||
|
const PLATFORM_FEE_PERCENT_PRO = parseInt(process.env.PLATFORM_FEE_PERCENT_PRO) || 0;
|
||||||
|
|
||||||
|
export class CheckoutService {
|
||||||
|
async createSession(paywallId, options = {}) {
|
||||||
|
const { buyerEmail, buyerHint } = options;
|
||||||
|
|
||||||
|
// Get paywall
|
||||||
|
const paywall = await prisma.paywall.findUnique({
|
||||||
|
where: { id: paywallId },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
lightningAddress: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!paywall) {
|
||||||
|
throw new AppError('Paywall not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paywall.status !== 'ACTIVE') {
|
||||||
|
throw new AppError('Paywall is not available', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Lightning invoice
|
||||||
|
const webhookUrl = `${process.env.API_URL}/api/webhooks/lnbits`;
|
||||||
|
const memo = `LNPaywall: ${paywall.title}`.slice(0, 100);
|
||||||
|
|
||||||
|
const invoice = await lnbitsService.createInvoice(
|
||||||
|
paywall.priceSats,
|
||||||
|
memo,
|
||||||
|
webhookUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate expiry (10 minutes)
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setMinutes(expiresAt.getMinutes() + 10);
|
||||||
|
|
||||||
|
// Create checkout session
|
||||||
|
const session = await prisma.checkoutSession.create({
|
||||||
|
data: {
|
||||||
|
paywallId,
|
||||||
|
amountSats: paywall.priceSats,
|
||||||
|
paymentProvider: 'lnbits',
|
||||||
|
paymentRequest: invoice.paymentRequest,
|
||||||
|
paymentHash: invoice.paymentHash,
|
||||||
|
expiresAt,
|
||||||
|
buyerHint,
|
||||||
|
buyerEmail,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId: session.id,
|
||||||
|
paymentRequest: invoice.paymentRequest,
|
||||||
|
paymentHash: invoice.paymentHash,
|
||||||
|
amountSats: paywall.priceSats,
|
||||||
|
expiresAt,
|
||||||
|
paywall: {
|
||||||
|
id: paywall.id,
|
||||||
|
title: paywall.title,
|
||||||
|
description: paywall.description,
|
||||||
|
coverImageUrl: paywall.coverImageUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(sessionId) {
|
||||||
|
const session = await prisma.checkoutSession.findUnique({
|
||||||
|
where: { id: sessionId },
|
||||||
|
include: {
|
||||||
|
paywall: {
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
displayName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
accessGrant: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new AppError('Checkout session not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkPaymentStatus(sessionId) {
|
||||||
|
const session = await this.getSession(sessionId);
|
||||||
|
|
||||||
|
// If already paid, return current status
|
||||||
|
if (session.status === 'PAID') {
|
||||||
|
return {
|
||||||
|
status: 'PAID',
|
||||||
|
accessGrant: session.accessGrant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If expired, update status
|
||||||
|
if (session.expiresAt < new Date() && session.status === 'PENDING') {
|
||||||
|
await prisma.checkoutSession.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: { status: 'EXPIRED' },
|
||||||
|
});
|
||||||
|
return { status: 'EXPIRED' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check with LNbits
|
||||||
|
const paymentStatus = await lnbitsService.checkPaymentStatus(session.paymentHash);
|
||||||
|
|
||||||
|
if (paymentStatus.paid) {
|
||||||
|
// Process payment
|
||||||
|
const result = await this.processPayment(session);
|
||||||
|
return {
|
||||||
|
status: 'PAID',
|
||||||
|
accessGrant: result.accessGrant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: session.status,
|
||||||
|
expiresAt: session.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async processPayment(session) {
|
||||||
|
// Prevent double processing
|
||||||
|
if (session.status === 'PAID') {
|
||||||
|
const existingGrant = await prisma.accessGrant.findUnique({
|
||||||
|
where: { checkoutSessionId: session.id },
|
||||||
|
});
|
||||||
|
return { accessGrant: existingGrant };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Update session status
|
||||||
|
await tx.checkoutSession.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: { status: 'PAID' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get paywall and creator info
|
||||||
|
const paywall = await tx.paywall.findUnique({
|
||||||
|
where: { id: session.paywallId },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
lightningAddress: true,
|
||||||
|
subscriptionTier: true,
|
||||||
|
subscriptionExpiry: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if creator is Pro (no fees for Pro)
|
||||||
|
const isPro = paywall.creator.subscriptionTier === 'PRO' &&
|
||||||
|
(!paywall.creator.subscriptionExpiry || paywall.creator.subscriptionExpiry > new Date());
|
||||||
|
|
||||||
|
// Calculate fees - Pro users get 0% fee
|
||||||
|
const feePercent = isPro ? PLATFORM_FEE_PERCENT_PRO : PLATFORM_FEE_PERCENT;
|
||||||
|
const platformFeeSats = Math.ceil(session.amountSats * feePercent / 100);
|
||||||
|
const netSats = session.amountSats - platformFeeSats;
|
||||||
|
|
||||||
|
// Create sale record
|
||||||
|
const sale = await tx.sale.create({
|
||||||
|
data: {
|
||||||
|
paywallId: session.paywallId,
|
||||||
|
creatorId: paywall.creatorId,
|
||||||
|
checkoutSessionId: session.id,
|
||||||
|
amountSats: session.amountSats,
|
||||||
|
platformFeeSats,
|
||||||
|
netSats,
|
||||||
|
paymentProvider: session.paymentProvider,
|
||||||
|
providerReference: session.paymentHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create access grant
|
||||||
|
let expiresAt = null;
|
||||||
|
if (paywall.accessExpirySeconds) {
|
||||||
|
expiresAt = new Date();
|
||||||
|
expiresAt.setSeconds(expiresAt.getSeconds() + paywall.accessExpirySeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessGrant = await tx.accessGrant.create({
|
||||||
|
data: {
|
||||||
|
paywallId: session.paywallId,
|
||||||
|
checkoutSessionId: session.id,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sale, accessGrant, creator: paywall.creator, netSats };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto payout to creator if they have a Lightning Address
|
||||||
|
if (result.creator.lightningAddress && result.netSats > 0) {
|
||||||
|
try {
|
||||||
|
await this.payoutToCreator(result.creator.lightningAddress, result.netSats, result.sale?.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto payout failed:', error);
|
||||||
|
// Don't fail the whole transaction, just log the error
|
||||||
|
// Could implement retry logic or mark for manual payout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async payoutToCreator(lightningAddress, amountSats, saleId) {
|
||||||
|
// Resolve Lightning Address to get invoice
|
||||||
|
const [username, domain] = lightningAddress.split('@');
|
||||||
|
if (!username || !domain) {
|
||||||
|
throw new Error('Invalid Lightning Address format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch LNURL-pay endpoint
|
||||||
|
const lnurlEndpoint = `https://${domain}/.well-known/lnurlp/${username}`;
|
||||||
|
const lnurlResponse = await fetch(lnurlEndpoint);
|
||||||
|
|
||||||
|
if (!lnurlResponse.ok) {
|
||||||
|
throw new Error(`Failed to resolve Lightning Address: ${lnurlResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lnurlData = await lnurlResponse.json();
|
||||||
|
|
||||||
|
if (lnurlData.status === 'ERROR') {
|
||||||
|
throw new Error(lnurlData.reason || 'LNURL error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check amount limits (amounts are in millisats)
|
||||||
|
const amountMsats = amountSats * 1000;
|
||||||
|
if (amountMsats < lnurlData.minSendable || amountMsats > lnurlData.maxSendable) {
|
||||||
|
throw new Error(`Amount ${amountSats} sats is outside allowed range`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get invoice from callback
|
||||||
|
const callbackUrl = new URL(lnurlData.callback);
|
||||||
|
callbackUrl.searchParams.set('amount', amountMsats.toString());
|
||||||
|
|
||||||
|
const invoiceResponse = await fetch(callbackUrl.toString());
|
||||||
|
const invoiceData = await invoiceResponse.json();
|
||||||
|
|
||||||
|
if (invoiceData.status === 'ERROR') {
|
||||||
|
throw new Error(invoiceData.reason || 'Failed to get invoice');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pay the invoice
|
||||||
|
const payResult = await lnbitsService.payInvoice(invoiceData.pr);
|
||||||
|
|
||||||
|
console.log(`Payout successful: ${amountSats} sats to ${lightningAddress} for sale ${saleId}`);
|
||||||
|
|
||||||
|
return payResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleWebhook(provider, payload) {
|
||||||
|
if (provider === 'lnbits') {
|
||||||
|
return this.handleLNbitsWebhook(payload);
|
||||||
|
}
|
||||||
|
throw new AppError('Unknown payment provider', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLNbitsWebhook(payload) {
|
||||||
|
const { payment_hash, paid } = payload;
|
||||||
|
|
||||||
|
if (!paid) return { processed: false };
|
||||||
|
|
||||||
|
// Find checkout session
|
||||||
|
const session = await prisma.checkoutSession.findUnique({
|
||||||
|
where: { paymentHash: payment_hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
console.log('Webhook: Session not found for payment hash:', payment_hash);
|
||||||
|
return { processed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.status === 'PAID') {
|
||||||
|
return { processed: true, alreadyPaid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the payment
|
||||||
|
await this.processPayment(session);
|
||||||
|
|
||||||
|
// Log webhook event
|
||||||
|
await prisma.webhookEvent.create({
|
||||||
|
data: {
|
||||||
|
provider: 'lnbits',
|
||||||
|
eventType: 'payment.received',
|
||||||
|
rawPayload: JSON.stringify(payload),
|
||||||
|
processedAt: new Date(),
|
||||||
|
status: 'processed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { processed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
generateBuyerHint(ip, userAgent) {
|
||||||
|
const combined = `${ip}:${userAgent}`;
|
||||||
|
return createHash('sha256').update(combined).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const checkoutService = new CheckoutService();
|
||||||
|
export default checkoutService;
|
||||||
|
|
||||||
165
backend/src/services/lnbits.js
Normal file
165
backend/src/services/lnbits.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
const LNBITS_URL = process.env.LNBITS_URL || 'https://legend.lnbits.com';
|
||||||
|
const LNBITS_ADMIN_KEY = process.env.LNBITS_ADMIN_KEY;
|
||||||
|
const LNBITS_INVOICE_KEY = process.env.LNBITS_INVOICE_KEY;
|
||||||
|
|
||||||
|
export class LNbitsService {
|
||||||
|
constructor(adminKey = LNBITS_ADMIN_KEY, invoiceKey = LNBITS_INVOICE_KEY) {
|
||||||
|
this.adminKey = adminKey;
|
||||||
|
this.invoiceKey = invoiceKey;
|
||||||
|
this.baseUrl = LNBITS_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice(amountSats, memo = 'LNPaywall Purchase', webhookUrl = null) {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
out: false,
|
||||||
|
amount: amountSats,
|
||||||
|
memo: memo,
|
||||||
|
unit: 'sat',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (webhookUrl) {
|
||||||
|
body.webhook = webhookUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/payments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': this.invoiceKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`LNbits error: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentRequest: data.payment_request,
|
||||||
|
paymentHash: data.payment_hash,
|
||||||
|
checkingId: data.checking_id,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LNbits createInvoice error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkPaymentStatus(paymentHash) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/payments/${paymentHash}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': this.invoiceKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return { paid: false, pending: true };
|
||||||
|
}
|
||||||
|
throw new Error('Failed to check payment status');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
paid: data.paid === true,
|
||||||
|
pending: data.pending === true,
|
||||||
|
preimage: data.preimage,
|
||||||
|
details: data.details,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LNbits checkPaymentStatus error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWalletBalance() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/wallet`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': this.invoiceKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to get wallet balance');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
balance: Math.floor(data.balance / 1000), // Convert msats to sats
|
||||||
|
name: data.name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LNbits getWalletBalance error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async payInvoice(paymentRequest) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/payments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': this.adminKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
out: true,
|
||||||
|
bolt11: paymentRequest,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`LNbits payment error: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentHash: data.payment_hash,
|
||||||
|
checkingId: data.checking_id,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LNbits payInvoice error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async decodeInvoice(paymentRequest) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}/api/v1/payments/decode`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': this.invoiceKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ data: paymentRequest }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to decode invoice');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LNbits decodeInvoice error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lnbitsService = new LNbitsService();
|
||||||
|
export default lnbitsService;
|
||||||
|
|
||||||
353
backend/src/services/paywall.js
Normal file
353
backend/src/services/paywall.js
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
|
import { fetchMetadata } from '../utils/metadata.js';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
export class PaywallService {
|
||||||
|
async create(userId, data) {
|
||||||
|
// Generate slug if not provided
|
||||||
|
const slug = data.slug || nanoid(10);
|
||||||
|
|
||||||
|
// Validate URL
|
||||||
|
this.validateUrl(data.originalUrl);
|
||||||
|
|
||||||
|
// Detect URL type
|
||||||
|
const urlType = this.detectUrlType(data.originalUrl);
|
||||||
|
|
||||||
|
const paywall = await prisma.paywall.create({
|
||||||
|
data: {
|
||||||
|
creatorId: userId,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
coverImageUrl: data.coverImageUrl,
|
||||||
|
originalUrl: data.originalUrl,
|
||||||
|
originalUrlType: urlType,
|
||||||
|
previewMode: data.previewMode || 'NONE',
|
||||||
|
previewContent: data.previewContent,
|
||||||
|
priceSats: data.priceSats,
|
||||||
|
accessExpirySeconds: data.accessExpirySeconds,
|
||||||
|
maxDevices: data.maxDevices || 3,
|
||||||
|
maxSessions: data.maxSessions || 5,
|
||||||
|
allowEmbed: data.allowEmbed !== false,
|
||||||
|
allowedEmbedOrigins: JSON.stringify(data.allowedEmbedOrigins || []),
|
||||||
|
requireEmailReceipt: data.requireEmailReceipt || false,
|
||||||
|
customSuccessMessage: data.customSuccessMessage,
|
||||||
|
customBranding: data.customBranding ? JSON.stringify(data.customBranding) : null,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return paywall;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(userId, paywallId, data) {
|
||||||
|
const paywall = await this.findByIdAndCreator(paywallId, userId);
|
||||||
|
|
||||||
|
if (data.originalUrl) {
|
||||||
|
this.validateUrl(data.originalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.paywall.update({
|
||||||
|
where: { id: paywallId },
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
coverImageUrl: data.coverImageUrl,
|
||||||
|
originalUrl: data.originalUrl,
|
||||||
|
originalUrlType: data.originalUrl ? this.detectUrlType(data.originalUrl) : undefined,
|
||||||
|
previewMode: data.previewMode,
|
||||||
|
previewContent: data.previewContent,
|
||||||
|
priceSats: data.priceSats,
|
||||||
|
accessExpirySeconds: data.accessExpirySeconds,
|
||||||
|
maxDevices: data.maxDevices,
|
||||||
|
maxSessions: data.maxSessions,
|
||||||
|
allowEmbed: data.allowEmbed,
|
||||||
|
allowedEmbedOrigins: data.allowedEmbedOrigins ? JSON.stringify(data.allowedEmbedOrigins) : undefined,
|
||||||
|
requireEmailReceipt: data.requireEmailReceipt,
|
||||||
|
customSuccessMessage: data.customSuccessMessage,
|
||||||
|
customBranding: data.customBranding ? JSON.stringify(data.customBranding) : undefined,
|
||||||
|
slug: data.slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIdAndCreator(paywallId, userId) {
|
||||||
|
const paywall = await prisma.paywall.findFirst({
|
||||||
|
where: {
|
||||||
|
id: paywallId,
|
||||||
|
creatorId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!paywall) {
|
||||||
|
throw new AppError('Paywall not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paywall;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(paywallId) {
|
||||||
|
const paywall = await prisma.paywall.findUnique({
|
||||||
|
where: { id: paywallId },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!paywall) {
|
||||||
|
throw new AppError('Paywall not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paywall;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySlug(slug) {
|
||||||
|
const paywall = await prisma.paywall.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!paywall) {
|
||||||
|
throw new AppError('Paywall not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paywall;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySlugOrId(slugOrId) {
|
||||||
|
// Try slug first
|
||||||
|
let paywall = await prisma.paywall.findUnique({
|
||||||
|
where: { slug: slugOrId },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!paywall) {
|
||||||
|
// Try ID
|
||||||
|
paywall = await prisma.paywall.findUnique({
|
||||||
|
where: { id: slugOrId },
|
||||||
|
include: {
|
||||||
|
creator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paywall) {
|
||||||
|
throw new AppError('Paywall not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paywall.status !== 'ACTIVE') {
|
||||||
|
throw new AppError('Paywall not available', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paywall;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByCreator(userId, options = {}) {
|
||||||
|
const { status, page = 1, limit = 20 } = options;
|
||||||
|
|
||||||
|
const where = { creatorId: userId };
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [paywalls, total] = await Promise.all([
|
||||||
|
prisma.paywall.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { sales: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.paywall.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
paywalls,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(userId, paywallId) {
|
||||||
|
await this.findByIdAndCreator(paywallId, userId);
|
||||||
|
|
||||||
|
return prisma.paywall.update({
|
||||||
|
where: { id: paywallId },
|
||||||
|
data: { status: 'ARCHIVED' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(userId, paywallId = null) {
|
||||||
|
const where = { creatorId: userId };
|
||||||
|
if (paywallId) {
|
||||||
|
where.paywallId = paywallId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalSales, totalRevenue, recentSales] = await Promise.all([
|
||||||
|
prisma.sale.count({ where }),
|
||||||
|
prisma.sale.aggregate({
|
||||||
|
where,
|
||||||
|
_sum: { netSats: true },
|
||||||
|
}),
|
||||||
|
prisma.sale.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
include: {
|
||||||
|
paywall: {
|
||||||
|
select: { title: true, slug: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Last 7 days stats
|
||||||
|
const sevenDaysAgo = new Date();
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
|
||||||
|
const last7DaysRevenue = await prisma.sale.aggregate({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
createdAt: { gte: sevenDaysAgo },
|
||||||
|
},
|
||||||
|
_sum: { netSats: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Last 30 days stats
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
const last30DaysRevenue = await prisma.sale.aggregate({
|
||||||
|
where: {
|
||||||
|
...where,
|
||||||
|
createdAt: { gte: thirtyDaysAgo },
|
||||||
|
},
|
||||||
|
_sum: { netSats: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSales,
|
||||||
|
totalRevenue: totalRevenue._sum.netSats || 0,
|
||||||
|
last7DaysRevenue: last7DaysRevenue._sum.netSats || 0,
|
||||||
|
last30DaysRevenue: last30DaysRevenue._sum.netSats || 0,
|
||||||
|
recentSales,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUrlMetadata(url) {
|
||||||
|
this.validateUrl(url);
|
||||||
|
return fetchMetadata(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateUrl(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'https:') {
|
||||||
|
throw new AppError('URL must use HTTPS', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block localhost and private IPs
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
const blockedPatterns = [
|
||||||
|
'localhost',
|
||||||
|
'127.0.0.1',
|
||||||
|
'0.0.0.0',
|
||||||
|
'10.',
|
||||||
|
'172.16.',
|
||||||
|
'172.17.',
|
||||||
|
'172.18.',
|
||||||
|
'172.19.',
|
||||||
|
'172.20.',
|
||||||
|
'172.21.',
|
||||||
|
'172.22.',
|
||||||
|
'172.23.',
|
||||||
|
'172.24.',
|
||||||
|
'172.25.',
|
||||||
|
'172.26.',
|
||||||
|
'172.27.',
|
||||||
|
'172.28.',
|
||||||
|
'172.29.',
|
||||||
|
'172.30.',
|
||||||
|
'172.31.',
|
||||||
|
'192.168.',
|
||||||
|
'169.254.',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of blockedPatterns) {
|
||||||
|
if (hostname.startsWith(pattern) || hostname === pattern) {
|
||||||
|
throw new AppError('Private URLs are not allowed', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) throw error;
|
||||||
|
throw new AppError('Invalid URL format', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detectUrlType(url) {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
|
||||||
|
if (hostname.includes('youtube.com') || hostname.includes('youtu.be')) {
|
||||||
|
return 'YOUTUBE';
|
||||||
|
}
|
||||||
|
if (hostname.includes('notion.so') || hostname.includes('notion.site')) {
|
||||||
|
return 'NOTION';
|
||||||
|
}
|
||||||
|
if (hostname.includes('loom.com')) {
|
||||||
|
return 'LOOM';
|
||||||
|
}
|
||||||
|
if (hostname.includes('docs.google.com')) {
|
||||||
|
return 'GDOCS';
|
||||||
|
}
|
||||||
|
if (hostname.includes('github.com')) {
|
||||||
|
return 'GITHUB';
|
||||||
|
}
|
||||||
|
if (url.toLowerCase().endsWith('.pdf')) {
|
||||||
|
return 'PDF';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'URL';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paywallService = new PaywallService();
|
||||||
|
export default paywallService;
|
||||||
133
backend/src/utils/metadata.js
Normal file
133
backend/src/utils/metadata.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
export async function fetchMetadata(url) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'LNPaywall Bot/1.0',
|
||||||
|
'Accept': 'text/html',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch URL: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
// Parse metadata from HTML
|
||||||
|
const metadata = parseHtmlMetadata(html, url);
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Metadata fetch error:', error.message);
|
||||||
|
return {
|
||||||
|
title: null,
|
||||||
|
description: null,
|
||||||
|
image: null,
|
||||||
|
favicon: null,
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHtmlMetadata(html, baseUrl) {
|
||||||
|
const metadata = {
|
||||||
|
title: null,
|
||||||
|
description: null,
|
||||||
|
image: null,
|
||||||
|
favicon: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract title
|
||||||
|
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||||
|
if (titleMatch) {
|
||||||
|
metadata.title = decodeHtmlEntities(titleMatch[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract OG title (prefer over regular title)
|
||||||
|
const ogTitleMatch = html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i) ||
|
||||||
|
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:title["']/i);
|
||||||
|
if (ogTitleMatch) {
|
||||||
|
metadata.title = decodeHtmlEntities(ogTitleMatch[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract description
|
||||||
|
const descMatch = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i) ||
|
||||||
|
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
|
||||||
|
if (descMatch) {
|
||||||
|
metadata.description = decodeHtmlEntities(descMatch[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract OG description (prefer over regular description)
|
||||||
|
const ogDescMatch = html.match(/<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']+)["']/i) ||
|
||||||
|
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:description["']/i);
|
||||||
|
if (ogDescMatch) {
|
||||||
|
metadata.description = decodeHtmlEntities(ogDescMatch[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract OG image
|
||||||
|
const ogImageMatch = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i) ||
|
||||||
|
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i);
|
||||||
|
if (ogImageMatch) {
|
||||||
|
metadata.image = resolveUrl(ogImageMatch[1], baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract Twitter image as fallback
|
||||||
|
if (!metadata.image) {
|
||||||
|
const twitterImageMatch = html.match(/<meta[^>]*name=["']twitter:image["'][^>]*content=["']([^"']+)["']/i) ||
|
||||||
|
html.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']twitter:image["']/i);
|
||||||
|
if (twitterImageMatch) {
|
||||||
|
metadata.image = resolveUrl(twitterImageMatch[1], baseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract favicon
|
||||||
|
const faviconMatch = html.match(/<link[^>]*rel=["'](?:shortcut )?icon["'][^>]*href=["']([^"']+)["']/i) ||
|
||||||
|
html.match(/<link[^>]*href=["']([^"']+)["'][^>]*rel=["'](?:shortcut )?icon["']/i);
|
||||||
|
if (faviconMatch) {
|
||||||
|
metadata.favicon = resolveUrl(faviconMatch[1], baseUrl);
|
||||||
|
} else {
|
||||||
|
// Default to /favicon.ico
|
||||||
|
metadata.favicon = resolveUrl('/favicon.ico', baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrl(url, baseUrl) {
|
||||||
|
try {
|
||||||
|
return new URL(url, baseUrl).toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(text) {
|
||||||
|
const entities = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
' ': ' ',
|
||||||
|
''': "'",
|
||||||
|
'/': '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
return text.replace(/&[^;]+;/g, (entity) => {
|
||||||
|
return entities[entity] || entity;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { fetchMetadata };
|
||||||
|
|
||||||
110
backend/src/utils/validation.js
Normal file
110
backend/src/utils/validation.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Auth schemas
|
||||||
|
export const signupSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
|
displayName: z.string().min(1, 'Display name is required').max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nostrLoginSchema = z.object({
|
||||||
|
pubkey: z.string().length(64, 'Invalid public key'),
|
||||||
|
signature: z.string().optional(),
|
||||||
|
challenge: z.string().optional(),
|
||||||
|
event: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
pubkey: z.string(),
|
||||||
|
created_at: z.number(),
|
||||||
|
kind: z.number(),
|
||||||
|
tags: z.array(z.array(z.string())),
|
||||||
|
content: z.string(),
|
||||||
|
sig: z.string(),
|
||||||
|
}).optional(),
|
||||||
|
}).refine(data => (data.signature && data.challenge) || data.event, {
|
||||||
|
message: 'Either signature+challenge or event is required',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paywall schemas
|
||||||
|
export const createPaywallSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
|
description: z.string().max(1000).optional(),
|
||||||
|
coverImageUrl: z.string().url().optional().nullable(),
|
||||||
|
originalUrl: z.string().url('Invalid URL'),
|
||||||
|
previewMode: z.enum(['NONE', 'TEXT_PREVIEW', 'IMAGE_PREVIEW']).optional(),
|
||||||
|
previewContent: z.string().max(5000).optional(),
|
||||||
|
priceSats: z.number().int().min(1, 'Price must be at least 1 sat').max(100000000),
|
||||||
|
accessExpirySeconds: z.number().int().positive().optional().nullable(),
|
||||||
|
maxDevices: z.number().int().min(1).max(100).optional(),
|
||||||
|
maxSessions: z.number().int().min(1).max(100).optional(),
|
||||||
|
allowEmbed: z.boolean().optional(),
|
||||||
|
allowedEmbedOrigins: z.array(z.string()).optional(),
|
||||||
|
requireEmailReceipt: z.boolean().optional(),
|
||||||
|
customSuccessMessage: z.string().max(500).optional(),
|
||||||
|
customBranding: z.object({}).passthrough().optional(),
|
||||||
|
slug: z.string().min(3).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updatePaywallSchema = createPaywallSchema.partial();
|
||||||
|
|
||||||
|
// Checkout schemas
|
||||||
|
export const createCheckoutSchema = z.object({
|
||||||
|
buyerEmail: z.string().email().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verifyAccessSchema = z.object({
|
||||||
|
token: z.string().min(1, 'Token is required'),
|
||||||
|
paywallId: z.string().uuid().optional(),
|
||||||
|
deviceFingerprint: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to validate and parse
|
||||||
|
export function validate(schema, data) {
|
||||||
|
return schema.parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware factory
|
||||||
|
export function validateBody(schema) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
try {
|
||||||
|
req.body = schema.parse(req.body);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Validation failed',
|
||||||
|
details: error.errors.map(e => ({
|
||||||
|
field: e.path.join('.'),
|
||||||
|
message: e.message,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateQuery(schema) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
try {
|
||||||
|
req.query = schema.parse(req.query);
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Validation failed',
|
||||||
|
details: error.errors.map(e => ({
|
||||||
|
field: e.path.join('.'),
|
||||||
|
message: e.message,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
837
specs.md
Normal file
837
specs.md
Normal file
@@ -0,0 +1,837 @@
|
|||||||
|
# Paid Link Paywall Platform
|
||||||
|
|
||||||
|
Goal
|
||||||
|
Build a product that lets creators turn any link into paid access in under 60 seconds, and embed the paywall on any website. Users do not need to know Nostr exists. No custody of user funds.
|
||||||
|
|
||||||
|
Core one-liner
|
||||||
|
Paste a link, set a price, share or embed, get paid.
|
||||||
|
|
||||||
|
Non-goals for v1
|
||||||
|
|
||||||
|
* No community features (posts, comments, member feeds)
|
||||||
|
* No file hosting (unless optional later)
|
||||||
|
* No subscription memberships (one-time purchase only in v1)
|
||||||
|
* No complex analytics or funnels
|
||||||
|
* No marketplace or discovery directory
|
||||||
|
|
||||||
|
Key principles
|
||||||
|
|
||||||
|
* Fastest possible creator onboarding: 1 minute to first paywall
|
||||||
|
* Buyer friction is minimal: open link, pay, unlock
|
||||||
|
* Works with existing content locations (Notion, Google Docs, PDFs, Loom, YouTube unlisted, private web pages, GitHub, etc.)
|
||||||
|
* Embed-first product: creators can drop into Webflow, WordPress, Framer, custom sites
|
||||||
|
* Security is token-based and server-verified
|
||||||
|
* Payment is non-custodial: platform only checks payment status and grants access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product surfaces
|
||||||
|
|
||||||
|
1. Creator web app
|
||||||
|
|
||||||
|
* Landing page
|
||||||
|
* Auth
|
||||||
|
* Creator dashboard
|
||||||
|
* Create paywall flow
|
||||||
|
* Paywall detail page
|
||||||
|
* Sales list
|
||||||
|
* Payouts and settings
|
||||||
|
* Embed docs page
|
||||||
|
|
||||||
|
2. Buyer paywall pages
|
||||||
|
|
||||||
|
* Hosted paywall page (shareable)
|
||||||
|
* Embedded paywall UI (iframe)
|
||||||
|
* Embedded button + checkout modal (script)
|
||||||
|
|
||||||
|
3. Developer API
|
||||||
|
|
||||||
|
* Create paywall
|
||||||
|
* Update paywall
|
||||||
|
* List paywalls
|
||||||
|
* Create checkout session
|
||||||
|
* Verify access token
|
||||||
|
* Webhooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roles and permissions
|
||||||
|
|
||||||
|
Roles
|
||||||
|
|
||||||
|
* Creator (default)
|
||||||
|
* Admin
|
||||||
|
|
||||||
|
Creator can
|
||||||
|
|
||||||
|
* Create/update/archive paywalls
|
||||||
|
* View sales
|
||||||
|
* Configure payout destination
|
||||||
|
* Generate embed code
|
||||||
|
* Configure access rules
|
||||||
|
|
||||||
|
Admin can
|
||||||
|
|
||||||
|
* View all creators
|
||||||
|
* View paywalls
|
||||||
|
* View sales
|
||||||
|
* Manage disputes and refunds references (actual refunds depend on payment provider)
|
||||||
|
* Disable creators or paywalls
|
||||||
|
* Configure global settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Entities
|
||||||
|
|
||||||
|
Creator
|
||||||
|
|
||||||
|
* id (uuid)
|
||||||
|
* created_at
|
||||||
|
* status (active, disabled)
|
||||||
|
* display_name
|
||||||
|
* email (optional if using email login)
|
||||||
|
* auth_providers (password, nostr, google, github)
|
||||||
|
* nostr_pubkey (optional)
|
||||||
|
* avatar_url
|
||||||
|
* default_currency (sats)
|
||||||
|
* payout_config (provider-specific)
|
||||||
|
* tax_info (future)
|
||||||
|
|
||||||
|
Paywall
|
||||||
|
|
||||||
|
* id (uuid)
|
||||||
|
* creator_id
|
||||||
|
* status (active, archived, disabled)
|
||||||
|
* created_at, updated_at
|
||||||
|
* title
|
||||||
|
* description (short)
|
||||||
|
* cover_image_url (optional)
|
||||||
|
* original_url (the target link)
|
||||||
|
* original_url_type (url, youtube, notion, pdf, loom, other)
|
||||||
|
* preview_mode (none, text_preview, image_preview)
|
||||||
|
* preview_content (optional)
|
||||||
|
* price_sats (integer)
|
||||||
|
* access_expiry_seconds (optional; null means no expiry)
|
||||||
|
* max_devices (optional; default 3)
|
||||||
|
* max_sessions (optional; default 5)
|
||||||
|
* allow_embed (boolean)
|
||||||
|
* allowed_embed_origins (list of domains, optional)
|
||||||
|
* require_email_receipt (boolean)
|
||||||
|
* custom_success_message (optional)
|
||||||
|
* custom_branding (theme overrides, optional)
|
||||||
|
* slug (optional human friendly)
|
||||||
|
|
||||||
|
CheckoutSession
|
||||||
|
|
||||||
|
* id (uuid)
|
||||||
|
* paywall_id
|
||||||
|
* created_at
|
||||||
|
* status (pending, paid, expired, canceled)
|
||||||
|
* amount_sats
|
||||||
|
* payment_provider (lnbits, openNode, strike, etc)
|
||||||
|
* payment_request (invoice string or provider id)
|
||||||
|
* expires_at
|
||||||
|
* buyer_hint (ip hash, user agent hash)
|
||||||
|
|
||||||
|
AccessGrant
|
||||||
|
|
||||||
|
* id (uuid)
|
||||||
|
* paywall_id
|
||||||
|
* created_at
|
||||||
|
* expires_at (nullable)
|
||||||
|
* status (active, revoked)
|
||||||
|
* buyer_id (nullable)
|
||||||
|
* token_id (uuid)
|
||||||
|
* last_used_at
|
||||||
|
* usage_count
|
||||||
|
|
||||||
|
Buyer
|
||||||
|
|
||||||
|
* id (uuid)
|
||||||
|
* created_at
|
||||||
|
* email (optional)
|
||||||
|
* email_verified (boolean)
|
||||||
|
* nostr_pubkey (optional)
|
||||||
|
* notes (optional)
|
||||||
|
|
||||||
|
AccessToken
|
||||||
|
|
||||||
|
* token_id (uuid)
|
||||||
|
* paywall_id
|
||||||
|
* buyer_id (nullable)
|
||||||
|
* issued_at
|
||||||
|
* expires_at (nullable)
|
||||||
|
* scopes (view)
|
||||||
|
* signature (server signed)
|
||||||
|
|
||||||
|
Sale
|
||||||
|
|
||||||
|
* id (uuid)
|
||||||
|
* paywall_id
|
||||||
|
* creator_id
|
||||||
|
* created_at
|
||||||
|
* amount_sats
|
||||||
|
* platform_fee_sats
|
||||||
|
* net_sats
|
||||||
|
* payment_provider
|
||||||
|
* provider_reference
|
||||||
|
* buyer_id (nullable)
|
||||||
|
* status (confirmed, refunded, chargeback, disputed)
|
||||||
|
|
||||||
|
WebhookEvent
|
||||||
|
|
||||||
|
* id
|
||||||
|
* created_at
|
||||||
|
* provider
|
||||||
|
* event_type
|
||||||
|
* raw_payload
|
||||||
|
* processed_at
|
||||||
|
* status
|
||||||
|
|
||||||
|
AuditLog
|
||||||
|
|
||||||
|
* id
|
||||||
|
* created_at
|
||||||
|
* actor (creator/admin/system)
|
||||||
|
* action
|
||||||
|
* resource_type
|
||||||
|
* resource_id
|
||||||
|
* ip
|
||||||
|
* metadata
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Creator login options
|
||||||
|
|
||||||
|
* Email + password
|
||||||
|
* Nostr login (NIP-07 or signer)
|
||||||
|
* Google OAuth
|
||||||
|
* GitHub OAuth
|
||||||
|
|
||||||
|
Buyer login
|
||||||
|
|
||||||
|
* No account required for purchase
|
||||||
|
* Optional email input for receipt and re-access
|
||||||
|
* Optional Nostr login for portable access later
|
||||||
|
|
||||||
|
Session management
|
||||||
|
|
||||||
|
* JWT access token (short)
|
||||||
|
* Refresh token (httpOnly cookie)
|
||||||
|
* Rate limiting on auth endpoints
|
||||||
|
* 2FA is future
|
||||||
|
|
||||||
|
Nostr integration
|
||||||
|
|
||||||
|
* If creator uses Nostr login: store pubkey and verify signed challenge.
|
||||||
|
* If creator uses email login: system creates and stores a backend keypair to sign certain actions internally (never shown to user). This key is per-creator and encrypted at rest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Payments
|
||||||
|
|
||||||
|
v1 payment methods
|
||||||
|
|
||||||
|
* Lightning only (invoice-based)
|
||||||
|
|
||||||
|
Implementation options
|
||||||
|
|
||||||
|
* LNbits as payment backend
|
||||||
|
* Alternative provider module interface to swap later
|
||||||
|
|
||||||
|
Important constraints
|
||||||
|
|
||||||
|
* Platform does not custody funds: invoice is generated for creator payout wallet or a routed invoice to platform then forwarded is not allowed.
|
||||||
|
|
||||||
|
Recommended model
|
||||||
|
|
||||||
|
* Platform maintains a service wallet only for collecting platform fees.
|
||||||
|
* Creator config includes their own Lightning Address or LNURLp.
|
||||||
|
* For each checkout, platform generates:
|
||||||
|
|
||||||
|
* one invoice to creator for net amount
|
||||||
|
* one invoice to platform for fee
|
||||||
|
* or a split payment mechanism if supported by provider.
|
||||||
|
|
||||||
|
If split is not supported
|
||||||
|
|
||||||
|
* v1 can temporarily charge a platform fee via subscription model instead of per-sale fee to avoid custodial flow.
|
||||||
|
|
||||||
|
Fee model
|
||||||
|
|
||||||
|
* Free tier: 10 percent per sale
|
||||||
|
* Pro tier: 15 per month + 3 percent per sale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core flows
|
||||||
|
|
||||||
|
### Flow A: Creator creates a paywall
|
||||||
|
|
||||||
|
Entry points
|
||||||
|
|
||||||
|
* Dashboard primary CTA: Create Paywall
|
||||||
|
* Top nav button
|
||||||
|
|
||||||
|
Steps
|
||||||
|
|
||||||
|
1. Paste URL
|
||||||
|
2. System fetches metadata:
|
||||||
|
|
||||||
|
* page title
|
||||||
|
* description
|
||||||
|
* favicon
|
||||||
|
* open graph image
|
||||||
|
3. Creator edits:
|
||||||
|
|
||||||
|
* title
|
||||||
|
* short description
|
||||||
|
* cover image
|
||||||
|
4. Set price
|
||||||
|
|
||||||
|
* sats integer
|
||||||
|
5. Set access rules
|
||||||
|
|
||||||
|
* expiry: none / 24h / 7d / 30d / custom
|
||||||
|
* max devices: default 3
|
||||||
|
* allow embed: on
|
||||||
|
* allowed origins: optional list
|
||||||
|
6. Generate output
|
||||||
|
|
||||||
|
* Share link
|
||||||
|
* Embed code (iframe)
|
||||||
|
* Button embed code
|
||||||
|
7. Save
|
||||||
|
|
||||||
|
Success state
|
||||||
|
|
||||||
|
* Show the paywall URL
|
||||||
|
* Copy buttons
|
||||||
|
* Preview mode
|
||||||
|
|
||||||
|
Validation
|
||||||
|
|
||||||
|
* URL must be https
|
||||||
|
* Block localhost and private IP ranges
|
||||||
|
* Block obvious malware/phishing domains (basic allow/deny list)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Flow B: Buyer purchases access via hosted paywall page
|
||||||
|
|
||||||
|
Entry
|
||||||
|
|
||||||
|
* buyer opens [https://app.domain/p/{slug_or_id}](https://app.domain/p/{slug_or_id})
|
||||||
|
|
||||||
|
Paywall page sections
|
||||||
|
|
||||||
|
* cover image
|
||||||
|
* title
|
||||||
|
* short description
|
||||||
|
* price
|
||||||
|
* what you get (simple)
|
||||||
|
* pay button
|
||||||
|
* trust line (secure payment)
|
||||||
|
|
||||||
|
When pay is clicked
|
||||||
|
|
||||||
|
* create checkout session
|
||||||
|
* render QR invoice
|
||||||
|
* show invoice string copy
|
||||||
|
* show countdown timer
|
||||||
|
* poll payment status every 2 seconds, max 2 minutes
|
||||||
|
* on paid:
|
||||||
|
|
||||||
|
* issue access token
|
||||||
|
* redirect to unlocked view
|
||||||
|
|
||||||
|
Unlocked view
|
||||||
|
|
||||||
|
* show success message
|
||||||
|
* show button: Open content
|
||||||
|
* auto redirect after 3 seconds
|
||||||
|
* store access token in secure cookie and localStorage token id (for iframe context)
|
||||||
|
|
||||||
|
Re-access
|
||||||
|
|
||||||
|
* if cookie exists and token valid: show Open content
|
||||||
|
* if token expired: show repurchase prompt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Flow C: Embedded iframe paywall
|
||||||
|
|
||||||
|
Creator embeds
|
||||||
|
|
||||||
|
<iframe src="https://app.domain/embed/{paywall_id}" ...></iframe>
|
||||||
|
|
||||||
|
States
|
||||||
|
|
||||||
|
* Loading state
|
||||||
|
* Locked state
|
||||||
|
* Checkout state (inside iframe)
|
||||||
|
* Unlocked state
|
||||||
|
|
||||||
|
Locked state UI
|
||||||
|
|
||||||
|
* small cover
|
||||||
|
* title
|
||||||
|
* price
|
||||||
|
* Unlock button
|
||||||
|
|
||||||
|
Unlock
|
||||||
|
|
||||||
|
* opens checkout inside iframe
|
||||||
|
* after paid, iframe switches to unlocked
|
||||||
|
|
||||||
|
Unlocked state behaviors
|
||||||
|
Option 1: reveal content within iframe by rendering a secure redirect button
|
||||||
|
Option 2: open target link in new tab with token parameter (less secure)
|
||||||
|
Option 3: show an inline preview plus open link button
|
||||||
|
|
||||||
|
Recommended v1
|
||||||
|
|
||||||
|
* Open target link in new tab after payment, and show persistent “Open again” button.
|
||||||
|
|
||||||
|
Origin restrictions
|
||||||
|
|
||||||
|
* If creator set allowed origins, embed endpoint checks the request referrer/origin.
|
||||||
|
* If mismatch: show error: embedding not allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Flow D: Button embed + modal checkout
|
||||||
|
|
||||||
|
Embed snippet
|
||||||
|
|
||||||
|
<script src="https://app.domain/js/paywall.js" data-paywall="{id}" data-theme="auto"></script>
|
||||||
|
|
||||||
|
Behavior
|
||||||
|
|
||||||
|
* Script finds placeholder element or injects a button.
|
||||||
|
* On click, opens modal overlay.
|
||||||
|
* Modal shows paywall details and invoice.
|
||||||
|
* On paid, modal shows success and “Open content” button.
|
||||||
|
|
||||||
|
Constraints
|
||||||
|
|
||||||
|
* Must be CSP-friendly
|
||||||
|
* Must not break SSR sites
|
||||||
|
* Provide a no-JS fallback link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Flow E: Creator revenue and sales tracking
|
||||||
|
|
||||||
|
Dashboard widgets
|
||||||
|
|
||||||
|
* Total revenue (last 7d, 30d)
|
||||||
|
* Net earnings
|
||||||
|
* Top paywalls
|
||||||
|
* Sales count
|
||||||
|
|
||||||
|
Sales table
|
||||||
|
|
||||||
|
* date
|
||||||
|
* paywall
|
||||||
|
* amount
|
||||||
|
* fee
|
||||||
|
* net
|
||||||
|
* status
|
||||||
|
|
||||||
|
Filters
|
||||||
|
|
||||||
|
* date range
|
||||||
|
* paywall
|
||||||
|
|
||||||
|
Exports
|
||||||
|
|
||||||
|
* CSV export v1 optional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages and UI spec
|
||||||
|
|
||||||
|
### Public landing page
|
||||||
|
|
||||||
|
Sections
|
||||||
|
|
||||||
|
* Hero headline: “Turn any link into paid access in 60 seconds.”
|
||||||
|
* Subheadline: “No uploads. No platform lock-in. Share or embed anywhere.”
|
||||||
|
* CTA: “Create a paywall”
|
||||||
|
* Demo embed showing locked/unlocked
|
||||||
|
* Use cases grid (Notion, Google Docs, PDFs, videos)
|
||||||
|
* Pricing
|
||||||
|
* FAQ
|
||||||
|
* Footer
|
||||||
|
|
||||||
|
Design
|
||||||
|
|
||||||
|
* Modern, premium, minimal
|
||||||
|
* Dark mode first with light mode toggle
|
||||||
|
* Rounded corners, soft shadows
|
||||||
|
* Strong typography
|
||||||
|
* No crypto jargon
|
||||||
|
|
||||||
|
### Auth pages
|
||||||
|
|
||||||
|
* Login
|
||||||
|
* Signup
|
||||||
|
* Provider buttons (Google, GitHub, Nostr)
|
||||||
|
* Forgot password
|
||||||
|
|
||||||
|
### Creator dashboard
|
||||||
|
|
||||||
|
Layout
|
||||||
|
|
||||||
|
* Left sidebar
|
||||||
|
|
||||||
|
* Overview
|
||||||
|
* Paywalls
|
||||||
|
* Sales
|
||||||
|
* Embeds
|
||||||
|
* Settings
|
||||||
|
* Help
|
||||||
|
* Top bar
|
||||||
|
|
||||||
|
* Create Paywall
|
||||||
|
* Profile menu
|
||||||
|
|
||||||
|
Overview page
|
||||||
|
|
||||||
|
* KPI cards
|
||||||
|
* Sales chart (simple line)
|
||||||
|
* Recent sales list
|
||||||
|
* Quick create CTA
|
||||||
|
|
||||||
|
Paywalls list
|
||||||
|
|
||||||
|
* Table / cards
|
||||||
|
* Status chips
|
||||||
|
* Copy link
|
||||||
|
* Copy embed
|
||||||
|
* Edit
|
||||||
|
* Archive
|
||||||
|
|
||||||
|
Create paywall page
|
||||||
|
|
||||||
|
* Stepper UI (Paste link -> Details -> Price -> Access -> Output)
|
||||||
|
|
||||||
|
Paywall detail page
|
||||||
|
|
||||||
|
* Preview hosted page
|
||||||
|
* Settings panels
|
||||||
|
* Copy buttons
|
||||||
|
* Embed origins list
|
||||||
|
* Regenerate embed codes
|
||||||
|
|
||||||
|
Sales page
|
||||||
|
|
||||||
|
* Sales table
|
||||||
|
* Export
|
||||||
|
|
||||||
|
Embeds page
|
||||||
|
|
||||||
|
* Docs and copy-paste snippets
|
||||||
|
* Testing sandbox
|
||||||
|
|
||||||
|
Settings
|
||||||
|
|
||||||
|
* Profile
|
||||||
|
* Payout destination
|
||||||
|
* Branding (logo, primary color)
|
||||||
|
* Domain/whitelabel (future)
|
||||||
|
* Security (sessions)
|
||||||
|
|
||||||
|
### Buyer hosted paywall page
|
||||||
|
|
||||||
|
* Responsive
|
||||||
|
* Minimal checkout
|
||||||
|
* QR centered
|
||||||
|
* Clear instructions
|
||||||
|
* After pay: success state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Look and feel
|
||||||
|
|
||||||
|
Theme
|
||||||
|
|
||||||
|
* Primary color: choose one brand color; allow creator override later
|
||||||
|
* Background: dark #0b0f14 style
|
||||||
|
* Cards: slightly lighter panels
|
||||||
|
* Typography: modern sans
|
||||||
|
* Spacing: generous
|
||||||
|
|
||||||
|
Components
|
||||||
|
|
||||||
|
* Buttons: large, rounded, high contrast
|
||||||
|
* Inputs: simple
|
||||||
|
* Toasts for copy actions
|
||||||
|
* Skeleton loaders
|
||||||
|
|
||||||
|
Microcopy tone
|
||||||
|
|
||||||
|
* Clear, short, confident
|
||||||
|
* Avoid crypto words
|
||||||
|
* Use “sats” not BTC
|
||||||
|
|
||||||
|
Accessibility
|
||||||
|
|
||||||
|
* AA contrast
|
||||||
|
* keyboard navigable
|
||||||
|
* screen reader labels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend architecture
|
||||||
|
|
||||||
|
Recommended stack
|
||||||
|
|
||||||
|
* FastAPI or Node/Express
|
||||||
|
* Postgres
|
||||||
|
* Redis for session and rate limit
|
||||||
|
* Worker queue for webhook processing
|
||||||
|
|
||||||
|
Services
|
||||||
|
|
||||||
|
1. API service
|
||||||
|
|
||||||
|
* REST endpoints
|
||||||
|
* auth
|
||||||
|
* paywall CRUD
|
||||||
|
* checkout sessions
|
||||||
|
* access verification
|
||||||
|
|
||||||
|
2. Webhook worker
|
||||||
|
|
||||||
|
* provider webhooks
|
||||||
|
* marks CheckoutSession paid
|
||||||
|
* issues AccessGrant
|
||||||
|
* records Sale
|
||||||
|
|
||||||
|
3. Embed service
|
||||||
|
|
||||||
|
* serves iframe content
|
||||||
|
* verifies origin
|
||||||
|
* uses access token
|
||||||
|
|
||||||
|
Security
|
||||||
|
|
||||||
|
* Strict input validation
|
||||||
|
* SSRF protection on metadata fetch
|
||||||
|
* Rate limits
|
||||||
|
* IP throttling on checkout creation
|
||||||
|
* Signed tokens (JWT with rotating keys)
|
||||||
|
* Encrypt secrets at rest
|
||||||
|
|
||||||
|
Metadata fetcher
|
||||||
|
|
||||||
|
* Fetch open graph tags
|
||||||
|
* Timeout 3 seconds
|
||||||
|
* Only allow https
|
||||||
|
* Block private ranges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API endpoints (v1)
|
||||||
|
|
||||||
|
Auth
|
||||||
|
|
||||||
|
* POST /auth/signup
|
||||||
|
* POST /auth/login
|
||||||
|
* POST /auth/logout
|
||||||
|
* POST /auth/refresh
|
||||||
|
* POST /auth/oauth/{provider}/start
|
||||||
|
* POST /auth/oauth/{provider}/callback
|
||||||
|
* POST /auth/nostr/challenge
|
||||||
|
* POST /auth/nostr/verify
|
||||||
|
|
||||||
|
Paywalls
|
||||||
|
|
||||||
|
* POST /paywalls
|
||||||
|
* GET /paywalls
|
||||||
|
* GET /paywalls/{id}
|
||||||
|
* PATCH /paywalls/{id}
|
||||||
|
* POST /paywalls/{id}/archive
|
||||||
|
|
||||||
|
Checkout
|
||||||
|
|
||||||
|
* POST /paywalls/{id}/checkout
|
||||||
|
* GET /checkout/{session_id}
|
||||||
|
|
||||||
|
Access
|
||||||
|
|
||||||
|
* POST /access/verify
|
||||||
|
* POST /access/revoke
|
||||||
|
|
||||||
|
Public
|
||||||
|
|
||||||
|
* GET /p/{slug_or_id} (render)
|
||||||
|
* GET /embed/{id} (render)
|
||||||
|
* GET /js/paywall.js (button script)
|
||||||
|
|
||||||
|
Webhooks
|
||||||
|
|
||||||
|
* POST /webhooks/{provider}
|
||||||
|
|
||||||
|
Admin
|
||||||
|
|
||||||
|
* GET /admin/creators
|
||||||
|
* GET /admin/paywalls
|
||||||
|
* GET /admin/sales
|
||||||
|
* POST /admin/paywalls/{id}/disable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Token and access logic
|
||||||
|
|
||||||
|
On payment confirmed
|
||||||
|
|
||||||
|
* Create AccessGrant
|
||||||
|
* Issue AccessToken JWT
|
||||||
|
Claims
|
||||||
|
* token_id
|
||||||
|
* paywall_id
|
||||||
|
* buyer_id (optional)
|
||||||
|
* iat
|
||||||
|
* exp (optional)
|
||||||
|
|
||||||
|
Storage
|
||||||
|
|
||||||
|
* httpOnly cookie: access_jwt
|
||||||
|
* localStorage: token_id (for embed communication)
|
||||||
|
|
||||||
|
Verification
|
||||||
|
|
||||||
|
* /access/verify checks signature and status
|
||||||
|
* checks revoked
|
||||||
|
* checks expiry
|
||||||
|
* checks max device fingerprint count
|
||||||
|
|
||||||
|
Device fingerprint
|
||||||
|
|
||||||
|
* hash of user agent + stable client id cookie
|
||||||
|
* do not use invasive fingerprinting
|
||||||
|
|
||||||
|
Limits
|
||||||
|
|
||||||
|
* max devices default 3
|
||||||
|
* if exceeded: prompt to “Reset devices” (creator can allow)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abuse prevention
|
||||||
|
|
||||||
|
Threats
|
||||||
|
|
||||||
|
* Link sharing
|
||||||
|
* Invoice replay
|
||||||
|
* Token theft
|
||||||
|
* Bots spamming checkout sessions
|
||||||
|
* SSRF via URL fetch
|
||||||
|
|
||||||
|
Controls
|
||||||
|
|
||||||
|
* Access tokens tied to limited devices
|
||||||
|
* Session expiry and revocation
|
||||||
|
* Rate limit checkout creation
|
||||||
|
* Webhook idempotency
|
||||||
|
* Block private network URL fetch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Observability and operations
|
||||||
|
|
||||||
|
Logging
|
||||||
|
|
||||||
|
* request id
|
||||||
|
* creator id
|
||||||
|
* paywall id
|
||||||
|
* checkout session id
|
||||||
|
* webhook event id
|
||||||
|
|
||||||
|
Metrics
|
||||||
|
|
||||||
|
* checkouts created
|
||||||
|
* conversion rate
|
||||||
|
* webhook latency
|
||||||
|
* embed loads
|
||||||
|
* errors
|
||||||
|
|
||||||
|
Alerts
|
||||||
|
|
||||||
|
* webhook failures
|
||||||
|
* payment provider downtime
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Environments
|
||||||
|
|
||||||
|
* dev
|
||||||
|
* staging
|
||||||
|
* production
|
||||||
|
|
||||||
|
Secrets
|
||||||
|
|
||||||
|
* provider keys
|
||||||
|
* jwt signing keys
|
||||||
|
* db password
|
||||||
|
|
||||||
|
CDN
|
||||||
|
|
||||||
|
* cache static assets
|
||||||
|
* do not cache paywall pages with personal state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1 acceptance criteria
|
||||||
|
|
||||||
|
Creator
|
||||||
|
|
||||||
|
* Can sign up and create a paywall in under 60 seconds
|
||||||
|
* Can copy share link and embed code
|
||||||
|
* Can see sales appear after payments
|
||||||
|
|
||||||
|
Buyer
|
||||||
|
|
||||||
|
* Can pay via lightning invoice and unlock within seconds
|
||||||
|
* Can revisit link and still access if not expired
|
||||||
|
|
||||||
|
Embed
|
||||||
|
|
||||||
|
* Iframe works on standard websites
|
||||||
|
* Button script opens modal checkout
|
||||||
|
|
||||||
|
Security
|
||||||
|
|
||||||
|
* Tokens are signed and validated server-side
|
||||||
|
* Metadata fetch is protected against SSRF
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap after v1
|
||||||
|
|
||||||
|
v1.1
|
||||||
|
|
||||||
|
* Email receipts
|
||||||
|
* Custom success message
|
||||||
|
* Better origin controls
|
||||||
|
|
||||||
|
v1.2
|
||||||
|
|
||||||
|
* Whitelabel domains
|
||||||
|
* Team accounts
|
||||||
|
|
||||||
|
v2
|
||||||
|
|
||||||
|
* Subscriptions
|
||||||
|
* Bundles
|
||||||
|
* Creator pages
|
||||||
|
* Affiliate links
|
||||||
Reference in New Issue
Block a user