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:
Michilis
2025-11-28 03:24:17 +00:00
parent f743a6749c
commit 918d3bc31e
21 changed files with 584 additions and 140 deletions

View File

@@ -3,10 +3,12 @@
import { useState, useEffect, useCallback } from 'react';
interface DrawAnimationProps {
winnerName: string;
winningTicket: number;
potAmount: number;
winnerName?: string;
winningTicket?: number;
potAmount?: number;
onComplete: () => void;
// If true, show "drawing" animation without winner info
isDrawing?: boolean;
}
export function DrawAnimation({
@@ -14,11 +16,14 @@ export function DrawAnimation({
winningTicket,
potAmount,
onComplete,
isDrawing = false,
}: DrawAnimationProps) {
const [phase, setPhase] = useState<'spinning' | 'revealing' | 'winner' | 'done'>('spinning');
const [phase, setPhase] = useState<'spinning' | 'revealing' | 'winner' | 'no-winner' | 'done'>('spinning');
const [displayTicket, setDisplayTicket] = useState(0);
const [showConfetti, setShowConfetti] = useState(false);
const hasWinner = winnerName !== undefined && winningTicket !== undefined && potAmount !== undefined;
// Generate random ticket numbers during spin
useEffect(() => {
if (phase !== 'spinning') return;
@@ -27,20 +32,40 @@ export function DrawAnimation({
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
}, 50);
// After 2.5 seconds, start revealing
// After 2.5 seconds, start revealing (only if we have winner info)
const revealTimeout = setTimeout(() => {
setPhase('revealing');
if (hasWinner) {
setPhase('revealing');
}
// If no winner, keep spinning until we get data or timeout
}, 2500);
return () => {
clearInterval(spinInterval);
clearTimeout(revealTimeout);
};
}, [phase]);
}, [phase, hasWinner]);
// When winner info becomes available during spinning, transition to revealing
useEffect(() => {
if (phase === 'spinning' && hasWinner) {
const timer = setTimeout(() => {
setPhase('revealing');
}, 500);
return () => clearTimeout(timer);
}
}, [phase, hasWinner]);
// If isDrawing becomes false and we have no winner, show no-winner phase
useEffect(() => {
if (!isDrawing && !hasWinner && phase === 'spinning') {
setPhase('no-winner');
}
}, [isDrawing, hasWinner, phase]);
// Slow down and reveal actual number
useEffect(() => {
if (phase !== 'revealing') return;
if (phase !== 'revealing' || !winningTicket) return;
let speed = 50;
let iterations = 0;
@@ -69,11 +94,24 @@ export function DrawAnimation({
setShowConfetti(true);
// Auto-dismiss after 6 seconds
// Auto-dismiss after 15 seconds (give time to load next cycle)
const dismissTimeout = setTimeout(() => {
setPhase('done');
onComplete();
}, 6000);
}, 15000);
return () => clearTimeout(dismissTimeout);
}, [phase, onComplete]);
// Handle no-winner phase
useEffect(() => {
if (phase !== 'no-winner') return;
// Auto-dismiss after 3 seconds
const dismissTimeout = setTimeout(() => {
setPhase('done');
onComplete();
}, 3000);
return () => clearTimeout(dismissTimeout);
}, [phase, onComplete]);
@@ -88,7 +126,7 @@ export function DrawAnimation({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
onClick={phase === 'winner' ? handleDismiss : undefined}
onClick={phase === 'winner' || phase === 'no-winner' ? handleDismiss : undefined}
>
{/* Confetti Effect */}
{showConfetti && (
@@ -130,7 +168,7 @@ export function DrawAnimation({
)}
{/* Winner Phase */}
{phase === 'winner' && (
{phase === 'winner' && hasWinner && (
<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">
@@ -143,11 +181,32 @@ export function DrawAnimation({
</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()}
#{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
{potAmount!.toLocaleString()} sats
</div>
</div>
<div className="mt-6 text-gray-400 text-sm animate-pulse">
Click anywhere to continue
</div>
</div>
)}
{/* No Winner Phase (no tickets sold) */}
{phase === 'no-winner' && (
<div className="animate-winner-reveal">
<div className="text-4xl mb-4">😔</div>
<div className="text-2xl md:text-3xl font-bold text-gray-400 mb-6">
No Tickets This Round
</div>
<div className="bg-gray-900 rounded-2xl p-8 border-2 border-gray-600 shadow-2xl">
<div className="text-gray-300 text-lg mb-4">
No tickets were sold for this draw.
</div>
<div className="text-bitcoin-orange text-xl font-semibold">
Next draw starting soon!
</div>
</div>
<div className="mt-6 text-gray-400 text-sm animate-pulse">
@@ -211,4 +270,3 @@ export function DrawAnimation({
</div>
);
}