8 Commits

Author SHA1 Message Date
4f7c6601a3 Relay front updates: payments, API, terms (markdown), styling; remove LNbits service
Made-with: Love
2026-04-29 05:48:14 +00:00
Michilis
c6749839af Update README.md 2025-11-11 14:11:07 -03:00
Michilis
da1d1b0d53 Merge pull request #4 from Michilis/dev
Move terms.txt to public/ to fix Terms page loading
2025-11-10 21:22:50 -03:00
Noderunners
0ab66479d9 Move terms.txt to public/ to fix Terms page loading 2025-11-11 01:21:52 +01:00
Michilis
1129fb9341 Merge pull request #3 from Michilis/dev
fix(lnbits): normalize invoice response (fallback to bolt11) and upda…
2025-11-10 16:51:25 -03:00
Noderunners
4afe3f6d3e fix(lnbits): normalize invoice response (fallback to bolt11) and update payment status check; chore: add .gitignore 2025-11-10 20:47:58 +01:00
Michilis
21bbd0fab1 Merge pull request #2 from Michilis/dev
Fix API calls to use npub instead of identifier
2025-11-06 17:17:48 -03:00
Michilis
bb3b5604a1 Fix API calls to use npub instead of identifier
- Updated getUserInfo to send npub field instead of identifier
- Updated whitelistUser to send npub field instead of identifier
- Resolves 422 Unprocessable Content error from backend API
2025-07-01 19:18:29 +00:00
21 changed files with 3020 additions and 3570 deletions

View File

@@ -1,25 +1,19 @@
VITE_LNBITS_URL="https://azzamo.tips"
VITE_LNBITS_API_KEY="LNbits Api key"
# App settings # App settings
VITE_APP_NAME="Noderunners Relay" VITE_APP_NAME="Noderunners Relay"
VITE_APP_DESCRIPTION="A high-performance Nostr relay built by Noderunners, for Noderunners" VITE_APP_DESCRIPTION="A high-performance Nostr relay built by Noderunners, for Noderunners"
VITE_LOGO_URL="https://cdn.azzamo.net/5cc03420a18166ef7a20b1e6b7dad240ad7d634824649643c80d74a924062258.png" VITE_LOGO_URL="https://cdn.azzamo.net/5cc03420a18166ef7a20b1e6b7dad240ad7d634824649643c80d74a924062258.png"
VITE_GITHUB_URL="https://github.com/noderunners-org/relay" VITE_GITHUB_URL="https://github.com/noderunners-org/relay"
# Nostr settings # Nostr settings — API base URL for /v1/* and /.well-known/nostr.json (no trailing slash).
# Leave unset or empty only when using `npm run dev` with vite proxy — see `.env.development`.
VITE_NOSTR_RELAY_URL="wss://relay.noderunners.network" VITE_NOSTR_RELAY_URL="wss://relay.noderunners.network"
VITE_API_URL="https://noderunnersapi.azzamo.net" VITE_API_URL="https://noderunnersapi.azzamo.net"
# Used only during `npm run dev` when requests are proxied (see vite.config.ts).
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8085
VITE_SUPPORTED_NIPS="1,2,4,9,11,22,28,40,70,77" VITE_SUPPORTED_NIPS="1,2,4,9,11,22,28,40,70,77"
VITE_RELAY_SOFTWARE="strfry v1.0.3" VITE_RELAY_SOFTWARE="strfry v1.0.3"
# Payment settings
VITE_MIN_PAYMENT_AMOUNT=10000
VITE_PAYMENT_MEMO="Noderunners Relay Access"
VITE_PAYMENT_CURRENCY="sat"
VITE_WEBHOOK_URL="N8N Webhook Url"
# Feature flags # Feature flags
VITE_ENABLE_WHITELIST=true VITE_ENABLE_WHITELIST=true
VITE_ENABLE_PAYMENT_VERIFICATION=true VITE_ENABLE_PAYMENT_VERIFICATION=true

64
.gitignore vendored Normal file
View File

@@ -0,0 +1,64 @@
# Dependencies
node_modules/
# Production builds
dist/
dist-ssr/
build/
# Vite and tooling caches
.vite/
.esbuild/
.rollup.cache/
.parcel-cache/
.turbo/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Environment files
.env
.env.local
.env.*.local
.env.production.local
.env.development.local
.env.test.local
!.env.example
# Coverage
coverage/
# TypeScript build info
*.tsbuildinfo
# Editor directories and files
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*~
# OS metadata
.DS_Store
Thumbs.db
# Misc secrets/keys (if present)
*.pem
*.key
*.crt
*.p12
*.pfx
# Package tarballs
*.tgz

View File

@@ -9,20 +9,20 @@ wss://relay.noderunners.network
## Features ## Features
- Lightning Network integration for payments - Lightning payments via the relay API (invoice creation and status)
- 🔒 Secure authentication with Nostr - Secure authentication with Nostr
- 💻 Modern, responsive web interface - Modern, responsive web interface
- 📊 Real-time relay statistics - Real-time relay statistics
- 🔍 Uptime monitoring - Uptime monitoring
- 🖼️ Iframe support for embedding - Iframe support for embedding
- 🔑 Multiple login methods (Extension, Manual, URL-based) - Multiple login methods (Extension, Manual, URL-based)
## Tech Stack ## Tech Stack
- **Frontend**: React + TypeScript + Vite - **Frontend**: React + TypeScript + Vite
- **Styling**: Tailwind CSS - **Styling**: Tailwind CSS
- **Icons**: Lucide React - **Icons**: Lucide React
- **Payment**: LNbits Integration - **Payments**: [NIP-05 relay API](../Nip05_api) (`/v1/pricing`, `/v1/invoices`, `/v1/users/…`)
- **Authentication**: Nostr Protocol - **Authentication**: Nostr Protocol
- **State Management**: Zustand - **State Management**: Zustand
@@ -31,28 +31,18 @@ wss://relay.noderunners.network
Create a `.env` file in the root directory with the following variables: Create a `.env` file in the root directory with the following variables:
```env ```env
# LNbits Configuration
VITE_LNBITS_URL="your-lnbits-url"
VITE_LNBITS_API_KEY="your-api-key"
# App Settings # App Settings
VITE_APP_NAME="Noderunners Relay" VITE_APP_NAME="Noderunners Relay"
VITE_APP_DESCRIPTION="A high-performance Nostr relay built by Bitcoiners, for Bitcoiners" VITE_APP_DESCRIPTION="A high-performance Nostr relay built by Bitcoiners, for Bitcoiners"
VITE_LOGO_URL="your-logo-url" VITE_LOGO_URL="your-logo-url"
VITE_GITHUB_URL="your-github-url" VITE_GITHUB_URL="your-github-url"
# Nostr Settings # Nostr — relay URL for clients; API URL serves /.well-known/nostr.json and /v1/*
VITE_NOSTR_RELAY_URL="wss://your-relay-url" VITE_NOSTR_RELAY_URL="wss://your-relay-url"
VITE_API_URL="your-api-url" VITE_API_URL="https://your-api-host"
VITE_SUPPORTED_NIPS="1,2,4,9,11,22,28,40,70,77" VITE_SUPPORTED_NIPS="1,2,4,9,11,22,28,40,70,77"
VITE_RELAY_SOFTWARE="strfry v1.0.3" VITE_RELAY_SOFTWARE="strfry v1.0.3"
# Payment Settings
VITE_MIN_PAYMENT_AMOUNT=10000
VITE_PAYMENT_MEMO="Noderunners Relay Access"
VITE_PAYMENT_CURRENCY="sat"
VITE_WEBHOOK_URL="your-webhook-url"
# Feature Flags # Feature Flags
VITE_ENABLE_WHITELIST=true VITE_ENABLE_WHITELIST=true
VITE_ENABLE_PAYMENT_VERIFICATION=true VITE_ENABLE_PAYMENT_VERIFICATION=true
@@ -86,10 +76,10 @@ The application supports multiple authentication methods:
1. **Nostr Extension** 1. **Nostr Extension**
- Uses browser extensions like Alby for seamless authentication - Uses browser extensions like Alby for seamless authentication
- Automatically retrieves the user's public key - Automatically retrieves your public key
2. **Manual Entry** 2. **Manual Entry**
- Users can manually input their npub or hex public key - You can manually input your npub or hex public key
- Supports both formats for maximum flexibility - Supports both formats for maximum flexibility
3. **URL-based Authentication** 3. **URL-based Authentication**
@@ -118,27 +108,6 @@ You can combine iframe mode with URL-based authentication:
<iframe src="https://your-relay-domain.com?iframe=1&npub=npub1..." width="100%" height="600px"></iframe> <iframe src="https://your-relay-domain.com?iframe=1&npub=npub1..." width="100%" height="600px"></iframe>
``` ```
## API Services
### LNbits Integration
- Invoice creation
- Payment verification
- Exchange rate conversion
- Wallet information
### Relay API
- User information
- Whitelist management
- Payment processing
- Status monitoring
## Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## License ## License
@@ -148,4 +117,3 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- Built by the Noderunners community - Built by the Noderunners community
- Powered by [strfry](https://github.com/hoytech/strfry) - Powered by [strfry](https://github.com/hoytech/strfry)
- Lightning Network integration via [LNbits](https://lnbits.com)

5345
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,33 +10,32 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@nostr-dev-kit/ndk": "^2.5.1", "@nostr-dev-kit/ndk": "^3.0.3",
"axios": "^1.6.7", "canvas-confetti": "^1.9.4",
"canvas-confetti": "^1.9.2", "lucide-react": "^1.12.0",
"lucide-react": "^0.344.0", "qrcode.react": "^4.2.0",
"qrcode.react": "^3.1.0", "react": "^19.2.5",
"react": "^18.3.1", "react-dom": "^19.2.5",
"react-dom": "^18.3.1", "react-markdown": "^10.1.0",
"react-markdown": "^9.0.1", "react-router-dom": "^7.14.2",
"react-router-dom": "^6.22.2", "zustand": "^5.0.12"
"zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.1", "@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/postcss": "^4.2.4",
"@types/canvas-confetti": "^1.6.4", "@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.5", "@types/canvas-confetti": "^1.9.0",
"@types/react-dom": "^18.3.0", "@types/react": "^19.2.14",
"@vitejs/plugin-react": "^4.3.1", "@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.18", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.9.1", "eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.11", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^15.9.0", "globals": "^17.5.0",
"postcss": "^8.4.35", "postcss": "^8.5.12",
"tailwindcss": "^3.4.1", "tailwindcss": "^4.2.4",
"typescript": "^5.5.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.3.0", "typescript-eslint": "^8.59.1",
"vite": "^5.4.2" "vite": "^8.0.10"
} }
} }

View File

@@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {},
}, },
}; };

View File

@@ -45,4 +45,4 @@ We reserve the right to terminate or suspend access to our service immediately,
## 8. Changes to Terms ## 8. Changes to Terms
We reserve the right to modify these terms at any time. We will notify users of any changes by updating the date at the top of this page. We reserve the right to modify these terms at any time. We will notify users of any changes by updating the date at the top of this page.

View File

@@ -1,10 +1,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Flame, Menu, X } from 'lucide-react'; import { Flame, Menu, X } from 'lucide-react';
import { useStore } from '../store/useStore'; import { useStore } from '../store/useStore';
export function Navigation() { export function Navigation() {
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { user, setUser } = useStore(); const { user, setUser } = useStore();

View File

@@ -1,3 +1,39 @@
@tailwind base; @import 'tailwindcss';
@tailwind components; @plugin '@tailwindcss/typography';
@tailwind utilities;
@theme {
--animate-slide-up: slide-up 0.3s ease-out;
--animate-fade-in: fade-in 0.3s ease-out;
--animate-success-appear: success-appear 0.5s ease-out;
}
@keyframes slide-up {
0% {
transform: translateY(100%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes success-appear {
0% {
transform: scale(0.8);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}

View File

@@ -9,16 +9,51 @@ import { useNotification } from '../hooks/useNotification';
export function Dashboard() { export function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, setUser } = useStore(); const { user, setUser } = useStore();
const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
const [uptime, setUptime] = useState<string | null>(null); const [uptime, setUptime] = useState<string | null>(null);
const [activeUsers, setActiveUsers] = useState<number | null>(null); const [activeUsers, setActiveUsers] = useState<number | null>(null);
const [lifetimeSats, setLifetimeSats] = useState<number | null>(() =>
isDemoMode ? 10000 : null
);
const [yearlySats, setYearlySats] = useState<number | null>(() =>
isDemoMode ? 1000 : null
);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
const apiUrl = import.meta.env.VITE_API_URL; const apiUrl = import.meta.env.VITE_API_URL;
const { isVisible, message, type, showNotification, hideNotification } = useNotification(); const { isVisible, message, type, showNotification, hideNotification } = useNotification();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const isIframe = searchParams.get('iframe') === '1'; const isIframe = searchParams.get('iframe') === '1';
// Check user authentication and fetch status once const lifetimeLabel =
lifetimeSats != null ? `${lifetimeSats.toLocaleString()} sats` : '…';
const yearlyLabel =
yearlySats != null ? `${yearlySats.toLocaleString()} sats` : '…';
useEffect(() => {
if (isDemoMode) {
return;
}
let cancelled = false;
void apiService
.getPricing()
.then((p) => {
if (!cancelled) {
setLifetimeSats(p.lifetime_sats);
setYearlySats(p.yearly_sats);
}
})
.catch(() => {
if (!cancelled) {
setLifetimeSats(null);
setYearlySats(null);
}
});
return () => {
cancelled = true;
};
}, [isDemoMode]);
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
navigate('/login'); navigate('/login');
@@ -29,29 +64,41 @@ export function Dashboard() {
if (!isDemoMode) { if (!isDemoMode) {
try { try {
const userInfo = await apiService.getUserInfo(user.pubkey); const userInfo = await apiService.getUserInfo(user.pubkey);
setUser({ if (userInfo) {
...user, setUser({
isWhitelisted: userInfo.is_whitelisted, ...user,
timeRemaining: userInfo.time_remaining, isWhitelisted: userInfo.is_whitelisted,
npub: userInfo.npub, npub: userInfo.npub,
}); username: userInfo.username,
} catch (error: any) { subscriptionType: userInfo.subscription_type,
if (error.response?.status === 404 || error) { expiresAt: userInfo.expires_at ?? null,
});
} else {
setUser({ setUser({
...user, ...user,
isWhitelisted: false, isWhitelisted: false,
subscriptionType: undefined,
expiresAt: undefined,
}); });
} }
} catch (error: unknown) {
console.error('Failed to fetch user status:', error); console.error('Failed to fetch user status:', error);
setUser({
...user,
isWhitelisted: false,
subscriptionType: undefined,
expiresAt: undefined,
});
} }
} }
setLoading(false); setLoading(false);
}; };
checkUserStatus(); void checkUserStatus();
// Intentionally depend on pubkey only so we don't re-fetch when whitelist state updates from this effect.
// eslint-disable-next-line react-hooks/exhaustive-deps -- sync user's pubkey identity only
}, [user?.pubkey, navigate, setUser, isDemoMode]); }, [user?.pubkey, navigate, setUser, isDemoMode]);
// Fetch uptime and active users
useEffect(() => { useEffect(() => {
const fetchUptime = async () => { const fetchUptime = async () => {
if (isDemoMode) { if (isDemoMode) {
@@ -79,7 +126,8 @@ export function Dashboard() {
} }
try { try {
const response = await fetch(`${apiUrl}/.well-known/nostr.json`); const base = String(apiUrl ?? '').replace(/\/$/, '');
const response = await fetch(`${base}/.well-known/nostr.json`);
const data = await response.json(); const data = await response.json();
setActiveUsers(data.names ? Object.keys(data.names).length : 0); setActiveUsers(data.names ? Object.keys(data.names).length : 0);
} catch (error) { } catch (error) {
@@ -115,6 +163,24 @@ export function Dashboard() {
navigate('/login'); navigate('/login');
}; };
const toPayment = (plan: 'yearly' | 'lifetime') => {
const q = new URLSearchParams();
q.set('plan', plan);
if (isIframe) q.set('iframe', '1');
navigate(`/payment?${q.toString()}`);
};
const formatExpiry = (iso: string) => {
try {
return new Date(iso).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
});
} catch {
return iso;
}
};
if (loading || !user) { if (loading || !user) {
return ( return (
<div className="flex justify-center items-center min-h-[400px]"> <div className="flex justify-center items-center min-h-[400px]">
@@ -123,10 +189,14 @@ export function Dashboard() {
); );
} }
const showYearlyRenewal =
user.isWhitelisted &&
user.subscriptionType === 'yearly' &&
Boolean(user.expiresAt);
return ( return (
<> <>
<div className="max-w-6xl mx-auto space-y-4 md:space-y-8"> <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={`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 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"> <div className="flex items-center space-x-4">
@@ -150,45 +220,78 @@ export function Dashboard() {
You can now use this relay in your Nostr client. Add the relay URL below 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. to your client's relay list to start posting and receiving messages.
</p> </p>
{showYearlyRenewal && user.expiresAt ? (
<>
<p className="text-gray-300 text-base">
Yearly subscription active until{' '}
<strong className="text-white">{formatExpiry(user.expiresAt)}</strong>.
</p>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<button
type="button"
onClick={() => toPayment('yearly')}
className="flex flex-1 items-center justify-center px-4 py-3 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold space-x-2"
>
<Zap className="h-5 w-5" />
<span>Add another year ({yearlyLabel})</span>
</button>
<button
type="button"
onClick={() => toPayment('lifetime')}
className="flex flex-1 items-center justify-center px-4 py-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors font-semibold border border-gray-600 space-x-2"
>
<Zap className="h-5 w-5" />
<span>Upgrade to lifetime ({lifetimeLabel})</span>
</button>
</div>
</>
) : null}
</div> </div>
) : ( ) : (
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<p className="text-orange-400 text-base md:text-lg"> <p className="text-orange-400 text-base md:text-lg">Lightning payment</p>
One-time Payment Required
</p>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-gray-400"> <p className="text-gray-400">
To use the Noderunners relay, you need to make a one-time payment of Choose yearly access ({yearlyLabel}) or lifetime access ({lifetimeLabel}). Pricing comes from the
10,000 sats. This payment helps maintain the relay's infrastructure relay API and funds relay infrastructure.
and ensures high-quality service.
</p> </p>
<p className="text-gray-400"> <p className="text-gray-400">
<span className="text-orange-400">21%</span> of all payments go to the{' '} <span className="text-orange-400">21%</span> of all payments go to the{' '}
<a <a
href="https://tip.noderunners.org" href="https://tip.noderunners.org"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-orange-400 hover:underline" className="text-orange-400 hover:underline"
> >
Noderunners community pot Noderunners community pot
</a> </a>{' '}
{' '}to support the development of Bitcoin and Nostr projects. to support the development of Bitcoin and Nostr projects.
</p> </p>
</div> </div>
</div> </div>
<button <div className="flex flex-col sm:flex-row gap-3">
onClick={() => navigate('/payment')} <button
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" type="button"
> onClick={() => toPayment('yearly')}
<Zap className="h-5 w-5" /> className="flex flex-1 items-center justify-center 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"
<span>Pay 10,000 sats for Access</span> >
</button> <Zap className="h-5 w-5" />
<span>Pay for one year ({yearlyLabel})</span>
</button>
<button
type="button"
onClick={() => toPayment('lifetime')}
className="flex flex-1 items-center justify-center px-4 md:px-6 py-3 md:py-4 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors font-semibold border border-gray-600 space-x-2"
>
<Zap className="h-5 w-5" />
<span>Pay for lifetime ({lifetimeLabel})</span>
</button>
</div>
</div> </div>
)} )}
</div> </div>
{/* Connection Information */}
<div className="bg-gray-800 rounded-lg p-4 md:p-8"> <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> <h2 className="text-lg md:text-xl font-bold mb-4 md:mb-6">Connection Information</h2>
<div> <div>
@@ -209,7 +312,6 @@ export function Dashboard() {
</div> </div>
</div> </div>
{/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6"> <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="bg-gray-800 p-4 md:p-6 rounded-lg">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -230,7 +332,6 @@ export function Dashboard() {
</div> </div>
</div> </div>
{/* Logout Button (only shown in iframe mode) */}
{isIframe && ( {isIframe && (
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
@@ -251,4 +352,4 @@ export function Dashboard() {
/> />
</> </>
); );
} }

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { Flame, Zap, Shield, Globe, Server, Code, Cpu, Copy } from 'lucide-react'; import { Zap, Shield, Globe, Server, Code, Cpu, Copy } from 'lucide-react';
import { Notification } from '../components/Notification'; import { Notification } from '../components/Notification';
import { useNotification } from '../hooks/useNotification'; import { useNotification } from '../hooks/useNotification';
@@ -9,6 +9,11 @@ export function Home() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const isIframe = searchParams.get('iframe') === '1'; const isIframe = searchParams.get('iframe') === '1';
const supportedNips = (import.meta.env.VITE_SUPPORTED_NIPS ?? '')
.split(',')
.map((nip) => nip.trim())
.filter(Boolean);
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@@ -96,11 +101,18 @@ export function Home() {
</h3> </h3>
<div className="bg-gray-900 p-4 rounded-lg"> <div className="bg-gray-900 p-4 rounded-lg">
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{import.meta.env.VITE_SUPPORTED_NIPS.split(',').map(nip => ( {supportedNips.length > 0 ? (
<span key={nip} className="bg-gray-800 px-3 py-1 rounded text-center"> supportedNips.map((nip) => (
{nip} <span key={nip} className="bg-gray-800 px-3 py-1 rounded text-center">
{nip}
</span>
))
) : (
<span className="col-span-4 text-gray-500 text-sm">
Set <code className="text-gray-400">VITE_SUPPORTED_NIPS</code> in your env (comma-separated, e.g.{' '}
<code className="text-gray-400">1,2,4,40</code>).
</span> </span>
))} )}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -46,17 +46,18 @@ export function Login() {
setUser({ pubkey, isWhitelisted: false }); setUser({ pubkey, isWhitelisted: false });
navigate(isIframe ? '/dashboard?iframe=1' : '/dashboard'); navigate(isIframe ? '/dashboard?iframe=1' : '/dashboard');
} catch (error: any) { } catch (error: unknown) {
if (error.message === 'Rejected by user') { const msg = error instanceof Error ? error.message : '';
if (msg === 'Rejected by user') {
return; return;
} }
console.error('Login failed:', error); console.error('Login failed:', error);
if (error.message.includes('Nostr provider not found')) { if (msg.includes('Nostr provider not found')) {
alert('No Nostr extension detected. Please install Alby or another Nostr extension and try again.'); alert('No Nostr extension detected. Please install Alby or another Nostr extension and try again.');
} else if (error.message.includes('No public key found')) { } else if (msg.includes('No public key found')) {
alert('Could not access your Nostr public key. Please make sure you\'re logged into your Nostr extension.'); 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') { } else if (msg !== 'Rejected by user') {
alert('Failed to connect. Please make sure you have a Nostr extension installed and try again.'); alert('Failed to connect. Please make sure you have a Nostr extension installed and try again.');
} }
} finally { } finally {

View File

@@ -1,104 +1,173 @@
import React, { useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { Copy, CheckCircle } from 'lucide-react'; import { Copy, CheckCircle } from 'lucide-react';
import { useStore } from '../store/useStore'; import { useStore } from '../store/useStore';
import { lnbitsService } from '../services/lnbits';
import { apiService } from '../services/api'; import { apiService } from '../services/api';
import type { LightningInvoice } from '../types'; import type { LightningInvoice } from '../types';
import { Notification } from '../components/Notification'; import { Notification } from '../components/Notification';
import { useNotification } from '../hooks/useNotification'; import { useNotification } from '../hooks/useNotification';
const POLL_MS = 2000;
export type Plan = 'yearly' | 'lifetime';
function formatInvoiceCountdown(expiresAtIso: string, nowMs: number): string {
const end = new Date(expiresAtIso).getTime();
const ms = Math.max(0, end - nowMs);
const totalSec = Math.floor(ms / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function Payment() { export function Payment() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { user, setUser } = useStore(); const planParam = searchParams.get('plan');
const [invoice, setInvoice] = useState<LightningInvoice | null>(null); const resolvedPlan: Plan | null =
const [loading, setLoading] = useState(true); planParam === 'yearly' || planParam === 'lifetime' ? planParam : null;
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);
const isIframe = searchParams.get('iframe') === '1';
const { user } = useStore();
const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
const [invoice, setInvoice] = useState<LightningInvoice | null>(null);
const [amountSats, setAmountSats] = useState<number | null>(null);
const [expiresAtIso, setExpiresAtIso] = useState<string | null>(null);
const [pricingYearly, setPricingYearly] = useState<number | null>(null);
const [pricingLifetime, setPricingLifetime] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const { isVisible, message, type, showNotification, hideNotification } = useNotification();
const [showSuccess, setShowSuccess] = useState(false);
const [nowTick, setNowTick] = useState(() => Date.now());
const isIframe = searchParams.get('iframe') === '1';
const pollingRef = useRef(false);
const handlePaymentSuccess = useCallback(() => {
setShowSuccess(true);
setTimeout(() => {
navigate(isIframe ? '/thank-you?iframe=1' : '/thank-you');
}, 1500);
}, [navigate, isIframe]);
useEffect(() => {
if (!expiresAtIso) return;
const id = window.setInterval(() => setNowTick(Date.now()), 1000);
return () => window.clearInterval(id);
}, [expiresAtIso]);
useEffect(() => {
if (isDemoMode || resolvedPlan) return;
let cancelled = false;
void apiService
.getPricing()
.then((p) => {
if (!cancelled) {
setPricingYearly(p.yearly_sats);
setPricingLifetime(p.lifetime_sats);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [isDemoMode, resolvedPlan]);
/** Auth redirect — separate from invoice fetch so unrelated store updates don't cancel invoice loading. */
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
navigate(isIframe ? '/login?iframe=1' : '/login'); navigate(isIframe ? '/login?iframe=1' : '/login');
return; return;
} }
// Active subscribers may still open /payment?plan=… for renewal or lifetime upgrade.
if (user.isWhitelisted) { if (user.isWhitelisted && !resolvedPlan) {
navigate(isIframe ? '/dashboard?iframe=1' : '/dashboard'); navigate(isIframe ? '/dashboard?iframe=1' : '/dashboard');
}
}, [user, navigate, isIframe, resolvedPlan]);
const pubkey = user?.pubkey;
useEffect(() => {
if (!pubkey || isDemoMode || !resolvedPlan) {
return; return;
} }
const generateInvoice = async () => { let cancelled = false;
try { let intervalId: ReturnType<typeof setInterval> | undefined;
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'
}
});
const poll = (paymentHash: string) => {
intervalId = window.setInterval(() => {
void (async () => {
if (pollingRef.current) return;
pollingRef.current = true;
try {
const st = await apiService.getInvoiceStatus(paymentHash);
if (cancelled) return;
if (st.status === 'paid') {
if (intervalId) {
clearInterval(intervalId);
intervalId = undefined;
}
handlePaymentSuccess();
}
if (st.status === 'expired') {
setError('This invoice has expired. Reload to generate a new one.');
if (intervalId) {
clearInterval(intervalId);
intervalId = undefined;
}
}
} catch (err) {
console.error('Error checking payment status:', err);
} finally {
pollingRef.current = false;
}
})();
}, POLL_MS);
};
const run = async () => {
try {
const pricing = await apiService.getPricing();
if (cancelled) return;
if (!pricing.lightning_enabled) {
setError('Lightning payments are temporarily unavailable. Please try again later.');
return;
}
setPricingYearly(pricing.yearly_sats);
setPricingLifetime(pricing.lifetime_sats);
const response = await apiService.createInvoice({
pubkey,
subscription_type: resolvedPlan === 'yearly' ? 'yearly' : 'lifetime',
years: resolvedPlan === 'yearly' ? 1 : undefined,
});
if (cancelled) return;
setAmountSats(response.amount_sats);
setExpiresAtIso(response.expires_at);
setInvoice({ setInvoice({
paymentRequest: response.payment_request, paymentRequest: response.payment_request,
qrCode: response.payment_request, qrCode: response.payment_request,
paymentHash: response.payment_hash paymentHash: response.payment_hash,
}); });
poll(response.payment_hash);
pollPaymentStatus(response.payment_hash);
} catch (err) { } catch (err) {
if (cancelled) return;
console.error('Failed to generate invoice:', err); console.error('Failed to generate invoice:', err);
setError('Failed to generate invoice. Please try again later.'); const msg = err instanceof Error ? err.message : 'Failed to generate invoice.';
} finally { setError(msg);
setLoading(false);
} }
}; };
generateInvoice(); void run();
}, [user, navigate, isIframe]);
const handlePaymentSuccess = async () => { return () => {
setShowSuccess(true); cancelled = true;
setTimeout(() => { if (intervalId) clearInterval(intervalId);
navigate(isIframe ? '/thank-you?iframe=1' : '/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);
}
}; };
}, [pubkey, isDemoMode, resolvedPlan, handlePaymentSuccess]);
const interval = setInterval(async () => {
const paid = await checkPayment();
if (paid) {
clearInterval(interval);
}
}, 2000);
return () => clearInterval(interval);
};
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
try { try {
@@ -114,7 +183,38 @@ export function Payment() {
handlePaymentSuccess(); handlePaymentSuccess();
}; };
if (loading) { if (!user) {
return null;
}
const showFetchSpinner =
!isDemoMode && !!resolvedPlan && !invoice && !error;
const navigateWithPlan = (p: Plan) => {
setError(null);
const q = new URLSearchParams();
q.set('plan', p);
if (isIframe) q.set('iframe', '1');
navigate(`/payment?${q.toString()}`);
};
const expectedSatsHint =
resolvedPlan === 'yearly'
? pricingYearly != null
? `${pricingYearly.toLocaleString()} sats`
: null
: pricingLifetime != null
? `${pricingLifetime.toLocaleString()} sats`
: null;
const planSubtitle =
resolvedPlan === 'yearly'
? 'One year of relay access'
: resolvedPlan === 'lifetime'
? 'Lifetime relay access'
: '';
if (showFetchSpinner) {
return ( return (
<div className="flex justify-center items-center min-h-[400px]"> <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 className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-orange-500"></div>
@@ -122,6 +222,51 @@ export function Payment() {
); );
} }
if (!isDemoMode && !resolvedPlan) {
return (
<>
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8">
<h1 className="text-2xl font-bold mb-2 text-center">Choose a plan</h1>
<p className="text-gray-400 text-center mb-8">
Pick yearly access or pay once for lifetime access.
</p>
<div className="space-y-4">
<button
type="button"
onClick={() => navigateWithPlan('yearly')}
className="w-full px-6 py-4 bg-orange-500 rounded-lg hover:bg-orange-600 transition-colors font-semibold text-lg"
>
Pay for one year
{pricingYearly != null ? (
<span className="block text-sm font-normal text-orange-100 mt-1">
{pricingYearly.toLocaleString()} sats / year
</span>
) : null}
</button>
<button
type="button"
onClick={() => navigateWithPlan('lifetime')}
className="w-full px-6 py-4 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors font-semibold text-lg border border-gray-600"
>
Pay for lifetime
{pricingLifetime != null ? (
<span className="block text-sm font-normal text-gray-300 mt-1">
{pricingLifetime.toLocaleString()} sats one-time
</span>
) : null}
</button>
</div>
</div>
<Notification
isVisible={isVisible}
message={message}
type={type}
onClose={hideNotification}
/>
</>
);
}
if (error) { if (error) {
return ( return (
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8"> <div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8">
@@ -138,7 +283,29 @@ export function Payment() {
); );
} }
if (!invoice) { if (isDemoMode) {
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">Payment (demo)</h1>
<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}
/>
</>
);
}
if (!invoice || !resolvedPlan) {
return ( return (
<div className="text-center"> <div className="text-center">
<p className="text-red-500">Failed to generate invoice. Please try again.</p> <p className="text-red-500">Failed to generate invoice. Please try again.</p>
@@ -146,6 +313,16 @@ export function Payment() {
); );
} }
const satsLabel =
amountSats != null ? `${amountSats.toLocaleString()} sats` : expectedSatsHint ?? '—';
const countdown =
expiresAtIso && Date.parse(expiresAtIso) > nowTick
? formatInvoiceCountdown(expiresAtIso, nowTick)
: expiresAtIso
? 'Expired'
: null;
return ( return (
<> <>
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8 relative"> <div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8 relative">
@@ -159,10 +336,15 @@ export function Payment() {
)} )}
<h1 className="text-2xl font-bold mb-6 text-center">Payment Required</h1> <h1 className="text-2xl font-bold mb-6 text-center">Payment Required</h1>
<div className="text-center mb-6"> <div className="text-center mb-6">
<p className="text-3xl font-bold text-orange-500">10,000 sats</p> <p className="text-3xl font-bold text-orange-500">{satsLabel}</p>
<p className="text-gray-400">One-time payment for relay access</p> <p className="text-gray-400">{planSubtitle}</p>
{countdown != null ? (
<p className="text-sm text-amber-400/90 mt-3 font-mono">
Invoice expires in {countdown}
</p>
) : null}
</div> </div>
<div className="bg-white p-4 rounded-lg mb-6"> <div className="bg-white p-4 rounded-lg mb-6">
@@ -198,15 +380,6 @@ export function Payment() {
> >
Open in Wallet Open in Wallet
</button> </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> </div>
<Notification <Notification
isVisible={isVisible} isVisible={isVisible}
@@ -216,4 +389,4 @@ export function Payment() {
/> />
</> </>
); );
} }

View File

@@ -1,18 +1,15 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Shield } from 'lucide-react'; import { Shield } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
export function Terms() { export function Terms() {
const [terms, setTerms] = useState<string>(''); const [terms, setTerms] = useState<string>('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchParams] = useSearchParams();
const isIframe = searchParams.get('iframe') === '1';
useEffect(() => { useEffect(() => {
const fetchTerms = async () => { const fetchTerms = async () => {
try { try {
const response = await fetch('/terms.txt'); const response = await fetch('/terms.md');
const text = await response.text(); const text = await response.text();
setTerms(text); setTerms(text);
} catch (error) { } catch (error) {

View File

@@ -109,7 +109,7 @@ export function ThankYou() {
</p> </p>
<p className="flex items-center"> <p className="flex items-center">
<ArrowRight className="h-4 w-4 mr-2 text-orange-500" /> <ArrowRight className="h-4 w-4 mr-2 text-orange-500" />
Access is permanent and doesn't require renewal Lifetime plans stay active without renewal; yearly plans renew on their expiry date
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,63 +1,168 @@
import { UserResponse } from '../types'; const API_URL = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '');
const API_URL = import.meta.env.VITE_API_URL; export interface ApiUserResponse {
pubkey: string;
npub: string;
is_whitelisted: boolean;
username?: string;
expires_at?: string | null;
expired_at?: string;
subscription_type?: string;
in_grace?: boolean;
reserved_username?: string;
}
export interface PricingResponse {
yearly_sats: number;
lifetime_sats: number;
lightning_enabled: boolean;
}
export interface CreateInvoiceResponse {
payment_hash: string;
payment_request: string;
amount_sats: number;
expires_at: string;
username: string;
is_renewal: boolean;
}
export type InvoiceStatusKind = 'pending' | 'paid' | 'expired';
export interface InvoiceStatusResponse {
payment_hash: string;
status: InvoiceStatusKind;
username: string;
}
export interface ApiErrorBody {
error: string;
detail: string;
}
async function parseJsonSafe(res: Response): Promise<unknown> {
const text = await res.text();
if (!text) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
export const apiService = { export const apiService = {
async getUserInfo(pubkey: string): Promise<UserResponse> { /** GET /v1/users/{pubkey} — pubkey may be hex or npub */
async getUserInfo(pubkey: string): Promise<ApiUserResponse | null> {
if (!API_URL) {
console.error('VITE_API_URL is not set');
return null;
}
try { try {
const response = await fetch(`${API_URL}/api/user/info`, { const encoded = encodeURIComponent(pubkey.trim());
method: 'POST', const response = await fetch(`${API_URL}/v1/users/${encoded}`, {
headers: { method: 'GET',
'Content-Type': 'application/json', headers: { Accept: 'application/json' },
},
body: JSON.stringify({
identifier: pubkey,
}),
}); });
if (!response.ok) { if (response.status === 404) {
if (response.status === 404) { return null;
// 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(); if (!response.ok) {
console.error('getUserInfo failed', response.status);
return null;
}
return (await response.json()) as ApiUserResponse;
} catch (error) { } catch (error) {
console.error('Error fetching user info:', error); console.error('Error fetching user info:', error);
// Return a default response on error return null;
return {
pubkey,
npub: '',
is_whitelisted: false
};
} }
}, },
async whitelistUser(pubkey: string, apiKey: string): Promise<void> { async getPricing(): Promise<PricingResponse> {
try { if (!API_URL) {
const response = await fetch(`${API_URL}/api/whitelist/add`, { throw new Error('VITE_API_URL is not set');
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
} }
const response = await fetch(`${API_URL}/v1/pricing`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
const body = (await parseJsonSafe(response)) as ApiErrorBody | null;
throw new Error(body?.detail ?? `pricing failed (${response.status})`);
}
return (await response.json()) as PricingResponse;
}, },
};
/** POST /v1/invoices — subscription handled server-side (LNbits inside API) */
async createInvoice(body: {
username?: string;
pubkey: string;
subscription_type: 'lifetime' | 'yearly';
years?: number;
}): Promise<CreateInvoiceResponse> {
if (!API_URL) {
throw new Error('VITE_API_URL is not set');
}
const payload: Record<string, unknown> = {
pubkey: body.pubkey.trim(),
subscription_type: body.subscription_type,
};
if (body.username?.trim()) {
payload.username = body.username.trim();
}
if (body.subscription_type === 'yearly') {
payload.years = body.years && body.years > 0 ? body.years : 1;
}
const response = await fetch(`${API_URL}/v1/invoices`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await parseJsonSafe(response);
if (!response.ok) {
const err = data as ApiErrorBody | null;
const msg = err?.detail || err?.error || `invoice create failed (${response.status})`;
throw new Error(msg);
}
const o = data as Record<string, unknown>;
const rawAmt = o.amount_sats;
const amountParsed =
typeof rawAmt === 'number'
? rawAmt
: typeof rawAmt === 'string'
? Number.parseInt(rawAmt, 10)
: NaN;
if (!Number.isFinite(amountParsed)) {
console.error('createInvoice: unexpected payload', data);
throw new Error('invalid invoice response from API');
}
return { ...(data as Record<string, unknown>), amount_sats: amountParsed } as CreateInvoiceResponse;
},
/** GET /v1/invoices/{payment_hash} */
async getInvoiceStatus(paymentHash: string): Promise<InvoiceStatusResponse> {
if (!API_URL) {
throw new Error('VITE_API_URL is not set');
}
const encoded = encodeURIComponent(paymentHash.trim());
const response = await fetch(`${API_URL}/v1/invoices/${encoded}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await parseJsonSafe(response);
if (!response.ok) {
const err = data as ApiErrorBody | null;
const msg = err?.detail || err?.error || `invoice status failed (${response.status})`;
throw new Error(msg);
}
return data as InvoiceStatusResponse;
},
};

View File

@@ -1,166 +0,0 @@
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;
}
},
};

View File

@@ -1,19 +1,16 @@
export interface NostrUser { export interface NostrUser {
pubkey: string; pubkey: string;
isWhitelisted: boolean; isWhitelisted: boolean;
timeRemaining?: number;
npub?: string; npub?: string;
username?: string;
/** From GET /v1/users when registered */
subscriptionType?: 'yearly' | 'lifetime' | string;
/** ISO date string when applicable */
expiresAt?: string | null;
} }
export interface LightningInvoice { export interface LightningInvoice {
paymentRequest: string; paymentRequest: string;
qrCode: string; qrCode: string;
paymentHash: string; paymentHash: string;
}
export interface UserResponse {
pubkey: string;
npub: string;
time_remaining?: number;
is_whitelisted: boolean;
} }

2
src/vite-env.d.ts vendored
View File

@@ -3,7 +3,7 @@
interface Window { interface Window {
nostr: { nostr: {
getPublicKey: () => Promise<string>; getPublicKey: () => Promise<string>;
signEvent: (event: any) => Promise<any>; signEvent: (event: Record<string, unknown>) => Promise<Record<string, unknown>>;
getRelays: () => Promise<{ [url: string]: { read: boolean; write: boolean; } }>; getRelays: () => Promise<{ [url: string]: { read: boolean; write: boolean; } }>;
nip04: { nip04: {
encrypt: (pubkey: string, plaintext: string) => Promise<string>; encrypt: (pubkey: string, plaintext: string) => Promise<string>;

View File

@@ -1,30 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
keyframes: {
'slide-up': {
'0%': { transform: 'translateY(100%)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' }
},
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' }
},
'success-appear': {
'0%': { transform: 'scale(0.8)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' }
}
},
animation: {
'slide-up': 'slide-up 0.3s ease-out',
'fade-in': 'fade-in 0.3s ease-out',
'success-appear': 'success-appear 0.5s ease-out'
}
},
},
plugins: [
require('@tailwindcss/typography'),
],
};

View File

@@ -1,10 +1,40 @@
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react()], const env = loadEnv(mode, process.cwd(), '');
optimizeDeps: { /** Backend for `npm run dev` when `VITE_API_URL` is empty (same-origin `/v1`, etc.). */
exclude: ['lucide-react'], const proxyTarget = env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:8085';
},
return {
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
server: {
proxy: {
'^/v1': {
target: proxyTarget,
changeOrigin: true,
},
'^/.well-known': {
target: proxyTarget,
changeOrigin: true,
},
'^/healthz': {
target: proxyTarget,
changeOrigin: true,
},
'^/openapi\\.json': {
target: proxyTarget,
changeOrigin: true,
},
'^/docs': {
target: proxyTarget,
changeOrigin: true,
},
},
},
};
}); });