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,190 @@
'use client';
import Link from 'next/link';
import STRINGS from '@/constants/strings';
export default function AboutPage() {
return (
<div className="max-w-3xl mx-auto">
<h1 className="text-4xl md:text-5xl font-bold mb-8 text-center text-white">
About {STRINGS.app.title}
</h1>
<div className="space-y-8">
{/* Introduction */}
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
<h2 className="text-2xl font-semibold mb-4 text-bitcoin-orange">
What is Lightning Lottery?
</h2>
<p className="text-gray-300 leading-relaxed">
Lightning Lottery is a provably fair lottery system built on the Bitcoin Lightning Network.
Players can purchase tickets using Lightning payments and winners receive instant payouts
directly to their Lightning address. No accounts required, no waiting for withdrawals.
</p>
</section>
{/* How It Works */}
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
<h2 className="text-2xl font-semibold mb-6 text-bitcoin-orange">
How It Works
</h2>
<div className="space-y-6">
<div className="flex gap-4">
<div className="flex-shrink-0 w-10 h-10 bg-bitcoin-orange rounded-full flex items-center justify-center text-white font-bold">
1
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-1">Buy Tickets</h3>
<p className="text-gray-400">
Enter your Lightning address and pay the invoice. Each ticket gives you a unique
randomly-generated number for the draw.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex-shrink-0 w-10 h-10 bg-bitcoin-orange rounded-full flex items-center justify-center text-white font-bold">
2
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-1">Wait for the Draw</h3>
<p className="text-gray-400">
Draws happen on a regular schedule. Watch the countdown and see the pot grow
as more players join.
</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex-shrink-0 w-10 h-10 bg-bitcoin-orange rounded-full flex items-center justify-center text-white font-bold">
3
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-1">Win Instantly</h3>
<p className="text-gray-400">
When the draw happens, a winning ticket is selected using cryptographically secure
random number generation. The winner receives the pot automatically via Lightning!
</p>
</div>
</div>
</div>
</section>
{/* Fairness */}
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
<h2 className="text-2xl font-semibold mb-4 text-bitcoin-orange">
Provably Fair
</h2>
<div className="space-y-4 text-gray-300">
<p>
Our lottery uses cryptographically secure random number generation (CSPRNG) from
Node.js's <code className="bg-gray-800 px-2 py-0.5 rounded text-bitcoin-orange">crypto.randomBytes()</code> module
to ensure completely unpredictable results.
</p>
<ul className="list-disc list-inside space-y-2 text-gray-400">
<li>Ticket numbers are randomly generated (not sequential)</li>
<li>Winner selection uses 8 bytes of cryptographic randomness</li>
<li>No one can predict or influence the outcome</li>
<li>All draws are logged and verifiable</li>
</ul>
</div>
</section>
{/* Technical Details */}
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
<h2 className="text-2xl font-semibold mb-4 text-bitcoin-orange">
Technical Details
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-lg font-semibold text-white mb-2">Payments</h3>
<ul className="text-gray-400 space-y-1 text-sm">
<li> Lightning Network (LNbits)</li>
<li> Instant confirmations</li>
<li> No minimum withdrawal</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-2">Security</h3>
<ul className="text-gray-400 space-y-1 text-sm">
<li> CSPRNG for all randomness</li>
<li> Nostr authentication (optional)</li>
<li> Open source codebase</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-2">Draws</h3>
<ul className="text-gray-400 space-y-1 text-sm">
<li> Automated on schedule</li>
<li> Instant winner notification</li>
<li> Automatic payout retry</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-2">Transparency</h3>
<ul className="text-gray-400 space-y-1 text-sm">
<li> Public winner history</li>
<li> Verifiable ticket numbers</li>
<li> Clear fee structure</li>
</ul>
</div>
</div>
</section>
{/* FAQ */}
<section className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
<h2 className="text-2xl font-semibold mb-6 text-bitcoin-orange">
Frequently Asked Questions
</h2>
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-white mb-2">
Do I need an account?
</h3>
<p className="text-gray-400">
No! You can buy tickets with just a Lightning address. Optionally, you can log in
with Nostr to track your tickets and auto-fill your details.
</p>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-2">
How do I receive my winnings?
</h3>
<p className="text-gray-400">
Winnings are sent automatically to the Lightning address you provided when buying
tickets. Make sure your address can receive payments!
</p>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-2">
What happens if payout fails?
</h3>
<p className="text-gray-400">
We automatically retry failed payouts. If it continues to fail after multiple attempts,
a new winner is drawn to ensure the pot is always paid out.
</p>
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-2">
What is the house fee?
</h3>
<p className="text-gray-400">
A small percentage of the pot goes to operating costs. The exact fee is shown before
each draw and the winner receives the pot after fees.
</p>
</div>
</div>
</section>
{/* CTA */}
<div className="text-center py-8">
<Link
href="/buy"
className="inline-block bg-bitcoin-orange hover:bg-orange-600 text-white px-12 py-4 rounded-lg text-xl font-bold transition-colors shadow-lg"
>
Buy Tickets Now
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,367 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { LightningInvoiceCard } from '@/components/LightningInvoiceCard';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import STRINGS from '@/constants/strings';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setUser } from '@/store/userSlice';
import { getAuthToken, hexToNpub, shortNpub } from '@/lib/nostr';
export default function BuyPage() {
const router = useRouter();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.user);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [jackpot, setJackpot] = useState<any>(null);
// Form state
const [lightningAddress, setLightningAddress] = useState('');
const [lightningAddressTouched, setLightningAddressTouched] = useState(false);
const [buyerName, setBuyerName] = useState('Anon');
const [buyerNameTouched, setBuyerNameTouched] = useState(false);
const [useNostrName, setUseNostrName] = useState(false);
const [tickets, setTickets] = useState(1);
// Invoice state
const [invoice, setInvoice] = useState<any>(null);
const [paymentStatus, setPaymentStatus] = useState<'idle' | 'waiting' | 'paid' | 'expired'>('idle');
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const expiryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const redirectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const profilePrefetchAttempted = useRef(false);
const [showPaidAnimation, setShowPaidAnimation] = useState(false);
const clearPolling = useCallback(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
if (expiryTimeoutRef.current) {
clearTimeout(expiryTimeoutRef.current);
expiryTimeoutRef.current = null;
}
}, []);
const clearVisualTimers = useCallback(() => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
animationTimeoutRef.current = null;
}
if (redirectTimeoutRef.current) {
clearTimeout(redirectTimeoutRef.current);
redirectTimeoutRef.current = null;
}
}, []);
useEffect(() => {
loadJackpot();
return () => {
clearPolling();
clearVisualTimers();
};
}, [clearPolling, clearVisualTimers]);
useEffect(() => {
if (user.authenticated || profilePrefetchAttempted.current) {
return;
}
const token = getAuthToken();
if (!token) {
return;
}
profilePrefetchAttempted.current = true;
api
.getProfile()
.then((response) => {
if (response.data?.user) {
const computedDisplayName =
response.data.user.display_name ||
(response.data.user.nostr_pubkey
? shortNpub(hexToNpub(response.data.user.nostr_pubkey))
: null);
dispatch(
setUser({
pubkey: response.data.user.nostr_pubkey,
lightning_address: response.data.user.lightning_address,
displayName: computedDisplayName,
token,
})
);
}
})
.catch(() => {
profilePrefetchAttempted.current = false;
});
}, [dispatch, user.authenticated]);
useEffect(() => {
if (user.lightning_address && !lightningAddressTouched) {
setLightningAddress(user.lightning_address);
}
}, [user.lightning_address, lightningAddressTouched]);
useEffect(() => {
if (useNostrName) {
if (user.displayName) {
setBuyerName(user.displayName);
} else if (user.pubkey) {
setBuyerName(shortNpub(hexToNpub(user.pubkey)));
} else {
setBuyerName('Anon');
}
} else if (!buyerNameTouched) {
setBuyerName('Anon');
}
}, [useNostrName, user.displayName, user.pubkey, buyerNameTouched]);
const loadJackpot = async () => {
try {
const response = await api.getNextJackpot();
if (response.data) {
setJackpot(response.data);
}
} catch (err: any) {
setError(err.message || 'Failed to load jackpot');
}
};
const handleLightningAddressChange = (value: string) => {
if (!lightningAddressTouched) {
setLightningAddressTouched(true);
}
setLightningAddress(value);
};
const handleBuyerNameChange = (value: string) => {
if (!buyerNameTouched) {
setBuyerNameTouched(true);
}
setUseNostrName(false);
setBuyerName(value);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const finalName = buyerName.trim() || 'Anon';
const response = await api.buyTickets(lightningAddress, tickets, finalName);
if (response.data) {
setInvoice(response.data);
setPaymentStatus('waiting');
setShowPaidAnimation(false);
clearVisualTimers();
startPolling(response.data.ticket_purchase_id);
}
} catch (err: any) {
setError(err.message || 'Failed to create purchase');
} finally {
setLoading(false);
}
};
const startPolling = (purchaseId?: string) => {
if (!purchaseId) {
console.error('Missing purchase ID for polling');
setError('Missing purchase identifier. Please try again.');
return;
}
clearPolling();
clearVisualTimers();
setShowPaidAnimation(false);
pollIntervalRef.current = setInterval(async () => {
try {
const response = await api.getTicketStatus(purchaseId);
if (response.data.purchase.invoice_status === 'paid') {
setPaymentStatus('paid');
clearPolling();
setShowPaidAnimation(true);
// Redirect after showing the paid animation
redirectTimeoutRef.current = setTimeout(() => {
router.push(`/tickets/${purchaseId}`);
}, 2500);
}
} catch (err) {
console.error('Polling error:', err);
}
}, 5000); // Poll every 5 seconds
// Stop polling after 20 minutes if still unpaid
expiryTimeoutRef.current = setTimeout(() => {
setPaymentStatus((prev) => {
if (prev === 'waiting') {
clearPolling();
return 'expired';
}
return prev;
});
}, 20 * 60 * 1000);
};
if (!jackpot) {
return <LoadingSpinner />;
}
const ticketPriceSats = jackpot.lottery.ticket_price_sats;
const totalCost = ticketPriceSats * tickets;
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-center text-white">
{STRINGS.buy.title}
</h1>
{!invoice ? (
/* Purchase Form */
<div className="bg-gray-900 rounded-xl p-8 border border-gray-800">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Lightning Address */}
<div>
<label className="block text-gray-300 mb-2 font-medium">
{STRINGS.buy.lightningAddress}
</label>
<input
type="text"
value={lightningAddress}
onChange={(e) => handleLightningAddressChange(e.target.value)}
placeholder={STRINGS.buy.lightningAddressPlaceholder}
required
className="w-full bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
/>
<p className="text-sm text-gray-400 mt-1">
Where to send your winnings
</p>
</div>
{/* Buyer Name */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-gray-300 font-medium">
{STRINGS.buy.buyerName}
</label>
{user.authenticated && (
<label className="flex items-center text-sm text-gray-400 space-x-2 cursor-pointer">
<input
type="checkbox"
checked={useNostrName}
onChange={(e) => setUseNostrName(e.target.checked)}
className="accent-bitcoin-orange"
/>
<span>{STRINGS.buy.useNostrName}</span>
</label>
)}
</div>
<input
type="text"
value={buyerName}
onChange={(e) => handleBuyerNameChange(e.target.value)}
placeholder={STRINGS.buy.buyerNamePlaceholder}
disabled={useNostrName}
maxLength={64}
className={`w-full bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange ${
useNostrName ? 'opacity-70 cursor-not-allowed' : ''
}`}
/>
<p className="text-sm text-gray-400 mt-1">
{STRINGS.buy.buyerNameHelp}
</p>
</div>
{/* Number of Tickets */}
<div>
<label className="block text-gray-300 mb-2 font-medium">
{STRINGS.buy.numberOfTickets}
</label>
<input
type="number"
value={tickets}
onChange={(e) => setTickets(Math.max(1, Math.min(100, parseInt(e.target.value) || 1)))}
min="1"
max="100"
required
className="w-full bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
/>
</div>
{/* Pricing Info */}
<div className="bg-gray-800 p-4 rounded-lg space-y-2">
<div className="flex justify-between text-gray-300">
<span>{STRINGS.buy.ticketPrice}</span>
<span className="font-mono">{ticketPriceSats.toLocaleString()} sats</span>
</div>
<div className="flex justify-between text-white font-bold text-lg">
<span>{STRINGS.buy.totalCost}</span>
<span className="font-mono">{totalCost.toLocaleString()} sats</span>
</div>
</div>
{error && (
<div className="bg-red-900/50 text-red-200 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-bitcoin-orange hover:bg-orange-600 disabled:bg-gray-600 text-white py-4 rounded-lg text-lg font-bold transition-colors"
>
{loading ? 'Creating Invoice...' : STRINGS.buy.createInvoice}
</button>
</form>
</div>
) : (
/* Invoice Display */
<div className="space-y-6">
{paymentStatus === 'paid' ? (
<div className="bg-green-900/50 text-green-200 px-6 py-4 rounded-lg text-center text-lg">
{STRINGS.buy.paymentReceived}
</div>
) : paymentStatus === 'expired' ? (
<div className="bg-red-900/50 text-red-200 px-6 py-4 rounded-lg text-center text-lg">
{STRINGS.buy.invoiceExpired}
</div>
) : (
<div className="bg-blue-900/50 text-blue-200 px-6 py-4 rounded-lg text-center">
{STRINGS.buy.waitingForPayment}
</div>
)}
<LightningInvoiceCard
paymentRequest={invoice.invoice.payment_request}
amountSats={invoice.invoice.amount_sats}
showPaidAnimation={showPaidAnimation}
/>
<div className="text-center">
<p className="text-gray-400 mb-2">{STRINGS.buy.paymentInstructions}</p>
<a
href={invoice.public_url}
className="text-bitcoin-orange hover:underline"
>
View ticket status page
</a>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import api from '@/lib/api';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import { hexToNpub, shortNpub, removeAuthToken } from '@/lib/nostr';
import STRINGS from '@/constants/strings';
import { logout } from '@/store/userSlice';
export default function DashboardPage() {
const router = useRouter();
const dispatch = useAppDispatch();
const user = useAppSelector((state) => state.user);
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!user.authenticated) {
router.push('/');
return;
}
loadProfile();
}, [user.authenticated]);
const loadProfile = async () => {
try {
setLoading(true);
const response = await api.getProfile();
if (response.data) {
setProfile(response.data);
}
} catch (err: any) {
setError(err.message || 'Failed to load profile');
} finally {
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>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-4 md:mb-0">
{STRINGS.dashboard.title}
</h1>
<button
onClick={() => {
removeAuthToken();
dispatch(logout());
router.push('/');
}}
className="self-start md:self-auto bg-gray-800 hover:bg-gray-700 text-white px-4 py-2 rounded-lg border border-gray-700 transition-colors"
>
Logout
</button>
</div>
{/* Profile Card */}
<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.dashboard.profile}
</h2>
<div className="space-y-3">
<div>
<span className="text-gray-400">Nostr Public Key:</span>
<div className="text-white font-mono">
{profile?.user?.nostr_pubkey ? shortNpub(hexToNpub(profile.user.nostr_pubkey)) : 'N/A'}
</div>
</div>
{profile?.user?.lightning_address && (
<div>
<span className="text-gray-400">Lightning Address:</span>
<div className="text-white">{profile.user.lightning_address}</div>
</div>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="text-gray-400 text-sm mb-2">
{STRINGS.dashboard.currentRoundTickets}
</div>
<div className="text-3xl font-bold text-bitcoin-orange">
{(profile?.stats?.current_round_tickets || 0).toLocaleString()}
</div>
</div>
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="text-gray-400 text-sm mb-2">
{STRINGS.dashboard.pastTickets}
</div>
<div className="text-3xl font-bold text-purple-400">
{(profile?.stats?.past_tickets || 0).toLocaleString()}
</div>
</div>
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="text-gray-400 text-sm mb-2">
{STRINGS.dashboard.totalWins}
</div>
<div className="text-3xl font-bold text-green-500">
{(profile?.stats?.total_wins || 0).toLocaleString()}
</div>
</div>
<div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
<div className="text-gray-400 text-sm mb-2">
{STRINGS.dashboard.totalWinnings}
</div>
<div className="text-3xl font-bold text-green-500">
{(profile?.stats?.total_winnings_sats || 0).toLocaleString()}
<span className="text-lg text-gray-400 ml-1">sats</span>
</div>
</div>
</div>
{/* Quick Links */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link
href="/dashboard/tickets"
className="bg-gray-900 hover:bg-gray-800 rounded-xl p-6 border border-gray-800 transition-colors"
>
<div className="text-2xl mb-2">🎫</div>
<h3 className="text-lg font-semibold text-white mb-1">
{STRINGS.dashboard.tickets}
</h3>
<p className="text-gray-400 text-sm">View your ticket purchase history</p>
</Link>
<Link
href="/dashboard/wins"
className="bg-gray-900 hover:bg-gray-800 rounded-xl p-6 border border-gray-800 transition-colors"
>
<div className="text-2xl mb-2">🏆</div>
<h3 className="text-lg font-semibold text-white mb-1">
{STRINGS.dashboard.wins}
</h3>
<p className="text-gray-400 text-sm">Check your wins and payouts</p>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAppSelector } from '@/store/hooks';
import api from '@/lib/api';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import { relativeTime } from '@/lib/format';
import STRINGS from '@/constants/strings';
export default function DashboardTicketsPage() {
const router = useRouter();
const user = useAppSelector((state) => state.user);
const [loading, setLoading] = useState(true);
const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!user.authenticated) {
router.push('/');
return;
}
loadTickets();
}, [user.authenticated]);
const loadTickets = async () => {
try {
setLoading(true);
const response = await api.getUserTickets();
if (response.data) {
setTickets(response.data.purchases || []);
}
} catch (err: any) {
setError(err.message || 'Failed to load tickets');
} finally {
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>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<Link
href="/dashboard"
className="inline-flex items-center text-sm text-gray-400 hover:text-white mb-4 transition-colors"
>
<span className="mr-2"></span>
{STRINGS.dashboard.backToDashboard}
</Link>
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-white">
{STRINGS.dashboard.tickets}
</h1>
{tickets.length === 0 ? (
<div className="bg-gray-900 rounded-xl p-12 border border-gray-800 text-center">
<div className="text-4xl mb-4">🎫</div>
<div className="text-xl text-gray-400 mb-4">{STRINGS.empty.noTickets}</div>
<Link
href="/buy"
className="inline-block bg-bitcoin-orange hover:bg-orange-600 text-white px-6 py-3 rounded-lg font-medium transition-colors"
>
{STRINGS.empty.buyNow}
</Link>
</div>
) : (
<div className="space-y-4">
{tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/tickets/${ticket.id}`}
className="block bg-gray-900 hover:bg-gray-800 rounded-xl p-6 border border-gray-800 transition-colors"
>
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<div className="text-gray-400 text-sm mb-1">
{relativeTime(ticket.created_at)}
</div>
<div className="text-white font-mono text-sm">
{ticket.id.substring(0, 16)}...
</div>
</div>
<div className={`
px-3 py-1 rounded-full text-sm font-medium
${ticket.invoice_status === 'paid' ? 'bg-green-900/30 text-green-300' : 'bg-yellow-900/30 text-yellow-300'}
`}>
{ticket.invoice_status}
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-400">Tickets:</span>
<span className="text-white ml-2">{ticket.number_of_tickets}</span>
</div>
<div>
<span className="text-gray-400">Amount:</span>
<span className="text-white ml-2">{ticket.amount_sats.toLocaleString()} sats</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAppSelector } from '@/store/hooks';
import api from '@/lib/api';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import { formatDateTime } from '@/lib/format';
import STRINGS from '@/constants/strings';
export default function DashboardWinsPage() {
const router = useRouter();
const user = useAppSelector((state) => state.user);
const [loading, setLoading] = useState(true);
const [wins, setWins] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!user.authenticated) {
router.push('/');
return;
}
loadWins();
}, [user.authenticated]);
const loadWins = async () => {
try {
setLoading(true);
const response = await api.getUserWins();
if (response.data) {
setWins(response.data.wins || []);
}
} catch (err: any) {
setError(err.message || 'Failed to load wins');
} finally {
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>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<Link
href="/dashboard"
className="inline-flex items-center text-sm text-gray-400 hover:text-white mb-4 transition-colors"
>
<span className="mr-2"></span>
{STRINGS.dashboard.backToDashboard}
</Link>
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-white">
{STRINGS.dashboard.wins}
</h1>
{wins.length === 0 ? (
<div className="bg-gray-900 rounded-xl p-12 border border-gray-800 text-center">
<div className="text-4xl mb-4">🏆</div>
<div className="text-xl text-gray-400">{STRINGS.empty.noWins}</div>
</div>
) : (
<div className="space-y-4">
{wins.map((win) => (
<div
key={win.id}
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
>
<div className="flex justify-between items-start mb-4">
<div>
<div className="text-2xl font-bold text-green-500 mb-1">
🎉 {win.amount_sats.toLocaleString()} sats
</div>
<div className="text-gray-400 text-sm">
{formatDateTime(win.created_at)}
</div>
</div>
<div className={`
px-3 py-1 rounded-full text-sm font-medium
${
win.status === 'paid'
? 'bg-green-900/30 text-green-300'
: win.status === 'pending'
? 'bg-yellow-900/30 text-yellow-300'
: 'bg-red-900/30 text-red-300'
}
`}>
{win.status}
</div>
</div>
<div className="text-sm text-gray-400">
Cycle ID: {win.cycle_id.substring(0, 16)}...
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 218, 218, 218;
--background-rgb: 11, 11, 11;
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.animate-fade-in {
animation: fade-in 0.5s ease-out forwards;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #0b0b0b;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}

View File

@@ -0,0 +1,36 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';
import { TopBar } from '@/components/TopBar';
import { Footer } from '@/components/Footer';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Lightning Lottery - Win Bitcoin',
description: 'Bitcoin Lightning Network powered lottery with instant payouts',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>
<div className="min-h-screen flex flex-col bg-black text-gray-200">
<TopBar />
<main className="flex-grow container mx-auto px-4 py-8">
{children}
</main>
<Footer />
</div>
</Providers>
</body>
</html>
);
}

293
front_end/src/app/page.tsx Normal file
View File

@@ -0,0 +1,293 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import api from '@/lib/api';
import { JackpotCountdown } from '@/components/JackpotCountdown';
import { JackpotPotDisplay } from '@/components/JackpotPotDisplay';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import { DrawAnimation } from '@/components/DrawAnimation';
import STRINGS from '@/constants/strings';
interface RecentWinner {
id: string;
winner_name: string;
winner_lightning_address: string;
winning_ticket_serial: number;
pot_after_fee_sats: number;
scheduled_at: string;
}
export default function HomePage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [jackpot, setJackpot] = useState<any>(null);
const [ticketId, setTicketId] = useState('');
const [recentWinner, setRecentWinner] = useState<RecentWinner | null>(null);
const [showDrawAnimation, setShowDrawAnimation] = useState(false);
const [drawJustCompleted, setDrawJustCompleted] = useState(false);
const [winnerBannerDismissed, setWinnerBannerDismissed] = useState(false);
const [isRecentWin, setIsRecentWin] = useState(false);
const loadJackpot = useCallback(async () => {
try {
setLoading(true);
const response = await api.getNextJackpot();
if (response.data) {
setJackpot(response.data);
}
} catch (err: any) {
setError(err.message || 'Failed to load jackpot');
} finally {
setLoading(false);
}
}, []);
const loadRecentWinner = useCallback(async () => {
try {
const response = await api.getPastWins(1, 0);
if (response.data?.wins?.length > 0) {
const latestWin = response.data.wins[0];
const winTime = new Date(latestWin.scheduled_at).getTime();
const now = Date.now();
const sixtySeconds = 60 * 1000;
setRecentWinner(latestWin);
// Check if this is a recent win (within 60 seconds)
const isRecent = now - winTime < sixtySeconds;
setIsRecentWin(isRecent);
// If draw completed within last 60 seconds, show animation
if (isRecent && !drawJustCompleted) {
setShowDrawAnimation(true);
setDrawJustCompleted(true);
setWinnerBannerDismissed(false);
}
}
} catch (err) {
console.error('Failed to load recent winner:', err);
}
}, [drawJustCompleted]);
useEffect(() => {
loadJackpot();
loadRecentWinner();
}, [loadJackpot, loadRecentWinner]);
// Poll for draw completion when countdown reaches zero
useEffect(() => {
if (!jackpot?.cycle?.scheduled_at) return;
const checkForDraw = () => {
const scheduledTime = new Date(jackpot.cycle.scheduled_at).getTime();
const now = Date.now();
// If we're past the scheduled time, start polling for the winner
if (now >= scheduledTime && !drawJustCompleted) {
loadRecentWinner();
}
};
const interval = setInterval(checkForDraw, 5000);
return () => clearInterval(interval);
}, [jackpot?.cycle?.scheduled_at, drawJustCompleted, loadRecentWinner]);
const handleCheckTicket = () => {
if (ticketId.trim()) {
router.push(`/tickets/${ticketId.trim()}`);
}
};
const handleAnimationComplete = () => {
setShowDrawAnimation(false);
};
const handlePlayAgain = () => {
setDrawJustCompleted(false);
setWinnerBannerDismissed(true);
setIsRecentWin(false);
loadJackpot();
loadRecentWinner();
};
const handleDismissWinnerBanner = () => {
setWinnerBannerDismissed(true);
};
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={loadJackpot}
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-6 py-2 rounded-lg"
>
Retry
</button>
</div>
);
}
if (!jackpot) {
return (
<div className="text-center py-12 text-gray-400">
No active jackpot available
</div>
);
}
// Only show winner banner if: recent win (within 60s), not dismissed, and animation not showing
const showWinnerBanner = isRecentWin && recentWinner && !showDrawAnimation && !winnerBannerDismissed;
return (
<div className="max-w-4xl mx-auto">
{/* Draw Animation Overlay */}
{showDrawAnimation && recentWinner && (
<DrawAnimation
winnerName={recentWinner.winner_name}
winningTicket={recentWinner.winning_ticket_serial}
potAmount={recentWinner.pot_after_fee_sats}
onComplete={handleAnimationComplete}
/>
)}
{/* Hero Section */}
<div className="text-center mb-12">
<h1 className="text-4xl md:text-6xl font-bold mb-4 text-white">
{STRINGS.app.title}
</h1>
<p className="text-xl text-gray-400">{STRINGS.app.tagline}</p>
</div>
{/* Recent Winner Banner - Only shown for 60 seconds after draw */}
{showWinnerBanner && (
<div className="bg-gradient-to-r from-yellow-900/40 via-yellow-800/30 to-yellow-900/40 border border-yellow-600/50 rounded-2xl p-6 mb-8 animate-fade-in relative">
{/* Close button */}
<button
onClick={handleDismissWinnerBanner}
className="absolute top-3 right-3 text-yellow-400/60 hover:text-yellow-400 transition-colors p-1"
aria-label="Dismiss"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="text-center">
<div className="text-yellow-400 text-sm uppercase tracking-wider mb-2">
🏆 Latest Winner
</div>
<div className="text-2xl md:text-3xl font-bold text-white mb-2">
{recentWinner.winner_name || 'Anon'}
</div>
<div className="text-yellow-400 text-xl font-mono mb-3">
Won {recentWinner.pot_after_fee_sats.toLocaleString()} sats
</div>
<div className="text-gray-400 text-sm">
Ticket #{recentWinner.winning_ticket_serial.toLocaleString()}
</div>
</div>
</div>
)}
{/* Current Jackpot Card */}
<div className="bg-gray-900 rounded-2xl p-8 md:p-12 mb-8 border border-gray-800">
<h2 className="text-2xl font-semibold text-center mb-6 text-gray-300">
{STRINGS.home.currentJackpot}
</h2>
{/* Pot Display */}
<div className="mb-8">
<JackpotPotDisplay potTotalSats={jackpot.cycle.pot_total_sats} />
</div>
{/* Countdown */}
<div className="mb-8">
<div className="text-center text-gray-400 mb-4">
{STRINGS.home.drawIn}
</div>
<div className="flex justify-center">
<JackpotCountdown scheduledAt={jackpot.cycle.scheduled_at} />
</div>
</div>
{/* Ticket Price */}
<div className="text-center text-gray-400 mb-8">
Ticket Price: {jackpot.lottery.ticket_price_sats.toLocaleString()} sats
</div>
{/* Buy Button - Show Refresh only after draw */}
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Link
href="/buy"
className="bg-bitcoin-orange hover:bg-orange-600 text-white px-12 py-4 rounded-lg text-xl font-bold transition-colors shadow-lg text-center"
>
{STRINGS.home.buyTickets}
</Link>
{drawJustCompleted && (
<button
onClick={handlePlayAgain}
className="bg-gray-700 hover:bg-gray-600 text-white px-8 py-4 rounded-lg text-lg font-medium transition-colors flex items-center justify-center gap-2"
>
<span>🔄</span> Refresh
</button>
)}
</div>
</div>
{/* Check Ticket Section */}
<div className="bg-gray-900 rounded-2xl p-8 border border-gray-800">
<h3 className="text-xl font-semibold text-center mb-4 text-gray-300">
{STRINGS.home.checkTicket}
</h3>
<div className="flex flex-col sm:flex-row gap-3">
<input
type="text"
value={ticketId}
onChange={(e) => setTicketId(e.target.value)}
placeholder={STRINGS.home.ticketIdPlaceholder}
className="flex-1 bg-gray-800 text-white px-4 py-3 rounded-lg focus:outline-none focus:ring-2 focus:ring-bitcoin-orange"
onKeyPress={(e) => e.key === 'Enter' && handleCheckTicket()}
/>
<button
onClick={handleCheckTicket}
className="bg-gray-700 hover:bg-gray-600 text-white px-8 py-3 rounded-lg font-medium transition-colors"
>
Check Status
</button>
</div>
</div>
{/* Info Section */}
<div className="mt-12 grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
<div className="text-4xl mb-3"></div>
<h4 className="text-lg font-semibold mb-2 text-white">Instant</h4>
<p className="text-gray-400 text-sm">
Lightning-fast ticket purchases and payouts
</p>
</div>
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
<div className="text-4xl mb-3">🔒</div>
<h4 className="text-lg font-semibold mb-2 text-white">Secure</h4>
<p className="text-gray-400 text-sm">
Cryptographically secure random number generation
</p>
</div>
<div className="bg-gray-900 p-6 rounded-lg border border-gray-800">
<div className="text-4xl mb-3">🎯</div>
<h4 className="text-lg font-semibold mb-2 text-white">Fair</h4>
<p className="text-gray-400 text-sm">
Transparent draws with verifiable results
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import { useEffect, useState } from 'react';
import api from '@/lib/api';
import { LoadingSpinner } from '@/components/LoadingSpinner';
import STRINGS from '@/constants/strings';
import { formatDateTime } from '@/lib/format';
interface PastWin {
cycle_id: string;
cycle_type: string;
scheduled_at: string;
pot_total_sats: number;
pot_after_fee_sats: number | null;
winner_name: string;
winning_ticket_serial: number | null;
}
export default function PastWinsPage() {
const [wins, setWins] = useState<PastWin[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadWins = async () => {
try {
const response = await api.getPastWins();
setWins(response.data?.wins || []);
} catch (err: any) {
setError(err.message || 'Failed to load past wins');
} finally {
setLoading(false);
}
};
loadWins();
}, []);
if (loading) {
return <LoadingSpinner />;
}
if (error) {
return (
<div className="max-w-3xl mx-auto text-center py-12">
<div className="text-red-500 text-xl mb-2"> {error}</div>
<p className="text-gray-400">Please try again in a moment.</p>
</div>
);
}
return (
<div className="max-w-5xl mx-auto">
<div className="text-center mb-10">
<h1 className="text-3xl md:text-4xl font-bold text-white mb-3">
{STRINGS.pastWins.title}
</h1>
<p className="text-gray-400">{STRINGS.pastWins.description}</p>
</div>
{wins.length === 0 ? (
<div className="bg-gray-900 rounded-xl p-12 text-center border border-gray-800">
<div className="text-4xl mb-4"></div>
<div className="text-gray-300 text-lg">{STRINGS.pastWins.noWins}</div>
</div>
) : (
<div className="space-y-4">
{wins.map((win) => (
<div
key={win.cycle_id}
className="bg-gray-900 rounded-xl p-6 border border-gray-800"
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-4">
<div>
<div className="text-sm uppercase tracking-wide text-gray-500">
{win.cycle_type} {formatDateTime(win.scheduled_at)}
</div>
<div className="text-white font-mono text-sm">
{win.cycle_id.substring(0, 16)}...
</div>
</div>
<div className="mt-4 md:mt-0 text-right">
<div className="text-gray-400 text-sm">{STRINGS.pastWins.pot}</div>
<div className="text-2xl font-bold text-bitcoin-orange">
{win.pot_total_sats.toLocaleString()} sats
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div>
<div className="text-gray-400 mb-1">{STRINGS.pastWins.winner}</div>
<div className="text-white font-semibold">
{win.winner_name || 'Anon'}
</div>
</div>
<div>
<div className="text-gray-400 mb-1">{STRINGS.pastWins.ticket}</div>
<div className="text-white">
{win.winning_ticket_serial !== null
? `#${win.winning_ticket_serial}`
: 'N/A'}
</div>
</div>
<div>
<div className="text-gray-400 mb-1">{STRINGS.pastWins.drawTime}</div>
<div className="text-white">{formatDateTime(win.scheduled_at)}</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,9 @@
'use client';
import { Provider } from 'react-redux';
import { store } from '@/store';
export function Providers({ children }: { children: React.ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}

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>
);
}