1/** 2 * Flexible Search frontend. 3 * 4 * Adds an "Exact dates / I'm flexible" toggle on the Explorer date modal, 5 * with stay-length chips, a month-picker strip, and an "alternative dates" 6 * chip strip beside the form. All date math runs in the property timezone 7 * supplied by `window.homelocal_explorer_settings.timezone`. 8 * 9 * Attach lifecycle: 10 * 1. DOMContentLoaded sweep over `window.homerunner_explorer_shortcodes` 11 * 2. MutationObserver on document.body for late-arriving `.hfse-form` 12 * instances (lazy-load, AJAX-paginated archives, layout builders). 13 * Each attach is keyed on the Explorer instance id so re-entry no-ops. 14 * 15 * On SearchbarPro-driven results pages the SBP bar replaces the Explorer's 16 * native form, so the full attach never runs there. The zero-results 17 * suggestion box still applies: a formless watcher keys off the properties 18 * container directly and hydrates state from the URL (#595). 19 * 20 * @package HomerunnerToolkit 21 */ 22(function ($, config) { 23 if (!config) { 24 return; 25 } 26 27 var I18N = config.i18n || {}; 28 var AVAIL = config.availability || {}; 29 var STAY_LENGTHS = Array.isArray(config.stayLengths) && config.stayLengths.length 30 ? config.stayLengths 31 : ["weekend", "1w", "2w", "1m"]; 32 var MONTHS_AHEAD = Math.min(24, Math.max(6, parseInt(config.monthsAhead, 10) || 12)); 33 var ANYTIME = "anytime"; 34 35 // Peer-feature gates (#609). Only an explicit false disables â a page 36 // cached before the config carried these keys keeps today's behavior. 37 var FEATURES = config.features || {}; 38 var TOGGLE_ON = FEATURES.explorerToggle !== false; 39 var SUGGEST_ON = FEATURES.suggestions !== false; 40 var NOTICE_ON = FEATURES.adjustedNotice !== false; 41 42 // Suggestion window (#609): weeks around the searched dates that 43 // exactAlternativeChips reaches. Defaults reproduce the original 44 // hardcoded [-14, -7, 7, 14, 21, 28] offsets. 45 var WINDOW = config.window || {}; 46 var WEEKS_BEFORE = Math.min(8, Math.max(0, parseInt(WINDOW.beforeWeeks, 10) >= 0 ? parseInt(WINDOW.beforeWeeks, 10) : 2)); 47 var WEEKS_AFTER = Math.min(12, Math.max(1, parseInt(WINDOW.afterWeeks, 10) || 4)); 48 49 // The availability endpoint answers at most 8 ranges per request 50 // (RestController::MAX_RANGES), so a larger chip set could never be 51 // fully verified or pruned; generation keeps to this cap â but only 52 // while verification actually runs (availabilityCheckActive): with 53 // the check off, chips are date math by design and the full 54 // configured window renders. 55 var MAX_VERIFIABLE_CHIPS = 8; 56 57 // Mirrors fetchAvailability's bail conditions: verification runs only 58 // when the setting/filter allows it AND the transport pieces exist. 59 function availabilityCheckActive() { 60 return !!(AVAIL.enabled && AVAIL.restUrl && window.hrtkFlexAvailability && typeof window.fetch === "function"); 61 } 62 63 var attached = {}; 64 65 // ---------- timezone-aware date helpers ---------- 66 67 function getTimezone() { 68 var settings = window.homelocal_explorer_settings || {}; 69 return settings.timezone || "UTC"; 70 } 71 72 function nowInZone(tz) { 73 if (window.luxon && window.luxon.DateTime) { 74 return window.luxon.DateTime.now().setZone(tz); 75 } 76 // Luxon should be present (Explorer enqueues it); fall back to local. 77 return null; 78 } 79 80 function dtFromYmd(y, m, d, tz) { 81 var L = window.luxon && window.luxon.DateTime; 82 if (!L) return null; 83 return L.fromObject({ year: y, month: m, day: d }, { zone: tz }); 84 } 85 86 function dtFromMonth(yyyymm, tz) { 87 var parts = yyyymm.split("-"); 88 return dtFromYmd(parseInt(parts[0], 10), parseInt(parts[1], 10), 1, tz); 89 } 90 91 function fmtYmd(dt) { 92 return dt.toFormat("yyyy-MM-dd"); 93 } 94 95 function fmtChipLabel(checkin, checkout) { 96 if (checkin.month === checkout.month) { 97 return checkin.toFormat("LLL d") + " - " + checkout.toFormat("d"); 98 } 99 return checkin.toFormat("LLL d") + " - " + checkout.toFormat("LLL d"); 100 } 101 102 function nextDowAtOrAfter(from, isoDow) { 103 var current = from.weekday; // 1=Mon..7=Sun 104 var delta = (isoDow - current + 7) % 7; 105 return from.plus({ days: delta }).startOf("day"); 106 } 107 108 function oneMonthCheckout(checkin) { 109 // Mirrors the PHP filter; default = fixed 30 nights. 110 return checkin.plus({ days: 30 }); 111 } 112 113 // ---------- URL state ---------- 114 115 function readUrlParams() { 116 var url = new URL(window.location.href); 117 return {
118 flex: url.searchParams.get("flex") || "", 119 stay_length: url.searchParams.get("stay_length") || "", 120 flex_month: url.searchParams.get("flex_month") || "", 121 checkin: url.searchParams.get("checkin") || "", 122 checkout: url.searchParams.get("checkout") || "", 123 }; 124 } 125 126 function writeUrlState(state, replace) { 127 var url = new URL(window.location.href); 128 if (state.flex === "flexible") { 129 url.searchParams.set("flex", "flexible"); 130 if (state.stay_length) url.searchParams.set("stay_length", state.stay_length); 131 if (state.flex_month) url.searchParams.set("flex_month", state.flex_month); 132 } else { 133 url.searchParams.delete("flex"); 134 url.searchParams.delete("stay_length"); 135 url.searchParams.delete("flex_month"); 136 } 137 if (state.checkin) { 138 url.searchParams.set("checkin", state.checkin); 139 } else { 140 url.searchParams.delete("checkin"); 141 } 142 if (state.checkout) { 143 url.searchParams.set("checkout", state.checkout); 144 } else { 145 url.searchParams.delete("checkout"); 146 } 147 var fn = replace ? "replaceState" : "pushState"; 148 window.history[fn]({}, "", url.toString()); 149 } 150 151 function sanitizeFlexMonth(raw, tz) { 152 if (!raw || raw === ANYTIME) return ANYTIME; 153 if (!/^\d{4}-\d{2}$/.test(raw)) return ANYTIME; 154 var dt = dtFromMonth(raw, tz); 155 if (!dt || !dt.isValid) return ANYTIME; 156 var now = nowInZone(tz); 157 var current = now ? now.startOf("month") : dt; 158 if (dt < current) return ANYTIME; 159 return raw; 160 } 161 162 function sanitizeStayLength(raw) { 163 if (STAY_LENGTHS.indexOf(raw) >= 0) return raw; 164 return STAY_LENGTHS[0]; 165 } 166 167 // ---------- representative range + chip enumeration ---------- 168 169 function representativeRange(stayLength, flexMonth, tz) { 170 var anchor = flexMonth === ANYTIME 171 ? (nowInZone(tz) || dtFromYmd(2000, 1, 1, tz)).startOf("day") 172 : dtFromMonth(flexMonth, tz); 173 if (!anchor) return null; 174 175 var checkin; 176 var checkout; 177 178 switch (stayLength) { 179 case "weekend": 180 checkin = nextDowAtOrAfter(anchor, 5); // Friday 181 checkout = checkin.plus({ days: 2 }); 182 break; 183 case "1w": 184 checkin = nextDowAtOrAfter(anchor, 6); // Saturday 185 checkout = checkin.plus({ days: 7 }); 186 break; 187 case "2w": 188 checkin = nextDowAtOrAfter(anchor, 6); 189 checkout = checkin.plus({ days: 14 }); 190 break; 191 case "1m": 192 checkin = anchor.startOf("month"); 193 checkout = oneMonthCheckout(checkin); 194 break; 195 default: 196 checkin = anchor; 197 checkout = anchor.plus({ days: 1 }); 198 } 199 return { checkin: fmtYmd(checkin), checkout: fmtYmd(checkout), checkinDt: checkin, checkoutDt: checkout }; 200 } 201 202 function alternativeChips(stayLength, flexMonth, tz) { 203 var isMonth = flexMonth !== ANYTIME; 204 if (stayLength === "weekend") { 205 return isMonth ? weeklyChipsInMonth(flexMonth, 5, 2, tz) : weeklyChipsAnytime(tz, 5, 2, 8); 206 } 207 if (stayLength === "1w") { 208 return isMonth ? weeklyChipsInMonth(flexMonth, 6, 7, tz) : weeklyChipsAnytime(tz, 6, 7, 8); 209 } 210 if (stayLength === "2w") { 211 return isMonth ? biweeklyChipsInMonth(flexMonth, 6, tz) : weeklyChipsAnytime(tz, 6, 14, 6, 2); 212 } 213 if (stayLength === "1m") { 214 return isMonth ? monthChipsAround(flexMonth, tz, 1) : monthChipsFromNow(tz, 6); 215 } 216 return []; 217 } 218 219 function weeklyChipsInMonth(flexMonth, isoDow, nights, tz) { 220 var first = dtFromMonth(flexMonth, tz); 221 if (!first) return []; 222 var last = first.endOf("month").startOf("day"); 223 var current = nextDowAtOrAfter(first, isoDow); 224 var out = []; 225 while (current <= last) { 226 var checkout = current.plus({ days: nights }); 227 out.push({ checkin: fmtYmd(current), checkout: fmtYmd(checkout), label: fmtChipLabel(current, checkout) }); 228 current = current.plus({ days: 7 }); 229 } 230 return out; 231 } 232 233 function biweeklyChipsInMonth(flexMonth, isoDow, tz) { 234 var first = dtFromMonth(flexMonth, tz); 235 if (!first) return []; 236 var last = first.endOf("month").startOf("day"); 237 var current = nextDowAtOrAfter(first, isoDow); 238 var out = []; 239 while (current <= last) { 240 var checkout = current.plus({ days: 14 }); 241 out.push({ checkin: fmtYmd(current), checkout: fmtYmd(checkout), label: fmtChipLabel(current, checkout) }); 242 current = current.plus({ days: 14 }); 243 } 244 return out; 245 } 246 247 function weeklyChipsAnytime(tz, isoDow, nights, count, strideWeeks) { 248 var stride = strideWeeks || 1; 249 var base = nowInZone(tz); 250 if (!base) return []; 251 var current = nextDowAtOrAfter(base.startOf("day"), isoDow); 252 var out = []; 253 for (var i = 0; i < count; i++) { 254 var checkout = current.plus({ days: nights }); 255 out.push({ checkin: fmtYmd(current), checkout: fmtYmd(checkout), label: fmtChipLabel(current, checkout) }); 256 current = current.plus({ days: 7 * stride }); 257 } 258 return out; 259 } 260 261 function monthChipsAround(flexMonth, tz, padding) { 262 var target = dtFromMonth(flexMonth, tz); 263 if (!target) return []; 264 var out = []; 265 for (var offset = -padding; offset <= padding; offset++) { 266 var start = target.plus({ months: offset }); 267 var checkout = oneMonthCheckout(start); 268 out.push({ checkin: fmtYmd(start), checkout: fmtYmd(checkout), label: start.toFormat("LLL yyyy") }); 269 } 270 return out; 271 } 272 273 function monthChipsFromNow(tz, count) { 274 var base = nowInZone(tz); 275 if (!base) return []; 276 var current = base.startOf("month"); 277 var out = []; 278 for (var i = 0; i < count; i++) { 279 var checkout = oneMonthCheckout(current); 280 out.push({ checkin: fmtYmd(current), checkout: fmtYmd(checkout), label: current.toFormat("LLL yyyy") }); 281 current = current.plus({ months: 1 }); 282 } 283 return out; 284 } 285 286 // ---------- DOM injection per Explorer instance ---------- 287 288 function attach(instanceId, $form) { 289 if (!instanceId || attached[instanceId]) return; 290 291 var $modal = $("#hfse-form-date-modal-" + instanceId); 292 if (!$modal.length) return; 293 294 var $dialog = $modal.find(".hk-modal-dialog").first(); 295 if (!$dialog.length) return;
295 // modal markup we don't recognize; skip 296 297 // Mark the modal so flexible-search.css can scope overrides (e.g. the 298 // dialog width cap) to fsearch-managed modals only â core's explorer.css 299 // sizes this modal for every site, marked or not. 300 $modal.addClass("hrtk-fs-date-modal"); 301 302 var $header = $dialog.find(".hk-modal-header").first(); 303 var $body = $dialog.find(".hk-modal-body").first(); 304 var $footer = $dialog.find(".hk-modal-footer").first(); 305 306 var tz = getTimezone(); 307 var state = hydrateState(tz); 308 309 var $toggle = buildToggle(); 310 var $panel = buildPanel(tz); 311 312 // Toggle pill rendered as its own row in the modal header so it sits 313 // alongside the existing "When" title â visible only when modal opens. 314 if ($header.length) { 315 $header.append($toggle); 316 } else { 317 $dialog.prepend($toggle); 318 } 319 320 // Flex panel becomes a sibling of the modal body. We toggle visibility 321 // between $body+$footer (exact) and $panel (flexible) based on state. 322 if ($body.length) { 323 $panel.insertAfter($body); 324 } else { 325 $dialog.append($panel); 326 } 327 328 // Form-side alternative chip strip â visible after submit when in flex 329 // mode. Lets users swap to a different specific range without reopening 330 // the modal. Mirrors the in-modal chip set; clicking re-submits. 331 var $formChipStrip = $('<div class="hrtk-fs-form-strip" hidden><div class="hrtk-fs-form-strip-heading"></div><div class="hrtk-fs-form-strip-chips"></div></div>'); 332 var $secondary = $form.find(".hfse-form-secondary-fields"); 333 if ($secondary.length) { 334 $secondary.after($formChipStrip); 335 } else { 336 $form.append($formChipStrip); 337 } 338 339 // Hidden inputs ride along on form submit so flex state is present in 340 // serializeArray() output (used by our own redirect builder and by 341 // Explorer's AJAX path). The form element is the same node as the form 342 // wrapper (`<form class="hfse-form hfse-filters">`), so use is(), not 343 // find(), to resolve it. Marked disabled in exact mode so they don't 344 // pollute the URL with empty params. 345 var $filters = $form.is(".hfse-filters") ? $form : $form.find(".hfse-filters").first(); 346 if (!$filters.length) $filters = $form; 347 var $hiddenFlex = $('<input type="hidden" name="flex" disabled>'); 348 var $hiddenStay = $('<input type="hidden" name="stay_length" disabled>'); 349 var $hiddenMonth = $('<input type="hidden" name="flex_month" disabled>'); 350 $filters.append($hiddenFlex, $hiddenStay, $hiddenMonth); 351 352 // Mirror Explorer's grid mode so Apply can mimic Explorer's UX: grid=0 353 // (homepage / search bar without results) waits for the user to click 354 // Search; grid=1 (results page) commits immediately. The Explorer wrapper 355 // carries `with-grid` / `without-grid` classes based on the shortcode. 356 var gridMode = $form.closest(".without-grid").length ? "0" : "1"; 357 358 var ctx = { 359 instanceId: instanceId, 360 tz: tz, 361 state: state, 362 gridMode: gridMode, 363 $form: $form, 364 $modal: $modal, 365 $dialog: $dialog, 366 $body: $body, 367 $footer: $footer, 368 $toggle: $toggle, 369 $panel: $panel, 370 $altChips: $panel.find(".hrtk-fs-alt-chips"), 371 $altHeading: $panel.find(".hrtk-fs-alt-heading"), 372 $formChipStrip: $formChipStrip, 373 $hiddenFlex: $hiddenFlex, 374 $hiddenStay: $hiddenStay, 375 $hiddenMonth: $hiddenMonth, 376 $filters: $filters, 377 }; 378 379 setupMonthStripArrows(ctx); 380 setupMonthStripDrag(ctx); 381 setupMonthStripReveal(ctx); 382 watchDateLabel(ctx); 383 bindFlexSubmit(ctx); 384 setupEmptyStateWatcher(ctx); 385 bindEvents(ctx); 386 applyState(ctx, true); 387 388 attached[instanceId] = ctx; 389 } 390 391 /** 392 * Flex search is most valuable when an exact-date search would have 393 * returned zero. Watch the Explorer results container for the 394 * `.hfse-properties-no-items` element it injects on empty results and 395 * replace its generic message with a suggestion box: in flex mode one 396 * that reads "No matches for 1 week in July" with the flex ranges as 397 * inline chips, on an exact dated search one that keeps the guest's stay 398 * length and offers the same window shifted across nearby weeks (#595). 399 * Clicking a chip pivots into that specific range. 400 */ 401 function setupEmptyStateWatcher(ctx) { 402 // Outer Explorer wrapper is `.hfse` (with `.hr-explorer`), not 403 // `.homerunner-explorer` â confirmed against the shortcode renderer. 404 var $explorer = ctx.$form.closest(".hfse"); 405 if (!$explorer.length) return; 406 var $propsContainer = $explorer.find(".hfse-properties-container"); 407 if (!$propsContainer.length) return; 408 watchEmptyState(ctx, $propsContainer); 409 } 410 411 function watchEmptyState(ctx, $propsContainer) { 412 if ($propsContainer.data("hrtkFsEmptyWatch")) return; 413 $propsContainer.data("hrtkFsEmptyWatch", 1); 414 415 var render = function () {
416 // The adjusted-dates notice (#605) shares this observer: it renders 417 // above a grid WITH results, and yields whenever the grid is empty. 418 if (NOTICE_ON) syncAdjustedNotice(ctx, $propsContainer); 419 if (!SUGGEST_ON) return; 420 var $noItems = $propsContainer.find(".hfse-properties-no-items").first(); 421 if (!$noItems.length) return; 422 if ($noItems.find(".hrtk-fs-empty").length) return; // already replaced 423 // EDMS owns the node when its min-stay correction rendered first: a 424 // same-dates "stay N nights instead" beats shifted-window suggestions. 425 // EDMS yields the same way to an existing .hrtk-fs-empty, so whichever 426 // module renders first wins deterministically per node. 427 if ($noItems.find(".hrtk-edms-empty").length) return; 428 // Formless contexts hydrate from the URL, and SBP refine mode commits 429 // flex changes in-page (replaceState + AJAX re-render, no navigation) â 430 // a load-time snapshot goes stale, so reread the URL per render. 431 if (ctx.formless) ctx.state = hydrateState(ctx.tz); 432 if (ctx.state.flex === "flexible") { 433 renderFlexEmpty(ctx, $noItems); 434 } else { 435 renderExactEmpty(ctx, $noItems); 436 } 437 }; 438 439 render(); // server-rendered case (initial page load) 440 if (typeof MutationObserver !== "function") return; 441 var observer = new MutationObserver(render); 442 observer.observe($propsContainer[0], { childList: true }); 443 } 444 445 function renderFlexEmpty(ctx, $noItems) { 446 var nl = naturalLanguageLabel(ctx.state.stay_length, ctx.state.flex_month, ctx.tz); 447 var prefix = I18N.noMatchesFor || "No matches for"; 448 var $empty = $('<div class="hrtk-fs-empty"></div>'); 449 $empty.append($('<h3 class="hrtk-fs-empty-title"></h3>').text(prefix + " " + nl + ".")); 450 451 var chips = applyServerChipFilter(ctx, alternativeChips(ctx.state.stay_length, ctx.state.flex_month, ctx.tz)); 452 if (chips.length) { 453 $empty.append($('<p class="hrtk-fs-empty-subtitle"></p>').text(I18N.tryThese || "Try one of these instead:")); 454 var $chipList = $('<div class="hrtk-fs-empty-chips"></div>'); 455 chips.forEach(function (chip) { 456 $chipList.append( 457 $('<button type="button" class="hrtk-fs-empty-chip"></button>') 458 .attr("data-checkin", chip.checkin) 459 .attr("data-checkout", chip.checkout) 460 .text(chip.label) 461 ); 462 }); 463 $empty.append($chipList); 464 } else { 465 $empty.append($('<p class="hrtk-fs-empty-subtitle"></p>').text(I18N.tryDifferentFlex || "Try a different stay length or month.")); 466 } 467 468 $empty.on("click", ".hrtk-fs-empty-chip", function () { 469 var ci = String($(this).data("checkin")); 470 var co = String($(this).data("checkout")); 471 ctx.state.pickedRange = { checkin: ci, checkout: co }; 472 // Formless (SBP-driven) contexts have no Explorer form to serialize â 473 // the current URL already carries every committed filter, so pivot 474 // off it instead. 475 if (ctx.$filters && ctx.$filters.length) { 476 flexRedirect(ctx); 477 } else { 478 urlOnlyRedirect(ctx, ci, co, true); 479 } 480 }); 481 482 $noItems.empty().append($empty); 483 verifyEmptyChips($empty, chips, "flex"); 484 } 485 486 /** 487 * Exact-date zero results (#595): the guest asked for specific dates, so 488 * the useful alternatives keep their stay length and shift the window 489 * across nearby weeks rather than assuming a flex bucket. Only renders 490 * for a dated search â an undated / filters-only empty grid keeps the 491 * generic message. 492 */ 493 function renderExactEmpty(ctx, $noItems) { 494 var raw = readUrlParams(); 495 if (!raw.checkin || !raw.checkout) return; 496 var L = window.luxon && window.luxon.DateTime; 497 if (!L) return; 498 var ci = L.fromISO(raw.checkin, { zone: ctx.tz }); 499 var co = L.fromISO(raw.checkout, { zone: ctx.tz }); 500 if (!ci.isValid || !co.isValid || co <= ci) return; 501 502 var chips = applyServerChipFilter(ctx, exactAlternativeChips(ci, co, ctx.tz), { 503 mode: "exact", 504 checkin: raw.checkin, 505 checkout: raw.checkout, 506 }); 507 508 var prefix = I18N.noMatchesFor || "No matches for"; 509 var $empty = $('<div class="hrtk-fs-empty"></div>'); 510 $empty.append($('<h3 class="hrtk-fs-empty-title"></h3>').text(prefix + " " + fmtChipLabel(ci, co) + ".")); 511 512 if (chips.length) { 513 $empty.append($('<p class="hrtk-fs-empty-subtitle"></p>').text(I18N.tryThese || "Try one of these instead:")); 514 var $chipList = $('<div class="hrtk-fs-empty-chips"></div>'); 515 chips.forEach(function (chip) { 516 $chipList.append( 517 $('<button type="button" class="hrtk-fs-empty-chip"></button>') 518 .attr("data-checkin", chip.checkin) 519 .attr("data-checkout", chip.checkout) 520 .text(chip.label) 521 ); 522 }); 523 $empty.append($chipList); 524 } else { 525 $empty.append($('<p class="hrtk-fs-empty-subtitle"></p>').text(I18N.tryDifferentDates || "Try different dates.")); 526 } 527 528 $empty.on("click", ".hrtk-fs-empty-chip", function () { 529 // Carry the searched dates so the destination can say plainly that 530 // the dates were adjusted (#605).
531 urlOnlyRedirect(ctx, String($(this).data("checkin")), String($(this).data("checkout")), false, raw.checkin + "_" + raw.checkout); 532 }); 533 534 $noItems.empty().append($empty); 535 verifyEmptyChips($empty, chips, "exact"); 536 } 537 538 /** 539 * Same-length windows shifted around the searched dates, a week either 540 * side first, then further out. Check-ins before today (property 541 * timezone) are dropped â the SBP calendar allows same-day check-in, so 542 * today stays suggestible. 543 */ 544 function exactAlternativeChips(checkinDt, checkoutDt, tz) { 545 var nights = Math.max(1, Math.round(checkoutDt.diff(checkinDt, "days").days)); 546 var now = nowInZone(tz); 547 var earliest = now ? now.startOf("day") : null; 548 var offsets = []; 549 for (var b = WEEKS_BEFORE; b >= 1; b--) offsets.push(-7 * b); 550 for (var a = 1; a <= WEEKS_AFTER; a++) offsets.push(7 * a); 551 var out = []; 552 offsets.forEach(function (days) { 553 var ci = checkinDt.plus({ days: days }); 554 if (earliest && ci < earliest) return; 555 var co = ci.plus({ days: nights }); 556 out.push({ checkin: fmtYmd(ci), checkout: fmtYmd(co), label: fmtChipLabel(ci, co) }); 557 }); 558 // A wide Hub window (before + after > 8 weeks) yields more chips than 559 // one availability request can verify, and an unverified chip could 560 // point at fully booked dates. Sample the cap evenly across the full 561 // span â boundaries included â so widening the window still reaches 562 // its far edge; chronological order is preserved by construction. 563 // Without an active check nothing gets verified either way, so the 564 // full configured window renders uncapped. 565 if (availabilityCheckActive() && out.length > MAX_VERIFIABLE_CHIPS) { 566 var sampled = []; 567 var last = out.length - 1; 568 for (var k = 0; k < MAX_VERIFIABLE_CHIPS; k++) { 569 sampled.push(out[Math.round((k * last) / (MAX_VERIFIABLE_CHIPS - 1))]); 570 } 571 out = sampled; 572 } 573 return out; 574 } 575 576 /** 577 * Redirect built from the current URL rather than a form. Results pages 578 * are URL-driven â SBP pages have no Explorer form at all, and on classic 579 * pages the Explorer's own updateUrl keeps the URL current â so the URL 580 * already carries every committed filter; only the dates (and, for a 581 * flex pivot, the flex markers) change. 582 */ 583 function urlOnlyRedirect(ctx, checkin, checkout, keepFlex, adjFrom) { 584 var url = new URL(window.location.href); 585 if (keepFlex) { 586 url.searchParams.set("flex", "flexible"); 587 url.searchParams.set("stay_length", ctx.state.stay_length); 588 url.searchParams.set("flex_month", ctx.state.flex_month); 589 } else { 590 url.searchParams.delete("flex"); 591 url.searchParams.delete("stay_length"); 592 url.searchParams.delete("flex_month"); 593 } 594 // Adjusted-dates marker (#605): set when this pivot leaves the guest's 595 // searched dates behind, cleared otherwise so a stale marker never 596 // rides into an unrelated search. 597 if (adjFrom) { 598 url.searchParams.set("hrtk_adj_from", adjFrom); 599 } else { 600 url.searchParams.delete("hrtk_adj_from"); 601 } 602 url.searchParams.set("checkin", checkin); 603 url.searchParams.set("checkout", checkout); 604 window.location.href = url.toString(); 605 } 606 607 // ---------- chip availability check + adjusted-dates notice (#605) ---------- 608 609 /** 610 * One fetch answering availability for every chip's range, or null when 611 * the check cannot run (disabled, no endpoint, no helper module, no 612 * fetch, nothing valid to ask). Resolves to the endpoint's results 613 * array, or null on any transport/shape failure â callers treat null as 614 * "leave the chips alone". 615 */ 616 function fetchAvailability(chips) { 617 if (!availabilityCheckActive()) return null; 618 var lib = window.hrtkFlexAvailability; 619 var ranges = lib.buildRangesParam(chips, MAX_VERIFIABLE_CHIPS); 620 if (!ranges) return null; 621 var url = AVAIL.restUrl + (AVAIL.restUrl.indexOf("?") >= 0 ? "&" : "?") + 622 "ranges=" + encodeURIComponent(ranges) + 623 "&guests=" + lib.parseGuests(window.location.search); 624 return window.fetch(url, { credentials: "same-origin" }) 625 .then(function (res) { return res.ok ? res.json() : null; }) 626 .then(function (body) { return body && Array.isArray(body.results) ? body.results : null; }) 627 .catch(function () { return null; }); 628 } 629 630 /** 631 * Progressive availability pass over a rendered empty-state box: never 632 * blocks or delays the render â when the endpoint answers, chips whose 633 * range has nothing bookable are removed and chips with confirmed homes 634 * get an "available" mark plus a stronger subtitle. Verdicts the 635 * endpoint could not reach (core plugin missing, Central error) change 636 * nothing. If every chip prunes, the box falls back to the same 637 * no-suggestions subtitle it renders when date math yields no chips. 638 */ 639 function verifyEmptyChips($empty, chips, mode) { 640 var pending = fetchAvailability(chips); 641 if (!pending) return; 642 pending.then(function (results) { 643 if (!results) return; 644 // The observer may have swapped the box out while the fetch ran. 645 if (!$.contains(document.documentElement, $empty[0])) return; 646 var folded = window.hrtkFlexAvailability.applyResults(chips, results); 647 var byKey = {}; 648 folded.chips.forEach(function (chip) { 649 byKey[chip.checkin + "|" + chip.checkout] = chip; 650 }); 651 $empty.find(".hrtk-fs-empty-chip").each(function () { 652 var $btn = $(this); 653 var kept = byKey[String($btn.data("checkin")) + "|" + String($btn.data("checkout"))]; 654 if (!kept) { 655 $btn.remove(); 656 return; 657 } 658 if (kept.verified) markChipAvailable($btn); 659 }); 660 var $subtitle = $empty.find(".hrtk-fs-empty-subtitle"); 661 if (!$empty.find(".hrtk-fs-empty-chip").length) { 662 $empty.find(".hrtk-fs-empty-chips").remove(); 663 $subtitle.text(mode === "exact" 664 ? (I18N.tryDifferentDates || "Try different dates.") 665 : (I18N.tryDifferentFlex || "Try a different stay length or month.")); 666 } else if (folded.verified) { 667 $subtitle.text(I18N.datesWithHomes || "These nearby dates have homes available:"); 668 } 669 }); 670 } 671 672 /** 673 * "Dates adjusted" notice: when the guest arrives via an exact 674 * empty-state chip the URL carries hrtk_adj_from=<orig-ci>_<orig-co>. 675 * On a results page that actually has homes, say plainly that the dates 676 * were adjusted and offer the original search's other verified windows 677 * as chips. Dismissing removes the notice and strips the marker via 678 * replaceState so refresh/share stays clean. When this page is ALSO
679 * empty, the empty-state box owns the messaging and the notice yields. 680 * Deliberately not shown for flex pivots â picking a concrete week out 681 * of "1 week in July" is a refinement, not an adjustment. 682 */ 683 function syncAdjustedNotice(ctx, $propsContainer) { 684 var lib = window.hrtkFlexAvailability; 685 if (!lib) return; 686 var from = lib.parseAdjFrom(window.location.search); 687 var $existing = $propsContainer.prevAll(".hrtk-fs-adjusted-notice").first(); 688 if (!from) { 689 $existing.remove(); 690 return; 691 } 692 var raw = readUrlParams(); 693 // An in-page refine can clear the dates or revert to the original 694 // range while hrtk_adj_from rides along â a mounted notice would 695 // then describe the wrong result set. Remove, don't just skip. 696 if (!raw.checkin || !raw.checkout) { 697 $existing.remove(); 698 return; 699 } 700 if (from.checkin === raw.checkin && from.checkout === raw.checkout) { 701 $existing.remove(); 702 return; 703 } 704 if ($propsContainer.find(".hfse-properties-no-items").length) { 705 $existing.remove(); 706 return; 707 } 708 // A rendered home is the only positive-results signal â container 709 // children also include toolbars and loaders. Card markup per core 710 // explorer.js (.hfse-property-card click surface, .hfse-property-container 711 // hover surface). No cards (loader gap, persistent error) also means 712 // an existing notice has lost its grounds â remove, don't keep. 713 if (!$propsContainer.find(".hfse-property-card, .hfse-property-container").length) { 714 $existing.remove(); 715 return; 716 } 717 // SBP refine mode can change the committed dates or guests in-page 718 // (replaceState, no navigation) while hrtk_adj_from rides along â a 719 // notice describing the previous result set must rebuild, not stand. 720 // Guests are part of the signature: the vetted alternatives were 721 // fetched for a specific guest count. 722 var sig = from.checkin + "_" + from.checkout + "|" + raw.checkin + "_" + raw.checkout + "|" + lib.parseGuests(window.location.search); 723 if ($existing.length) { 724 if ($existing.attr("data-hrtk-sig") === sig) return; 725 $existing.remove(); 726 } 727 if ($propsContainer.data("hrtkFsAdjDismissed")) return; 728 729 var L = window.luxon && window.luxon.DateTime; 730 if (!L) return; 731 var fromCi = L.fromISO(from.checkin, { zone: ctx.tz }); 732 var fromCo = L.fromISO(from.checkout, { zone: ctx.tz }); 733 var curCi = L.fromISO(raw.checkin, { zone: ctx.tz }); 734 var curCo = L.fromISO(raw.checkout, { zone: ctx.tz }); 735 if (!fromCi.isValid || !fromCo.isValid || !curCi.isValid || !curCo.isValid) return; 736 737 var template = I18N.adjustedNotice || "No homes were available for %1$s. Showing %2$s instead."; 738 var text = template 739 .replace("%1$s", fmtChipLabel(fromCi, fromCo)) 740 .replace("%2$s", fmtChipLabel(curCi, curCo)); 741 742 var $notice = $('<div class="hrtk-fs-adjusted-notice" role="status"></div>').attr("data-hrtk-sig", sig); 743 var $main = $('<div class="hrtk-fs-adjusted-notice-main"></div>'); 744 $main.append($('<p class="hrtk-fs-adjusted-notice-text"></p>').text(text)); 745 var $dismiss = $('<button type="button" class="hrtk-fs-adjusted-notice-dismiss">×</button>') 746 .attr("aria-label", I18N.dismiss || "Dismiss"); 747 $notice.append($main, $dismiss); 748 749 $dismiss.on("click", function () { 750 $propsContainer.data("hrtkFsAdjDismissed", 1); 751 $notice.remove(); 752 var url = new URL(window.location.href); 753 url.searchParams.delete("hrtk_adj_from"); 754 window.history.replaceState({}, "", url.toString()); 755 }); 756 757 $propsContainer.before($notice); 758 appendOtherAvailableDates(ctx, $notice, $main, from, raw); 759 } 760 761 /** 762 * Verified marking: the checkmark is CSS-generated, invisible to 763 * assistive tech â carry the same fact in the accessible name. The 764 * whole name is one localized template so translators control order 765 * and inflection. 766 */ 767 function markChipAvailable($btn) { 768 $btn.addClass("is-available") 769 .attr("aria-label", (I18N.availableAria || "%s, available").replace("%s", $btn.text())); 770 } 771 772 /** 773 * The original search's other alternative windows, availability-checked; 774 * only ranges with confirmed homes render â the notice's whole point is 775 * pre-vetted suggestions, so unknown (null) verdicts are omitted here 776 * rather than kept. Clicking one keeps the original dates as the 777 * notice's reference on the next page. 778 */ 779 function appendOtherAvailableDates(ctx, $notice, $main, from, raw) { 780 var lib = window.hrtkFlexAvailability; 781 var L = window.luxon && window.luxon.DateTime; 782 if (!L) return; 783 var fromCi = L.fromISO(from.checkin, { zone: ctx.tz }); 784 var fromCo = L.fromISO(from.checkout, { zone: ctx.tz }); 785 if (!fromCi.isValid || !fromCo.isValid) return; 786 var candidates = exactAlternativeChips(fromCi, fromCo, ctx.tz).filter(function (chip) { 787 return !(chip.checkin === raw.checkin && chip.checkout === raw.checkout); 788 }); 789 var pending = fetchAvailability(candidates); 790 if (!pending) return; 791 pending.then(function (results) { 792 if (!results) return; 793 if (!$.contains(document.documentElement, $notice[0])) return; 794 var confirmed = lib.applyResults(candidates, results).chips.filter(function (chip) { 795 return chip.verified; 796 }).slice(0, 4); 797 if (!confirmed.length) return; 798 var $strip = $('<div class="hrtk-fs-adjusted-notice-alts"></div>'); 799 $strip.append($('<span class="hrtk-fs-adjusted-notice-alts-heading"></span>').text(I18N.otherAvailableDates || "Other available dates:")); 800 confirmed.forEach(function (chip) { 801 var $btn = $('<button type="button" class="hrtk-fs-empty-chip"></button>') 802 .attr("data-checkin", chip.checkin) 803 .attr("data-checkout", chip.checkout) 804 .text(chip.label); 805 markChipAvailable($btn); 806 $strip.append($btn); 807 }); 808 $strip.on("click", ".hrtk-fs-empty-chip", function () {
809 urlOnlyRedirect(ctx, String($(this).data("checkin")), String($(this).data("checkout")), false, 810 lib.buildAdjFromValue(from.checkin, from.checkout)); 811 }); 812 $main.append($strip); 813 }); 814 } 815 816 /** 817 * Native horizontal scroll without a scrollbar; show chevron arrows that 818 * appear only when there's room to scroll in that direction. 819 */ 820 function setupMonthStripArrows(ctx) { 821 var $wrap = ctx.$panel.find(".hrtk-fs-month-strip-wrap"); 822 var $strip = $wrap.find(".hrtk-fs-month-strip"); 823 var $left = $wrap.find(".hrtk-fs-month-arrow-left"); 824 var $right = $wrap.find(".hrtk-fs-month-arrow-right"); 825 if (!$strip.length) return; 826 827 var stripEl = $strip[0]; 828 829 function update() { 830 var maxScroll = stripEl.scrollWidth - stripEl.clientWidth; 831 var canLeft = stripEl.scrollLeft > 4; 832 var canRight = stripEl.scrollLeft < maxScroll - 4; 833 $left.attr("hidden", !canLeft); 834 $right.attr("hidden", !canRight); 835 } 836 837 function scrollBy(dir) { 838 var step = Math.max(160, Math.floor(stripEl.clientWidth * 0.7)); 839 try { 840 stripEl.scrollBy({ left: dir * step, behavior: "smooth" }); 841 } catch (e) { 842 stripEl.scrollLeft += dir * step; 843 } 844 } 845 846 $strip.on("scroll", update); 847 $left.on("click", function () { scrollBy(-1); }); 848 $right.on("click", function () { scrollBy(1); }); 849 850 // Initial check after layout settles, plus on resize. 851 setTimeout(update, 50); 852 if (typeof ResizeObserver === "function") { 853 var ro = new ResizeObserver(update); 854 ro.observe(stripEl); 855 } else { 856 $(window).on("resize", update); 857 } 858 } 859 860 /** 861 * Mouse drag-to-scroll for the month strip. overflow-x already gives the 862 * strip wheel/trackpad and native touch panning, but a mouse drag does 863 * nothing â and with the scrollbar hidden, grab-and-drag is the affordance 864 * the strip implies. Mouse/pen pointers only: touch pans natively, and 865 * claiming it here would fight the browser's own scrolling. Mirrors the 866 * Searchbar Pro quick-chips track (dead-zone drag + inertial flick). 867 */ 868 function setupMonthStripDrag(ctx) { 869 var $strip = ctx.$panel.find(".hrtk-fs-month-strip"); 870 if (!$strip.length || typeof window.PointerEvent !== "function") return; 871 var strip = $strip[0]; 872 873 var pressed = false; // primary button is down on the strip 874 var dragged = false; // moved past the click threshold 875 var startX = 0; 876 var startLeft = 0; 877 878 // Inertial flick: a drag released with speed keeps the strip coasting 879 // with exponential decay. Constants match the Searchbar Pro chip row. 880 var MIN_FLICK_V = 0.3; // px/ms at release; slower stays a plain stop 881 var STOP_V = 0.02; // coasting below this is invisible â end the loop 882 var DECAY_PER_MS = 0.99; // v *= 0.99^dt â coast settles in ~300-500ms 883 var STALE_MS = 80; // held-still-then-release must not flick 884 var prevX = 0, prevT = 0; // last two pointermove samples â 885 var lastX = 0, lastT = 0; // all a release velocity needs 886 var flickRaf = 0; 887 888 // The strip is `scroll-behavior: smooth` for the arrow buttons; drag 889 // and coast write scrollLeft directly and must not be re-animated. The 890 // arrows pass an explicit behavior to scrollBy, so they stay smooth. 891 strip.style.scrollBehavior = "auto"; 892 893 function stopFlick() { 894 if (flickRaf) { 895 window.cancelAnimationFrame(flickRaf); 896 flickRaf = 0; 897 } 898 } 899 900 function startFlick(upTime) { 901 var sampleDt = lastT - prevT; 902 // A single sample or a pointer that paused before lifting means 903 // "held still, then let go": no coast. 904 if (sampleDt <= 0 || upTime - lastT > STALE_MS) return; 905 var v = (lastX - prevX) / sampleDt; // pointer px/ms 906 if (Math.abs(v) < MIN_FLICK_V) return; 907 var maxLeft = strip.scrollWidth - strip.clientWidth; 908 var lastFrame = 0; 909 // First frame only records its timestamp: e.timeStamp is not 910 // guaranteed to share rAF's clock everywhere, and a mismatched epoch 911 // would compute one giant step. 912 flickRaf = window.requestAnimationFrame(function step(now) { 913 if (lastFrame) { 914 var dt = now - lastFrame;
915 strip.scrollLeft -= v * dt; 916 v *= Math.pow(DECAY_PER_MS, dt); 917 if (Math.abs(v) < STOP_V || strip.scrollLeft <= 0 || strip.scrollLeft >= maxLeft - 1) { 918 flickRaf = 0; 919 return; 920 } 921 } 922 lastFrame = now; 923 flickRaf = window.requestAnimationFrame(step); 924 }); 925 } 926 927 strip.addEventListener("pointerdown", function (e) { 928 // Any press stops a coast â grab-to-stop is the native gesture â and 929 // it must run before the touch guard so a finger can also catch a 930 // coasting strip. 931 stopFlick(); 932 if (e.pointerType === "touch" || e.button !== 0) return; 933 if (strip.scrollWidth - strip.clientWidth <= 0) return; 934 pressed = true; 935 dragged = false; 936 startX = e.clientX; 937 startLeft = strip.scrollLeft; 938 prevX = lastX = e.clientX; 939 prevT = lastT = e.timeStamp; 940 // No capture and no preventDefault yet: a clean press-and-release 941 // must stay a normal chip click. 942 }); 943 944 strip.addEventListener("pointermove", function (e) { 945 if (!pressed) return; 946 var dx = e.clientX - startX; 947 if (!dragged) { 948 // Dead zone so a twitchy press still clicks. 949 if (Math.abs(dx) < 5) return; 950 dragged = true; 951 // Capture so the drag survives the pointer leaving the strip; 952 // harmless if unsupported. 953 if (strip.setPointerCapture) { 954 try { strip.setPointerCapture(e.pointerId); } catch (err) {} 955 } 956 strip.classList.add("hrtk-fs-month-strip--dragging"); 957 } 958 strip.scrollLeft = startLeft - dx; 959 prevX = lastX; 960 prevT = lastT; 961 lastX = e.clientX; 962 lastT = e.timeStamp; 963 }); 964 965 strip.addEventListener("pointerup", function (e) { 966 if (!pressed) return; 967 pressed = false; 968 strip.classList.remove("hrtk-fs-month-strip--dragging"); 969 // `dragged` intentionally survives until the click this pointerup is 970 // about to dispatch (see below). 971 if (dragged) startFlick(e.timeStamp); 972 }); 973 974 // Wheel input while coasting means the guest took over: yield. `scroll` 975 // must NOT cancel â the flick's own writes fire scroll events and would 976 // kill the loop on frame one. 977 strip.addEventListener("wheel", stopFlick, { passive: true }); 978 979 strip.addEventListener("pointercancel", function () { 980 pressed = false; 981 dragged = false; // no click follows a cancel 982 strip.classList.remove("hrtk-fs-month-strip--dragging"); 983 }); 984 985 // A drag releases over some chip and the browser still dispatches its 986 // click â swallow that one click in the capture phase so a scroll 987 // gesture never selects a month. 988 strip.addEventListener("click", function (e) { 989 if (!dragged) return; 990 dragged = false; 991 e.preventDefault(); 992 e.stopPropagation(); 993 }, true); 994 } 995 996 /** 997 * Scroll the active month chip into view when the date modal opens. A 998 * hydrated link (`?flex=flexible&flex_month=2026-12`) marks its chip 999 * `.is-active` wherever it sits â worst case the out-of-range extra chip, 1000 * which reflectMonthChips appends at the strip's END â so the guest can 1001 * open the modal and see a strip of months with no visible selection. 1002 * 1003 * The trigger is modal open, never attach time: the modal starts 1004 * display:none, where scrollWidth/clientWidth/rects all read 0 and any 1005 * reveal computed there is garbage that silently no-ops. hk-modal 1006 * (hkUIkit 3.x) dispatches native `show` (display just flipped on, open 1007 * animation starting) and `shown` (animation finished) on the modal 1008 * element. Reveal on `show` so the chip is already in place as the modal 1009 * fades in â nothing visibly jumps â and re-check on `shown` in case a 1010 * theme's animation made the `show`-time measurement bail; the re-check 1011 * is a no-op when the chip is already in view. Modal-open also can't 1012 * overlap an in-progress drag or flick, so this never fights the drag 1013 * code's scroll writes. 1014 * 1015 * Reveal mechanics ported from the Searchbar Pro quick-chips track 1016 * (revealChip in searchbar-pro.js). Notes: 1017 * - Scroll the strip, never chip.scrollIntoView(): that walks every 1018 * scrollable ancestor and can yank the page vertically. 1019 * - Center rather than scroll-the-minimum. The chevron arrows overlay
1020 * 36px circles onto whichever edge still has content past it, so a 1021 * chip parked flush against an edge renders half-covered. Centering 1022 * clears both; the browser clamps to [0, max] for chips near either 1023 * end, and the arrows' own scroll listener re-syncs their visibility. 1024 * - Geometry via rects, not offsetLeft: the chip's offsetParent is 1025 * whichever ancestor the host theme happens to position. 1026 */ 1027 function setupMonthStripReveal(ctx) { 1028 var $strip = ctx.$panel.find(".hrtk-fs-month-strip"); 1029 if (!$strip.length) return; 1030 var strip = $strip[0]; 1031 var modal = ctx.$modal[0]; 1032 1033 function revealActiveChip() { 1034 var chip = strip.querySelector(".hrtk-fs-month-chip.is-active"); 1035 if (!chip) return; 1036 if (strip.scrollWidth - strip.clientWidth <= 0) return; 1037 1038 var sr = strip.getBoundingClientRect(); 1039 var cr = chip.getBoundingClientRect(); 1040 if (!cr.width) return; 1041 1042 // Already fully clear of both arrow overlays â leave it alone rather 1043 // than nudge the row for no visible gain. 1044 if (cr.left >= sr.left + 36 && cr.right <= sr.right - 36) return; 1045 1046 // The drag code pins the strip to inline scroll-behavior:auto, so 1047 // this write lands instantly â on `show` the guest never sees the 1048 // motion, just a strip that opened in the right place. 1049 strip.scrollLeft += (cr.left + cr.width / 2) - (sr.left + sr.width / 2); 1050 } 1051 1052 function onOpen(e) { 1053 // `show`/`shown` bubble up from any hk component nested inside the 1054 // modal too â only act on the modal's own open. 1055 if (e.target !== modal) return; 1056 // Defer one frame so layout has settled after display flips on. 1057 window.requestAnimationFrame(revealActiveChip); 1058 } 1059 1060 modal.addEventListener("show", onOpen); 1061 modal.addEventListener("shown", onOpen); 1062 } 1063 1064 /** 1065 * The Explorer date input is a `<input type="text" readonly>` â its visible 1066 * value is the `.value` property. Explorer's own init/AJAX code sets that 1067 * property after ours runs, overwriting our string. A MutationObserver 1068 * doesn't help (property writes don't mutate attributes), so we override 1069 * the property descriptor on this single element. Adaptive behavior: 1070 * - flex mode + no specific picked range â force natural language 1071 * - flex mode + picked range â pass through (concrete date range) 1072 * - exact mode â pass through 1073 */ 1074 function watchDateLabel(ctx) { 1075 var $label = ctx.$form.find(".hfse-form-date-input").first(); 1076 if (!$label.length) return; 1077 var labelEl = $label[0]; 1078 var desc = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); 1079 if (!desc || !desc.set || !desc.get) return; 1080 Object.defineProperty(labelEl, "value", { 1081 configurable: true, 1082 enumerable: true, 1083 get: function () { 1084 return desc.get.call(this); 1085 }, 1086 set: function (incoming) { 1087 if (ctx.state && ctx.state.flex === "flexible" && !ctx.state.pickedRange) { 1088 desc.set.call(this, naturalLanguageLabel(ctx.state.stay_length, ctx.state.flex_month, ctx.tz)); 1089 return; 1090 } 1091 desc.set.call(this, incoming); 1092 }, 1093 }); 1094 } 1095 1096 function hydrateState(tz) { 1097 var raw = readUrlParams(); 1098 var flex = raw.flex === "flexible" ? "flexible" : "exact"; 1099 if (flex === "exact") { 1100 return { flex: "exact", stay_length: STAY_LENGTHS[0], flex_month: ANYTIME, pickedRange: null }; 1101 } 1102 var stayLength = sanitizeStayLength(raw.stay_length); 1103 var flexMonth = sanitizeFlexMonth(raw.flex_month, tz); 1104 var pickedRange = raw.checkin && raw.checkout ? { checkin: raw.checkin, checkout: raw.checkout } : null; 1105 return { flex: "flexible", stay_length: stayLength, flex_month: flexMonth, pickedRange: pickedRange }; 1106 } 1107 1108 function buildToggle() { 1109 return $( 1110 '<div class="hrtk-fs-toggle" role="tablist">' + 1111 '<button type="button" class="hrtk-fs-toggle-btn" data-mode="exact" role="tab">' + escapeHtml(I18N.exactDates || "Exact dates") + "</button>" + 1112 '<button type="button" class="hrtk-fs-toggle-btn" data-mode="flexible" role="tab">' + escapeHtml(I18N.imFlexible || "I'm flexible") + "</button>" + 1113 "</div>" 1114 ); 1115 } 1116 1117 function buildPanel(tz) { 1118 var $panel = $('<div class="hrtk-fs-panel" hidden></div>'); 1119 1120 // How long 1121 var $stayHeading = $('<h3 class="hrtk-fs-heading"></h3>').text(I18N.howLong || "How long would you like to stay?"); 1122 var $stayChips = $('<div class="hrtk-fs-chip-group hrtk-fs-stay-lengths" role="radiogroup"></div>'); 1123 STAY_LENGTHS.forEach(function (slug) { 1124 var label = stayLengthLabel(slug); 1125 $stayChips.append( 1126 $('<button type="button" class="hrtk-fs-chip hrtk-fs-stay-chip" role="radio"></button>') 1127 .attr("data-stay", slug) 1128 .text(label) 1129 ); 1130 }); 1131 1132 // When 1133 var $whenHeading = $('<h3 class="hrtk-fs-heading"></h3>').text(I18N.whenGo || "When do you want to go?");
1134 var $monthStripWrap = $('<div class="hrtk-fs-month-strip-wrap"></div>'); 1135 var $arrowLeft = $('<button type="button" class="hrtk-fs-month-arrow hrtk-fs-month-arrow-left" aria-label="Scroll left" hidden>' + ICON_CHEVRON_LEFT + '</button>'); 1136 var $arrowRight = $('<button type="button" class="hrtk-fs-month-arrow hrtk-fs-month-arrow-right" aria-label="Scroll right" hidden>' + ICON_CHEVRON_RIGHT + '</button>'); 1137 var $monthStrip = $('<div class="hrtk-fs-month-strip"></div>'); 1138 var anchor = nowInZone(tz); 1139 if (anchor) { 1140 $monthStrip.append(buildMonthChip(ANYTIME, I18N.anytime || "Anytime", true)); 1141 var orNode = $('<span class="hrtk-fs-or"></span>').text(I18N.or || "or"); 1142 $monthStrip.append(orNode); 1143 var current = anchor.startOf("month"); 1144 for (var i = 0; i < MONTHS_AHEAD; i++) { 1145 $monthStrip.append(buildMonthChip(current.toFormat("yyyy-MM"), current.toFormat("LLL"), false, current.toFormat("yyyy"))); 1146 current = current.plus({ months: 1 }); 1147 } 1148 } 1149 $monthStripWrap.append($arrowLeft, $monthStrip, $arrowRight); 1150 1151 // Alternative chips (rendered inside the panel, below the month strip). 1152 var $altSection = $('<div class="hrtk-fs-alt-section" hidden></div>'); 1153 var $altHeading = $('<div class="hrtk-fs-alt-heading"></div>'); 1154 var $altChips = $('<div class="hrtk-fs-alt-chips"></div>'); 1155 $altSection.append($altHeading, $altChips); 1156 1157 // Footer 1158 var $footer = $('<div class="hrtk-fs-panel-footer"></div>'); 1159 var $clear = $('<button type="button" class="hrtk-fs-clear"></button>').text(I18N.clear || "Clear dates"); 1160 var $apply = $('<button type="button" class="hrtk-fs-apply"></button>').text(I18N.apply || "Apply"); 1161 $footer.append($clear, $apply); 1162 1163 $panel.append($stayHeading, $stayChips, $whenHeading, $monthStripWrap, $altSection, $footer); 1164 return $panel; 1165 } 1166 1167 var ICON_CLOCK = 1168 '<svg class="hrtk-fs-chip-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + 1169 '<circle cx="12" cy="12" r="9"></circle>' + 1170 '<polyline points="12 7 12 12 15 14"></polyline>' + 1171 '</svg>'; 1172 var ICON_CALENDAR = 1173 '<svg class="hrtk-fs-chip-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + 1174 '<rect x="3" y="5" width="18" height="16" rx="2"></rect>' + 1175 '<line x1="3" y1="10" x2="21" y2="10"></line>' + 1176 '<line x1="8" y1="3" x2="8" y2="7"></line>' + 1177 '<line x1="16" y1="3" x2="16" y2="7"></line>' + 1178 '</svg>'; 1179 var ICON_CHEVRON_LEFT = 1180 '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + 1181 '<polyline points="15 18 9 12 15 6"></polyline>' + 1182 '</svg>'; 1183 var ICON_CHEVRON_RIGHT = 1184 '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + 1185 '<polyline points="9 18 15 12 9 6"></polyline>' + 1186 '</svg>'; 1187 1188 function buildMonthChip(value, primaryLabel, isAnytime, secondaryLabel) { 1189 var $chip = $('<button type="button" class="hrtk-fs-chip hrtk-fs-month-chip"></button>').attr("data-month", value); 1190 if (isAnytime) { 1191 $chip.addClass("hrtk-fs-month-chip-anytime"); 1192 $chip.append(ICON_CLOCK); 1193 } else { 1194 $chip.append(ICON_CALENDAR); 1195 } 1196 $chip.append($('<span class="hrtk-fs-chip-primary"></span>').text(primaryLabel)); 1197 if (secondaryLabel) { 1198 $chip.append($('<span class="hrtk-fs-chip-secondary"></span>').text(secondaryLabel)); 1199 } 1200 return $chip; 1201 } 1202 1203 function stayLengthLabel(slug) { 1204 if (slug === "weekend") return I18N.weekendStay || "Weekend stay"; 1205 if (slug === "1w") return I18N.oneWeek || "1 week"; 1206 if (slug === "2w") return I18N.twoWeeks || "2 weeks"; 1207 if (slug === "1m") return I18N.oneMonth || "1 month"; 1208 return slug; 1209 } 1210 1211 // ---------- events ---------- 1212 1213 function bindEvents(ctx) { 1214 ctx.$toggle.on("click", ".hrtk-fs-toggle-btn", function () { 1215 var mode = $(this).data("mode"); 1216 ctx.state.flex = mode === "flexible" ? "flexible" : "exact"; 1217 applyState(ctx, false); 1218 }); 1219 1220 ctx.$panel.on("click", ".hrtk-fs-stay-chip", function () { 1221 ctx.state.stay_length = $(this).data("stay"); 1222 // Stay-length change invalidates a previously picked specific range. 1223 ctx.state.pickedRange = null; 1224 reflectStayChips(ctx); 1225 }); 1226 1227 ctx.$panel.on("click", ".hrtk-fs-month-chip", function () { 1228 ctx.state.flex_month = String($(this).data("month")); 1229 // Month change invalidates a previously picked specific range. 1230 ctx.state.pickedRange = null; 1231 reflectMonthChips(ctx); 1232 }); 1233 1234 ctx.$panel.on("click", ".hrtk-fs-apply", function () { 1235 applyFlex(ctx); 1236 }); 1237 1238 ctx.$panel.on("click", ".hrtk-fs-clear", function () { 1239 clearFlex(ctx); 1240 }); 1241 1242 ctx.$panel.on("click", ".hrtk-fs-alt-chip", function () { 1243 var checkin = String($(this).data("checkin")); 1244 var checkout = String($(this).data("checkout")); 1245 ctx.state.pickedRange = { checkin: checkin, checkout: checkout }; 1246 reflectAltChips(ctx, checkin, checkout); 1247 }); 1248 1249 // Form-side chip strip: clicking a specific week/month commits that 1250 // range as the active specific selection while staying in flex mode. 1251 // The search bar reads the concrete dates (so it's clear what was 1252 // searched), and the chip strip remains visible so the guest can swap 1253 // to a different specific range without leaving flex. 1254 ctx.$formChipStrip.on("click", ".hrtk-fs-form-strip-chip", function () { 1255 var checkin = String($(this).data("checkin")); 1256 var checkout = String($(this).data("checkout")); 1257 ctx.state.pickedRange = { checkin: checkin, checkout: checkout }; 1258 flexRedirect(ctx); 1259 }); 1260 } 1261 1262 function applyState(ctx, isInit) { 1263 var isFlex = ctx.state.flex === "flexible"; 1264 ctx.$toggle.find(".hrtk-fs-toggle-btn").each(function () { 1265 var $btn = $(this); 1266 var isActive = $btn.data("mode") === ctx.state.flex; 1267 $btn.toggleClass("is-active", isActive).attr("aria-selected", isActive ? "true" : "false"); 1268 }); 1269 1270 // Toggle visibility: when flexible, hide the exact-date body+footer and 1271 // show the flex panel. When exact, do the opposite. Use both attribute 1272 // and inline style so we beat any !important rules from the host theme. 1273 ctx.$panel.attr("hidden", !isFlex).css("display", isFlex ? "" : "none"); 1274 if (ctx.$body && ctx.$body.length) { 1275 ctx.$body.attr("hidden", isFlex).css("display", isFlex ? "none" : ""); 1276 } 1277 if (ctx.$footer && ctx.$footer.length) { 1278 ctx.$footer.attr("hidden", isFlex).css("display", isFlex ? "none" : ""); 1279 } 1280 reflectStayChips(ctx); 1281 reflectMonthChips(ctx); 1282 1283 if (isFlex) { 1284 // Recompute representative range and render alternative chips so the 1285 // user can preview matching dates inside the modal before applying. 1286 var range = ctx.state.pickedRange || representativeRange(ctx.state.stay_length, ctx.state.flex_month, ctx.tz); 1287 if (range) { 1288 renderAltChips(ctx, range.checkin, range.checkout); 1289 if (isInit) {
1290 // URL precedence on hydration: write hidden inputs + label so a 1291 // shared ?flex=flexible link reflects the canonical state on load. 1292 writeDates(ctx, range.checkin, range.checkout); 1293 writeUrlState({ 1294 flex: "flexible", 1295 stay_length: ctx.state.stay_length, 1296 flex_month: ctx.state.flex_month, 1297 checkin: range.checkin, 1298 checkout: range.checkout, 1299 }, true); 1300 } 1301 } 1302 } else { 1303 // Exact mode: hide the form-side chip strip (its purpose is to 1304 // browse alternatives within a flex selection â irrelevant in exact). 1305 ctx.$formChipStrip.attr("hidden", true); 1306 } 1307 syncHiddenInputs(ctx); 1308 } 1309 1310 function applyFlex(ctx) { 1311 // grid=0 (homepage / search bar without results): mirror Explorer's 1312 // own pattern â Apply commits state to the form, closes the modal, 1313 // and waits for the user to click the SEARCH button. The bound submit 1314 // handler intercepts that submit and calls flexRedirect. 1315 // grid=1 (results page): redirect immediately. 1316 if (ctx.gridMode === "0") { 1317 var range = ctx.state.pickedRange || representativeRange(ctx.state.stay_length, ctx.state.flex_month, ctx.tz); 1318 if (range) { 1319 writeDates(ctx, range.checkin, range.checkout); 1320 } 1321 syncHiddenInputs(ctx); 1322 closeModal(ctx); 1323 return; 1324 } 1325 flexRedirect(ctx); 1326 } 1327 1328 function clearFlex(ctx) { 1329 ctx.state.flex = "exact"; 1330 ctx.state.stay_length = STAY_LENGTHS[0]; 1331 ctx.state.flex_month = ANYTIME; 1332 ctx.state.pickedRange = null; 1333 1334 // Wipe the hidden inputs and label. 1335 writeDates(ctx, "", ""); 1336 1337 // Reset Explorer's calendar widget so it forgets its highlighted range. 1338 // Explorer listens for this event and clears its internal selection. 1339 $(document).trigger("homelocal_range_calendar_reset_selection_explorer-" + ctx.instanceId, ["", true]); 1340 1341 // Also click the existing "Clear Dates" reset button inside the modal 1342 // (type="reset" .hfse-modal-reset-button) which cascades a form reset. 1343 var $resetBtn = ctx.$dialog.find(".hfse-modal-reset-button").first(); 1344 if ($resetBtn.length) $resetBtn.trigger("click"); 1345 1346 ctx.$formChipStrip.attr("hidden", true); 1347 writeUrlState({ flex: "exact", checkin: "", checkout: "" }, true); 1348 applyState(ctx, false); 1349 } 1350 1351 function reflectStayChips(ctx) { 1352 ctx.$panel.find(".hrtk-fs-stay-chip").each(function () { 1353 var active = $(this).data("stay") === ctx.state.stay_length; 1354 $(this).toggleClass("is-active", active).attr("aria-checked", active ? "true" : "false"); 1355 }); 1356 var range = ctx.state.pickedRange || representativeRange(ctx.state.stay_length, ctx.state.flex_month, ctx.tz); 1357 if (range && ctx.state.flex === "flexible") { 1358 renderAltChips(ctx, range.checkin, range.checkout); 1359 } 1360 } 1361 1362 function reflectMonthChips(ctx) { 1363 var found = false; 1364 ctx.$panel.find(".hrtk-fs-month-chip").each(function () { 1365 var $chip = $(this); 1366 var active = String($chip.data("month")) === ctx.state.flex_month; 1367 $chip.toggleClass("is-active", active); 1368 if (active) found = true; 1369 }); 1370 // Out-of-bounds month not in default chip set: append a chip dynamically. 1371 if (!found && ctx.state.flex_month && ctx.state.flex_month !== ANYTIME) { 1372 var dt = dtFromMonth(ctx.state.flex_month, ctx.tz); 1373 if (dt && dt.isValid) { 1374 var $extra = buildMonthChip(ctx.state.flex_month, dt.toFormat("LLL"), false, dt.toFormat("yyyy")); 1375 $extra.addClass("is-active hrtk-fs-month-chip-extra"); 1376 ctx.$panel.find(".hrtk-fs-month-strip").append($extra); 1377 } 1378 } 1379 if (ctx.state.flex === "flexible") { 1380 var range = ctx.state.pickedRange || representativeRange(ctx.state.stay_length, ctx.state.flex_month, ctx.tz); 1381 if (range) renderAltChips(ctx, range.checkin, range.checkout); 1382 } 1383 } 1384 1385 function reflectAltChips(ctx, activeCheckin, activeCheckout) { 1386 ctx.$altChips.find(".hrtk-fs-alt-chip").each(function () { 1387 var $chip = $(this); 1388 var active = String($chip.data("checkin")) === activeCheckin && String($chip.data("checkout")) === activeCheckout; 1389 $chip.toggleClass("is-active", active); 1390 }); 1391 } 1392 1393 function renderAltChips(ctx, activeCheckin, activeCheckout) { 1394 var chips = alternativeChips(ctx.state.stay_length, ctx.state.flex_month, ctx.tz); 1395 chips = applyServerChipFilter(ctx, chips); 1396 1397 var $section = ctx.$panel.find(".hrtk-fs-alt-section"); 1398 if (!chips.length) { 1399 $section.attr("hidden", true); 1400 ctx.$formChipStrip.attr("hidden", true); 1401 return; 1402 } 1403 1404 // In-modal chips (no pre-highlight on initial render â only highlight 1405 // when the user has explicitly picked one). 1406 ctx.$altChips.empty(); 1407 chips.forEach(function (chip) { 1408 var $btn = $('<button type="button" class="hrtk-fs-alt-chip"></button>') 1409 .attr("data-checkin", chip.checkin) 1410 .attr("data-checkout", chip.checkout) 1411 .text(chip.label); 1412 ctx.$altChips.append($btn); 1413 }); 1414 ctx.$altHeading.text(headingFor(ctx.state.stay_length)); 1415 $section.attr("hidden", false); 1416 1417 if (ctx.state.pickedRange) { 1418 reflectAltChips(ctx, activeCheckin, activeCheckout); 1419 } 1420 1421 // Form-side chips: only render after a flex search has been committed 1422 // (i.e. URL has flex=flexible AND we're not currently editing in the 1423 // modal). The strip lets users swap to a different matching range 1424 // without reopening the modal. 1425 renderFormStrip(ctx, chips); 1426 } 1427 1428 function renderFormStrip(ctx, chips) { 1429 if (ctx.state.flex !== "flexible") { 1430 ctx.$formChipStrip.attr("hidden", true); 1431 return; 1432 } 1433 var $heading = ctx.$formChipStrip.find(".hrtk-fs-form-strip-heading"); 1434 var $list = ctx.$formChipStrip.find(".hrtk-fs-form-strip-chips").empty(); 1435 chips.forEach(function (chip) { 1436 var $btn = $('<button type="button" class="hrtk-fs-form-strip-chip"></button>') 1437 .attr("data-checkin", chip.checkin) 1438 .attr("data-checkout", chip.checkout) 1439 .text(chip.label); 1440 $list.append($btn); 1441 }); 1442 $heading.text(headingFor(ctx.state.stay_length)); 1443 1444 // Highlight the currently-active range if the user has picked a 1445 // specific chip (post-submit on a re-loaded page would also surface it). 1446 var activeCi = ctx.state.pickedRange ? ctx.state.pickedRange.checkin : null; 1447 var activeCo = ctx.state.pickedRange ? ctx.state.pickedRange.checkout : null; 1448 if (activeCi) { 1449 $list.find(".hrtk-fs-form-strip-chip").each(function () { 1450 var $c = $(this); 1451 $c.toggleClass("is-active", String($c.data("checkin")) === activeCi && String($c.data("checkout")) === activeCo); 1452 }); 1453 } 1454 ctx.$formChipStrip.attr("hidden", false); 1455 } 1456 1457 function applyServerChipFilter(ctx, chips, extra) { 1458 // Allow other plugins (e.g. AI Search Enhancer) to enrich chips with 1459 // availability counts on the JS side. They can hook by overriding 1460 // window.hrtkFlexibleSearchEnrichChips (function returning new array). 1461 if (typeof window.hrtkFlexibleSearchEnrichChips === "function") {
1462 try { 1463 var payload = { 1464 stay_length: ctx.state.stay_length, 1465 flex_month: ctx.state.flex_month, 1466 timezone: ctx.tz, 1467 instance_id: ctx.instanceId, 1468 }; 1469 if (extra) $.extend(payload, extra); 1470 var enriched = window.hrtkFlexibleSearchEnrichChips(chips, payload); 1471 if (Array.isArray(enriched)) return enriched; 1472 } catch (e) { 1473 // Swallow â keep default chips. 1474 } 1475 } 1476 return chips; 1477 } 1478 1479 function headingFor(stayLength) { 1480 if (stayLength === "weekend") return I18N.altHeadingWeekend || "Suggested weekends:"; 1481 if (stayLength === "1w") return I18N.altHeadingWeek || "Suggested weeks:"; 1482 if (stayLength === "2w") return I18N.altHeadingTwoWeek || "Suggested 2-week stays:"; 1483 if (stayLength === "1m") return I18N.altHeadingMonth || "Suggested months:"; 1484 return ""; 1485 } 1486 1487 // ---------- date sync ---------- 1488 1489 function writeDates(ctx, checkin, checkout) { 1490 var $checkin = ctx.$modal.find('input[name="checkin"]'); 1491 var $checkout = ctx.$modal.find('input[name="checkout"]'); 1492 $checkin.val(checkin); 1493 $checkout.val(checkout); 1494 1495 if (checkin) { 1496 $(document).trigger("homelocal_range_calendar_set_checkin_explorer-" + ctx.instanceId, [checkin]); 1497 } 1498 if (checkout) { 1499 $(document).trigger("homelocal_range_calendar_set_checkout_explorer-" + ctx.instanceId, [checkout]); 1500 } 1501 1502 updateLabelAndNights(ctx, checkin, checkout); 1503 } 1504 1505 function updateLabelAndNights(ctx, checkin, checkout) { 1506 var $label = ctx.$form.find(".hfse-form-date-input"); 1507 var $nights = ctx.$form.find(".hfse-form-nights-count-text"); 1508 if (!checkin || !checkout) { 1509 $label.val(""); 1510 $nights.text(""); 1511 return; 1512 } 1513 var L = window.luxon && window.luxon.DateTime; 1514 if (!L) return; 1515 var ci = L.fromISO(checkin, { zone: ctx.tz }); 1516 var co = L.fromISO(checkout, { zone: ctx.tz }); 1517 if (!ci.isValid || !co.isValid) return; 1518 var nights = Math.max(0, Math.round(co.diff(ci, "days").days)); 1519 1520 // Adaptive label: 1521 // - flex without a specific picked range â natural language 1522 // ("2 weeks in July") 1523 // - flex with a picked range OR exact â concrete date range 1524 // ("Jul 04 - Jul 11") 1525 // The property proxy in watchDateLabel mirrors this rule, so any later 1526 // writes from Explorer's own code self-heal too. 1527 if (ctx.state && ctx.state.flex === "flexible" && !ctx.state.pickedRange) { 1528 $label.val(naturalLanguageLabel(ctx.state.stay_length, ctx.state.flex_month, ctx.tz)); 1529 } else { 1530 $label.val(ci.toFormat("LLL d") + " - " + co.toFormat("LLL d")); 1531 } 1532 1533 var settings = window.homelocal_explorer_settings || {}; 1534 var nightWord = nights === 1 ? (settings.datepicker && settings.datepicker.night) || "night" : (settings.datepicker && settings.datepicker.nights) || "nights"; 1535 $nights.text(nights + " " + nightWord); 1536 } 1537 1538 // Mirror flex state into the form's hidden inputs so it survives Explorer's 1539 // submit-and-redirect cycle. Disabled inputs are skipped by the form 1540 // serializer, keeping URLs clean in exact mode. 1541 function syncHiddenInputs(ctx) { 1542 if (!ctx.$hiddenFlex) return; 1543 if (ctx.state.flex === "flexible") { 1544 ctx.$hiddenFlex.val("flexible").prop("disabled", false); 1545 ctx.$hiddenStay.val(ctx.state.stay_length).prop("disabled", false); 1546 ctx.$hiddenMonth.val(ctx.state.flex_month).prop("disabled", false); 1547 } else { 1548 ctx.$hiddenFlex.val("").prop("disabled", true); 1549 ctx.$hiddenStay.val("").prop("disabled", true); 1550 ctx.$hiddenMonth.val("").prop("disabled", true); 1551 } 1552 } 1553 1554 function naturalLanguageLabel(stayLength, flexMonth, tz) { 1555 var stay = stayPhrase(stayLength); 1556 if (flexMonth === ANYTIME) { 1557 return stay + ", " + (I18N.anytime || "anytime").toLowerCase(); 1558 } 1559 var dt = dtFromMonth(flexMonth, tz); 1560 if (!dt || !dt.isValid) return stay; 1561 var thisYear = (nowInZone(tz) || dt).year; 1562 var monthName = dt.toFormat("LLLL"); 1563 if (dt.year !== thisYear) { 1564 return stay + " in " + monthName + " " + dt.year; 1565 } 1566 return stay + " in " + monthName; 1567 } 1568 1569 function stayPhrase(slug) { 1570 if (slug === "weekend") return I18N.weekendStay || "Weekend stay"; 1571 if (slug === "1w") return I18N.oneWeek || "1 week";
1572 if (slug === "2w") return I18N.twoWeeks || "2 weeks"; 1573 if (slug === "1m") return I18N.oneMonth || "1 month"; 1574 return slug; 1575 } 1576 1577 /** 1578 * Explorer's `getRedirectUrlParams` filters submitted fields against its 1579 * `options.default_params` whitelist, which strips our flex params on 1580 * redirect. Rather than try to intercept Explorer's submit (which can be 1581 * triggered through several internal paths â calendar events, footer 1582 * Apply, AJAX timer), we build the URL ourselves and navigate directly 1583 * for flex commits. Form fields are taken from serializeArray() so all 1584 * other filter values (location, guests, etc.) are preserved. 1585 */ 1586 /** 1587 * On grid=0, Apply commits state to the form and waits for the user to 1588 * click SEARCH. When that submit fires we intercept it and run our 1589 * canonical redirect builder so flex params survive Explorer's URL 1590 * filter. Element-level handlers run before delegated handlers on the 1591 * container, so this beats Explorer's `app.onFormSubmit`. 1592 */ 1593 function bindFlexSubmit(ctx) { 1594 if (!ctx.$filters || !ctx.$filters.length) return; 1595 ctx.$filters.on("submit.hrtkfs", function (e) { 1596 if (!ctx.state || ctx.state.flex !== "flexible") return; 1597 e.preventDefault(); 1598 e.stopPropagation(); 1599 e.stopImmediatePropagation(); 1600 flexRedirect(ctx); 1601 }); 1602 } 1603 1604 function flexRedirect(ctx) { 1605 var $filters = ctx.$filters; 1606 if (!$filters || !$filters.length) return; 1607 var action = $filters.attr("action") || window.location.pathname; 1608 var url; 1609 try { 1610 url = new URL(action, window.location.origin); 1611 } catch (err) { 1612 url = new URL(window.location.href); 1613 } 1614 url.search = ""; 1615 $filters.serializeArray().forEach(function (kv) { 1616 if (!kv.value) return;
1617 if (kv.name === "flex" || kv.name === "stay_length" || kv.name === "flex_month") return; 1618 url.searchParams.append(kv.name, kv.value); 1619 }); 1620 url.searchParams.set("flex", "flexible"); 1621 url.searchParams.set("stay_length", ctx.state.stay_length); 1622 url.searchParams.set("flex_month", ctx.state.flex_month); 1623 var range = ctx.state.pickedRange || representativeRange(ctx.state.stay_length, ctx.state.flex_month, ctx.tz); 1624 if (range) { 1625 url.searchParams.set("checkin", range.checkin); 1626 url.searchParams.set("checkout", range.checkout); 1627 } 1628 window.location.href = url.toString(); 1629 } 1630 1631 function closeModal(ctx) { 1632 // The hk-modal pattern uses an element with the [hk-close] attribute. 1633 // Click it; if absent, fall back to dispatching the modal lib's hide 1634 // event and as a last resort hide via attribute toggle. 1635 var $close = ctx.$dialog.find("[hk-close]").first(); 1636 if (!$close.length) $close = ctx.$modal.find("[hk-close], .hk-modal-close-default, .hk-modal-close").first(); 1637 if ($close.length) { 1638 $close.trigger("click"); 1639 return; 1640 } 1641 ctx.$modal.removeClass("hk-open").trigger("hide.hk.modal"); 1642 ctx.$modal.attr("hidden", true); 1643 } 1644 1645 // ---------- discovery + lifecycle ---------- 1646 1647 function escapeHtml(s) { 1648 return String(s) 1649 .replace(/&/g, "&") 1650 .replace(/</g, "<") 1651 .replace(/>/g, ">") 1652 .replace(/"/g, """) 1653 .replace(/'/g, "'"); 1654 } 1655 1656 function discoverInstances() { 1657 var ids = []; 1658 var shortcodes = window.homerunner_explorer_shortcodes || []; 1659 shortcodes.forEach(function (sc) { 1660 if (sc && sc.config && sc.config.id) ids.push(sc.config.id); 1661 }); 1662 // Also scan DOM for any additional .hfse-form instances (manual embeds). 1663 $(".hfse-form").each(function () { 1664 var $form = $(this); 1665 var match = $form.attr("id") && $form.attr("id").match(/explorer-([\w-]+)/); 1666 if (match && ids.indexOf(match[1]) < 0) ids.push(match[1]); 1667 var modalId = $form.find('[id^="hfse-form-date-modal-"]').attr("id"); 1668 if (modalId) { 1669 var idPart = modalId.replace("hfse-form-date-modal-", ""); 1670 if (idPart && ids.indexOf(idPart) < 0) ids.push(idPart); 1671 } 1672 }); 1673 return ids; 1674 } 1675 1676 function attachAll() { 1677 // Explorer-toggle feature off (#609): skip the classic attach sweep 1678 // entirely â no toggle/panel injection, no hidden flex inputs. The 1679 // formless watcher below claims those containers instead, so the 1680 // suggestions feature keeps working off the URL alone. 1681 if (TOGGLE_ON) { 1682 var ids = discoverInstances(); 1683 ids.forEach(function (id) { 1684 var $modal = $("#hfse-form-date-modal-" + id); 1685 if (!$modal.length) return; 1686 var $form = $modal.closest(".hfse-form-container, .homerunner-explorer, body").find(".hfse-form").first(); 1687 if (!$form.length) { 1688 $form = $('.hfse-form').filter(function () { 1689 return $(this).find('#hfse-form-date-modal-' + id).length > 0; 1690 }).first(); 1691 } 1692 if (!$form.length) return; 1693 attach(id, $form); 1694 }); 1695 } 1696 // After the classic attaches have claimed their containers. 1697 attachFormlessEmptyStates(); 1698 } 1699 1700 /** 1701 * SBP-driven results pages (#595): the SearchbarPro bar replaces the 1702 * Explorer's native form, so attach() never runs and no empty-state 1703 * watcher registers. The grid still renders, so key off the properties 1704 * container directly. State comes from the URL â the SBP explorer bridge 1705 * pins flex/stay_length/flex_month back into every URL the Explorer 1706 * writes, and dated searches carry checkin/checkout. Runs after the 1707 * classic sweep, so containers already claimed by a full attach (flagged 1708 * in watchEmptyState) are skipped; it also covers classic pages where 1709 * attach() bailed on unrecognized modal markup. 1710 */ 1711 function attachFormlessEmptyStates() { 1712 // Neither URL-driven feature on (#609) â nothing this watcher renders. 1713 if (!SUGGEST_ON && !NOTICE_ON) return; 1714 $(".hfse-properties-container").each(function () { 1715 var $propsContainer = $(this); 1716 if ($propsContainer.data("hrtkFsEmptyWatch")) return; 1717 var tz = getTimezone(); 1718 watchEmptyState({ tz: tz, state: hydrateState(tz), formless: true }, $propsContainer); 1719 }); 1720 } 1721 1722 function watchLateInstances() { 1723 if (typeof MutationObserver === "undefined") return; 1724 var observer = new MutationObserver(function (mutations) { 1725 var saw = false;
1726 for (var i = 0; i < mutations.length; i++) { 1727 var m = mutations[i]; 1728 for (var j = 0; j < m.addedNodes.length; j++) { 1729 var node = m.addedNodes[j]; 1730 if (node && node.nodeType === 1) { 1731 if (node.matches && (node.matches(".hfse-form, .hfse-properties-container") || node.querySelector(".hfse-form, .hfse-properties-container"))) { 1732 saw = true; 1733 break; 1734 } 1735 } 1736 } 1737 if (saw) break; 1738 } 1739 if (saw) attachAll(); 1740 }); 1741 observer.observe(document.body, { childList: true, subtree: true }); 1742 } 1743 1744 $(function () { 1745 attachAll(); 1746 watchLateInstances(); 1747 }); 1748})(jQuery, window.hrtkFlexibleSearch);
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.