Add files via upload

This commit is contained in:
Michilis
2025-02-09 21:06:57 +01:00
committed by GitHub
parent 81d680e571
commit c899bac3fe
18 changed files with 1449 additions and 0 deletions

28
src/App.tsx Normal file
View File

@@ -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 (
<Router>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="login" element={<Login />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="payment" element={<Payment />} />
<Route path="thank-you" element={<ThankYou />} />
<Route path="terms" element={<Terms />} />
</Route>
</Routes>
</Router>
);
}
export default App;

33
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Link, Outlet } from 'react-router-dom';
import { Navigation } from './Navigation';
export function Layout() {
return (
<div className="min-h-screen bg-gray-900 text-white">
<Navigation />
<main className="container mx-auto px-4 py-4 md:py-8 min-h-[calc(100vh-64px-160px)]">
<Outlet />
</main>
<footer className="border-t border-gray-800 py-6 md:py-8 mt-8">
<div className="container mx-auto px-4 text-center">
<div className="flex flex-col md:flex-row justify-center space-y-4 md:space-y-0 md:space-x-6 mb-4">
<Link to="/terms" className="text-gray-400 hover:text-white transition-colors">
Terms of Service
</Link>
<a
href={import.meta.env.VITE_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-white transition-colors"
>
GitHub
</a>
</div>
<p className="text-gray-400 text-sm">© {new Date().getFullYear()} Noderunners - A project of 21 Toxic Bitcoin Maximalists</p>
<p className="mt-2 text-gray-400 text-sm">Always under development</p>
</div>
</footer>
</div>
);
}

View File

@@ -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 (
<nav className="border-b border-gray-800 bg-gray-900 sticky top-0 z-50">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<Link to="/" className="flex items-center space-x-2" onClick={handleNavigation}>
{import.meta.env.VITE_LOGO_URL ? (
<img
src={import.meta.env.VITE_LOGO_URL}
alt="Noderunners"
className="h-8 w-auto"
/>
) : (
<>
<Flame className="h-8 w-8 text-orange-500" />
<span className="text-xl font-bold">Noderunners</span>
</>
)}
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-4">
{user ? (
<>
<Link
to="/dashboard"
className="px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors"
>
Dashboard
</Link>
<button
onClick={handleLogout}
className="px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors"
>
Logout
</button>
</>
) : (
<Link
to="/login"
className="px-4 py-2 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors"
>
Connect Nostr
</Link>
)}
</div>
{/* Mobile Menu Button */}
<button
onClick={handleMenuClick}
className="md:hidden p-2 rounded-lg hover:bg-gray-800 transition-colors"
>
{isMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</button>
</div>
{/* Mobile Navigation */}
{isMenuOpen && (
<div className="md:hidden border-t border-gray-800 py-4">
<div className="flex flex-col space-y-2">
{user ? (
<>
<Link
to="/dashboard"
onClick={handleNavigation}
className="px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors"
>
Dashboard
</Link>
<button
onClick={handleLogout}
className="px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors text-left"
>
Logout
</button>
</>
) : (
<Link
to="/login"
onClick={handleNavigation}
className="px-4 py-2 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors text-center"
>
Connect Nostr
</Link>
)}
</div>
</div>
)}
</div>
</nav>
);
}

View File

@@ -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 (
<div className="fixed bottom-0 left-0 right-0 flex justify-center items-center p-4 pointer-events-none z-50">
<div className="bg-gray-800 text-white rounded-lg shadow-lg flex items-center space-x-3 px-4 py-3 animate-slide-up">
{type === 'success' ? (
<Check className="h-5 w-5 text-green-500 flex-shrink-0" />
) : (
<X className="h-5 w-5 text-red-500 flex-shrink-0" />
)}
<p className="text-sm">{message}</p>
</div>
</div>
);
}

View File

@@ -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
};
}

3
src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
src/main.tsx Normal file
View File

@@ -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(
<StrictMode>
<App />
</StrictMode>
);

235
src/pages/Dashboard.tsx Normal file
View File

@@ -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<string | null>(null);
const [activeUsers, setActiveUsers] = useState<number | null>(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 (
<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>
</div>
);
}
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">
{user.isWhitelisted ? (
<Shield className="h-8 md:h-12 w-8 md:w-12 text-green-500" />
) : (
<AlertTriangle className="h-8 md:h-12 w-8 md:w-12 text-orange-500" />
)}
<h1 className="text-2xl md:text-3xl font-bold">
{user.isWhitelisted ? 'Whitelisted' : 'Payment Required'}
</h1>
</div>
</div>
{user.isWhitelisted ? (
<div className="space-y-4">
<p className="text-green-400 text-base md:text-lg">
Your account has full access to the Noderunners relay
</p>
<p className="text-gray-400">
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>
</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>
<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.
</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"
rel="noopener noreferrer"
className="text-orange-400 hover:underline"
>
Noderunners community pot
</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>
)}
</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>
<p className="text-gray-400 mb-2">Relay URL</p>
<div className="flex flex-col md:flex-row items-start md:items-center space-y-2 md:space-y-0 md:space-x-2">
<code className="w-full md:flex-1 block bg-gray-900 p-3 md:p-4 rounded-lg font-mono text-sm md:text-base break-all">
wss://relay.noderunners.network
</code>
<button
onClick={() => 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 className="h-5 w-5" />
<span className="md:hidden">Copy URL</span>
</button>
</div>
</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">
<Activity className="h-6 md:h-8 w-6 md:w-8 text-green-500" />
<span className="text-xs text-gray-400">Last 30 days</span>
</div>
<p className="text-xl md:text-2xl font-bold">{uptime || 'Loading...'}</p>
<p className="text-gray-400">Uptime</p>
</div>
<div className="bg-gray-800 p-4 md:p-6 rounded-lg">
<div className="flex items-center justify-between mb-4">
<Users className="h-6 md:h-8 w-6 md:w-8 text-blue-500" />
<span className="text-xs text-gray-400">Registered Users</span>
</div>
<p className="text-xl md:text-2xl font-bold">{activeUsers?.toLocaleString() || 'Loading...'}</p>
<p className="text-gray-400">Active Users</p>
</div>
</div>
</div>
<Notification
isVisible={isVisible}
message={message}
type={type}
onClose={hideNotification}
/>
</>
);
}

141
src/pages/Home.tsx Normal file
View File

@@ -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 (
<>
<div className="max-w-4xl mx-auto">
<div className="text-center mb-16">
<h1 className="text-4xl font-bold mb-4">Welcome to Noderunners Relay</h1>
<p className="text-xl text-gray-400 mb-8">
A high-performance Nostr relay built by Bitcoiners, for Bitcoiners
</p>
<Link
to="/login"
className="inline-block px-8 py-4 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold text-lg"
>
Get Started
</Link>
</div>
<div className="grid md:grid-cols-3 gap-8 mb-16">
<div className="bg-gray-800 p-6 rounded-lg">
<Zap className="h-8 w-8 text-orange-500 mb-4" />
<h3 className="text-xl font-semibold mb-2">Lightning Fast</h3>
<p className="text-gray-400">
Built with performance in mind, ensuring your messages are delivered instantly
</p>
</div>
<div className="bg-gray-800 p-6 rounded-lg">
<Shield className="h-8 w-8 text-orange-500 mb-4" />
<h3 className="text-xl font-semibold mb-2">Secure</h3>
<p className="text-gray-400">
Your data is protected with state-of-the-art encryption and security measures
</p>
</div>
<div className="bg-gray-800 p-6 rounded-lg">
<Globe className="h-8 w-8 text-orange-500 mb-4" />
<h3 className="text-xl font-semibold mb-2">Hosted by Azzamo</h3>
<p className="text-gray-400">
Reliable infrastructure with 24/7 monitoring and maintenance
</p>
</div>
</div>
<div className="bg-gray-800 rounded-lg p-8 mb-16">
<h2 className="text-2xl font-bold mb-6">Relay Information</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div className="bg-gray-900 p-4 rounded-lg">
<p className="text-gray-400 mb-2">Relay URL</p>
<div className="flex items-center justify-between">
<code className="text-white break-all">wss://relay.noderunners.network</code>
<button
onClick={() => copyToClipboard('wss://relay.noderunners.network')}
className="ml-4 p-2 hover:bg-gray-800 rounded transition-colors"
title="Copy to clipboard"
>
<Copy className="h-5 w-5" />
</button>
</div>
</div>
<div className="bg-gray-900 p-4 rounded-lg">
<p className="text-gray-400 mb-1">Software</p>
<p className="text-white">{import.meta.env.VITE_RELAY_SOFTWARE}</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-800 rounded-lg p-8">
<h2 className="text-2xl font-bold mb-6">Technical Specifications</h2>
<div className="grid md:grid-cols-2 gap-6">
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Server className="h-5 w-5 mr-2 text-orange-500" />
Supported NIPs
</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}
</span>
))}
</div>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Cpu className="h-5 w-5 mr-2 text-orange-500" />
Features
</h3>
<ul className="space-y-2 text-gray-400">
<li className="flex items-center">
<Code className="h-4 w-4 mr-2 text-orange-500" />
High-performance strfry backend
</li>
<li className="flex items-center">
<Code className="h-4 w-4 mr-2 text-orange-500" />
Paid relay with Lightning Network
</li>
<li className="flex items-center">
<Code className="h-4 w-4 mr-2 text-orange-500" />
Advanced spam protection
</li>
<li className="flex items-center">
<Code className="h-4 w-4 mr-2 text-orange-500" />
24/7 monitoring and maintenance
</li>
</ul>
</div>
</div>
</div>
</div>
<Notification
isVisible={isVisible}
message={message}
type={type}
onClose={hideNotification}
/>
</>
);
}

151
src/pages/Login.tsx Normal file
View File

@@ -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 (
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8">
<h1 className="text-2xl font-bold mb-6 text-center">Connect with Nostr</h1>
<p className="text-gray-400 mb-6">
To access the Noderunners relay, connect using your Nostr account.
You can use a Nostr extension or enter your public key manually.
</p>
<div className="space-y-4">
<button
onClick={handleExtensionLogin}
disabled={isLoading}
className={`w-full px-6 py-3 bg-orange-500 rounded-lg font-semibold flex items-center justify-center space-x-2
${isLoading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-orange-600'} transition-colors`}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Connecting...</span>
</>
) : (
<>
<Zap className="h-5 w-5" />
<span>Sign in with Extension</span>
</>
)}
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-800 text-gray-400">Or enter manually</span>
</div>
</div>
<div className="space-y-3">
<label htmlFor="pubkey" className="block text-sm font-medium text-gray-300">
Enter your Nostr public key or npub
</label>
<input
type="text"
id="pubkey"
value={pubkeyInput}
onChange={(e) => 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"
/>
<button
onClick={handlePubkeyLogin}
disabled={isLoading || !pubkeyInput.trim()}
className={`w-full px-6 py-3 bg-gray-700 rounded-lg font-semibold
${isLoading || !pubkeyInput.trim() ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-600'}
transition-colors`}
>
Continue
</button>
</div>
</div>
<p className="mt-4 text-sm text-gray-400 text-center hidden md:block">
Don't have a Nostr extension?{' '}
<a
href="https://getalby.com"
target="_blank"
rel="noopener noreferrer"
className="text-orange-500 hover:underline"
>
Get Alby
</a>
</p>
</div>
);
}

218
src/pages/Payment.tsx Normal file
View File

@@ -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<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);
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 (
<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>
</div>
);
}
if (error) {
return (
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8">
<div className="text-center text-red-500 mb-4">
<p>{error}</p>
</div>
<button
onClick={() => 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
</button>
</div>
);
}
if (!invoice) {
return (
<div className="text-center">
<p className="text-red-500">Failed to generate invoice. Please try again.</p>
</div>
);
}
return (
<>
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8 relative">
{/* Success Animation Overlay */}
{showSuccess && (
<div className="absolute inset-0 bg-gray-900/95 flex items-center justify-center rounded-lg animate-fade-in z-10">
<div className="text-center animate-success-appear">
<CheckCircle className="h-16 w-16 text-green-500 mx-auto mb-4" />
<p className="text-xl font-semibold text-white">Payment Received!</p>
</div>
</div>
)}
<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>
</div>
<div className="bg-white p-4 rounded-lg mb-6">
<QRCodeSVG
value={invoice.paymentRequest}
size={256}
className="w-full h-auto"
level="L"
/>
</div>
<div className="bg-gray-900 rounded-lg mb-6">
<div className="flex items-center">
<div className="flex-1 overflow-x-auto whitespace-nowrap p-4 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
<code className="font-mono text-sm text-white select-all" onClick={() => copyToClipboard(invoice.paymentRequest)}>
{invoice.paymentRequest}
</code>
</div>
<button
onClick={() => 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 className="h-4 w-4" />
<span className="text-sm">Copy</span>
</button>
</div>
</div>
<button
onClick={() => 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
</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}
message={message}
type={type}
onClose={hideNotification}
/>
</>
);
}

45
src/pages/Terms.tsx Normal file
View File

@@ -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<string>('');
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 (
<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>
</div>
);
}
return (
<div className="max-w-3xl mx-auto">
<div className="text-center mb-12">
<Shield className="h-12 w-12 text-orange-500 mx-auto mb-4" />
</div>
<div className="prose prose-invert prose-headings:text-white prose-headings:font-bold prose-p:text-gray-400 prose-strong:text-white prose-ul:text-gray-400 max-w-none">
<ReactMarkdown>{terms}</ReactMarkdown>
</div>
</div>
);
}

131
src/pages/ThankYou.tsx Normal file
View File

@@ -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 (
<div className="max-w-2xl mx-auto text-center">
<div className="mb-12">
<CheckCircle className="h-20 w-20 text-green-500 mx-auto mb-6" />
<h1 className="text-4xl font-bold mb-4">
Welcome to the Noderunners Relay!
</h1>
<p className="text-xl text-gray-400 mb-8">
Your payment has been received and processed successfully.
</p>
</div>
<div className="bg-gray-800 rounded-lg p-8 mb-8">
<h2 className="text-2xl font-semibold mb-6">Connect Your Nostr Client</h2>
<p className="text-gray-400 mb-6">
Add the following relay URL to your Nostr client to start using the Noderunners relay:
</p>
<div className="bg-gray-900 p-4 rounded-lg mb-6">
<div className="flex items-center justify-between">
<code className="text-orange-500 text-lg">{relayUrl}</code>
<button
onClick={() => copyToClipboard(relayUrl)}
className="ml-4 p-2 hover:bg-gray-800 rounded-lg transition-colors group"
title="Copy to clipboard"
>
<Copy className="h-5 w-5 text-gray-400 group-hover:text-white" />
</button>
</div>
</div>
<div className="text-left space-y-2 text-gray-400">
<p className="flex items-center">
<ArrowRight className="h-4 w-4 mr-2 text-orange-500" />
Your account is now whitelisted and ready to use
</p>
<p className="flex items-center">
<ArrowRight className="h-4 w-4 mr-2 text-orange-500" />
You can start posting and receiving messages immediately
</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
</p>
</div>
</div>
<button
onClick={() => navigate('/dashboard')}
className="px-8 py-4 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold"
>
Go to Dashboard
</button>
<Notification
isVisible={isVisible}
message={message}
type={type}
onClose={hideNotification}
/>
</div>
);
}

63
src/services/api.ts Normal file
View File

@@ -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<UserResponse> {
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<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({
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
}
},
};

166
src/services/lnbits.ts Normal file
View File

@@ -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<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;
}
},
};

12
src/store/useStore.ts Normal file
View File

@@ -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<Store>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));

19
src/types.ts Normal file
View File

@@ -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;
}

13
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
interface Window {
nostr: {
getPublicKey: () => Promise<string>;
signEvent: (event: any) => Promise<any>;
getRelays: () => Promise<{ [url: string]: { read: boolean; write: boolean; } }>;
nip04: {
encrypt: (pubkey: string, plaintext: string) => Promise<string>;
decrypt: (pubkey: string, ciphertext: string) => Promise<string>;
};
};
}