1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = ["scrollContainer", "namesList", "drawButton", "redrawButton", "winnerInfo", "winnerName", "winnerEmail"] 5 static values = { 6 prizeId: Number, 7 eligibleNames: Array, 8 drawUrl: String 9 } 10 11 connect() { 12 this.isDrawing = false 13 this.itemHeight = 64 // h-16 = 4rem = 64px 14 this.maxItems = 500 // Cap DOM elements for performance 15 this.setupNamesList() 16 } 17 18 setupNamesList() { 19 const names = this.eligibleNamesValue 20 if (names.length === 0) return 21 22 // Shuffle a copy for visual variety, then cap total items 23 this.shuffledNames = this.shuffle([...names]) 24 const displayNames = this.buildDisplayList(this.shuffledNames) 25 26 // Build DOM elements safely (no innerHTML with user data) 27 this.namesListTarget.textContent = '' 28 displayNames.forEach((name) => { 29 const div = document.createElement('div') 30 div.className = 'h-16 flex items-center justify-center text-2xl font-semibold text-stone-300 name-item' 31 div.dataset.name = name 32 div.textContent = name 33 this.namesListTarget.appendChild(div) 34 }) 35 this.allItems = Array.from(this.namesListTarget.querySelectorAll('.name-item')) 36 37 // Position so first items are visible in center 38 const containerHeight = this.scrollContainerTarget.offsetHeight 39 const centerOffset = (containerHeight - this.itemHeight) / 2 40 this.currentPosition = centerOffset 41 this.namesListTarget.style.transform = `translateY(${this.currentPosition}px)` 42 this.highlightCenterItem() 43 } 44 45 buildDisplayList(names) { 46 // Repeat shuffled names to fill maxItems, ensuring enough scroll distance 47 const result = [] 48 while (result.length < this.maxItems) { 49 for (const name of names) { 50 result.push(name) 51 if (result.length >= this.maxItems) break 52 } 53 } 54 return result 55 } 56 57 shuffle(array) { 58 for (let i = array.length - 1; i > 0; i--) { 59 const j = Math.floor(Math.random() * (i + 1)); 60 [array[i], array[j]] = [array[j], array[i]] 61 } 62 return array 63 } 64 65 async draw() { 66 if (this.isDrawing) return 67 if (this.eligibleNamesValue.length === 0) return 68 69 this.isDrawing = true 70 this.drawButtonTarget.classList.add("hidden") 71 this.redrawButtonTarget.classList.add("hidden") 72 this.winnerInfoTarget.classList.add("hidden") 73 74 // Start scrolling immediately AND fetch winner in parallel 75 const winnerPromise = this.fetchWinner() 76 this.startFreeScroll() 77 78 const winner = await winnerPromise 79 80 if (winner) { 81 // Decelerate and land on the winner 82 await this.decelerateToWinner(winner.display_name) 83 this.revealWinner(winner) 84 } else { 85 this.stopFreeScroll() 86 this.redrawButtonTarget.classList.remove("hidden") 87 } 88 89 this.isDrawing = false 90 } 91 92 startFreeScroll() { 93 // Scroll at a constant velocity until we know the winner 94 this.freeScrolling = true 95 this.scrollVelocity = 0 96 this.maxScrollVelocity = 18 // pixels per frame at ~60fps 97 this.accelFrames = 30 // ramp up over ~0.5s 98 this.frameCount = 0 99 100 const scroll = () => { 101 if (!this.freeScrolling) return 102 103 this.frameCount++ 104 // Ramp up speed 105 if (this.frameCount < this.accelFrames) { 106 this.scrollVelocity = this.maxScrollVelocity * (this.frameCount / this.accelFrames) 107 } else { 108 this.scrollVelocity = this.maxScrollVelocity 109 } 110 111 this.currentPosition -= this.scrollVelocity 112 113 // Wrap around: if we've scrolled past all items, jump back 114 const totalHeight = this.allItems.length * this.itemHeight 115 if (Math.abs(this.currentPosition) > totalHeight) { 116 this.currentPosition += totalHeight 117 } 118 119 this.namesListTarget.style.transform = `translateY(${this.currentPosition}px)` 120 this.highlightCenterItem() 121 this.freeScrollRAF = requestAnimationFrame(scroll) 122 } 123 124 this.freeScrollRAF = requestAnimationFrame(scroll) 125 } 126 127 stopFreeScroll() { 128 this.freeScrolling = false 129 if (this.freeScrollRAF) { 130 cancelAnimationFrame(this.freeScrollRAF) 131 this.freeScrollRAF = null 132 } 133 } 134 135 async decelerateToWinner(winnerName) { 136 this.stopFreeScroll() 137 138 // Find a winner item ahead of current position to land on 139 const containerHeight = this.scrollContainerTarget.offsetHeight 140 const centerY = (containerHeight - this.itemHeight) / 2 141 const currentCenterIndex = Math.round((containerHeight / 2 - this.currentPosition) / this.itemHeight - 0.5) 142 143 // Find a winner item that's at least 80 items ahead (gives ~3s of deceleration) 144 const minAhead = 80 145 let targetIndex = -1 146 for (let i = currentCenterIndex + minAhead; i < this.allItems.length; i++) { 147 if (this.allItems[i] && this.allItems[i].dataset.name === winnerName) { 148 targetIndex = i 149 break 150 } 151 } 152 153 // If not found ahead, inject winner name at the target position 154 if (targetIndex === -1) { 155 targetIndex = Math.min(currentCenterIndex + minAhead + 10, this.allItems.length - 1) 156 if (this.allItems[targetIndex]) { 157 this.allItems[targetIndex].dataset.name = winnerName 158 this.allItems[targetIndex].textContent = winnerName 159 } 160 } 161 162 const targetPosition = centerY - (targetIndex * this.itemHeight) 163 const startPosition = this.currentPosition 164 const totalDistance = startPosition - targetPosition 165 const decelDuration = 3000 // 3 seconds to decelerate 166 167 const startTime = performance.now() 168 169 return new Promise((resolve) => { 170 const animate = (currentTime) => {
171 const elapsed = currentTime - startTime 172 const progress = Math.min(elapsed / decelDuration, 1) 173 174 // Smooth deceleration curve (ease-out cubic) 175 const eased = 1 - Math.pow(1 - progress, 3) 176 177 const currentPos = startPosition - (totalDistance * eased) 178 this.namesListTarget.style.transform = `translateY(${currentPos}px)` 179 this.currentPosition = currentPos 180 181 this.highlightCenterItem() 182 183 if (progress < 1) { 184 requestAnimationFrame(animate) 185 } else { 186 this.namesListTarget.style.transform = `translateY(${targetPosition}px)` 187 this.currentPosition = targetPosition 188 this.highlightCenterItem() 189 resolve() 190 } 191 } 192 193 requestAnimationFrame(animate) 194 }) 195 } 196 197 highlightCenterItem() { 198 const containerHeight = this.scrollContainerTarget.offsetHeight 199 const centerIndex = Math.round((containerHeight / 2 - this.currentPosition) / this.itemHeight - 0.5) 200 201 if (this.lastHighlightIndex === centerIndex) return 202 203 // Remove highlight from previous item 204 if (this.lastHighlightIndex != null && this.allItems[this.lastHighlightIndex]) { 205 const prev = this.allItems[this.lastHighlightIndex] 206 prev.classList.remove('text-white', 'scale-110', 'text-primary-400', 'scale-125') 207 prev.classList.add('text-stone-300') 208 } 209 210 // Add highlight to current center item 211 if (centerIndex >= 0 && centerIndex < this.allItems.length) { 212 const current = this.allItems[centerIndex] 213 current.classList.remove('text-stone-300') 214 current.classList.add('text-white', 'scale-110') 215 } 216 217 this.lastHighlightIndex = centerIndex 218 } 219 220 async fetchWinner() { 221 try { 222 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 223 const response = await fetch(this.drawUrlValue, { 224 method: "POST", 225 headers: { 226 "Content-Type": "application/json", 227 "Accept": "application/json", 228 "X-CSRF-Token": csrfToken 229 } 230 }) 231 232 const data = await response.json() 233 234 if (data.success) { 235 return data.winner 236 } else { 237 console.error("Draw failed:", data.error) 238 return null 239 } 240 } catch (err) { 241 console.error("Network error:", err) 242 return null 243 } 244 } 245 246 revealWinner(winner) { 247 // Highlight winner in center 248 if (this.lastHighlightIndex != null && this.allItems[this.lastHighlightIndex]) { 249 const item = this.allItems[this.lastHighlightIndex] 250 if (item.dataset.name === winner.display_name) { 251 item.classList.remove('text-white', 'text-stone-300', 'scale-110') 252 item.classList.add('text-primary-400', 'scale-125', 'font-bold') 253 } 254 } 255 256 // Show winner info section 257 this.winnerNameTarget.textContent = winner.display_name 258 this.winnerEmailTarget.textContent = winner.email 259 this.winnerInfoTarget.classList.remove("hidden") 260 261 // Show redraw button 262 this.redrawButtonTarget.classList.remove("hidden") 263 264 // Fire confetti! 265 this.fireConfetti() 266 } 267 268 fireConfetti() { 269 const defaults = { 270 spread: 360, 271 ticks: 100, 272 gravity: 0.5, 273 decay: 0.94, 274 startVelocity: 30, 275 colors: ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'] 276 } 277 278 if (typeof confetti !== 'undefined') { 279 confetti({ 280 ...defaults, 281 particleCount: 100, 282 origin: { y: 0.6 } 283 }) 284 285 setTimeout(() => { 286 confetti({ 287 ...defaults, 288 particleCount: 50, 289 origin: { x: 0.2, y: 0.5 } 290 }) 291 }, 200) 292 293 setTimeout(() => { 294 confetti({ 295 ...defaults, 296 particleCount: 50, 297 origin: { x: 0.8, y: 0.5 } 298 }) 299 }, 400) 300 301 setTimeout(() => { 302 confetti({ 303 ...defaults, 304 particleCount: 75, 305 origin: { x: 0.5, y: 0.3 } 306 }) 307 }, 600) 308 } 309 } 310}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.