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:
190
front_end/src/app/about/page.tsx
Normal file
190
front_end/src/app/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
367
front_end/src/app/buy/page.tsx
Normal file
367
front_end/src/app/buy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
159
front_end/src/app/dashboard/page.tsx
Normal file
159
front_end/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
119
front_end/src/app/dashboard/tickets/page.tsx
Normal file
119
front_end/src/app/dashboard/tickets/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
111
front_end/src/app/dashboard/wins/page.tsx
Normal file
111
front_end/src/app/dashboard/wins/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
53
front_end/src/app/globals.css
Normal file
53
front_end/src/app/globals.css
Normal 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;
|
||||
}
|
||||
|
||||
36
front_end/src/app/layout.tsx
Normal file
36
front_end/src/app/layout.tsx
Normal 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
293
front_end/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
front_end/src/app/past-wins/page.tsx
Normal file
117
front_end/src/app/past-wins/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
9
front_end/src/app/providers.tsx
Normal file
9
front_end/src/app/providers.tsx
Normal 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>;
|
||||
}
|
||||
|
||||
192
front_end/src/app/tickets/[id]/page.tsx
Normal file
192
front_end/src/app/tickets/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user