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

View File

@@ -0,0 +1,214 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
interface DrawAnimationProps {
winnerName: string;
winningTicket: number;
potAmount: number;
onComplete: () => void;
}
export function DrawAnimation({
winnerName,
winningTicket,
potAmount,
onComplete,
}: DrawAnimationProps) {
const [phase, setPhase] = useState<'spinning' | 'revealing' | 'winner' | 'done'>('spinning');
const [displayTicket, setDisplayTicket] = useState(0);
const [showConfetti, setShowConfetti] = useState(false);
// Generate random ticket numbers during spin
useEffect(() => {
if (phase !== 'spinning') return;
const spinInterval = setInterval(() => {
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
}, 50);
// After 2.5 seconds, start revealing
const revealTimeout = setTimeout(() => {
setPhase('revealing');
}, 2500);
return () => {
clearInterval(spinInterval);
clearTimeout(revealTimeout);
};
}, [phase]);
// Slow down and reveal actual number
useEffect(() => {
if (phase !== 'revealing') return;
let speed = 50;
let iterations = 0;
const maxIterations = 15;
const slowDown = () => {
if (iterations >= maxIterations) {
setDisplayTicket(winningTicket);
setPhase('winner');
return;
}
speed += 30;
iterations++;
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
setTimeout(slowDown, speed);
};
slowDown();
}, [phase, winningTicket]);
// Show winner and confetti
useEffect(() => {
if (phase !== 'winner') return;
setShowConfetti(true);
// Auto-dismiss after 6 seconds
const dismissTimeout = setTimeout(() => {
setPhase('done');
onComplete();
}, 6000);
return () => clearTimeout(dismissTimeout);
}, [phase, onComplete]);
const handleDismiss = useCallback(() => {
setPhase('done');
onComplete();
}, [onComplete]);
if (phase === 'done') return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
onClick={phase === 'winner' ? handleDismiss : undefined}
>
{/* Confetti Effect */}
{showConfetti && (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{[...Array(50)].map((_, i) => (
<div
key={i}
className="confetti-piece"
style={{
left: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 2}s`,
backgroundColor: ['#f7931a', '#ffd700', '#ff6b6b', '#4ecdc4', '#45b7d1'][
Math.floor(Math.random() * 5)
],
}}
/>
))}
</div>
)}
<div className="text-center px-6 max-w-lg">
{/* Spinning Phase */}
{(phase === 'spinning' || phase === 'revealing') && (
<>
<div className="text-2xl text-yellow-400 mb-4 animate-pulse">
🎰 Drawing Winner...
</div>
<div className="bg-gray-900 rounded-2xl p-8 border-2 border-yellow-500/50 shadow-2xl shadow-yellow-500/20">
<div className="text-gray-400 text-sm mb-2">Ticket Number</div>
<div
className={`text-5xl md:text-6xl font-mono font-bold text-bitcoin-orange ${
phase === 'spinning' ? 'animate-number-spin' : ''
}`}
>
#{displayTicket.toLocaleString()}
</div>
</div>
</>
)}
{/* Winner Phase */}
{phase === 'winner' && (
<div className="animate-winner-reveal">
<div className="text-4xl mb-4">🎉🏆🎉</div>
<div className="text-3xl md:text-4xl font-bold text-yellow-400 mb-6">
We Have a Winner!
</div>
<div className="bg-gradient-to-br from-yellow-900/60 to-orange-900/60 rounded-2xl p-8 border-2 border-yellow-500 shadow-2xl shadow-yellow-500/30">
<div className="text-gray-300 text-sm mb-1">Winner</div>
<div className="text-3xl md:text-4xl font-bold text-white mb-4">
{winnerName || 'Anon'}
</div>
<div className="text-gray-300 text-sm mb-1">Winning Ticket</div>
<div className="text-2xl font-mono text-bitcoin-orange mb-4">
#{winningTicket.toLocaleString()}
</div>
<div className="text-gray-300 text-sm mb-1">Prize</div>
<div className="text-4xl md:text-5xl font-bold text-green-400">
{potAmount.toLocaleString()} sats
</div>
</div>
<div className="mt-6 text-gray-400 text-sm animate-pulse">
Click anywhere to continue
</div>
</div>
)}
</div>
<style jsx>{`
.confetti-piece {
position: absolute;
width: 10px;
height: 10px;
top: -10px;
animation: confetti-fall 4s ease-out forwards;
}
@keyframes confetti-fall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
.animate-number-spin {
animation: number-glow 0.1s ease-in-out infinite alternate;
}
@keyframes number-glow {
from {
text-shadow: 0 0 10px rgba(247, 147, 26, 0.5);
}
to {
text-shadow: 0 0 20px rgba(247, 147, 26, 0.8);
}
}
.animate-winner-reveal {
animation: winner-pop 0.5s ease-out forwards;
}
@keyframes winner-pop {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
opacity: 1;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import Link from 'next/link';
export function Footer() {
return (
<footer className="bg-gray-900 border-t border-gray-800 py-8 mt-12">
<div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="text-gray-400 text-sm mb-4 md:mb-0">
© 2025 Lightning Lottery. Powered by Bitcoin Lightning Network.
</div>
<div className="flex space-x-6">
<Link
href="/about"
className="text-gray-400 hover:text-white transition-colors"
>
About
</Link>
<Link
href="/past-wins"
className="text-gray-400 hover:text-white transition-colors"
>
Past Winners
</Link>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useEffect, useState } from 'react';
import { formatCountdown } from '@/lib/format';
interface JackpotCountdownProps {
scheduledAt: string;
}
export function JackpotCountdown({ scheduledAt }: JackpotCountdownProps) {
const [countdown, setCountdown] = useState(formatCountdown(scheduledAt));
useEffect(() => {
const interval = setInterval(() => {
setCountdown(formatCountdown(scheduledAt));
}, 1000);
return () => clearInterval(interval);
}, [scheduledAt]);
if (countdown.total <= 0) {
return <div className="text-2xl font-bold text-yellow-500">Drawing Now!</div>;
}
return (
<div className="flex space-x-4" role="timer" aria-live="polite">
{countdown.days > 0 && (
<div className="flex flex-col items-center">
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
{countdown.days}
</div>
<div className="text-sm text-gray-400">days</div>
</div>
)}
<div className="flex flex-col items-center">
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
{countdown.hours.toString().padStart(2, '0')}
</div>
<div className="text-sm text-gray-400">hours</div>
</div>
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
<div className="flex flex-col items-center">
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
{countdown.minutes.toString().padStart(2, '0')}
</div>
<div className="text-sm text-gray-400">minutes</div>
</div>
<div className="text-4xl md:text-5xl font-bold text-gray-500">:</div>
<div className="flex flex-col items-center">
<div className="text-4xl md:text-5xl font-bold text-bitcoin-orange">
{countdown.seconds.toString().padStart(2, '0')}
</div>
<div className="text-sm text-gray-400">seconds</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { formatSats, satsToBTC } from '@/lib/format';
interface JackpotPotDisplayProps {
potTotalSats: number;
}
export function JackpotPotDisplay({ potTotalSats }: JackpotPotDisplayProps) {
return (
<div className="text-center">
<div className="text-5xl md:text-7xl font-bold text-bitcoin-orange mb-2">
{formatSats(potTotalSats)}
<span className="text-3xl md:text-4xl ml-2 text-gray-400">sats</span>
</div>
<div className="text-xl md:text-2xl text-gray-400">
{satsToBTC(potTotalSats)} BTC
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
interface LightningInvoiceCardProps {
paymentRequest: string;
amountSats: number;
showPaidAnimation?: boolean;
}
export function LightningInvoiceCard({
paymentRequest,
amountSats,
showPaidAnimation = false,
}: LightningInvoiceCardProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(paymentRequest);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className="bg-white p-6 rounded-lg shadow-lg relative overflow-hidden">
{/* QR Code Container */}
<div className="flex justify-center mb-4 relative">
<div
className={`transition-all duration-700 ease-out ${
showPaidAnimation ? 'scale-95 opacity-80' : 'scale-100 opacity-100'
}`}
>
<QRCodeSVG
value={paymentRequest.toUpperCase()}
size={260}
level="M"
includeMargin={true}
/>
</div>
{/* Paid Overlay - Smooth Green Circle with Checkmark */}
<div
className={`absolute inset-0 flex items-center justify-center transition-all duration-500 ease-out ${
showPaidAnimation
? 'opacity-100 scale-100'
: 'opacity-0 scale-50 pointer-events-none'
}`}
>
<div className="paid-badge">
<svg
className="checkmark-svg"
viewBox="0 0 52 52"
width="64"
height="64"
>
<circle
className="checkmark-circle"
cx="26"
cy="26"
r="24"
fill="none"
stroke="#22c55e"
strokeWidth="3"
/>
<path
className="checkmark-check"
fill="none"
stroke="#22c55e"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
d="M14 27l8 8 16-16"
/>
</svg>
</div>
</div>
</div>
{/* Paid Status Text */}
<div
className={`text-center mb-4 transition-all duration-500 ${
showPaidAnimation ? 'opacity-100' : 'opacity-0 h-0 overflow-hidden'
}`}
>
<div className="text-green-600 font-bold text-lg">Payment Received!</div>
</div>
{/* Amount */}
<div className="text-center mb-4">
<div className="text-2xl font-bold text-gray-900">
{amountSats.toLocaleString()} sats
</div>
</div>
{/* Invoice */}
<div className="mb-4">
<div className="bg-gray-100 p-3 rounded text-xs break-all text-gray-700 max-h-24 overflow-y-auto">
{paymentRequest}
</div>
</div>
{/* Copy Button */}
<button
onClick={handleCopy}
disabled={showPaidAnimation}
className={`w-full py-3 rounded-lg font-medium transition-all duration-300 ${
showPaidAnimation
? 'bg-green-500 text-white cursor-default'
: 'bg-bitcoin-orange hover:bg-orange-600 text-white'
}`}
>
{showPaidAnimation ? '✓ Paid' : copied ? '✓ Copied!' : '📋 Copy Invoice'}
</button>
<style jsx>{`
.paid-badge {
background: rgba(255, 255, 255, 0.95);
border-radius: 50%;
padding: 1.5rem;
box-shadow: 0 10px 40px rgba(34, 197, 94, 0.4);
}
.checkmark-circle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
animation: circle-draw 0.6s ease-out forwards;
}
.checkmark-check {
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: check-draw 0.4s ease-out 0.4s forwards;
}
@keyframes circle-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes check-draw {
to {
stroke-dashoffset: 0;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import STRINGS from '@/constants/strings';
export function LoadingSpinner() {
return (
<div className="flex flex-col items-center justify-center py-12">
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-bitcoin-orange"></div>
<div className="text-gray-400 mt-4">{STRINGS.loading}</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { useState } from 'react';
import { useAppDispatch } from '@/store/hooks';
import { setUser } from '@/store/userSlice';
import { getNostrPublicKey, signNostrMessage, storeAuthToken, shortNpub, hexToNpub } from '@/lib/nostr';
import api from '@/lib/api';
export function NostrLoginButton() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const dispatch = useAppDispatch();
const handleLogin = async () => {
setLoading(true);
setError(null);
try {
// Get Nostr public key
const pubkey = await getNostrPublicKey();
// Generate nonce
const nonce = Math.random().toString(36).substring(7);
// Sign message
const signature = await signNostrMessage(nonce);
// Authenticate with backend
const response = await api.nostrAuth(pubkey, signature, nonce);
if (response.data && response.data.token) {
// Store token
storeAuthToken(response.data.token);
const displayName =
response.data.user.display_name ||
shortNpub(hexToNpub(response.data.user.nostr_pubkey));
// Update Redux state
dispatch(
setUser({
pubkey: response.data.user.nostr_pubkey,
lightning_address: response.data.user.lightning_address,
token: response.data.token,
displayName,
})
);
}
} catch (err: any) {
console.error('Nostr login error:', err);
setError(err.message || 'Failed to login with Nostr');
} finally {
setLoading(false);
}
};
return (
<div>
<button
onClick={handleLogin}
disabled={loading}
className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors font-medium"
>
{loading ? 'Connecting...' : '🔐 Login with Nostr'}
</button>
{error && (
<div className="text-red-500 text-sm mt-2">{error}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import STRINGS from '@/constants/strings';
interface PayoutStatusProps {
status: 'pending' | 'paid' | 'failed' | null;
amountSats?: number;
}
export function PayoutStatus({ status, amountSats }: PayoutStatusProps) {
if (!status) {
return null;
}
const getStatusDisplay = () => {
switch (status) {
case 'pending':
return {
text: STRINGS.payout.pending,
color: 'text-yellow-500',
icon: '⏳',
};
case 'paid':
return {
text: STRINGS.payout.paid,
color: 'text-green-500',
icon: '✓',
};
case 'failed':
return {
text: STRINGS.payout.failed,
color: 'text-red-500',
icon: '⚠️',
};
}
};
const display = getStatusDisplay();
return (
<div className="bg-gray-800 p-6 rounded-lg">
<div className="text-lg font-semibold text-gray-300 mb-2">
{STRINGS.ticket.payoutStatus}
</div>
<div className={`text-2xl font-bold ${display.color} flex items-center space-x-2`}>
<span>{display.icon}</span>
<span>{display.text}</span>
</div>
{amountSats && (
<div className="text-gray-400 mt-2">
Amount: {amountSats.toLocaleString()} sats
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
interface Ticket {
id: string;
serial_number: number;
is_winning_ticket: boolean;
}
interface TicketListProps {
tickets: Ticket[];
}
export function TicketList({ tickets }: TicketListProps) {
if (tickets.length === 0) {
return (
<div className="text-center text-gray-400 py-8">
No tickets issued yet
</div>
);
}
return (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-3">
{tickets.map((ticket) => (
<div
key={ticket.id}
className={`
p-4 rounded-lg text-center font-bold text-lg
${
ticket.is_winning_ticket
? 'bg-green-600 text-white ring-4 ring-green-400'
: 'bg-gray-800 text-gray-300'
}
`}
>
#{ticket.serial_number}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,62 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useAppSelector } from '@/store/hooks';
import { NostrLoginButton } from './NostrLoginButton';
import { shortNpub, hexToNpub } from '@/lib/nostr';
import STRINGS from '@/constants/strings';
export function TopBar() {
const user = useAppSelector((state) => state.user);
return (
<nav className="bg-gray-900 border-b border-gray-800">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<span className="text-2xl"></span>
<span className="text-xl font-bold text-bitcoin-orange">
{STRINGS.app.title}
</span>
</Link>
{/* Navigation */}
<div className="flex items-center space-x-6">
<Link
href="/"
className="text-gray-300 hover:text-white transition-colors"
>
Home
</Link>
<Link
href="/buy"
className="text-gray-300 hover:text-white transition-colors"
>
Buy Tickets
</Link>
<Link
href="/past-wins"
className="text-gray-300 hover:text-white transition-colors"
>
Past Winners
</Link>
{user.authenticated ? (
<Link
href="/dashboard"
className="text-gray-300 hover:text-white transition-colors"
>
Dashboard
</Link>
) : (
<NostrLoginButton />
)}
</div>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,7 @@
export const config = {
apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000',
appBaseUrl: process.env.NEXT_PUBLIC_APP_BASE_URL || 'http://localhost:3001',
};
export default config;

View File

@@ -0,0 +1,96 @@
// All user-facing text strings
export const STRINGS = {
app: {
title: 'Lightning Lottery',
tagline: 'Win Bitcoin on the Lightning Network',
},
home: {
currentJackpot: 'Current Jackpot',
drawIn: 'Draw In',
buyTickets: 'Buy Tickets',
checkTicket: 'Check My Ticket',
ticketIdPlaceholder: 'Enter ticket ID',
},
buy: {
title: 'Buy Lottery Tickets',
lightningAddress: 'Lightning Address',
lightningAddressPlaceholder: 'you@getalby.com',
buyerName: 'Display Name (optional)',
buyerNamePlaceholder: 'Anon',
buyerNameHelp: 'Shown on the public winners board. Leave blank to appear as Anon.',
useNostrName: 'Use my Nostr name',
numberOfTickets: 'Number of Tickets',
ticketPrice: 'Ticket Price',
totalCost: 'Total Cost',
createInvoice: 'Create Invoice',
paymentInstructions: 'Scan QR code or copy invoice to pay',
copyInvoice: 'Copy Invoice',
waitingForPayment: 'Waiting for payment...',
paymentReceived: 'Payment Received!',
invoiceExpired: 'Invoice Expired',
},
ticket: {
title: 'Ticket Status',
purchaseId: 'Purchase ID',
status: 'Status',
ticketNumbers: 'Your Ticket Numbers',
drawTime: 'Draw Time',
currentPot: 'Current Pot',
waiting: 'Waiting for payment...',
issued: 'Tickets Issued',
drawPending: 'Draw pending...',
congratulations: 'Congratulations! You Won!',
winningTicket: 'Winning Ticket',
betterLuck: 'Better luck next time!',
payoutStatus: 'Payout Status',
},
payout: {
pending: 'Payout pending...',
paid: 'Paid! 🎉',
failed: 'Payout failed - contact support',
},
dashboard: {
title: 'My Dashboard',
profile: 'Profile',
tickets: 'My Tickets',
wins: 'My Wins',
stats: 'Statistics',
totalTickets: 'Total Tickets',
currentRoundTickets: 'Current Round Tickets',
pastTickets: 'Past Tickets (Completed Rounds)',
totalWins: 'Total Wins',
totalWinnings: 'Total Winnings',
backToDashboard: 'Back to Dashboard',
},
pastWins: {
title: 'Past Winners',
description: 'Recent jackpots and their champions.',
noWins: 'No completed jackpots yet. Check back soon!',
winner: 'Winner',
ticket: 'Ticket #',
pot: 'Pot',
drawTime: 'Draw Time',
},
admin: {
title: 'Admin Dashboard',
cycles: 'Cycles',
payouts: 'Payouts',
runDraw: 'Run Draw',
retryPayout: 'Retry Payout',
},
errors: {
generic: 'Something went wrong. Please try again.',
nostrNotFound: 'Nostr extension not found',
invalidAddress: 'Invalid Lightning Address',
networkError: 'Network error. Please check your connection.',
},
loading: 'Loading...',
empty: {
noTickets: 'You have no tickets yet',
noWins: 'No wins yet',
buyNow: 'Buy Tickets Now',
},
};
export default STRINGS;

224
front_end/src/lib/api.ts Normal file
View 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;

View File

@@ -0,0 +1,79 @@
/**
* Convert sats to BTC
*/
export function satsToBTC(sats: number): string {
return (sats / 100000000).toFixed(8);
}
/**
* Format sats with thousands separator
*/
export function formatSats(sats: number): string {
return sats.toLocaleString('en-US');
}
/**
* Format relative time
*/
export function relativeTime(date: Date | string): string {
const now = new Date();
const then = new Date(date);
const diffMs = now.getTime() - then.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSeconds < 60) {
return 'just now';
} else if (diffMinutes < 60) {
return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
} else if (diffDays < 7) {
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
} else {
return then.toLocaleDateString();
}
}
/**
* Format countdown time
*/
export function formatCountdown(targetDate: Date | string): {
days: number;
hours: number;
minutes: number;
seconds: number;
total: number;
} {
const now = new Date();
const target = new Date(targetDate);
const diffMs = target.getTime() - now.getTime();
if (diffMs <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, total: 0 };
}
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
return { days, hours, minutes, seconds, total: diffMs };
}
/**
* Format date and time
*/
export function formatDateTime(date: Date | string): string {
const d = new Date(date);
return d.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

100
front_end/src/lib/nostr.ts Normal file
View File

@@ -0,0 +1,100 @@
// Nostr utilities for NIP-07 authentication
interface NostrWindow extends Window {
nostr?: {
getPublicKey: () => Promise<string>;
signEvent: (event: any) => Promise<any>;
};
}
declare let window: NostrWindow;
/**
* Check if Nostr extension is available (NIP-07)
*/
export function isNostrAvailable(): boolean {
if (typeof window === 'undefined') return false;
return !!window.nostr;
}
/**
* Get user's Nostr public key
*/
export async function getNostrPublicKey(): Promise<string> {
if (!isNostrAvailable()) {
throw new Error('Nostr extension not found. Please install a Nostr browser extension.');
}
try {
const pubkey = await window.nostr!.getPublicKey();
return pubkey;
} catch (error: any) {
throw new Error('Failed to get Nostr public key: ' + error.message);
}
}
/**
* Sign a message with Nostr
*/
export async function signNostrMessage(message: string): Promise<string> {
if (!isNostrAvailable()) {
throw new Error('Nostr extension not found');
}
try {
// Create a simple event to sign
const event = {
kind: 22242, // Custom kind for auth
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: message,
};
const signedEvent = await window.nostr!.signEvent(event);
return signedEvent.sig;
} catch (error: any) {
throw new Error('Failed to sign message: ' + error.message);
}
}
/**
* Store auth token
*/
export function storeAuthToken(token: string): void {
if (typeof window === 'undefined') return;
localStorage.setItem('auth_token', token);
}
/**
* Get auth token
*/
export function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('auth_token');
}
/**
* Remove auth token
*/
export function removeAuthToken(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem('auth_token');
}
/**
* Convert hex pubkey to npub format (simplified)
*/
export function hexToNpub(hex: string): string {
// This is a simplified version
// In production, use a proper bech32 encoding library
return `npub1${hex.substring(0, 58)}`;
}
/**
* Shorten npub for display
*/
export function shortNpub(npub: string): string {
if (npub.length < 16) return npub;
return `${npub.substring(0, 8)}...${npub.substring(npub.length - 4)}`;
}

View File

@@ -0,0 +1,6 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './index';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@@ -0,0 +1,16 @@
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import jackpotReducer from './jackpotSlice';
import purchaseReducer from './purchaseSlice';
export const store = configureStore({
reducer: {
user: userReducer,
jackpot: jackpotReducer,
purchase: purchaseReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -0,0 +1,45 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Cycle {
id: string;
cycle_type: string;
scheduled_at: string;
pot_total_sats: number;
ticket_price_sats: number;
status: string;
}
interface JackpotState {
cycle: Cycle | null;
loading: boolean;
error: string | null;
}
const initialState: JackpotState = {
cycle: null,
loading: false,
error: null,
};
const jackpotSlice = createSlice({
name: 'jackpot',
initialState,
reducers: {
setCycle: (state, action: PayloadAction<Cycle>) => {
state.cycle = action.payload;
state.loading = false;
state.error = null;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
state.loading = false;
},
},
});
export const { setCycle, setLoading, setError } = jackpotSlice.actions;
export default jackpotSlice.reducer;

View File

@@ -0,0 +1,45 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface Ticket {
id: string;
serial_number: number;
is_winning_ticket: boolean;
}
interface PurchaseState {
ticket_purchase_id: string | null;
invoice_status: string | null;
tickets: Ticket[];
loading: boolean;
error: string | null;
}
const initialState: PurchaseState = {
ticket_purchase_id: null,
invoice_status: null,
tickets: [],
loading: false,
error: null,
};
const purchaseSlice = createSlice({
name: 'purchase',
initialState,
reducers: {
setPurchase: (state, action: PayloadAction<Partial<PurchaseState>>) => {
return { ...state, ...action.payload, loading: false, error: null };
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
state.loading = false;
},
clearPurchase: () => initialState,
},
});
export const { setPurchase, setLoading, setError, clearPurchase } = purchaseSlice.actions;
export default purchaseSlice.reducer;

View File

@@ -0,0 +1,32 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface UserState {
authenticated: boolean;
pubkey: string | null;
lightning_address: string | null;
token: string | null;
displayName: string | null;
}
const initialState: UserState = {
authenticated: false,
pubkey: null,
lightning_address: null,
token: null,
displayName: null,
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser: (state, action: PayloadAction<Partial<UserState>>) => {
return { ...state, ...action.payload, authenticated: true };
},
logout: () => initialState,
},
});
export const { setUser, logout } = userSlice.actions;
export default userSlice.reducer;