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:
Michilis
2025-11-27 22:13:37 +00:00
commit d3bf8080b6
75 changed files with 18184 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import api from '@/lib/api';
import { JackpotCountdown } from '@/components/JackpotCountdown';
import { TicketList } from '@/components/TicketList';
import { PayoutStatus } from '@/components/PayoutStatus';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import { formatDateTime } from '@/lib/format';
import STRINGS from '@/constants/strings';
export default function TicketStatusPage() {
const params = useParams();
const ticketId = params.id as string;
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<any>(null);
const [autoRefresh, setAutoRefresh] = useState(true);
useEffect(() => {
loadTicketStatus();
// Auto-refresh if payment pending or draw not complete
const interval = setInterval(() => {
if (autoRefresh) {
loadTicketStatus(true);
}
}, 5000);
return () => clearInterval(interval);
}, [ticketId, autoRefresh]);
const loadTicketStatus = async (silent = false) => {
try {
if (!silent) setLoading(true);
const response = await api.getTicketStatus(ticketId);
if (response.data) {
setData(response.data);
// Stop auto-refresh if payment is complete and draw is done
if (
response.data.purchase.invoice_status === 'paid' &&
response.data.cycle.status === 'completed'
) {
setAutoRefresh(false);
}
}
} catch (err: any) {
setError(err.message || 'Failed to load ticket status');
} finally {
if (!silent) setLoading(false);
}
};
if (loading) {
return <LoadingSpinner />;
}
if (error) {
return (
<div className="text-center py-12">
<div className="text-red-500 text-xl mb-4"> {error}</div>
<button
onClick={() => loadTicketStatus()}
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-6 py-2 rounded-lg"
>
Retry
</button>
</div>
);
}
if (!data) {
return <div className="text-center py-12 text-gray-400">Ticket not found</div>;
}
const { purchase, tickets, cycle, result } = data;
return (
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
{STRINGS.ticket.title}
</h1>
{/* Purchase Info */}
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-400">Purchase ID:</span>
<div className="text-white font-mono break-all">{purchase.id}</div>
</div>
<div>
<span className="text-gray-400">Status:</span>
<div className="text-white capitalize">{purchase.invoice_status}</div>
</div>
<div>
<span className="text-gray-400">Tickets:</span>
<div className="text-white">{purchase.number_of_tickets}</div>
</div>
<div>
<span className="text-gray-400">Amount:</span>
<div className="text-white">{purchase.amount_sats.toLocaleString()} sats</div>
</div>
</div>
</div>
{/* Payment Status */}
{purchase.invoice_status === 'pending' && (
<div className="bg-yellow-900/30 text-yellow-200 px-6 py-4 rounded-lg mb-6 text-center">
{STRINGS.ticket.waiting}
</div>
)}
{/* Tickets */}
{purchase.ticket_issue_status === 'issued' && (
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4 text-gray-300">
{STRINGS.ticket.ticketNumbers}
</h2>
<TicketList tickets={tickets} />
</div>
)}
{/* Draw Info */}
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4 text-gray-300">
Draw Information
</h2>
<div className="space-y-4">
<div>
<span className="text-gray-400">Draw Time:</span>
<div className="text-white">{formatDateTime(cycle.scheduled_at)}</div>
</div>
<div>
<span className="text-gray-400">Current Pot:</span>
<div className="text-2xl font-bold text-bitcoin-orange">
{cycle.pot_total_sats.toLocaleString()} sats
</div>
</div>
{cycle.status !== 'completed' && (
<div>
<span className="text-gray-400 block mb-2">Time Until Draw:</span>
<JackpotCountdown scheduledAt={cycle.scheduled_at} />
</div>
)}
</div>
</div>
{/* Results */}
{result.has_drawn && (
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<h2 className="text-xl font-semibold mb-4 text-gray-300">
Draw Results
</h2>
{result.is_winner ? (
<div>
<div className="bg-green-900/30 text-green-200 px-6 py-4 rounded-lg mb-4 text-center text-2xl font-bold">
🎉 {STRINGS.ticket.congratulations}
</div>
{result.payout && (
<PayoutStatus
status={result.payout.status}
amountSats={result.payout.amount_sats}
/>
)}
</div>
) : (
<div>
<div className="bg-gray-800 px-6 py-4 rounded-lg mb-4 text-center">
<div className="text-gray-400 mb-2">{STRINGS.ticket.betterLuck}</div>
{cycle.winning_ticket_id && (
<div className="text-gray-300">
{STRINGS.ticket.winningTicket}: <span className="font-bold text-bitcoin-orange">#{cycle.winning_ticket_id.substring(0, 8)}</span>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
);
}