Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f7c6601a3 | |||
|
|
c6749839af | ||
|
|
da1d1b0d53 | ||
|
|
0ab66479d9 | ||
|
|
1129fb9341 | ||
|
|
4afe3f6d3e | ||
|
|
21bbd0fab1 | ||
|
|
bb3b5604a1 |
16
.env.example
16
.env.example
@@ -1,25 +1,19 @@
|
||||
VITE_LNBITS_URL="https://azzamo.tips"
|
||||
VITE_LNBITS_API_KEY="LNbits Api key"
|
||||
|
||||
# App settings
|
||||
VITE_APP_NAME="Noderunners Relay"
|
||||
VITE_APP_DESCRIPTION="A high-performance Nostr relay built by Noderunners, for Noderunners"
|
||||
VITE_LOGO_URL="https://cdn.azzamo.net/5cc03420a18166ef7a20b1e6b7dad240ad7d634824649643c80d74a924062258.png"
|
||||
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_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_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
|
||||
VITE_ENABLE_WHITELIST=true
|
||||
VITE_ENABLE_PAYMENT_VERIFICATION=true
|
||||
|
||||
64
.gitignore
vendored
Normal file
64
.gitignore
vendored
Normal 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
|
||||
|
||||
|
||||
56
README.md
56
README.md
@@ -9,20 +9,20 @@ wss://relay.noderunners.network
|
||||
|
||||
## Features
|
||||
|
||||
- ⚡ Lightning Network integration for payments
|
||||
- 🔒 Secure authentication with Nostr
|
||||
- 💻 Modern, responsive web interface
|
||||
- 📊 Real-time relay statistics
|
||||
- 🔍 Uptime monitoring
|
||||
- 🖼️ Iframe support for embedding
|
||||
- 🔑 Multiple login methods (Extension, Manual, URL-based)
|
||||
- Lightning payments via the relay API (invoice creation and status)
|
||||
- Secure authentication with Nostr
|
||||
- Modern, responsive web interface
|
||||
- Real-time relay statistics
|
||||
- Uptime monitoring
|
||||
- Iframe support for embedding
|
||||
- Multiple login methods (Extension, Manual, URL-based)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: React + TypeScript + Vite
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Icons**: Lucide React
|
||||
- **Payment**: LNbits Integration
|
||||
- **Payments**: [NIP-05 relay API](../Nip05_api) (`/v1/pricing`, `/v1/invoices`, `/v1/users/…`)
|
||||
- **Authentication**: Nostr Protocol
|
||||
- **State Management**: Zustand
|
||||
|
||||
@@ -31,28 +31,18 @@ wss://relay.noderunners.network
|
||||
Create a `.env` file in the root directory with the following variables:
|
||||
|
||||
```env
|
||||
# LNbits Configuration
|
||||
VITE_LNBITS_URL="your-lnbits-url"
|
||||
VITE_LNBITS_API_KEY="your-api-key"
|
||||
|
||||
# App Settings
|
||||
VITE_APP_NAME="Noderunners Relay"
|
||||
VITE_APP_DESCRIPTION="A high-performance Nostr relay built by Bitcoiners, for Bitcoiners"
|
||||
VITE_LOGO_URL="your-logo-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_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_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
|
||||
VITE_ENABLE_WHITELIST=true
|
||||
VITE_ENABLE_PAYMENT_VERIFICATION=true
|
||||
@@ -86,10 +76,10 @@ The application supports multiple authentication methods:
|
||||
|
||||
1. **Nostr Extension**
|
||||
- Uses browser extensions like Alby for seamless authentication
|
||||
- Automatically retrieves the user's public key
|
||||
- Automatically retrieves your public key
|
||||
|
||||
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
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
@@ -148,4 +117,3 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
||||
|
||||
- Built by the Noderunners community
|
||||
- Powered by [strfry](https://github.com/hoytech/strfry)
|
||||
- Lightning Network integration via [LNbits](https://lnbits.com)
|
||||
|
||||
5333
package-lock.json
generated
5333
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -10,33 +10,32 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nostr-dev-kit/ndk": "^2.5.1",
|
||||
"axios": "^1.6.7",
|
||||
"canvas-confetti": "^1.9.2",
|
||||
"lucide-react": "^0.344.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"zustand": "^4.5.2"
|
||||
"@nostr-dev-kit/ndk": "^3.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"lucide-react": "^1.12.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/canvas-confetti": "^1.6.4",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.2"
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { useStore } from '../store/useStore';
|
||||
|
||||
export function Navigation() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user, setUser } = useStore();
|
||||
|
||||
@@ -1,3 +1,39 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,51 @@ import { useNotification } from '../hooks/useNotification';
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { user, setUser } = useStore();
|
||||
const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
|
||||
const [uptime, setUptime] = useState<string | 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 isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
const { isVisible, message, type, showNotification, hideNotification } = useNotification();
|
||||
const [searchParams] = useSearchParams();
|
||||
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(() => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
@@ -29,29 +64,41 @@ export function Dashboard() {
|
||||
if (!isDemoMode) {
|
||||
try {
|
||||
const userInfo = await apiService.getUserInfo(user.pubkey);
|
||||
if (userInfo) {
|
||||
setUser({
|
||||
...user,
|
||||
isWhitelisted: userInfo.is_whitelisted,
|
||||
timeRemaining: userInfo.time_remaining,
|
||||
npub: userInfo.npub,
|
||||
username: userInfo.username,
|
||||
subscriptionType: userInfo.subscription_type,
|
||||
expiresAt: userInfo.expires_at ?? null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404 || error) {
|
||||
} else {
|
||||
setUser({
|
||||
...user,
|
||||
isWhitelisted: false,
|
||||
subscriptionType: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to fetch user status:', error);
|
||||
setUser({
|
||||
...user,
|
||||
isWhitelisted: false,
|
||||
subscriptionType: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
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]);
|
||||
|
||||
// Fetch uptime and active users
|
||||
useEffect(() => {
|
||||
const fetchUptime = async () => {
|
||||
if (isDemoMode) {
|
||||
@@ -79,7 +126,8 @@ export function Dashboard() {
|
||||
}
|
||||
|
||||
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();
|
||||
setActiveUsers(data.names ? Object.keys(data.names).length : 0);
|
||||
} catch (error) {
|
||||
@@ -115,6 +163,24 @@ export function Dashboard() {
|
||||
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) {
|
||||
return (
|
||||
<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 (
|
||||
<>
|
||||
<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">
|
||||
@@ -150,18 +220,41 @@ export function Dashboard() {
|
||||
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>
|
||||
{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 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>
|
||||
<p className="text-orange-400 text-base md:text-lg">Lightning payment</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.
|
||||
Choose yearly access ({yearlyLabel}) or lifetime access ({lifetimeLabel}). Pricing comes from the
|
||||
relay API and funds relay infrastructure.
|
||||
</p>
|
||||
<p className="text-gray-400">
|
||||
<span className="text-orange-400">21%</span> of all payments go to the{' '}
|
||||
@@ -172,23 +265,33 @@ export function Dashboard() {
|
||||
className="text-orange-400 hover:underline"
|
||||
>
|
||||
Noderunners community pot
|
||||
</a>
|
||||
{' '}to support the development of Bitcoin and Nostr projects.
|
||||
</a>{' '}
|
||||
to support the development of Bitcoin and Nostr projects.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<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"
|
||||
type="button"
|
||||
onClick={() => toPayment('yearly')}
|
||||
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"
|
||||
>
|
||||
<Zap className="h-5 w-5" />
|
||||
<span>Pay 10,000 sats for Access</span>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
@@ -209,7 +312,6 @@ export function Dashboard() {
|
||||
</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">
|
||||
@@ -230,7 +332,6 @@ export function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logout Button (only shown in iframe mode) */}
|
||||
{isIframe && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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 { useNotification } from '../hooks/useNotification';
|
||||
|
||||
@@ -9,6 +9,11 @@ export function Home() {
|
||||
const [searchParams] = useSearchParams();
|
||||
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) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -96,11 +101,18 @@ export function Home() {
|
||||
</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 => (
|
||||
{supportedNips.length > 0 ? (
|
||||
supportedNips.map((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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,17 +46,18 @@ export function Login() {
|
||||
|
||||
setUser({ pubkey, isWhitelisted: false });
|
||||
navigate(isIframe ? '/dashboard?iframe=1' : '/dashboard');
|
||||
} catch (error: any) {
|
||||
if (error.message === 'Rejected by user') {
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : '';
|
||||
if (msg === 'Rejected by user') {
|
||||
return;
|
||||
}
|
||||
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.');
|
||||
} 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.');
|
||||
} 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.');
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -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 { 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';
|
||||
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
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);
|
||||
const isIframe = searchParams.get('iframe') === '1';
|
||||
const planParam = searchParams.get('plan');
|
||||
const resolvedPlan: Plan | null =
|
||||
planParam === 'yearly' || planParam === 'lifetime' ? planParam : null;
|
||||
|
||||
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(() => {
|
||||
if (!user) {
|
||||
navigate(isIframe ? '/login?iframe=1' : '/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.isWhitelisted) {
|
||||
// Active subscribers may still open /payment?plan=… for renewal or lifetime upgrade.
|
||||
if (user.isWhitelisted && !resolvedPlan) {
|
||||
navigate(isIframe ? '/dashboard?iframe=1' : '/dashboard');
|
||||
}
|
||||
}, [user, navigate, isIframe, resolvedPlan]);
|
||||
|
||||
const pubkey = user?.pubkey;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey || isDemoMode || !resolvedPlan) {
|
||||
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'
|
||||
}
|
||||
});
|
||||
let cancelled = false;
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
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({
|
||||
paymentRequest: response.payment_request,
|
||||
qrCode: response.payment_request,
|
||||
paymentHash: response.payment_hash
|
||||
paymentHash: response.payment_hash,
|
||||
});
|
||||
|
||||
pollPaymentStatus(response.payment_hash);
|
||||
poll(response.payment_hash);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('Failed to generate invoice:', err);
|
||||
setError('Failed to generate invoice. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const msg = err instanceof Error ? err.message : 'Failed to generate invoice.';
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
generateInvoice();
|
||||
}, [user, navigate, isIframe]);
|
||||
void run();
|
||||
|
||||
const handlePaymentSuccess = async () => {
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const paid = await checkPayment();
|
||||
if (paid) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [pubkey, isDemoMode, resolvedPlan, handlePaymentSuccess]);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
@@ -114,7 +183,38 @@ export function Payment() {
|
||||
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 (
|
||||
<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>
|
||||
@@ -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) {
|
||||
return (
|
||||
<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 (
|
||||
<div className="text-center">
|
||||
<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 (
|
||||
<>
|
||||
<div className="max-w-md mx-auto bg-gray-800 rounded-lg p-8 relative">
|
||||
@@ -161,8 +338,13 @@ export function Payment() {
|
||||
<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>
|
||||
<p className="text-3xl font-bold text-orange-500">{satsLabel}</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 className="bg-white p-4 rounded-lg mb-6">
|
||||
@@ -198,15 +380,6 @@ export function Payment() {
|
||||
>
|
||||
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}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Shield } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
export function Terms() {
|
||||
const [terms, setTerms] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchParams] = useSearchParams();
|
||||
const isIframe = searchParams.get('iframe') === '1';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTerms = async () => {
|
||||
try {
|
||||
const response = await fetch('/terms.txt');
|
||||
const response = await fetch('/terms.md');
|
||||
const text = await response.text();
|
||||
setTerms(text);
|
||||
} catch (error) {
|
||||
|
||||
@@ -109,7 +109,7 @@ export function ThankYou() {
|
||||
</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
|
||||
Lifetime plans stay active without renewal; yearly plans renew on their expiry date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 = {
|
||||
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 {
|
||||
const response = await fetch(`${API_URL}/api/user/info`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: pubkey,
|
||||
}),
|
||||
const encoded = encodeURIComponent(pubkey.trim());
|
||||
const response = await fetch(`${API_URL}/v1/users/${encoded}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
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 null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
if (!response.ok) {
|
||||
console.error('getUserInfo failed', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as ApiUserResponse;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user info:', error);
|
||||
// Return a default response on error
|
||||
return {
|
||||
pubkey,
|
||||
npub: '',
|
||||
is_whitelisted: false
|
||||
};
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async whitelistUser(pubkey: string, apiKey: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/whitelist/add`, {
|
||||
async getPricing(): Promise<PricingResponse> {
|
||||
if (!API_URL) {
|
||||
throw new Error('VITE_API_URL is not set');
|
||||
}
|
||||
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',
|
||||
'X-Api-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
identifier: pubkey,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await parseJsonSafe(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const err = data as ApiErrorBody | null;
|
||||
const msg = err?.detail || err?.error || `invoice create failed (${response.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error whitelisting user:', error);
|
||||
throw error; // Re-throw as this is a critical operation
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
13
src/types.ts
13
src/types.ts
@@ -1,8 +1,12 @@
|
||||
export interface NostrUser {
|
||||
pubkey: string;
|
||||
isWhitelisted: boolean;
|
||||
timeRemaining?: number;
|
||||
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 {
|
||||
@@ -10,10 +14,3 @@ export interface LightningInvoice {
|
||||
qrCode: string;
|
||||
paymentHash: string;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
pubkey: string;
|
||||
npub: string;
|
||||
time_remaining?: number;
|
||||
is_whitelisted: boolean;
|
||||
}
|
||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
@@ -3,7 +3,7 @@
|
||||
interface Window {
|
||||
nostr: {
|
||||
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; } }>;
|
||||
nip04: {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string>;
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
};
|
||||
@@ -1,10 +1,40 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
/** Backend for `npm run dev` when `VITE_API_URL` is empty (same-origin `/v1`, etc.). */
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user