Initial commit: Lightning Lottery - Bitcoin Lightning Network powered lottery
Features: - Lightning Network payments via LNbits integration - Provably fair draws using CSPRNG - Random ticket number generation - Automatic payouts with retry/redraw logic - Nostr authentication (NIP-07) - Multiple draw cycles (hourly, daily, weekly, monthly) - PostgreSQL and SQLite database support - Real-time countdown and payment animations - Swagger API documentation - Docker support Stack: - Backend: Node.js, TypeScript, Express - Frontend: Next.js, React, TailwindCSS, Redux - Payments: LNbits
This commit is contained in:
224
front_end/src/lib/api.ts
Normal file
224
front_end/src/lib/api.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import config from '@/config';
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]']);
|
||||
const EXPLICIT_BACKEND_PORT = process.env.NEXT_PUBLIC_BACKEND_PORT;
|
||||
|
||||
const buildBaseUrl = (protocol: string, hostname: string, port?: string) => {
|
||||
if (port && port.length > 0) {
|
||||
return `${protocol}//${hostname}:${port}`;
|
||||
}
|
||||
return `${protocol}//${hostname}`;
|
||||
};
|
||||
|
||||
const inferPort = (configuredPort?: string, runtimePort?: string, runtimeProtocol?: string): string | undefined => {
|
||||
if (EXPLICIT_BACKEND_PORT && EXPLICIT_BACKEND_PORT.length > 0) {
|
||||
return EXPLICIT_BACKEND_PORT;
|
||||
}
|
||||
|
||||
if (configuredPort && configuredPort.length > 0) {
|
||||
return configuredPort;
|
||||
}
|
||||
|
||||
if (runtimePort && runtimePort.length > 0) {
|
||||
return runtimePort === '3001' ? '3000' : runtimePort;
|
||||
}
|
||||
|
||||
if (runtimeProtocol === 'https:') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolveBrowserBaseUrl = (staticBaseUrl: string) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return staticBaseUrl;
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location;
|
||||
|
||||
if (!staticBaseUrl || staticBaseUrl.length === 0) {
|
||||
const inferredPort = inferPort(undefined, port, protocol);
|
||||
return buildBaseUrl(protocol, hostname, inferredPort);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(staticBaseUrl);
|
||||
const shouldSwapHost = LOOPBACK_HOSTS.has(parsed.hostname) && !LOOPBACK_HOSTS.has(hostname);
|
||||
|
||||
if (shouldSwapHost) {
|
||||
const inferredPort = inferPort(parsed.port, port, parsed.protocol);
|
||||
return buildBaseUrl(parsed.protocol, hostname, inferredPort);
|
||||
}
|
||||
|
||||
return staticBaseUrl;
|
||||
} catch {
|
||||
return staticBaseUrl;
|
||||
}
|
||||
};
|
||||
|
||||
interface ApiOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
auth?: boolean;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private cachedBrowserBaseUrl: string | null = null;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private getBaseUrl(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return this.baseUrl || 'http://localhost:3000';
|
||||
}
|
||||
|
||||
if (this.cachedBrowserBaseUrl) {
|
||||
return this.cachedBrowserBaseUrl;
|
||||
}
|
||||
|
||||
const resolved = resolveBrowserBaseUrl(this.baseUrl || 'http://localhost:3000');
|
||||
this.cachedBrowserBaseUrl = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('auth_token');
|
||||
}
|
||||
|
||||
async request<T = any>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
const { method = 'GET', headers = {}, body, auth = false } = options;
|
||||
|
||||
const url = `${this.getBaseUrl()}${path}`;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (auth) {
|
||||
const token = this.getAuthToken();
|
||||
if (token) {
|
||||
requestHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
};
|
||||
|
||||
if (body && method !== 'GET') {
|
||||
requestOptions.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'API request failed');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('API request error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Public endpoints
|
||||
async getNextJackpot() {
|
||||
return this.request('/jackpot/next');
|
||||
}
|
||||
|
||||
async buyTickets(lightningAddress: string, tickets: number, buyerName?: string) {
|
||||
return this.request('/jackpot/buy', {
|
||||
method: 'POST',
|
||||
body: { lightning_address: lightningAddress, tickets, name: buyerName },
|
||||
auth: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getTicketStatus(ticketPurchaseId: string) {
|
||||
return this.request(`/tickets/${ticketPurchaseId}`);
|
||||
}
|
||||
|
||||
async getPastWins(limit = 25, offset = 0) {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
return this.request(`/jackpot/past-wins?${params.toString()}`);
|
||||
}
|
||||
|
||||
// Auth endpoints
|
||||
async nostrAuth(nostrPubkey: string, signedMessage: string, nonce: string) {
|
||||
return this.request('/auth/nostr', {
|
||||
method: 'POST',
|
||||
body: { nostr_pubkey: nostrPubkey, signed_message: signedMessage, nonce },
|
||||
});
|
||||
}
|
||||
|
||||
// User endpoints
|
||||
async getProfile() {
|
||||
return this.request('/me', { auth: true });
|
||||
}
|
||||
|
||||
async updateLightningAddress(lightningAddress: string) {
|
||||
return this.request('/me/lightning-address', {
|
||||
method: 'PATCH',
|
||||
body: { lightning_address: lightningAddress },
|
||||
auth: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserTickets(limit = 50, offset = 0) {
|
||||
return this.request(`/me/tickets?limit=${limit}&offset=${offset}`, { auth: true });
|
||||
}
|
||||
|
||||
async getUserWins() {
|
||||
return this.request('/me/wins', { auth: true });
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
async listCycles(status?: string, cycleType?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.append('status', status);
|
||||
if (cycleType) params.append('cycle_type', cycleType);
|
||||
|
||||
return this.request(`/admin/cycles?${params.toString()}`, {
|
||||
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||
});
|
||||
}
|
||||
|
||||
async runDrawManually(cycleId: string) {
|
||||
return this.request(`/admin/cycles/${cycleId}/run-draw`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||
});
|
||||
}
|
||||
|
||||
async listPayouts(status?: string) {
|
||||
const params = status ? `?status=${status}` : '';
|
||||
return this.request(`/admin/payouts${params}`, {
|
||||
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||
});
|
||||
}
|
||||
|
||||
async retryPayout(payoutId: string) {
|
||||
return this.request(`/admin/payouts/${payoutId}/retry`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Admin-Key': process.env.NEXT_PUBLIC_ADMIN_KEY || '' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(config.apiBaseUrl);
|
||||
export default api;
|
||||
|
||||
Reference in New Issue
Block a user