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.