1/** 2 * Guest Picker Module â Frontend JS 3 * 4 * Replaces the booking widget's <select id="guests"> dropdown with 5 * a modal containing Adults / Children / Pets stepper controls. 6 * Injects adults, children, pets params into quote AJAX requests. 7 * 8 * Each picker is also the booking widget's party provider: on the core 9 * booking API (jQuery(widget).data("homelocalBookingApi"), announced by 10 * "homelocal:booking-widget:ready") it registers { get, validate, set }, so a 11 * stay set through that API carries the adults, children and pets the picker 12 * shows. window.hrtkGuestPicker = { ready, get, set } is the same state for 13 * other Toolkit scripts. 14 */ 15(function ($) { 16 "use strict"; 17 18 // State map: property_id -> { adults, children, pets } 19 var stateMap = {}; 20 21 // i18n strings (passed from PHP via wp_localize_script). 22 var i18n = window.hrtkGPi18n || {}; 23 24 // Party providers: "w:<widget id>" and "p:<property id>" -> { get, validate, set }. 25 var pickers = {}; 26 27 // Resolves once the pickers on this page are initialised, or once the 28 // bootstrap below gave up looking for a widget. Null without Promise. 29 var readyDone = false; 30 var resolveReady = null; 31 var readyPromise = 32 typeof window.Promise === "function" 33 ? new window.Promise(function (resolve) { 34 resolveReady = resolve; 35 }) 36 : null; 37 38 function markReady() { 39 if (readyDone) return; 40 readyDone = true; 41 42 var count = 0; 43 for (var key in pickers) { 44 if (pickers.hasOwnProperty(key) && key.indexOf("w:") === 0) count++; 45 } 46 47 if (resolveReady) resolveReady({ widgets: count }); 48 49 // For a script that loaded before this one and could not read `ready`. 50 if (typeof window.CustomEvent === "function") { 51 document.dispatchEvent( 52 new CustomEvent("hrtk:guest-picker:ready", { detail: { widgets: count } }) 53 ); 54 } 55 } 56 57 function wholeNumber(value) { 58 if (typeof value === "string" && /^\d+$/.test(value)) value = Number(value); 59 return typeof value === "number" && isFinite(value) && Math.floor(value) === value && value >= 0 60 ? value 61 : null; 62 } 63 64 /** 65 * Checks a party against one widget's limits, the same limits the steppers 66 * enforce. Returns { ok, reason, reason_text, party }: `party` is the party 67 * as whole numbers when ok, `reason` one of party_unsupported, 68 * party_over_capacity, pets_not_allowed when not. 69 */ 70 function validateParty(party, limits) { 71 party = party || {}; 72 73 var adults = wholeNumber(party.adults); 74 var children = wholeNumber(party.children === undefined || party.children === null ? 0 : party.children); 75 var pets = wholeNumber(party.pets === undefined || party.pets === null ? 0 : party.pets); 76 77 function refuse(reason, text) { 78 return { ok: false, reason: reason, reason_text: text, party: null }; 79 } 80 81 if (adults === null || adults < 1 || children === null || pets === null) { 82 return refuse( 83 "party_unsupported", 84 i18n.partyInvalid || "Guests are whole numbers, with at least one adult." 85 ); 86 } 87 88 if (limits.maxAdults > 0 && adults > limits.maxAdults) { 89 return refuse( 90 "party_over_capacity", 91 limits.maxAdults === 1 92 ? (i18n.maxAdultsSingular || "This property allows a maximum of 1 adult") 93 : (i18n.maxAdultsPlural || "This property allows a maximum of %d adults").replace("%d", limits.maxAdults) 94 ); 95 } 96 97 if (adults + children > limits.maxGuests) { 98 return refuse( 99 "party_over_capacity", 100 (i18n.maxGuestsPlural || "This property allows a maximum of %d guests").replace("%d", limits.maxGuests) 101 ); 102 } 103 104 if (pets > 0 && !limits.petsAllowed) { 105 return refuse("pets_not_allowed", i18n.noPets || "This property does not allow pets"); 106 } 107 108 if (limits.maxPets > 0 && pets > limits.maxPets) { 109 return refuse( 110 "party_over_capacity", 111 limits.maxPets === 1 112 ? (i18n.maxPetsSingular || "This property allows a maximum of 1 pet") 113 : (i18n.maxPetsPlural || "This property allows a maximum of %d pets").replace("%d", limits.maxPets) 114 ); 115 } 116 117 return { 118 ok: true, 119 reason: null, 120 reason_text: null, 121 party: { adults: adults, children: children, pets: pets }, 122 }; 123 } 124 125 function findPicker(key) { 126 return pickers["w:" + key] || pickers["p:" + key] || null; 127 } 128 129 window.hrtkGuestPicker = { 130 ready: readyPromise, 131 132 // The party a picker shows, by widget id or property id; null without one. 133 get: function (widgetIdOrPropertyId) { 134 var picker = findPicker(widgetIdOrPropertyId); 135 return picker ? picker.get() : null; 136 }, 137 138 // Puts a party into a picker without starting a quote. Returns the verdict. 139 set: function (widgetIdOrPropertyId, party) { 140 var picker = findPicker(widgetIdOrPropertyId); 141 if (!picker) { 142 return { ok: false, reason: "party_unsupported", reason_text: null, party: null }; 143 } 144 return picker.set(party); 145 }, 146 }; 147 148 // ââ Utility ââââââââââââââââââââââââââââââââââââââââââââââââââââ 149 150 function formatSummary(state) { 151 var parts = []; 152 parts.push(state.adults + " " + (state.adults === 1 ? (i18n.adult || "Adult") : (i18n.adultsPlural || "Adults"))); 153 if (state.children > 0) { 154 parts.push(state.children + " " + (state.children === 1 ? (i18n.child || "Child") : (i18n.childrenPlural || "Children"))); 155 } 156 if (state.pets > 0) { 157 parts.push(state.pets + " " + (state.pets === 1 ? (i18n.pet || "Pet") : (i18n.petsPlural || "Pets"))); 158 } 159 return parts.join(", "); 160 } 161 162 function getUrlParam(name) { 163 var params = new URLSearchParams(window.location.search); 164 return params.get(name); 165 } 166 167 // ââ Modal HTML builder âââââââââââââââââââââââââââââââââââââââââ 168 169 function buildModal(id, petsAllowed, maxPets) { 170 var petsClass = petsAllowed ? "" : " hrtk-gp-pets-disabled"; 171 var petsNotice = petsAllowed 172 ? "" 173 : '<div class="hrtk-gp-pets-notice">' + (i18n.noPets || "This property does not allow pets") + '</div>'; 174 175 return ( 176 '<div id="hrtk-gp-modal-' + id + '" class="hrtk-gp-modal hk-modal" hk-modal>' + 177 '<div class="hk-modal-dialog hk-margin-auto-vertical hk-flex hk-flex-column">' + 178 '<button class="hk-modal-close-default" type="button" hk-close></button>' + 179 '<div class="hk-modal-header">' + 180 '<span class="hk-h4">' + 181 '<span class="hk-margin-small-right hk-icon" hk-icon="icon: user"></span>' +
182 (i18n.guests || "Guests") + 183 "</span>" + 184 "</div>" + 185 '<div class="hk-modal-body">' + 186 // Adults 187 '<div class="hrtk-gp-row-item">' + 188 '<div class="hrtk-gp-row-label">' + 189 '<span class="hrtk-gp-label">' + (i18n.adults || "Adults") + '</span>' + 190 "</div>" + 191 '<div class="hrtk-gp-stepper" data-field="adults">' + 192 '<button type="button" class="hrtk-gp-stepper-btn hrtk-gp-minus disabled" data-field="adults">−</button>' + 193 '<span class="hrtk-gp-stepper-value" data-field="adults">1</span>' + 194 '<button type="button" class="hrtk-gp-stepper-btn hrtk-gp-plus" data-field="adults">+</button>' + 195 "</div>" + 196 "</div>" + 197 // Children 198 '<div class="hrtk-gp-row-item">' + 199 '<div class="hrtk-gp-row-label">' + 200 '<span class="hrtk-gp-label">' + (i18n.children || "Children") + '</span>' + 201 "</div>" + 202 '<div class="hrtk-gp-stepper" data-field="children">' + 203 '<button type="button" class="hrtk-gp-stepper-btn hrtk-gp-minus disabled" data-field="children">−</button>' + 204 '<span class="hrtk-gp-stepper-value" data-field="children">0</span>' + 205 '<button type="button" class="hrtk-gp-stepper-btn hrtk-gp-plus" data-field="children">+</button>' + 206 "</div>" + 207 "</div>" + 208 // Pets 209 '<div class="hrtk-gp-row-item hrtk-gp-pets-row' + petsClass + '">' + 210 '<div class="hrtk-gp-row-label">' + 211 '<span class="hrtk-gp-label">' + (i18n.pets || "Pets") + '</span>' + 212 petsNotice + 213 "</div>" + 214 '<div class="hrtk-gp-stepper" data-field="pets">' + 215 '<button type="button" class="hrtk-gp-stepper-btn hrtk-gp-minus disabled" data-field="pets">−</button>' + 216 '<span class="hrtk-gp-stepper-value" data-field="pets">0</span>' + 217 '<div class="hrtk-gp-plus-wrap">' + 218 '<button type="button" class="hrtk-gp-stepper-btn hrtk-gp-plus" data-field="pets">+</button>' + 219 '<div class="hrtk-gp-tooltip" style="display:none"></div>' + 220 '</div>' + 221 "</div>" + 222 "</div>" + 223 '<div class="hrtk-gp-pet-notice" style="display:none"></div>' + 224 "</div>" + 225 '<div class="hk-modal-footer">' + 226 '<button type="button" class="hrtk-gp-reset hk-hidden">' + (i18n.clear || "Clear") + '</button>' + 227 '<button type="button" class="hk-button hk-button-small hk-button-primary hk-margin-auto-left hrtk-gp-apply">' + (i18n.apply || "Apply") + '</button>' + 228 "</div>" + 229 "</div>" + 230 "</div>" 231 ); 232 } 233 234 // ââ Per-widget initializer âââââââââââââââââââââââââââââââââââââ 235 236 function initGuestPicker(shortcode) { 237 var $widget = $(shortcode.selector); 238 if (!$widget.length) return; 239 240 var $dataEl = $widget.find(".hrtk-gp-data"); 241 if (!$dataEl.length) return; 242 243 var petsAllowed = $dataEl.data("pets-allowed") === 1 || $dataEl.data("pets-allowed") === "1"; 244 var maxGuests = parseInt($dataEl.data("max-guests"), 10) || 20; 245 // PMS adult cap (OwnerRez max_adults via Central additional_info); 0 = none. 246 var maxAdults = parseInt($dataEl.data("max-adults"), 10) || 0; 247 var maxPets = parseInt($dataEl.data("max-pets"), 10) || 0; // 0 = no known limit 248 var petNoticeEnabled = $dataEl.data("pet-notice-enabled") === 1 || $dataEl.data("pet-notice-enabled") === "1"; 249 var petNoticeText = $dataEl.data("pet-notice-text") || ""; 250 var propertyId = shortcode.config.property_id; 251 var widgetId = 252 $widget.attr("id") || "bw-" + Math.random().toString(36).substr(2, 6); 253 254 // Initialize state. 255 var state = { 256 adults: 1, 257 children: 0, 258 pets: 0, 259 }; 260 261 // Pre-populate from URL params if present. 262 var urlAdults = getUrlParam("adults"); 263 var urlChildren = getUrlParam("children"); 264 var urlPets = getUrlParam("pets"); 265 266 if (urlAdults) state.adults = Math.max(1, parseInt(urlAdults, 10) || 1); 267 if (urlChildren) state.children = Math.max(0, parseInt(urlChildren, 10) || 0); 268 if (urlPets && petsAllowed) { 269 if (urlPets === "yes") { 270 state.pets = 1; 271 } else if (urlPets !== "no") { 272 state.pets = Math.max(0, parseInt(urlPets, 10) || 0); 273 } 274 } 275 276 // Enforce max adults constraint before the total, so a URL with too many 277 // adults is trimmed to what the PMS will quote. 278 if (maxAdults > 0 && state.adults > maxAdults) { 279 state.adults = maxAdults; 280 } 281 282 // Enforce max guests constraint. 283 if (state.adults + state.children > maxGuests) { 284 state.children = Math.max(0, maxGuests - state.adults); 285 } 286 287 // Enforce max pets constraint. 288 if (maxPets > 0 && state.pets > maxPets) { 289 state.pets = maxPets; 290 } 291 292 // The applied party: what the summary, the URL and every quote request
293 // carry. `state` above is the modal's draft: the steppers change it, and 294 // Apply copies it here. The two differ only while the guest has stepper 295 // changes they have not applied. 296 var committed = { adults: state.adults, children: state.children, pets: state.pets }; 297 298 // Has the guest changed the draft? Only then does closing the modal 299 // apply it. An untouched draft never reprices: the modal also closes 300 // when another one opens (the choice of checkout), and that must not 301 // replace the quote the guest is about to check out. 302 function draftChanged() { 303 return ( 304 state.adults !== committed.adults || 305 state.children !== committed.children || 306 state.pets !== committed.pets 307 ); 308 } 309 310 // Store in global state map. 311 stateMap[propertyId] = committed; 312 313 // ââ Hide original dropdown âââââââââââââââââââââââââââââââââ 314 var $guestsRow = $widget.find(".guests-row"); 315 $guestsRow.hide(); 316 317 // ââ Inject trigger button ââââââââââââââââââââââââââââââââââ 318 var $triggerRow = $( 319 '<div class="hk-width-1-1 hrtk-gp-row">' + 320 '<label class="hk-form-label">' + (i18n.guests || "Guests") + '</label>' + 321 '<div class="hk-margin-small hk-form-controls">' + 322 '<button type="button" class="hrtk-gp-trigger">' + 323 '<span class="hrtk-gp-summary">' + 324 formatSummary(state) + 325 "</span>" + 326 "</button>" + 327 "</div>" + 328 "</div>" 329 ); 330 $guestsRow.after($triggerRow); 331 332 // ââ Inject modal âââââââââââââââââââââââââââââââââââââââââââ 333 var modalId = "hrtk-gp-modal-" + widgetId; 334 var $modal = $(buildModal(widgetId, petsAllowed, maxPets)); 335 336 // Append to the closest modal scope or body. 337 var $scope = $widget.closest(".hk-scope-modal"); 338 if ($scope.length) { 339 $scope.append($modal); 340 } else { 341 $("body").append($modal); 342 } 343 344 var $summary = $triggerRow.find(".hrtk-gp-summary"); 345 var $tooltip = $modal.find(".hrtk-gp-tooltip"); 346 var tooltipTimer = null; 347 348 // ââ UI references inside modal âââââââââââââââââââââââââââââ 349 function getValueEl(field) { 350 return $modal.find('.hrtk-gp-stepper-value[data-field="' + field + '"]'); 351 } 352 function getMinusBtn(field) { 353 return $modal.find('.hrtk-gp-minus[data-field="' + field + '"]'); 354 } 355 function getPlusBtn(field) { 356 return $modal.find('.hrtk-gp-plus[data-field="' + field + '"]'); 357 } 358 359 // ââ Render state into modal ââââââââââââââââââââââââââââââââ
360 function renderState() { 361 var totalGuests = state.adults + state.children; 362 363 getValueEl("adults").text(state.adults); 364 getValueEl("children").text(state.children); 365 getValueEl("pets").text(state.pets); 366 367 // Adults minus: disabled at 1. 368 getMinusBtn("adults").toggleClass("disabled", state.adults <= 1); 369 // Adults plus: disabled at the total cap or the PMS adult cap. 370 var adultsAtMax = maxAdults > 0 && state.adults >= maxAdults; 371 getPlusBtn("adults").toggleClass("disabled", totalGuests >= maxGuests || adultsAtMax); 372 373 // Children minus: disabled at 0. 374 getMinusBtn("children").toggleClass("disabled", state.children <= 0); 375 // Children plus: disabled at max. 376 getPlusBtn("children").toggleClass("disabled", totalGuests >= maxGuests); 377 378 // Pets: handle both not-allowed and max-pets cases. 379 if (petsAllowed) { 380 getMinusBtn("pets").toggleClass("disabled", state.pets <= 0); 381 var petsAtMax = maxPets > 0 && state.pets >= maxPets; 382 getPlusBtn("pets").toggleClass("disabled", petsAtMax); 383 } 384 385 // Pet notice visibility. 386 if (petNoticeEnabled && petNoticeText) { 387 var $petNotice = $modal.find(".hrtk-gp-pet-notice"); 388 if (state.pets > 0) { 389 $petNotice.text(petNoticeText).show(); 390 } else { 391 $petNotice.hide(); 392 } 393 } 394 395 // Clear button visibility. 396 var isDefault = state.adults === 1 && state.children === 0 && state.pets === 0; 397 $modal.find(".hrtk-gp-reset").toggleClass("hk-hidden", isDefault); 398 } 399 400 // ââ Show tooltip on disabled pets + button âââââââââââââââââ 401 function showPetsTooltip() { 402 if (!maxPets || maxPets <= 0) return; 403 404 var msg = maxPets === 1 405 ? (i18n.maxPetsSingular || "This property allows a maximum of 1 pet") 406 : (i18n.maxPetsPlural || "This property allows a maximum of %d pets").replace("%d", maxPets); 407 408 $tooltip.text(msg).fadeIn(150); 409 410 clearTimeout(tooltipTimer); 411 tooltipTimer = setTimeout(function () { 412 $tooltip.fadeOut(150); 413 }, 3000); 414 } 415 416 // ââ Show tooltip on capped adults + button âââââââââââââââââ 417 function showAdultsTooltip() { 418 if (!maxAdults || maxAdults <= 0) return; 419 420 var msg = maxAdults === 1 421 ? (i18n.maxAdultsSingular || "This property allows a maximum of 1 adult") 422 : (i18n.maxAdultsPlural || "This property allows a maximum of %d adults").replace("%d", maxAdults); 423 424 $tooltip.text(msg).fadeIn(150); 425 426 clearTimeout(tooltipTimer); 427 tooltipTimer = setTimeout(function () { 428 $tooltip.fadeOut(150); 429 }, 3000); 430 } 431 432 // ââ Stepper click handlers âââââââââââââââââââââââââââââââââ 433 $modal.on("click", ".hrtk-gp-minus:not(.disabled)", function () { 434 var field = $(this).data("field"); 435 var minVal = field === "adults" ? 1 : 0; 436 437 if (state[field] > minVal) { 438 state[field]--; 439 renderState(); 440 } 441 }); 442 443 $modal.on("click", ".hrtk-gp-plus", function () { 444 var field = $(this).data("field"); 445 var $btn = $(this); 446 var totalGuests = state.adults + state.children; 447 448 if (field === "pets") { 449 if (!petsAllowed) return; 450 451 // Check max pets limit. 452 if (maxPets > 0 && state.pets >= maxPets) { 453 showPetsTooltip(); 454 return; 455 } 456 457 state.pets++; 458 renderState(); 459 return; 460 } 461 462 // Adults also stop at the PMS adult cap; explain why on click. 463 if (field === "adults" && maxAdults > 0 && state.adults >= maxAdults) { 464 showAdultsTooltip(); 465 return; 466 } 467 468 // Adults and children share the max_guests cap. 469 if ($btn.hasClass("disabled")) return; 470 if (totalGuests < maxGuests) { 471 state[field]++; 472 renderState(); 473 } 474 }); 475 476 // ââ Apply logic (shared by button and modal close) âââââââââ 477 // 478 // `announce` fires the "change" that makes the booking widget price the 479 // stay. The party provider commits without it: the booking API that
480 // called it runs the one quote itself. 481 function commitState(announce) { 482 // Everything below shows the applied party, never the modal's draft. 483 484 // Update summary text. 485 $summary.text(formatSummary(committed)); 486 487 // Persist to sessionStorage for checkout page. 488 try { 489 sessionStorage.setItem("hrtk_guest_state", JSON.stringify(committed)); 490 } catch (e) {} 491 492 // Sync the hidden #guests select for backward compat. 493 var totalGuests = committed.adults + committed.children; 494 var $guestsSelect = $widget.find("#guests"); 495 if ($guestsSelect.length) { 496 if ($guestsSelect.find('option[value="' + totalGuests + '"]').length) { 497 $guestsSelect.val(totalGuests); 498 } else { 499 var lastVal = $guestsSelect.find("option:last").val(); 500 $guestsSelect.val(lastVal); 501 } 502 } 503 504 // Update state map. 505 stateMap[propertyId] = committed; 506 507 // Trigger the change event to fire updateBookingCost. 508 if (announce) { 509 $guestsSelect.trigger("change"); 510 } 511 512 // Update browser URL with guest details (replaceState, no history entry). 513 var url = new URL(window.location.href); 514 if (committed.adults > 1) { 515 url.searchParams.set("adults", committed.adults); 516 } else { 517 url.searchParams.delete("adults"); 518 } 519 if (committed.children > 0) { 520 url.searchParams.set("children", committed.children); 521 } else { 522 url.searchParams.delete("children"); 523 } 524 if (committed.pets > 0) { 525 url.searchParams.set("pets", committed.pets); 526 } else { 527 url.searchParams.delete("pets"); 528 } 529 window.history.replaceState({}, "", url); 530 } 531 532 // The guest applies the draft. 533 function applyState() { 534 committed.adults = state.adults; 535 committed.children = state.children; 536 committed.pets = state.pets; 537 commitState(true); 538 } 539 540 // ââ Apply button âââââââââââââââââââââââââââââââââââââââââââ 541 $modal.on("click", ".hrtk-gp-apply", function () { 542 applyState(); 543 closeModal(); 544 }); 545 546 // ââ Modal close (click outside / X button) = auto-apply ââââ 547 // Only what the guest changed. After Apply, or with an untouched 548 // picker, there is nothing to apply and nothing is priced again. 549 $modal.on("hidden", function () { 550 if (draftChanged()) { 551 applyState(); 552 } 553 }); 554 555 // ââ Clear button âââââââââââââââââââââââââââââââââââââââââââ 556 $modal.on("click", ".hrtk-gp-reset", function () { 557 state.adults = 1; 558 state.children = 0; 559 state.pets = 0; 560 renderState(); 561 }); 562 563 // ââ Open modal âââââââââââââââââââââââââââââââââââââââââââââ 564 $triggerRow.on("click", ".hrtk-gp-trigger", function (e) { 565 e.preventDefault(); 566 renderState(); 567 568 var uikit = window.hkUIkit || window.UIkit; 569 if (uikit && uikit.modal) { 570 uikit.modal("#" + modalId).show(); 571 } 572 }); 573 574 function closeModal() { 575 var uikit = window.hkUIkit || window.UIkit; 576 if (uikit && uikit.modal) { 577 uikit.modal("#" + modalId).hide(); 578 } 579 } 580 581 // ââ Sync initial #guests value âââââââââââââââââââââââââââââ 582 var $guestsSelect = $widget.find("#guests"); 583 if ($guestsSelect.length) { 584 var initialTotal = state.adults + state.children; 585 if ($guestsSelect.find('option[value="' + initialTotal + '"]').length) { 586 $guestsSelect.val(initialTotal); 587 } else { 588 // If exact total exceeds max option, clamp to highest available. 589 var lastVal = $guestsSelect.find("option:last").val(); 590 $guestsSelect.val(lastVal); 591 } 592 593 // When URL params drove the initial state, trigger change so any 594 // auto-quote on page load uses the correct guest breakdown. 595 if (urlAdults || urlChildren || urlPets) { 596 $guestsSelect.trigger("change"); 597 } 598 } 599 600 // Persist initial URL-driven state to sessionStorage for checkout. 601 if (urlAdults || urlChildren || urlPets) {
602 try { 603 sessionStorage.setItem("hrtk_guest_state", JSON.stringify(state)); 604 } catch (e) {} 605 } 606 607 renderState(); 608 609 // ââ Party provider for the core booking API ââââââââââââââââ 610 var limits = { 611 petsAllowed: petsAllowed, 612 maxGuests: maxGuests, 613 maxAdults: maxAdults, 614 maxPets: maxPets, 615 }; 616 617 // The provider reads and writes the applied party. A draft the guest has 618 // not applied is never reported as the party. A party set from here is 619 // the party in force: the steppers take it, open modal or not, so the 620 // picker shows what is priced and closing it applies nothing. 621 var provider = { 622 get: function () { 623 return { adults: committed.adults, children: committed.children, pets: committed.pets }; 624 }, 625 626 validate: function (party) { 627 return validateParty(party, limits); 628 }, 629 630 // State, steppers, summary, the hidden guests value and the URL, with 631 // no "change": a change here would start a second quote and supersede 632 // the operation that is setting the stay. 633 set: function (party) { 634 var verdict = validateParty(party, limits); 635 if (!verdict.ok) return verdict; 636 637 committed.adults = verdict.party.adults; 638 committed.children = verdict.party.children; 639 committed.pets = verdict.party.pets; 640 641 // The draft follows, so it has no change of the guest's left in it. 642 // What the guest steps after this is theirs again. 643 state.adults = committed.adults; 644 state.children = committed.children; 645 state.pets = committed.pets; 646 renderState(); 647 648 commitState(false); 649 650 return verdict; 651 }, 652 }; 653 654 pickers["w:" + widgetId] = provider; 655 pickers["p:" + propertyId] = provider; 656 657 function registerProvider() { 658 var api = $widget.data("homelocalBookingApi"); 659 if (!api || typeof api.registerPartyProvider !== "function") return false; 660 return api.registerPartyProvider(provider) !== false; 661 } 662 663 // The widget may mount before or after this picker: register now when 664 // its API is there, and again whenever it announces itself. 665 registerProvider(); 666 document.addEventListener("homelocal:booking-widget:ready", function (event) { 667 var detail = (event && event.detail) || {}; 668 var mine = detail.widget_id 669 ? detail.widget_id === $widget.attr("id") 670 : String(detail.property_id) === String(propertyId); 671 if (mine) registerProvider(); 672 }); 673 } 674 675 // ââ AJAX prefilter: inject adults/children/pets âââââââââââââââ 676 677 $.ajaxPrefilter(function (options) { 678 if (!options.url) return; 679 if (options.type !== "POST" && options.type !== "post") return; 680 681 // Only intercept the quote creation endpoint, not sub-routes. 682 if (options.url.indexOf("/homelocal/v1/quotes") === -1) return; 683 684 // Exclude sub-routes like /quotes/{id}/addons, /quotes/{id}/reservation. 685 var quotesIdx = options.url.indexOf("/quotes"); 686 var afterQuotes = options.url.substring(quotesIdx + "/quotes".length); 687 if (afterQuotes && afterQuotes !== "" && afterQuotes.charAt(0) !== "?") { 688 return; 689 } 690 691 // Find the property_id in the data to look up the right state. 692 var propertyId = null; 693 694 if (typeof options.data === "string") { 695 var match = options.data.match(/property_id=([^&]+)/); 696 if (match) propertyId = match[1]; 697 } else if (options.data && typeof options.data === "object") { 698 propertyId = options.data.property_id; 699 } 700 701 // Use property-specific state if available, otherwise fall back to URL params 702 // (covers explorer quote requests where no per-property widget exists). 703 var guestState = stateMap[propertyId] || null; 704 705 if (!guestState) { 706 var urlAdults = getUrlParam("adults"); 707 var urlChildren = getUrlParam("children"); 708 var urlPets = getUrlParam("pets"); 709 710 if (!urlAdults && !urlPets) return; 711 712 guestState = { 713 adults: Math.max(1, parseInt(urlAdults, 10) || 1), 714 children: Math.max(0, parseInt(urlChildren, 10) || 0), 715 pets: 0, 716 }; 717 718 if (urlPets === "yes") { 719 guestState.pets = 1; 720 } else if (urlPets && urlPets !== "no") { 721 guestState.pets = Math.max(0, parseInt(urlPets, 10) || 0); 722 } 723 } 724 725 if (typeof options.data === "string") { 726 options.data += 727 "&adults=" + guestState.adults + 728 "&children=" + guestState.children + 729 "&pets=" + guestState.pets; 730 } else if (options.data && typeof options.data === "object") { 731 options.data.adults = guestState.adults; 732 options.data.children = guestState.children; 733 options.data.pets = guestState.pets; 734 } 735 }); 736 737 // ââ Persist guest state to sessionStorage for checkout page âââââ 738 // 739 // The booking widget redirects to checkout via two paths (Central 740 // checkout-url AJAX or direct URL build) â neither carries guest 741 // params. We store the state before navigation and read it on the 742 // checkout page. 743 744 function persistGuestState() { 745 var guestState = null; 746 for (var pid in stateMap) { 747 if (stateMap.hasOwnProperty(pid)) { 748 guestState = stateMap[pid]; 749 break; 750 } 751 } 752 753 if (!guestState) { 754 var urlAdults = getUrlParam("adults"); 755 var urlPets = getUrlParam("pets"); 756 if (!urlAdults && !urlPets) return; 757 758 guestState = { 759 adults: Math.max(1, parseInt(urlAdults, 10) || 1), 760 children: Math.max(0, parseInt(getUrlParam("children"), 10) || 0), 761 pets: 0, 762 }; 763 if (urlPets === "yes") { 764 guestState.pets = 1; 765 } else if (urlPets && urlPets !== "no") { 766 guestState.pets = Math.max(0, parseInt(urlPets, 10) || 0); 767 } 768 } 769 770 if (guestState) {
771 try { 772 sessionStorage.setItem("hrtk_guest_state", JSON.stringify(guestState)); 773 } catch (e) {} 774 } 775 } 776 777 // Save state whenever it changes (quote requests, apply button). 778 $(document).ajaxSend(function (event, jqXHR, settings) { 779 if (settings.url && settings.url.indexOf("/checkout-url") !== -1) { 780 persistGuestState(); 781 } 782 }); 783 784 // Also persist on any checkout button click (covers direct URL path). 785 $(document).on("click", ".checkout-button", function () { 786 persistGuestState(); 787 }); 788 789 // ââ Explorer: replace pets Yes/No radio with numeric stepper ââââââ 790 791 function enhanceExplorerPets() { 792 var $explorer = $(".hfse"); 793 if (!$explorer.length) return; 794 795 // Account-wide max pets fallback from toolkit settings (0 = no limit). 796 var maxPetsAccount = parseInt(i18n.maxPetsAccountWide, 10) || 0; 797 798 function maxPetsMessage() { 799 if (maxPetsAccount === 1) { 800 return i18n.maxPetsExplorerSingular || "Maximum 1 pet per booking"; 801 } 802 return (i18n.maxPetsExplorerPlural || "Maximum %d pets per booking").replace("%d", maxPetsAccount); 803 } 804 805 // Convert the pets radio -> stepper inside ONE explorer container. Returns 806 // true when handled (converted now, or already converted on a prior pass); 807 // false when the guests form / pets radio is not yet in the DOM so the 808 // caller can retry. 809 function convertExplorerPets($modal) { 810 // Idempotent guard: already enhanced -> nothing to do. 811 if ($modal.find(".hrtk-gp-explorer-pets-wrap").length) return true; 812 813 // The guests modal can be portaled outside .hfse (UIkit moves modals to 814 // <body> on open), so resolve the owning explorer by climbing first, then 815 // falling back to the single explorer on the page. 816 var $container = $modal.closest(".hfse"); 817 if (!$container.length) $container = $(".hfse").first(); 818 819 var $petsRadioWrap = $modal.find('input[name="pets"][type="radio"]').closest(".hk-flex.hk-flex-middle"); 820 if (!$petsRadioWrap.length) return false; 821 822 // Read current radio value. 823 var currentVal = $modal.find('input[name="pets"]:checked').val(); 824 var petsVal = currentVal === "yes" ? 1 : 0; 825 826 // Also check URL for numeric pets value. 827 var urlPets = getUrlParam("pets"); 828 if (urlPets && urlPets !== "no" && urlPets !== "yes") { 829 petsVal = Math.max(0, parseInt(urlPets, 10) || 0); 830 } else if (urlPets === "yes") { 831 petsVal = 1; 832 } 833 834 // Clamp initial value against the account-wide cap. 835 if (maxPetsAccount > 0 && petsVal > maxPetsAccount) { 836 petsVal = maxPetsAccount; 837 } 838 839 // Build the replacement stepper HTML (matches plugin's hfse-number-with-control pattern). 840 var maxAttr = maxPetsAccount > 0 ? ' max="' + maxPetsAccount + '"' : ''; 841 var noticeHtml = maxPetsAccount > 0 842 ? '<div class="hrtk-gp-explorer-pets-notice hk-text-meta" style="display:none;margin-top:.25em;color:#b32d2e;"></div>' 843 : ''; 844 var $replacement = $( 845 '<div class="hk-flex hk-flex-middle hk-margin hrtk-gp-explorer-pets-wrap">' + 846 '<div class="hk-width-3-5">' + 847 '<label class="hk-form-label">' + (i18n.pets || "Pets") + '</label>' + 848 '<div class="hk-text-meta">' + (i18n.bringingPet || "Bringing a pet?") + '</div>' + 849 '</div>' + 850 '<div class="hk-width-2-5">' + 851 '<div class="hk-form-controls hfse-number-with-control">' + 852 '<div class="reduce-btn" title="-">-</div>' + 853 '<input type="text" min="0" step="1"' + maxAttr + ' name="pets" class="number-input" value="' + petsVal + '" autocomplete="off" />' + 854 '<div class="increase-btn" title="+">+</div>' + 855 '</div>' + 856 noticeHtml + 857 '</div>' + 858 '</div>' 859 ); 860 861 // Replace radio row with stepper row. 862 $petsRadioWrap.replaceWith($replacement); 863 864 // The plugin's explorer.js `numberControlsInit` already binds delegated 865 // click handlers on `.hfse-number-with-control .reduce-btn/.increase-btn` 866 // inside the guests modal â it does the +/- increment and triggers change. 867 // We deliberately don't bind our own click handlers here; doing so caused 868 // a +2 / -2 double-increment because both delegates fired per click. 869 var $petsInput = $replacement.find('input[name="pets"]'); 870 var $petsNotice = $replacement.find(".hrtk-gp-explorer-pets-notice"); 871 var noticeTimer; 872 873 function showMaxNotice() { 874 if (!$petsNotice.length) return; 875 $petsNotice.text(maxPetsMessage()).stop(true, true).fadeIn(150); 876 clearTimeout(noticeTimer); 877 noticeTimer = setTimeout(function () { 878 $petsNotice.fadeOut(150); 879 }, 3000); 880 } 881 882 // Enforce the cap when the user tries to increment past it. Direct 883 // (non-delegated) click handlers fire BEFORE the plugin's delegated 884 // `.increase-btn` handler bound on the ancestor modal, so calling 885 // stopImmediatePropagation here cancels that one click â without
886 // re-introducing the v3.22.0 double-increment bug, because we only 887 // short-circuit when already at the cap (otherwise we yield to the 888 // plugin's handler, which performs the single increment). 889 if (maxPetsAccount > 0) { 890 $replacement.find(".increase-btn").on("click", function (e) { 891 var current = parseInt($petsInput.val(), 10) || 0; 892 if (current >= maxPetsAccount) { 893 e.stopImmediatePropagation(); 894 e.preventDefault(); 895 showMaxNotice(); 896 return false; 897 } 898 // Otherwise: do nothing and let the plugin's delegated handler increment. 899 }); 900 901 // Defensive clamp on any other input mutation (URL hydration, reset, etc.). 902 $petsInput.on("change", function () { 903 var current = parseInt($petsInput.val(), 10) || 0; 904 if (current > maxPetsAccount) { 905 $petsInput.val(maxPetsAccount); 906 showMaxNotice(); 907 } 908 }); 909 } 910 911 // Patch the explorer's updateGuestsInput to read numeric pets. 912 // The explorer builds a display value like "3" or "3, Pets". 913 // We override the display to show "3, 2 Pets" instead. 914 var $guestsValueInput = $container.find(".hfse-form-guests-value"); 915 if ($guestsValueInput.length) { 916 $petsInput.on("change", function () { 917 var adults = parseInt($modal.find('input[name="adults"]').val(), 10) || 1; 918 var children = parseInt($modal.find('input[name="children"]').val(), 10) || 0; 919 var pets = parseInt($petsInput.val(), 10) || 0; 920 var total = adults + children; 921 var display = total + ""; 922 if (pets > 0) { 923 display += ", " + pets + " " + (pets === 1 ? (i18n.pet || "Pet") : (i18n.petsPlural || "Pets")); 924 } 925 $guestsValueInput.val(display); 926 }); 927 928 // Also patch adults/children change to include pets in display. 929 $modal.find('input[name="adults"], input[name="children"]').on("change", function () { 930 $petsInput.trigger("change"); 931 }); 932 933 // Set initial display. 934 if (petsVal > 0) { 935 $petsInput.trigger("change"); 936 } 937 } 938 939 // Clear pets on "Start Over". Two bugs to handle: 940 // 1. The plugin fires reset on $container (the .hfse root), not on the 941 // descendant $modal â and reset does not bubble down â so the old 942 // $modal.on("reset") handler never fired. 943 // 2. The numeric stepper value never equals default_params.pets ("no"), 944 // so the plugin's updateUrl re-adds pets=0 to the URL even after a 945 // reset. Strip pets/pet_count from the URL after the plugin's 946 // post-reset performFilter()/updateUrl() (a ~5ms timer) runs. 947 $container.on("reset", function () { 948 $petsInput.val("0").trigger("change"); 949 setTimeout(function () { 950 var u = new URL(window.location.href); 951 u.searchParams.delete("pets"); 952 u.searchParams.delete("pet_count"); 953 window.history.replaceState({}, "", u); 954 }, 80); 955 }); 956 957 return true; 958 } 959 960 // The explorer renders its guests modal asynchronously and may portal it to 961 // <body> when opened, so a single document-ready pass misses it. Convert 962 // every guests modal currently in the DOM (found GLOBALLY, not scoped to 963 // .hfse), retry briefly on an interval, AND keep a persistent debounced 964 // MutationObserver on <body> so a modal opened or rebuilt at any time still 965 // gets its pets radio converted. convertExplorerPets is idempotent, so 966 // re-running on every mutation is cheap. 967 function runAllExplorerPets() { 968 var $modals = $(".hfse-form-guests-options"); 969 if (!$modals.length) return false; // guests form not rendered yet 970 var allDone = true; 971 $modals.each(function () { 972 if (!convertExplorerPets($(this))) allDone = false; 973 }); 974 return allDone; 975 } 976 977 runAllExplorerPets(); 978 979 var petsTries = 0; 980 var PETS_MAX_TRIES = 50; // ~10s of active polling at 200ms 981 var petsInterval = setInterval(function () { 982 if (runAllExplorerPets() || ++petsTries >= PETS_MAX_TRIES) { 983 clearInterval(petsInterval); 984 petsInterval = null; 985 } 986 }, 200); 987 988 // Persistent observer: never torn down (the modal can open long after the 989 // poll window closes, and may be rebuilt on each open). Debounced; the 990 // conversion itself is guarded/idempotent. 991 if (window.MutationObserver) { 992 var petsDebounce = null; 993 var petsObserver = new MutationObserver(function () { 994 if (petsDebounce) return; 995 petsDebounce = setTimeout(function () { 996 petsDebounce = null; 997 runAllExplorerPets(); 998 }, 50); 999 }); 1000 petsObserver.observe(document.body, { childList: true, subtree: true }); 1001 } 1002 } 1003 1004 // ââ Explorer: append search params to property card links âââââââ 1005 1006 function enhanceExplorerLinks() { 1007 var $explorer = $(".hfse"); 1008 if (!$explorer.length) return; 1009 1010 function updateCardLinks() { 1011 var params = new URLSearchParams(window.location.search); 1012 var checkin = params.get("checkin"); 1013 var checkout = params.get("checkout"); 1014 var adults = params.get("adults"); 1015 var children = params.get("children"); 1016 var pets = params.get("pets"); 1017 1018 // Only proceed if there are guest/date params to append. 1019 if (!checkin && !adults && !pets) return; 1020 1021 // Default adults to 1 when dates are present (widget needs at least 1 for quote). 1022 if (checkin && !adults) adults = "1"; 1023 1024 $explorer.find(".hfse-property-card").each(function () { 1025 var $link = $(this); 1026 var baseUrl = $link.attr("href"); 1027 if (!baseUrl) return; 1028 1029 var url; 1030 try { 1031 url = new URL(baseUrl, window.location.origin); 1032 } catch (e) { 1033 return; 1034 } 1035 1036 // Clear any previous booking params we may have set. 1037 ["checkin", "checkout", "adults", "children", "pets", "guests"].forEach( 1038 function (p) { 1039 url.searchParams.delete(p); 1040 } 1041 ); 1042 1043 if (checkin) url.searchParams.set("checkin", checkin); 1044 if (checkout) url.searchParams.set("checkout", checkout); 1045 if (adults) url.searchParams.set("adults", adults); 1046 if (children && children !== "0") 1047 url.searchParams.set("children", children); 1048 if (pets && pets !== "no" && pets !== "0") url.searchParams.set("pets", pets); 1049 1050 // Always set combined guests for backward compat with booking widget PHP. 1051 // This ensures the PHP-rendered #guests dropdown matches the stepper state, 1052 // even when adults=1 (prevents base_guests override on property page). 1053 var totalGuests = 1054 (parseInt(adults, 10) || 1) + (parseInt(children, 10) || 0); 1055 url.searchParams.set("guests", totalGuests); 1056 1057 $link.attr("href", url.toString()); 1058 }); 1059 } 1060 1061 // Debounced MutationObserver on the properties container. 1062 var $results = $explorer.find(".hfse-properties-container"); 1063 if ($results.length) { 1064 var debounceTimer = null; 1065 var observer = new MutationObserver(function () { 1066 clearTimeout(debounceTimer); 1067 debounceTimer = setTimeout(updateCardLinks, 50); 1068 }); 1069 observer.observe($results[0], { childList: true, subtree: true }); 1070 } 1071 1072 // Re-run on back/forward browser navigation. 1073 $(window).on("popstate", updateCardLinks); 1074 1075 // Initial run. 1076 updateCardLinks(); 1077 } 1078 1079 // ââ Checkout summary: replace static guest count with breakdown â 1080 1081 function enhanceCheckoutSummary() { 1082 var $checkout = $(".homelocal-checkout-overview"); 1083 if (!$checkout.length) return; 1084 1085 // Read guest state from sessionStorage (set on property page before redirect). 1086 var guestState = null; 1087 try { 1088 var stored = sessionStorage.getItem("hrtk_guest_state"); 1089 if (stored) guestState = JSON.parse(stored); 1090 } catch (e) {} 1091 1092 // Fall back to URL params. 1093 if (!guestState) { 1094 var adults = parseInt(getUrlParam("adults"), 10) || 0; 1095 var children = parseInt(getUrlParam("children"), 10) || 0; 1096 var pets = getUrlParam("pets"); 1097 var petCount = 0; 1098 1099 if (pets === "yes") { 1100 petCount = 1; 1101 } else if (pets && pets !== "no") { 1102 petCount = parseInt(pets, 10) || 0; 1103 } 1104 1105 if (!adults && !petCount) return; 1106 1107 guestState = { 1108 adults: Math.max(1, adults), 1109 children: children, 1110 pets: petCount, 1111 }; 1112 } 1113 1114 var summary = formatSummary(guestState); 1115 1116 // Find the static "Guests" label in the checkout dates grid and replace its value. 1117 // Match against both the English string and the localized toolkit str
1117ing so this 1118 // works on translated sites where the plugin's homelocal domain is translated too. 1119 var guestsLabels = ["Guests", i18n.guests || "Guests"]; 1120 $checkout.find(".hk-grid .hk-width-1-2").each(function () { 1121 var $col = $(this); 1122 var labelText = $col.find("label").text().trim(); 1123 if (guestsLabels.indexOf(labelText) !== -1) { 1124 $col.find(".hk-form-controls").text(summary); 1125 } 1126 }); 1127 } 1128 1129 // ââ Bootstrap: init guest pickers after widgets are ready ââââââ 1130 1131 $(function () { 1132 // Enhance checkout summary with guest breakdown. 1133 enhanceCheckoutSummary(); 1134 1135 // Replace explorer pets radio with numeric stepper. 1136 enhanceExplorerPets(); 1137 1138 // Enhance explorer property card links with search params. 1139 enhanceExplorerLinks(); 1140 var attempts = 0; 1141 var maxAttempts = 50; 1142 1143 var checkInterval = setInterval(function () { 1144 attempts++; 1145 1146 var shortcodes = window.homerunner_booking_widget_shortcodes; 1147 if (!shortcodes || !shortcodes.length) { 1148 if (attempts >= maxAttempts) { 1149 clearInterval(checkInterval); 1150 markReady(); 1151 } 1152 return; 1153 } 1154 1155 // Check if at least one widget has been initialized (has the data element). 1156 var hasData = false; 1157 for (var i = 0; i < shortcodes.length; i++) { 1158 if ($(shortcodes[i].selector).find(".hrtk-gp-data").length) { 1159 hasData = true; 1160 break; 1161 } 1162 } 1163 1164 if (!hasData) { 1165 if (attempts >= maxAttempts) { 1166 clearInterval(checkInterval); 1167 markReady(); 1168 } 1169 return; 1170 } 1171 1172 clearInterval(checkInterval); 1173 1174 try { 1175 shortcodes.forEach(function (shortcode) { 1176 initGuestPicker(shortcode); 1177 }); 1178 } finally { 1179 markReady(); 1180 } 1181 }, 100); 1182 }); 1183})(jQuery);
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.