PageSourceSearch

https://mountaincabinsutah.com/wp-content/plugins/homerunner-toolkit/assets/js/flexible-search.js?ver=3.309.18

js mountaincabinsutah.com collected 2026-10-10 02:02:26 UTC 72,726 bytes, 1,748 lines download raw bytes

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">&times;</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, "&amp;")
1650      .replace(/</g, "&lt;")
1651      .replace(/>/g, "&gt;")
1652      .replace(/"/g, "&quot;")
1653      .replace(/'/g, "&#39;");
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.