diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..a9d03b2
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
+import { Home } from './pages/Home';
+import { Login } from './pages/Login';
+import { Dashboard } from './pages/Dashboard';
+import { Payment } from './pages/Payment';
+import { ThankYou } from './pages/ThankYou';
+import { Terms } from './pages/Terms';
+import { Layout } from './components/Layout';
+
+function App() {
+ return (
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
+}
+
+export default App;
\ No newline at end of file
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
new file mode 100644
index 0000000..0cf129f
--- /dev/null
+++ b/src/components/Layout.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { Link, Outlet } from 'react-router-dom';
+import { Navigation } from './Navigation';
+
+export function Layout() {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx
new file mode 100644
index 0000000..44d369f
--- /dev/null
+++ b/src/components/Navigation.tsx
@@ -0,0 +1,120 @@
+import React, { useState } from 'react';
+import { Link, useLocation, useNavigate } 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 { user, setUser } = useStore();
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const handleLogout = () => {
+ setUser(null);
+ setIsMenuOpen(false);
+ navigate('/login', { replace: true }); // Use replace to prevent going back to dashboard
+ };
+
+ const handleMenuClick = () => {
+ setIsMenuOpen(!isMenuOpen);
+ };
+
+ const handleNavigation = () => {
+ setIsMenuOpen(false);
+ };
+
+ return (
+
+
+
+
+ {import.meta.env.VITE_LOGO_URL ? (
+
+ ) : (
+ <>
+
+
Noderunners
+ >
+ )}
+
+
+ {/* Desktop Navigation */}
+
+ {user ? (
+ <>
+
+ Dashboard
+
+
+ Logout
+
+ >
+ ) : (
+
+ Connect Nostr
+
+ )}
+
+
+ {/* Mobile Menu Button */}
+
+ {isMenuOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* Mobile Navigation */}
+ {isMenuOpen && (
+
+
+ {user ? (
+ <>
+
+ Dashboard
+
+
+ Logout
+
+ >
+ ) : (
+
+ Connect Nostr
+
+ )}
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx
new file mode 100644
index 0000000..7556b77
--- /dev/null
+++ b/src/components/Notification.tsx
@@ -0,0 +1,36 @@
+import React, { useEffect } from 'react';
+import { Check, X } from 'lucide-react';
+
+interface NotificationProps {
+ message: string;
+ isVisible: boolean;
+ onClose: () => void;
+ type?: 'success' | 'error';
+}
+
+export function Notification({ message, isVisible, onClose, type = 'success' }: NotificationProps) {
+ useEffect(() => {
+ if (isVisible) {
+ const timer = setTimeout(() => {
+ onClose();
+ }, 3000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [isVisible, onClose]);
+
+ if (!isVisible) return null;
+
+ return (
+
+
+ {type === 'success' ? (
+
+ ) : (
+
+ )}
+
{message}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts
new file mode 100644
index 0000000..a170f4c
--- /dev/null
+++ b/src/hooks/useNotification.ts
@@ -0,0 +1,25 @@
+import { useState, useCallback } from 'react';
+
+export function useNotification() {
+ const [isVisible, setIsVisible] = useState(false);
+ const [message, setMessage] = useState('');
+ const [type, setType] = useState<'success' | 'error'>('success');
+
+ const showNotification = useCallback((text: string, notificationType: 'success' | 'error' = 'success') => {
+ setMessage(text);
+ setType(notificationType);
+ setIsVisible(true);
+ }, []);
+
+ const hideNotification = useCallback(() => {
+ setIsVisible(false);
+ }, []);
+
+ return {
+ isVisible,
+ message,
+ type,
+ showNotification,
+ hideNotification
+ };
+}
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..b5c61c9
--- /dev/null
+++ b/src/index.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..e0bbe63
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App.tsx';
+import './index.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+);
\ No newline at end of file
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..07f43a9
--- /dev/null
+++ b/src/pages/Dashboard.tsx
@@ -0,0 +1,235 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Shield, AlertTriangle, Zap, Copy, Activity, Users } from 'lucide-react';
+import { useStore } from '../store/useStore';
+import { apiService } from '../services/api';
+import { Notification } from '../components/Notification';
+import { useNotification } from '../hooks/useNotification';
+
+export function Dashboard() {
+ const navigate = useNavigate();
+ const { user, setUser } = useStore();
+ const [uptime, setUptime] = useState(null);
+ const [activeUsers, setActiveUsers] = useState(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();
+
+ // Check user authentication and fetch status once
+ useEffect(() => {
+ if (!user) {
+ navigate('/login');
+ return;
+ }
+
+ const checkUserStatus = async () => {
+ 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 user is not found (404) or any other error, assume not whitelisted
+ if (error.response?.status === 404 || error) {
+ setUser({
+ ...user,
+ isWhitelisted: false,
+ });
+ }
+ console.error('Failed to fetch user status:', error);
+ }
+ }
+ setLoading(false);
+ };
+
+ checkUserStatus();
+ }, [user?.pubkey, navigate, setUser, isDemoMode]); // Only run on mount and when these dependencies change
+
+ // Fetch uptime and active users
+ useEffect(() => {
+ const fetchUptime = async () => {
+ if (isDemoMode) {
+ setUptime('99.99%');
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `${import.meta.env.VITE_UPTIME_KUMA_URL}/api/status-page/heartbeat/${import.meta.env.VITE_UPTIME_KUMA_ID}`
+ );
+ const data = await response.json();
+ const uptimePercentage = ((data.uptime / data.total) * 100).toFixed(2);
+ setUptime(`${uptimePercentage}%`);
+ } catch (error) {
+ console.error('Failed to fetch uptime:', error);
+ setUptime('N/A');
+ }
+ };
+
+ const fetchActiveUsers = async () => {
+ if (isDemoMode) {
+ setActiveUsers(421);
+ return;
+ }
+
+ try {
+ const response = await fetch(`${apiUrl}/.well-known/nostr.json`);
+ const data = await response.json();
+ setActiveUsers(data.names ? Object.keys(data.names).length : 0);
+ } catch (error) {
+ console.error('Failed to fetch active users:', error);
+ setActiveUsers(null);
+ }
+ };
+
+ fetchUptime();
+ fetchActiveUsers();
+
+ const uptimeInterval = setInterval(fetchUptime, 60000);
+ const usersInterval = setInterval(fetchActiveUsers, 30000);
+
+ return () => {
+ clearInterval(uptimeInterval);
+ clearInterval(usersInterval);
+ };
+ }, [isDemoMode, apiUrl]);
+
+ const copyToClipboard = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ showNotification('Relay URL copied to clipboard');
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ showNotification('Failed to copy URL', 'error');
+ }
+ };
+
+ if (loading || !user) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+ {/* Status Banner */}
+
+
+
+ {user.isWhitelisted ? (
+
+ ) : (
+
+ )}
+
+ {user.isWhitelisted ? 'Whitelisted' : 'Payment Required'}
+
+
+
+
+ {user.isWhitelisted ? (
+
+
+ ✓ Your account has full access to the Noderunners relay
+
+
+ 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.
+
+
+ ) : (
+
+
+
+ One-time Payment Required
+
+
+
+ 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.
+
+
+ 21% of all payments go to the{' '}
+
+ Noderunners community pot
+
+ {' '}to support the development of Bitcoin and Nostr projects.
+
+
+
+
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"
+ >
+
+ Pay 10,000 sats for Access
+
+
+ )}
+
+
+ {/* Connection Information */}
+
+
Connection Information
+
+
Relay URL
+
+
+ wss://relay.noderunners.network
+
+ copyToClipboard('wss://relay.noderunners.network')}
+ className="w-full md:w-auto px-4 py-3 md:p-3 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors flex items-center justify-center space-x-2"
+ title="Copy to clipboard"
+ >
+
+ Copy URL
+
+
+
+
+
+ {/* Stats Overview */}
+
+
+
+
{uptime || 'Loading...'}
+
Uptime
+
+
+
+
+
+ Registered Users
+
+
{activeUsers?.toLocaleString() || 'Loading...'}
+
Active Users
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
new file mode 100644
index 0000000..c7b5e0f
--- /dev/null
+++ b/src/pages/Home.tsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { Flame, Zap, Shield, Globe, Server, Code, Cpu, Copy } from 'lucide-react';
+import { Notification } from '../components/Notification';
+import { useNotification } from '../hooks/useNotification';
+
+export function Home() {
+ const { isVisible, message, type, showNotification, hideNotification } = useNotification();
+
+ const copyToClipboard = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ showNotification('Relay URL copied to clipboard');
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ showNotification('Failed to copy URL', 'error');
+ }
+ };
+
+ return (
+ <>
+
+
+
Welcome to Noderunners Relay
+
+ A high-performance Nostr relay built by Bitcoiners, for Bitcoiners
+
+
+ Get Started
+
+
+
+
+
+
+
Lightning Fast
+
+ Built with performance in mind, ensuring your messages are delivered instantly
+
+
+
+
+
+
Secure
+
+ Your data is protected with state-of-the-art encryption and security measures
+
+
+
+
+
+
Hosted by Azzamo
+
+ Reliable infrastructure with 24/7 monitoring and maintenance
+
+
+
+
+
+
Relay Information
+
+
+
+
Relay URL
+
+ wss://relay.noderunners.network
+ copyToClipboard('wss://relay.noderunners.network')}
+ className="ml-4 p-2 hover:bg-gray-800 rounded transition-colors"
+ title="Copy to clipboard"
+ >
+
+
+
+
+
+
Software
+
{import.meta.env.VITE_RELAY_SOFTWARE}
+
+
+
+
+
+
+
Technical Specifications
+
+
+
+
+ Supported NIPs
+
+
+
+ {import.meta.env.VITE_SUPPORTED_NIPS.split(',').map(nip => (
+
+ {nip}
+
+ ))}
+
+
+
+
+
+
+
+ Features
+
+
+
+
+ High-performance strfry backend
+
+
+
+ Paid relay with Lightning Network
+
+
+
+ Advanced spam protection
+
+
+
+ 24/7 monitoring and maintenance
+
+
+
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
new file mode 100644
index 0000000..ecf2638
--- /dev/null
+++ b/src/pages/Login.tsx
@@ -0,0 +1,151 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Loader2, Zap } from 'lucide-react';
+import { useStore } from '../store/useStore';
+
+export function Login() {
+ const navigate = useNavigate();
+ const { user, setUser } = useStore();
+ const [isLoading, setIsLoading] = useState(false);
+ const [pubkeyInput, setPubkeyInput] = useState('');
+
+ useEffect(() => {
+ if (user) {
+ navigate('/dashboard');
+ }
+ }, [user, navigate]);
+
+ const handleExtensionLogin = async () => {
+ setIsLoading(true);
+ try {
+ let attempts = 0;
+ while (!window.nostr && attempts < 50) {
+ await new Promise(resolve => setTimeout(resolve, 100));
+ attempts++;
+ }
+
+ if (!window.nostr) {
+ throw new Error('Nostr provider not found after waiting');
+ }
+
+ const pubkey = await window.nostr.getPublicKey();
+ if (!pubkey) {
+ throw new Error('No public key found');
+ }
+
+ // Simply store the pubkey and navigate to dashboard
+ setUser({ pubkey, isWhitelisted: false });
+ navigate('/dashboard');
+ } catch (error: any) {
+ if (error.message === 'Rejected by user') {
+ return;
+ }
+ console.error('Login failed:', error);
+
+ if (error.message.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')) {
+ 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') {
+ alert('Failed to connect. Please make sure you have a Nostr extension installed and try again.');
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handlePubkeyLogin = async () => {
+ if (!pubkeyInput.trim()) {
+ alert('Please enter a public key or npub');
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ // Simply store the pubkey and navigate to dashboard
+ setUser({ pubkey: pubkeyInput.trim(), isWhitelisted: false });
+ navigate('/dashboard');
+ } catch (error) {
+ console.error('Login failed:', error);
+ alert('Failed to login with the provided public key. Please check the format and try again.');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
Connect with Nostr
+
+
+ To access the Noderunners relay, connect using your Nostr account.
+ You can use a Nostr extension or enter your public key manually.
+
+
+
+
+ {isLoading ? (
+ <>
+
+ Connecting...
+ >
+ ) : (
+ <>
+
+ Sign in with Extension
+ >
+ )}
+
+
+
+
+
+ Or enter manually
+
+
+
+
+
+ Enter your Nostr public key or npub
+
+ setPubkeyInput(e.target.value)}
+ placeholder="npub1... or hex public key"
+ className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent text-white placeholder-gray-500"
+ />
+
+ Continue
+
+
+
+
+
+ Don't have a Nostr extension?{' '}
+
+ Get Alby
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/Payment.tsx b/src/pages/Payment.tsx
new file mode 100644
index 0000000..e710dd2
--- /dev/null
+++ b/src/pages/Payment.tsx
@@ -0,0 +1,218 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate } 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';
+
+export function Payment() {
+ const navigate = useNavigate();
+ const { user, setUser } = useStore();
+ const [invoice, setInvoice] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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);
+
+ useEffect(() => {
+ if (!user) {
+ navigate('/login');
+ return;
+ }
+
+ if (user.isWhitelisted) {
+ navigate('/dashboard');
+ 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'
+ }
+ });
+
+ setInvoice({
+ paymentRequest: response.payment_request,
+ qrCode: response.payment_request,
+ paymentHash: response.payment_hash
+ });
+
+ pollPaymentStatus(response.payment_hash);
+ } catch (err) {
+ console.error('Failed to generate invoice:', err);
+ setError('Failed to generate invoice. Please try again later.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ generateInvoice();
+ }, [user, navigate]);
+
+ const handlePaymentSuccess = async () => {
+ setShowSuccess(true);
+ setTimeout(() => {
+ navigate('/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);
+ };
+
+ const copyToClipboard = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ showNotification('Lightning invoice copied to clipboard');
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ showNotification('Failed to copy invoice', 'error');
+ }
+ };
+
+ const handleDemoPayment = () => {
+ handlePaymentSuccess();
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
window.location.reload()}
+ className="w-full px-6 py-3 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold"
+ >
+ Try Again
+
+
+ );
+ }
+
+ if (!invoice) {
+ return (
+
+
Failed to generate invoice. Please try again.
+
+ );
+ }
+
+ return (
+ <>
+
+ {/* Success Animation Overlay */}
+ {showSuccess && (
+
+ )}
+
+
Payment Required
+
+
+
10,000 sats
+
One-time payment for relay access
+
+
+
+
+
+
+
+
+
+ copyToClipboard(invoice.paymentRequest)}>
+ {invoice.paymentRequest}
+
+
+
copyToClipboard(invoice.paymentRequest)}
+ className="p-4 hover:bg-gray-800 transition-colors border-l border-gray-800 flex items-center gap-2"
+ title="Copy invoice"
+ >
+
+ Copy
+
+
+
+
+
window.open(`lightning:${invoice.paymentRequest}`)}
+ className="w-full px-6 py-3 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold mb-4"
+ >
+ Open in Wallet
+
+
+ {isDemoMode && (
+
+ Demo: Simulate Payment
+
+ )}
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/pages/Terms.tsx b/src/pages/Terms.tsx
new file mode 100644
index 0000000..f9b244f
--- /dev/null
+++ b/src/pages/Terms.tsx
@@ -0,0 +1,45 @@
+import React, { useEffect, useState } from 'react';
+import { Shield } from 'lucide-react';
+import ReactMarkdown from 'react-markdown';
+
+export function Terms() {
+ const [terms, setTerms] = useState('');
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const fetchTerms = async () => {
+ try {
+ const response = await fetch('/terms.txt');
+ const text = await response.text();
+ setTerms(text);
+ } catch (error) {
+ console.error('Failed to load terms:', error);
+ setTerms('Failed to load terms of service. Please try again later.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchTerms();
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/ThankYou.tsx b/src/pages/ThankYou.tsx
new file mode 100644
index 0000000..00b4efe
--- /dev/null
+++ b/src/pages/ThankYou.tsx
@@ -0,0 +1,131 @@
+import React, { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { CheckCircle, Copy, ArrowRight } from 'lucide-react';
+import { useStore } from '../store/useStore';
+import confetti from 'canvas-confetti';
+import { Notification } from '../components/Notification';
+import { useNotification } from '../hooks/useNotification';
+
+export function ThankYou() {
+ const navigate = useNavigate();
+ const { user } = useStore();
+ const { isVisible, message, type, showNotification, hideNotification } = useNotification();
+ const relayUrl = import.meta.env.VITE_NOSTR_RELAY_URL;
+
+ useEffect(() => {
+ if (!user) {
+ navigate('/login');
+ return;
+ }
+
+ // Trigger confetti animation
+ const duration = 2000;
+ const animationEnd = Date.now() + duration;
+ const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
+
+ function randomInRange(min: number, max: number) {
+ return Math.random() * (max - min) + min;
+ }
+
+ const interval: NodeJS.Timer = setInterval(function() {
+ const timeLeft = animationEnd - Date.now();
+
+ if (timeLeft <= 0) {
+ return clearInterval(interval);
+ }
+
+ const particleCount = 50 * (timeLeft / duration);
+
+ // Since they fall down, start a bit higher than random
+ confetti({
+ ...defaults,
+ particleCount,
+ origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
+ });
+ confetti({
+ ...defaults,
+ particleCount,
+ origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
+ });
+ }, 250);
+
+ return () => clearInterval(interval);
+ }, [user, navigate]);
+
+ const copyToClipboard = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ showNotification('Relay URL copied to clipboard');
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ showNotification('Failed to copy URL', 'error');
+ }
+ };
+
+ if (!user) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ Welcome to the Noderunners Relay!
+
+
+ Your payment has been received and processed successfully.
+
+
+
+
+
Connect Your Nostr Client
+
+ Add the following relay URL to your Nostr client to start using the Noderunners relay:
+
+
+
+
+ {relayUrl}
+ copyToClipboard(relayUrl)}
+ className="ml-4 p-2 hover:bg-gray-800 rounded-lg transition-colors group"
+ title="Copy to clipboard"
+ >
+
+
+
+
+
+
+
+
+ Your account is now whitelisted and ready to use
+
+
+
+ You can start posting and receiving messages immediately
+
+
+
+ Access is permanent and doesn't require renewal
+
+
+
+
+
navigate('/dashboard')}
+ className="px-8 py-4 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold"
+ >
+ Go to Dashboard
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/services/api.ts b/src/services/api.ts
new file mode 100644
index 0000000..8b9b163
--- /dev/null
+++ b/src/services/api.ts
@@ -0,0 +1,63 @@
+import { UserResponse } from '../types';
+
+const API_URL = import.meta.env.VITE_API_URL;
+
+export const apiService = {
+ async getUserInfo(pubkey: string): Promise {
+ try {
+ const response = await fetch(`${API_URL}/api/user/info`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ identifier: pubkey,
+ }),
+ });
+
+ 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 await response.json();
+ } catch (error) {
+ console.error('Error fetching user info:', error);
+ // Return a default response on error
+ return {
+ pubkey,
+ npub: '',
+ is_whitelisted: false
+ };
+ }
+ },
+
+ async whitelistUser(pubkey: string, apiKey: string): Promise {
+ try {
+ const response = await fetch(`${API_URL}/api/whitelist/add`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Api-Key': apiKey,
+ },
+ body: JSON.stringify({
+ identifier: 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
+ }
+ },
+};
\ No newline at end of file
diff --git a/src/services/lnbits.ts b/src/services/lnbits.ts
new file mode 100644
index 0000000..6db1c65
--- /dev/null
+++ b/src/services/lnbits.ts
@@ -0,0 +1,166 @@
+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;
+}
+
+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;
+ 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 {
+ 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 {
+ 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 {
+ 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;
+ }
+ },
+};
\ No newline at end of file
diff --git a/src/store/useStore.ts b/src/store/useStore.ts
new file mode 100644
index 0000000..470c21b
--- /dev/null
+++ b/src/store/useStore.ts
@@ -0,0 +1,12 @@
+import { create } from 'zustand';
+import { NostrUser } from '../types';
+
+interface Store {
+ user: NostrUser | null;
+ setUser: (user: NostrUser | null) => void;
+}
+
+export const useStore = create((set) => ({
+ user: null,
+ setUser: (user) => set({ user }),
+}));
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..409164c
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,19 @@
+export interface NostrUser {
+ pubkey: string;
+ isWhitelisted: boolean;
+ timeRemaining?: number;
+ npub?: string;
+}
+
+export interface LightningInvoice {
+ paymentRequest: string;
+ qrCode: string;
+ paymentHash: string;
+}
+
+export interface UserResponse {
+ pubkey: string;
+ npub: string;
+ time_remaining?: number;
+ is_whitelisted: boolean;
+}
\ No newline at end of file
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..a026918
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,13 @@
+///
+
+interface Window {
+ nostr: {
+ getPublicKey: () => Promise;
+ signEvent: (event: any) => Promise;
+ getRelays: () => Promise<{ [url: string]: { read: boolean; write: boolean; } }>;
+ nip04: {
+ encrypt: (pubkey: string, plaintext: string) => Promise;
+ decrypt: (pubkey: string, ciphertext: string) => Promise;
+ };
+ };
+}
\ No newline at end of file