PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/ecommerce/quote_details_controller-bc96365a.js

js littlebirdelectronics.com.au collected 2026-09-25 14:11:10 UTC 11,153 bytes, 316 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Gates the cart page's "Download Quote" buttons behind the buyer's details, so
4// every quote we generate is made out to a named person at a real organisation
5// and we can email them a copy.
6//
7// This is only the pleasant half of the rule — CartsController enforces the same
8// gate on the export URLs themselves, so skipping the modal gets you a redirect
9// back to it, not a quote.
10export default class extends Controller {
11  static targets = [
12    "modal", "form", "errors", "errorText",
13    "submitButton", "submitText", "success", "successText", "country",
14    "email", "detailsStep"
15  ]
16
17  static values = {
18    url: String,
19    emailUrl: String,
20    required: Boolean,
21    autoOpen: Boolean
22  }
23
24  connect() {
25    // Which half of the form we're on. The server renders the second fieldset
26    // already open for a buyer whose address we hold, so read the markup rather
27    // than assuming everyone starts at the top.
28    this.step = this.#detailsRevealed() ? "full" : "email"
29
30    // The server bounces a direct export hit back here with ?quote_details=1.
31    if (this.autoOpenValue && this.requiredValue) this.open()
32  }
33
34  disconnect() {
35    document.body.style.overflow = ""
36  }
37
38  // Bound in the CAPTURE phase on the page wrapper, so it always runs before the
39  // download link's own behaviour and before copy-markdown#copy on the Copy
40  // Quote button — stopping propagation here means the event never reaches
41  // them, whichever controller connected first.
42  intercept(event) {
43    if (!this.requiredValue || !this.hasModalTarget) return
44
45    const trigger = event.target.closest?.("[data-quote-download]")
46    if (!trigger) return
47
48    event.preventDefault()
49    event.stopPropagation()
50
51    this.pending = {
52      kind: trigger.dataset.quoteDownload,
53      url: trigger.dataset.quoteDownloadUrl || trigger.getAttribute("href")
54    }
55    this.open()
56  }
57
58  open() {
59    if (!this.hasModalTarget) return
60
61    // Reopening after a success ("Change") must show the form again, not the
62    // confirmation left over from last time.
63    this.successTarget.classList.add("hidden")
64    this.formTarget.classList.remove("hidden")
65    this.#hideErrors()
66    this.#resetSubmit()
67    this.#adoptSidebarCountry()
68
69    this.modalTarget.classList.remove("hidden")
70    this.modalTarget.classList.add("flex")
71    document.body.style.overflow = "hidden"
72
73    // Focus the first thing needing attention, but ALWAYS land somewhere inside
74    // the dialog: a signed-in buyer can arrive with every field prefilled, and
75    // focus left on the button behind the overlay is a dead end for anyone on a
76    // keyboard or a screen reader.
77    this.returnFocusTo = document.activeElement
78    ;(this.#firstEmptyField() || this.emailTarget)?.focus()
79  }
80
81  close() {
82    if (!this.hasModalTarget) return
83
84    this.modalTarget.classList.add("hidden")
85    this.modalTarget.classList.remove("flex")
86    document.body.style.overflow = ""
87    this.returnFocusTo?.focus?.()
88    this.returnFocusTo = null
89  }
90
91  // Bound to keydown@window, so it fires even when this modal isn't showing.
92  // Escape must then do NOTHING: close() clears document.body.style.overflow,
93  // which at that moment belongs to the quote drawer's scroll lock, not ours.
94  closeOnEscape(event) {
95    if (event.key !== "Escape" || !this.#isOpen()) return
96    this.close()
97  }
98
99  #isOpen() {
100    return this.hasModalTarget && !this.modalTarget.classList.contains("hidden")
101  }
102
103  submit(event) {
104    event.preventDefault()
105    if (this.submitting) return
106
107    this.submitting = true
108    this.#hideErrors()
109    this.#showLoading()
110
111    const pending = this.pending
112    const savePromise = this.#saveDetails()
113
114    // Step one only banks the address — there's no quote to fetch yet, and
115    // reaching for one would hit the export gate and 403. Reveal the rest of the
116    // fields instead and let the buyer submit again.
117    if (this.step === "email") {
118      savePromise
119        .then(() => this.#revealDetails())
120        .catch((error) => this.#onFailed(error))
121        .finally(() => {
122          this.submitting = false
123        })
124      return
125    }
126
127    // Safari only honours a clipboard write inside the tick that the user's
128    // gesture started, so hand ClipboardItem a PROMISE now rather than awaiting
129    // the round-trip first (same trick as copy_markdown_controller). Everything
130    // else is a plain download, which needs no gesture.
131    if (this.#wantsClipboard(pending)) {
132      const blobPromise = savePromise
133        .then(() => fetch(pending.url, { headers: { Accept: "text/markdown" } }))
134        .then((response) => {
135          if (!response.ok) throw new Error("Failed to fetch the quote")
136          return response.text()
137        })
138        .then((markdown) => new Blob([markdown], { type: "text/plain" }))
139
140      navigator.clipboard
141        .write([new ClipboardItem({ "text/plain": blobPromise })])
142        .catch(() => {
143          // Clipboard refused (or the save failed and the promise rejected).
144          // The save's own handler below reports anything the user needs to fix.
145        })
146    }
147
148    savePromise
149      .then((data) => this.#onSaved(data, pending))
150      .catch((error) => this.#onFailed(error))
151      .finally(() => {
152        this.submitting = false
153      })
154  }
155
156  // Don't make the buyer restate a destination they already chose in the cart
157  // sidebar's rate estimator.
158  #adoptSidebarCountry() {
159    if (!this.hasCountryTarget) return
160
161    const sidebar = document.querySelector('select[name="shipping_estimate[country]"]')
162    if (sidebar?.value) this.countryTarget.value = sidebar.value
163  }
164
165  // Resolves with the server's JSON on success; rejects with an Error whose
166  // message is fit to show the buyer.
167  async #saveDetails() {
168    const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
169    // A disabled fieldset contributes nothing to FormData, so on step one this
170    // carries the email and nothing else — which is exactly what the server is
171    // being asked to accept.
172    const body = new FormData(this.formTarget)
173
174    // Price the service the buyer actually selected on the cart page, not just
175    // the cheapest one. The radio lives outside this controller (same handoff
176    // the quote drawer uses).
177    const selectedRate = document.querySelector('input[name="shipping_estimate"]:checked')
178    if (selectedRate) body.set("shipping_method", selectedRate.value)
179
180    // The step is the endpoint, not a field in the payload.
181    const url = this.step === "email" ? this.emailUrlValue : this.urlValue
182
183    let response
184    try {
185      response = await fetch(url, {
186        method: "POST",
187        headers: { "X-CSRF-Token": csrfToken, Accept: "application/json" },
188        body
189      })
190    } catch {
191      throw new Error("Network error — check your connection and try again.")
192    }
193
194    let data = {}
195    try {
196      data = await response.json()
197    } catch {
198      // fall through to the generic message below
199    }
200
201    if (!response.ok || !data.success) {
202      throw new Error(data.errors?.join(" ") || "We couldn't save those details. Please try again.")
203    }
204
205    return data
206  }
207
208  #onSaved(data, pending) {
209    // Details are on file now, so the buttons behave like ordinary links again.
210    this.requiredValue = false
211    this.pending = null
212    this.closeDelay = 3500
213
214    const emailed = `We've emailed a copy to ${data.email}.`
215
216    if (!pending || !pending.url) {
217      this.#showSuccess(emailed)
218    } else if (pending.kind !== "copy") {
219      // A Content-Disposition: attachment response downloads without navigating
220      // away, so the confirmation below stays on screen. The PDF is typeset by
221      // the same renderer as the emailed copy and takes a few seconds — say so
222      // rather than leaving the buyer looking at a page that seems to do
223      // nothing, and hold the panel open long enough to cover the wait.
224      window.location.href = pending.url
225      this.#showSuccess(
226        pending.kind === "pdf"
227          ? `We're typesetting your PDF — the download starts in a few seconds. ${emailed}`
228          : `Your download has started. ${emailed}`
229      )
230      this.closeDelay = pending.kind === "pdf" ? 12000 : 3500
231    } else if (this.#clipboardSupported()) {
232      // The write was already handed to the clipboard inside the click gesture.
233      this.#showSuccess(`Copied to your clipboard. ${emailed}`)
234    } else {
235      // No ClipboardItem (older Firefox): fetch and write plain text instead.
236      fetch(pending.url, { headers: { Accept: "text/markdown" } })
237        .then((response) => response.text())
238        .then((markdown) => navigator.clipboard?.writeText(markdown))
239        .catch(() => {})
240      this.#showSuccess(`Copied to your clipboard. ${emailed}`)
241    }
242
243    setTimeout(() => this.close(), this.closeDelay)
244  }
245
246  #onFailed(error) {
247    this.#resetSubmit()
248    this.errorTextTarget.textContent = error.message
249    this.errorsTarget.classList.remove("hidden")
250  }
251
252  // Step one accepted: grow the form. The fieldset is re-enabled as well as
253  // shown — while disabled its `required` fields are skipped by validation AND
254  // omitted from FormData, which is what let step one submit alone.
255  #revealDetails() {
256    this.step = "full"
257    if (this.hasDetailsStepTarget) {
258      this.detailsStepTarget.disabled = false
259      this.detailsStepTarget.classList.remove("hidden")
260    }
261    this.#adoptSidebarCountry()
262    this.#resetSubmit()
263    this.#firstEmptyField()?.focus()
264  }
265
266  #detailsRevealed() {
267    return this.hasDetailsStepTarget && !this.detailsStepTarget.disabled
268  }
269
270  #wantsClipboard(pending) {
271    return pending?.kind === "copy" && Boolean(pending.url) && this.#clipboardSupported()
272  }
273
274  #clipboardSupported() {
275    return typeof ClipboardItem !== "undefined" && Boolean(navigator.clipboard?.write)
276  }
277
278  #showLoading() {
279    this.submitButtonTarget.disabled = true
280    this.submitTextTarget.textContent =
281      this.step === "email" ? "Checking…" : "Preparing your quote…"
282  }
283
284  #resetSubmit() {
285    this.submitButtonTarget.disabled = false
286    this.submitTextTarget.textContent =
287      this.step === "email" ? "Continue" : "Email me the quote & download"
288  }
289
290  #showSuccess(message) {
291    this.formTarget.classList.add("hidden")
292    this.successTextTarget.textContent = message
293    this.successTarget.classList.remove("hidden")
294  }
295
296  #hideErrors() {
297    this.errorsTarget.classList.add("hidden")
298  }
299
300  // form.elements still lists the controls inside a disabled fieldset, and
301  // focusing one of those is a no-op — so on step one focus would silently land
302  // nowhere.
303  //
304  // Must be :disabled, NOT .disabled: the IDL property reflects only the
305  // element's OWN disabled attribute, so it reads false for an input inside a
306  // <fieldset disabled>. The pseudo-class is the actually-disabled state.
307  #firstEmptyField() {
308    return Array.from(this.formTarget.elements).find(
309      (field) =>
310        field.tagName === "INPUT" &&
311        field.type !== "hidden" &&
312        !field.matches(":disabled") &&
313        !field.value
314    )
315  }
316}

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.