12 Commits

Author SHA1 Message Date
4f7c6601a3 Relay front updates: payments, API, terms (markdown), styling; remove LNbits service
Made-with: Love
2026-04-29 05:48:14 +00:00
Michilis
c6749839af Update README.md 2025-11-11 14:11:07 -03:00
Michilis
da1d1b0d53 Merge pull request #4 from Michilis/dev
Move terms.txt to public/ to fix Terms page loading
2025-11-10 21:22:50 -03:00
Noderunners
0ab66479d9 Move terms.txt to public/ to fix Terms page loading 2025-11-11 01:21:52 +01:00
Michilis
1129fb9341 Merge pull request #3 from Michilis/dev
fix(lnbits): normalize invoice response (fallback to bolt11) and upda…
2025-11-10 16:51:25 -03:00
Noderunners
4afe3f6d3e fix(lnbits): normalize invoice response (fallback to bolt11) and update payment status check; chore: add .gitignore 2025-11-10 20:47:58 +01:00
Michilis
21bbd0fab1 Merge pull request #2 from Michilis/dev
Fix API calls to use npub instead of identifier
2025-11-06 17:17:48 -03:00
Michilis
bb3b5604a1 Fix API calls to use npub instead of identifier
- Updated getUserInfo to send npub field instead of identifier
- Updated whitelistUser to send npub field instead of identifier
- Resolves 422 Unprocessable Content error from backend API
2025-07-01 19:18:29 +00:00
Michilis
9faf7b5cef Update README.md 2025-02-10 05:04:59 +01:00
Michilis
c984601352 Update README.md 2025-02-10 05:04:42 +01:00
Michilis
8cdca7bb8c Update README.md 2025-02-10 04:50:11 +01:00
Michilis
cbf2172fe4 Merge pull request #1 from Michilis/V0.02-ref-login
V0.02 ref login
2025-02-10 04:47:27 +01:00
21 changed files with 3023 additions and 3589 deletions

View File

@@ -1,25 +1,19 @@
VITE_LNBITS_URL="https://azzamo.tips"
VITE_LNBITS_API_KEY="LNbits Api key"
# App settings
VITE_APP_NAME="Noderunners Relay"
VITE_APP_DESCRIPTION="A high-performance Nostr relay built by Noderunners, for Noderunners"
VITE_LOGO_URL="https://cdn.azzamo.net/5cc03420a18166ef7a20b1e6b7dad240ad7d634824649643c80d74a924062258.png"
VITE_GITHUB_URL="https://github.com/noderunners-org/relay"
# Nostr settings
# Nostr settings — API base URL for /v1/* and /.well-known/nostr.json (no trailing slash).
# Leave unset or empty only when using `npm run dev` with vite proxy — see `.env.development`.
VITE_NOSTR_RELAY_URL="wss://relay.noderunners.network"
VITE_API_URL="https://noderunnersapi.azzamo.net"
# Used only during `npm run dev` when requests are proxied (see vite.config.ts).
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8085
VITE_SUPPORTED_NIPS="1,2,4,9,11,22,28,40,70,77"
VITE_RELAY_SOFTWARE="strfry v1.0.3"
# Payment settings
VITE_MIN_PAYMENT_AMOUNT=10000
VITE_PAYMENT_MEMO="Noderunners Relay Access"
VITE_PAYMENT_CURRENCY="sat"
VITE_WEBHOOK_URL="N8N Webhook Url"
# Feature flags
VITE_ENABLE_WHITELIST=true
VITE_ENABLE_PAYMENT_VERIFICATION=true

64
.gitignore vendored Normal file
View File

@@ -0,0 +1,64 @@
# Dependencies
node_modules/
# Production builds
dist/
dist-ssr/
build/
# Vite and tooling caches
.vite/
.esbuild/
.rollup.cache/
.parcel-cache/
.turbo/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Environment files
.env
.env.local
.env.*.local
.env.production.local
.env.development.local
.env.test.local
!.env.example
# Coverage
coverage/
# TypeScript build info
*.tsbuildinfo
# Editor directories and files
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*~
# OS metadata
.DS_Store
Thumbs.db
# Misc secrets/keys (if present)
*.pem
*.key
*.crt
*.p12
*.pfx
# Package tarballs
*.tgz

View File

@@ -1,26 +1,28 @@
# Noderunners Relay
A high-performance Nostr relay built by Bitcoiners, for Bitcoiners. This project provides a web interface for managing access to the Noderunners relay service.
```
wss://relay.noderunners.network
```
![Noderunners Relay](https://cdn.azzamo.net/5cc03420a18166ef7a20b1e6b7dad240ad7d634824649643c80d74a924062258.png)
## Features
- 🚀 Lightning-fast relay performance with strfry v1.0.3
- ⚡ Lightning Network integration for payments
- 🔒 Secure authentication with Nostr
- 💻 Modern, responsive web interface
- 📊 Real-time relay statistics
- 🔍 Uptime monitoring
- 🖼️ Iframe support for embedding
- 🔑 Multiple login methods (Extension, Manual, URL-based)
- Lightning payments via the relay API (invoice creation and status)
- Secure authentication with Nostr
- Modern, responsive web interface
- Real-time relay statistics
- Uptime monitoring
- Iframe support for embedding
- Multiple login methods (Extension, Manual, URL-based)
## Tech Stack
- **Frontend**: React + TypeScript + Vite
- **Styling**: Tailwind CSS
- **Icons**: Lucide React
- **Payment**: LNbits Integration
- **Payments**: [NIP-05 relay API](../Nip05_api) (`/v1/pricing`, `/v1/invoices`, `/v1/users/…`)
- **Authentication**: Nostr Protocol
- **State Management**: Zustand
@@ -29,28 +31,18 @@ A high-performance Nostr relay built by Bitcoiners, for Bitcoiners. This project
Create a `.env` file in the root directory with the following variables:
```env
# LNbits Configuration
VITE_LNBITS_URL="your-lnbits-url"
VITE_LNBITS_API_KEY="your-api-key"
# App Settings
VITE_APP_NAME="Noderunners Relay"
VITE_APP_DESCRIPTION="A high-performance Nostr relay built by Bitcoiners, for Bitcoiners"
VITE_LOGO_URL="your-logo-url"
VITE_GITHUB_URL="your-github-url"
# Nostr Settings
# Nostr — relay URL for clients; API URL serves /.well-known/nostr.json and /v1/*
VITE_NOSTR_RELAY_URL="wss://your-relay-url"
VITE_API_URL="your-api-url"
VITE_API_URL="https://your-api-host"
VITE_SUPPORTED_NIPS="1,2,4,9,11,22,28,40,70,77"
VITE_RELAY_SOFTWARE="strfry v1.0.3"
# Payment Settings
VITE_MIN_PAYMENT_AMOUNT=10000
VITE_PAYMENT_MEMO="Noderunners Relay Access"
VITE_PAYMENT_CURRENCY="sat"
VITE_WEBHOOK_URL="your-webhook-url"
# Feature Flags
VITE_ENABLE_WHITELIST=true
VITE_ENABLE_PAYMENT_VERIFICATION=true
@@ -84,10 +76,10 @@ The application supports multiple authentication methods:
1. **Nostr Extension**
- Uses browser extensions like Alby for seamless authentication
- Automatically retrieves the user's public key
- Automatically retrieves your public key
2. **Manual Entry**
- Users can manually input their npub or hex public key
- You can manually input your npub or hex public key
- Supports both formats for maximum flexibility
3. **URL-based Authentication**
@@ -116,52 +108,12 @@ You can combine iframe mode with URL-based authentication:
<iframe src="https://your-relay-domain.com?iframe=1&npub=npub1..." width="100%" height="600px"></iframe>
```
## Supported NIPs
The relay supports the following Nostr Implementation Possibilities (NIPs):
- NIP-01: Basic protocol flow description
- NIP-02: Contact List and Petnames
- NIP-04: Encrypted Direct Messages
- NIP-09: Event Deletion
- NIP-11: Relay Information Document
- NIP-22: Event `created_at` Limits
- NIP-28: Public Chat
- NIP-40: Expiration Timestamp
- NIP-70: Relay Payment Info
- NIP-77: Lightning Network Relay Payment
## API Services
### LNbits Integration
- Invoice creation
- Payment verification
- Exchange rate conversion
- Wallet information
### Relay API
- User information
- Whitelist management
- Payment processing
- Status monitoring
## Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
For support, join our [Telegram group](https://t.me/noderunners) or visit [our website](https://noderunners.network).
## Acknowledgments
- Built by the Noderunners community
- Powered by [strfry](https://github.com/hoytech/strfry)
- Lightning Network integration via [LNbits](https://lnbits.com)

5333
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,33 +10,32 @@
"preview": "vite preview"
},
"dependencies": {
"@nostr-dev-kit/ndk": "^2.5.1",
"axios": "^1.6.7",
"canvas-confetti": "^1.9.2",
"lucide-react": "^0.344.0",
"qrcode.react": "^3.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.22.2",
"zustand": "^4.5.2"
"@nostr-dev-kit/ndk": "^3.0.3",
"canvas-confetti": "^1.9.4",
"lucide-react": "^1.12.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@tailwindcss/typography": "^0.5.10",
"@types/canvas-confetti": "^1.6.4",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/typography": "^0.5.19",
"@types/canvas-confetti": "^1.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"postcss": "^8.5.12",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10"
}
}

View File

@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
'@tailwindcss/postcss': {},
},
};

View File

@@ -1,10 +1,9 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Flame, Menu, X } from 'lucide-react';
import { useStore } from '../store/useStore';
export function Navigation() {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user, setUser } = useStore();

View File

@@ -1,3 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@theme {
--animate-slide-up: slide-up 0.3s ease-out;
--animate-fade-in: fade-in 0.3s ease-out;
--animate-success-appear: success-appear 0.5s ease-out;
}
@keyframes slide-up {
0% {
transform: translateY(100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes success-appear {
0% {
transform: scale(0.8);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}

View File

@@ -9,16 +9,51 @@ import { useNotification } from '../hooks/useNotification';
export function Dashboard() {
const navigate = useNavigate();
const { user, setUser } = useStore();
const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
const [uptime, setUptime] = useState<string | null>(null);
const [activeUsers, setActiveUsers] = useState<number | null>(null);
const [lifetimeSats, setLifetimeSats] = useState<number | null>(() =>
isDemoMode ? 10000 : null
);
const [yearlySats, setYearlySats] = useState<number | null>(() =>
isDemoMode ? 1000 : null
);
const [loading, setLoading] = useState(true);
const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
const apiUrl = import.meta.env.VITE_API_URL;
const { isVisible, message, type, showNotification, hideNotification } = useNotification();
const [searchParams] = useSearchParams();
const isIframe = searchParams.get('iframe') === '1';
// Check user authentication and fetch status once
const lifetimeLabel =
lifetimeSats != null ? `${lifetimeSats.toLocaleString()} sats` : '…';
const yearlyLabel =
yearlySats != null ? `${yearlySats.toLocaleString()} sats` : '…';
useEffect(() => {
if (isDemoMode) {
return;
}
let cancelled = false;
void apiService
.getPricing()
.then((p) => {
if (!cancelled) {
setLifetimeSats(p.lifetime_sats);
setYearlySats(p.yearly_sats);
}
})
.catch(() => {
if (!cancelled) {
setLifetimeSats(null);
setYearlySats(null);
}
});
return () => {
cancelled = true;
};
}, [isDemoMode]);
useEffect(() => {
if (!user) {
navigate('/login');
@@ -29,29 +64,41 @@ export function Dashboard() {
if (!isDemoMode) {
try {
const userInfo = await apiService.getUserInfo(user.pubkey);
if (userInfo) {
setUser({
...user,
isWhitelisted: userInfo.is_whitelisted,
timeRemaining: userInfo.time_remaining,
npub: userInfo.npub,
username: userInfo.username,
subscriptionType: userInfo.subscription_type,
expiresAt: userInfo.expires_at ?? null,
});
} catch (error: any) {
if (error.response?.status === 404 || error) {
} else {
setUser({
...user,
isWhitelisted: false,
subscriptionType: undefined,
expiresAt: undefined,
});
}
} catch (error: unknown) {
console.error('Failed to fetch user status:', error);
setUser({
...user,
isWhitelisted: false,
subscriptionType: undefined,
expiresAt: undefined,
});
}
}
setLoading(false);
};
checkUserStatus();
void checkUserStatus();
// Intentionally depend on pubkey only so we don't re-fetch when whitelist state updates from this effect.
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync user's pubkey identity only
}, [user?.pubkey, navigate, setUser, isDemoMode]);
// Fetch uptime and active users
useEffect(() => {
const fetchUptime = async () => {
if (isDemoMode) {
@@ -79,7 +126,8 @@ export function Dashboard() {
}
try {
const response = await fetch(`${apiUrl}/.well-known/nostr.json`);
const base = String(apiUrl ?? '').replace(/\/$/, '');
const response = await fetch(`${base}/.well-known/nostr.json`);
const data = await response.json();
setActiveUsers(data.names ? Object.keys(data.names).length : 0);
} catch (error) {
@@ -115,6 +163,24 @@ export function Dashboard() {
navigate('/login');
};
const toPayment = (plan: 'yearly' | 'lifetime') => {
const q = new URLSearchParams();
q.set('plan', plan);
if (isIframe) q.set('iframe', '1');
navigate(`/payment?${q.toString()}`);
};
const formatExpiry = (iso: string) => {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
});
} catch {
return iso;
}
};
if (loading || !user) {
return (
<div className="flex justify-center items-center min-h-[400px]">
@@ -123,10 +189,14 @@ export function Dashboard() {
);
}
const showYearlyRenewal =
user.isWhitelisted &&
user.subscriptionType === 'yearly' &&
Boolean(user.expiresAt);
return (
<>
<div className="max-w-6xl mx-auto space-y-4 md:space-y-8">
{/* Status Banner */}
<div className={`p-4 md:p-8 rounded-lg ${user.isWhitelisted ? 'bg-green-900/20' : 'bg-orange-900/20'}`}>
<div className="flex flex-col md:flex-row md:items-center justify-between mb-4 space-y-4 md:space-y-0">
<div className="flex items-center space-x-4">
@@ -150,18 +220,41 @@ export function Dashboard() {
You can now use this relay in your Nostr client. Add the relay URL below
to your client's relay list to start posting and receiving messages.
</p>
{showYearlyRenewal && user.expiresAt ? (
<>
<p className="text-gray-300 text-base">
Yearly subscription active until{' '}
<strong className="text-white">{formatExpiry(user.expiresAt)}</strong>.
</p>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<button
type="button"
onClick={() => toPayment('yearly')}
className="flex flex-1 items-center justify-center px-4 py-3 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold space-x-2"
>
<Zap className="h-5 w-5" />
<span>Add another year ({yearlyLabel})</span>
</button>
<button
type="button"
onClick={() => toPayment('lifetime')}
className="flex flex-1 items-center justify-center px-4 py-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors font-semibold border border-gray-600 space-x-2"
>
<Zap className="h-5 w-5" />
<span>Upgrade to lifetime ({lifetimeLabel})</span>
</button>
</div>
</>
) : null}
</div>
) : (
<div className="space-y-4 md:space-y-6">
<div className="space-y-4">
<p className="text-orange-400 text-base md:text-lg">
One-time Payment Required
</p>
<p className="text-orange-400 text-base md:text-lg">Lightning payment</p>
<div className="space-y-2">
<p className="text-gray-400">
To use the Noderunners relay, you need to make a one-time payment of
10,000 sats. This payment helps maintain the relay's infrastructure
and ensures high-quality service.
Choose yearly access ({yearlyLabel}) or lifetime access ({lifetimeLabel}). Pricing comes from the
relay API and funds relay infrastructure.
</p>
<p className="text-gray-400">
<span className="text-orange-400">21%</span> of all payments go to the{' '}
@@ -172,23 +265,33 @@ export function Dashboard() {
className="text-orange-400 hover:underline"
>
Noderunners community pot
</a>
{' '}to support the development of Bitcoin and Nostr projects.
</a>{' '}
to support the development of Bitcoin and Nostr projects.
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={() => navigate('/payment')}
className="flex items-center justify-center w-full px-4 md:px-6 py-3 md:py-4 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold space-x-2"
type="button"
onClick={() => toPayment('yearly')}
className="flex flex-1 items-center justify-center px-4 md:px-6 py-3 md:py-4 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold space-x-2"
>
<Zap className="h-5 w-5" />
<span>Pay 10,000 sats for Access</span>
<span>Pay for one year ({yearlyLabel})</span>
</button>
<button
type="button"
onClick={() => toPayment('lifetime')}
className="flex flex-1 items-center justify-center px-4 md:px-6 py-3 md:py-4 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors font-semibold border border-gray-600 space-x-2"
>
<Zap className="h-5 w-5" />
<span>Pay for lifetime ({lifetimeLabel})</span>
</button>
</div>
</div>
)}
</div>
{/* Connection Information */}
<div className="bg-gray-800 rounded-lg p-4 md:p-8">
<h2 className="text-lg md:text-xl font-bold mb-4 md:mb-6">Connection Information</h2>
<div>
@@ -209,7 +312,6 @@ export function Dashboard() {
</div>
</div>
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
<div className="bg-gray-800 p-4 md:p-6 rounded-lg">
<div className="flex items-center justify-between mb-4">
@@ -230,7 +332,6 @@ export function Dashboard() {
</div>
</div>
{/* Logout Button (only shown in iframe mode) */}
{isIframe && (
<div className="flex justify-center">
<button

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Flame, Zap, Shield, Globe, Server, Code, Cpu, Copy } from 'lucide-react';
import { Zap, Shield, Globe, Server, Code, Cpu, Copy } from 'lucide-react';
import { Notification } from '../components/Notification';
import { useNotification } from '../hooks/useNotification';
@@ -9,6 +9,11 @@ export function Home() {
const [searchParams] = useSearchParams();
const isIframe = searchParams.get('iframe') === '1';
const supportedNips = (import.meta.env.VITE_SUPPORTED_NIPS ?? '')
.split(',')
.map((nip) => nip.trim())
.filter(Boolean);
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
@@ -96,11 +101,18 @@ export function Home() {
</h3>
<div className="bg-gray-900 p-4 rounded-lg">
<div className="grid grid-cols-4 gap-2">
{import.meta.env.VITE_SUPPORTED_NIPS.split(',').map(nip => (
{supportedNips.length > 0 ? (
supportedNips.map((nip) => (
<span key={nip} className="bg-gray-800 px-3 py-1 rounded text-center">
{nip}
</span>
))}
))
) : (
<span className="col-span-4 text-gray-500 text-sm">
Set <code className="text-gray-400">VITE_SUPPORTED_NIPS</code> in your env (comma-separated, e.g.{' '}
<code className="text-gray-400">1,2,4,40</code>).
</span>
)}
</div>
</div>
</div>

View File

@@ -46,17 +46,18 @@ export function Login() {
setUser({ pubkey, isWhitelisted: false });
navigate(isIframe ? '/dashboard?iframe=1' : '/dashboard');
} catch (error: any) {
if (error.message === 'Rejected by user') {
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : '';
if (msg === 'Rejected by user') {
return;
}
console.error('Login failed:', error);
if (error.message.includes('Nostr provider not found')) {
if (msg.includes('Nostr provider not found')) {
alert('No Nostr extension detected. Please install Alby or another Nostr extension and try again.');
} else if (error.message.includes('No public key found')) {
} else if (msg.includes('No public key found')) {
alert('Could not access your Nostr public key. Please make sure you\'re logged into your Nostr extension.');
} else if (error.message !== 'Rejected by user') {
} else if (msg !== 'Rejected by user') {
alert('Failed to connect. Please make sure you have a Nostr extension installed and try again.');
}
} finally {

View File

@@ -1,104 +1,173 @@
import React, { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import { Copy, CheckCircle } from 'lucide-react';
import { useStore } from '../store/useStore';
import { lnbitsService } from '../services/lnbits';
import { apiService } from '../services/api';
import type { LightningInvoice } from '../types';
import { Notification } from '../components/Notification';
import { useNotification } from '../hooks/useNotification';
const POLL_MS = 2000;
export type Plan = 'yearly' | 'lifetime';
function formatInvoiceCountdown(expiresAtIso: string, nowMs: number): string {
const end = new Date(expiresAtIso).getTime();
const ms = Math.max(0, end - nowMs);
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function Payment() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { user, setUser } = useStore();
const [invoice, setInvoice] = useState<LightningInvoice | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
const { isVisible, message, type, showNotification, hideNotification } = useNotification();
const [checkingPayment, setCheckingPayment] = useState(false);
const [showSuccess, setShowSuccess] = useState(false);
const isIframe = searchParams.get('iframe') === '1';
const planParam = searchParams.get('plan');
const resolvedPlan: Plan | null =
planParam === 'yearly' || planParam === 'lifetime' ? planParam : null;
const { user } = useStore();
const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
const [invoice, setInvoice] = useState<LightningInvoice | null>(null);
const [amountSats, setAmountSats] = useState<number | null>(null);
const [expiresAtIso, setExpiresAtIso] = useState<string | null>(null);
const [pricingYearly, setPricingYearly] = useState<number | null>(null);
const [pricingLifetime, setPricingLifetime] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const { isVisible, message, type, showNotification, hideNotification } = useNotification();
const [showSuccess, setShowSuccess] = useState(false);
const [nowTick, setNowTick] = useState(() => Date.now());
const isIframe = searchParams.get('iframe') === '1';
const pollingRef = useRef(false);
const handlePaymentSuccess = useCallback(() => {
setShowSuccess(true);
setTimeout(() => {
navigate(isIframe ? '/thank-you?iframe=1' : '/thank-you');
}, 1500);
}, [navigate, isIframe]);
useEffect(() => {
if (!expiresAtIso) return;
const id = window.setInterval(() => setNowTick(Date.now()), 1000);
return () => window.clearInterval(id);
}, [expiresAtIso]);
useEffect(() => {
if (isDemoMode || resolvedPlan) return;
let cancelled = false;
void apiService
.getPricing()
.then((p) => {
if (!cancelled) {
setPricingYearly(p.yearly_sats);
setPricingLifetime(p.lifetime_sats);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [isDemoMode, resolvedPlan]);
/** Auth redirect — separate from invoice fetch so unrelated store updates don't cancel invoice loading. */
useEffect(() => {
if (!user) {
navigate(isIframe ? '/login?iframe=1' : '/login');
return;
}
if (user.isWhitelisted) {
// Active subscribers may still open /payment?plan=… for renewal or lifetime upgrade.
if (user.isWhitelisted && !resolvedPlan) {
navigate(isIframe ? '/dashboard?iframe=1' : '/dashboard');
}
}, [user, navigate, isIframe, resolvedPlan]);
const pubkey = user?.pubkey;
useEffect(() => {
if (!pubkey || isDemoMode || !resolvedPlan) {
return;
}
const generateInvoice = async () => {
try {
const response = await lnbitsService.createInvoice({
amount: 10000,
memo: `${import.meta.env.VITE_PAYMENT_MEMO || "Noderunners Relay Access"} - ${user.pubkey}`,
webhook: import.meta.env.VITE_WEBHOOK_URL,
extra: {
pubkey: user.pubkey,
type: 'relay_access'
}
});
let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | undefined;
const poll = (paymentHash: string) => {
intervalId = window.setInterval(() => {
void (async () => {
if (pollingRef.current) return;
pollingRef.current = true;
try {
const st = await apiService.getInvoiceStatus(paymentHash);
if (cancelled) return;
if (st.status === 'paid') {
if (intervalId) {
clearInterval(intervalId);
intervalId = undefined;
}
handlePaymentSuccess();
}
if (st.status === 'expired') {
setError('This invoice has expired. Reload to generate a new one.');
if (intervalId) {
clearInterval(intervalId);
intervalId = undefined;
}
}
} catch (err) {
console.error('Error checking payment status:', err);
} finally {
pollingRef.current = false;
}
})();
}, POLL_MS);
};
const run = async () => {
try {
const pricing = await apiService.getPricing();
if (cancelled) return;
if (!pricing.lightning_enabled) {
setError('Lightning payments are temporarily unavailable. Please try again later.');
return;
}
setPricingYearly(pricing.yearly_sats);
setPricingLifetime(pricing.lifetime_sats);
const response = await apiService.createInvoice({
pubkey,
subscription_type: resolvedPlan === 'yearly' ? 'yearly' : 'lifetime',
years: resolvedPlan === 'yearly' ? 1 : undefined,
});
if (cancelled) return;
setAmountSats(response.amount_sats);
setExpiresAtIso(response.expires_at);
setInvoice({
paymentRequest: response.payment_request,
qrCode: response.payment_request,
paymentHash: response.payment_hash
paymentHash: response.payment_hash,
});
pollPaymentStatus(response.payment_hash);
poll(response.payment_hash);
} catch (err) {
if (cancelled) return;
console.error('Failed to generate invoice:', err);
setError('Failed to generate invoice. Please try again later.');
} finally {
setLoading(false);
const msg = err instanceof Error ? err.message : 'Failed to generate invoice.';
setError(msg);
}
};
generateInvoice();
}, [user, navigate, isIframe]);
void run();
const handlePaymentSuccess = async () => {
setShowSuccess(true);
setTimeout(() => {
navigate(isIframe ? '/thank-you?iframe=1' : '/thank-you');
}, 1500);
return true;
};
const pollPaymentStatus = async (paymentHash: string) => {
const checkPayment = async () => {
if (checkingPayment) return false;
setCheckingPayment(true);
try {
const status = await lnbitsService.checkPayment(paymentHash);
if (status.paid) {
return handlePaymentSuccess();
}
return false;
} catch (error) {
console.error('Error checking payment status:', error);
return false;
} finally {
setCheckingPayment(false);
}
};
const interval = setInterval(async () => {
const paid = await checkPayment();
if (paid) {
clearInterval(interval);
}
}, 2000);
return () => clearInterval(interval);
return () => {
cancelled = true;
if (intervalId) clearInterval(intervalId);
};
}, [pubkey, isDemoMode, resolvedPlan, handlePaymentSuccess]);
const copyToClipboard = async (text: string) => {
try {
@@ -114,7 +183,38 @@ export function Payment() {
handlePaymentSuccess();
};
if (loading) {
if (!user) {
return null;
}
const showFetchSpinner =
!isDemoMode && !!resolvedPlan && !invoice && !error;
const navigateWithPlan = (p: Plan) => {
setError(null);
const q = new URLSearchParams();
q.set('plan', p);
if (isIframe) q.set('iframe', '1');
navigate(`/payment?${q.toString()}`);
};
const expectedSatsHint =
resolvedPlan === 'yearly'
? pricingYearly != null
? `${pricingYearly.toLocaleString()} sats`
: null
: pricingLifetime != null
? `${pricingLifetime.toLocaleString()} sats`
: null;
const planSubtitle =
resolvedPlan === 'yearly'
? 'One year of relay access'
: resolvedPlan === 'lifetime'
? 'Lifetime relay access'
: '';
if (showFetchSpinner) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-orange-500"></div>
@@ -122,6 +222,51 @@ export function Payment() {
);
}
if (!isDemoMode && !resolvedPlan) {
return (
<>
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8">
<h1 className="text-2xl font-bold mb-2 text-center">Choose a plan</h1>
<p className="text-gray-400 text-center mb-8">
Pick yearly access or pay once for lifetime access.
</p>
<div className="space-y-4">
<button
type="button"
onClick={() => navigateWithPlan('yearly')}
className="w-full px-6 py-4 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold text-lg"
>
Pay for one year
{pricingYearly != null ? (
<span className="block text-sm font-normal text-orange-100 mt-1">
{pricingYearly.toLocaleString()} sats / year
</span>
) : null}
</button>
<button
type="button"
onClick={() => navigateWithPlan('lifetime')}
className="w-full px-6 py-4 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors font-semibold text-lg border border-gray-600"
>
Pay for lifetime
{pricingLifetime != null ? (
<span className="block text-sm font-normal text-gray-300 mt-1">
{pricingLifetime.toLocaleString()} sats one-time
</span>
) : null}
</button>
</div>
</div>
<Notification
isVisible={isVisible}
message={message}
type={type}
onClose={hideNotification}
/>
</>
);
}
if (error) {
return (
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8">
@@ -138,7 +283,29 @@ export function Payment() {
);
}
if (!invoice) {
if (isDemoMode) {
return (
<>
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8">
<h1 className="text-2xl font-bold mb-6 text-center">Payment (demo)</h1>
<button
onClick={handleDemoPayment}
className="w-full px-6 py-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors font-semibold text-gray-300"
>
Demo: Simulate Payment
</button>
</div>
<Notification
isVisible={isVisible}
message={message}
type={type}
onClose={hideNotification}
/>
</>
);
}
if (!invoice || !resolvedPlan) {
return (
<div className="text-center">
<p className="text-red-500">Failed to generate invoice. Please try again.</p>
@@ -146,6 +313,16 @@ export function Payment() {
);
}
const satsLabel =
amountSats != null ? `${amountSats.toLocaleString()} sats` : expectedSatsHint ?? '—';
const countdown =
expiresAtIso && Date.parse(expiresAtIso) > nowTick
? formatInvoiceCountdown(expiresAtIso, nowTick)
: expiresAtIso
? 'Expired'
: null;
return (
<>
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8 relative">
@@ -161,8 +338,13 @@ export function Payment() {
<h1 className="text-2xl font-bold mb-6 text-center">Payment Required</h1>
<div className="text-center mb-6">
<p className="text-3xl font-bold text-orange-500">10,000 sats</p>
<p className="text-gray-400">One-time payment for relay access</p>
<p className="text-3xl font-bold text-orange-500">{satsLabel}</p>
<p className="text-gray-400">{planSubtitle}</p>
{countdown != null ? (
<p className="text-sm text-amber-400/90 mt-3 font-mono">
Invoice expires in {countdown}
</p>
) : null}
</div>
<div className="bg-white p-4 rounded-lg mb-6">
@@ -198,15 +380,6 @@ export function Payment() {
>
Open in Wallet
</button>
{isDemoMode && (
<button
onClick={handleDemoPayment}
className="w-full px-6 py-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors font-semibold text-gray-300"
>
Demo: Simulate Payment
</button>
)}
</div>
<Notification
isVisible={isVisible}

View File

@@ -1,18 +1,15 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Shield } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
export function Terms() {
const [terms, setTerms] = useState<string>('');
const [loading, setLoading] = useState(true);
const [searchParams] = useSearchParams();
const isIframe = searchParams.get('iframe') === '1';
useEffect(() => {
const fetchTerms = async () => {
try {
const response = await fetch('/terms.txt');
const response = await fetch('/terms.md');
const text = await response.text();
setTerms(text);
} catch (error) {

View File

@@ -109,7 +109,7 @@ export function ThankYou() {
</p>
<p className="flex items-center">
<ArrowRight className="h-4 w-4 mr-2 text-orange-500" />
Access is permanent and doesn't require renewal
Lifetime plans stay active without renewal; yearly plans renew on their expiry date
</p>
</div>
</div>

View File

@@ -1,63 +1,168 @@
import { UserResponse } from '../types';
const API_URL = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '');
const API_URL = import.meta.env.VITE_API_URL;
export interface ApiUserResponse {
pubkey: string;
npub: string;
is_whitelisted: boolean;
username?: string;
expires_at?: string | null;
expired_at?: string;
subscription_type?: string;
in_grace?: boolean;
reserved_username?: string;
}
export interface PricingResponse {
yearly_sats: number;
lifetime_sats: number;
lightning_enabled: boolean;
}
export interface CreateInvoiceResponse {
payment_hash: string;
payment_request: string;
amount_sats: number;
expires_at: string;
username: string;
is_renewal: boolean;
}
export type InvoiceStatusKind = 'pending' | 'paid' | 'expired';
export interface InvoiceStatusResponse {
payment_hash: string;
status: InvoiceStatusKind;
username: string;
}
export interface ApiErrorBody {
error: string;
detail: string;
}
async function parseJsonSafe(res: Response): Promise<unknown> {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
export const apiService = {
async getUserInfo(pubkey: string): Promise<UserResponse> {
/** GET /v1/users/{pubkey} — pubkey may be hex or npub */
async getUserInfo(pubkey: string): Promise<ApiUserResponse | null> {
if (!API_URL) {
console.error('VITE_API_URL is not set');
return null;
}
try {
const response = await fetch(`${API_URL}/api/user/info`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
identifier: pubkey,
}),
const encoded = encodeURIComponent(pubkey.trim());
const response = await fetch(`${API_URL}/v1/users/${encoded}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
if (response.status === 404) {
// Return a default response for non-existent users
return {
pubkey,
npub: '',
is_whitelisted: false
};
}
throw new Error(`HTTP error! status: ${response.status}`);
return null;
}
return await response.json();
if (!response.ok) {
console.error('getUserInfo failed', response.status);
return null;
}
return (await response.json()) as ApiUserResponse;
} catch (error) {
console.error('Error fetching user info:', error);
// Return a default response on error
return {
pubkey,
npub: '',
is_whitelisted: false
};
return null;
}
},
async whitelistUser(pubkey: string, apiKey: string): Promise<void> {
try {
const response = await fetch(`${API_URL}/api/whitelist/add`, {
async getPricing(): Promise<PricingResponse> {
if (!API_URL) {
throw new Error('VITE_API_URL is not set');
}
const response = await fetch(`${API_URL}/v1/pricing`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
const body = (await parseJsonSafe(response)) as ApiErrorBody | null;
throw new Error(body?.detail ?? `pricing failed (${response.status})`);
}
return (await response.json()) as PricingResponse;
},
/** POST /v1/invoices — subscription handled server-side (LNbits inside API) */
async createInvoice(body: {
username?: string;
pubkey: string;
subscription_type: 'lifetime' | 'yearly';
years?: number;
}): Promise<CreateInvoiceResponse> {
if (!API_URL) {
throw new Error('VITE_API_URL is not set');
}
const payload: Record<string, unknown> = {
pubkey: body.pubkey.trim(),
subscription_type: body.subscription_type,
};
if (body.username?.trim()) {
payload.username = body.username.trim();
}
if (body.subscription_type === 'yearly') {
payload.years = body.years && body.years > 0 ? body.years : 1;
}
const response = await fetch(`${API_URL}/v1/invoices`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Api-Key': apiKey,
},
body: JSON.stringify({
identifier: pubkey,
}),
body: JSON.stringify(payload),
});
const data = await parseJsonSafe(response);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
const err = data as ApiErrorBody | null;
const msg = err?.detail || err?.error || `invoice create failed (${response.status})`;
throw new Error(msg);
}
} catch (error) {
console.error('Error whitelisting user:', error);
throw error; // Re-throw as this is a critical operation
const o = data as Record<string, unknown>;
const rawAmt = o.amount_sats;
const amountParsed =
typeof rawAmt === 'number'
? rawAmt
: typeof rawAmt === 'string'
? Number.parseInt(rawAmt, 10)
: NaN;
if (!Number.isFinite(amountParsed)) {
console.error('createInvoice: unexpected payload', data);
throw new Error('invalid invoice response from API');
}
return { ...(data as Record<string, unknown>), amount_sats: amountParsed } as CreateInvoiceResponse;
},
/** GET /v1/invoices/{payment_hash} */
async getInvoiceStatus(paymentHash: string): Promise<InvoiceStatusResponse> {
if (!API_URL) {
throw new Error('VITE_API_URL is not set');
}
const encoded = encodeURIComponent(paymentHash.trim());
const response = await fetch(`${API_URL}/v1/invoices/${encoded}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await parseJsonSafe(response);
if (!response.ok) {
const err = data as ApiErrorBody | null;
const msg = err?.detail || err?.error || `invoice status failed (${response.status})`;
throw new Error(msg);
}
return data as InvoiceStatusResponse;
},
};

View File

@@ -1,166 +0,0 @@
import axios from 'axios';
const LNBITS_URL = import.meta.env.VITE_LNBITS_URL;
const API_KEY = import.meta.env.VITE_LNBITS_API_KEY;
const api = axios.create({
baseURL: LNBITS_URL,
headers: {
'X-Api-Key': API_KEY,
'Content-Type': 'application/json',
},
});
export interface CreateInvoiceParams {
amount: number;
memo: string;
unit?: string;
webhook?: string;
internal?: boolean;
extra?: Record<string, any>;
}
export interface Invoice {
payment_hash: string;
payment_request: string;
checking_id: string;
amount: number;
fee: number;
memo: string;
time: number;
bolt11: string;
preimage: string;
expiry: number;
extra: Record<string, any>;
webhook?: string;
webhook_status?: number;
}
export interface PaymentStatus {
paid: boolean;
preimage?: string;
details?: {
bolt11: string;
checking_id: string;
pending: boolean;
amount: number;
fee: number;
memo: string;
time: number;
payment_hash: string;
};
}
export const lnbitsService = {
// Create a new invoice
async createInvoice({
amount,
memo,
unit = 'sat',
webhook = '',
internal = false,
extra = {},
}: CreateInvoiceParams): Promise<Invoice> {
try {
const response = await api.post('/api/v1/payments', {
out: false,
amount,
memo,
unit,
webhook,
internal,
extra,
});
return response.data;
} catch (error) {
console.error('Error creating invoice:', error);
throw error;
}
},
// Check payment status
async checkPayment(paymentHash: string): Promise<PaymentStatus> {
try {
const response = await api.get(`/api/v1/payments/${paymentHash}`);
return {
paid: response.data.paid,
preimage: response.data.preimage,
details: response.data.details,
};
} catch (error) {
console.error('Error checking payment:', error);
throw error;
}
},
// Get wallet info
async getWalletInfo() {
try {
const response = await api.get('/api/v1/wallet');
return response.data;
} catch (error) {
console.error('Error getting wallet info:', error);
throw error;
}
},
// Get payment history
async getPaymentHistory(limit = 10) {
try {
const response = await api.get('/api/v1/payments', {
params: {
limit,
offset: 0,
sortby: 'time',
direction: 'desc',
},
});
return response.data;
} catch (error) {
console.error('Error getting payment history:', error);
throw error;
}
},
// Get current exchange rate
async getExchangeRate(currency: string = 'USD') {
try {
const response = await api.get(`/api/v1/rate/${currency.toLowerCase()}`);
return response.data;
} catch (error) {
console.error('Error getting exchange rate:', error);
throw error;
}
},
// Convert fiat to sats
async convertFiatToSats(amount: number, from: string = 'USD') {
try {
const response = await api.post('/api/v1/conversion', {
from_: from.toLowerCase(),
amount,
to: 'sat',
});
return response.data;
} catch (error) {
console.error('Error converting fiat to sats:', error);
throw error;
}
},
// Long poll payment status
async longPollPayment(paymentHash: string, timeout = 60000): Promise<boolean> {
try {
const response = await api.get(`/public/v1/payment/${paymentHash}`, {
timeout,
});
return response.data.paid;
} catch (error) {
if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
return false; // Timeout reached
}
console.error('Error polling payment:', error);
throw error;
}
},
};

View File

@@ -1,8 +1,12 @@
export interface NostrUser {
pubkey: string;
isWhitelisted: boolean;
timeRemaining?: number;
npub?: string;
username?: string;
/** From GET /v1/users when registered */
subscriptionType?: 'yearly' | 'lifetime' | string;
/** ISO date string when applicable */
expiresAt?: string | null;
}
export interface LightningInvoice {
@@ -10,10 +14,3 @@ export interface LightningInvoice {
qrCode: string;
paymentHash: string;
}
export interface UserResponse {
pubkey: string;
npub: string;
time_remaining?: number;
is_whitelisted: boolean;
}

2
src/vite-env.d.ts vendored
View File

@@ -3,7 +3,7 @@
interface Window {
nostr: {
getPublicKey: () => Promise<string>;
signEvent: (event: any) => Promise<any>;
signEvent: (event: Record<string, unknown>) => Promise<Record<string, unknown>>;
getRelays: () => Promise<{ [url: string]: { read: boolean; write: boolean; } }>;
nip04: {
encrypt: (pubkey: string, plaintext: string) => Promise<string>;

View File

@@ -1,30 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
keyframes: {
'slide-up': {
'0%': { transform: 'translateY(100%)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' }
},
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' }
},
'success-appear': {
'0%': { transform: 'scale(0.8)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' }
}
},
animation: {
'slide-up': 'slide-up 0.3s ease-out',
'fade-in': 'fade-in 0.3s ease-out',
'success-appear': 'success-appear 0.5s ease-out'
}
},
},
plugins: [
require('@tailwindcss/typography'),
],
};

View File

@@ -1,10 +1,40 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
/** Backend for `npm run dev` when `VITE_API_URL` is empty (same-origin `/v1`, etc.). */
const proxyTarget = env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:8085';
return {
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
server: {
proxy: {
'^/v1': {
target: proxyTarget,
changeOrigin: true,
},
'^/.well-known': {
target: proxyTarget,
changeOrigin: true,
},
'^/healthz': {
target: proxyTarget,
changeOrigin: true,
},
'^/openapi\\.json': {
target: proxyTarget,
changeOrigin: true,
},
'^/docs': {
target: proxyTarget,
changeOrigin: true,
},
},
},
};
});