PageSourceSearch

https://trashtalktour.org/assets/admin_controllers/prize_drawing_controller-71ca7b8d.js

js trashtalktour.org collected 2026-10-05 07:08:45 UTC 9,558 bytes, 310 lines download raw bytes

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.