1/** 2 * HomeRunner Toolkit â Explorer Dynamic Min Stay (frontend). 3 * 4 * Patches HomeLocalCalendarUtils._calculateMinstay so that whenever 5 * a calendar instance has no per-date data for the day being queried 6 * (which is the Explorer's situation, since the inventory-wide picker 7 * never loads a property-specific calendar), the lookup falls through 8 * to a server-computed aggregate of `MIN(min_stay)` across the 9 * inventory for that date. 10 * 11 * Property booking widgets are not affected: they load a per-property 12 * calendar that populates `dateOptions(day)`, and our patch only 13 * consults the aggregate when `dateOptions(day)` is empty. 14 * 15 * Class binding: HomeLocalCalendarUtils is declared as a top-level 16 * `class` in homerunner-plugin/assets/js/calendar-utils.js, which 17 * creates a lexical global, NOT a property of `window`. Bare-name 18 * reference is the working pattern; same lesson as ANB v3.51.2. 19 * 20 * Second concern (3.195.0): a min-stay-aware zero-results state. The 21 * calendar patch above only helps visitors who open the picker; anyone 22 * arriving with checkin/checkout already in the URL (shared link, 23 * bookmark, back button, old email) fires the search unvalidated and 24 * lands on core's generic "No properties found" element. When the 25 * searched nights are below the aggregate minimum for the checkin date 26 * (which guarantees zero results), replace that element with an 27 * explanation and a one-click longer-stay suggestion. Decision logic 28 * lives in edms-empty.js (hrtkEdmsEmpty, a registered dependency); 29 * this file owns only the DOM. 30 * 31 * Config (window.hrtkExplorerMinStay): 32 * - aggregate: { 'YYYY-MM-DD': minStayInt, ... } 33 * - i18n: { title, tryLonger, chipNights } (optional, tokens documented 34 * in Core.php; fallbacks below keep the feature alive without them) 35 */ 36(function () { 37 'use strict'; 38 39 var cfg = window.hrtkExplorerMinStay || {}; 40 var aggregate = (cfg && cfg.aggregate) || {}; 41 if (!aggregate || typeof aggregate !== 'object') { 42 return; 43 } 44 45 function toIsoDate(day) { 46 if (typeof day === 'string') { 47 // Already ISO-ish (YYYY-MM-DD) or close to it. 48 return /^\d{4}-\d{2}-\d{2}/.test(day) ? day.slice(0, 10) : null; 49 } 50 if (day && typeof day.toFormat === 'function') { 51 // Luxon DateTime support. 52 try { 53 return day.toFormat('yyyy-LL-dd'); 54 } catch (e) { 55 /* fall through */ 56 } 57 } 58 if (day && typeof day.getFullYear === 'function') { 59 var y = day.getFullYear(); 60 var m = String(day.getMonth() + 1).padStart(2, '0'); 61 var d = String(day.getDate()).padStart(2, '0'); 62 return y + '-' + m + '-' + d; 63 } 64 return null; 65 } 66 67 function aggregateFor(day) { 68 var key = toIsoDate(day); 69 if (!key || !Object.prototype.hasOwnProperty.call(aggregate, key)) { 70 return 0; 71 } 72 var v = parseInt(aggregate[key], 10); 73 return v > 0 ? v : 0; 74 } 75 76 function patchCalendarUtils() { 77 if (typeof HomeLocalCalendarUtils !== 'function' || !HomeLocalCalendarUtils.prototype) { 78 return false; 79 } 80 if (HomeLocalCalendarUtils.prototype.__hrtkEdmsPatched) { 81 return true; 82 } 83 84 var origCalc = HomeLocalCalendarUtils.prototype._calculateMinstay; 85 HomeLocalCalendarUtils.prototype._calculateMinstay = function (day) { 86 // Try the host's normal path first. 87 var origResult = (typeof origCalc === 'function') ? origCalc.call(this, day) : 1; 88 89 // If this instance has per-date data for `day`, honor it 90 // (property booking widget case â PriceLabs values win). 91 if (typeof this.dateOptions === 'function') { 92 try { 93 var opts = this.dateOptions(day); 94 if (opts) { 95 return origResult; 96 } 97 } catch (e) { 98 return origResult; 99 } 100 } 101 102 // No per-date data on this instance â consult the aggregate. 103 var fromAggregate = aggregateFor(day); 104 if (fromAggregate > 0) { 105 return Math.min(fromAggregate, 100); 106 } 107 return origResult; 108 }; 109 110 HomeLocalCalendarUtils.prototype.__hrtkEdmsPatched = true; 111 return true; 112 } 113 114 /** 115 * Tiny '{key}' template substitution; the localized strings keep 116 * documented tokens instead of printf placeholders (Core.php). 117 */ 118 function tpl(str, tokens) { 119 return String(str).replace(/\{(\w+)\}/g, function (m, key) { 120 return Object.prototype.hasOwnProperty.call(tokens, key) ? String(tokens[key]) : m; 121 }); 122 } 123 124 /** "Aug 21" from '2026-08-21', pinned to UTC so no TZ off-by-one. */ 125 function prettyDate(iso) { 126 try { 127 return new Date(iso + 'T00:00:00Z').toLocaleDateString(undefined, { 128 month: 'short', 129 day: 'numeric', 130 timeZone: 'UTC', 131 }); 132 } catch (e) { 133 return iso; 134 } 135 } 136 137 function renderMinStayEmpty(target, verdict, i18n, logic) { 138 var wrap = document.createElement('div'); 139 wrap.className = 'hrtk-edms-empty'; 140 141 var title = document.createElement('h3'); 142 title.className = 'hrtk-edms-empty-title'; 143 title.textContent = tpl( 144 i18n.title || 'These dates require a minimum stay of {min} nights.', 145 { min: verdict.required } 146 ); 147 wrap.appendChild(title); 148 149 var subtitle = document.createElement('p'); 150 subtitle.className = 'hrtk-edms-empty-subtitle'; 151 subtitle.textContent = i18n.tryLonger || 'Try the same check-in with a longer stay:'; 152 wrap.appendChild(subtitle); 153 154 var chips = document.createElement('div'); 155 chips.className = 'hrtk-edms-empty-chips'; 156 var chip = document.createElement('button'); 157 chip.type = 'button'; 158 chip.className = 'hrtk-edms-empty-chip'; 159 chip.textContent = tpl(i18n.chipNights || '{from} â {to} · {min} nights', { 160 from: prettyDate(verdict.checkin), 161 to: prettyDate(verdict.suggestedCheckout), 162 min: verdict.required, 163 }); 164 chip.addEventListener('click', function () { 165 window.location.href = logic.withCheckout( 166 window.location.href, 167 verdict.suggestedCheckout 168 ); 169 }); 170 chips.appendChild(chip); 171 wrap.appendChild(chips); 172 173 // textContent wipe clears core's generic message; everything above 174 // was built with createElement/textContent, so nothing is injectable. 175 target.textContent = ''; 176 target.appendChild(wrap); 177 } 178 179 /** 180 * The min-stay verdict for the CURRENT live URL, or null when no 181 * claim may be made. Computed fresh on every render: the explorer 182 * pushStates new params on each interactive search (explorer.js 183 * app.updateUrl), so a verdict captured once at load would go stale 184 * the moment the guest changes dates or filters, and a stale claim
185 * is a false claim. 186 */ 187 function currentVerdict(logic) { 188 var search = window.location.search; 189 190 // SBP flexible-dates mode owns the zero-results UX for flex 191 // searches (flexible-search.js replaces the same node); flex 192 // URLs carry checkin/checkout too, so bow out entirely. 193 if (logic.getParam(search, 'flex') === 'flexible') { 194 return null; 195 } 196 197 var params = logic.parseSearch(search); 198 if (!params) { 199 return null; 200 } 201 // Claims read ONLY the certified snapshot pair (claimAggregate + 202 // uncertain, both null until an aggregation run certifies 203 // fleet-complete data); evaluate() fails closed on either. The 204 // picker's cfg.aggregate is deliberately not consulted here â it 205 // may be newer than the snapshot and the pair must stay consistent. 206 var verdict = logic.evaluate(cfg.claimAggregate, params.checkin, params.checkout, cfg.uncertain); 207 if (!verdict || !verdict.suggestedCheckout) { 208 return null; 209 } 210 verdict.checkin = params.checkin; 211 return verdict; 212 } 213 214 function setupEmptyState() { 215 var logic = window.hrtkEdmsEmpty; 216 if ( 217 !logic || 218 typeof logic.getParam !== 'function' || 219 typeof logic.parseSearch !== 'function' || 220 typeof logic.evaluate !== 'function' || 221 typeof logic.withCheckout !== 'function' 222 ) { 223 return; // dependency missing or global collided â enhancement off 224 } 225 if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') { 226 return; 227 } 228 229 var i18n = (cfg && cfg.i18n) || {}; 230 var containers = document.querySelectorAll('.hfse .hfse-properties-container'); 231 Array.prototype.forEach.call(containers, function (container) { 232 var render = function () { 233 var target = container.querySelector('.hfse-properties-no-items'); 234 if (!target) { 235 return; 236 } 237 if (target.querySelector('.hrtk-edms-empty') || target.querySelector('.hrtk-fs-empty')) { 238 return; 239 } 240 var verdict = currentVerdict(logic); 241 if (!verdict) { 242 return; 243 } 244 renderMinStayEmpty(target, verdict, i18n, logic); 245 }; 246 render(); // server-rendered empty state (initial page load) 247 if (typeof MutationObserver === 'function') { 248 new MutationObserver(render).observe(container, { childList: true }); 249 } 250 }); 251 } 252 253 // The empty-state layer is an optional enhancement; the calendar 254 // patch below is this module's load-bearing shipped feature and must 255 // survive ANY failure here (a hostile window.hrtkEdmsEmpty collision 256 // included). Deliberately swallowed: the enhancement degrades to 257 // core's generic message, which is the pre-3.195 status quo. 258 try { 259 setupEmptyState(); 260 } catch (e) { 261 /* intentional â see above */ 262 } 263 264 if (patchCalendarUtils()) { 265 return; 266 } 267 268 // Host script may load fractionally after us; poll briefly. 269 var attempts = 0; 270 var iv = setInterval(function () { 271 attempts++; 272 if (patchCalendarUtils() || attempts > 60) { 273 clearInterval(iv); 274 } 275 }, 150); 276})();
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.