This commit is contained in:
Aditya Siregar 2025-09-13 16:53:25 +07:00
parent fb6e2571a5
commit 50a8f4a295
2 changed files with 412 additions and 224 deletions

View File

@ -26,16 +26,24 @@ interface WinnerHistory {
const RandomDrawApp: React.FC = () => { const RandomDrawApp: React.FC = () => {
const [spinRows, setSpinRows] = useState<SpinRow[]>([]); const [spinRows, setSpinRows] = useState<SpinRow[]>([]);
const [numberOfRows, setNumberOfRows] = useState<number>(4); const [numberOfRows, setNumberOfRows] = useState<number>(5);
const [winnersHistory, setWinnersHistory] = useState<WinnerHistory[]>([]); const [winnersHistory, setWinnersHistory] = useState<WinnerHistory[]>([]);
const [isRevealingWinners, setIsRevealingWinners] = useState<boolean>(false); const [isRevealingWinners, setIsRevealingWinners] = useState<boolean>(false);
const [revealedWinners, setRevealedWinners] = useState<Set<number>>(new Set()); const [revealedWinners, setRevealedWinners] = useState<Set<number>>(new Set());
const [isSequentialSpinning, setIsSequentialSpinning] = useState<boolean>(false);
const [currentSpinningRow, setCurrentSpinningRow] = useState<number | null>(null);
const [selectedPrize, setSelectedPrize] = useState<number>(1); // 1 for Emas 0.25 Gram, 2 for Emas 3 Gram
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
const tickIntervalRefs = useRef<Map<string, NodeJS.Timeout>>(new Map()); const tickIntervalRefs = useRef<Map<string, NodeJS.Timeout>>(new Map());
const shuffleIntervalRefs = useRef<Map<string, NodeJS.Timeout>>(new Map()); const shuffleIntervalRefs = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Use React Query hook for fetching voucher data // Use React Query hook for fetching voucher data - disabled by default
const { data: voucherData, isLoading: isLoadingData, refetch: refetchVouchers, error: fetchError } = useVoucherRows({ rows: numberOfRows }); const { data: voucherData, isLoading: isLoadingData, refetch: refetchVouchers, error: fetchError } = useVoucherRows({
rows: numberOfRows,
winner_number: selectedPrize
}, {
enabled: false // Disable automatic fetching - only fetch when Load Data is clicked
});
// Initialize Audio Context // Initialize Audio Context
useEffect(() => { useEffect(() => {
@ -44,9 +52,9 @@ const RandomDrawApp: React.FC = () => {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)(); audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
} }
}; };
document.addEventListener('click', initAudio, { once: true }); document.addEventListener('click', initAudio, { once: true });
return () => { return () => {
document.removeEventListener('click', initAudio); document.removeEventListener('click', initAudio);
tickIntervalRefs.current.forEach(timeout => clearTimeout(timeout)); tickIntervalRefs.current.forEach(timeout => clearTimeout(timeout));
@ -57,21 +65,21 @@ const RandomDrawApp: React.FC = () => {
// Create tick sound effect // Create tick sound effect
const playTickSound = () => { const playTickSound = () => {
if (!audioContextRef.current) return; if (!audioContextRef.current) return;
const ctx = audioContextRef.current; const ctx = audioContextRef.current;
const oscillator = ctx.createOscillator(); const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
oscillator.connect(gainNode); oscillator.connect(gainNode);
gainNode.connect(ctx.destination); gainNode.connect(ctx.destination);
oscillator.frequency.setValueAtTime(800, ctx.currentTime); oscillator.frequency.setValueAtTime(800, ctx.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.05); oscillator.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.05);
gainNode.gain.setValueAtTime(0, ctx.currentTime); gainNode.gain.setValueAtTime(0, ctx.currentTime);
gainNode.gain.linearRampToValueAtTime(0.1, ctx.currentTime + 0.01); gainNode.gain.linearRampToValueAtTime(0.1, ctx.currentTime + 0.01);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.05); gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.05);
oscillator.start(ctx.currentTime); oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.05); oscillator.stop(ctx.currentTime + 0.05);
}; };
@ -79,12 +87,12 @@ const RandomDrawApp: React.FC = () => {
// Create drum roll sound // Create drum roll sound
const playDrumRoll = () => { const playDrumRoll = () => {
if (!audioContextRef.current) return; if (!audioContextRef.current) return;
const ctx = audioContextRef.current; const ctx = audioContextRef.current;
const oscillator = ctx.createOscillator(); const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
const noiseNode = ctx.createBufferSource(); const noiseNode = ctx.createBufferSource();
// Create noise buffer for snare-like sound // Create noise buffer for snare-like sound
const bufferSize = ctx.sampleRate * 0.05; const bufferSize = ctx.sampleRate * 0.05;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
@ -93,15 +101,15 @@ const RandomDrawApp: React.FC = () => {
data[i] = Math.random() * 2 - 1; data[i] = Math.random() * 2 - 1;
} }
noiseNode.buffer = buffer; noiseNode.buffer = buffer;
oscillator.connect(gainNode); oscillator.connect(gainNode);
noiseNode.connect(gainNode); noiseNode.connect(gainNode);
gainNode.connect(ctx.destination); gainNode.connect(ctx.destination);
oscillator.frequency.setValueAtTime(200, ctx.currentTime); oscillator.frequency.setValueAtTime(200, ctx.currentTime);
gainNode.gain.setValueAtTime(0.05, ctx.currentTime); gainNode.gain.setValueAtTime(0.05, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1); gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.1);
oscillator.start(ctx.currentTime); oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.1); oscillator.stop(ctx.currentTime + 0.1);
noiseNode.start(ctx.currentTime); noiseNode.start(ctx.currentTime);
@ -110,23 +118,23 @@ const RandomDrawApp: React.FC = () => {
// Create winner sound // Create winner sound
const playWinnerSound = () => { const playWinnerSound = () => {
if (!audioContextRef.current) return; if (!audioContextRef.current) return;
const ctx = audioContextRef.current; const ctx = audioContextRef.current;
const notes = [523.25, 659.25, 783.99, 1046.5]; // C, E, G, C (higher octave) const notes = [523.25, 659.25, 783.99, 1046.5]; // C, E, G, C (higher octave)
notes.forEach((freq, index) => { notes.forEach((freq, index) => {
const oscillator = ctx.createOscillator(); const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
oscillator.connect(gainNode); oscillator.connect(gainNode);
gainNode.connect(ctx.destination); gainNode.connect(ctx.destination);
oscillator.frequency.setValueAtTime(freq, ctx.currentTime + index * 0.1); oscillator.frequency.setValueAtTime(freq, ctx.currentTime + index * 0.1);
gainNode.gain.setValueAtTime(0, ctx.currentTime + index * 0.1); gainNode.gain.setValueAtTime(0, ctx.currentTime + index * 0.1);
gainNode.gain.linearRampToValueAtTime(0.2, ctx.currentTime + index * 0.1 + 0.05); gainNode.gain.linearRampToValueAtTime(0.2, ctx.currentTime + index * 0.1 + 0.05);
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + index * 0.1 + 0.5); gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + index * 0.1 + 0.5);
oscillator.start(ctx.currentTime + index * 0.1); oscillator.start(ctx.currentTime + index * 0.1);
oscillator.stop(ctx.currentTime + index * 0.1 + 0.5); oscillator.stop(ctx.currentTime + index * 0.1 + 0.5);
}); });
@ -138,23 +146,23 @@ const RandomDrawApp: React.FC = () => {
if (existingInterval) { if (existingInterval) {
clearTimeout(existingInterval); clearTimeout(existingInterval);
} }
let tickInterval = 50; let tickInterval = 50;
const maxInterval = 200; const maxInterval = 200;
const intervalIncrement = (maxInterval - tickInterval) / (duration / 100); const intervalIncrement = (maxInterval - tickInterval) / (duration / 100);
const tick = () => { const tick = () => {
playTickSound(); playTickSound();
tickInterval += intervalIncrement; tickInterval += intervalIncrement;
if (tickInterval < maxInterval) { if (tickInterval < maxInterval) {
const timeoutId = setTimeout(tick, Math.min(tickInterval, maxInterval)); const timeoutId = setTimeout(tick, Math.min(tickInterval, maxInterval));
tickIntervalRefs.current.set(rowId, timeoutId); tickIntervalRefs.current.set(rowId, timeoutId);
} }
}; };
tick(); tick();
setTimeout(() => { setTimeout(() => {
const interval = tickIntervalRefs.current.get(rowId); const interval = tickIntervalRefs.current.get(rowId);
if (interval) { if (interval) {
@ -200,10 +208,7 @@ const RandomDrawApp: React.FC = () => {
} }
}, [fetchError]); }, [fetchError]);
// Refetch when numberOfRows changes // Remove auto-refetch - data will only load when Load Data button is clicked
useEffect(() => {
refetchVouchers();
}, [numberOfRows]);
// Shuffle all rows (Acak functionality) // Shuffle all rows (Acak functionality)
const shuffleAllRows = () => { const shuffleAllRows = () => {
@ -216,10 +221,10 @@ const RandomDrawApp: React.FC = () => {
spinRows.forEach((row, index) => { spinRows.forEach((row, index) => {
let shuffleCount = 0; let shuffleCount = 0;
const maxShuffles = 20; const maxShuffles = 20;
const shuffleInterval = setInterval(() => { const shuffleInterval = setInterval(() => {
shuffleCount++; shuffleCount++;
setSpinRows(prev => prev.map(r => { setSpinRows(prev => prev.map(r => {
if (r.id === row.id) { if (r.id === row.id) {
return { return {
@ -229,9 +234,9 @@ const RandomDrawApp: React.FC = () => {
} }
return r; return r;
})); }));
playTickSound(); playTickSound();
if (shuffleCount >= maxShuffles) { if (shuffleCount >= maxShuffles) {
clearInterval(shuffleInterval); clearInterval(shuffleInterval);
setTimeout(() => { setTimeout(() => {
@ -247,7 +252,7 @@ const RandomDrawApp: React.FC = () => {
}, index * 100); }, index * 100);
} }
}, 100); }, 100);
shuffleIntervalRefs.current.set(row.id, shuffleInterval as any); shuffleIntervalRefs.current.set(row.id, shuffleInterval as any);
}); });
}; };
@ -256,47 +261,47 @@ const RandomDrawApp: React.FC = () => {
const revealWinnersDramatically = async () => { const revealWinnersDramatically = async () => {
setIsRevealingWinners(true); setIsRevealingWinners(true);
setRevealedWinners(new Set()); setRevealedWinners(new Set());
// First, arrange winners to be in the winner zone for each row // First, arrange winners to be in the winner zone for each row
const arrangedRows = spinRows.map(row => { const arrangedRows = spinRows.map(row => {
const winner = row.vouchers.find(v => v.is_winner); const winner = row.vouchers.find(v => v.is_winner);
if (!winner) return row; if (!winner) return row;
// Create a new display order with winner at position 3 or 4 (will be in winner zone) // Create a new display order with winner at position 3 or 4 (will be in winner zone)
const otherVouchers = row.vouchers.filter(v => v.voucher_code !== winner.voucher_code); const otherVouchers = row.vouchers.filter(v => v.voucher_code !== winner.voucher_code);
const shuffledOthers = shuffleArray(otherVouchers); const shuffledOthers = shuffleArray(otherVouchers);
// Place winner at index 3 (4th position) which will be in the winner zone // Place winner at index 3 (4th position) which will be in the winner zone
const newDisplayVouchers = [ const newDisplayVouchers = [
...shuffledOthers.slice(0, 3), ...shuffledOthers.slice(0, 3),
winner, winner,
...shuffledOthers.slice(3) ...shuffledOthers.slice(3)
]; ];
return { return {
...row, ...row,
displayVouchers: newDisplayVouchers displayVouchers: newDisplayVouchers
}; };
}); });
// Update display to show arranged vouchers // Update display to show arranged vouchers
setSpinRows(arrangedRows); setSpinRows(arrangedRows);
// Start drum roll effect // Start drum roll effect
const drumRollInterval = setInterval(() => { const drumRollInterval = setInterval(() => {
playDrumRoll(); playDrumRoll();
}, 100); }, 100);
// Create a scrolling/spinning effect for all wheels simultaneously // Create a scrolling/spinning effect for all wheels simultaneously
let scrollSpeed = 50; let scrollSpeed = 50;
let currentScroll = 0; let currentScroll = 0;
const maxScroll = 2000; const maxScroll = 2000;
const deceleration = 0.95; const deceleration = 0.95;
const spinInterval = setInterval(() => { const spinInterval = setInterval(() => {
currentScroll += scrollSpeed; currentScroll += scrollSpeed;
scrollSpeed *= deceleration; scrollSpeed *= deceleration;
// Apply scrolling to all wheels // Apply scrolling to all wheels
arrangedRows.forEach(row => { arrangedRows.forEach(row => {
const wheelElement = row.wheelRef.current; const wheelElement = row.wheelRef.current;
@ -305,12 +310,12 @@ const RandomDrawApp: React.FC = () => {
wheelElement.style.transform = `translateY(${-currentScroll}px)`; wheelElement.style.transform = `translateY(${-currentScroll}px)`;
} }
}); });
// Stop when speed is very slow // Stop when speed is very slow
if (scrollSpeed < 0.5 || currentScroll >= maxScroll) { if (scrollSpeed < 0.5 || currentScroll >= maxScroll) {
clearInterval(spinInterval); clearInterval(spinInterval);
clearInterval(drumRollInterval); clearInterval(drumRollInterval);
// Final positioning - snap to winner position // Final positioning - snap to winner position
arrangedRows.forEach(row => { arrangedRows.forEach(row => {
const wheelElement = row.wheelRef.current; const wheelElement = row.wheelRef.current;
@ -319,20 +324,20 @@ const RandomDrawApp: React.FC = () => {
const WINNER_ZONE_TOP = 260; // Top position of winner zone const WINNER_ZONE_TOP = 260; // Top position of winner zone
// Position 4th card (index 3) exactly at the winner zone // Position 4th card (index 3) exactly at the winner zone
const finalPosition = -(3 * CARD_HEIGHT - WINNER_ZONE_TOP); const finalPosition = -(3 * CARD_HEIGHT - WINNER_ZONE_TOP);
wheelElement.style.transition = 'transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; wheelElement.style.transition = 'transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
wheelElement.style.transform = `translateY(${finalPosition}px)`; wheelElement.style.transform = `translateY(${finalPosition}px)`;
} }
}); });
} }
}, 20); }, 20);
// Wait for scrolling to complete // Wait for scrolling to complete
await new Promise(resolve => setTimeout(resolve, 3000)); await new Promise(resolve => setTimeout(resolve, 3000));
// Play winner sound // Play winner sound
playWinnerSound(); playWinnerSound();
// Collect all winners // Collect all winners
const winnersToReveal: { row: SpinRow, winner: Voucher }[] = []; const winnersToReveal: { row: SpinRow, winner: Voucher }[] = [];
arrangedRows.forEach(row => { arrangedRows.forEach(row => {
@ -341,11 +346,11 @@ const RandomDrawApp: React.FC = () => {
winnersToReveal.push({ row, winner }); winnersToReveal.push({ row, winner });
} }
}); });
// Update all winners at once with highlighting // Update all winners at once with highlighting
const allWinnerNumbers = new Set<number>(); const allWinnerNumbers = new Set<number>();
const historyEntries: WinnerHistory[] = []; const historyEntries: WinnerHistory[] = [];
winnersToReveal.forEach(({ row, winner }) => { winnersToReveal.forEach(({ row, winner }) => {
allWinnerNumbers.add(row.rowNumber); allWinnerNumbers.add(row.rowNumber);
historyEntries.push({ historyEntries.push({
@ -354,10 +359,10 @@ const RandomDrawApp: React.FC = () => {
timestamp: new Date() timestamp: new Date()
}); });
}); });
// Update state to highlight winners // Update state to highlight winners
setRevealedWinners(allWinnerNumbers); setRevealedWinners(allWinnerNumbers);
setSpinRows(prev => prev.map(r => { setSpinRows(prev => prev.map(r => {
const winnerInfo = winnersToReveal.find(w => w.row.id === r.id); const winnerInfo = winnersToReveal.find(w => w.row.id === r.id);
if (winnerInfo) { if (winnerInfo) {
@ -369,12 +374,112 @@ const RandomDrawApp: React.FC = () => {
} }
return r; return r;
})); }));
setWinnersHistory(prev => [...prev, ...historyEntries]); setWinnersHistory(prev => [...prev, ...historyEntries]);
setIsRevealingWinners(false); setIsRevealingWinners(false);
}; };
const performDramaticSpin = (row: SpinRow, isSequential: boolean = false) => {
const wheelElement = row.wheelRef.current;
if (!wheelElement) return;
const winner = row.vouchers.find(v => v.is_winner);
if (!winner) {
console.error('No winner found in vouchers');
return;
}
const CARD_HEIGHT = 80;
const WINNER_ZONE_TOP = 260; // Top position of winner zone
// Shuffle the display vouchers to create more dramatic effect
const shuffledVouchers = shuffleArray([...row.vouchers]);
const winnerIndex = shuffledVouchers.findIndex(v => v.voucher_code === winner.voucher_code);
// Update display vouchers with shuffled order
setSpinRows(prev => prev.map(r =>
r.id === row.id ? { ...r, displayVouchers: shuffledVouchers } : r
));
wheelElement.style.transition = 'none';
wheelElement.style.transform = 'translateY(0px)';
wheelElement.offsetHeight;
setTimeout(() => {
// More dramatic spinning with multiple phases
const minSpins = 15;
const maxSpins = 25;
const spins = minSpins + Math.random() * (maxSpins - minSpins);
const totalItems = shuffledVouchers.length;
const totalRotations = Math.floor(spins);
const baseScrollDistance = totalRotations * totalItems * CARD_HEIGHT;
const winnerScrollPosition = winnerIndex * CARD_HEIGHT;
const finalPosition = -(baseScrollDistance + winnerScrollPosition) + WINNER_ZONE_TOP;
const distance = Math.abs(finalPosition);
const baseDuration = 4000; // Longer base duration
const maxDuration = 7000; // Longer max duration
const duration = Math.min(baseDuration + (distance / 8000) * 2000, maxDuration);
// Phase 1: Fast initial spin
const phase1Duration = duration * 0.3;
const phase1Distance = finalPosition * 0.7;
wheelElement.style.transform = `translateY(${phase1Distance}px)`;
wheelElement.style.transition = `transform ${phase1Duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`;
// Phase 2: Slower approach to final position
setTimeout(() => {
wheelElement.style.transform = `translateY(${finalPosition}px)`;
wheelElement.style.transition = `transform ${duration - phase1Duration}ms cubic-bezier(0.17, 0.67, 0.12, 0.99)`;
}, phase1Duration);
startTickSound(row.id, duration);
setTimeout(() => {
const interval = tickIntervalRefs.current.get(row.id);
if (interval) {
clearTimeout(interval);
tickIntervalRefs.current.delete(row.id);
}
playWinnerSound();
setSpinRows(prev => prev.map(r =>
r.id === row.id
? { ...r, isSpinning: false, winner: winner, selectedWinner: winner }
: r
));
setWinnersHistory(prev => [...prev, {
rowNumber: row.rowNumber,
winner: winner,
timestamp: new Date()
}]);
// If this is sequential spinning, trigger next row
if (isSequential) {
const nextRowIndex = spinRows.findIndex(r => r.id === row.id) + 1;
if (nextRowIndex < spinRows.length) {
setTimeout(() => {
spinSequentialRow(nextRowIndex);
}, 2000); // Wait 2 seconds before next spin
} else {
// All rows completed
setIsSequentialSpinning(false);
setCurrentSpinningRow(null);
}
}
// Keep the winner in the winner zone - don't reset position
// The winning card will stay exactly where it landed in the winner zone
}, duration);
}, 100);
};
const performSpin = (row: SpinRow) => { const performSpin = (row: SpinRow) => {
const wheelElement = row.wheelRef.current; const wheelElement = row.wheelRef.current;
if (!wheelElement) return; if (!wheelElement) return;
@ -387,89 +492,113 @@ const RandomDrawApp: React.FC = () => {
const CARD_HEIGHT = 80; const CARD_HEIGHT = 80;
const WINNER_ZONE_TOP = 260; // Top position of winner zone const WINNER_ZONE_TOP = 260; // Top position of winner zone
const winnerIndex = row.displayVouchers.findIndex(v => v.voucher_code === winner.voucher_code); // Shuffle the display vouchers to create more dramatic effect
const shuffledVouchers = shuffleArray([...row.vouchers]);
const winnerIndex = shuffledVouchers.findIndex(v => v.voucher_code === winner.voucher_code);
// Update display vouchers with shuffled order
setSpinRows(prev => prev.map(r =>
r.id === row.id ? { ...r, displayVouchers: shuffledVouchers } : r
));
wheelElement.style.transition = 'none'; wheelElement.style.transition = 'none';
wheelElement.style.transform = 'translateY(0px)'; wheelElement.style.transform = 'translateY(0px)';
wheelElement.offsetHeight; wheelElement.offsetHeight;
setTimeout(() => { setTimeout(() => {
const minSpins = 10; // More dramatic spinning with multiple phases
const maxSpins = 20; const minSpins = 15;
const maxSpins = 25;
const spins = minSpins + Math.random() * (maxSpins - minSpins); const spins = minSpins + Math.random() * (maxSpins - minSpins);
const totalItems = row.displayVouchers.length; const totalItems = shuffledVouchers.length;
const totalRotations = Math.floor(spins); const totalRotations = Math.floor(spins);
const baseScrollDistance = totalRotations * totalItems * CARD_HEIGHT; const baseScrollDistance = totalRotations * totalItems * CARD_HEIGHT;
const winnerScrollPosition = winnerIndex * CARD_HEIGHT; const winnerScrollPosition = winnerIndex * CARD_HEIGHT;
const finalPosition = -(baseScrollDistance + winnerScrollPosition) + WINNER_ZONE_TOP; const finalPosition = -(baseScrollDistance + winnerScrollPosition) + WINNER_ZONE_TOP;
const distance = Math.abs(finalPosition); const distance = Math.abs(finalPosition);
const baseDuration = 3000; const baseDuration = 4000; // Longer base duration
const maxDuration = 5000; const maxDuration = 7000; // Longer max duration
const duration = Math.min(baseDuration + (distance / 10000) * 1000, maxDuration); const duration = Math.min(baseDuration + (distance / 8000) * 2000, maxDuration);
wheelElement.style.transform = `translateY(${finalPosition}px)`; // Phase 1: Fast initial spin
wheelElement.style.transition = `transform ${duration}ms cubic-bezier(0.17, 0.67, 0.12, 0.99)`; const phase1Duration = duration * 0.3;
const phase1Distance = finalPosition * 0.7;
wheelElement.style.transform = `translateY(${phase1Distance}px)`;
wheelElement.style.transition = `transform ${phase1Duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`;
// Phase 2: Slower approach to final position
setTimeout(() => {
wheelElement.style.transform = `translateY(${finalPosition}px)`;
wheelElement.style.transition = `transform ${duration - phase1Duration}ms cubic-bezier(0.17, 0.67, 0.12, 0.99)`;
}, phase1Duration);
startTickSound(row.id, duration); startTickSound(row.id, duration);
setTimeout(() => { setTimeout(() => {
const interval = tickIntervalRefs.current.get(row.id); const interval = tickIntervalRefs.current.get(row.id);
if (interval) { if (interval) {
clearTimeout(interval); clearTimeout(interval);
tickIntervalRefs.current.delete(row.id); tickIntervalRefs.current.delete(row.id);
} }
playWinnerSound(); playWinnerSound();
setSpinRows(prev => prev.map(r => setSpinRows(prev => prev.map(r =>
r.id === row.id r.id === row.id
? { ...r, isSpinning: false, winner: winner, selectedWinner: winner } ? { ...r, isSpinning: false, winner: winner, selectedWinner: winner }
: r : r
)); ));
setWinnersHistory(prev => [...prev, { setWinnersHistory(prev => [...prev, {
rowNumber: row.rowNumber, rowNumber: row.rowNumber,
winner: winner, winner: winner,
timestamp: new Date() timestamp: new Date()
}]); }]);
setTimeout(() => { // Keep the winner in the winner zone - don't reset position
wheelElement.style.transition = 'none'; // The winning card will stay exactly where it landed in the winner zone
wheelElement.style.transform = 'translateY(0px)';
wheelElement.offsetHeight;
}, 1500);
}, duration); }, duration);
}, 100); }, 100);
}; };
const spinWheel = (rowId: string) => { const spinSequentialRow = (rowIndex: number) => {
const row = spinRows.find(r => r.id === rowId); if (rowIndex >= spinRows.length) {
if (!row || row.isSpinning || row.winner) return; setIsSequentialSpinning(false);
setCurrentSpinningRow(null);
setSpinRows(prev => prev.map(r => return;
r.id === rowId ? { ...r, isSpinning: true, selectedWinner: null } : r }
const row = spinRows[rowIndex];
if (!row || row.isSpinning || row.winner) {
// Skip this row and go to next
spinSequentialRow(rowIndex + 1);
return;
}
setCurrentSpinningRow(row.rowNumber);
setSpinRows(prev => prev.map(r =>
r.id === row.id ? { ...r, isSpinning: true, selectedWinner: null } : r
)); ));
const wheelElement = row.wheelRef.current; const wheelElement = row.wheelRef.current;
if (wheelElement) { if (wheelElement) {
wheelElement.style.transition = 'none'; wheelElement.style.transition = 'none';
wheelElement.style.transform = 'translateY(0px)'; wheelElement.style.transform = 'translateY(0px)';
wheelElement.offsetHeight; wheelElement.offsetHeight;
} }
performSpin(row); performDramaticSpin(row, true);
}; };
const clearResults = () => { const startSequentialSpinning = () => {
tickIntervalRefs.current.forEach(timeout => clearTimeout(timeout)); if (spinRows.length === 0) return;
shuffleIntervalRefs.current.forEach(timeout => clearTimeout(timeout));
tickIntervalRefs.current.clear(); // Clear any existing results
shuffleIntervalRefs.current.clear();
setWinnersHistory([]); setWinnersHistory([]);
setRevealedWinners(new Set()); setRevealedWinners(new Set());
setSpinRows(prev => prev.map(row => ({ setSpinRows(prev => prev.map(row => ({
@ -480,7 +609,62 @@ const RandomDrawApp: React.FC = () => {
selectedWinner: null, selectedWinner: null,
displayVouchers: [...row.vouchers] displayVouchers: [...row.vouchers]
}))); })));
// Reset all wheel positions
spinRows.forEach(row => {
const wheelElement = row.wheelRef.current;
if (wheelElement) {
wheelElement.style.transition = 'none';
wheelElement.style.transform = 'translateY(0px)';
}
});
setIsSequentialSpinning(true);
setCurrentSpinningRow(null);
// Start with first row after a short delay
setTimeout(() => {
spinSequentialRow(0);
}, 500);
};
const spinWheel = (rowId: string) => {
const row = spinRows.find(r => r.id === rowId);
if (!row || row.isSpinning || row.winner) return;
setSpinRows(prev => prev.map(r =>
r.id === rowId ? { ...r, isSpinning: true, selectedWinner: null } : r
));
const wheelElement = row.wheelRef.current;
if (wheelElement) {
wheelElement.style.transition = 'none';
wheelElement.style.transform = 'translateY(0px)';
wheelElement.offsetHeight;
}
performSpin(row);
};
const clearResults = () => {
tickIntervalRefs.current.forEach(timeout => clearTimeout(timeout));
shuffleIntervalRefs.current.forEach(timeout => clearTimeout(timeout));
tickIntervalRefs.current.clear();
shuffleIntervalRefs.current.clear();
setWinnersHistory([]);
setRevealedWinners(new Set());
setIsSequentialSpinning(false);
setCurrentSpinningRow(null);
setSpinRows(prev => prev.map(row => ({
...row,
isSpinning: false,
isShuffling: false,
winner: null,
selectedWinner: null,
displayVouchers: [...row.vouchers]
})));
spinRows.forEach(row => { spinRows.forEach(row => {
const wheelElement = row.wheelRef.current; const wheelElement = row.wheelRef.current;
if (wheelElement) { if (wheelElement) {
@ -495,7 +679,7 @@ const RandomDrawApp: React.FC = () => {
shuffleIntervalRefs.current.forEach(timeout => clearTimeout(timeout)); shuffleIntervalRefs.current.forEach(timeout => clearTimeout(timeout));
tickIntervalRefs.current.clear(); tickIntervalRefs.current.clear();
shuffleIntervalRefs.current.clear(); shuffleIntervalRefs.current.clear();
try { try {
await refetchVouchers(); await refetchVouchers();
toast.success('Data refreshed successfully'); toast.success('Data refreshed successfully');
@ -527,97 +711,110 @@ const RandomDrawApp: React.FC = () => {
opacity: 1; opacity: 1;
} }
} }
.animate-bounce-in { .animate-bounce-in {
animation: bounce-in 0.6s ease-out; animation: bounce-in 0.6s ease-out;
} }
`}</style> `}</style>
<div className="max-w-full px-4 mx-auto"> <div className="max-w-full px-4 mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-5xl font-bold bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent mb-2"> <h1 className="text-5xl font-bold bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent mb-2">
Voucher Lucky Draw Voucher Lucky Draw
</h1> </h1>
<p className="text-gray-600 text-lg">Select random winners from voucher database</p>
</div> </div>
<div className="space-y-6"> <div className="space-y-4">
{/* Controls Section */} {/* Controls Section - Compact Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
{/* Configuration Card */} {/* Configuration Card */}
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm"> <div className="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<h2 className="text-xl font-semibold mb-4 text-gray-900"> <h2 className="text-lg font-semibold mb-3 text-gray-900">
Configuration Configuration
</h2> </h2>
<div className="space-y-4"> <div className="space-y-3">
<div> <div className="grid grid-cols-2 gap-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> <div>
Number of Rows <label className="block text-xs font-medium text-gray-700 mb-1">
</label> Number of Rows
<input </label>
type="number" <input
min="1" type="number"
max="10" min="1"
value={numberOfRows} max="10"
onChange={(e) => setNumberOfRows(parseInt(e.target.value) || 1)} value={numberOfRows}
className="w-full bg-white border border-gray-300 rounded-lg p-2 text-gray-900 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" onChange={(e) => setNumberOfRows(parseInt(e.target.value) || 1)}
disabled={isLoadingData} className="w-full bg-white border border-gray-300 rounded p-1.5 text-sm text-gray-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
/> disabled={isLoadingData}
</div> />
</div>
<button
onClick={refreshData} <div>
disabled={isLoadingData} <label className="block text-xs font-medium text-gray-700 mb-1">
className="w-full px-4 py-2 bg-primary hover:bg-primary/90 disabled:bg-gray-400 text-white font-semibold rounded-lg flex items-center justify-center gap-2 transition-colors" Prize Selection
> </label>
<RefreshCw size={16} className={isLoadingData ? 'animate-spin' : ''} /> <select
{isLoadingData ? 'Loading...' : 'Load Data'} value={selectedPrize}
</button> onChange={(e) => setSelectedPrize(parseInt(e.target.value))}
</div> className="w-full bg-white border border-gray-300 rounded p-1.5 text-sm text-gray-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
disabled={isLoadingData}
{spinRows.length > 0 && ( >
<div className="mt-4 p-3 bg-gray-50 rounded-lg"> <option value={1}>🥇 Emas 0.25 Gram</option>
<div className="text-sm text-gray-600"> <option value={2}>🥇 Emas 3 Gram</option>
<div>Rows: {spinRows.length}</div> </select>
<div>Total Vouchers: {spinRows.reduce((acc, row) => acc + row.vouchers.length, 0)}</div> </div>
</div> </div>
<button
onClick={refreshData}
disabled={isLoadingData}
className="w-full px-3 py-1.5 bg-primary hover:bg-primary/90 disabled:bg-gray-400 text-white text-sm font-semibold rounded flex items-center justify-center gap-1 transition-colors"
>
<RefreshCw size={14} className={isLoadingData ? 'animate-spin' : ''} />
{isLoadingData ? 'Loading...' : 'Load Data'}
</button>
</div>
{spinRows.length > 0 && (
<div className="mt-3 p-2 bg-gray-50 rounded text-xs text-gray-600">
<div>Rows: {spinRows.length} | Vouchers: {spinRows.reduce((acc, row) => acc + row.vouchers.length, 0)}</div>
</div> </div>
)} )}
</div> </div>
{/* Actions Card */} {/* Actions Card */}
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm"> <div className="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<h2 className="text-xl font-semibold mb-4 text-gray-900"> <h2 className="text-lg font-semibold mb-3 text-gray-900">
Actions Actions
</h2> </h2>
<div className="space-y-3"> <div className="space-y-2">
<button <button
onClick={shuffleAllRows} onClick={shuffleAllRows}
disabled={isLoadingData || spinRows.some(r => r.isShuffling) || spinRows.length === 0} disabled={isLoadingData || spinRows.some(r => r.isShuffling) || spinRows.length === 0}
className="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 text-white font-semibold rounded-lg flex items-center justify-center gap-2 transition-colors" className="w-full px-3 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 text-white text-sm font-semibold rounded flex items-center justify-center gap-1 transition-colors"
> >
<Shuffle size={16} className={spinRows.some(r => r.isShuffling) ? 'animate-pulse' : ''} /> <Shuffle size={14} className={spinRows.some(r => r.isShuffling) ? 'animate-pulse' : ''} />
Shuffle All Rows Shuffle All
</button> </button>
<button <button
onClick={revealWinnersDramatically} onClick={startSequentialSpinning}
disabled={isRevealingWinners || spinRows.length === 0} disabled={isSequentialSpinning || spinRows.length === 0}
className="w-full px-4 py-2 bg-gradient-to-r from-yellow-400 to-orange-500 hover:from-yellow-500 hover:to-orange-600 disabled:from-gray-400 disabled:to-gray-500 text-white font-bold rounded-lg flex items-center justify-center gap-2 transition-all transform hover:scale-105" className="w-full px-3 py-1.5 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 disabled:from-gray-400 disabled:to-gray-500 text-white text-sm font-bold rounded flex items-center justify-center gap-1 transition-all"
> >
<Trophy size={16} className={isRevealingWinners ? 'animate-bounce' : ''} /> <Trophy size={14} className={isSequentialSpinning ? 'animate-spin' : ''} />
{isRevealingWinners ? 'Revealing...' : 'Reveal All Winners'} {isSequentialSpinning ? 'Spinning...' : 'Spin for Winner'}
</button> </button>
{winnersHistory.length > 0 && ( {winnersHistory.length > 0 && (
<button <button
onClick={clearResults} onClick={clearResults}
className="w-full px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg flex items-center justify-center gap-2 transition-colors" className="w-full px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm font-semibold rounded flex items-center justify-center gap-1 transition-colors"
> >
<Trash2 size={16} /> <Trash2 size={14} />
Clear Results Clear Results
</button> </button>
)} )}
@ -625,18 +822,18 @@ const RandomDrawApp: React.FC = () => {
</div> </div>
{/* Winners Summary Card */} {/* Winners Summary Card */}
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm"> <div className="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<h2 className="text-xl font-semibold mb-4 text-gray-900 flex items-center gap-2"> <h2 className="text-lg font-semibold mb-3 text-gray-900 flex items-center gap-2">
<Trophy className="text-yellow-500" size={24} /> <Trophy className="text-yellow-500" size={18} />
Winners Winners
</h2> </h2>
{winnersHistory.length > 0 ? ( {winnersHistory.length > 0 ? (
<div className="space-y-2 max-h-48 overflow-y-auto"> <div className="space-y-1 max-h-32 overflow-y-auto">
{winnersHistory.map((history) => ( {winnersHistory.map((history) => (
<div <div
key={`${history.rowNumber}-${history.timestamp.getTime()}`} key={`${history.rowNumber}-${history.timestamp.getTime()}`}
className="p-2 bg-yellow-50 border border-yellow-200 rounded text-sm" className="p-1.5 bg-yellow-50 border border-yellow-200 rounded text-xs"
> >
<div className="font-semibold text-yellow-700"> <div className="font-semibold text-yellow-700">
Row {history.rowNumber}: {history.winner.name} Row {history.rowNumber}: {history.winner.name}
@ -648,9 +845,9 @@ const RandomDrawApp: React.FC = () => {
))} ))}
</div> </div>
) : ( ) : (
<div className="text-center py-4"> <div className="text-center py-2">
<Trophy className="mx-auto text-gray-300 mb-2" size={32} /> <Trophy className="mx-auto text-gray-300 mb-1" size={20} />
<p className="text-gray-500 text-sm">No winners yet</p> <p className="text-gray-500 text-xs">No winners yet</p>
</div> </div>
)} )}
</div> </div>
@ -670,22 +867,7 @@ const RandomDrawApp: React.FC = () => {
row.isShuffling ? 'border-purple-400 animate-pulse' : 'border-gray-200' row.isShuffling ? 'border-purple-400 animate-pulse' : 'border-gray-200'
}`}> }`}>
<div className="p-4 border-b border-gray-200"> <div className="p-4 border-b border-gray-200">
<div className="flex flex-col gap-2">
<h3 className="text-base font-semibold text-gray-900">
Row {row.rowNumber}
<span className="text-sm text-gray-600 ml-1">({row.vouchers.length})</span>
</h3>
<button
onClick={() => spinWheel(row.id)}
disabled={row.isSpinning || row.winner !== null || row.isShuffling}
className="w-full px-3 py-2 bg-primary hover:bg-primary/90 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg flex items-center justify-center gap-2 transition-colors text-sm"
>
<RotateCw size={14} className={row.isSpinning ? 'animate-spin' : ''} />
{row.isSpinning ? 'Spinning...' : row.winner ? 'Winner' : 'Spin'}
</button>
</div>
{row.winner && ( {row.winner && (
<div className="mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded-lg animate-bounce-in"> <div className="mt-2 p-2 bg-yellow-50 border border-yellow-200 rounded-lg animate-bounce-in">
<div className="flex items-center gap-1 text-sm"> <div className="flex items-center gap-1 text-sm">
@ -700,54 +882,57 @@ const RandomDrawApp: React.FC = () => {
</div> </div>
)} )}
</div> </div>
{/* Wheel Container */} {/* Wheel Container */}
<div <div
className="relative bg-gray-50 overflow-hidden" className="relative bg-gray-50 overflow-hidden"
style={{ height: '600px' }} style={{ height: '600px' }}
> >
{/* Winner Selection Zone - Exactly 80px matching card height */} {/* Subtle Winner Zone Indicator */}
<div <div
className="absolute left-0 right-0 z-50" className="absolute left-0 right-0 z-10 pointer-events-none"
style={{ style={{
top: '260px', top: '260px',
height: '80px', height: '80px',
border: '3px solid #ef4444', background: 'linear-gradient(90deg, transparent 0%, rgba(34, 197, 94, 0.1) 20%, rgba(34, 197, 94, 0.2) 50%, rgba(34, 197, 94, 0.1) 80%, transparent 100%)',
backgroundColor: 'rgba(239, 68, 68, 0.15)', borderTop: '2px dashed rgba(34, 197, 94, 0.3)',
display: 'flex', borderBottom: '2px dashed rgba(34, 197, 94, 0.3)'
alignItems: 'center',
justifyContent: 'flex-end',
paddingRight: '10px'
}} }}
> >
<div className="bg-red-600 text-white px-3 py-1 rounded font-bold text-xs tracking-wider shadow-lg"> <div className="absolute right-2 top-1/2 transform -translate-y-1/2">
WINNER <div className="bg-green-500 text-white px-2 py-1 rounded text-xs font-bold shadow-lg">
🏆 WINNER
</div>
</div> </div>
</div> </div>
{/* Scrolling Cards */} {/* Scrolling Cards */}
<div <div
ref={row.wheelRef} ref={row.wheelRef}
className="absolute inset-0" className="absolute inset-0"
style={{ transform: 'translateY(0px)' }} style={{ transform: 'translateY(0px)' }}
> >
{Array.from({ length: 50 }, (_, repeatIndex) => {Array.from({ length: 50 }, (_, repeatIndex) =>
row.displayVouchers.map((voucher, voucherIndex) => { row.displayVouchers.map((voucher, voucherIndex) => {
const isSelectedWinner = row.selectedWinner?.voucher_code === voucher.voucher_code; const isSelectedWinner = row.selectedWinner?.voucher_code === voucher.voucher_code;
return ( return (
<div <div
key={`${repeatIndex}-${voucherIndex}`} key={`${repeatIndex}-${voucherIndex}`}
className={`flex items-center px-4 border-b border-gray-300 transition-colors ${ className={`flex items-center px-4 border-b border-gray-300 transition-all duration-300 ${
isSelectedWinner ? 'bg-yellow-100' : isSelectedWinner
voucherIndex % 2 === 0 ? 'bg-white' : 'bg-gray-50' ? 'bg-gradient-to-r from-yellow-100 to-yellow-200 border-yellow-300 shadow-lg'
}`} : voucherIndex % 2 === 0
style={{ ? 'bg-white hover:bg-gray-50'
height: '80px', : 'bg-gray-50 hover:bg-gray-100'
minHeight: '80px', }`}
maxHeight: '80px' style={{
}} height: '80px',
> minHeight: '80px',
maxHeight: '80px',
boxSizing: 'border-box'
}}
>
<div className="flex-1"> <div className="flex-1">
<div className={`font-bold text-lg mb-1 ${ <div className={`font-bold text-lg mb-1 ${
isSelectedWinner ? 'text-yellow-600' : 'text-gray-900' isSelectedWinner ? 'text-yellow-600' : 'text-gray-900'
@ -760,7 +945,7 @@ const RandomDrawApp: React.FC = () => {
{voucher.name} {voucher.name}
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-gray-500 text-xs"> <div className="text-gray-500 text-xs">
{formatPhoneNumber(voucher.phone_number)} {formatPhoneNumber(voucher.phone_number)}
@ -774,7 +959,7 @@ const RandomDrawApp: React.FC = () => {
}) })
)} )}
</div> </div>
{/* Gradient Overlays */} {/* Gradient Overlays */}
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-gray-50 via-gray-50/70 to-transparent pointer-events-none z-40"></div> <div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-gray-50 via-gray-50/70 to-transparent pointer-events-none z-40"></div>
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-gray-50 via-gray-50/70 to-transparent pointer-events-none z-40"></div> <div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-gray-50 via-gray-50/70 to-transparent pointer-events-none z-40"></div>
@ -788,4 +973,4 @@ const RandomDrawApp: React.FC = () => {
); );
}; };
export default RandomDrawApp; export default RandomDrawApp;

View File

@ -4,28 +4,31 @@ import { api } from '../api'
export interface VouchersQueryParams { export interface VouchersQueryParams {
rows?: number rows?: number
winner_number?: number
} }
export function useVoucherRows(params: VouchersQueryParams = {}) { export function useVoucherRows(params: VouchersQueryParams = {}, options: { enabled?: boolean } = {}) {
const { rows = 4 } = params const { rows = 5, winner_number = 1 } = params
const { enabled = true } = options
return useQuery<VoucherRowsResponse>({ return useQuery<VoucherRowsResponse>({
queryKey: ['voucher-rows', { rows }], queryKey: ['voucher-rows', { rows, winner_number }],
queryFn: async () => { queryFn: async () => {
const res = await api.get(`/vouchers/rows`, { const res = await api.get(`/vouchers/rows`, {
params: { rows } params: { rows, winner_number }
}) })
return res.data.data return res.data.data
}, },
enabled,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false refetchOnWindowFocus: false
}) })
} }
// Manual fetch function for cases where you need to fetch without using the hook // Manual fetch function for cases where you need to fetch without using the hook
export async function fetchVoucherRows(rows: number = 4): Promise<VoucherRowsResponse> { export async function fetchVoucherRows(rows: number = 5, winner_number: number = 1): Promise<VoucherRowsResponse> {
const res = await api.get(`/vouchers/rows`, { const res = await api.get(`/vouchers/rows`, {
params: { rows } params: { rows, winner_number }
}) })
return res.data.data return res.data.data
} }