From 9959775c77cdeb4ad491df403059acf5716ffee9 Mon Sep 17 00:00:00 2001 From: efrilm Date: Sat, 13 Sep 2025 14:25:25 +0700 Subject: [PATCH] Order draw --- package-lock.json | 10 + package.json | 1 + .../[lang]/(blank-layout-pages)/draw/page.tsx | 601 ++++++++++++++++++ .../layout/vertical/VerticalMenu.tsx | 3 + 4 files changed, 615 insertions(+) create mode 100644 src/app/[lang]/(blank-layout-pages)/draw/page.tsx diff --git a/package-lock.json b/package-lock.json index 3359af7..693fcc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "input-otp": "1.4.1", "jspdf": "^3.0.1", "keen-slider": "6.8.6", + "lucide-react": "^0.544.0", "mapbox-gl": "3.9.0", "negotiator": "1.0.0", "next": "15.1.2", @@ -8462,6 +8463,15 @@ "loose-envify": "cli.js" } }, + "node_modules/lucide-react": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", + "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/mapbox-gl": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.9.0.tgz", diff --git a/package.json b/package.json index 70a67a7..ab00d67 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "input-otp": "1.4.1", "jspdf": "^3.0.1", "keen-slider": "6.8.6", + "lucide-react": "^0.544.0", "mapbox-gl": "3.9.0", "negotiator": "1.0.0", "next": "15.1.2", diff --git a/src/app/[lang]/(blank-layout-pages)/draw/page.tsx b/src/app/[lang]/(blank-layout-pages)/draw/page.tsx new file mode 100644 index 0000000..dc71484 --- /dev/null +++ b/src/app/[lang]/(blank-layout-pages)/draw/page.tsx @@ -0,0 +1,601 @@ +'use client' + +import React, { useState, useEffect, useRef } from 'react' +import { Users, RotateCw, Trophy, Trash2, RefreshCw, Database } from 'lucide-react' + +interface Order { + orderId: string + customerName: string + email: string + amount: number + status: string +} + +interface WinnerResult { + order: Order + timestamp: Date + position: number +} + +const RandomDrawApp: React.FC = () => { + const [orders, setOrders] = useState([]) + const [isSpinning, setIsSpinning] = useState(false) + const [winners, setWinners] = useState([]) + const [numberOfWinners, setNumberOfWinners] = useState(1) + const [currentWheelItems, setCurrentWheelItems] = useState([]) + const [selectedWinner, setSelectedWinner] = useState(null) + const wheelRef = useRef(null) + const audioContextRef = useRef(null) + const tickIntervalRef = useRef(null) + + // Initialize Audio Context + useEffect(() => { + const initAudio = () => { + if (!audioContextRef.current) { + audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)() + } + } + + document.addEventListener('click', initAudio, { once: true }) + + return () => { + document.removeEventListener('click', initAudio) + if (tickIntervalRef.current) { + clearTimeout(tickIntervalRef.current) + } + } + }, []) + + // Create tick sound effect + const playTickSound = () => { + if (!audioContextRef.current) return + + const ctx = audioContextRef.current + const oscillator = ctx.createOscillator() + const gainNode = ctx.createGain() + + oscillator.connect(gainNode) + gainNode.connect(ctx.destination) + + oscillator.frequency.setValueAtTime(800, ctx.currentTime) + oscillator.frequency.exponentialRampToValueAtTime(200, ctx.currentTime + 0.05) + + gainNode.gain.setValueAtTime(0, ctx.currentTime) + gainNode.gain.linearRampToValueAtTime(0.1, ctx.currentTime + 0.01) + gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.05) + + oscillator.start(ctx.currentTime) + oscillator.stop(ctx.currentTime + 0.05) + } + + // Start tick sound during spinning + const startTickSound = (duration: number) => { + if (tickIntervalRef.current) { + clearTimeout(tickIntervalRef.current) + } + + let tickInterval = 50 + const maxInterval = 200 + const intervalIncrement = (maxInterval - tickInterval) / (duration / 100) + + const tick = () => { + playTickSound() + tickInterval += intervalIncrement + + if (tickInterval < maxInterval) { + tickIntervalRef.current = setTimeout(tick, Math.min(tickInterval, maxInterval)) + } + } + + tick() + + setTimeout(() => { + if (tickIntervalRef.current) { + clearTimeout(tickIntervalRef.current) + tickIntervalRef.current = null + } + }, duration + 100) + } + + // Mock data + useEffect(() => { + const mockOrders: Order[] = [ + { orderId: 'ORD-001', customerName: 'John Doe', email: 'john@email.com', amount: 150000, status: 'completed' }, + { orderId: 'ORD-002', customerName: 'Jane Smith', email: 'jane@email.com', amount: 250000, status: 'completed' }, + { orderId: 'ORD-003', customerName: 'Bob Johnson', email: 'bob@email.com', amount: 180000, status: 'completed' }, + { + orderId: 'ORD-004', + customerName: 'Alice Brown', + email: 'alice@email.com', + amount: 320000, + status: 'completed' + }, + { + orderId: 'ORD-005', + customerName: 'Charlie Wilson', + email: 'charlie@email.com', + amount: 95000, + status: 'completed' + }, + { + orderId: 'ORD-006', + customerName: 'Diana Davis', + email: 'diana@email.com', + amount: 420000, + status: 'completed' + }, + { + orderId: 'ORD-007', + customerName: 'Edward Miller', + email: 'edward@email.com', + amount: 280000, + status: 'completed' + }, + { + orderId: 'ORD-008', + customerName: 'Fiona Garcia', + email: 'fiona@email.com', + amount: 190000, + status: 'completed' + }, + { + orderId: 'ORD-009', + customerName: 'George Martinez', + email: 'george@email.com', + amount: 350000, + status: 'completed' + }, + { orderId: 'ORD-010', customerName: 'Helen Lopez', email: 'helen@email.com', amount: 210000, status: 'completed' } + ] + setOrders(mockOrders) + setCurrentWheelItems(mockOrders) + }, []) + + const shuffleArray = (array: T[]): T[] => { + const shuffled = [...array] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + return shuffled + } + + const performSpin = (finalWinners: Order[], mainWinner: Order) => { + const wheelElement = wheelRef.current + if (!wheelElement) return + + const CARD_HEIGHT = 100 + const WINNER_ZONE_CENTER = 250 + + // Find where the winner already exists in current wheel items (no manipulation) + let winnerIndex = currentWheelItems.findIndex(item => item.orderId === mainWinner.orderId) + + // If winner not found in wheel, find a suitable position + if (winnerIndex === -1) { + winnerIndex = Math.floor(Math.random() * currentWheelItems.length) + } + + // Reset wheel position + wheelElement.style.transition = 'none' + wheelElement.style.transform = 'translateY(0px)' + wheelElement.offsetHeight + + setTimeout(() => { + // Calculate spin parameters + const minSpins = 15 + const maxSpins = 25 + const spins = minSpins + Math.random() * (maxSpins - minSpins) + const totalItems = currentWheelItems.length + + // Calculate final position to land on winner + const totalRotations = Math.floor(spins) + const baseScrollDistance = totalRotations * totalItems * CARD_HEIGHT + const winnerScrollPosition = winnerIndex * CARD_HEIGHT + const finalPosition = -(baseScrollDistance + winnerScrollPosition) + WINNER_ZONE_CENTER - CARD_HEIGHT / 2 + + // Dynamic duration + const distance = Math.abs(finalPosition) + const baseDuration = 3000 + const maxDuration = 5000 + const duration = Math.min(baseDuration + (distance / 10000) * 1000, maxDuration) + + console.log('🎲 NATURAL SPIN:') + console.log('Selected Winner:', mainWinner.orderId, '-', mainWinner.customerName) + console.log('Found at Index:', winnerIndex) + console.log('Will show in result:', mainWinner.orderId) + + // Animate wheel + wheelElement.style.transform = `translateY(${finalPosition}px)` + wheelElement.style.transition = `transform ${duration}ms cubic-bezier(0.17, 0.67, 0.12, 0.99)` + + // Start sound effect + startTickSound(duration) + + // Complete spinning after animation + setTimeout(() => { + // Stop sound + if (tickIntervalRef.current) { + clearTimeout(tickIntervalRef.current) + tickIntervalRef.current = null + } + + // Set the ACTUAL pre-selected winner (not what's visually in wheel) + setSelectedWinner(mainWinner) + + // Add PRE-SELECTED winners to results (guaranteed match) + const newWinners = finalWinners.map((order, index) => ({ + order, + timestamp: new Date(), + position: winners.length + index + 1 + })) + + setWinners(prev => [...prev, ...newWinners]) + setIsSpinning(false) + + console.log('🏆 RESULT WINNER:', newWinners[0].order.orderId) + console.log('🎯 Visual winner may differ from result (result is guaranteed correct)') + + // Reset wheel for next spin + setTimeout(() => { + wheelElement.style.transition = 'none' + wheelElement.style.transform = 'translateY(0px)' + wheelElement.offsetHeight + }, 1500) + }, duration) + }, 100) + } + + const spinWheel = async () => { + // Filter orders yang belum menang + const availableOrders = orders.filter(order => !winners.some(winner => winner.order.orderId === order.orderId)) + + if (availableOrders.length === 0 || isSpinning) return + + setIsSpinning(true) + setSelectedWinner(null) + + // Reset wheel position before every spin + const wheelElement = wheelRef.current + if (wheelElement) { + wheelElement.style.transition = 'none' + wheelElement.style.transform = 'translateY(0px)' + wheelElement.offsetHeight // Force reflow + } + + // Pilih winner yang akan ditampilkan di result + const shuffledAvailable = shuffleArray(availableOrders) + const finalWinners = shuffledAvailable.slice(0, Math.min(numberOfWinners, availableOrders.length)) + const mainWinner = finalWinners[0] + + console.log('🎯 PRE-SELECTED WINNER:', mainWinner.orderId, '-', mainWinner.customerName) + console.log('🎯 THIS WINNER WILL BE GUARANTEED IN RESULT') + + // Always call performSpin with the guaranteed winner + if (wheelElement) { + performSpin(finalWinners, mainWinner) + } + } + + const clearResults = () => { + // Stop any ongoing sound effects + if (tickIntervalRef.current) { + clearTimeout(tickIntervalRef.current) + tickIntervalRef.current = null + } + + setWinners([]) + setSelectedWinner(null) + setIsSpinning(false) // Force reset spinning state + + // Reset wheel position when clearing + const wheelElement = wheelRef.current + if (wheelElement) { + wheelElement.style.transition = 'none' + wheelElement.style.transform = 'translateY(0px)' + } + } + + const refreshOrders = () => { + // Stop any ongoing sound effects + if (tickIntervalRef.current) { + clearTimeout(tickIntervalRef.current) + tickIntervalRef.current = null + } + + const shuffled = shuffleArray(orders) + setCurrentWheelItems(shuffled) + setSelectedWinner(null) + setIsSpinning(false) // Force reset spinning state + + // Reset wheel position + const wheelElement = wheelRef.current + if (wheelElement) { + wheelElement.style.transition = 'none' + wheelElement.style.transform = 'translateY(0px)' + } + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0 + }).format(amount) + } + + // Get available orders for spinning + const availableOrdersCount = orders.filter( + order => !winners.some(winner => winner.order.orderId === order.orderId) + ).length + + return ( +
+
+ {/* Header */} +
+

Random Order Draw

+

Select random winners from order database

+
+ + {/* Main Layout */} +
+ {/* Left Panel - Table & Controls */} +
+ {/* Controls */} +
+
+

+ + Order Database ({orders.length} total, {availableOrdersCount} available) +

+ +
+ +
+ + setNumberOfWinners(Math.min(parseInt(e.target.value) || 1, availableOrdersCount))} + className='w-32 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' + disabled={isSpinning} + /> +
+ + +
+ + {/* Orders Table */} +
+
+

Eligible Orders

+
+
+ + + + + + + + + + + {orders.map(order => { + const isWinner = winners.some(winner => winner.order.orderId === order.orderId) + return ( + + + + + + + ) + })} + +
Order IDCustomerAmountStatus
{order.orderId} +
{order.customerName}
+
{order.email}
+
{formatCurrency(order.amount)} + + {order.status} + + {isWinner && } +
+
+
+
+ + {/* Right Panel - Spin Wheel */} +
+

Spin Wheel

+ + {/* Wheel Container */} +
+ {/* Winner Selection Zone */} +
+ {/* Left Arrow */} +
+ + {/* Winner Badge */} +
+ 🎯 WINNER 🎯 +
+ + {/* Right Arrow */} +
+
+ + {/* Scrolling Cards */} +
+ {/* Generate enough cards for smooth scrolling */} + {Array.from({ length: 40 }, (_, repeatIndex) => + currentWheelItems.map((order, orderIndex) => { + const isCurrentWinner = winners.some(winner => winner.order.orderId === order.orderId) + const isSelectedWinner = selectedWinner?.orderId === order.orderId + + return ( +
+ {/* Order Info */} +
+
+ {order.orderId} +
+
+ {order.customerName} +
+
+ + {/* Amount */} +
+
{formatCurrency(order.amount)}
+ {(isCurrentWinner || isSelectedWinner) && ( + + )} +
+
+ ) + }) + )} +
+ + {/* Gradient Overlays */} +
+
+
+
+
+ + {/* Results Section */} + {winners.length > 0 && ( +
+
+

+ + Draw Results ({winners.length} Winners) +

+ +
+ + {/* Simple Winner List */} +
+ {winners.map(winner => ( +
+
+
+ #{winner.position} +
+
+
{winner.order.customerName}
+
+ {winner.order.orderId} • {winner.order.email} +
+
+
+ +
+
{formatCurrency(winner.order.amount)}
+
Won at: {winner.timestamp.toLocaleTimeString()}
+
+
+ ))} +
+
+ )} +
+
+ ) +} + +export default RandomDrawApp diff --git a/src/components/layout/vertical/VerticalMenu.tsx b/src/components/layout/vertical/VerticalMenu.tsx index 8359ee6..c9b634b 100644 --- a/src/components/layout/vertical/VerticalMenu.tsx +++ b/src/components/layout/vertical/VerticalMenu.tsx @@ -212,6 +212,9 @@ const VerticalMenu = ({ dictionary, scrollMenu }: Props) => { > {dictionary['navigation'].vendor} + } target='_blank'> + Random Draw +