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