1import { Controller } from "@hotwired/stimulus" 2import { requestPreview } from "lib/booking_preview_client" 3import { track, trackSecondary } from "lib/analytics" 4 5// Handles step 2 interactions: segments, extras, passenger data and preview totals. 6export default class extends Controller { 7 static targets = [ 8 "segment", 9 "extraToggle", 10 "baseTotal", 11 "extrasTotal", 12 "grandTotal", 13 "summaryNote", 14 "paymentMethod", 15 "payment", 16 "cardTotal", 17 "cardNote" 18 ] 19 20 static values = { 21 previewEndpoint: String 22 } 23 24 connect() { 25 this.abortController = null 26 this.debouncedRefresh = this.debounce(() => this.refreshPreview(), 250) 27 28 this.sentCheckoutDetailsStepView = false 29 30 this.boundChangeHandler = (event) => { 31 // Keep date constraints fresh on any change 32 this.adjustDateMinsToLocalToday() 33 this.enforceRoundTripDateOrdering() 34 // Toggle return date section visibility based on service type 35 this.toggleReturnDateSection() 36 this.trackDateSelected(event) 37 this.debouncedRefresh() 38 // Reflect passenger count in the URL so shared links preserve the selection 39 try { 40 this.updateUrlWithPassengers() 41 } catch (e) { 42 // no-op 43 } 44 } 45 this.element.addEventListener("input", this.boundChangeHandler) 46 this.element.addEventListener("change", this.boundChangeHandler) 47 // Listen for Flowbite datepicker change events 48 this.element.addEventListener("changeDate", this.boundChangeHandler) 49 this.boundStepChangeHandler = (e) => { 50 this.toggleReturnDateSection() 51 const step = Number(e?.detail?.step) 52 if (!Number.isNaN(step)) this.maybeTrackDetailsStepView(step) 53 } 54 this.element.addEventListener("transfer-form:step-changed", this.boundStepChangeHandler) 55 // Validate round-trip return date before form submission 56 const form = this.element.closest("form") 57 if (form) { 58 this.boundSubmitHandler = (e) => this.validateBeforeSubmit(e) 59 form.addEventListener("submit", this.boundSubmitHandler) 60 // Also listen for Turbo submit events in case Turbo intercepts the submit 61 document.addEventListener("turbo:submit-start", this.boundSubmitHandler) 62 } else { 63 } 64 // Ensure date pickers allow selecting the user's local "today" (override server-side time zone skew) 65 this.adjustDateMinsToLocalToday() 66 // Apply round-trip constraint: inbound date >= last outbound date 67 this.enforceRoundTripDateOrdering() 68 // Toggle return date section visibility based on service type 69 this.toggleReturnDateSection() 70 71 // If we land directly on step 2 (details), emit the view event. 72 const currentStepInput = form?.querySelector("input[name='booking[current_step]']") 73 const currentStep = Number(currentStepInput?.value) 74 if (!Number.isNaN(currentStep)) this.maybeTrackDetailsStepView(currentStep) 75 76 this.refreshPreview() 77 } 78 79 updateUrlWithPassengers() { 80 const root = this.element.closest("form") 81 if (!root || typeof window === 'undefined') return 82 const passengerInputs = Array.from(root.querySelectorAll("[name='booking[passengers]']")) 83 const passengers = passengerInputs.reduce((memo, input) => { 84 const val = parseInt(input.value || "0", 10) 85 return (!Number.isNaN(val) && val > 0) ? val : memo 86 }, 0) 87 88 try { 89 const url = new URL(window.location.href) 90 const params = url.searchParams 91 if (passengers && passengers > 0) { 92 params.set('booking[passengers]', String(passengers)) 93 } else { 94 params.delete('booking[passengers]') 95 } 96 // Replace state to avoid polluting history while keeping shareable URL 97 const newUrl = url.pathname + (params.toString() ? `?${params.toString()}` : '') + (url.hash || '') 98 window.history.replaceState({}, document.title, newUrl) 99 } catch (e) { 100 // If URL constructor isn't available or other error, ignore 101 } 102 } 103 104 maybeTrackDetailsStepView(currentStep) { 105 if (this.sentCheckoutDetailsStepView) return 106 // Transfer form UI step 2 corresponds to the GA4 checkout step 3 (details) 107 if (Number(currentStep) !== 2) return 108 109 track( 110 "checkout_step_view", 111 { 112 step: 3, 113 step_name: "details" 114 }, 115 { dedupeTtlMs: 30_000 } 116 ) 117 this.sentCheckoutDetailsStepView = true 118 } 119 120 disconnect() { 121 this.element.removeEventListener("input", this.boundChangeHandler) 122 this.element.removeEventListener("change", this.boundChangeHandler) 123 this.element.removeEventListener("changeDate", this.boundChangeHandler) 124 const form = this.element.closest("form") 125 if (form && this.boundSubmitHandler) { 126 form.removeEventListener("submit", this.boundSubmitHandler) 127 document.removeEventListener("turbo:submit-start", this.boundSubmitHandler) 128 } 129 if (this.boundStepChangeHandler) { 130 this.element.removeEventListener("transfer-form:step-changed", this.boundStepChangeHandler) 131 } 132 if (this.abortController) this.abortController.abort() 133 } 134 135 validateBeforeSubmit(e) { 136 // Get the form - could be from submit event or Turbo event 137 let form = e.target 138 if (e.type === "turbo:submit-start") { 139 form = e.detail.formSubmission.form 140 } 141 if (!form) return 142 143 trackSecondary( 144 "booking_form_submit", 145 { 146 checkout_step: 2, 147 step_name: "details", 148 }, 149 { dedupeTtlMs: 2_000, dataLayerEvent: true } 150 ) 151 152 // Log all segment date inputs before submission 153 const outboundDateInput = form.querySelector("input[name='booking[segments][0][date]']") 154 const returnDateInput = form.querySelector("input[name='booking[segments][1][date]']")
155 if (!outboundDateInput?.value || !returnDateInput?.value) { 156 // Don't block submission - let server validation handle it 157 // This allows us to see the actual data being sent 158 } 159 } 160 161 trackDateSelected(event) { 162 const target = event?.target 163 if (!target || !target.matches?.("input[name*='[date]']")) return 164 if (!target.value) return 165 166 trackSecondary( 167 "date_selected", 168 { 169 field_name: target.name, 170 date: target.value, 171 }, 172 { dedupeTtlMs: 2_000, dataLayerEvent: true } 173 ) 174 } 175 176 async refreshPreview() { 177 const endpoint = this.previewEndpointValue || "/api/v1/bookings/preview" 178 const payload = this.buildPayload() 179 if (!payload) return 180 181 if (this.abortController) this.abortController.abort() 182 this.abortController = new AbortController() 183 184 try { 185 const result = await requestPreview(endpoint, payload, this.abortController.signal) 186 this.renderTotals(result) 187 this.element.dispatchEvent(new CustomEvent("booking-step:preview-updated", { detail: result, bubbles: true })) 188 } catch (error) { 189 if (error?.name === "AbortError") return 190 console.error("[BookingStep] refreshPreview error", error) 191 this.renderTotals(null) 192 this.element.dispatchEvent(new CustomEvent("booking-step:preview-error", { detail: error, bubbles: true })) 193 } 194 } 195 196 debounce(fn, wait = 200) { 197 let timeout 198 return (...args) => { 199 clearTimeout(timeout) 200 timeout = setTimeout(() => { 201 Promise.resolve(fn.apply(this, args)).catch((error) => { 202 console.error("[BookingStep] debounce error", error) 203 this.element.dispatchEvent(new CustomEvent("booking-step:preview-error", { detail: error, bubbles: true })) 204 }) 205 }, wait) 206 } 207 } 208 209 buildPayload() { 210 const root = this.element.closest("form") 211 if (!root) return null 212 213 const passengerInputs = Array.from(root.querySelectorAll("[name='booking[passengers]']")) 214 const passengers = passengerInputs.reduce((memo, input) => { 215 const val = parseInt(input.value || "0", 10) 216 return (!Number.isNaN(val) && val > 0) ? val : memo 217 }, 0) 218 if (!passengers || Number.isNaN(passengers)) return null 219 220 const serviceType = this.resolveServiceType(root) 221 // Prefer hidden Step 2 input so we don't rely on potentially blank selects 222 const vehicleInput = root.querySelector("input[type='hidden'][name='booking[vehicle_id]']") 223 || this.element.querySelector("[name='booking[vehicle_id]']") 224 || root.querySelector("[name='booking[vehicle_id]']") 225 const originInput = root.querySelector("[name='booking[origin]']") 226 const destinationInput = root.querySelector("[name='booking[destination]']") 227 228 const origin = originInput ? originInput.dataset.zoneId : null 229 const destination = destinationInput ? destinationInput.dataset.zoneId : null 230 231 const segmentContainers = serviceType === "round_trip" ? this.segmentTargets : this.segmentTargets.slice(0, 1) 232 233 const segments = segmentContainers.map((container, index) => { 234 const seg = this.serializeSegment(container) 235 seg.sequence = index + 1 236 237 // Force directions based on service type and position 238 seg.direction = serviceType === "round_trip" ? (index === 0 ? "outbound" : "inbound") : "outbound" 239 240 // Assign zone ids, swapping for inbound legs when round trip 241 const isInbound = serviceType === "round_trip" && index === 1 242 const originZone = isInbound ? destination : origin 243 const destinationZone = isInbound ? origin : destination 244 seg.origin_zone_id = originZone 245 seg.destination_zone_id = destinationZone 246 return seg 247 }) 248 if (segments.length === 0) return null 249 // Require zone ids and date for each segment before calling preview to avoid 400s 250 const hasRequiredSegmentFields = segments.every((s) => { 251 const oz = (s.origin_zone_id || "").toString().trim() 252 const dz = (s.destination_zone_id || "").toString().trim() 253 return oz && dz 254 }) 255 if (!hasRequiredSegmentFields) return null 256 257 const extras = [] 258 const optOutCodes = []
259 this.extraToggleTargets.forEach((toggle) => { 260 const code = toggle.dataset.code 261 if (!code) return 262 const selected = toggle.checked 263 if (!selected) optOutCodes.push(code) 264 const amountField = this.findSiblingInput(toggle, `booking[extras]`, "amount_cents") 265 const amount = amountField ? parseInt(amountField.value || "0", 10) : null 266 extras.push({ code, selected, amount_cents: amount }) 267 }) 268 269 const payload = { 270 passengers, 271 service_type: serviceType, 272 segments, 273 opt_out_codes: optOutCodes, 274 extras 275 } 276 277 if (vehicleInput && vehicleInput.value) { 278 payload.vehicle_id = vehicleInput.value 279 } 280 281 return payload 282 } 283 284 serializeSegment(container) { 285 const data = {} 286 const dateInput = container.querySelector("input[name*='[date]']") 287 288 container.querySelectorAll("input:not([disabled]), select:not([disabled])").forEach((field) => { 289 const name = field.getAttribute("name") 290 if (!name) return 291 const match = name.match(/booking\[segments\]\[(\d+)\]\[(.+)\]/) 292 if (!match) return 293 const key = match[2] 294 const value = field.value 295 data[key] = value 296 }) 297 return data 298 } 299 300 findSiblingInput(toggle, scope, attribute) { 301 const regex = new RegExp(`${scope}\\\\[(\\\\d+)\\\\]\\\\[${attribute}\\\\]`) 302 const siblings = toggle.parentElement?.querySelectorAll(`input[name*='${scope}']`) || [] 303 return Array.from(siblings).find((input) => regex.test(input.name)) || null 304 } 305 306 renderTotals(result) { 307 if (!result) { 308 this.updateTotalTarget(this.baseTotalTarget, null) 309 this.updateTotalTarget(this.extrasTotalTarget, null) 310 this.updateTotalTarget(this.grandTotalTarget, null) 311 this.updateCardTotal(null) 312 if (this.hasSummaryNoteTarget) { 313 this.summaryNoteTarget.textContent = this.summaryPlaceholderText() 314 this.summaryNoteTarget.classList.remove("hidden") 315 } 316 return 317 } 318 319 const formatter = this.currencyFormatter(result.currency || "USD") 320 this.updateTotalTarget(this.baseTotalTarget, formatter(result.base_total_cents || 0)) 321 this.updateTotalTarget(this.extrasTotalTarget, formatter(result.extras_total_cents || 0)) 322 this.updateTotalTarget(this.grandTotalTarget, formatter(result.grand_total_cents || 0)) 323 this.updateCardTotal(result) 324 if (this.hasSummaryNoteTarget) { 325 this.summaryNoteTarget.textContent = "" 326 this.summaryNoteTarget.classList.add("hidden") 327 } 328 } 329 330 updateTotalTarget(target, value) { 331 if (!target) return 332 const displayValue = value == null ? "â" : value 333 target.textContent = displayValue 334 } 335 336 updateCardTotal(result) { 337 if (!this.hasCardTotalTarget) return 338 339 if (!result) { 340 this.cardTotalTarget.textContent = "â" 341 // Still show the trip type even if no result 342 if (this.hasCardNoteTarget) { 343 const serviceTypeInput = this.element.querySelector("input[name='booking[service_type]']:checked") 344 const serviceType = serviceTypeInput?.value || 'one_way' 345 if (serviceType === 'round_trip') { 346 this.cardNoteTarget.textContent = this.roundTripNoteText() 347 } else { 348 this.cardNoteTarget.textContent = this.oneWayNoteText() 349 } 350 this.cardNoteTarget.classList.remove("hidden") 351 } 352 return 353 } 354 355 const formatter = this.currencyFormatter(result.currency || "USD") 356 this.cardTotalTarget.textContent = formatter(result.base_total_cents || result.grand_total_cents || 0) 357 358 if (this.hasCardNoteTarget) { 359 if ((result.service_type || "").toString() === "round_trip") { 360 this.cardNoteTarget.textContent = this.roundTripNoteText() 361 this.cardNoteTarget.classList.remove("hidden") 362 } else { 363 this.cardNoteTarget.textContent = this.oneWayNoteText() 364 this.cardNoteTarget.classList.remove("hidden") 365 } 366 } 367 } 368 369 roundTripNoteText() { 370 try { 371 const noteTarget = this.hasCardNoteTarget ? this.cardNoteTarget : null 372 const text = noteTarget?.dataset?.roundTripNote 373 return text || "round trip" 374 } catch (e) { 375 return "round trip" 376 } 377 } 378 379 oneWayNoteText() { 380 try { 381 const noteTarget = this.hasCardNoteTarget ? this.cardNoteTarget : null 382 const text = noteTarget?.dataset?.oneWayNote 383 return text || "Ã1 (one-way)" 384 } catch (e) { 385 return "Ã1 (one-way)" 386 } 387 } 388 389 currencyFormatter(currency) { 390 let formatter 391 try { 392 formatter = new Intl.NumberFormat(undefined, { style: "currency", currency }) 393 } catch (e) { 394 formatter = new Intl.NumberFormat(undefined, { style: "currency", currency: "USD" }) 395 } 396 return (cents) => formatter.format((cents || 0) / 100) 397 } 398 399 debounce(fn, wait = 200) { 400 let timeout 401 return (...args) => { 402 clearTimeout(timeout) 403 timeout = setTimeout(() => fn.apply(this, args), wait) 404 } 405 } 406 407 // Force all date inputs within this step to have min=today (local), so same-day booking is selectable 408 adjustDateMinsToLocalToday() { 409 try { 410 const now = new Date() 411 const yyyy = now.getFullYear() 412 const mm = String(now.getMonth() + 1).padStart(2, "0") 413 const dd = String(now.getDate()).padStart(2, "0") 414 const todayStr = `${yyyy}-${mm}-${dd}` 415 this.element.querySelectorAll("input[name*='[date]']").forEach((input) => { 416 this.applyDatepickerMin(input, todayStr) 417 }) 418 } catch (e) { 419 // no-op: if the browser doesn't support date inputs or something goes wrong, ignore 420 } 421 } 422
423 // Ensure departure/return date is not before arrival/outbound for round trips 424 enforceRoundTripDateOrdering() { 425 try { 426 const root = this.element.closest("form") 427 if (!root) return 428 const serviceType = this.resolveServiceType(root) 429 if (serviceType !== "round_trip") return 430 431 // Compute local today string 432 const now = new Date() 433 const yyyy = now.getFullYear() 434 const mm = String(now.getMonth() + 1).padStart(2, "0") 435 const dd = String(now.getDate()).padStart(2, "0") 436 const todayStr = `${yyyy}-${mm}-${dd}` 437 438 // Collect outbound dates and find max 439 let outboundDates = [] 440 this.segmentTargets.forEach((container, index) => { 441 const dateInput = container.querySelector("input[name*='[date]']") 442 if (!dateInput) return 443 const direction = this.segmentDirection(container, index) 444 const value = (dateInput.value || "").trim() 445 if (direction === "outbound" && value.match(/^\d{4}-\d{2}-\d{2}$/)) { 446 outboundDates.push(value) 447 } 448 }) 449 const outboundMax = outboundDates.sort().slice(-1)[0] || todayStr 450 const minForInbound = outboundMax > todayStr ? outboundMax : todayStr 451 452 // Apply min to inbound date fields 453 this.segmentTargets.forEach((container, index) => { 454 const dateInput = container.querySelector("input[name*='[date]']") 455 if (!dateInput) return 456 const direction = this.segmentDirection(container, index) 457 if (direction === "inbound") { 458 this.applyDatepickerMin(dateInput, minForInbound) 459 // If current value is before min, clear it to force re-selection 460 const v = (dateInput.value || "").trim() 461 if (v && v < minForInbound) { 462 dateInput.value = "" 463 if (dateInput.datepicker && typeof dateInput.datepicker.setDate === "function") { 464 dateInput.datepicker.setDate({ clear: true }) 465 } 466 } 467 } 468 }) 469 } catch (e) { 470 // ignore; non-fatal UI enhancement 471 } 472 } 473 474 summaryPlaceholderText() { 475 try { 476 const el = document.querySelector("#step-2") 477 const text = el?.dataset?.summaryPlaceholder 478 if (text) return text 479 } catch (e) { } 480 return "Select a vehicle to see totals" 481 } 482 483 applyDatepickerMin(input, minValue) { 484 if (!input || !minValue) return 485 const normalized = this.normalizeDateString(minValue) 486 if (!normalized) return 487 input.setAttribute("min", normalized) 488 input.setAttribute("data-min-date", normalized) 489 if (input.hasAttribute("datepicker")) { 490 input.setAttribute("datepicker-min-date", normalized) 491 const picker = this.findFlowbiteDatepicker(input) 492 if (picker && typeof picker.setOptions === "function") { 493 const minDateObj = this.castDatepickerValue(normalized) 494 picker.setOptions({ minDate: minDateObj || normalized }) 495 if (picker.config && minDateObj) { 496 picker.config.minDate = minDateObj 497 } 498 } 499 } 500 } 501 502 resolveServiceType(root = null) { 503 const scope = root || this.element.closest("form") 504 if (!scope) return "one_way" 505 const input = scope.querySelector("input[name='booking[service_type]']:checked") 506 || scope.querySelector("input[name='booking[service_type]'][type='hidden']") 507 || scope.querySelector("[name='booking[service_type]']") 508 return input && input.value ? input.value : "one_way" 509 } 510 511 segmentDirection(container, index) { 512 const dirInput = container ? container.querySelector("input[name*='[direction]']") : null 513 let datasetDirection = "" 514 if (container && container.dataset && container.dataset.direction) { 515 datasetDirection = container.dataset.direction 516 } 517 const directionValue = (dirInput && dirInput.value ? dirInput.value : datasetDirection || "").toLowerCase() 518 if (directionValue) return directionValue 519 return index === 0 ? "outbound" : "inbound" 520 } 521 522 findFlowbiteDatepicker(input) { 523 if (!input) return null 524 if (input.datepicker) return input.datepicker 525 const id = input.getAttribute("id") 526 if (!id) return null 527 const instances = window.FlowbiteInstances 528 if (instances && typeof instances.getInstance === "function") { 529 const wrapper = instances.getInstance("Datepicker", id) 530 if (wrapper && typeof wrapper.getDatepickerInstance === "function") { 531 return wrapper.getDatepickerInstance() 532 } 533 } 534 return null 535 } 536 537 normalizeDateString(value) { 538 if (!value) return null 539 if (value instanceof Date) { 540 const yyyy = value.getFullYear() 541 const mm = String(value.getMonth() + 1).padStart(2, "0") 542 const dd = String(value.getDate()).padStart(2, "0") 543 return `${yyyy}-${mm}-${dd}` 544 } 545 if (typeof value === "string") { 546 const trimmed = value.trim() 547 if (!trimmed) return null 548 if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed 549 const parsed = new Date(trimmed) 550 if (!Number.isNaN(parsed.getTime())) { 551 return this.normalizeDateString(parsed) 552 } 553 } 554 return null 555 } 556 557 castDatepickerValue(value) { 558 if (!value) return null 559 if (value instanceof Date) return value 560 const normalized = this.normalizeDateString(value) 561 if (!normalized) return null 562 const [yyyy, mm, dd] = normalized.split("-").map((part) => parseInt(part, 10)) 563 if (!yyyy || !mm || !dd) return null 564 const date = new Date(yyyy, mm - 1, dd) 565 return Number.isNaN(date.getTime()) ? null : date 566 } 567 568 handleOpenTimePicker(event) { 569 const trigger = event.currentTarget 570 if (!trigger) return 571 const inputId = trigger.dataset.inputId 572 if (!inputId) return 573 574 const field = document.getElementById(inputId) 575 if (!field) return 576 577 if (typeof field.showPicker === "function") { 578 field.showPicker() 579 return 580 } 581 582 field.focus() 583 try { 584 field.click() 585 } catch (e) { 586 // no-op: some browsers do not allow programmatic clicks here. 587 } 588 } 589 590 logFullFormData(prefix = 'Full Form Data') { 591 const form = this.element.closest('form') 592 if (!form) { 593 return 594 } 595 const formData = new FormData(form) 596 const data = {} 597 for (let [key, value] of formData.entries()) { 598 if (key.startsWith('booking[')) { 599 data[key] = value 600 } 601 } 602 } 603 604 paymentMethodChanged(event) { 605 const selectedValue = event.target.value 606 607 // Toggle Stripe wrapper visibility 608 this.toggleStripeWrapper(selectedValue === 'stripe') 609 610 this.debouncedRefresh() 611 } 612 613 toggleStripeWrapper(shouldShow) { 614 const wrapper = document.getElementById('stripe-card-wrapper') 615 if (!wrapper) return 616 617 const currentlyHidden = wrapper.classList.contains('hidden') 618 if (shouldShow && currentlyHidden) { 619 wrapper.classList.remove('hidden') 620 // Attempt to mount Stripe elements if controller exists 621 const stripeController = this.findStripeCardController() 622 if (stripeController && !stripeController.mounted) {
623 stripeController.ensureStripeLoaded() 624 } 625 } else if (!shouldShow && !currentlyHidden) { 626 wrapper.classList.add('hidden') 627 } 628 } 629 630 findStripeCardController() { 631 const wrapper = document.getElementById('stripe-card-wrapper') 632 if (!wrapper) return null 633 return this.application.getControllerForElementAndIdentifier(wrapper, 'stripe-card') 634 } 635 636 toggleReturnDateSection() { 637 const form = this.element.closest("form") 638 if (!form) return 639 640 const currentStepInput = form.querySelector("input[name='booking[current_step]']") 641 const currentStep = currentStepInput?.value || "" 642 const isStepTwo = currentStep === "2" 643 644 // Find the service type value 645 const serviceTypeInput = form.querySelector("input[name='booking[service_type]']:checked") 646 || form.querySelector("input[name='booking[service_type]'][type='hidden']") 647 const isRoundTrip = serviceTypeInput && (serviceTypeInput.value === "round_trip" || serviceTypeInput.dataset.serviceType === "round_trip") 648 649 // Find the return date section 650 const returnDateSection = document.getElementById("return-date-section") 651 if (returnDateSection) { 652 returnDateSection.style.display = isRoundTrip ? "block" : "none" 653 } 654 655 // Disable all return inputs until Step 2 is visible (clarifies auto-submit behavior) 656 const stepTwoSection = document.getElementById("step-2") 657 const stepTwoVisible = stepTwoSection && !stepTwoSection.classList.contains("hidden") 658 const returnDateInputs = form.querySelectorAll("#return-date-section input, #return-date-section select") 659 returnDateInputs.forEach((input) => { 660 if (isRoundTrip && isStepTwo) { 661 input.removeAttribute("disabled") 662 } else { 663 input.setAttribute("disabled", "disabled") 664 } 665 }) 666 667 // Update the required attribute only while Step 2 is active and round-trip 668 const returnDateInput = form.querySelector("input[name='booking[segments][1][date]']") 669 if (returnDateInput) { 670 if (isRoundTrip && isStepTwo) { 671 returnDateInput.setAttribute("required", "required") 672 } else { 673 returnDateInput.removeAttribute("required") 674 } 675 } 676 } 677}
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.