5 Commits
dev ... main

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
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
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
20 changed files with 2955 additions and 3591 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

View File

@@ -9,20 +9,20 @@ wss://relay.noderunners.network
## Features
- 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
@@ -31,28 +31,18 @@ wss://relay.noderunners.network
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
@@ -86,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**
@@ -118,27 +108,6 @@ 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>
```
## 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
@@ -148,4 +117,3 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- Built by the Noderunners community
- Powered by [strfry](https://github.com/hoytech/strfry)
- Lightning Network integration via [LNbits](https://lnbits.com)

5345
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

@@ -46,4 +46,3 @@ We reserve the right to terminate or suspend access to our service immediately,
## 8. Changes to Terms
We reserve the right to modify these terms at any time. We will notify users of any changes by updating the date at the top of this page.

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);
setUser({
...user,
isWhitelisted: userInfo.is_whitelisted,
timeRemaining: userInfo.time_remaining,
npub: userInfo.npub,
});
} catch (error: any) {
if (error.response?.status === 404 || error) {
if (userInfo) {
setUser({
...user,
isWhitelisted: userInfo.is_whitelisted,
npub: userInfo.npub,
username: userInfo.username,
subscriptionType: userInfo.subscription_type,
expiresAt: userInfo.expires_at ?? null,
});
} 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,45 +220,78 @@ 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{' '}
<a
href="https://tip.noderunners.org"
target="_blank"
<a
href="https://tip.noderunners.org"
target="_blank"
rel="noopener noreferrer"
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>
<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"
>
<Zap className="h-5 w-5" />
<span>Pay 10,000 sats for Access</span>
</button>
<div className="flex flex-col sm:flex-row gap-3">
<button
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 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
@@ -251,4 +352,4 @@ export function Dashboard() {
/>
</>
);
}
}

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 => (
<span key={nip} className="bg-gray-800 px-3 py-1 rounded text-center">
{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);
}
return () => {
cancelled = true;
if (intervalId) clearInterval(intervalId);
};
const interval = setInterval(async () => {
const paid = await checkPayment();
if (paid) {
clearInterval(interval);
}
}, 2000);
return () => clearInterval(interval);
};
}, [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">
@@ -159,10 +336,15 @@ 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}
@@ -216,4 +389,4 @@ export function Payment() {
/>
</>
);
}
}

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({
npub: 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}`);
if (response.status === 404) {
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`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': apiKey,
},
body: JSON.stringify({
npub: pubkey,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
console.error('Error whitelisting user:', error);
throw error; // Re-throw as this is a critical operation
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',
},
body: JSON.stringify(payload),
});
const data = await parseJsonSafe(response);
if (!response.ok) {
const err = data as ApiErrorBody | null;
const msg = err?.detail || err?.error || `invoice create failed (${response.status})`;
throw new Error(msg);
}
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,187 +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',
Accept: '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 {
// Build a V1-safe payload: only include known/supported fields when defined
const payload: Record<string, any> = {
out: false,
amount,
memo,
};
if (unit) payload.unit = unit;
if (webhook) payload.webhook = webhook;
if (typeof internal === 'boolean') payload.internal = internal;
if (extra && Object.keys(extra).length > 0) payload.extra = extra;
const response = await api.post('/api/v1/payments', payload);
const data = response.data ?? {};
// Normalize response to ensure payment_request is always populated for the UI
const normalized: Invoice = {
...data,
payment_request: data.payment_request || data.bolt11,
bolt11: data.bolt11 || data.payment_request,
};
return normalized;
} 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}`);
const data = response.data || {};
// Normalize potential V1 shapes
const paid: boolean = (data.paid === true) || (data.status === 'paid') || (data.settled === true);
return {
paid,
preimage: data.preimage,
details: 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> {
// Fallback to authenticated polling against V1 status endpoint to ensure compatibility
const start = Date.now();
const pollIntervalMs = 2000;
while (Date.now() - start < timeout) {
try {
const status = await this.checkPayment(paymentHash);
if (status.paid) return true;
} catch (error) {
// If transient error, keep polling until timeout
if (axios.isAxiosError(error) && (error.response?.status ?? 0) >= 500) {
// continue
} else {
console.error('Error polling payment:', error);
throw error;
}
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
return false;
},
};

View File

@@ -1,19 +1,16 @@
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 {
paymentRequest: string;
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({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
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,
},
},
},
};
});