Compare commits
1 Commits
main
...
V0.02-ref-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
947799e8c8 |
16
.env.example
16
.env.example
@@ -1,19 +1,25 @@
|
|||||||
|
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 — API base URL for /v1/* and /.well-known/nostr.json (no trailing slash).
|
# Nostr settings
|
||||||
# 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
64
.gitignore
vendored
@@ -1,64 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
78
README.md
78
README.md
@@ -1,28 +1,26 @@
|
|||||||
# Noderunners Relay
|
# Noderunners Relay
|
||||||
|
|
||||||
A high-performance Nostr relay built by Bitcoiners, for Bitcoiners. This project provides a web interface for managing access to the Noderunners relay service.
|
A high-performance Nostr relay built by Bitcoiners, for Bitcoiners. This project provides a web interface for managing access to the Noderunners relay service.
|
||||||
```
|
|
||||||
wss://relay.noderunners.network
|
|
||||||
```
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Lightning payments via the relay API (invoice creation and status)
|
- 🚀 Lightning-fast relay performance with strfry v1.0.3
|
||||||
- Secure authentication with Nostr
|
- ⚡ Lightning Network integration for payments
|
||||||
- Modern, responsive web interface
|
- 🔒 Secure authentication with Nostr
|
||||||
- Real-time relay statistics
|
- 💻 Modern, responsive web interface
|
||||||
- Uptime monitoring
|
- 📊 Real-time relay statistics
|
||||||
- Iframe support for embedding
|
- 🔍 Uptime monitoring
|
||||||
- Multiple login methods (Extension, Manual, URL-based)
|
- 🖼️ Iframe support for embedding
|
||||||
|
- 🔑 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
|
||||||
- **Payments**: [NIP-05 relay API](../Nip05_api) (`/v1/pricing`, `/v1/invoices`, `/v1/users/…`)
|
- **Payment**: LNbits Integration
|
||||||
- **Authentication**: Nostr Protocol
|
- **Authentication**: Nostr Protocol
|
||||||
- **State Management**: Zustand
|
- **State Management**: Zustand
|
||||||
|
|
||||||
@@ -31,18 +29,28 @@ 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 — relay URL for clients; API URL serves /.well-known/nostr.json and /v1/*
|
# Nostr Settings
|
||||||
VITE_NOSTR_RELAY_URL="wss://your-relay-url"
|
VITE_NOSTR_RELAY_URL="wss://your-relay-url"
|
||||||
VITE_API_URL="https://your-api-host"
|
VITE_API_URL="your-api-url"
|
||||||
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
|
||||||
@@ -76,10 +84,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 your public key
|
- Automatically retrieves the user's public key
|
||||||
|
|
||||||
2. **Manual Entry**
|
2. **Manual Entry**
|
||||||
- You can manually input your npub or hex public key
|
- Users can manually input their 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**
|
||||||
@@ -108,12 +116,52 @@ 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>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Supported NIPs
|
||||||
|
|
||||||
|
The relay supports the following Nostr Implementation Possibilities (NIPs):
|
||||||
|
- NIP-01: Basic protocol flow description
|
||||||
|
- NIP-02: Contact List and Petnames
|
||||||
|
- NIP-04: Encrypted Direct Messages
|
||||||
|
- NIP-09: Event Deletion
|
||||||
|
- NIP-11: Relay Information Document
|
||||||
|
- NIP-22: Event `created_at` Limits
|
||||||
|
- NIP-28: Public Chat
|
||||||
|
- NIP-40: Expiration Timestamp
|
||||||
|
- NIP-70: Relay Payment Info
|
||||||
|
- NIP-77: Lightning Network Relay Payment
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For support, join our [Telegram group](https://t.me/noderunners) or visit [our website](https://noderunners.network).
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
- 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)
|
||||||
5427
package-lock.json
generated
5427
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
51
package.json
51
package.json
@@ -10,32 +10,33 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nostr-dev-kit/ndk": "^3.0.3",
|
"@nostr-dev-kit/ndk": "^2.5.1",
|
||||||
"canvas-confetti": "^1.9.4",
|
"axios": "^1.6.7",
|
||||||
"lucide-react": "^1.12.0",
|
"canvas-confetti": "^1.9.2",
|
||||||
"qrcode.react": "^4.2.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^19.2.5",
|
"qrcode.react": "^3.1.0",
|
||||||
"react-dom": "^19.2.5",
|
"react": "^18.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-markdown": "^9.0.1",
|
||||||
"zustand": "^5.0.12"
|
"react-router-dom": "^6.22.2",
|
||||||
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^9.9.1",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@types/canvas-confetti": "^1.6.4",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"autoprefixer": "^10.4.18",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^9.9.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
"globals": "^17.5.0",
|
"globals": "^15.9.0",
|
||||||
"postcss": "^8.5.12",
|
"postcss": "^8.4.35",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.59.1",
|
"typescript-eslint": "^8.3.0",
|
||||||
"vite": "^8.0.10"
|
"vite": "^5.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useLocation, 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,39 +1,3 @@
|
|||||||
@import 'tailwindcss';
|
@tailwind base;
|
||||||
@plugin '@tailwindcss/typography';
|
@tailwind components;
|
||||||
|
@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,51 +9,16 @@ 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';
|
||||||
|
|
||||||
const lifetimeLabel =
|
// Check user authentication and fetch status once
|
||||||
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');
|
||||||
@@ -64,41 +29,29 @@ export function Dashboard() {
|
|||||||
if (!isDemoMode) {
|
if (!isDemoMode) {
|
||||||
try {
|
try {
|
||||||
const userInfo = await apiService.getUserInfo(user.pubkey);
|
const userInfo = await apiService.getUserInfo(user.pubkey);
|
||||||
if (userInfo) {
|
setUser({
|
||||||
setUser({
|
...user,
|
||||||
...user,
|
isWhitelisted: userInfo.is_whitelisted,
|
||||||
isWhitelisted: userInfo.is_whitelisted,
|
timeRemaining: userInfo.time_remaining,
|
||||||
npub: userInfo.npub,
|
npub: userInfo.npub,
|
||||||
username: userInfo.username,
|
});
|
||||||
subscriptionType: userInfo.subscription_type,
|
} catch (error: any) {
|
||||||
expiresAt: userInfo.expires_at ?? null,
|
if (error.response?.status === 404 || error) {
|
||||||
});
|
|
||||||
} 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
void checkUserStatus();
|
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) {
|
||||||
@@ -126,8 +79,7 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const base = String(apiUrl ?? '').replace(/\/$/, '');
|
const response = await fetch(`${apiUrl}/.well-known/nostr.json`);
|
||||||
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) {
|
||||||
@@ -163,24 +115,6 @@ 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]">
|
||||||
@@ -189,14 +123,10 @@ 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">
|
||||||
@@ -220,41 +150,18 @@ 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">Lightning payment</p>
|
<p className="text-orange-400 text-base md:text-lg">
|
||||||
|
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">
|
||||||
Choose yearly access ({yearlyLabel}) or lifetime access ({lifetimeLabel}). Pricing comes from the
|
To use the Noderunners relay, you need to make a one-time payment of
|
||||||
relay API and funds relay infrastructure.
|
10,000 sats. This payment helps maintain the relay's 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{' '}
|
||||||
@@ -265,33 +172,23 @@ export function Dashboard() {
|
|||||||
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>
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<button
|
||||||
<button
|
onClick={() => navigate('/payment')}
|
||||||
type="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"
|
||||||
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>
|
||||||
<Zap className="h-5 w-5" />
|
</button>
|
||||||
<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>
|
||||||
@@ -312,6 +209,7 @@ 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">
|
||||||
@@ -332,6 +230,7 @@ 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
|
||||||
|
|||||||
@@ -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 { Zap, Shield, Globe, Server, Code, Cpu, Copy } from 'lucide-react';
|
import { Flame, 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,11 +9,6 @@ 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);
|
||||||
@@ -101,18 +96,11 @@ 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">
|
||||||
{supportedNips.length > 0 ? (
|
{import.meta.env.VITE_SUPPORTED_NIPS.split(',').map(nip => (
|
||||||
supportedNips.map((nip) => (
|
<span key={nip} className="bg-gray-800 px-3 py-1 rounded text-center">
|
||||||
<span key={nip} className="bg-gray-800 px-3 py-1 rounded text-center">
|
{nip}
|
||||||
{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,18 +46,17 @@ 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: unknown) {
|
} catch (error: any) {
|
||||||
const msg = error instanceof Error ? error.message : '';
|
if (error.message === 'Rejected by user') {
|
||||||
if (msg === 'Rejected by user') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error('Login failed:', error);
|
console.error('Login failed:', error);
|
||||||
|
|
||||||
if (msg.includes('Nostr provider not found')) {
|
if (error.message.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 (msg.includes('No public key found')) {
|
} else if (error.message.includes('No public key found')) {
|
||||||
alert('Could not access your Nostr public key. Please make sure you\'re logged into your Nostr extension.');
|
alert('Could not access your Nostr public key. Please make sure you\'re logged into your Nostr extension.');
|
||||||
} else if (msg !== 'Rejected by user') {
|
} else if (error.message !== '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,173 +1,104 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useEffect, 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 planParam = searchParams.get('plan');
|
const { user, setUser } = useStore();
|
||||||
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 [invoice, setInvoice] = useState<LightningInvoice | null>(null);
|
||||||
const [amountSats, setAmountSats] = useState<number | null>(null);
|
const [loading, setLoading] = useState(true);
|
||||||
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 [error, setError] = useState<string | null>(null);
|
||||||
|
const isDemoMode = import.meta.env.VITE_ENABLE_DEMO === 'true';
|
||||||
const { isVisible, message, type, showNotification, hideNotification } = useNotification();
|
const { isVisible, message, type, showNotification, hideNotification } = useNotification();
|
||||||
|
const [checkingPayment, setCheckingPayment] = useState(false);
|
||||||
const [showSuccess, setShowSuccess] = useState(false);
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
const [nowTick, setNowTick] = useState(() => Date.now());
|
|
||||||
const isIframe = searchParams.get('iframe') === '1';
|
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 && !resolvedPlan) {
|
if (user.isWhitelisted) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
const generateInvoice = async () => {
|
||||||
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 {
|
try {
|
||||||
const pricing = await apiService.getPricing();
|
const response = await lnbitsService.createInvoice({
|
||||||
if (cancelled) return;
|
amount: 10000,
|
||||||
|
memo: `${import.meta.env.VITE_PAYMENT_MEMO || "Noderunners Relay Access"} - ${user.pubkey}`,
|
||||||
if (!pricing.lightning_enabled) {
|
webhook: import.meta.env.VITE_WEBHOOK_URL,
|
||||||
setError('Lightning payments are temporarily unavailable. Please try again later.');
|
extra: {
|
||||||
return;
|
pubkey: user.pubkey,
|
||||||
}
|
type: 'relay_access'
|
||||||
|
}
|
||||||
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);
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to generate invoice.';
|
setError('Failed to generate invoice. Please try again later.');
|
||||||
setError(msg);
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void run();
|
generateInvoice();
|
||||||
|
}, [user, navigate, isIframe]);
|
||||||
|
|
||||||
return () => {
|
const handlePaymentSuccess = async () => {
|
||||||
cancelled = true;
|
setShowSuccess(true);
|
||||||
if (intervalId) clearInterval(intervalId);
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [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 {
|
||||||
@@ -183,38 +114,7 @@ export function Payment() {
|
|||||||
handlePaymentSuccess();
|
handlePaymentSuccess();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (loading) {
|
||||||
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>
|
||||||
@@ -222,51 +122,6 @@ 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">
|
||||||
@@ -283,29 +138,7 @@ export function Payment() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDemoMode) {
|
if (!invoice) {
|
||||||
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>
|
||||||
@@ -313,16 +146,6 @@ 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">
|
||||||
@@ -338,13 +161,8 @@ 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">{satsLabel}</p>
|
<p className="text-3xl font-bold text-orange-500">10,000 sats</p>
|
||||||
<p className="text-gray-400">{planSubtitle}</p>
|
<p className="text-gray-400">One-time payment for relay access</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">
|
||||||
@@ -380,6 +198,15 @@ 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}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
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.md');
|
const response = await fetch('/terms.txt');
|
||||||
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" />
|
||||||
Lifetime plans stay active without renewal; yearly plans renew on their expiry date
|
Access is permanent and doesn't require renewal
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,168 +1,63 @@
|
|||||||
const API_URL = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '');
|
import { UserResponse } from '../types';
|
||||||
|
|
||||||
export interface ApiUserResponse {
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
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 = {
|
||||||
/** GET /v1/users/{pubkey} — pubkey may be hex or npub */
|
async getUserInfo(pubkey: string): Promise<UserResponse> {
|
||||||
async getUserInfo(pubkey: string): Promise<ApiUserResponse | null> {
|
|
||||||
if (!API_URL) {
|
|
||||||
console.error('VITE_API_URL is not set');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const encoded = encodeURIComponent(pubkey.trim());
|
const response = await fetch(`${API_URL}/api/user/info`, {
|
||||||
const response = await fetch(`${API_URL}/v1/users/${encoded}`, {
|
method: 'POST',
|
||||||
method: 'GET',
|
headers: {
|
||||||
headers: { Accept: 'application/json' },
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
identifier: pubkey,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 404) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('getUserInfo failed', response.status);
|
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()) as ApiUserResponse;
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching user info:', error);
|
console.error('Error fetching user info:', error);
|
||||||
return null;
|
// Return a default response on error
|
||||||
|
return {
|
||||||
|
pubkey,
|
||||||
|
npub: '',
|
||||||
|
is_whitelisted: false
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPricing(): Promise<PricingResponse> {
|
async whitelistUser(pubkey: string, apiKey: string): Promise<void> {
|
||||||
if (!API_URL) {
|
try {
|
||||||
throw new Error('VITE_API_URL is not set');
|
const response = await fetch(`${API_URL}/api/whitelist/add`, {
|
||||||
}
|
method: 'POST',
|
||||||
const response = await fetch(`${API_URL}/v1/pricing`, {
|
headers: {
|
||||||
method: 'GET',
|
'Content-Type': 'application/json',
|
||||||
headers: { Accept: 'application/json' },
|
'X-Api-Key': apiKey,
|
||||||
});
|
},
|
||||||
if (!response.ok) {
|
body: JSON.stringify({
|
||||||
const body = (await parseJsonSafe(response)) as ApiErrorBody | null;
|
identifier: pubkey,
|
||||||
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) */
|
if (!response.ok) {
|
||||||
async createInvoice(body: {
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
username?: string;
|
}
|
||||||
pubkey: string;
|
} catch (error) {
|
||||||
subscription_type: 'lifetime' | 'yearly';
|
console.error('Error whitelisting user:', error);
|
||||||
years?: number;
|
throw error; // Re-throw as this is a critical operation
|
||||||
}): 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;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
166
src/services/lnbits.ts
Normal file
166
src/services/lnbits.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const LNBITS_URL = import.meta.env.VITE_LNBITS_URL;
|
||||||
|
const API_KEY = import.meta.env.VITE_LNBITS_API_KEY;
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: LNBITS_URL,
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': API_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface CreateInvoiceParams {
|
||||||
|
amount: number;
|
||||||
|
memo: string;
|
||||||
|
unit?: string;
|
||||||
|
webhook?: string;
|
||||||
|
internal?: boolean;
|
||||||
|
extra?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
payment_hash: string;
|
||||||
|
payment_request: string;
|
||||||
|
checking_id: string;
|
||||||
|
amount: number;
|
||||||
|
fee: number;
|
||||||
|
memo: string;
|
||||||
|
time: number;
|
||||||
|
bolt11: string;
|
||||||
|
preimage: string;
|
||||||
|
expiry: number;
|
||||||
|
extra: Record<string, any>;
|
||||||
|
webhook?: string;
|
||||||
|
webhook_status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentStatus {
|
||||||
|
paid: boolean;
|
||||||
|
preimage?: string;
|
||||||
|
details?: {
|
||||||
|
bolt11: string;
|
||||||
|
checking_id: string;
|
||||||
|
pending: boolean;
|
||||||
|
amount: number;
|
||||||
|
fee: number;
|
||||||
|
memo: string;
|
||||||
|
time: number;
|
||||||
|
payment_hash: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lnbitsService = {
|
||||||
|
// Create a new invoice
|
||||||
|
async createInvoice({
|
||||||
|
amount,
|
||||||
|
memo,
|
||||||
|
unit = 'sat',
|
||||||
|
webhook = '',
|
||||||
|
internal = false,
|
||||||
|
extra = {},
|
||||||
|
}: CreateInvoiceParams): Promise<Invoice> {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/v1/payments', {
|
||||||
|
out: false,
|
||||||
|
amount,
|
||||||
|
memo,
|
||||||
|
unit,
|
||||||
|
webhook,
|
||||||
|
internal,
|
||||||
|
extra,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating invoice:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check payment status
|
||||||
|
async checkPayment(paymentHash: string): Promise<PaymentStatus> {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/v1/payments/${paymentHash}`);
|
||||||
|
return {
|
||||||
|
paid: response.data.paid,
|
||||||
|
preimage: response.data.preimage,
|
||||||
|
details: response.data.details,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking payment:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get wallet info
|
||||||
|
async getWalletInfo() {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/v1/wallet');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting wallet info:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get payment history
|
||||||
|
async getPaymentHistory(limit = 10) {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/v1/payments', {
|
||||||
|
params: {
|
||||||
|
limit,
|
||||||
|
offset: 0,
|
||||||
|
sortby: 'time',
|
||||||
|
direction: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting payment history:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get current exchange rate
|
||||||
|
async getExchangeRate(currency: string = 'USD') {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/v1/rate/${currency.toLowerCase()}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting exchange rate:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Convert fiat to sats
|
||||||
|
async convertFiatToSats(amount: number, from: string = 'USD') {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/v1/conversion', {
|
||||||
|
from_: from.toLowerCase(),
|
||||||
|
amount,
|
||||||
|
to: 'sat',
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting fiat to sats:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Long poll payment status
|
||||||
|
async longPollPayment(paymentHash: string, timeout = 60000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/public/v1/payment/${paymentHash}`, {
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
return response.data.paid;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.code === 'ECONNABORTED') {
|
||||||
|
return false; // Timeout reached
|
||||||
|
}
|
||||||
|
console.error('Error polling payment:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
13
src/types.ts
13
src/types.ts
@@ -1,12 +1,8 @@
|
|||||||
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 {
|
||||||
@@ -14,3 +10,10 @@ export interface LightningInvoice {
|
|||||||
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: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
signEvent: (event: any) => Promise<any>;
|
||||||
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>;
|
||||||
|
|||||||
30
tailwind.config.js
Normal file
30
tailwind.config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/** @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,40 +1,10 @@
|
|||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig } 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(({ mode }) => {
|
export default defineConfig({
|
||||||
const env = loadEnv(mode, process.cwd(), '');
|
plugins: [react()],
|
||||||
/** Backend for `npm run dev` when `VITE_API_URL` is empty (same-origin `/v1`, etc.). */
|
optimizeDeps: {
|
||||||
const proxyTarget = env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:8085';
|
exclude: ['lucide-react'],
|
||||||
|
},
|
||||||
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