1/** 2 * Searchbar Pro â date rules for the date picker. 3 * 4 * Pure functions extracted so the logic is testable in Node 5 * (tests/js/searchbar-pro-date-rules.test.mjs), same contract as 6 * edms-empty.js: UMD-ish wrapper, ES5 syntax, dependency-free. 7 * 8 * Mirrors what the plugin's own explorer range calendar enforces 9 * (homerunner-plugin range-calendar.js dayClicked over calendar-utils.js, 10 * as patched by the toolkit's edms-frontend.js and anb-frontend.js): 11 * 12 * - booking window: dates after today + max_num_days are unavailable 13 * (calendar-utils.js _calculateUnavailability); 14 * - advance notice: check-in dates before the server-computed earliest 15 * check-in are blocked (anb-frontend.js, Advance Notice Blocking); 16 * - minimum stay: the first selectable check-out is check-in + minimum, 17 * where the minimum is the per-date inventory aggregate when the 18 * Explorer Dynamic Min Stay module supplies one, else the site-wide 19 * Explorer minimum, else 1 night. 20 * 21 * blockReason() applies them in the core picker's precedence. 22 */ 23(function (root, factory) { 24 if (typeof module === 'object' && module.exports) { 25 module.exports = factory(); 26 } else { 27 root.hrtkSbpDateRules = factory(); 28 } 29})(typeof self !== 'undefined' ? self : this, function () { 30 31 var ISO_RE = /^\d{4}-\d{2}-\d{2}$/; 32 var DAY_MS = 86400000; 33 // calendar-utils.js caps _calculateMinstay at 100 nights; edms-frontend.js 34 // applies the same cap to the aggregate. 35 var MAX_MIN_STAY = 100; 36 37 /** 38 * Strict ISO calendar date: the `YYYY-MM-DD` shape AND a real day of 39 * that month. Shape alone is not enough â Date.UTC silently rolls 40 * `2026-13-01` into January and `2026-02-30` into March, and the URL 41 * hydration path hands this visitor-shaped input to the formatter. 42 */ 43 function isIso(v) { 44 if (!ISO_RE.test(v || '')) { return false; } 45 var y = parseInt(v.slice(0, 4), 10); 46 var m = parseInt(v.slice(5, 7), 10); 47 var d = parseInt(v.slice(8, 10), 10); 48 var t = new Date(Date.UTC(y, m - 1, d)); 49 return !isNaN(t.getTime()) && t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d; 50 } 51 52 /** Positive integer night count from a localized value (may be a string), else 0. */ 53 function nights(value) { 54 var n = parseInt(value, 10); 55 return n > 0 ? Math.min(n, MAX_MIN_STAY) : 0; 56 } 57 58 /** 59 * Minimum stay for a check-in date. 60 * 61 * @param {string} checkin ISO check-in date. 62 * @param {{minStay?: number|string, byDate?: Object<string, number|string>}} cfg 63 * @returns {number} At least 1. 64 */ 65 function resolve(checkin, cfg) { 66 cfg = cfg || {}; 67 var byDate = cfg.byDate; 68 if (checkin && byDate && typeof byDate === 'object' && Object.prototype.hasOwnProperty.call(byDate, checkin)) { 69 var perDate = nights(byDate[checkin]); 70 if (perDate > 0) { return perDate; } 71 } 72 return nights(cfg.minStay) || 1; 73 } 74 75 /** ISO date `n` days after `iso` (UTC math, so no DST off-by-one); null for non-ISO input. */ 76 function addDays(iso, n) { 77 if (!isIso(iso)) { return null; } 78 var d = new Date(iso + 'T00:00:00Z'); 79 if (isNaN(d.getTime())) { return null; } 80 var out = new Date(d.getTime() + n * DAY_MS); 81 // An absurd `n` (a corrupted booking-window option) overflows the 82 // timestamp range; toISOString() on that throws, so answer null. 83 if (isNaN(out.getTime())) { return null; } 84 return out.toISOString().slice(0, 10); 85 } 86 87 /** First check-out the minimum allows: check-in + min stay (at least 1 night). */ 88 function firstCheckout(checkin, minStay) { 89 return addDays(checkin, nights(minStay) || 1); 90 } 91 92 /** 93 * Whether `iso` is a night the visitor cannot check out on: strictly 94 * after the check-in and strictly before the first valid check-out. 95 */ 96 function inWindow(iso, checkin, minStay) { 97 if (!isIso(iso) || !isIso(checkin)) { return false; } 98 var first = firstCheckout(checkin, minStay); 99 return !!first && iso > checkin && iso < first; 100 } 101 102 /** 103 * Last selectable date: today + maxDays (the core's max_num_days, which 104 * Explorer.php also passes as max_stay). Null when no positive window is 105 * configured, so the picker keeps its own navigation limit. 106 */ 107 function maxDate(today, maxDays) { 108 var n = parseInt(maxDays, 10); 109 if (!(n > 0) || !isIso(today)) { return null; } 110 return addDays(today, n); 111 } 112 113 /** 114 * Why `iso` cannot be picked right now, in the core picker's order: 115 * 'window' (after the booking window), 'notice' (before the earliest 116 * check-in), 'min-stay' (a check-out short of check-in + minimum while a 117 * check-out is pending), or null. Past dates return null â the picker 118 * already renders those as its own non-interactive disabled state. 119 * 120 * @param {string} iso 121 * @param {{today?: string, maxDate?: string|null, minCheckin?: string|null, 122 * checkin?: string|null, checkout?: string|null, minStay?
122: number|string}} ctx 123 * @returns {'window'|'notice'|'min-stay'|null} 124 */ 125 function blockReason(iso, ctx) { 126 ctx = ctx || {}; 127 if (!isIso(iso)) { return null; } 128 if (isIso(ctx.today) && iso < ctx.today) { return null; } 129 if (isIso(ctx.maxDate) && iso > ctx.maxDate) { return 'window'; } 130 if (isIso(ctx.minCheckin) && iso < ctx.minCheckin) { return 'notice'; } 131 if (ctx.checkin && !ctx.checkout && inWindow(iso, ctx.checkin, ctx.minStay)) { return 'min-stay'; } 132 return null; 133 } 134 135 /** Fill the core calendar's `{night}` token; the strings keep it verbatim for translators. */ 136 function message(template, n) { 137 return String(template == null ? '' : template).replace(/\{night\}/g, String(n)); 138 } 139 140 /** Generic `{key}` substitution (the localized strings keep documented tokens, not printf). */ 141 function fill(template, tokens) { 142 return String(template == null ? '' : template).replace(/\{(\w+)\}/g, function (m, key) { 143 return Object.prototype.hasOwnProperty.call(tokens, key) ? String(tokens[key]) : m; 144 }); 145 } 146 147 /** 148 * The nearest valid stay for a min-stay rejection while picking a 149 * check-out: check-in + minimum (calendar-hover-tips.js suggestRangeStay, 150 * which for the explorer range calendar is exactly this clamp). 151 * 152 * @returns {{checkin: string, checkout: string, nights: number}|null} 153 */ 154 function suggestStay(checkin, minStay) { 155 if (!isIso(checkin)) { return null; } 156 var n = nights(minStay) || 1; 157 var checkout = addDays(checkin, n); 158 return checkout ? { checkin: checkin, checkout: checkout, nights: n } : null; 159 } 160 161 /** 162 * One-tap button label for a suggestion â "Select Sep 05 â Sep 08 · 3 163 * nights" â from the Calendar Hover Tips strings (`select`, `nightOne`, 164 * `nightOther`) and the picker's own day formatter. 165 * 166 * @param {{checkin: string, checkout: string, nights: number}} suggestion 167 * @param {{select?: string, nightOne?: string, nightOther?: string}} i18n 168 * @param {function(string): string} formatDay 169 */ 170 function applyLabel(suggestion, i18n, formatDay) { 171 if (!suggestion) { return ''; } 172 var s = i18n || {}; 173 var nightsText = fill(suggestion.nights === 1 ? (s.nightOne || '{n} night') : (s.nightOther || '{n} nights'), { n: suggestion.nights }); 174 return fill(s.select || 'Select {from} â {to} · {nights}', { 175 from: formatDay(suggestion.checkin), 176 to: formatDay(suggestion.checkout), 177 nights: nightsText, 178 }); 179 } 180 181 var EN_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; 182 var EN_DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 183 184 function pad2(n) { 185 return (n < 10 ? '0' : '') + n; 186 } 187 188 /** 189 * A numeric token the way Luxon renders it: through the locale's own 190 * number formatter, so a locale whose default numbering system is not 191 * Latin (ar-EG, bn-BD) gets its digits â "سبت٠بر Ù Ù¦" for `LLL dd` â and 192 * every Latin locale gets the plain padded ASCII it always got. 193 */ 194 function num(n, pad, locale) { 195 try { 196 if (typeof Intl !== 'undefined' && Intl.NumberFormat) { 197 return new Intl.NumberFormat(locale || undefined, { useGrouping: false, minimumIntegerDigits: pad || 1 }).format(n); 198 } 199 } catch (e) { /* fall through to ASCII */ } 200 return pad === 2 ? pad2(n) : String(n); 201 } 202 203 /** Intl-formatted piece of a UTC date for the locale, with an English fallback. */ 204 function intlPart(date, opts, locale, fallback) { 205 try { 206 if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) { 207 var o = { timeZone: 'UTC' }; 208 for (var k in opts) { if (Object.prototype.hasOwnProperty.call(opts, k)) { o[k] = opts[k]; } } 209 return new Intl.DateTimeFormat(locale || undefined, o).format(date); 210 } 211 } catch (e) { /* fall through to English */ } 212 return fallback; 213 } 214 215 /** 216 * A month or weekday name from Intl in Luxon's two grammatical forms: 217 * standalone (`L`/`c` tokens â the bare name, e.g. Russian "ÑенÑÑбÑÑ")
218 * or format context (`M`/`E` tokens â the name as it reads inside a 219 * date, "6 ÑенÑÑбÑÑ" â "ÑенÑÑбÑÑ"). Luxon builds the context form by 220 * formatting a fuller date and extracting the part; formatToParts does 221 * the same here, with the standalone Intl call as the fallback (the two 222 * forms are identical in English anyway). 223 */ 224 function intlName(date, type, length, formatContext, locale, fallback) { 225 var standalone = {}; 226 standalone[type] = length; 227 if (!formatContext) { return intlPart(date, standalone, locale, fallback); } 228 var opts = { timeZone: 'UTC' }; 229 opts[type] = length; 230 if (type === 'month') { opts.day = 'numeric'; } 231 else { opts.month = 'long'; opts.day = 'numeric'; } 232 try { 233 if (typeof Intl !== 'undefined' && Intl.DateTimeFormat && Intl.DateTimeFormat.prototype.formatToParts) { 234 var parts = new Intl.DateTimeFormat(locale || undefined, opts).formatToParts(date); 235 for (var i = 0; i < parts.length; i++) { 236 if (parts[i].type === type) { return parts[i].value; } 237 } 238 } 239 } catch (e) { /* fall through to the standalone form */ } 240 return intlPart(date, standalone, locale, fallback); 241 } 242 243 /** 244 * Format an ISO date with a Luxon token string â the subset that applies 245 * to a date-only value: d/dd, L/M families (number, padded, short, long, 246 * narrow), E/c weekday families (ISO number, short, long, narrow), 247 * y/yy/yyyy, the D/DD/DDD/DDDD localized macros, and single-quoted 248 * literals. The Explorer's "Date format (js)" setting 249 * (homelocal_explorer_date_format) is written in these tokens. Names come 250 * from Intl for `locale` (the browser default when omitted), so they match 251 * what Luxon renders for the core search bar; digits go through 252 * Intl.NumberFormat for the same reason (ar-EG gets Ù Ù¦). `L`/`c` are Luxon's 253 * standalone name forms and `M`/`E` its format-context forms â the same 254 * in English, distinct in e.g. Russian or Polish. Unknown tokens pass 255 * through unchanged; a non-ISO or impossible (`2026-13-01`) input 256 * returns ''. 257 */ 258 function formatDate(iso, format, locale) { 259 if (!isIso(iso)) { return ''; } 260 var y = parseInt(iso.slice(0, 4), 10); 261 var m = parseInt(iso.slice(5, 7), 10); 262 var d = parseInt(iso.slice(8, 10), 10); 263 var date = new Date(Date.UTC(y, m - 1, d)); 264 if (isNaN(date.getTime())) { return ''; } 265 var fmt = String(format == null || format === '' ? 'LLL dd' : format); 266 var dow = date.getUTCDay(); 267 var isoDow = dow === 0 ? 7 : dow; 268 269 function token(t) { 270 switch (t) { 271 case 'd': return num(d, 1, locale); 272 case 'dd': return num(d, 2, locale); 273 case 'L': case 'M': return num(m, 1, locale); 274 case 'LL': case 'MM': return num(m, 2, locale); 275 case 'LLL': return intlName(date, 'month', 'short', false, locale, EN_MONTHS[m - 1].slice(0, 3)); 276 case 'MMM': return intlName(date, 'month', 'short', true, locale, EN_MONTHS[m - 1].slice(0, 3)); 277 case 'LLLL': return intlName(date, 'month', 'long', false, locale, EN_MONTHS[m - 1]); 278 case 'MMMM': return intlName(date, 'month', 'long', true, locale, EN_MONTHS[m - 1]); 279 case 'LLLLL': return intlName(date, 'month', 'narrow', false, locale, EN_MONTHS[m - 1].charAt(0)); 280 case 'MMMMM': return intlName(date, 'month', 'narrow', true, locale, EN_MONTHS[m - 1].charAt(0)); 281 case 'E': case 'c': return num(isoDow, 1, locale); 282 case 'ccc': return intlName(date, 'weekday', 'short', false, locale, EN_DAYS[dow].slice(0, 3)); 283 case 'EEE': return intlName(date, 'weekday', 'short', true, locale, EN_DAYS[dow].slice(0, 3)); 284 case 'cccc': return intlName(date, 'weekday', 'long', false, locale, EN_DAYS[dow]); 285 case 'EEEE': return intlName(date, 'weekday', 'long', true, locale, EN_DAYS[dow]); 286 case 'ccccc': return intlName(date, 'weekday', 'narrow', false, locale, EN_DAYS[dow].charAt(0)); 287 case 'EEEEE': return intlName(date, 'weekday', 'narrow', true, locale, EN_DAYS[dow].charAt(0)); 288 case 'y': return num(y, 1, locale); 289 case 'yyyy': return num(y, 4, locale); 290 case 'yy': return num(y % 100, 2, locale); 291 case 'D': return intlPart(date, { year: 'numeric', month: 'numeric', day: 'numeric' }, locale, m + '/' + d + '/' + y); 292 case 'DD': return intlPart(date, { year: 'numeric', month: 'short', day: 'numeric' }, locale, EN_MONTHS[m - 1].slice(0, 3) + ' ' + d + ', ' + y); 293 case 'DDD': return intlPart(date, { year: 'numeric', month: 'long', day: 'numeric' }, locale, EN_MONTHS[m - 1] + ' ' + d + ', ' + y); 294 case 'DDDD': return intlPart(date, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }, locale, EN_DAYS[dow] + ', ' + EN_MONTHS[m - 1] + ' ' + d + ', ' + y); 295 default: return t; 296 } 297 } 298 299 var out = ''; 300 var i = 0; 301 while (i < fmt.length) { 302 var ch = fmt.charAt(i); 303 if (ch === "'") { 304 // Luxon (3.7): a doubled quote outside a literal is an apostrophe 305 // ("MMM d ''yy" â "Sep 6 '26"); inside a literal it closes and 306 // reopens ("'o''clock'" â "oclock"); an unterminated quote runs 307 // to the end of the format. 308 if (fmt.charAt(i + 1) === "'") { out += "'"; i += 2; continue; } 309 var end = fmt.indexOf("'", i + 1); 310 if (end === -1) { out += fmt.slice(i + 1); break; } 311 out += fmt.slice(i + 1, end); 312 i = end + 1; 313 continue; 314 } 315 if (!/[a-zA-Z]/.test(ch)) { out += ch; i++; continue; } 316 var j = i; 317 while (j < fmt.length && fmt.charAt(j) === ch) { j++; } 318 out += token(fmt.slice(i, j)); 319 i = j; 320 } 321 return out; 322 } 323 324 /** 325 * Dates an Explorer shortcode presets for its own page (v3.289.1, #858). 326 *
327 * A page can pin its grid to a stay with `[homerunner-explorer checkin=⦠328 * checkout=â¦]`. The server filters by that pair and hands it to the front 329 * end as `shortcode_params` in the Explorer config, with nothing in the 330 * URL. The search bar and the Explorer bridge read dates from the URL 331 * only, so such a page showed a filtered grid under an empty date field 332 * and base "Starting at" pricing. Callers consult this when the URL 333 * carries no dates. 334 * 335 * Accepts one Explorer config, one `{ selector, config }` entry, or a list 336 * of either (window.homerunner_explorer_shortcodes). Returns the first 337 * pair that passes the URL hydration checks: real ISO dates, check-in 338 * before check-out, check-in not before `today`. 339 * 340 * @param {Object|Array} configs Explorer config(s). 341 * @param {string} today YYYY-MM-DD; anything else skips the past check. 342 * @return {{checkin: string, checkout: string}|null} 343 */ 344 function presetRange(configs, today) { 345 var list = Object.prototype.toString.call(configs) === '[object Array]' ? configs : [ configs ]; 346 for (var i = 0; i < list.length; i++) { 347 var entry = list[i]; 348 if (!entry || typeof entry !== 'object') { continue; } 349 var cfg = (entry.config && typeof entry.config === 'object') ? entry.config : entry; 350 var sp = cfg.shortcode_params; 351 if (!sp || typeof sp !== 'object') { continue; } 352 var ci = typeof sp.checkin === 'string' ? sp.checkin : ''; 353 var co = typeof sp.checkout === 'string' ? sp.checkout : ''; 354 if (isIso(ci) && isIso(co) && ci < co && (!isIso(today) || ci >= today)) { 355 return { checkin: ci, checkout: co }; 356 } 357 } 358 return null; 359 } 360 361 return { 362 isIso: isIso, 363 resolve: resolve, 364 addDays: addDays, 365 firstCheckout: firstCheckout, 366 inWindow: inWindow, 367 maxDate: maxDate, 368 blockReason: blockReason, 369 message: message, 370 fill: fill, 371 suggestStay: suggestStay, 372 applyLabel: applyLabel, 373 formatDate: formatDate, 374 presetRange: presetRange, 375 }; 376});
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.