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:
@@ -73,6 +73,46 @@ DEFAULT_HOUSE_FEE_PERCENT=5
|
|||||||
# Maximum Lightning payout attempts before drawing a new winner
|
# Maximum Lightning payout attempts before drawing a new winner
|
||||||
PAYOUT_MAX_ATTEMPTS=2
|
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
|
# Notes
|
||||||
# ======================
|
# ======================
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ const allowedCorsOrigins = (() => {
|
|||||||
return Array.from(new Set([...frontendOrigins, appBaseUrl]));
|
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 {
|
interface Config {
|
||||||
app: {
|
app: {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -75,6 +89,7 @@ interface Config {
|
|||||||
defaultHouseFeePercent: number;
|
defaultHouseFeePercent: number;
|
||||||
maxTicketsPerPurchase: number;
|
maxTicketsPerPurchase: number;
|
||||||
};
|
};
|
||||||
|
cycle: CycleConfig;
|
||||||
admin: {
|
admin: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
};
|
};
|
||||||
@@ -119,6 +134,17 @@ const config: Config = {
|
|||||||
defaultHouseFeePercent: parseInt(process.env.DEFAULT_HOUSE_FEE_PERCENT || '5', 10),
|
defaultHouseFeePercent: parseInt(process.env.DEFAULT_HOUSE_FEE_PERCENT || '5', 10),
|
||||||
maxTicketsPerPurchase: 100,
|
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: {
|
admin: {
|
||||||
apiKey: process.env.ADMIN_API_KEY || '',
|
apiKey: process.env.ADMIN_API_KEY || '',
|
||||||
},
|
},
|
||||||
@@ -149,6 +175,51 @@ function validateConfig(): void {
|
|||||||
console.error('❌ DATABASE_TYPE must be either "postgres" or "sqlite"');
|
console.error('❌ DATABASE_TYPE must be either "postgres" or "sqlite"');
|
||||||
process.exit(1);
|
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') {
|
if (config.app.nodeEnv !== 'test') {
|
||||||
|
|||||||
@@ -4,9 +4,120 @@ import { executeDraw } from '../services/draw';
|
|||||||
import { autoRetryFailedPayouts } from '../services/payout';
|
import { autoRetryFailedPayouts } from '../services/payout';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { JackpotCycle } from '../types';
|
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> {
|
async function generateFutureCycles(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -23,36 +134,34 @@ async function generateFutureCycles(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const lottery = lotteryResult.rows[0];
|
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) {
|
// Get existing future cycles count
|
||||||
await generateCyclesForType(lottery.id, cycleType);
|
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) {
|
// Get the latest cycle to determine next sequence number and time
|
||||||
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
|
|
||||||
const latestResult = await db.query<JackpotCycle>(
|
const latestResult = await db.query<JackpotCycle>(
|
||||||
`SELECT * FROM jackpot_cycles
|
`SELECT * FROM jackpot_cycles
|
||||||
WHERE lottery_id = $1 AND cycle_type = $2
|
WHERE lottery_id = $1
|
||||||
ORDER BY sequence_number DESC
|
ORDER BY scheduled_at DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[lotteryId, cycleType]
|
[lottery.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
let lastScheduledAt: Date;
|
let lastScheduledAt: Date;
|
||||||
@@ -68,71 +177,44 @@ async function generateCyclesForType(
|
|||||||
sequenceNumber = latest.sequence_number;
|
sequenceNumber = latest.sequence_number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate cycles until horizon
|
// Generate new cycles
|
||||||
while (lastScheduledAt < horizonDate) {
|
for (let i = 0; i < cyclesToCreate; i++) {
|
||||||
sequenceNumber++;
|
sequenceNumber++;
|
||||||
|
|
||||||
// Calculate next scheduled time
|
const nextScheduledAt = calculateNextDrawTime(lastScheduledAt);
|
||||||
let nextScheduledAt: Date;
|
|
||||||
|
|
||||||
switch (cycleType) {
|
// Sales open now, close X minutes before draw
|
||||||
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
|
|
||||||
const salesOpenAt = new Date();
|
const salesOpenAt = new Date();
|
||||||
const salesCloseAt = nextScheduledAt;
|
const salesCloseAt = new Date(nextScheduledAt.getTime() - salesCloseMinutes * 60 * 1000);
|
||||||
|
|
||||||
// Check if cycle already exists
|
const cycleId = randomUUID();
|
||||||
const existingResult = await db.query(
|
|
||||||
`SELECT id FROM jackpot_cycles
|
await db.query(
|
||||||
WHERE lottery_id = $1 AND cycle_type = $2 AND sequence_number = $3`,
|
`INSERT INTO jackpot_cycles (
|
||||||
[lotteryId, cycleType, sequenceNumber]
|
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) {
|
console.log(`Created cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`);
|
||||||
// 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()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastScheduledAt = nextScheduledAt;
|
lastScheduledAt = nextScheduledAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`Generated ${cyclesToCreate} new cycles`);
|
||||||
|
|
||||||
} catch (error) {
|
} 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
|
* Start all schedulers
|
||||||
*/
|
*/
|
||||||
export function startSchedulers(): void {
|
export function startSchedulers(): void {
|
||||||
console.log('Starting schedulers...');
|
console.log('Starting schedulers...');
|
||||||
|
|
||||||
|
// Log cycle configuration
|
||||||
|
console.log(`Cycle type: ${config.cycle.type}`);
|
||||||
|
|
||||||
// Cycle generator - every 5 minutes (or configured interval)
|
// Cycle generator - every 5 minutes (or configured interval)
|
||||||
const cycleGenInterval = Math.max(config.scheduler.cycleGeneratorIntervalSeconds, 60);
|
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)
|
// Draw executor - every minute (or configured interval)
|
||||||
const drawInterval = Math.max(config.scheduler.drawIntervalSeconds, 30);
|
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
|
// Payout retry - every 10 minutes
|
||||||
cron.schedule('*/10 * * * *', autoRetryFailedPayouts);
|
cron.schedule('*/10 * * * *', autoRetryFailedPayouts);
|
||||||
@@ -191,9 +303,9 @@ export function startSchedulers(): void {
|
|||||||
console.log(`✓ Payout retry scheduled (every 10 minutes)`);
|
console.log(`✓ Payout retry scheduled (every 10 minutes)`);
|
||||||
|
|
||||||
// Run immediately on startup
|
// Run immediately on startup
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
generateFutureCycles();
|
await generateFutureCycles();
|
||||||
checkAndExecuteDraws();
|
await updateCycleStatuses();
|
||||||
|
await checkAndExecuteDraws();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ export default function HomePage() {
|
|||||||
const [ticketId, setTicketId] = useState('');
|
const [ticketId, setTicketId] = useState('');
|
||||||
const [recentWinner, setRecentWinner] = useState<RecentWinner | null>(null);
|
const [recentWinner, setRecentWinner] = useState<RecentWinner | null>(null);
|
||||||
const [showDrawAnimation, setShowDrawAnimation] = useState(false);
|
const [showDrawAnimation, setShowDrawAnimation] = useState(false);
|
||||||
|
const [drawInProgress, setDrawInProgress] = useState(false);
|
||||||
const [drawJustCompleted, setDrawJustCompleted] = useState(false);
|
const [drawJustCompleted, setDrawJustCompleted] = useState(false);
|
||||||
const [winnerBannerDismissed, setWinnerBannerDismissed] = useState(false);
|
const [winnerBannerDismissed, setWinnerBannerDismissed] = useState(false);
|
||||||
const [isRecentWin, setIsRecentWin] = useState(false);
|
const [isRecentWin, setIsRecentWin] = useState(false);
|
||||||
|
const [awaitingNextCycle, setAwaitingNextCycle] = useState(false);
|
||||||
|
const [pendingWinner, setPendingWinner] = useState<RecentWinner | null>(null);
|
||||||
|
|
||||||
const loadJackpot = useCallback(async () => {
|
const loadJackpot = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -77,23 +80,108 @@ export default function HomePage() {
|
|||||||
loadRecentWinner();
|
loadRecentWinner();
|
||||||
}, [loadJackpot, 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(() => {
|
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 checkForDraw = () => {
|
||||||
const scheduledTime = new Date(jackpot.cycle.scheduled_at).getTime();
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// If we're past the scheduled time, start polling for the winner
|
// If we're past the scheduled time, start the draw
|
||||||
if (now >= scheduledTime && !drawJustCompleted) {
|
if (now >= scheduledTime) {
|
||||||
loadRecentWinner();
|
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);
|
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 = () => {
|
const handleCheckTicket = () => {
|
||||||
if (ticketId.trim()) {
|
if (ticketId.trim()) {
|
||||||
@@ -103,14 +191,8 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const handleAnimationComplete = () => {
|
const handleAnimationComplete = () => {
|
||||||
setShowDrawAnimation(false);
|
setShowDrawAnimation(false);
|
||||||
};
|
setDrawInProgress(false);
|
||||||
|
setPendingWinner(null);
|
||||||
const handlePlayAgain = () => {
|
|
||||||
setDrawJustCompleted(false);
|
|
||||||
setWinnerBannerDismissed(true);
|
|
||||||
setIsRecentWin(false);
|
|
||||||
loadJackpot();
|
|
||||||
loadRecentWinner();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDismissWinnerBanner = () => {
|
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
|
// Only show winner banner if: recent win (within 60s), not dismissed, and animation not showing
|
||||||
const showWinnerBanner = isRecentWin && recentWinner && !showDrawAnimation && !winnerBannerDismissed;
|
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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Draw Animation Overlay */}
|
{/* Draw Animation Overlay */}
|
||||||
{showDrawAnimation && recentWinner && (
|
{showDrawAnimation && (
|
||||||
<DrawAnimation
|
<DrawAnimation
|
||||||
winnerName={recentWinner.winner_name}
|
winnerName={pendingWinner?.winner_name || recentWinner?.winner_name}
|
||||||
winningTicket={recentWinner.winning_ticket_serial}
|
winningTicket={pendingWinner?.winning_ticket_serial || recentWinner?.winning_ticket_serial}
|
||||||
potAmount={recentWinner.pot_after_fee_sats}
|
potAmount={pendingWinner?.pot_after_fee_sats || recentWinner?.pot_after_fee_sats}
|
||||||
onComplete={handleAnimationComplete}
|
onComplete={handleAnimationComplete}
|
||||||
|
isDrawing={drawInProgress}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -213,7 +300,10 @@ export default function HomePage() {
|
|||||||
{STRINGS.home.drawIn}
|
{STRINGS.home.drawIn}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<JackpotCountdown scheduledAt={jackpot.cycle.scheduled_at} />
|
<JackpotCountdown
|
||||||
|
scheduledAt={jackpot.cycle.scheduled_at}
|
||||||
|
drawCompleted={awaitingNextCycle || drawJustCompleted}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,23 +312,17 @@ export default function HomePage() {
|
|||||||
Ticket Price: {jackpot.lottery.ticket_price_sats.toLocaleString()} sats
|
Ticket Price: {jackpot.lottery.ticket_price_sats.toLocaleString()} sats
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buy Button - Show Refresh only after draw */}
|
{/* Buy Button - Hide when waiting for next round */}
|
||||||
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
{!isWaitingForNextRound && (
|
||||||
<Link
|
<div className="flex justify-center">
|
||||||
href="/buy"
|
<Link
|
||||||
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"
|
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"
|
|
||||||
>
|
>
|
||||||
<span>🔄</span> Refresh
|
{STRINGS.home.buyTickets}
|
||||||
</button>
|
</Link>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check Ticket Section */}
|
{/* Check Ticket Section */}
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ export default function TicketStatusPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [data, setData] = useState<any>(null);
|
const [data, setData] = useState<any>(null);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
loadTicketStatus();
|
loadTicketStatus();
|
||||||
@@ -86,6 +101,48 @@ export default function TicketStatusPage() {
|
|||||||
{STRINGS.ticket.title}
|
{STRINGS.ticket.title}
|
||||||
</h1>
|
</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 */}
|
{/* Purchase Info */}
|
||||||
<div className="bg-gray-900 rounded-xl p-6 mb-6 border border-gray-800">
|
<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">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
interface DrawAnimationProps {
|
interface DrawAnimationProps {
|
||||||
winnerName: string;
|
winnerName?: string;
|
||||||
winningTicket: number;
|
winningTicket?: number;
|
||||||
potAmount: number;
|
potAmount?: number;
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
|
// If true, show "drawing" animation without winner info
|
||||||
|
isDrawing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawAnimation({
|
export function DrawAnimation({
|
||||||
@@ -14,11 +16,14 @@ export function DrawAnimation({
|
|||||||
winningTicket,
|
winningTicket,
|
||||||
potAmount,
|
potAmount,
|
||||||
onComplete,
|
onComplete,
|
||||||
|
isDrawing = false,
|
||||||
}: DrawAnimationProps) {
|
}: 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 [displayTicket, setDisplayTicket] = useState(0);
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
|
||||||
|
const hasWinner = winnerName !== undefined && winningTicket !== undefined && potAmount !== undefined;
|
||||||
|
|
||||||
// Generate random ticket numbers during spin
|
// Generate random ticket numbers during spin
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'spinning') return;
|
if (phase !== 'spinning') return;
|
||||||
@@ -27,20 +32,40 @@ export function DrawAnimation({
|
|||||||
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
|
setDisplayTicket(Math.floor(Math.random() * 999999999) + 1);
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
// After 2.5 seconds, start revealing
|
// After 2.5 seconds, start revealing (only if we have winner info)
|
||||||
const revealTimeout = setTimeout(() => {
|
const revealTimeout = setTimeout(() => {
|
||||||
setPhase('revealing');
|
if (hasWinner) {
|
||||||
|
setPhase('revealing');
|
||||||
|
}
|
||||||
|
// If no winner, keep spinning until we get data or timeout
|
||||||
}, 2500);
|
}, 2500);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(spinInterval);
|
clearInterval(spinInterval);
|
||||||
clearTimeout(revealTimeout);
|
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
|
// Slow down and reveal actual number
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== 'revealing') return;
|
if (phase !== 'revealing' || !winningTicket) return;
|
||||||
|
|
||||||
let speed = 50;
|
let speed = 50;
|
||||||
let iterations = 0;
|
let iterations = 0;
|
||||||
@@ -69,11 +94,24 @@ export function DrawAnimation({
|
|||||||
|
|
||||||
setShowConfetti(true);
|
setShowConfetti(true);
|
||||||
|
|
||||||
// Auto-dismiss after 6 seconds
|
// Auto-dismiss after 15 seconds (give time to load next cycle)
|
||||||
const dismissTimeout = setTimeout(() => {
|
const dismissTimeout = setTimeout(() => {
|
||||||
setPhase('done');
|
setPhase('done');
|
||||||
onComplete();
|
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);
|
return () => clearTimeout(dismissTimeout);
|
||||||
}, [phase, onComplete]);
|
}, [phase, onComplete]);
|
||||||
@@ -88,7 +126,7 @@ export function DrawAnimation({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
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 */}
|
{/* Confetti Effect */}
|
||||||
{showConfetti && (
|
{showConfetti && (
|
||||||
@@ -130,7 +168,7 @@ export function DrawAnimation({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Winner Phase */}
|
{/* Winner Phase */}
|
||||||
{phase === 'winner' && (
|
{phase === 'winner' && hasWinner && (
|
||||||
<div className="animate-winner-reveal">
|
<div className="animate-winner-reveal">
|
||||||
<div className="text-4xl mb-4">🎉🏆🎉</div>
|
<div className="text-4xl mb-4">🎉🏆🎉</div>
|
||||||
<div className="text-3xl md:text-4xl font-bold text-yellow-400 mb-6">
|
<div className="text-3xl md:text-4xl font-bold text-yellow-400 mb-6">
|
||||||
@@ -143,11 +181,32 @@ export function DrawAnimation({
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-gray-300 text-sm mb-1">Winning Ticket</div>
|
<div className="text-gray-300 text-sm mb-1">Winning Ticket</div>
|
||||||
<div className="text-2xl font-mono text-bitcoin-orange mb-4">
|
<div className="text-2xl font-mono text-bitcoin-orange mb-4">
|
||||||
#{winningTicket.toLocaleString()}
|
#{winningTicket!.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-300 text-sm mb-1">Prize</div>
|
<div className="text-gray-300 text-sm mb-1">Prize</div>
|
||||||
<div className="text-4xl md:text-5xl font-bold text-green-400">
|
<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>
|
</div>
|
||||||
<div className="mt-6 text-gray-400 text-sm animate-pulse">
|
<div className="mt-6 text-gray-400 text-sm animate-pulse">
|
||||||
@@ -211,4 +270,3 @@ export function DrawAnimation({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { formatCountdown } from '@/lib/format';
|
|||||||
|
|
||||||
interface JackpotCountdownProps {
|
interface JackpotCountdownProps {
|
||||||
scheduledAt: string;
|
scheduledAt: string;
|
||||||
|
drawCompleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JackpotCountdown({ scheduledAt }: JackpotCountdownProps) {
|
export function JackpotCountdown({ scheduledAt, drawCompleted = false }: JackpotCountdownProps) {
|
||||||
const [countdown, setCountdown] = useState(formatCountdown(scheduledAt));
|
const [countdown, setCountdown] = useState(formatCountdown(scheduledAt));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -19,7 +20,14 @@ export function JackpotCountdown({ scheduledAt }: JackpotCountdownProps) {
|
|||||||
}, [scheduledAt]);
|
}, [scheduledAt]);
|
||||||
|
|
||||||
if (countdown.total <= 0) {
|
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 (
|
return (
|
||||||
|
|||||||
1
telegram_bot/.gitignore
vendored
1
telegram_bot/.gitignore
vendored
@@ -26,3 +26,4 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,3 +42,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -144,3 +144,4 @@ User states:
|
|||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,3 +35,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,3 +48,4 @@ export const config = {
|
|||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -339,3 +339,4 @@ export default {
|
|||||||
broadcastDrawReminder,
|
broadcastDrawReminder,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -143,3 +143,4 @@ class ApiClient {
|
|||||||
export const apiClient = new ApiClient();
|
export const apiClient = new ApiClient();
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -222,3 +222,4 @@ class GroupStateManager {
|
|||||||
export const groupStateManager = new GroupStateManager();
|
export const groupStateManager = new GroupStateManager();
|
||||||
export default groupStateManager;
|
export default groupStateManager;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,4 @@ export const logPaymentEvent = (
|
|||||||
|
|
||||||
export default logger;
|
export default logger;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ export async function generateQRCode(data: string): Promise<Buffer> {
|
|||||||
|
|
||||||
export default { generateQRCode };
|
export default { generateQRCode };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -260,3 +260,4 @@ class StateManager {
|
|||||||
export const stateManager = new StateManager();
|
export const stateManager = new StateManager();
|
||||||
export default stateManager;
|
export default stateManager;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
ticketPurchaseAllowed: false, // Disabled by default for privacy - users should buy in DM
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -69,3 +69,4 @@ export function truncate(str: string, maxLength: number): string {
|
|||||||
return str.substring(0, maxLength - 3) + '...';
|
return str.substring(0, maxLength - 3) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,4 @@
|
|||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user