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

@@ -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
# ======================

View File

@@ -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') {

View File

@@ -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<void> {
try {
@@ -23,36 +134,34 @@ async function generateFutureCycles(): Promise<void> {
}
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<void> {
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<JackpotCycle>(
`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<void> {
}
}
/**
* Update cycles to 'sales_open' status when appropriate
*/
async function updateCycleStatuses(): Promise<void> {
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);
}

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 = () => {
@@ -146,15 +228,20 @@ 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">

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>
);
}

View File

@@ -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 <div className="text-2xl font-bold text-yellow-500">Drawing Now!</div>;
if (drawCompleted) {
return (
<div className="text-2xl font-bold text-gray-400 animate-pulse">
Waiting for next round...
</div>
);
}
return <div className="text-2xl font-bold text-yellow-500 animate-pulse">🎰 Drawing Now!</div>;
}
return (

View File

@@ -26,3 +26,4 @@ Thumbs.db
*.swp
*.swo

View File

@@ -42,3 +42,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ["node", "dist/index.js"]

View File

@@ -144,3 +144,4 @@ User states:
MIT

View File

@@ -35,3 +35,4 @@
}
}

View File

@@ -48,3 +48,4 @@ export const config = {
export default config;

View File

@@ -339,3 +339,4 @@ export default {
broadcastDrawReminder,
};

View File

@@ -143,3 +143,4 @@ class ApiClient {
export const apiClient = new ApiClient();
export default apiClient;

View File

@@ -222,3 +222,4 @@ class GroupStateManager {
export const groupStateManager = new GroupStateManager();
export default groupStateManager;

View File

@@ -80,3 +80,4 @@ export const logPaymentEvent = (
export default logger;

View File

@@ -25,3 +25,4 @@ export async function generateQRCode(data: string): Promise<Buffer> {
export default { generateQRCode };

View File

@@ -260,3 +260,4 @@ class StateManager {
export const stateManager = new StateManager();
export default stateManager;

View File

@@ -23,3 +23,4 @@ export const DEFAULT_GROUP_SETTINGS: Omit<GroupSettings, 'groupId' | 'groupTitle
ticketPurchaseAllowed: false, // Disabled by default for privacy - users should buy in DM
};

View File

@@ -69,3 +69,4 @@ export function truncate(str: string, maxLength: number): string {
return str.substring(0, maxLength - 3) + '...';
}

View File

@@ -19,3 +19,4 @@
"exclude": ["node_modules", "dist"]
}