1import { Controller } from "@hotwired/stimulus" 2 3// The purchase-order review page: one controller across both columns, because 4// the sticky rail is a live reflection of the form beside it. 5// 6// A PO buyer is answering three questions their paperwork never covered â a 7// mobile the courier can text, an email that isn't the accounts inbox, and 8// which delivery service â so the page's whole job is to say what's left, take 9// them there in one click, and price the delivery for the address on the PO 10// before they commit to anything. 11// 12// The server is authoritative about all of it: it prices both shipping modes in 13// one call (/purchase-orders/upload/:token/delivery-options), re-reads every 14// answer on submit, and decides for itself whether the request is complete. 15// Everything here is the running commentary. 16export default class extends Controller { 17 static targets = [ 18 // Delivery pricing 19 "postcode", "country", "rates", "loading", "loadingLabel", "ratesError", "ratesErrorMessage", 20 "splitChoice", "methodField", "methodTitleField", "methodPriceField", "splitField", "deliverySection", 21 // The two fields that are genuinely on the customer 22 "mobile", "mobilePanel", "mobileIcon", "mobileReason", 23 "email", "emailPanel", "emailError", "emailOk", 24 // Running state 25 "pendingBanner", "pendingTitle", "pendingFields", "readyBanner", 26 "checklistTitle", "checklistCount", "checklistItem", 27 "summaryShipping", "summaryShippingLabel", "summaryTotal", "cta", "ctaLabel", 28 "instructions", "instructionsCount", 29 "matchDot", "matchLabel", "unmatchedOpen", "unmatchedDropped", "unmatchedField", 30 "serverMissing", "subtotalDisplay", "draftStatus" 31 ] 32 static values = { 33 url: String, selected: String, split: Boolean, subtotal: Number 34 } 35 36 connect() { 37 this.rates = { combined: null, split: null } 38 this.splittable = false 39 this.mode = this.splitValue ? "split" : "combined" 40 // Don't shout at a field they haven't touched yet â the server-rendered 41 // amber panel already says what's missing. 42 this.mobileTouched = false 43 this.emailTouched = false 44 45 // The PO carried the delivery address, so there is nothing to press â 46 // quote it the moment the section is on screen. 47 if (this.postcodeReady()) this.loadRates() 48 this.refresh() 49 50 // The other thing a price depends on is what's in the box. A "Change" 51 // correction swaps a line â different weight, possibly a different number 52 // of parcels â and arrives as a Turbo broadcast, not as anything this form 53 // can see. Re-quote when one lands. 54 this.itemsHandler = (event) => this.itemsChanged(event) 55 document.addEventListener("turbo:before-stream-render", this.itemsHandler) 56 57 // One delegated listener rather than an action per input: the banner, the 58 // rail, the total and the button all reflect the form, and a field that 59 // quietly wasn't wired up (a first name, a suburb) would leave them saying 60 // something the form no longer means. 61 this.formHandler = () => { this.refresh(); this.queueDraft() } 62 this.element.addEventListener("input", this.formHandler) 63 this.element.addEventListener("change", this.formHandler) 64 65 // Last resort for a tab closed inside the debounce window. sendBeacon is 66 // the only request the browser guarantees to finish once the page is going. 67 this.leaveHandler = () => this.flushDraft() 68 window.addEventListener("pagehide", this.leaveHandler) 69 } 70 71 disconnect() { 72 if (this.debounce) clearTimeout(this.debounce) 73 if (this.draftTimer) clearTimeout(this.draftTimer) 74 this.flushDraft() 75 document.removeEventListener("turbo:before-stream-render", this.itemsHandler) 76 this.element.removeEventListener("input", this.formHandler) 77 this.element.removeEventListener("change", this.formHandler) 78 window.removeEventListener("pagehide", this.leaveHandler) 79 } 80 81 // --- Keeping what they typed ------------------------------------------ 82 // 83 // The Continue button refuses to submit while anything required is 84 // outstanding, so without this a part-finished form never reaches the server:
85 // whoever filled in the contact name, the AP inbox or the phone number from 86 // an email would lose all of it on reload, and the link they forwarded would 87 // render the raw extraction again. Every keystroke is saved as a draft, so 88 // the page can be part-completed by one person and finished by another. 89 90 detailsForm() { return document.getElementById("po-details-form") } 91 92 queueDraft() { 93 this.draftDirty = true 94 if (this.draftTimer) clearTimeout(this.draftTimer) 95 this.draftTimer = setTimeout(() => this.saveDraft(), 700) 96 this.setDraftStatus("saving") 97 } 98 99 // POST + Rails' _method override, so the form's own hidden _method and 100 // authenticity_token do the work and we never hand-roll a CSRF header. 101 draftBody() { 102 const form = this.detailsForm() 103 if (!form) return null 104 const body = new FormData(form) 105 body.set("draft", "1") 106 return body 107 } 108 109 async saveDraft() { 110 const body = this.draftBody() 111 if (!body) return 112 this.draftDirty = false 113 try { 114 const response = await fetch(this.detailsForm().action, { 115 method: "POST", body, credentials: "same-origin", headers: {"Accept": "application/json"} 116 }) 117 this.setDraftStatus(response.ok ? "saved" : "error") 118 } catch { 119 // Offline or the request was cancelled by a navigation. The draft is 120 // still in the fields; say so rather than claiming it's saved. 121 this.setDraftStatus("error") 122 } 123 } 124 125 flushDraft() { 126 if (!this.draftDirty) return 127 const body = this.draftBody() 128 if (!body || !navigator.sendBeacon) return 129 this.draftDirty = false 130 navigator.sendBeacon(this.detailsForm().action, body) 131 } 132 133 setDraftStatus(state) { 134 if (!this.hasDraftStatusTarget) return 135 const copy = { 136 saving: "Saving\u2026", 137 saved: "Saved \u2014 you can send this link on and finish it later", 138 error: "Couldn't save just now \u2014 keep this tab open" 139 } 140 this.draftStatusTarget.textContent = copy[state] || "" 141 this.draftStatusTarget.classList.toggle("text-red-600", state === "error") 142 this.draftStatusTarget.classList.toggle("dark:text-red-400", state === "error") 143 } 144 145 // --- What's still outstanding ----------------------------------------- 146 147 cleanPhone(value) { return (value || "").replace(/[\s\-()]/g, "") } 148 149 // Mirrors PurchaseOrderRequest::REQUIRED_ADDRESS_FIELDS, which asks a purchase 150 // order for A number, not a MOBILE: the contact on a school or council PO is 151 // the switchboard and the buyer can't swap it for a mobile. The SERVER 152 // decides â this only drives what the page says while they type. 153 mobileOk() { 154 return this.cleanPhone(this.hasMobileTarget ? this.mobileTarget.value : "").length > 0 155 } 156 157 emailOk() { 158 const value = (this.hasEmailTarget ? this.emailTarget.value : "").trim() 159 return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value) 160 } 161 162 deliveryOk() { return !!(this.hasMethodFieldTarget && this.methodFieldTarget.value) } 163 164 // In the order the page reads top to bottom, so "the first thing missing" is 165 // the first thing they'd have got to anyway. 166 checklist() { 167 return [ 168 { field: "shipping_address.phone", done: this.mobileOk(), label: "Contact number for the delivery" }, 169 { field: "email", done: this.emailOk(), label: "Email for the order confirmation" }, 170 { field: "shipping_method", done: this.deliveryOk(), label: "Delivery method" } 171 ] 172 } 173 174 // Everything ELSE the server said was missing â a PO with no street address,
175 // no state, a separate bill-to it never filled in. The rail renders these as 176 // their own amber rows; they aren't in the three-item checklist, but they 177 // absolutely still block checkout, so they have to gate the button too. 178 // Without this the CTA goes green, the submit comes back 422, and the buyer 179 // presses it again. 180 outstandingServerFields() { 181 return this.serverMissingTargets 182 .map((el) => el.dataset.field) 183 .filter((field) => { 184 const el = this.fieldElement(field) 185 return !el || !el.value.trim() 186 }) 187 } 188 189 // --- Keeping the page in step ----------------------------------------- 190 191 refresh() { 192 const checklist = this.checklist() 193 const pending = checklist.filter((c) => !c.done) 194 const done = checklist.length - pending.length 195 const blocking = pending.concat( 196 this.outstandingServerFields().map((field) => ({ field, done: false, label: this.serverFieldLabel(field) })) 197 ) 198 199 this.renderChecklist(checklist, pending, done) 200 this.renderBanner(blocking) 201 this.renderPanels() 202 this.renderSummary() 203 this.renderCta(blocking) 204 this.renderServerRows() 205 this.syncPendingValues() 206 } 207 208 serverFieldLabel(field) { 209 const row = this.serverMissingTargets.find((el) => el.dataset.field === field) 210 return row?.dataset.label || field 211 } 212 213 // The rail's amber rows tick off as they're filled, like the three above. 214 renderServerRows() { 215 const outstanding = new Set(this.outstandingServerFields()) 216 this.serverMissingTargets.forEach((row) => { 217 row.classList.toggle("hidden", !outstanding.has(row.dataset.field)) 218 }) 219 } 220 221 renderChecklist(checklist, pending, done) { 222 if (this.hasChecklistCountTarget) this.checklistCountTarget.textContent = `${done} of ${checklist.length} done` 223 if (this.hasChecklistTitleTarget) this.checklistTitleTarget.textContent = this.needTitle(pending.length) 224 225 this.checklistItemTargets.forEach((item) => { 226 const entry = checklist.find((c) => c.field === item.dataset.field) 227 if (!entry) return 228 item.querySelector("[data-checked]")?.classList.toggle("hidden", !entry.done) 229 item.querySelector("[data-unchecked]")?.classList.toggle("hidden", entry.done) 230 item.querySelector("[data-chevron]")?.classList.toggle("hidden", entry.done) 231 item.classList.toggle("bg-emerald-50", entry.done) 232 item.classList.toggle("dark:bg-emerald-950/25", entry.done) 233 item.classList.toggle("ring-emerald-200", entry.done) 234 item.classList.toggle("dark:ring-emerald-900/60", entry.done) 235 item.classList.toggle("bg-white", !entry.done) 236 item.classList.toggle("dark:bg-gray-900", !entry.done) 237 item.classList.toggle("ring-gray-200", !entry.done) 238 item.classList.toggle("dark:ring-gray-700", !entry.done) 239 const label = item.querySelector("span:not([data-unchecked])") 240 if (label) { 241 label.classList.toggle("text-emerald-900", entry.done) 242 label.classList.toggle("dark:text-emerald-200", entry.done) 243 label.classList.toggle("text-gray-900", !entry.done) 244 label.classList.toggle("dark:text-white", !entry.done) 245 } 246 }) 247 } 248 249 needTitle(count) {
250 if (count === 0) return "Everything's here" 251 return count === 1 ? "1 thing we still need" : `${count} things we still need` 252 } 253 254 // The banner doesn't just point at what's missing â it ASKS for it. Each 255 // outstanding requirement gets its real control right here, at the top of the 256 // page, and every one of them is a proxy for the field further down: type in 257 // either and the other follows. (Same trick as checkout's #shipping_phone_fix 258 // â no `name` attribute, so a proxy can never double-post.) 259 // 260 // The point is that a buyer who lands on a PO with three gaps can fill all 261 // three without scrolling, and a buyer who scrolls and fills them in place 262 // watches the banner empty itself. 263 renderBanner(blocking) { 264 const anyMissing = blocking.length > 0 265 if (this.hasPendingBannerTarget) this.pendingBannerTarget.classList.toggle("hidden", !anyMissing) 266 if (this.hasReadyBannerTarget) this.readyBannerTarget.classList.toggle("hidden", anyMissing) 267 if (this.hasPendingTitleTarget) this.pendingTitleTarget.textContent = this.needTitle(blocking.length) 268 if (!this.hasPendingFieldsTarget) return 269 270 // Rebuilding on every keystroke would rip the caret out of the control 271 // being typed into, so only rebuild when the SET changes, and never while 272 // the customer is working inside it â the satisfied row drops away as soon 273 // as they move on. 274 // The rate count and mode ride in the signature: the delivery control can't 275 // be drawn until pricing lands, so "same three fields" is not the same 276 // banner once the rates arrive (or once they switch shipping mode). 277 const rateCount = ((this.mode === "split" ? this.rates.split : this.rates.combined) || []).length 278 const signature = `${blocking.map((b) => b.field).sort().join("|")}#${this.mode}#${rateCount}` 279 const focusInside = this.pendingFieldsTarget.contains(document.activeElement) 280 281 if (signature !== this.pendingSignature && !focusInside) { 282 this.pendingSignature = signature 283 this.pendingFieldsTarget.innerHTML = blocking.map((item) => this.pendingControl(item)).join("") 284 this.syncPendingValues() 285 } else if (focusInside) { 286 this.pendingRebuildQueued = signature !== this.pendingSignature 287 this.markPendingSatisfied(blocking) 288 } 289 } 290 291 // Focus left the banner: apply any rebuild we held back while they typed. 292 // 293 // Deferred a tick on purpose. blur fires BEFORE focus lands, so tabbing (or 294 // clicking) from one banner field to the next would otherwise rebuild the 295 // banner out from under the field they were moving INTO â they'd land on a 296 // replaced node and their first keystroke would go nowhere. 297 pendingBlurred() { 298 if (!this.pendingRebuildQueued) return 299 300 setTimeout(() => { 301 if (this.hasPendingFieldsTarget && this.pendingFieldsTarget.contains(document.activeElement)) return 302 this.pendingRebuildQueued = false 303 this.pendingSignature = null 304 this.refresh() 305 }, 0) 306 } 307 308 // Whether each control in the banner is now satisfied â shown in place while 309 // the row is still on screen, so the answer lands before the row leaves. 310 markPendingSatisfied(blocking) { 311 const outstanding = new Set(blocking.map((b) => b.field)) 312 this.pendingFieldsTarget.querySelectorAll("[data-po-pending]").forEach((row) => { 313 const done = !outstanding.has(row.dataset.poPending) 314 row.querySelector("[data-po-pending-tick]")?.classList.toggle("hidden", !done) 315 row.classList.toggle("ring-emerald-300", done) 316 row.classList.toggle("dark:ring-emerald-800", done) 317 row.classList.toggle("ring-gray-200", !done) 318 row.classList.toggle("dark:ring-gray-700", !done) 319 }) 320 } 321 322 pendingControl(item) { 323 const real = this.fieldElement(item.field) 324 const label = this.escape(item.label || item.field) 325 const tick = `<svg data-po-pending-tick class="hidden h-4 w-4 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke-width="2.6" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>` 326 // One requirement per row, full width: an odd number of these in a 327 // two-column grid left half the banner empty, and the controls themselves 328 // sat at a third of the width they had available. Label left, control 329 // filling the rest. 330 const shell = (inner) => ` 331 <div data-po-pending="${this.escape(item.field)}" class="rounded-xl bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-700 px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-5">
332 <span class="flex items-center gap-1.5 sm:w-72 sm:shrink-0"> 333 <span class="text-sm font-semibold text-gray-900 dark:text-white">${label}</span> 334 ${tick} 335 </span> 336 <span class="flex-1 min-w-0">${inner}</span> 337 </div>` 338 339 const classes = "block w-full rounded-lg border-0 bg-gray-50 dark:bg-gray-800 py-2.5 px-3 text-sm text-gray-900 dark:text-white ring-1 ring-inset ring-gray-200 dark:ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-brand-500 transition-colors" 340 const wiring = `data-po-pending-input data-field="${this.escape(item.field)}" data-action="input->ecommerce--purchase-order-form#pendingInput change->ecommerce--purchase-order-form#pendingInput blur->ecommerce--purchase-order-form#pendingBlurred"` 341 342 // Delivery is a list of priced rates, not a text box. Offer the same 343 // choice as a compact select so it can be answered from up here too. 344 if (item.field === "shipping_method") { 345 const rates = (this.mode === "split" ? this.rates.split : this.rates.combined) || [] 346 if (!rates.length) { 347 return shell(`<p class="text-xs text-gray-500 dark:text-gray-400">Pricing to your addressâ¦</p>`) 348 } 349 const options = rates.map((rate) => 350 `<option value="${this.escape(rate.code)}">${this.escape(rate.title || rate.code)} â $${parseFloat(rate.price).toFixed(2)}</option>` 351 ).join("") 352 return shell(`<select ${wiring} class="${classes}"><option value="">Choose a delivery serviceâ¦</option>${options}</select>`) 353 } 354 355 // Everything else mirrors the real input, borrowing its own type and 356 // placeholder so the two can't drift apart. 357 const type = this.escape(real?.getAttribute("type") || "text") 358 const placeholder = this.escape(real?.getAttribute("placeholder") || "") 359 return shell(`<input type="${type}" placeholder="${placeholder}" ${wiring} class="${classes}">`) 360 } 361 362 // Banner -> form. Writes through to the real field and fires its own input 363 // event, so anything else listening to that field reacts exactly as if the 364 // customer had typed it down there. 365 pendingInput(event) { 366 const proxy = event.currentTarget 367 const real = this.fieldElement(proxy.dataset.field) 368 if (!real) return 369 370 if (proxy.dataset.field === "shipping_method") { 371 const radio = this.element.querySelector(`input[name='po_shipping_rate'][value='${CSS.escape(proxy.value)}']`) 372 if (radio) { 373 radio.checked = true 374 radio.dispatchEvent(new Event("change", { bubbles: true })) 375 } 376 return 377 } 378 379 real.value = proxy.value 380 real.dispatchEvent(new Event("input", { bubbles: true })) 381 this.refresh() 382 } 383 384 // Form -> banner. Never touches the control they're typing in. 385 syncPendingValues() { 386 if (!this.hasPendingFieldsTarget) return 387 this.pendingFieldsTarget.querySelectorAll("[data-po-pending-input]").forEach((proxy) => { 388 if (proxy === document.activeElement) return 389 if (proxy.dataset.field === "shipping_method") { 390 proxy.value = this.hasMethodFieldTarget ? this.methodFieldTarget.value : "" 391 return 392 } 393 const real = this.fieldElement(proxy.dataset.field) 394 if (real) proxy.value = real.value 395 }) 396 } 397 398 // The two panelled fields go green the moment they're satisfied â the reward 399 // for the only typing this page actually asks for. 400 renderPanels() { 401 const amber = ["bg-amber-50", "dark:bg-amber-950/25", "ring-amber-200", "dark:ring-amber-900/60"] 402 const green = ["bg-emerald-50", "dark:bg-emerald-950/25", "ring-emerald-200", "dark:ring-emerald-900/60"] 403 const paint = (panel, ok) => { 404 if (!panel) return 405 panel.classList.remove(...(ok ? amber : green)) 406 panel.classList.add(...(ok ? green : amber)) 407 } 408 409 const mobileOk = this.mobileOk() 410 paint(this.hasMobilePanelTarget ? this.mobilePanelTarget : null, mobileOk) 411 if (this.hasMobileIconTarget) { 412 this.mobileIconTarget.classList.toggle("text-emerald-600", mobileOk) 413 this.mobileIconTarget.classList.toggle("dark:text-emerald-400", mobileOk) 414 this.mobileIconTarget.classList.toggle("text-amber-700", !mobileOk) 415 this.mobileIconTarget.classList.toggle("dark:text-amber-400", !mobileOk) 416 } 417 if (this.hasMobileTarget) { 418 const bad = !mobileOk && this.mobileTouched 419 this.mobileTarget.classList.toggle("ring-red-400", bad) 420 this.mobileTarget.classList.toggle("dark:ring-red-700", bad) 421 this.mobileTarget.classList.toggle("text-red-700", bad) 422 this.mobileTarget.classList.toggle("dark:text-red-400", bad) 423 this.mobileTarget.classList.toggle("ring-gray-200", !bad) 424 this.mobileTarget.classList.toggle("dark:ring-gray-700", !bad) 425 } 426 if (mobileOk && this.hasMobileReasonTarget) { 427 this.mobileReasonTarget.textContent = "The courier rings this on arrival. A mobile also gets the SMS delivery updates." 428 } 429 430 const emailOk = this.emailOk() 431 paint(this.hasEmailPanelTarget ? this.emailPanelTarget : null, emailOk) 432 const emailEmpty = !(this.hasEmailTarget ? this.emailTarget.value.trim() : "") 433 if (this.hasEmailErrorTarget) { 434 this.emailErrorTarget.classList.toggle("hidden", emailOk || emailEmpty || !this.emailTouched) 435 } 436 if (this.hasEmailOkTarget) this.emailOkTarget.classList.toggle("hidden", !emailOk) 437 } 438 439 renderSummary() { 440 if (!this.hasSummaryTotalTarget) return 441 442 const price = parseFloat(this.hasMethodPriceFieldTarget ? this.methodPriceFieldTarget.value : "") 443 const chosen = this.deliveryOk() && !Number.isNaN(price) 444 const title = this.hasMethodTitleFieldTarget ? this.methodTitleFieldTarget.value : "" 445 446 if (this.hasSummaryShippingLabelTarget) { 447 this.summaryShippingLabelTarget.textContent = chosen && title ? `Delivery â ${title}` : "Delivery" 448 } 449 if (this.hasSummaryShippingTarget) { 450 this.summaryShippingTarget.textContent = chosen ? `$${price.toFixed(2)}` : "Not chosen yet" 451 this.summaryShippingTarget.classList.toggle("text-amber-700", !chosen) 452 this.summaryShippingTarget.classList.toggle("dark:text-amber-400", !chosen) 453 this.summaryShippingTarget.classList.toggle("text-gray-900", chosen) 454 this.summaryShippingTarget.classList.toggle("dark:text-white", chosen) 455 } 456 // "+" while the delivery is unpriced: the total is honest about being 457 // incomplete rather than quietly understating what the PO will be billed. 458 const subtotal = this.subtotalValue || 0 459 this.summaryTotalTarget.textContent = chosen 460 ? `$${(subtotal + price).toFixed(2)}` 461 : `$${subtotal.toFixed(2)}+` 462 } 463 464 renderCta(pending) { 465 if (!this.hasCtaTarget) return 466 const ready = pending.length === 0 467 const enabled = ["bg-[#FFD814]", "text-gray-900", "hover:bg-[#F7CA00]", "active:scale-[0.99]", "cursor-pointer"] 468 const disabled = ["bg-gray-200", "dark:bg-gray-700", "text-gray-500", "dark:text-gray-400", "cursor-not-allowed"] 469 this.ctaTarget.classList.remove(...(ready ? disabled : enabled)) 470 this.ctaTarget.classList.add(...(ready ? enabled : disabled)) 471 if (this.hasCtaLabelTarget) { 472 this.ctaLabelTarget.textContent = ready 473 ? "Continue to checkout" 474 : `Finish the ${pending.length} ${pending.length === 1 ? "bit" : "bits"} above` 475 } 476 } 477 478 // --- Interactions ------------------------------------------------------ 479 480 // Every route to a missing field lands here: the banner chips, the sidebar 481 // checklist, and pressing the CTA before it's ready. 482 focusField(event) { 483 const field = event.currentTarget?.dataset?.field || event 484 const el = this.fieldElement(field) 485 if (!el) return 486 487 el.scrollIntoView({ block: "center", behavior: "smooth" })
488 // After the scroll starts, so focus doesn't fight it. 489 setTimeout(() => (el.focus ? el.focus({ preventScroll: true }) : null), 220) 490 } 491 492 fieldElement(field) { 493 if (field === "shipping_address.phone") return this.hasMobileTarget ? this.mobileTarget : null 494 if (field === "email") return this.hasEmailTarget ? this.emailTarget : null 495 if (field === "shipping_method") { 496 return this.element.querySelector("input[name='po_shipping_rate']") || 497 (this.hasDeliverySectionTarget ? this.deliverySectionTarget : null) 498 } 499 // Anything else is a plain address field, named the way the form posts it. 500 const [scope, name] = field.split(".") 501 return name ? this.element.querySelector(`[name="${scope}[${name}]"]`) : this.element.querySelector(`[name="${field}"]`) 502 } 503 504 // Pressing "Finish the 3 bits above" takes them to the first one instead of 505 // submitting a form the server would only bounce back. 506 submitAttempt(event) { 507 // A line being re-matched right now is still the WRONG line in the cart. 508 if (this.element.querySelector("[data-po-line].opacity-60")) { 509 event.preventDefault() 510 return this.showError("One line is still being re-matched â give it a moment.") 511 } 512 513 const pending = this.checklist().filter((c) => !c.done).concat( 514 this.outstandingServerFields().map((field) => ({ field })) 515 ) 516 if (pending.length === 0) return 517 event.preventDefault() 518 this.mobileTouched = true 519 this.emailTouched = true 520 this.refresh() 521 this.focusField(pending[0].field) 522 } 523 524 mobileTyped() { this.refresh() } 525 mobileBlurred() { this.mobileTouched = true; this.refresh() } 526 emailTyped() { this.refresh() } 527 emailBlurred() { this.emailTouched = true; this.refresh() } 528 529 instructionsTyped() { 530 if (!this.hasInstructionsTarget || !this.hasInstructionsCountTarget) return 531 const max = parseInt(this.instructionsTarget.getAttribute("maxlength") || "200", 10) 532 const length = this.instructionsTarget.value.length 533 this.instructionsCountTarget.textContent = `${length} / ${max}` 534 this.instructionsCountTarget.classList.toggle("text-amber-700", length > max * 0.85 && length < max) 535 this.instructionsCountTarget.classList.toggle("text-red-600", length >= max) 536 } 537 538 dropUnmatched() { this.setUnmatched(true) } 539 restoreUnmatched() { this.setUnmatched(false) } 540 541 // "We couldn't match this line" reads as a problem until someone says it 542 // isn't one, so leaving it off is recorded as a decision, and the lines 543 // header stops calling the order incomplete. 544 setUnmatched(dropped) { 545 if (this.hasUnmatchedFieldTarget) this.unmatchedFieldTarget.value = dropped ? "true" : "false" 546 if (this.hasUnmatchedOpenTarget) this.unmatchedOpenTarget.classList.toggle("hidden", dropped) 547 if (this.hasUnmatchedDroppedTarget) this.unmatchedDroppedTarget.classList.toggle("hidden", !dropped) 548 if (this.hasMatchDotTarget) { 549 this.matchDotTarget.classList.toggle("bg-emerald-500", dropped) 550 this.matchDotTarget.classList.toggle("bg-amber-500", !dropped) 551 } 552 if (this.hasMatchLabelTarget) { 553 const label = dropped 554 ? this.matchLabelTarget.dataset.droppedLabel 555 : this.matchLabelTarget.dataset.openLabel 556 if (label) this.matchLabelTarget.textContent = label 557 } 558 } 559 560 // --- Delivery pricing -------------------------------------------------- 561 562 postcodeReady() { 563 const postcode = (this.hasPostcodeTarget ? this.postcodeTarget.value : "").trim() 564 return postcode.length >= 4 || this.country() !== "AU" 565 } 566 567 country() { 568 return (this.hasCountryTarget ? this.countryTarget.value : "AU") || "AU" 569 } 570 571 // A different destination means different prices, so any quote on screen is 572 // now a lie. Drop it and re-quote. Bound to `change`, not `input`, so it fires 573 // once the field is committed rather than on every keystroke of a postcode. 574 // The short debounce only coalesces a country + postcode edit into one quote. 575 addressChanged() { 576 this.clearRates() 577 if (this.debounce) clearTimeout(this.debounce) 578 if (!this.postcodeReady()) return 579 this.debounce = setTimeout(() => this.loadRates(), 150) 580 } 581 582 // Only a settled line counts: the row broadcast the moment the customer asks 583 // for a swap is a "lookingâ¦" placeholder for the SAME part, so quoting on it 584 // would price a basket that's about to change again a second later. 585 itemsChanged(event) { 586 const target = event.target?.getAttribute?.("target") || "" 587 if (!target.startsWith("cart-link-item-") && target !== "cart-link-stream-items") return 588 if (event.target.templateContent?.textContent?.includes("Looking for a better match")) return 589 590 setTimeout(() => { 591 this.recalculateSubtotal() 592 if (this.postcodeReady()) this.loadRates() 593 }, 100) 594 } 595
596 // A swapped line is a different price. Re-add the line totals off the DOM 597 // (the rows carry them) so the lines card and the rail stop quoting the part 598 // the customer just told us was wrong. 599 recalculateSubtotal() { 600 const totals = [...this.element.querySelectorAll("[data-po-line] [data-line-total]")] 601 .map((el) => parseFloat(el.dataset.lineTotal)) 602 .filter((n) => !Number.isNaN(n)) 603 if (!totals.length) return 604 605 this.subtotalValue = totals.reduce((a, b) => a + b, 0) 606 if (this.hasSubtotalDisplayTarget) { 607 this.subtotalDisplayTargets.forEach((el) => { el.textContent = `$${this.subtotalValue.toFixed(2)}` }) 608 } 609 this.renderSummary() 610 } 611 612 loadRates() { 613 this.hideError() 614 this.setLoading(true) 615 616 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 617 fetch(this.urlValue, { 618 method: "POST", 619 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, Accept: "application/json" }, 620 body: JSON.stringify({ shipping_address: this.addressPayload() }) 621 }) 622 .then((response) => response.json().then((data) => ({ ok: response.ok, data }))) 623 .then(({ ok, data }) => { 624 this.setLoading(false) 625 if (!ok || data.error) return this.showError(data.error || "We couldn't price the delivery just now.") 626 627 this.rates = { combined: data.rates || [], split: data.split_rates || null } 628 this.splittable = !!data.splittable 629 if (!this.splittable) this.mode = "combined" 630 this.renderSplitChoice() 631 this.renderRates() 632 }) 633 .catch(() => { 634 this.setLoading(false) 635 this.showError("We couldn't reach the delivery pricing.") 636 }) 637 } 638 639 addressPayload() { 640 const value = (name) => this.element.querySelector(`[name="shipping_address[${name}]"]`)?.value?.trim() || "" 641 return { 642 first_name: value("first_name"), last_name: value("last_name"), company: value("company"), 643 address1: value("address1"), address2: value("address2"), city: value("city"), 644 province: value("province"), zip: value("zip"), country: this.country(), phone: value("phone") 645 } 646 } 647 648 // --- Split shipping ---------------------------------------------------- 649 650 // Mirrors the cart's split-shipping control (carts/_cart_items_grouped): the 651 // same pill toggle, the same blue parcels note, the same words. A buyer who 652 // has used the cart already knows what this does, and "ship together vs ship 653 // separately" is the decision they're actually making â not a choice between 654 // two priced bundles. 655 renderSplitChoice() { 656 if (!this.hasSplitChoiceTarget) return 657 if (!this.splittable || !this.rates.split?.length) { 658 this.splitChoiceTarget.classList.add("hidden") 659 this.splitChoiceTarget.innerHTML = "" 660 this.setSplit(false) 661 return 662 } 663 664 const split = this.mode === "split" 665 const on = "bg-blue-50 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 shadow-sm ring-1 ring-blue-300 dark:ring-blue-600" 666 const off = "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300" 667 const dotFilled = `<svg class="h-3 w-3 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="10" r="4" /></svg>` 668 const dotEmpty = `<svg class="h-3 w-3" fill="none" viewBox="0 0 20 20" stroke-width="1.5" stroke="currentColor"><circle cx="10" cy="10" r="6" /></svg>` 669 670 // The alternative's price, inline and one click away â the same move the 671 // cart makes (ecommerce--alternative-shipping-link). Without it the toggle 672 // hides what the other option COSTS, and "should we split this?" is a 673 // question about money. 674 const other = this.cheapest(split ? "combined" : "split") 675 const offer = other === null ? "" : ` 676 <button type="button" data-action="ecommerce--purchase-order-form#splitModeChanged" data-mode="${split ? "combined" : "split"}" 677 class="ml-1 font-semibold underline underline-offset-2 hover:no-underline cursor-pointer"> 678 ${split ? "Ship together" : "Ship separately"} for $${other.toFixed(2)} 679 </button>` 680 681 const note = split 682 ? `<span class="font-semibold">Shipping in 2 parcels.</span> Items available from our Sydney warehouse ship straight away. The rest will follow in a second shipment once they arrive from the manufacturer.${offer}` 683 : `<span class="font-semibold">Shipping as a single parcel.</span> Your entire order will be held and shipped together once those items arrive from the manufacturer.${offer}` 684 685 this.splitChoiceTarget.innerHTML = ` 686 <div class="rounded-xl ring-1 ring-amber-200 dark:ring-amber-900/50 overflow-hidden"> 687 <div class="flex flex-col sm:flex-row sm:items-center gap-2 px-5 py-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-900/50"> 688 <div class="flex items-center gap-2 flex-1 min-w-0">
689 <span class="h-2 w-2 rounded-full bg-amber-500 flex-shrink-0"></span> 690 <h3 class="text-sm font-semibold text-gray-900 dark:text-white">Some lines are incoming from the manufacturer</h3> 691 </div> 692 <div class="inline-flex self-start sm:self-auto rounded-full bg-white dark:bg-gray-800 p-1 ring-1 ring-amber-300 dark:ring-amber-700"> 693 <button type="button" data-action="ecommerce--purchase-order-form#splitModeChanged" data-mode="combined" 694 class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all whitespace-nowrap cursor-pointer ${split ? off : on}"> 695 ${split ? dotEmpty : dotFilled} 696 Ship together 697 </button> 698 <button type="button" data-action="ecommerce--purchase-order-form#splitModeChanged" data-mode="split" 699 class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all whitespace-nowrap cursor-pointer ${split ? on : off}"> 700 ${split ? dotFilled : dotEmpty} 701 Ship separately 702 </button> 703 </div> 704 </div> 705 <div class="m-5 flex items-start gap-2.5 rounded-lg bg-blue-50/60 dark:bg-blue-900/10 border border-blue-200/60 dark:border-blue-800/40 px-4 py-3"> 706 <svg class="h-4 w-4 text-blue-500 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 448 512"><path opacity=".4" d="M48 224l352 0 0 192c0 8.8-7.2 16-16 16L64 432c-8.8 0-16-7.2-16-16l0-192z" /><path d="M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 48 352 0 0-48c0-8.8-7.2-16-16-16l-264 0zM48 224l0 192c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-192-352 0z" /></svg> 707 <p class="text-xs leading-relaxed text-blue-800 dark:text-blue-300">${note}</p> 708 </div> 709 </div>` 710 711 this.splitChoiceTarget.classList.remove("hidden") 712 this.setSplit(split) 713 } 714 715 cheapest(mode) { 716 const rates = (mode === "split" ? this.rates.split : this.rates.combined) || [] 717 const prices = rates.map((rate) => parseFloat(rate.price)).filter((price) => !Number.isNaN(price)) 718 return prices.length ? Math.min(...prices) : null 719 } 720 721 splitModeChanged(event) { 722 this.mode = event.currentTarget.dataset.mode 723 this.setSplit(this.mode === "split") 724 this.renderSplitChoice() 725 this.renderRates() 726 } 727 728 setSplit(on) { 729 if (this.hasSplitFieldTarget) this.splitFieldTarget.value = on ? "true" : "false" 730 } 731 732 // --- Rates ------------------------------------------------------------- 733 734 renderRates() { 735 const rates = (this.mode === "split" ? this.rates.split : this.rates.combined) || [] 736 if (!rates.length) { 737 this.ratesTarget.innerHTML = "" 738 return this.showError("No delivery options are available for that address.") 739 } 740 741 // Keep an earlier choice across a re-quote or a mode switch when the same 742 // service is still offered. Nothing is auto-selected otherwise: which 743 // service to use is the ONE question the purchase order didn't answer, so 744 // answering it for them would defeat the point of asking. 745 const previous = this.methodFieldTarget.value || this.selectedValue 746 const chosen = rates.findIndex((rate) => rate.code === previous) 747 748 this.ratesTarget.innerHTML = rates.map((rate, index) => { 749 const collection = rate.code === "click_and_collect" 750 // Same "= $8.37 + $8.37" the cart shows once an order splits: two 751 // deliveries means two charges, and the buyer should see both. 752 const perShipment = Array.isArray(rate.per_shipment_totals) ? rate.per_shipment_totals : null 753 const breakdown = (perShipment && perShipment.length > 1) 754 ? `<span class="block text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 tabular-nums">= ${perShipment.map((n) => `$${parseFloat(n).toFixed(2)}`).join(" + ")}</span>` 755 : "" 756 const badge = rate.carrier === "gopeople" 757 ? `<span class="inline-flex items-center rounded-full bg-emerald-50 dark:bg-emerald-900/30 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 dark:text-emerald-400 ring-1 ring-inset ring-emerald-600/20">Same day</span>` 758 : (collection ? `<span class="inline-flex items-center rounded-full bg-brand-50 dark:bg-brand-900/30 px-1.5 py-0.5 text-[10px] font-semibold text-brand-700 dark:text-brand-300 ring-1 ring-inset ring-brand-600/20">Pickup</span>` : "") 759 return `
760 <label class="group flex items-center gap-4 rounded-xl bg-white dark:bg-gray-900 ring-1 ring-gray-200 dark:ring-gray-700 px-5 py-4 cursor-pointer has-[:checked]:ring-2 has-[:checked]:ring-brand-500 has-[:checked]:bg-brand-50 dark:has-[:checked]:bg-brand-950/30 transition-all"> 761 <input type="radio" name="po_shipping_rate" value="${this.escape(rate.code)}" ${index === chosen ? "checked" : ""} 762 data-price="${this.escape(String(rate.price))}" data-title="${this.escape(rate.title || "")}" 763 data-action="change->ecommerce--purchase-order-form#rateChanged" 764 class="h-4.5 w-4.5 border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"> 765 <span class="flex-1 min-w-0"> 766 <span class="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">${this.escape(rate.title || "")} ${badge}</span> 767 <span class="block text-sm text-gray-500 dark:text-gray-400 mt-0.5">${this.escape(rate.estimated_days || "")}</span> 768 </span> 769 <span class="text-right whitespace-nowrap"> 770 <span class="block text-base font-bold text-gray-900 dark:text-white tabular-nums">${collection ? "Free" : `$${parseFloat(rate.price).toFixed(2)}`}</span> 771 ${breakdown} 772 </span> 773 </label>` 774 }).join("") 775 776 this.commitRate(chosen >= 0 ? rates[chosen] : null) 777 } 778 779 rateChanged(event) { 780 this.commitRate({ 781 code: event.target.value, 782 title: event.target.dataset.title, 783 price: event.target.dataset.price 784 }) 785 } 786 787 // The radios are display only â these hidden fields are what the form 788 // actually posts, so the server records the service the buyer looked at. 789 commitRate(rate) { 790 const changed = this.methodFieldTarget.value !== (rate?.code || "") 791 this.methodFieldTarget.value = rate?.code || "" 792 if (this.hasMethodTitleFieldTarget) this.methodTitleFieldTarget.value = rate?.title || "" 793 if (this.hasMethodPriceFieldTarget) this.methodPriceFieldTarget.value = rate?.price ?? "" 794 this.refresh() 795 // Written straight to hidden inputs, so no input/change event reaches the 796 // delegated listener. Ask for the save explicitly or the chosen delivery 797 // method is the one thing on the form that never persists. Only when it 798 // actually moved: quoting on load re-selects the saved rate, and a page 799 // nobody has touched shouldn't announce that it just saved something. 800 if (changed && rate?.code) this.queueDraft() 801 } 802 803 clearRates() { 804 this.rates = { combined: null, split: null } 805 this.ratesTarget.innerHTML = "" 806 if (this.hasSplitChoiceTarget) { 807 this.splitChoiceTarget.innerHTML = "" 808 this.splitChoiceTarget.classList.add("hidden") 809 } 810 this.commitRate(null) 811 } 812 813 // --- Chrome ------------------------------------------------------------ 814 815 setLoading(loading) { 816 if (this.hasLoadingTarget) this.loadingTarget.classList.toggle("hidden", !loading) 817 if (loading && this.hasLoadingLabelTarget) { 818 const postcode = (this.hasPostcodeTarget ? this.postcodeTarget.value : "").trim() 819 this.loadingLabelTarget.textContent = postcode 820 ? `Pricing delivery to ${postcode}â¦` 821 : "Pricing delivery to this addressâ¦" 822 } 823 } 824 825 // The retry button lives INSIDE this panel, so it only exists when there is 826 // something to retry â the rest of the time the options just sit there. 827 showError(message) { 828 if (!this.hasRatesErrorTarget) return 829 if (this.hasRatesErrorMessageTarget) this.ratesErrorMessageTarget.textContent = message 830 this.ratesErrorTarget.classList.remove("hidden") 831 } 832 833 hideError() { 834 if (!this.hasRatesErrorTarget) return 835 this.ratesErrorTarget.classList.add("hidden") 836 } 837 838 escape(value) { 839 const div = document.createElement("div") 840 div.textContent = value ?? "" 841 return div.innerHTML 842 } 843}
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.