From c899bac3fe52103dd5b36373870736a1f96c3a69 Mon Sep 17 00:00:00 2001 From: Michilis <120072772+Michilis@users.noreply.github.com> Date: Sun, 9 Feb 2025 21:06:57 +0100 Subject: [PATCH] Add files via upload --- src/App.tsx | 28 ++++ src/components/Layout.tsx | 33 +++++ src/components/Navigation.tsx | 120 ++++++++++++++++ src/components/Notification.tsx | 36 +++++ src/hooks/useNotification.ts | 25 ++++ src/index.css | 3 + src/main.tsx | 10 ++ src/pages/Dashboard.tsx | 235 ++++++++++++++++++++++++++++++++ src/pages/Home.tsx | 141 +++++++++++++++++++ src/pages/Login.tsx | 151 ++++++++++++++++++++ src/pages/Payment.tsx | 218 +++++++++++++++++++++++++++++ src/pages/Terms.tsx | 45 ++++++ src/pages/ThankYou.tsx | 131 ++++++++++++++++++ src/services/api.ts | 63 +++++++++ src/services/lnbits.ts | 166 ++++++++++++++++++++++ src/store/useStore.ts | 12 ++ src/types.ts | 19 +++ src/vite-env.d.ts | 13 ++ 18 files changed, 1449 insertions(+) create mode 100644 src/App.tsx create mode 100644 src/components/Layout.tsx create mode 100644 src/components/Navigation.tsx create mode 100644 src/components/Notification.tsx create mode 100644 src/hooks/useNotification.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/Home.tsx create mode 100644 src/pages/Login.tsx create mode 100644 src/pages/Payment.tsx create mode 100644 src/pages/Terms.tsx create mode 100644 src/pages/ThankYou.tsx create mode 100644 src/services/api.ts create mode 100644 src/services/lnbits.ts create mode 100644 src/store/useStore.ts create mode 100644 src/types.ts create mode 100644 src/vite-env.d.ts diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..a9d03b2 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { Home } from './pages/Home'; +import { Login } from './pages/Login'; +import { Dashboard } from './pages/Dashboard'; +import { Payment } from './pages/Payment'; +import { ThankYou } from './pages/ThankYou'; +import { Terms } from './pages/Terms'; +import { Layout } from './components/Layout'; + +function App() { + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +export default App; \ No newline at end of file diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..0cf129f --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Link, Outlet } from 'react-router-dom'; +import { Navigation } from './Navigation'; + +export function Layout() { + return ( +
+ +
+ +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx new file mode 100644 index 0000000..44d369f --- /dev/null +++ b/src/components/Navigation.tsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Flame, Menu, X } from 'lucide-react'; +import { useStore } from '../store/useStore'; + +export function Navigation() { + const location = useLocation(); + const navigate = useNavigate(); + const { user, setUser } = useStore(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const handleLogout = () => { + setUser(null); + setIsMenuOpen(false); + navigate('/login', { replace: true }); // Use replace to prevent going back to dashboard + }; + + const handleMenuClick = () => { + setIsMenuOpen(!isMenuOpen); + }; + + const handleNavigation = () => { + setIsMenuOpen(false); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx new file mode 100644 index 0000000..7556b77 --- /dev/null +++ b/src/components/Notification.tsx @@ -0,0 +1,36 @@ +import React, { useEffect } from 'react'; +import { Check, X } from 'lucide-react'; + +interface NotificationProps { + message: string; + isVisible: boolean; + onClose: () => void; + type?: 'success' | 'error'; +} + +export function Notification({ message, isVisible, onClose, type = 'success' }: NotificationProps) { + useEffect(() => { + if (isVisible) { + const timer = setTimeout(() => { + onClose(); + }, 3000); + + return () => clearTimeout(timer); + } + }, [isVisible, onClose]); + + if (!isVisible) return null; + + return ( +
+
+ {type === 'success' ? ( + + ) : ( + + )} +

{message}

+
+
+ ); +} \ No newline at end of file diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts new file mode 100644 index 0000000..a170f4c --- /dev/null +++ b/src/hooks/useNotification.ts @@ -0,0 +1,25 @@ +import { useState, useCallback } from 'react'; + +export function useNotification() { + const [isVisible, setIsVisible] = useState(false); + const [message, setMessage] = useState(''); + const [type, setType] = useState<'success' | 'error'>('success'); + + const showNotification = useCallback((text: string, notificationType: 'success' | 'error' = 'success') => { + setMessage(text); + setType(notificationType); + setIsVisible(true); + }, []); + + const hideNotification = useCallback(() => { + setIsVisible(false); + }, []); + + return { + isVisible, + message, + type, + showNotification, + hideNotification + }; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..e0bbe63 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + +); \ No newline at end of file diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..07f43a9 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,235 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Shield, AlertTriangle, Zap, Copy, Activity, Users } from 'lucide-react'; +import { useStore } from '../store/useStore'; +import { apiService } from '../services/api'; +import { Notification } from '../components/Notification'; +import { useNotification } from '../hooks/useNotification'; + +export function Dashboard() { + const navigate = useNavigate(); + const { user, setUser } = useStore(); + const [uptime, setUptime] = useState(null); + const [activeUsers, setActiveUsers] = useState(null); + const [loading, setLoading] = useState(true); + const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true'; + const apiUrl = import.meta.env.VITE_API_URL; + const { isVisible, message, type, showNotification, hideNotification } = useNotification(); + + // Check user authentication and fetch status once + useEffect(() => { + if (!user) { + navigate('/login'); + return; + } + + const checkUserStatus = async () => { + if (!isDemoMode) { + try { + const userInfo = await apiService.getUserInfo(user.pubkey); + setUser({ + ...user, + isWhitelisted: userInfo.is_whitelisted, + timeRemaining: userInfo.time_remaining, + npub: userInfo.npub, + }); + } catch (error: any) { + // If user is not found (404) or any other error, assume not whitelisted + if (error.response?.status === 404 || error) { + setUser({ + ...user, + isWhitelisted: false, + }); + } + console.error('Failed to fetch user status:', error); + } + } + setLoading(false); + }; + + checkUserStatus(); + }, [user?.pubkey, navigate, setUser, isDemoMode]); // Only run on mount and when these dependencies change + + // Fetch uptime and active users + useEffect(() => { + const fetchUptime = async () => { + if (isDemoMode) { + setUptime('99.99%'); + return; + } + + try { + const response = await fetch( + `${import.meta.env.VITE_UPTIME_KUMA_URL}/api/status-page/heartbeat/${import.meta.env.VITE_UPTIME_KUMA_ID}` + ); + const data = await response.json(); + const uptimePercentage = ((data.uptime / data.total) * 100).toFixed(2); + setUptime(`${uptimePercentage}%`); + } catch (error) { + console.error('Failed to fetch uptime:', error); + setUptime('N/A'); + } + }; + + const fetchActiveUsers = async () => { + if (isDemoMode) { + setActiveUsers(421); + return; + } + + try { + const response = await fetch(`${apiUrl}/.well-known/nostr.json`); + const data = await response.json(); + setActiveUsers(data.names ? Object.keys(data.names).length : 0); + } catch (error) { + console.error('Failed to fetch active users:', error); + setActiveUsers(null); + } + }; + + fetchUptime(); + fetchActiveUsers(); + + const uptimeInterval = setInterval(fetchUptime, 60000); + const usersInterval = setInterval(fetchActiveUsers, 30000); + + return () => { + clearInterval(uptimeInterval); + clearInterval(usersInterval); + }; + }, [isDemoMode, apiUrl]); + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + showNotification('Relay URL copied to clipboard'); + } catch (err) { + console.error('Failed to copy:', err); + showNotification('Failed to copy URL', 'error'); + } + }; + + if (loading || !user) { + return ( +
+
+
+ ); + } + + return ( + <> +
+ {/* Status Banner */} +
+
+
+ {user.isWhitelisted ? ( + + ) : ( + + )} +

+ {user.isWhitelisted ? 'Whitelisted' : 'Payment Required'} +

+
+
+ + {user.isWhitelisted ? ( +
+

+ ✓ Your account has full access to the Noderunners relay +

+

+ You can now use this relay in your Nostr client. Add the relay URL below + to your client's relay list to start posting and receiving messages. +

+
+ ) : ( +
+
+

+ One-time Payment Required +

+
+

+ To use the Noderunners relay, you need to make a one-time payment of + 10,000 sats. This payment helps maintain the relay's infrastructure + and ensures high-quality service. +

+

+ 21% of all payments go to the{' '} + + Noderunners community pot + + {' '}to support the development of Bitcoin and Nostr projects. +

+
+
+ +
+ )} +
+ + {/* Connection Information */} +
+

Connection Information

+
+

Relay URL

+
+ + wss://relay.noderunners.network + + +
+
+
+ + {/* Stats Overview */} +
+
+
+ + Last 30 days +
+

{uptime || 'Loading...'}

+

Uptime

+
+ +
+
+ + Registered Users +
+

{activeUsers?.toLocaleString() || 'Loading...'}

+

Active Users

+
+
+
+ + + ); +} \ No newline at end of file diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..c7b5e0f --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Flame, Zap, Shield, Globe, Server, Code, Cpu, Copy } from 'lucide-react'; +import { Notification } from '../components/Notification'; +import { useNotification } from '../hooks/useNotification'; + +export function Home() { + const { isVisible, message, type, showNotification, hideNotification } = useNotification(); + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + showNotification('Relay URL copied to clipboard'); + } catch (err) { + console.error('Failed to copy:', err); + showNotification('Failed to copy URL', 'error'); + } + }; + + return ( + <> +
+
+

Welcome to Noderunners Relay

+

+ A high-performance Nostr relay built by Bitcoiners, for Bitcoiners +

+ + Get Started + +
+ +
+
+ +

Lightning Fast

+

+ Built with performance in mind, ensuring your messages are delivered instantly +

+
+ +
+ +

Secure

+

+ Your data is protected with state-of-the-art encryption and security measures +

+
+ +
+ +

Hosted by Azzamo

+

+ Reliable infrastructure with 24/7 monitoring and maintenance +

+
+
+ +
+

Relay Information

+
+
+
+

Relay URL

+
+ wss://relay.noderunners.network + +
+
+
+

Software

+

{import.meta.env.VITE_RELAY_SOFTWARE}

+
+
+
+
+ +
+

Technical Specifications

+
+
+

+ + Supported NIPs +

+
+
+ {import.meta.env.VITE_SUPPORTED_NIPS.split(',').map(nip => ( + + {nip} + + ))} +
+
+
+ +
+

+ + Features +

+
    +
  • + + High-performance strfry backend +
  • +
  • + + Paid relay with Lightning Network +
  • +
  • + + Advanced spam protection +
  • +
  • + + 24/7 monitoring and maintenance +
  • +
+
+
+
+
+ + + ); +} \ No newline at end of file diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..ecf2638 --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Loader2, Zap } from 'lucide-react'; +import { useStore } from '../store/useStore'; + +export function Login() { + const navigate = useNavigate(); + const { user, setUser } = useStore(); + const [isLoading, setIsLoading] = useState(false); + const [pubkeyInput, setPubkeyInput] = useState(''); + + useEffect(() => { + if (user) { + navigate('/dashboard'); + } + }, [user, navigate]); + + const handleExtensionLogin = async () => { + setIsLoading(true); + try { + let attempts = 0; + while (!window.nostr && attempts < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + attempts++; + } + + if (!window.nostr) { + throw new Error('Nostr provider not found after waiting'); + } + + const pubkey = await window.nostr.getPublicKey(); + if (!pubkey) { + throw new Error('No public key found'); + } + + // Simply store the pubkey and navigate to dashboard + setUser({ pubkey, isWhitelisted: false }); + navigate('/dashboard'); + } catch (error: any) { + if (error.message === 'Rejected by user') { + return; + } + console.error('Login failed:', error); + + if (error.message.includes('Nostr provider not found')) { + alert('No Nostr extension detected. Please install Alby or another Nostr extension and try again.'); + } else if (error.message.includes('No public key found')) { + alert('Could not access your Nostr public key. Please make sure you\'re logged into your Nostr extension.'); + } else if (error.message !== 'Rejected by user') { + alert('Failed to connect. Please make sure you have a Nostr extension installed and try again.'); + } + } finally { + setIsLoading(false); + } + }; + + const handlePubkeyLogin = async () => { + if (!pubkeyInput.trim()) { + alert('Please enter a public key or npub'); + return; + } + + setIsLoading(true); + try { + // Simply store the pubkey and navigate to dashboard + setUser({ pubkey: pubkeyInput.trim(), isWhitelisted: false }); + navigate('/dashboard'); + } catch (error) { + console.error('Login failed:', error); + alert('Failed to login with the provided public key. Please check the format and try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Connect with Nostr

+ +

+ To access the Noderunners relay, connect using your Nostr account. + You can use a Nostr extension or enter your public key manually. +

+ +
+ + +
+
+
+
+
+ Or enter manually +
+
+ +
+ + 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" + /> + +
+
+ +

+ Don't have a Nostr extension?{' '} + + Get Alby + +

+
+ ); +} \ No newline at end of file diff --git a/src/pages/Payment.tsx b/src/pages/Payment.tsx new file mode 100644 index 0000000..e710dd2 --- /dev/null +++ b/src/pages/Payment.tsx @@ -0,0 +1,218 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { QRCodeSVG } from 'qrcode.react'; +import { Copy, CheckCircle } from 'lucide-react'; +import { useStore } from '../store/useStore'; +import { lnbitsService } from '../services/lnbits'; +import { apiService } from '../services/api'; +import type { LightningInvoice } from '../types'; +import { Notification } from '../components/Notification'; +import { useNotification } from '../hooks/useNotification'; + +export function Payment() { + const navigate = useNavigate(); + const { user, setUser } = useStore(); + const [invoice, setInvoice] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true'; + const { isVisible, message, type, showNotification, hideNotification } = useNotification(); + const [checkingPayment, setCheckingPayment] = useState(false); + const [showSuccess, setShowSuccess] = useState(false); + + useEffect(() => { + if (!user) { + navigate('/login'); + return; + } + + if (user.isWhitelisted) { + navigate('/dashboard'); + return; + } + + const generateInvoice = async () => { + try { + const response = await lnbitsService.createInvoice({ + amount: 10000, + memo: `${import.meta.env.VITE_PAYMENT_MEMO || "Noderunners Relay Access"} - ${user.pubkey}`, + webhook: import.meta.env.VITE_WEBHOOK_URL, + extra: { + pubkey: user.pubkey, + type: 'relay_access' + } + }); + + setInvoice({ + paymentRequest: response.payment_request, + qrCode: response.payment_request, + paymentHash: response.payment_hash + }); + + pollPaymentStatus(response.payment_hash); + } catch (err) { + console.error('Failed to generate invoice:', err); + setError('Failed to generate invoice. Please try again later.'); + } finally { + setLoading(false); + } + }; + + generateInvoice(); + }, [user, navigate]); + + const handlePaymentSuccess = async () => { + setShowSuccess(true); + setTimeout(() => { + navigate('/thank-you'); + }, 1500); + return true; + }; + + const pollPaymentStatus = async (paymentHash: string) => { + const checkPayment = async () => { + if (checkingPayment) return false; + + setCheckingPayment(true); + try { + const status = await lnbitsService.checkPayment(paymentHash); + if (status.paid) { + return handlePaymentSuccess(); + } + return false; + } catch (error) { + console.error('Error checking payment status:', error); + return false; + } finally { + setCheckingPayment(false); + } + }; + + const interval = setInterval(async () => { + const paid = await checkPayment(); + if (paid) { + clearInterval(interval); + } + }, 2000); + + return () => clearInterval(interval); + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + showNotification('Lightning invoice copied to clipboard'); + } catch (err) { + console.error('Failed to copy:', err); + showNotification('Failed to copy invoice', 'error'); + } + }; + + const handleDemoPayment = () => { + handlePaymentSuccess(); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+
+ +
+ ); + } + + if (!invoice) { + return ( +
+

Failed to generate invoice. Please try again.

+
+ ); + } + + return ( + <> +
+ {/* Success Animation Overlay */} + {showSuccess && ( +
+
+ +

Payment Received!

+
+
+ )} + +

Payment Required

+ +
+

10,000 sats

+

One-time payment for relay access

+
+ +
+ +
+ +
+
+
+ copyToClipboard(invoice.paymentRequest)}> + {invoice.paymentRequest} + +
+ +
+
+ + + + {isDemoMode && ( + + )} +
+ + + ); +} \ No newline at end of file diff --git a/src/pages/Terms.tsx b/src/pages/Terms.tsx new file mode 100644 index 0000000..f9b244f --- /dev/null +++ b/src/pages/Terms.tsx @@ -0,0 +1,45 @@ +import React, { useEffect, useState } from 'react'; +import { Shield } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; + +export function Terms() { + const [terms, setTerms] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchTerms = async () => { + try { + const response = await fetch('/terms.txt'); + const text = await response.text(); + setTerms(text); + } catch (error) { + console.error('Failed to load terms:', error); + setTerms('Failed to load terms of service. Please try again later.'); + } finally { + setLoading(false); + } + }; + + fetchTerms(); + }, []); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+ +
+ +
+ {terms} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/ThankYou.tsx b/src/pages/ThankYou.tsx new file mode 100644 index 0000000..00b4efe --- /dev/null +++ b/src/pages/ThankYou.tsx @@ -0,0 +1,131 @@ +import React, { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { CheckCircle, Copy, ArrowRight } from 'lucide-react'; +import { useStore } from '../store/useStore'; +import confetti from 'canvas-confetti'; +import { Notification } from '../components/Notification'; +import { useNotification } from '../hooks/useNotification'; + +export function ThankYou() { + const navigate = useNavigate(); + const { user } = useStore(); + const { isVisible, message, type, showNotification, hideNotification } = useNotification(); + const relayUrl = import.meta.env.VITE_NOSTR_RELAY_URL; + + useEffect(() => { + if (!user) { + navigate('/login'); + return; + } + + // Trigger confetti animation + const duration = 2000; + const animationEnd = Date.now() + duration; + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }; + + function randomInRange(min: number, max: number) { + return Math.random() * (max - min) + min; + } + + const interval: NodeJS.Timer = setInterval(function() { + const timeLeft = animationEnd - Date.now(); + + if (timeLeft <= 0) { + return clearInterval(interval); + } + + const particleCount = 50 * (timeLeft / duration); + + // Since they fall down, start a bit higher than random + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } + }); + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } + }); + }, 250); + + return () => clearInterval(interval); + }, [user, navigate]); + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + showNotification('Relay URL copied to clipboard'); + } catch (err) { + console.error('Failed to copy:', err); + showNotification('Failed to copy URL', 'error'); + } + }; + + if (!user) { + return null; + } + + return ( +
+
+ +

+ Welcome to the Noderunners Relay! +

+

+ Your payment has been received and processed successfully. +

+
+ +
+

Connect Your Nostr Client

+

+ Add the following relay URL to your Nostr client to start using the Noderunners relay: +

+ +
+
+ {relayUrl} + +
+
+ +
+

+ + Your account is now whitelisted and ready to use +

+

+ + You can start posting and receiving messages immediately +

+

+ + Access is permanent and doesn't require renewal +

+
+
+ + + + +
+ ); +} \ No newline at end of file diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..8b9b163 --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,63 @@ +import { UserResponse } from '../types'; + +const API_URL = import.meta.env.VITE_API_URL; + +export const apiService = { + async getUserInfo(pubkey: string): Promise { + try { + const response = await fetch(`${API_URL}/api/user/info`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + identifier: pubkey, + }), + }); + + if (!response.ok) { + if (response.status === 404) { + // Return a default response for non-existent users + return { + pubkey, + npub: '', + is_whitelisted: false + }; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Error fetching user info:', error); + // Return a default response on error + return { + pubkey, + npub: '', + is_whitelisted: false + }; + } + }, + + async whitelistUser(pubkey: string, apiKey: string): Promise { + try { + const response = await fetch(`${API_URL}/api/whitelist/add`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Api-Key': apiKey, + }, + body: JSON.stringify({ + identifier: pubkey, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + } catch (error) { + console.error('Error whitelisting user:', error); + throw error; // Re-throw as this is a critical operation + } + }, +}; \ No newline at end of file diff --git a/src/services/lnbits.ts b/src/services/lnbits.ts new file mode 100644 index 0000000..6db1c65 --- /dev/null +++ b/src/services/lnbits.ts @@ -0,0 +1,166 @@ +import axios from 'axios'; + +const LNBITS_URL = import.meta.env.VITE_LNBITS_URL; +const API_KEY = import.meta.env.VITE_LNBITS_API_KEY; + +const api = axios.create({ + baseURL: LNBITS_URL, + headers: { + 'X-Api-Key': API_KEY, + 'Content-Type': 'application/json', + }, +}); + +export interface CreateInvoiceParams { + amount: number; + memo: string; + unit?: string; + webhook?: string; + internal?: boolean; + extra?: Record; +} + +export interface Invoice { + payment_hash: string; + payment_request: string; + checking_id: string; + amount: number; + fee: number; + memo: string; + time: number; + bolt11: string; + preimage: string; + expiry: number; + extra: Record; + webhook?: string; + webhook_status?: number; +} + +export interface PaymentStatus { + paid: boolean; + preimage?: string; + details?: { + bolt11: string; + checking_id: string; + pending: boolean; + amount: number; + fee: number; + memo: string; + time: number; + payment_hash: string; + }; +} + +export const lnbitsService = { + // Create a new invoice + async createInvoice({ + amount, + memo, + unit = 'sat', + webhook = '', + internal = false, + extra = {}, + }: CreateInvoiceParams): Promise { + try { + const response = await api.post('/api/v1/payments', { + out: false, + amount, + memo, + unit, + webhook, + internal, + extra, + }); + return response.data; + } catch (error) { + console.error('Error creating invoice:', error); + throw error; + } + }, + + // Check payment status + async checkPayment(paymentHash: string): Promise { + try { + const response = await api.get(`/api/v1/payments/${paymentHash}`); + return { + paid: response.data.paid, + preimage: response.data.preimage, + details: response.data.details, + }; + } catch (error) { + console.error('Error checking payment:', error); + throw error; + } + }, + + // Get wallet info + async getWalletInfo() { + try { + const response = await api.get('/api/v1/wallet'); + return response.data; + } catch (error) { + console.error('Error getting wallet info:', error); + throw error; + } + }, + + // Get payment history + async getPaymentHistory(limit = 10) { + try { + const response = await api.get('/api/v1/payments', { + params: { + limit, + offset: 0, + sortby: 'time', + direction: 'desc', + }, + }); + return response.data; + } catch (error) { + console.error('Error getting payment history:', error); + throw error; + } + }, + + // Get current exchange rate + async getExchangeRate(currency: string = 'USD') { + try { + const response = await api.get(`/api/v1/rate/${currency.toLowerCase()}`); + return response.data; + } catch (error) { + console.error('Error getting exchange rate:', error); + throw error; + } + }, + + // Convert fiat to sats + async convertFiatToSats(amount: number, from: string = 'USD') { + try { + const response = await api.post('/api/v1/conversion', { + from_: from.toLowerCase(), + amount, + to: 'sat', + }); + return response.data; + } catch (error) { + console.error('Error converting fiat to sats:', error); + throw error; + } + }, + + // Long poll payment status + async longPollPayment(paymentHash: string, timeout = 60000): Promise { + try { + const response = await api.get(`/public/v1/payment/${paymentHash}`, { + timeout, + }); + return response.data.paid; + } catch (error) { + if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') { + return false; // Timeout reached + } + console.error('Error polling payment:', error); + throw error; + } + }, +}; \ No newline at end of file diff --git a/src/store/useStore.ts b/src/store/useStore.ts new file mode 100644 index 0000000..470c21b --- /dev/null +++ b/src/store/useStore.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand'; +import { NostrUser } from '../types'; + +interface Store { + user: NostrUser | null; + setUser: (user: NostrUser | null) => void; +} + +export const useStore = create((set) => ({ + user: null, + setUser: (user) => set({ user }), +})); \ No newline at end of file diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..409164c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,19 @@ +export interface NostrUser { + pubkey: string; + isWhitelisted: boolean; + timeRemaining?: number; + npub?: string; +} + +export interface LightningInvoice { + paymentRequest: string; + qrCode: string; + paymentHash: string; +} + +export interface UserResponse { + pubkey: string; + npub: string; + time_remaining?: number; + is_whitelisted: boolean; +} \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..a026918 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/// + +interface Window { + nostr: { + getPublicKey: () => Promise; + signEvent: (event: any) => Promise; + getRelays: () => Promise<{ [url: string]: { read: boolean; write: boolean; } }>; + nip04: { + encrypt: (pubkey: string, plaintext: string) => Promise; + decrypt: (pubkey: string, ciphertext: string) => Promise; + }; + }; +} \ No newline at end of file