Add files via upload
This commit is contained in:
28
src/App.tsx
Normal file
28
src/App.tsx
Normal 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
33
src/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/Navigation.tsx
Normal file
120
src/components/Navigation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/Notification.tsx
Normal file
36
src/components/Notification.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/hooks/useNotification.ts
Normal file
25
src/hooks/useNotification.ts
Normal 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
3
src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal 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
235
src/pages/Dashboard.tsx
Normal 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
141
src/pages/Home.tsx
Normal 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
151
src/pages/Login.tsx
Normal 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
218
src/pages/Payment.tsx
Normal 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
45
src/pages/Terms.tsx
Normal 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
131
src/pages/ThankYou.tsx
Normal 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
63
src/services/api.ts
Normal 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
166
src/services/lnbits.ts
Normal 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
12
src/store/useStore.ts
Normal 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
19
src/types.ts
Normal 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
13
src/vite-env.d.ts
vendored
Normal 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>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user