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
|
# 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
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
|
## 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
5345
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
53
package.json
53
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
'@tailwindcss/postcss': {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,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
2
src/vite-env.d.ts
vendored
@@ -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>;
|
||||||
|
|||||||
@@ -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';
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user