diff --git a/back_end/env.example b/back_end/env.example index 6a2ea73..c46db67 100644 --- a/back_end/env.example +++ b/back_end/env.example @@ -73,6 +73,46 @@ DEFAULT_HOUSE_FEE_PERCENT=5 # Maximum Lightning payout attempts before drawing a new winner PAYOUT_MAX_ATTEMPTS=2 +# ====================== +# Draw Cycle Configuration +# ====================== +# Cycle type: "minutes" | "hourly" | "daily" | "weekly" | "custom" +CYCLE_TYPE=hourly + +# ---- For MINUTES cycle ---- +# Draw every X minutes (e.g., 30 = every 30 minutes) +CYCLE_INTERVAL_MINUTES=60 + +# ---- For HOURLY cycle ---- +# Draw every X hours (e.g., 1 = every hour, 4 = every 4 hours) +CYCLE_INTERVAL_HOURS=1 + +# ---- For DAILY cycle ---- +# Draw time in 24-hour format (HH:MM in UTC) +# Example: 18:00 = 6 PM UTC +CYCLE_DAILY_TIME=18:00 + +# ---- For WEEKLY cycle ---- +# Day of week: 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday +CYCLE_WEEKLY_DAY=6 +# Draw time in 24-hour format (HH:MM in UTC) +CYCLE_WEEKLY_TIME=20:00 + +# ---- For CUSTOM cycle (advanced) ---- +# Cron expression for custom schedules +# Format: "minute hour day-of-month month day-of-week" +# Example: "0 */4 * * *" = every 4 hours +# Example: "0 20 * * 6" = every Saturday at 8 PM +# Example: "30 12,18 * * *" = 12:30 PM and 6:30 PM daily +CYCLE_CRON_EXPRESSION=0 * * * * + +# ---- Sales Window ---- +# How many minutes before the draw to stop accepting tickets +SALES_CLOSE_BEFORE_DRAW_MINUTES=5 + +# How many cycles to pre-generate in advance +CYCLES_TO_GENERATE_AHEAD=5 + # ====================== # Notes # ====================== diff --git a/back_end/src/config/index.ts b/back_end/src/config/index.ts index 255653d..0f4a51a 100644 --- a/back_end/src/config/index.ts +++ b/back_end/src/config/index.ts @@ -42,6 +42,20 @@ const allowedCorsOrigins = (() => { return Array.from(new Set([...frontendOrigins, appBaseUrl])); })(); +type CycleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'custom'; + +interface CycleConfig { + type: CycleType; + intervalMinutes: number; + intervalHours: number; + dailyTime: string; + weeklyDay: number; + weeklyTime: string; + cronExpression: string; + salesCloseBeforeDrawMinutes: number; + cyclesToGenerateAhead: number; +} + interface Config { app: { port: number; @@ -75,6 +89,7 @@ interface Config { defaultHouseFeePercent: number; maxTicketsPerPurchase: number; }; + cycle: CycleConfig; admin: { apiKey: string; }; @@ -119,6 +134,17 @@ const config: Config = { defaultHouseFeePercent: parseInt(process.env.DEFAULT_HOUSE_FEE_PERCENT || '5', 10), maxTicketsPerPurchase: 100, }, + cycle: { + type: (process.env.CYCLE_TYPE || 'hourly') as CycleType, + intervalMinutes: parseInt(process.env.CYCLE_INTERVAL_MINUTES || '60', 10), + intervalHours: parseInt(process.env.CYCLE_INTERVAL_HOURS || '1', 10), + dailyTime: process.env.CYCLE_DAILY_TIME || '18:00', + weeklyDay: parseInt(process.env.CYCLE_WEEKLY_DAY || '6', 10), + weeklyTime: process.env.CYCLE_WEEKLY_TIME || '20:00', + cronExpression: process.env.CYCLE_CRON_EXPRESSION || '0 * * * *', + salesCloseBeforeDrawMinutes: parseInt(process.env.SALES_CLOSE_BEFORE_DRAW_MINUTES || '5', 10), + cyclesToGenerateAhead: parseInt(process.env.CYCLES_TO_GENERATE_AHEAD || '5', 10), + }, admin: { apiKey: process.env.ADMIN_API_KEY || '', }, @@ -149,6 +175,51 @@ function validateConfig(): void { console.error('❌ DATABASE_TYPE must be either "postgres" or "sqlite"'); process.exit(1); } + + // Validate cycle type + const validCycleTypes: CycleType[] = ['minutes', 'hourly', 'daily', 'weekly', 'custom']; + if (!validCycleTypes.includes(config.cycle.type)) { + console.error(`❌ CYCLE_TYPE must be one of: ${validCycleTypes.join(', ')}`); + process.exit(1); + } + + // Validate weekly day (0-6) + if (config.cycle.weeklyDay < 0 || config.cycle.weeklyDay > 6) { + console.error('❌ CYCLE_WEEKLY_DAY must be between 0 (Sunday) and 6 (Saturday)'); + process.exit(1); + } + + // Validate time formats (HH:MM) + const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/; + if (!timeRegex.test(config.cycle.dailyTime)) { + console.error('❌ CYCLE_DAILY_TIME must be in HH:MM format (24-hour)'); + process.exit(1); + } + if (!timeRegex.test(config.cycle.weeklyTime)) { + console.error('❌ CYCLE_WEEKLY_TIME must be in HH:MM format (24-hour)'); + process.exit(1); + } + + // Log cycle configuration + console.log(`✓ Cycle configuration: ${config.cycle.type}`); + switch (config.cycle.type) { + case 'minutes': + console.log(` → Draw every ${config.cycle.intervalMinutes} minutes`); + break; + case 'hourly': + console.log(` → Draw every ${config.cycle.intervalHours} hour(s)`); + break; + case 'daily': + console.log(` → Draw daily at ${config.cycle.dailyTime} UTC`); + break; + case 'weekly': + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + console.log(` → Draw every ${days[config.cycle.weeklyDay]} at ${config.cycle.weeklyTime} UTC`); + break; + case 'custom': + console.log(` → Cron: ${config.cycle.cronExpression}`); + break; + } } if (config.app.nodeEnv !== 'test') { diff --git a/back_end/src/scheduler/index.ts b/back_end/src/scheduler/index.ts index 4753099..6d2543f 100644 --- a/back_end/src/scheduler/index.ts +++ b/back_end/src/scheduler/index.ts @@ -4,9 +4,120 @@ import { executeDraw } from '../services/draw'; import { autoRetryFailedPayouts } from '../services/payout'; import config from '../config'; import { JackpotCycle } from '../types'; +import { randomUUID } from 'crypto'; /** - * Generate future cycles for all cycle types + * Calculate the next scheduled draw time based on cycle configuration + */ +function calculateNextDrawTime(fromDate: Date): Date { + const cycleConfig = config.cycle; + let next = new Date(fromDate); + + switch (cycleConfig.type) { + case 'minutes': + // Add interval minutes + next = new Date(next.getTime() + cycleConfig.intervalMinutes * 60 * 1000); + break; + + case 'hourly': + // Add interval hours + next = new Date(next.getTime() + cycleConfig.intervalHours * 60 * 60 * 1000); + // Round to the start of the hour + next.setMinutes(0, 0, 0); + break; + + case 'daily': + // Move to next day at specified time + const [dailyHour, dailyMinute] = cycleConfig.dailyTime.split(':').map(Number); + next.setUTCDate(next.getUTCDate() + 1); + next.setUTCHours(dailyHour, dailyMinute, 0, 0); + break; + + case 'weekly': + // Find next occurrence of the specified day + const [weeklyHour, weeklyMinute] = cycleConfig.weeklyTime.split(':').map(Number); + const targetDay = cycleConfig.weeklyDay; + const currentDay = next.getUTCDay(); + + // Calculate days until target day + let daysUntilTarget = targetDay - currentDay; + if (daysUntilTarget <= 0) { + daysUntilTarget += 7; + } + + next.setUTCDate(next.getUTCDate() + daysUntilTarget); + next.setUTCHours(weeklyHour, weeklyMinute, 0, 0); + break; + + case 'custom': + // Parse cron and calculate next occurrence + next = calculateNextCronTime(cycleConfig.cronExpression, next); + break; + } + + return next; +} + +/** + * Calculate next occurrence from a cron expression + */ +function calculateNextCronTime(cronExpr: string, fromDate: Date): Date { + // Simple cron parser for common patterns + // Format: "minute hour day-of-month month day-of-week" + const parts = cronExpr.trim().split(/\s+/); + if (parts.length !== 5) { + console.error('Invalid cron expression:', cronExpr); + // Fallback to 1 hour from now + return new Date(fromDate.getTime() + 60 * 60 * 1000); + } + + const [minutePart, hourPart] = parts; + let next = new Date(fromDate); + + // Handle common patterns + if (hourPart.startsWith('*/')) { + // Every X hours + const interval = parseInt(hourPart.slice(2), 10); + next = new Date(next.getTime() + interval * 60 * 60 * 1000); + next.setMinutes(parseInt(minutePart, 10) || 0, 0, 0); + } else if (minutePart.startsWith('*/')) { + // Every X minutes + const interval = parseInt(minutePart.slice(2), 10); + next = new Date(next.getTime() + interval * 60 * 1000); + } else { + // Specific time - move to next occurrence + const targetHour = hourPart === '*' ? next.getUTCHours() : parseInt(hourPart, 10); + const targetMinute = minutePart === '*' ? 0 : parseInt(minutePart, 10); + + next.setUTCHours(targetHour, targetMinute, 0, 0); + if (next <= fromDate) { + next.setUTCDate(next.getUTCDate() + 1); + } + } + + return next; +} + +/** + * Get the cycle type name for database storage based on config + */ +function getCycleTypeName(): 'hourly' | 'daily' | 'weekly' | 'monthly' { + switch (config.cycle.type) { + case 'minutes': + case 'hourly': + case 'custom': + return 'hourly'; + case 'daily': + return 'daily'; + case 'weekly': + return 'weekly'; + default: + return 'hourly'; + } +} + +/** + * Generate future cycles based on configuration */ async function generateFutureCycles(): Promise { try { @@ -23,36 +134,34 @@ async function generateFutureCycles(): Promise { } const lottery = lotteryResult.rows[0]; - const cycleTypes: Array<'hourly' | 'daily' | 'weekly' | 'monthly'> = ['hourly', 'daily']; + const cycleType = getCycleTypeName(); + const cyclesToGenerate = config.cycle.cyclesToGenerateAhead; + const salesCloseMinutes = config.cycle.salesCloseBeforeDrawMinutes; - for (const cycleType of cycleTypes) { - await generateCyclesForType(lottery.id, cycleType); + // Get existing future cycles count + const existingResult = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM jackpot_cycles + WHERE lottery_id = $1 + AND status IN ('scheduled', 'sales_open') + AND scheduled_at > NOW()`, + [lottery.id] + ); + + const existingCount = parseInt(existingResult.rows[0]?.count || '0', 10); + const cyclesToCreate = Math.max(0, cyclesToGenerate - existingCount); + + if (cyclesToCreate === 0) { + console.log(`Already have ${existingCount} future cycles, no generation needed`); + return; } - } catch (error) { - console.error('Cycle generation error:', error); - } -} - -/** - * Generate cycles for a specific type - */ -async function generateCyclesForType( - lotteryId: string, - cycleType: 'hourly' | 'daily' | 'weekly' | 'monthly' -): Promise { - try { - // Determine horizon (how far in the future to generate) - const horizonHours = cycleType === 'hourly' ? 48 : 168; // 48h for hourly, 1 week for daily - const horizonDate = new Date(Date.now() + horizonHours * 60 * 60 * 1000); - - // Get latest cycle for this type + // Get the latest cycle to determine next sequence number and time const latestResult = await db.query( `SELECT * FROM jackpot_cycles - WHERE lottery_id = $1 AND cycle_type = $2 - ORDER BY sequence_number DESC + WHERE lottery_id = $1 + ORDER BY scheduled_at DESC LIMIT 1`, - [lotteryId, cycleType] + [lottery.id] ); let lastScheduledAt: Date; @@ -68,71 +177,44 @@ async function generateCyclesForType( sequenceNumber = latest.sequence_number; } - // Generate cycles until horizon - while (lastScheduledAt < horizonDate) { + // Generate new cycles + for (let i = 0; i < cyclesToCreate; i++) { sequenceNumber++; - // Calculate next scheduled time - let nextScheduledAt: Date; + const nextScheduledAt = calculateNextDrawTime(lastScheduledAt); - switch (cycleType) { - case 'hourly': - nextScheduledAt = new Date(lastScheduledAt.getTime() + 60 * 60 * 1000); - break; - case 'daily': - nextScheduledAt = new Date(lastScheduledAt.getTime() + 24 * 60 * 60 * 1000); - nextScheduledAt.setHours(20, 0, 0, 0); // 8 PM UTC - break; - case 'weekly': - nextScheduledAt = new Date(lastScheduledAt.getTime() + 7 * 24 * 60 * 60 * 1000); - break; - case 'monthly': - nextScheduledAt = new Date(lastScheduledAt); - nextScheduledAt.setMonth(nextScheduledAt.getMonth() + 1); - break; - } - - // Sales open immediately, close at draw time + // Sales open now, close X minutes before draw const salesOpenAt = new Date(); - const salesCloseAt = nextScheduledAt; + const salesCloseAt = new Date(nextScheduledAt.getTime() - salesCloseMinutes * 60 * 1000); - // Check if cycle already exists - const existingResult = await db.query( - `SELECT id FROM jackpot_cycles - WHERE lottery_id = $1 AND cycle_type = $2 AND sequence_number = $3`, - [lotteryId, cycleType, sequenceNumber] + const cycleId = randomUUID(); + + await db.query( + `INSERT INTO jackpot_cycles ( + id, lottery_id, cycle_type, sequence_number, scheduled_at, + sales_open_at, sales_close_at, status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + cycleId, + lottery.id, + cycleType, + sequenceNumber, + nextScheduledAt.toISOString(), + salesOpenAt.toISOString(), + salesCloseAt.toISOString(), + 'scheduled', + ] ); - if (existingResult.rows.length === 0) { - // Create new cycle with explicit UUID generation - const crypto = require('crypto'); - const cycleId = crypto.randomUUID(); - - await db.query( - `INSERT INTO jackpot_cycles ( - id, lottery_id, cycle_type, sequence_number, scheduled_at, - sales_open_at, sales_close_at, status - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - [ - cycleId, - lotteryId, - cycleType, - sequenceNumber, - nextScheduledAt.toISOString(), - salesOpenAt.toISOString(), - salesCloseAt.toISOString(), - 'scheduled', - ] - ); - - console.log(`Created ${cycleType} cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`); - } + console.log(`Created cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`); lastScheduledAt = nextScheduledAt; } + console.log(`Generated ${cyclesToCreate} new cycles`); + } catch (error) { - console.error(`Error generating ${cycleType} cycles:`, error); + console.error('Cycle generation error:', error); } } @@ -167,23 +249,53 @@ async function checkAndExecuteDraws(): Promise { } } +/** + * Update cycles to 'sales_open' status when appropriate + */ +async function updateCycleStatuses(): Promise { + try { + const now = new Date().toISOString(); + + // Update scheduled cycles to sales_open when sales period starts + await db.query( + `UPDATE jackpot_cycles + SET status = 'sales_open', updated_at = NOW() + WHERE status = 'scheduled' + AND sales_open_at <= $1 + AND scheduled_at > $2`, + [now, now] + ); + + } catch (error) { + console.error('Cycle status update error:', error); + } +} + /** * Start all schedulers */ export function startSchedulers(): void { console.log('Starting schedulers...'); + // Log cycle configuration + console.log(`Cycle type: ${config.cycle.type}`); + // Cycle generator - every 5 minutes (or configured interval) const cycleGenInterval = Math.max(config.scheduler.cycleGeneratorIntervalSeconds, 60); - cron.schedule(`*/${Math.floor(cycleGenInterval / 60)} * * * *`, generateFutureCycles); + const cycleGenMinutes = Math.max(1, Math.floor(cycleGenInterval / 60)); + cron.schedule(`*/${cycleGenMinutes} * * * *`, generateFutureCycles); - console.log(`✓ Cycle generator scheduled (every ${cycleGenInterval}s)`); + console.log(`✓ Cycle generator scheduled (every ${cycleGenMinutes} minute(s))`); // Draw executor - every minute (or configured interval) const drawInterval = Math.max(config.scheduler.drawIntervalSeconds, 30); - cron.schedule(`*/${Math.floor(drawInterval / 60)} * * * *`, checkAndExecuteDraws); + const drawMinutes = Math.max(1, Math.floor(drawInterval / 60)); + cron.schedule(`*/${drawMinutes} * * * *`, async () => { + await updateCycleStatuses(); + await checkAndExecuteDraws(); + }); - console.log(`✓ Draw executor scheduled (every ${drawInterval}s)`); + console.log(`✓ Draw executor scheduled (every ${drawMinutes} minute(s))`); // Payout retry - every 10 minutes cron.schedule('*/10 * * * *', autoRetryFailedPayouts); @@ -191,9 +303,9 @@ export function startSchedulers(): void { console.log(`✓ Payout retry scheduled (every 10 minutes)`); // Run immediately on startup - setTimeout(() => { - generateFutureCycles(); - checkAndExecuteDraws(); + setTimeout(async () => { + await generateFutureCycles(); + await updateCycleStatuses(); + await checkAndExecuteDraws(); }, 5000); } - diff --git a/front_end/src/app/page.tsx b/front_end/src/app/page.tsx index 333a16b..ac5527c 100644 --- a/front_end/src/app/page.tsx +++ b/front_end/src/app/page.tsx @@ -27,9 +27,12 @@ export default function HomePage() { const [ticketId, setTicketId] = useState(''); const [recentWinner, setRecentWinner] = useState(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(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 (
{/* Draw Animation Overlay */} - {showDrawAnimation && recentWinner && ( + {showDrawAnimation && ( )} @@ -213,7 +300,10 @@ export default function HomePage() { {STRINGS.home.drawIn}
- +
@@ -222,23 +312,17 @@ export default function HomePage() { Ticket Price: {jackpot.lottery.ticket_price_sats.toLocaleString()} sats - {/* Buy Button - Show Refresh only after draw */} -
- - {STRINGS.home.buyTickets} - - {drawJustCompleted && ( - - )} -
+ {STRINGS.home.buyTickets} + + + )} {/* Check Ticket Section */} diff --git a/front_end/src/app/tickets/[id]/page.tsx b/front_end/src/app/tickets/[id]/page.tsx index 20a12a3..74979e9 100644 --- a/front_end/src/app/tickets/[id]/page.tsx +++ b/front_end/src/app/tickets/[id]/page.tsx @@ -18,6 +18,21 @@ export default function TicketStatusPage() { const [error, setError] = useState(null); const [data, setData] = useState(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} + {/* Save This Link */} +
+
+
🔖
+
+

Save This Link!

+

+ Bookmark or save this page to check if you've won after the draw. This is your only way to view your ticket status. +

+
+
+ {ticketUrl || `/tickets/${ticketId}`} +
+ +
+
+
+
+ {/* Purchase Info */}
diff --git a/front_end/src/components/DrawAnimation.tsx b/front_end/src/components/DrawAnimation.tsx index afdaf18..a29da6a 100644 --- a/front_end/src/components/DrawAnimation.tsx +++ b/front_end/src/components/DrawAnimation.tsx @@ -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 (
{/* Confetti Effect */} {showConfetti && ( @@ -130,7 +168,7 @@ export function DrawAnimation({ )} {/* Winner Phase */} - {phase === 'winner' && ( + {phase === 'winner' && hasWinner && (
🎉🏆🎉
@@ -143,11 +181,32 @@ export function DrawAnimation({
Winning Ticket
- #{winningTicket.toLocaleString()} + #{winningTicket!.toLocaleString()}
Prize
- {potAmount.toLocaleString()} sats + {potAmount!.toLocaleString()} sats +
+
+
+ Click anywhere to continue +
+
+ )} + + {/* No Winner Phase (no tickets sold) */} + {phase === 'no-winner' && ( +
+
😔
+
+ No Tickets This Round +
+
+
+ No tickets were sold for this draw. +
+
+ Next draw starting soon!
@@ -211,4 +270,3 @@ export function DrawAnimation({
); } - diff --git a/front_end/src/components/JackpotCountdown.tsx b/front_end/src/components/JackpotCountdown.tsx index cdcbe94..4b96c60 100644 --- a/front_end/src/components/JackpotCountdown.tsx +++ b/front_end/src/components/JackpotCountdown.tsx @@ -5,9 +5,10 @@ import { formatCountdown } from '@/lib/format'; interface JackpotCountdownProps { scheduledAt: string; + drawCompleted?: boolean; } -export function JackpotCountdown({ scheduledAt }: JackpotCountdownProps) { +export function JackpotCountdown({ scheduledAt, drawCompleted = false }: JackpotCountdownProps) { const [countdown, setCountdown] = useState(formatCountdown(scheduledAt)); useEffect(() => { @@ -19,7 +20,14 @@ export function JackpotCountdown({ scheduledAt }: JackpotCountdownProps) { }, [scheduledAt]); if (countdown.total <= 0) { - return
Drawing Now!
; + if (drawCompleted) { + return ( +
+ ⏳ Waiting for next round... +
+ ); + } + return
🎰 Drawing Now!
; } return ( diff --git a/telegram_bot/.gitignore b/telegram_bot/.gitignore index 072e016..9609226 100644 --- a/telegram_bot/.gitignore +++ b/telegram_bot/.gitignore @@ -26,3 +26,4 @@ Thumbs.db *.swp *.swo + diff --git a/telegram_bot/Dockerfile b/telegram_bot/Dockerfile index 441f4d1..08dc35f 100644 --- a/telegram_bot/Dockerfile +++ b/telegram_bot/Dockerfile @@ -42,3 +42,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD ["node", "dist/index.js"] + diff --git a/telegram_bot/README.md b/telegram_bot/README.md index 4c84bc1..380c2b9 100644 --- a/telegram_bot/README.md +++ b/telegram_bot/README.md @@ -144,3 +144,4 @@ User states: MIT + diff --git a/telegram_bot/package.json b/telegram_bot/package.json index 42d1c23..bcf696c 100644 --- a/telegram_bot/package.json +++ b/telegram_bot/package.json @@ -35,3 +35,4 @@ } } + diff --git a/telegram_bot/src/config/index.ts b/telegram_bot/src/config/index.ts index 363f490..5edf85d 100644 --- a/telegram_bot/src/config/index.ts +++ b/telegram_bot/src/config/index.ts @@ -48,3 +48,4 @@ export const config = { export default config; + diff --git a/telegram_bot/src/handlers/groups.ts b/telegram_bot/src/handlers/groups.ts index 05f2baa..4bd924e 100644 --- a/telegram_bot/src/handlers/groups.ts +++ b/telegram_bot/src/handlers/groups.ts @@ -339,3 +339,4 @@ export default { broadcastDrawReminder, }; + diff --git a/telegram_bot/src/services/api.ts b/telegram_bot/src/services/api.ts index 82d54ca..8b12ae7 100644 --- a/telegram_bot/src/services/api.ts +++ b/telegram_bot/src/services/api.ts @@ -143,3 +143,4 @@ class ApiClient { export const apiClient = new ApiClient(); export default apiClient; + diff --git a/telegram_bot/src/services/groupState.ts b/telegram_bot/src/services/groupState.ts index c4f0d4f..ffc6244 100644 --- a/telegram_bot/src/services/groupState.ts +++ b/telegram_bot/src/services/groupState.ts @@ -222,3 +222,4 @@ class GroupStateManager { export const groupStateManager = new GroupStateManager(); export default groupStateManager; + diff --git a/telegram_bot/src/services/logger.ts b/telegram_bot/src/services/logger.ts index 478cf30..dab4d53 100644 --- a/telegram_bot/src/services/logger.ts +++ b/telegram_bot/src/services/logger.ts @@ -80,3 +80,4 @@ export const logPaymentEvent = ( export default logger; + diff --git a/telegram_bot/src/services/qr.ts b/telegram_bot/src/services/qr.ts index fd6ab0b..8cd9a62 100644 --- a/telegram_bot/src/services/qr.ts +++ b/telegram_bot/src/services/qr.ts @@ -25,3 +25,4 @@ export async function generateQRCode(data: string): Promise { export default { generateQRCode }; + diff --git a/telegram_bot/src/services/state.ts b/telegram_bot/src/services/state.ts index 6d6838f..696babe 100644 --- a/telegram_bot/src/services/state.ts +++ b/telegram_bot/src/services/state.ts @@ -260,3 +260,4 @@ class StateManager { export const stateManager = new StateManager(); export default stateManager; + diff --git a/telegram_bot/src/types/groups.ts b/telegram_bot/src/types/groups.ts index bd3faed..d477a90 100644 --- a/telegram_bot/src/types/groups.ts +++ b/telegram_bot/src/types/groups.ts @@ -23,3 +23,4 @@ export const DEFAULT_GROUP_SETTINGS: Omit