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

@@ -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 */}

View File

@@ -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">