feat: Add configurable draw cycles, improve UX
Backend: - Add configurable draw cycle settings (minutes/hourly/daily/weekly/custom) - Add CYCLE_TYPE, CYCLE_INTERVAL_*, CYCLE_DAILY_TIME, CYCLE_WEEKLY_* env vars - Add SALES_CLOSE_BEFORE_DRAW_MINUTES and CYCLES_TO_GENERATE_AHEAD - Fix SQLite parameter issue in scheduler Frontend: - Add 'Save This Link' section with copy button on ticket status page - Improve draw animation to show immediately when draw starts - Show 'Waiting for next round...' instead of 'Drawing Now!' after draw - Hide Buy Tickets button when waiting for next round - Skip draw animation if no tickets were sold - Keep winner screen open longer (15s) for next cycle to load - Auto-refresh to next lottery cycle after draw Telegram Bot: - Various improvements and fixes
This commit is contained in:
@@ -27,9 +27,12 @@ export default function HomePage() {
|
||||
const [ticketId, setTicketId] = useState('');
|
||||
const [recentWinner, setRecentWinner] = useState<RecentWinner | null>(null);
|
||||
const [showDrawAnimation, setShowDrawAnimation] = useState(false);
|
||||
const [drawInProgress, setDrawInProgress] = useState(false);
|
||||
const [drawJustCompleted, setDrawJustCompleted] = useState(false);
|
||||
const [winnerBannerDismissed, setWinnerBannerDismissed] = useState(false);
|
||||
const [isRecentWin, setIsRecentWin] = useState(false);
|
||||
const [awaitingNextCycle, setAwaitingNextCycle] = useState(false);
|
||||
const [pendingWinner, setPendingWinner] = useState<RecentWinner | null>(null);
|
||||
|
||||
const loadJackpot = useCallback(async () => {
|
||||
try {
|
||||
@@ -77,23 +80,108 @@ export default function HomePage() {
|
||||
loadRecentWinner();
|
||||
}, [loadJackpot, loadRecentWinner]);
|
||||
|
||||
// Poll for draw completion when countdown reaches zero
|
||||
// Detect when draw time passes and trigger draw animation (only if tickets were sold)
|
||||
useEffect(() => {
|
||||
if (!jackpot?.cycle?.scheduled_at) return;
|
||||
if (!jackpot?.cycle?.scheduled_at || awaitingNextCycle) return;
|
||||
|
||||
const scheduledTime = new Date(jackpot.cycle.scheduled_at).getTime();
|
||||
const hasTicketsSold = jackpot.cycle.pot_total_sats > 0;
|
||||
|
||||
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();
|
||||
// If we're past the scheduled time, start the draw
|
||||
if (now >= scheduledTime) {
|
||||
setAwaitingNextCycle(true);
|
||||
setDrawInProgress(true);
|
||||
// Only show draw animation if tickets were sold
|
||||
if (hasTicketsSold) {
|
||||
setShowDrawAnimation(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkForDraw, 5000);
|
||||
const interval = setInterval(checkForDraw, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [jackpot?.cycle?.scheduled_at, drawJustCompleted, loadRecentWinner]);
|
||||
}, [jackpot?.cycle?.scheduled_at, jackpot?.cycle?.pot_total_sats, awaitingNextCycle]);
|
||||
|
||||
// When awaiting next cycle, poll for winner and next cycle
|
||||
useEffect(() => {
|
||||
if (!awaitingNextCycle) return;
|
||||
|
||||
const currentCycleId = jackpot?.cycle?.id;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 12; // Try for up to 60 seconds (12 * 5s)
|
||||
let foundWinner = false;
|
||||
|
||||
const checkForWinner = 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 thirtySeconds = 30 * 1000;
|
||||
|
||||
// Check if this is a recent win (within 30 seconds)
|
||||
if (now - winTime < thirtySeconds && !foundWinner) {
|
||||
foundWinner = true;
|
||||
setPendingWinner(latestWin);
|
||||
setRecentWinner(latestWin);
|
||||
setIsRecentWin(true);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check for winner:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const tryFetchNextCycle = async () => {
|
||||
attempts++;
|
||||
|
||||
// Check for winner each time
|
||||
await checkForWinner();
|
||||
|
||||
try {
|
||||
const response = await api.getNextJackpot();
|
||||
if (response.data) {
|
||||
// Check if we got a new cycle (different ID or scheduled time in future)
|
||||
const newScheduledTime = new Date(response.data.cycle.scheduled_at).getTime();
|
||||
const isNewCycle = response.data.cycle.id !== currentCycleId || newScheduledTime > Date.now();
|
||||
|
||||
if (isNewCycle) {
|
||||
console.log('New cycle found, updating jackpot...');
|
||||
setJackpot(response.data);
|
||||
setAwaitingNextCycle(false);
|
||||
setDrawJustCompleted(true);
|
||||
// Don't hide animation here - let it complete naturally
|
||||
// Animation will call handleAnimationComplete when done
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch next jackpot:', err);
|
||||
}
|
||||
|
||||
// Keep trying if we haven't exceeded max attempts
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(tryFetchNextCycle, 5000);
|
||||
} else {
|
||||
// Give up and just refresh with whatever we have
|
||||
console.log('Max attempts reached, forcing refresh...');
|
||||
setAwaitingNextCycle(false);
|
||||
setDrawInProgress(false);
|
||||
setShowDrawAnimation(false);
|
||||
loadJackpot();
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling after 5 seconds (give backend time to process draw)
|
||||
// We poll in background while animation plays
|
||||
const initialDelay = setTimeout(tryFetchNextCycle, 5000);
|
||||
|
||||
return () => clearTimeout(initialDelay);
|
||||
}, [awaitingNextCycle, jackpot?.cycle?.id, loadJackpot]);
|
||||
|
||||
const handleCheckTicket = () => {
|
||||
if (ticketId.trim()) {
|
||||
@@ -103,14 +191,8 @@ export default function HomePage() {
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
setShowDrawAnimation(false);
|
||||
};
|
||||
|
||||
const handlePlayAgain = () => {
|
||||
setDrawJustCompleted(false);
|
||||
setWinnerBannerDismissed(true);
|
||||
setIsRecentWin(false);
|
||||
loadJackpot();
|
||||
loadRecentWinner();
|
||||
setDrawInProgress(false);
|
||||
setPendingWinner(null);
|
||||
};
|
||||
|
||||
const handleDismissWinnerBanner = () => {
|
||||
@@ -145,16 +227,21 @@ export default function HomePage() {
|
||||
|
||||
// Only show winner banner if: recent win (within 60s), not dismissed, and animation not showing
|
||||
const showWinnerBanner = isRecentWin && recentWinner && !showDrawAnimation && !winnerBannerDismissed;
|
||||
|
||||
// Check if we're waiting for the next round (countdown passed, waiting for next cycle)
|
||||
const scheduledTime = jackpot?.cycle?.scheduled_at ? new Date(jackpot.cycle.scheduled_at).getTime() : 0;
|
||||
const isWaitingForNextRound = (awaitingNextCycle || drawJustCompleted) && Date.now() >= scheduledTime;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Draw Animation Overlay */}
|
||||
{showDrawAnimation && recentWinner && (
|
||||
{showDrawAnimation && (
|
||||
<DrawAnimation
|
||||
winnerName={recentWinner.winner_name}
|
||||
winningTicket={recentWinner.winning_ticket_serial}
|
||||
potAmount={recentWinner.pot_after_fee_sats}
|
||||
winnerName={pendingWinner?.winner_name || recentWinner?.winner_name}
|
||||
winningTicket={pendingWinner?.winning_ticket_serial || recentWinner?.winning_ticket_serial}
|
||||
potAmount={pendingWinner?.pot_after_fee_sats || recentWinner?.pot_after_fee_sats}
|
||||
onComplete={handleAnimationComplete}
|
||||
isDrawing={drawInProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -213,7 +300,10 @@ export default function HomePage() {
|
||||
{STRINGS.home.drawIn}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<JackpotCountdown scheduledAt={jackpot.cycle.scheduled_at} />
|
||||
<JackpotCountdown
|
||||
scheduledAt={jackpot.cycle.scheduled_at}
|
||||
drawCompleted={awaitingNextCycle || drawJustCompleted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,23 +312,17 @@ export default function HomePage() {
|
||||
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"
|
||||
{/* Buy Button - Hide when waiting for next round */}
|
||||
{!isWaitingForNextRound && (
|
||||
<div className="flex justify-center">
|
||||
<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"
|
||||
>
|
||||
<span>🔄</span> Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{STRINGS.home.buyTickets}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check Ticket Section */}
|
||||
|
||||
@@ -18,6 +18,21 @@ export default function TicketStatusPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const ticketUrl = typeof window !== 'undefined'
|
||||
? `${window.location.origin}/tickets/${ticketId}`
|
||||
: '';
|
||||
|
||||
const copyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(ticketUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTicketStatus();
|
||||
@@ -86,6 +101,48 @@ export default function TicketStatusPage() {
|
||||
{STRINGS.ticket.title}
|
||||
</h1>
|
||||
|
||||
{/* Save This Link */}
|
||||
<div className="bg-gradient-to-r from-blue-900/30 to-purple-900/30 border border-blue-700/50 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-3xl">🔖</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Save This Link!</h3>
|
||||
<p className="text-gray-300 text-sm mb-3">
|
||||
Bookmark or save this page to check if you've won after the draw. This is your only way to view your ticket status.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex-1 bg-gray-800/80 rounded-lg px-3 py-2 font-mono text-sm text-gray-300 break-all">
|
||||
{ticketUrl || `/tickets/${ticketId}`}
|
||||
</div>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
copied
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-white'
|
||||
}`}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
Copy Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
|
||||
Reference in New Issue
Block a user