PageSourceSearch

https://onlinestore.summerclassics.com/assets/controllers/rep_assi…a541388268178548fa4cbe122882697ac44fe412.js

js summerclassics.com collected 2026-09-26 04:21:13 UTC 10,440 bytes, 368 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3export default class extends Controller {
4  static targets = [
5    "scrollContainer", "messages", "form", "input", "submit",
6    "cannedPrompts", "cannedPromptId", "customerName",
7    "customerPicker", "customerSearch", "customerList",
8    "selectedCustomer", "selectedCustomerName"
9  ]
10
11  static values = {
12    territoryCustomers: { type: Array, default: [] }
13  }
14
15  connect() {
16    this.scrollToBottom()
17    this.observer = new MutationObserver(() => this.scrollToBottom())
18    this.observer.observe(this.messagesTarget, { childList: true, subtree: true })
19    this.pendingPromptId = null
20    this.activeSubmission = null
21  }
22
23  disconnect() {
24    this.observer?.disconnect()
25    this.activeSubmission?.abort()
26  }
27
28  handleKeydown(event) {
29    if (event.key !== "Enter" || event.shiftKey) return
30
31    event.preventDefault()
32
33    if (this.inputTarget.disabled || this.inputTarget.value.trim() === "") return
34
35    this.formTarget.requestSubmit()
36  }
37
38  selectCannedPrompt(event) {
39    const button = event.currentTarget
40    const promptId = button.dataset.cannedPromptId
41    const requiresCustomer = button.dataset.requiresCustomer === "true"
42
43    if (requiresCustomer) {
44      this.pendingPromptId = promptId
45      this.inputTarget.value = this.findPromptLabel(promptId)
46      this.showCustomerPicker()
47    } else {
48      this.submitCannedPrompt(promptId)
49    }
50  }
51
52  showCustomerPicker() {
53    if (!this.hasCustomerPickerTarget) return
54
55    this.customerPickerTarget.classList.remove("hidden")
56    this.customerSearchTarget.value = ""
57    this.customerSearchTarget.focus()
58    this.renderCustomerList(this.territoryCustomersValue)
59  }
60
61  hideCustomerPicker() {
62    if (!this.hasCustomerPickerTarget) return
63
64    this.customerPickerTarget.classList.add("hidden")
65    this.customerSearchTarget.value = ""
66    this.pendingPromptId = null
67    this.inputTarget.value = ""
68  }
69
70  filterCustomers() {
71    const query = this.customerSearchTarget.value.toLowerCase().trim()
72    if (query === "") {
73      this.renderCustomerList(this.territoryCustomersValue)
74      return
75    }
76
77    const filtered = this.territoryCustomersValue.filter(c =>
78      c.name.toLowerCase().includes(query) || c.code.toLowerCase().includes(query)
79    )
80    this.renderCustomerList(filtered)
81  }
82
83  handleCustomerKeydown(event) {
84    if (event.key === "Escape") {
85      this.hideCustomerPicker()
86    }
87  }
88
89  renderCustomerList(customers) {
90    const list = this.customerListTarget
91    const shown = customers.slice(0, 50)
92
93    list.innerHTML = shown.map(c =>
94      `<li class="cursor-pointer px-3 py-2 text-sm text-sc-text hover:bg-sc-raised transition-colors"
95           data-action="click->rep-assistant--chat#pickCustomer"
96           data-customer-name="${this.escapeAttr(c.name)}">${this.escapeHtml(c.name)}<span class="ml-2 text-xs text-sc-muted">${this.escapeHtml(c.code)}</span></li>`
97    ).join("")
98
99    if (customers.length > 50) {
100      list.innerHTML += `<li class="px-3 py-2 text-xs text-sc-muted italic">Showing 50 of ${customers.length} — type to narrow results</li>`
101    }
102
103    if (customers.length === 0) {
104      list.innerHTML = `<li class="px-3 py-2 text-sm text-sc-muted italic">No customers found</li>`
105    }
106  }
107
108  pickCustomer(event) {
109    const customerName = event.currentTarget.dataset.customerName
110    const promptId = this.pendingPromptId
111    if (!promptId) return
112
113    this.selectedCustomerValue = customerName
114    this.customerNameTarget.value = customerName
115    this.hideCustomerPicker()
116
117    if (this.hasSelectedCustomerTarget) {
118      this.selectedCustomerNameTarget.textContent = customerName
119      this.selectedCustomerTarget.classList.remove("hidden")
120    }
121
122    this.submitCannedPrompt(promptId, customerName)
123  }
124
125  clearCustomer() {
126    this.selectedCustomerValue = null
127    this.customerNameTarget.value = ""
128    this.pendingPromptId = null
129
130    if (this.hasSelectedCustomerTarget) {
131      this.selectedCustomerTarget.classList.add("hidden")
132    }
133  }
134
135  submitCannedPrompt(promptId, customerName = null) {
136    this.cannedPromptIdTarget.value = promptId
137    this.customerNameTarget.value = customerName || ""
138    this.inputTarget.value = ""
139    this.inputTarget.removeAttribute("required")
140
141    this.hideCannedPrompts()
142
143    const label = this.findPromptLabel(promptId, customerName)
144    this.appendUserMessage(label)
145    this.appendLoadingIndicator()
146    this.disableComposer()
147
148    this.formTarget.requestSubmit()
149  }
150
151  findPromptLabel(promptId, customerName) {
152    const button = this.element.querySelector(`[data-canned-prompt-id="${promptId}"]`)
153    let label = button ? button.textContent.trim() : promptId
154    if (customerName) {
155      label = label.replace(/a customer/, customerName)
156    }
157    return label
158  }
159
160  hideCannedPrompts() {
161    if (this.hasCannedPromptsTarget) {
162      this.cannedPromptsTarget.remove()
163    }
164  }
165
166  async submitWithStream(event) {
167    event.preventDefault()
168
169    const isCanned = this.cannedPromptIdTarget.value !== ""
170    if (!isCanned) {
171      const content = this.inputTarget.value.trim()
172      if (!content) return
173
174      this.appendUserMessage(content)
175      this.appendLoadingIndicator()
176    }
177
178    const formData = new FormData(this.formTarget)
179
180    this.disableComposer()
181    this.clearInput()
182
183    this.activeSubmission?.abort()
184    this.activeSubmission = new AbortController()
185
186    const url = this.formTarget.action
187    if (!url) {
188      this.showStreamError("Something went wrong. Please try again.")
189      this.finishSubmission()
190      return
191    }
192
193    const headers = { Accept: "text/vnd.turbo-stream.html" }
194    const csrfToken = this.csrfToken()
195    if (csrfToken) headers["X-CSRF-Token"] = csrfToken
196
197    try {
198      const response = await fetch(url, {
199        method: "POST",
200        body: formData,
201        headers,
202        signal: this.activeSubmission.signal,
203        credentials: "same-origin"
204      })
205
206      if (!response.ok) {
207        throw new Error(`Request failed (${response.status})`)
208      }
209
210      await this.consumeTurboStream(response)
211    } catch (error) {
212      if (error.name !== "AbortError") {
213        console.error("Rep Assistant stream error:", error)
214        this.showStreamError("Something went wrong. Please try again.")
215      }
216    } finally {
217      this.activeSubmission = null
218      this.finishSubmission()
219    }
220  }
221
222  async consumeTurboStream(response) {
223    const turbo = this.turbo()
224    if (!turbo?.renderStreamMessage) {
225      throw new Error("Turbo stream rendering is unavailable")
226    }
227
228    const reader = response.body?.getReader()
229    if (!reader) {
230      const html = await response.text()
231      if (html.trim()) turbo.renderStreamMessage(html)
232      return
233    }
234
235    const decoder = new TextDecoder()
236    let buffer = ""
237
238    while (true) {
239      const { done, value } = await reader.read()
240      if (done) break
241
242      buffer += decoder.decode(value, { stream: true })
243      buffer = this.renderBufferedTurboStreams(buffer, turbo)
244    }
245
246    buffer += decoder.decode()
247    if (buffer.trim()) {
248      this.renderBufferedTurboStreams(buffer, turbo, { flush: true })
249    }
250  }
251
252  renderBufferedTurboStreams(buffer, turbo, { flush = false } = {}) {
253    const pattern = /<turbo-stream[\s\S]*?<\/turbo-stream>/g
254    let match
255    let lastIndex = 0
256
257    while ((match = pattern.exec(buffer)) !== null) {
258      turbo.renderStreamMessage(match[0])
259      lastIndex = pattern.lastIndex
260    }
261
262    const remainder = buffer.slice(lastIndex)
263    if (flush && remainder.trim()) {
264      turbo.renderStreamMessage(remainder)
265      return ""
266    }
267
268    return remainder
269  }
270
271  showStreamError(message) {
272    document.getElementById("rep-assistant-loading")?.remove()
273    document.getElementById("rep-assistant-pending-user")?.remove()
274
275    this.messagesTarget.insertAdjacentHTML(
276      "beforeend",
277      `<div class="flex justify-start">
278        <div class="max-w-[min(85%,720px)] rounded-2xl rounded-bl-md border border-sc-crimson/30 bg-sc-crimson/5 px-4 py-3 text-sm leading-relaxed text-sc-crimson">
279          ${this.escapeHtml(message)}
280        </div>
281      </div>`
282    )
283    this.scrollToBottom()
284  }
285
286  finishSubmission() {
287    this.enableComposer()
288    this.cannedPromptIdTarget.value = ""
289    this.customerNameTarget.value = ""
290    this.selectedCustomerValue = null
291    this.inputTarget.setAttribute("required", "")
292
293    if (this.hasSelectedCustomerTarget) {
294      this.selectedCustomerTarget.classList.add("hidden")
295    }
296
297    this.scrollToBottom()
298  }
299
300  disableComposer() {
301    this.inputTarget.disabled = true
302    this.submitTarget.disabled = true
303  }
304
305  enableComposer() {
306    this.inputTarget.disabled = false
307    this.submitTarget.disabled = false
308    this.inputTarget.focus()
309  }
310
311  clearInput() {
312    this.inputTarget.value = ""
313  }
314
315  appendUserMessage(content) {
316    if (document.getElementById("rep-assistant-pending-user")) return
317
318    const formatted = this.escapeHtml(content).replace(/\n/g, "<br />")
319
320    this.messagesTarget.insertAdjacentHTML(
321      "beforeend",
322      `<div id="rep-assistant-pending-user" class="flex justify-end">
323        <div class="max-w-[min(85%,720px)] rounded-2xl rounded-br-md bg-sc-crimson px-4 py-3 text-sm leading-relaxed text-white">
324          <p class="mb-0 text-white">${formatted}</p>
325        </div>
326      </div>`
327    )
328    this.scrollToBottom()
329  }
330
331  appendLoadingIndicator() {
332    if (document.getElementById("rep-assistant-loading")) return
333
334    this.messagesTarget.insertAdjacentHTML(
335      "beforeend",
336      `<div id="rep-assistant-loading" class="flex justify-start">
337        <div class="max-w-[min(85%,720px)] animate-pulse rounded-2xl rounded-bl-md border border-sc-border bg-white px-4 py-3 text-sm text-sc-text-3">
338          Thinking...
339        </div>
340      </div>`
341    )
342    this.scrollToBottom()
343  }
344
345  scrollToBottom() {
346    const container = this.hasScrollContainerTarget ? this.scrollContainerTarget : this.messagesTarget
347    container.scrollTop = container.scrollHeight
348  }
349
350  csrfToken() {
351    return document.querySelector('meta[name="csrf-token"]')?.content
352  }
353
354  turbo() {
355    return window.Turbo || (typeof Turbo !== "undefined" ? Turbo : null)
356  }
357
358  escapeHtml(text) {
359    const div = document.createElement("div")
360    div.textContent = text
361    return div.innerHTML
362  }
363
364  escapeAttr(text) {
365    return text.replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
366  }
367}
368;

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.