PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/ecommerce/purchase_order_form_controller-6f360425.js

js littlebirdelectronics.com.au collected 2026-09-25 14:11:05 UTC 41,283 bytes, 843 lines download raw bytes

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.