1function rentalCurrentLocale() { 2 return window.I18n?.locale || document.documentElement.lang || 'es' 3} 4 5function localeVariants(locale) { 6 const normalized = (locale || '').toString().replace('_', '-') 7 const base = normalized.split('-')[0] 8 return [normalized, base].filter((value, index, arr) => value && arr.indexOf(value) === index) 9} 10 11function rentalI18nNode() { 12 const i18n = window.I18n 13 if (!i18n) return null 14 15 const locales = localeVariants(rentalCurrentLocale()) 16 for (const locale of locales) { 17 if (i18n[locale]) return i18n[locale] 18 } 19 20 return null 21} 22 23function rentalText(path, fallback) { 24 const root = rentalI18nNode() 25 if (!root) return fallback 26 27 const value = path.split('.').reduce((node, key) => (node && node[key] != null ? node[key] : null), root) 28 return typeof value === 'string' ? value : fallback 29} 30 31function rentalTextArray(path, fallback) { 32 const root = rentalI18nNode() 33 if (!root) return fallback 34 35 const value = path.split('.').reduce((node, key) => (node && node[key] != null ? node[key] : null), root) 36 return Array.isArray(value) ? value : fallback 37} 38 39const DEFAULT_RENTAL_DATES_ERROR_MESSAGE = rentalText('store.rental.messages.dates_required', 'Debes seleccionar las fechas de alquiler.') 40const DEFAULT_RENTAL_HOURS_ERROR_MESSAGE = rentalText('store.rental.messages.hours_invalid', 'La hora de fin debe ser posterior a la hora de inicio.') 41const RENTAL_CALENDAR_WINDOW_DAYS = 28 42const RENTAL_WEEKDAY_LABELS = rentalTextArray('store.rental.calendar.weekday_labels', ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom']) 43const RENTAL_TIME_STEP_MINUTES = 30 44const RENTAL_DAY_MINUTES = 24 * 60 45 46export function rentalDateElements(rootElement, formSelector) { 47 if (!rootElement) return { startDateInput: null, endDateInput: null, form: null } 48 49 return { 50 rangeInput: rootElement.querySelector('input[name="rental_date_time_range"]'), 51 startAtInput: rootElement.querySelector('input[name="rental_start_at"]'), 52 endAtInput: rootElement.querySelector('input[name="rental_end_at"]'), 53 startDateInput: rootElement.querySelector('input[name="rental_start_day"]'), 54 endDateInput: rootElement.querySelector('input[name="rental_end_day"]'), 55 startTimeInput: rootElement.querySelector('[name="rental_start_time"]'), 56 endTimeInput: rootElement.querySelector('[name="rental_end_time"]'), 57 form: rootElement.querySelector(formSelector) 58 } 59} 60 61function parseTimeToMinutes(timeValue) { 62 if (!timeValue || !/^\d{2}:\d{2}$/.test(timeValue)) return null 63 const [hours, minutes] = timeValue.split(':').map((value) => Number(value)) 64 if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null 65 return (hours * 60) + minutes 66} 67 68function normalizeMinutesToStep(minutes) { 69 if (!Number.isFinite(minutes)) return null 70 const bounded = Math.max(0, Math.min(RENTAL_DAY_MINUTES - RENTAL_TIME_STEP_MINUTES, minutes)) 71 return Math.floor(bounded / RENTAL_TIME_STEP_MINUTES) * RENTAL_TIME_STEP_MINUTES 72} 73 74function formatMinutesToTime(minutes) { 75 if (!Number.isFinite(minutes)) return null 76 const clamped = Math.max(0, Math.min(RENTAL_DAY_MINUTES - RENTAL_TIME_STEP_MINUTES, minutes)) 77 const hours = Math.floor(clamped / 60) 78 const mins = clamped % 60 79 return `${String(hours).padStart(2, '0')}
79:${String(mins).padStart(2, '0')}` 80} 81 82function buildAllowedTimeValues(minMinutes, maxMinutes) { 83 const values = [] 84 const normalizedMin = normalizeMinutesToStep(minMinutes) 85 const normalizedMax = normalizeMinutesToStep(maxMinutes) 86 if (!Number.isFinite(normalizedMin) || !Number.isFinite(normalizedMax) || normalizedMin > normalizedMax) { 87 return values 88 } 89 90 for (let cursor = normalizedMin; cursor <= normalizedMax; cursor += RENTAL_TIME_STEP_MINUTES) { 91 values.push(formatMinutesToTime(cursor)) 92 } 93 94 return values 95} 96 97function allowedTimesFromInput(inputEl) { 98 if (!inputEl || inputEl.tagName !== 'INPUT') return [] 99 100 const minFromDataset = parseTimeToMinutes(inputEl.dataset.minTime) 101 const maxFromDataset = parseTimeToMinutes(inputEl.dataset.maxTime) 102 const minMinutes = Number.isFinite(minFromDataset) ? minFromDataset : 0 103 const maxMinutes = Number.isFinite(maxFromDataset) ? maxFromDataset : (RENTAL_DAY_MINUTES - RENTAL_TIME_STEP_MINUTES) 104 105 return buildAllowedTimeValues(minMinutes, maxMinutes) 106} 107 108function ensureTimeSuggestionsBox(inputEl) { 109 if (!inputEl || inputEl.tagName !== 'INPUT') return null 110 111 let box = inputEl.parentElement?.querySelector('.rental-time-suggestions-box') 112 if (box) return box 113 114 const parent = inputEl.parentElement 115 if (!parent) return null 116 117 const currentParentPosition = window.getComputedStyle(parent).position 118 if (currentParentPosition === 'static') { 119 parent.style.position = 'relative' 120 } 121 122 box = document.createElement('div') 123 box.className = 'rental-time-suggestions-box' 124 box.style.position = 'absolute' 125 box.style.top = '100%' 126 box.style.left = '0' 127 box.style.right = '0' 128 box.style.maxHeight = '220px' 129 box.style.overflowY = 'auto' 130 box.style.background = '#fff' 131 box.style.border = '1px solid #ced4da' 132 box.style.borderRadius = '0.375rem' 133 box.style.marginTop = '4px' 134 box.style.boxShadow = '0 4px 12px rgba(0,0,0,0.12)' 135 box.style.zIndex = '1200' 136 box.style.display = 'none' 137 138 parent.appendChild(box) 139 return box 140} 141 142function renderTimeSuggestions(inputEl, filterText = '') { 143 const box = ensureTimeSuggestionsBox(inputEl) 144 if (!box) return 145 146 const values = allowedTimesFromInput(inputEl) 147 const normalizedFilter = (filterText || '').trim().toLowerCase() 148 const filteredValues = normalizedFilter 149 ? values.filter((value) => value.toLowerCase().includes(normalizedFilter)) 150 : values 151 152 box.innerHTML = '' 153 if (filteredValues.length === 0) { 154 box.style.display = 'none' 155 return 156 } 157 158 filteredValues.forEach((value) => { 159 const optionEl = document.createElement('button') 160 optionEl.type = 'button' 161 optionEl.textContent = value 162 optionEl.style.display = 'block' 163 optionEl.style.width = '100%' 164 optionEl.style.textAlign = 'left' 165 optionEl.style.background = 'transparent' 166 optionEl.style.border = '0' 167 optionEl.style.padding = '8px 10px' 168 optionEl.style.fontSize = '0.95rem' 169 optionEl.style.cursor = 'pointer' 170 171 optionEl.addEventListener('mouseenter', () => { 172 optionEl.style.background = '#f8f9fa' 173 }) 174 175 optionEl.addEventListener('mouseleave', () => { 176 optionEl.style.background = 'transparent' 177 }) 178 179 optionEl.addEventListener('mousedown', (event) => { 180 event.preventDefault() 181 inputEl.value = value 182 inputEl.dispatchEvent(new Event('change', { bubbles: true })) 183 box.style.display = 'none' 184 }) 185 186 box.appendChild(optionEl) 187 }) 188 189 box.style.display = 'block' 190} 191 192function hideTimeSuggestions(inputEl) { 193 const box = inputEl?.parentElement?.querySelector('.rental-time-suggestions-box') 194 if (box) box.style.display = 'none' 195} 196 197function setupTimeInputSuggestionBehavior(startTimeInput, endTimeInput) { 198 ;[startTimeInput, endTimeInput].forEach((inputEl) => { 199 if (!inputEl || inputEl.tagName !== 'INPUT') return 200 if (inputEl.dataset.rentalSuggestionsBound === '1') return 201 202 inputEl.dataset.rentalSuggestionsBound = '1' 203 inputEl.addEventListener('focus', () => renderTimeSuggestions(inputEl, '')) 204 inputEl.addEventListener('click', () => renderTimeSuggestions(inputEl, '')) 205 inputEl.addEventListener('input', () => renderTimeSuggestions(inputEl, inputEl.value)) 206 inputEl.addEventListener('blur', () => { 207 setTimeout(() => hideTimeSuggestions(inputEl), 120) 208 }) 209 inputEl.addEventListener('keydown', (event) => { 210 if (event.key === 'Escape') { 211 hideTimeSuggestions(inputEl) 212 } 213 }) 214 }) 215} 216 217function firstEnabledTimeOption(selectEl) { 218 if (!selectEl) return null 219 const options = Array.from(selectEl.options || []) 220 const firstEnabled = options.find((option) => !option.disabled) 221 return firstEnabled?.value || null 222} 223 224function setTimeSelectBounds(selectEl, minMinutes, maxMinutes) { 225 if (!selectEl) return 226 227 const normalizedMin = normalizeMinutesToStep(minMinutes) 228 const normalizedMax = normalizeMinutesToStep(maxMinutes) 229 230 if (selectEl.tagName === 'INPUT') { 231 const values = buildAllowedTimeValues(normalizedMin, normalizedMax) 232 233 selectEl.dataset.minTime = formatMinutesToTime(normalizedMin) || '' 234 selectEl.dataset.maxTime = formatMinutesToTime(normalizedMax) || '' 235 236 const typedMinutes = parseTimeToMinutes(selectEl.value) 237 if (!selectEl.value) return 238 239 if (typedMinutes == null || typedMinutes < normalizedMin || typedMinutes > normalizedMax) { 240 selectEl.value = values[0] || '' 241 } 242 243 if (document.activeElement === selectEl) { 244 renderTimeSuggestions(selectEl, selectEl.value) 245 } 246 247 return 248 } 249 250 const options = Array.from(selectEl.options || []) 251 252 options.forEach((option) => { 253 if (option.value === '') { 254 option.disabled = false 255 return 256 } 257 258 const optionMinutes = parseTimeToMinutes(option.value) 259 if (optionMinutes == null) { 260 option.disabled = true 261 return 262 } 263 264 option.disabled = optionMinutes < normalizedMin || optionMinutes > normalizedMax 265 }) 266 267 const selectedMinutes = parseTimeToMinutes(selectEl.value) 268 if (!selectEl.value) return 269 270 if (selectedMinutes == null || selectedMinutes < normalizedMin || selectedMinutes > normalizedMax) { 271 const fallback = firstEnabledTimeOption(selectEl) 272 selectEl.value = fallback || '' 273 } 274} 275 276function syncRentalTimeSelectBounds(startDateInput, endDateInput, startTimeInput, endTimeInput, options = {}) { 277 if (!startTimeInput || !endTimeInput) return 278 279 const minimumDurationRaw = Number(options.minimumDurationMinutes) 280 const minimumDurationMinutes = Number.isFinite(minimumDurationRaw) && minimumDurationRaw > 0 281 ? minimumDurationRaw 282 : RENTAL_TIME_STEP_MINUTES 283 284 const sameDay = !!startDateInput?.value && !!endDateInput?.value && startDateInput.value === endDateInput.value 285 const configuredStart = parseTimeToMinutes(startTimeInput.dataset.allowedStartTime || endTimeInput.dataset.allowedStartTime) 286 const configuredEnd = parseTimeToMinutes(startTimeInput.dataset.allowedEndTime || endTimeInput.dataset.allowedEndTime) 287 const baseMin = Number.isFinite(configuredStart) ? configuredStart : 0 288 const fallbackMax = RENTAL_DAY_MINUTES - RENTAL_TIME_STEP_MINUTES 289 const baseMax = Number.isFinite(configuredEnd) ? configuredEnd : fallbackMax 290 const absoluteStartMax = Math.max(baseMin, baseMax - RENTAL_TIME_STEP_MINUTES) 291 const absoluteEndMin = Math.min(baseMax, baseMin + RENTAL_TIME_STEP_MINUTES) 292 293 // Inicio siempre manda: nunca se limita por la hora de fin. 294 setTimeSelectBounds(startTimeInput, baseMin, absoluteStartMax) 295 296 if (!sameDay) { 297 setTimeSelectBounds(endTimeInput, baseMin, baseMax) 298 return 299 } 300 301 const startMinutes = parseTimeToMinutes(startTimeInput.value) 302 const endMin = startMinutes == null ? absoluteEndMin : Math.min(startMinutes + minimumDurationMinutes, baseMax) 303 setTimeSelectBounds(endTimeInput, endMin, baseMax) 304} 305 306function shouldResetEndTimeOnStartChange(startDateInput, endDateInput) { 307 return !!startDateInput?.value && !!endDateInput?.value && startDateInput.value === endDateInput.value 308} 309 310export function rentalDatesErrorMessage(formElement) { 311 return formElement?.dataset?.rentalDatesErrorMessage || DEFAULT_RENTAL_DATES_ERROR_MESSAGE 312} 313 314function rentalHoursErrorMessage(formElement) { 315 return formElement?.dataset?.rentalTimeErrorMessage || DEFAULT_RENTAL_HOURS_ERROR_MESSAGE 316} 317
318function rentalValidationFeedbackElement(rootElement, anchorElement) { 319 let feedbackEl = rootElement.querySelector('.rental-availability-feedback') 320 if (!feedbackEl) { 321 feedbackEl = document.createElement('div') 322 feedbackEl.className = 'rental-availability-feedback mt-2' 323 feedbackEl.style.cssText = 'font-size:0.9rem;' 324 anchorElement?.closest('.col-12, .col-md-6, div')?.insertAdjacentElement('afterend', feedbackEl) || 325 anchorElement?.insertAdjacentElement('afterend', feedbackEl) 326 } 327 328 return feedbackEl 329} 330 331function combineRentalDateAndTime(dateValue, timeValue, fallbackTime = '00:00') { 332 if (!dateValue) return null 333 const time = (timeValue || fallbackTime || '00:00').trim() 334 const normalizedTime = /^\d{2}:\d{2}$/.test(time) ? time : fallbackTime 335 return `${dateValue}T${normalizedTime}` 336} 337 338function formatDateComponent(value) { 339 return String(value).padStart(2, '0') 340} 341 342function formatDateTimeLocal(dateObj) { 343 if (!(dateObj instanceof Date) || Number.isNaN(dateObj.getTime())) return '' 344 345 const year = dateObj.getFullYear()
346 const month = formatDateComponent(dateObj.getMonth() + 1) 347 const day = formatDateComponent(dateObj.getDate()) 348 const hour = formatDateComponent(dateObj.getHours()) 349 const minute = formatDateComponent(dateObj.getMinutes()) 350 return `${year}-${month}-${day}T${hour}:${minute}` 351} 352 353function formatDateTimeEs(dateObj) { 354 if (!(dateObj instanceof Date) || Number.isNaN(dateObj.getTime())) return '' 355 356 const day = formatDateComponent(dateObj.getDate()) 357 const month = formatDateComponent(dateObj.getMonth() + 1) 358 const year = dateObj.getFullYear() 359 const hour = formatDateComponent(dateObj.getHours()) 360 const minute = formatDateComponent(dateObj.getMinutes()) 361 return `${day}/${month}/${year} ${hour}:${minute}` 362} 363 364function parseRentalDateTime(rawValue) { 365 if (!rawValue) return null 366 const parsed = new Date(rawValue) 367 if (Number.isNaN(parsed.getTime())) return null 368 return parsed 369} 370 371function applyRangeSelectionToFields(elements, startDateObj, endDateObj = null) { 372 const { 373 startDateInput, 374 endDateInput, 375 startTimeInput, 376 endTimeInput, 377 startAtInput, 378 endAtInput 379 } = elements 380 381 if (startDateObj) { 382 const startIso = formatDateTimeLocal(startDateObj) 383 if (startAtInput) startAtInput.value = startIso 384 if (startDateInput) startDateInput.value = startIso.slice(0, 10) 385 if (startTimeInput) startTimeInput.value = startIso.slice(11, 16) 386 } 387 388 if (endDateObj) { 389 const endIso = formatDateTimeLocal(endDateObj) 390 if (endAtInput) endAtInput.value = endIso 391 if (endDateInput) endDateInput.value = endIso.slice(0, 10) 392 if (endTimeInput) endTimeInput.value = endIso.slice(11, 16) 393 return 394 } 395 396 if (endAtInput) endAtInput.value = '' 397 if (endTimeInput) endTimeInput.value = '' 398} 399 400function rentalDateTimesAreValid(startDateInput, endDateInput, startTimeInput, endTimeInput, startAtInput = null, endAtInput = null, minimumDurationMinutes = RENTAL_TIME_STEP_MINUTES) { 401 if (!startDateInput || !endDateInput) return true 402 403 const normalizedMinimumDuration = Number.isFinite(Number(minimumDurationMinutes)) && Number(minimumDurationMinutes) > 0 404 ? Number(minimumDurationMinutes) 405 : RENTAL_TIME_STEP_MINUTES 406 407 const startDate = startDateInput.value 408 const endDate = endDateInput.value 409 const startTime = startTimeInput?.value 410 const endTime = endTimeInput?.value 411 412 let startAtValue = null 413 let endAtValue = null 414 415 if (startDate && endDate && startTime && endTime) { 416 startAtValue = combineRentalDateAndTime(startDate, startTime, '') 417 endAtValue = combineRentalDateAndTime(endDate, endTime, '') 418 } else { 419 startAtValue = startAtInput?.value 420 endAtValue = endAtInput?.value 421 } 422 423 if (startAtValue && endAtValue) { 424 const parsedStart = parseRentalDateTime(startAtValue) 425 const parsedEnd = parseRentalDateTime(endAtValue) 426 if (!parsedStart || !parsedEnd) return false 427 return (parsedEnd.getTime() - parsedStart.getTime()) >= (normalizedMinimumDuration * 60 * 1000) 428 } 429 430 if (!startDate || !endDate) return true 431 432 if (!startTime || !endTime) return false 433 434 const startValue = combineRentalDateAndTime(startDate, startTime, '') 435 const endValue = combineRentalDateAndTime(endDate, endTime, '') 436 if (!startValue || !endValue) return true 437 const parsedStart = parseRentalDateTime(startValue) 438 const parsedEnd = parseRentalDateTime(endValue) 439 if (!parsedStart || !parsedEnd) return false 440 441 return (parsedEnd.getTime() - parsedStart.getTime()) >= (normalizedMinimumDuration * 60 * 1000) 442} 443 444function setValidationMessage(feedbackEl, message, invalid = false) { 445 if (!feedbackEl) return 446 if (!message) { 447 feedbackEl.innerHTML = '' 448 return 449 } 450 451 const color = invalid ? '#842029' : '#1e6a4e' 452 const icon = invalid ? 'â' : 'â ' 453 feedbackEl.innerHTML = `<span style="color:${color};font-weight:600;">${icon} ${message}</span>` 454} 455 456function sanitizeRentalUrl(rawUrl) { 457 const normalized = (rawUrl || '').toString().trim() 458 if (!normalized) return '' 459 return normalized.replace(/^['\"]+|['\"]+$/g, '') 460} 461 462export function setupRentalDateValidation(rootElement, formSelector, onInvalidSubmit = (event) => event.preventDefault()) { 463 const { 464 rangeInput, 465 startDateInput, 466 endDateInput, 467 startTimeInput, 468 endTimeInput, 469 startAtInput, 470 endAtInput, 471 form 472 } = rentalDateElements(rootElement, formSelector) 473 if (!startDateInput || !endDateInput || !form) return 474 const feedbackEl = rentalValidationFeedbackElement(rootElement, rangeInput || endTimeInput || endDateInput) 475 setupTimeInputSuggestionBehavior(startTimeInput, endTimeInput) 476 const updateEndDateMin = () => syncRentalEndDateMin(startDateInput, endDateInput) 477 478 const syncTimeBounds = () => syncRentalTimeSelectBounds(startDateInput, endDateInput, startTimeInput, endTimeInput, { minimumDurationMinutes: RENTAL_TIME_STEP_MINUTES }) 479 480 startDateInput.addEventListener('change', () => { 481 updateEndDateMin() 482 syncTimeBounds() 483 }) 484 endDateInput.addEventListener('change', syncTimeBounds) 485 updateEndDateMin() 486 syncTimeBounds() 487 488 const validateTimeRange = () => { 489 const valid = rentalDateTimesAreValid(startDateInput, endDateInput, startTimeInput, endTimeInput, startAtInput, endAtInput, RENTAL_TIME_STEP_MINUTES) 490 if (valid) { 491 startTimeInput?.setCustomValidity('') 492 endTimeInput?.setCustomValidity('') 493 if (!feedbackEl?.dataset?.availabilityState) { 494 setValidationMessage(feedbackEl, null) 495 } 496 return true 497 } 498 499 const message = rentalHoursErrorMessage(form) 500 startTimeInput?.setCustomValidity(message) 501 endTimeInput?.setCustomValidity(message) 502 setValidationMessage(feedbackEl, message, true) 503 return false 504 } 505 506 const onStartTimeChange = () => { 507 if (shouldResetEndTimeOnStartChange(startDateInput, endDateInput) && endTimeInput?.value) { 508 endTimeInput.value = '' 509 } 510 if (shouldResetEndTimeOnStartChange(startDateInput, endDateInput) && endAtInput) { 511 endAtInput.value = '' 512 } 513 514 syncTimeBounds() 515 validateTimeRange() 516 } 517 518 const onEndTimeChange = () => { 519 syncTimeBounds() 520 validateTimeRange() 521 } 522 523 startTimeInput?.addEventListener('change', onStartTimeChange) 524 endTimeInput?.addEventListener('change', onEndTimeChange) 525 startAtInput?.addEventListener('change', validateTimeRange) 526 endAtInput?.addEventListener('change', validateTimeRange) 527 528 form.addEventListener('submit', (event) => { 529 const datesValid = rentalDatesAreValid(startDateInput, endDateInput, rentalDatesErrorMessage(form), feedbackEl) 530 const timesValid = validateTimeRange() 531 if (!datesValid || !timesValid) { 532 onInvalidSubmit(event) 533 if (rangeInput) { 534 rangeInput.reportValidity() 535 } else { 536 startDateInput.reportValidity() 537 endDateInput.reportValidity() 538 endTimeInput?.reportValidity() 539 } 540 } 541 }) 542} 543 544export function syncRentalEndDateMin(startDateInput, endDateInput) { 545 if (!startDateInput || !endDateInput) return 546 547 endDateInput.min = startDateInput.value || '' 548 if (startDateInput.value && endDateInput.value && endDateInput.value < startDateInput.value) { 549 endDateInput.value = '' 550 } 551} 552 553export function rentalDatesAreValid(startDateInput, endDateInput, errorMessage = DEFAULT_RENTAL_DATES_ERROR_MESSAGE, feedbackEl = null) { 554 if (!startDateInput || !endDateInput) return true 555 556 const startDate = startDateInput.value 557 const endDate = endDateInput.value 558 if (!startDate || !endDate) { 559 startDateInput.setCustomValidity(errorMessage) 560 endDateInput.setCustomValidity(errorMessage) 561 setValidationMessage(feedbackEl, errorMessage, true) 562 return false 563 } 564 565 if (endDate < startDate) { 566 startDateInput.setCustomValidity(errorMessage) 567 endDateInput.setCustomValidity(errorMessage) 568 setValidationMessage(feedbackEl, errorMessage, true) 569 return false 570 } 571 572 startDateInput.setCustomValidity('') 573 endDateInput.setCustomValidity('') 574 575 return true 576} 577 578export async function checkRentalAvailability(variantId, startValue, endValue, availabilityUrl, requestedUnits = 1) { 579 const sanitizedAvailabilityUrl = sanitizeRentalUrl(availabilityUrl) 580 if (!variantId || !startValue || !endValue || !sanitizedAvailabilityUrl) return null 581 if (endValue < startValue) return null 582 583 try { 584 const normalizedUnits = Number(requestedUnits) 585 const safeUnits = Number.isFinite(normalizedUnits) && normalizedUnits > 0 ? Math.floor(normalizedUnits) : 1 586 const url = `${sanitizedAvailabilityUrl}?variant_id=${encodeURIComponent(variantId)}&start_at=${encodeURIComponent(startValue)}&end_at=${encodeURIComponent(endValue)}&quantity=${encodeURIComponent(safeUnits)}` 587 const response = await fetch(url, { headers: { 'Accept': 'application/json' } }) 588 if (!response.ok) return null 589 return await response.json() 590 } catch (_) { 591 return null 592 } 593} 594 595export async function fetchReservedRanges(variantId, reservedRangesUrl) { 596 const sanitizedReservedRangesUrl = sanitizeRentalUrl(reservedRangesUrl) 597 if (!variantId || !sanitizedReservedRangesUrl) return [] 598 599 try { 600 const url = `${sanitizedReservedRangesUrl}?variant_id=${encodeURIComponent(variantId)}` 601 const response = await fetch(url, { headers: { 'Accept': 'application/json' } }) 602 if (!response.ok) return [] 603 604 const payload = await response.json() 605 if (!Array.isArray(payload?.ranges)) return [] 606 607 return payload.ranges 608 .filter((range) => range?.start_date && range?.end_date) 609 .map((range) => ({ start_date: range.start_date, end_date: range.end_date })) 610 } catch (_) { 611 return [] 612 } 613} 614 615function overlapsReservedRanges(startDate, endDate, ranges) { 616 if (!startDate || !endDate || !Array.isArray(ranges) || ranges.length === 0) return false 617 618 return ranges.some((range) => { 619 const rangeStart = range.start_date 620 const rangeEnd = range.end_date 621 if (!rangeStart || !rangeEnd) return false 622 623 return startDate <= rangeEnd && endDate >= rangeStart 624 }) 625} 626 627function findConflictingRange(startDate, endDate, ranges) { 628 if (!startDate || !endDate || !Array.isArray(ranges) || ranges.length === 0) return null 629 630 return ranges.find((range) => { 631 const rangeStart = range.start_date 632 const rangeEnd = range.end_date 633 if (!rangeStart || !rangeEnd) return false 634 635 return startDate <= rangeEnd && endDate >= rangeStart 636 }) || null 637} 638 639function findRangeContainingDate(dateValue, ranges) {
640 if (!dateValue || !Array.isArray(ranges) || ranges.length === 0) return null 641 642 return ranges.find((range) => { 643 const rangeStart = range.start_date 644 const rangeEnd = range.end_date 645 if (!rangeStart || !rangeEnd) return false 646 647 return dateValue >= rangeStart && dateValue <= rangeEnd 648 }) || null 649} 650 651function renderReservedRanges(rangesEl, ranges) { 652 if (!rangesEl) return 653 rangesEl.innerHTML = '' 654} 655 656function formatIsoDateByLocale(isoDate) { 657 if (!isoDate) return '' 658 659 const locale = document.documentElement.lang || 'es' 660 const formatter = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }) 661 const parsed = new Date(`${isoDate}T00:00:00`) 662 if (Number.isNaN(parsed.getTime())) return isoDate 663 664 return formatter.format(parsed) 665} 666 667export function setupRentalAvailabilityCheck(rootElement, formSelector, availabilityUrl, reservedRangesUrl = null) { 668 const { 669 rangeInput, 670 startAtInput, 671 endAtInput, 672 startDateInput, 673 endDateInput, 674 startTimeInput, 675 endTimeInput, 676 form 677 } = rentalDateElements(rootElement, formSelector) 678 if (!startDateInput || !endDateInput || !form) return 679 setupTimeInputSuggestionBehavior(startTimeInput, endTimeInput) 680 const sanitizedAvailabilityUrl = sanitizeRentalUrl(availabilityUrl) 681 const sanitizedReservedRangesUrl = sanitizeRentalUrl(reservedRangesUrl) 682 683 const feedbackEl = rentalValidationFeedbackElement(rootElement, rangeInput || endTimeInput || endDateInput) 684 685 let reservedRangesEl = rootElement.querySelector('.rental-reserved-ranges-feedback') 686 if (!reservedRangesEl) { 687 reservedRangesEl = document.createElement('div') 688 reservedRangesEl.className = 'rental-reserved-ranges-feedback' 689 feedbackEl.insertAdjacentElement('afterend', reservedRangesEl) 690 } 691 692 const priceDisplayEl = rootElement.querySelector('[data-product-target="price"], [data-product-preview-target="price"]') 693 const initialPriceDisplayHtml = priceDisplayEl ? priceDisplayEl.innerHTML : null 694 const rootData = rootElement.dataset || {} 695 const dataText = (datasetKey, fallback) => { 696 const value = rootData[datasetKey] 697 return (typeof value === 'string' && value.trim() !== '') ? value : fallback 698 } 699 const parseWeekdayLabels = () => { 700 const raw = rootData.rentalWeekdayLabels 701 if (!raw) return RENTAL_WEEKDAY_LABELS 702 703 try { 704 const parsed = JSON.parse(raw) 705 return Array.isArray(parsed) && parsed.length > 0 ? parsed : RENTAL_WEEKDAY_LABELS 706 } catch (_) { 707 return RENTAL_WEEKDAY_LABELS 708 } 709 } 710 711 const pendingAcceptMessage = dataText('rentalPendingAcceptMessage', rentalText('store.rental.messages.pending_accept', 'Pulsa Aceptar en el calendario para confirmar el rango.')) 712 const outOfRangeDaysMessage = dataText('rentalOutOfRangeDaysMessage', rentalText('store.rental.messages.range_has_unavailable_days', 'El periodo elegido incluye dias no disponibles. Selecciona otro rango.')) 713 const loadingAvailabilityMessage = dataText('rentalLoadingAvailabilityMessage', rentalText('store.rental.messages.checking_availability', 'Comprobando disponibilidad...')) 714 const unavailableDatesMessage = dataText('rentalUnavailableDatesMessage', rentalText('store.rental.messages.unavailable_dates', 'Fechas no disponibles')) 715 const availableDatesMessage = dataText('rentalAvailableDatesMessage', rentalText('store.rental.messages.available_dates', 'Fechas disponibles')) 716 const remainingUnitsTemplate = dataText('rentalRemainingUnitsTemplate', rentalText('store.rental.messages.remaining_units', '(quedan %{count})')) 717 const previousWeeksAriaLabel = dataText('rentalPreviousWeeksAriaLabel', rentalText('store.rental.calendar.previous_weeks', 'Semanas anteriores')) 718 const nextWeeksAriaLabel = dataText('rentalNextWeeksAriaLabel', rentalText('store.rental.calendar.next_weeks', 'Semanas siguientes')) 719 const perDaySuffix = dataText('rentalPerDaySuffix', rentalText('store.rental.messages.per_day_suffix', '/dia')) 720 const depositLabel = dataText('rentalDepositLabel', rentalText('store.rental.messages.deposit_label', 'Deposito')) 721 const weekdayLabels = parseWeekdayLabels() 722 const defaultStartTime = dataText('rentalDefaultStartTime', '') || startTimeInput?.dataset?.allowedStartTime || '' 723 const defaultEndTime = dataText('rentalDefaultEndTime', '') || endTimeInput?.dataset?.allowedEndTime || '' 724 let rentalIntervalConfig = { unit: 'day', value: 1 }
725 let calendarConfigInvalid = false 726 let calendarConfigErrorMessage = '' 727 728 const minimumIntervalDurationMinutes = () => { 729 const unit = (rentalIntervalConfig?.unit || 'day').toString().toLowerCase() 730 const value = Number.isFinite(Number(rentalIntervalConfig?.value)) && Number(rentalIntervalConfig.value) > 0 731 ? Number(rentalIntervalConfig.value) 732 : 1 733 734 if (unit === 'hour') return Math.max(RENTAL_TIME_STEP_MINUTES, value * 60) 735 736 // Para intervalos por dias, la duracion minima se controla con las fechas. 737 // No bloqueamos el calculo por diferencia horaria para permitir recalculo de precio. 738 return RENTAL_TIME_STEP_MINUTES 739 } 740 741 const syncIntervalAwareTimeBounds = () => { 742 syncRentalTimeSelectBounds(startDateInput, endDateInput, startTimeInput, endTimeInput, { 743 minimumDurationMinutes: minimumIntervalDurationMinutes() 744 }) 745 } 746 747 const intervalUnitLabel = (unit, value) => { 748 const currentLocale = (rentalCurrentLocale() || 'es').toLowerCase() 749 const baseLocale = currentLocale.split('-')[0] 750 const normalizedValue = Number.isFinite(Number(value)) && Number(value) > 0 ? Math.floor(Number(value)) : 1 751 const normalizedUnit = (unit || '').toString().toLowerCase() 752 753 const labels = { 754 es: { day: ['dia', 'dias'], hour: ['hora', 'horas'] }, 755 en: { day: ['day', 'days'], hour: ['hour', 'hours'] }, 756 de: { day: ['Tag', 'Tage'], hour: ['Stunde', 'Stunden'] }, 757 fr: { day: ['jour', 'jours'], hour: ['heure', 'heures'] }, 758 it: { day: ['giorno', 'giorni'], hour: ['ora', 'ore'] }, 759 pt: { day: ['dia', 'dias'], hour: ['hora', 'horas'] } 760 } 761 762 const localized = labels[baseLocale] || labels.es 763 const key = normalizedUnit === 'hour' ? 'hour' : 'day' 764 const [singular, plural] = localized[key] 765 return normalizedValue === 1 ? singular : plural 766 } 767 768 const currentIntervalSuffix = () => { 769 const unit = rentalIntervalConfig?.unit || 'day' 770 const value = Number.isFinite(Number(rentalIntervalConfig?.value)) && Number(rentalIntervalConfig.value) > 0 771 ? Math.floor(Number(rentalIntervalConfig.value)) 772 : 1 773 774 if (unit !== 'hour' && value === 1) return perDaySuffix 775 return `/${value} ${intervalUnitLabel(unit, value)}` 776 } 777 778 const formatCurrencyEs = (value) => { 779 const numeric = Number(value) 780 if (Number.isNaN(numeric)) return '' 781 return `${numeric.toFixed(2).replace('.', ',')} â¬` 782 } 783 784 // Sin fechas: muestra precio por intervalo y depósito de la talla seleccionada (no el snapshot por defecto). 785 const renderSelectedVariantBase = () => { 786 if (!priceDisplayEl) return 787 788 const talla = rootElement.querySelector('input[name="talla"]:checked') 789 || rootElement.querySelector('input[name="talla"][type="hidden"]') 790 || rootElement.querySelector('input[name="talla"]') 791 const dayPrice = talla?.dataset?.rentalDayPrice 792 if (dayPrice == null || dayPrice === '' || Number.isNaN(Number(dayPrice))) { 793 if (initialPriceDisplayHtml != null) priceDisplayEl.innerHTML = initialPriceDisplayHtml 794 return 795 } 796 797 let html = `<span>${formatCurrencyEs(dayPrice)}${currentIntervalSuffix()}</span>` 798 const deposit = talla?.dataset?.rentalDeposit 799 if (deposit != null && deposit !== '' && !Number.isNaN(Number(deposit)) && Number(deposit) > 0) { 800 html += ` <span class="ms-2 fw-normal text-muted" style="font-size: .8em;">${depositLabel}: ${formatCurrencyEs(deposit)}</span>` 801 } 802 priceDisplayEl.innerHTML = html 803 } 804 805 const setRentalTotalDisplay = (subtotal, deposit) => { 806 if (!priceDisplayEl) return 807 808 if (subtotal == null || Number.isNaN(Number(subtotal)) || Number(subtotal) <= 0) { 809 renderSelectedVariantBase() 810 return 811 } 812 813 let html = `<span>${formatCurrencyEs(subtotal)}</span>` 814 if (deposit != null && !Number.isNaN(Number(deposit)) && Number(deposit) > 0) { 815 html += ` <span class="ms-2 fw-normal text-muted" style="font-size: .8em;">${depositLabel}: ${formatCurrencyEs(deposit)}</span>` 816 } 817 priceDisplayEl.innerHTML = html 818 } 819 820 const minimumIntervalHoursErrorMessage = () => { 821 const unit = (rentalIntervalConfig?.unit || 'day').toString().toLowerCase() 822 const value = Number.isFinite(Number(rentalIntervalConfig?.value)) && Number(rentalIntervalConfig.value) > 0 823 ? Number(rentalIntervalConfig.value) 824 : 1 825 826 if (unit !== 'hour') { 827 return rentalHoursErrorMessage(form) 828 } 829 830 const unitLabel = intervalUnitLabel(unit, value) 831 const template = rentalText( 832 'store.rental.messages.minimum_interval_error', 833 'La hora de fin debe respetar el intervalo minimo de %{count} %{unit}.' 834 ) 835 836 return template 837 .replace('%{count}', String(value)) 838 .replace('%{unit}', unitLabel) 839 } 840 841 let debounceTimer = null 842 let nextCheckDelay = null 843 let allowNextSubmit = false
844 let currentReservedRanges = [] 845 const reservedRangesCache = {} 846 let hasPendingRangeSelection = false 847 let rangesLoadedAtLeastOnce = false 848 849 const rangeSelectionKey = (startDateObj, endDateObj) => { 850 if (!startDateObj || !endDateObj) return null 851 852 const startIso = formatDateTimeLocal(startDateObj) 853 const endIso = formatDateTimeLocal(endDateObj) 854 if (!startIso || !endIso) return null 855 856 return `${startIso}|${endIso}` 857 } 858 859 const selectedVariantId = () => { 860 const selectedSize = rootElement.querySelector('input[name="talla"]:checked')?.value 861 if (selectedSize) return selectedSize 862 863 const hiddenSize = rootElement.querySelector('input[name="talla"][type="hidden"]')?.value 864 if (hiddenSize) return hiddenSize 865 866 const checkedVariant = rootElement.querySelector('input[name="variant_id"]:checked')?.value 867 if (checkedVariant) return checkedVariant 868 869 const hiddenVariant = rootElement.querySelector('input[name="variant_id"][type="hidden"]')?.value 870 return hiddenVariant || null 871 } 872 873 const syncHiddenVariantInput = () => { 874 const selected = selectedVariantId() 875 const hiddenVariantInput = rootElement.querySelector('input[name="variant_id"][type="hidden"]') 876 if (!selected || !hiddenVariantInput) return 877 878 hiddenVariantInput.value = selected 879 } 880 881 const parseIsoToDate = (isoDate) => { 882 if (!isoDate) return null 883 const parsed = new Date(`${isoDate}T00:00:00`) 884 if (Number.isNaN(parsed.getTime())) return null 885 return parsed 886 } 887 888 const dateInsideReservedRange = (date) => { 889 if (!Array.isArray(currentReservedRanges) || currentReservedRanges.length === 0) return false 890 891 return currentReservedRanges.some((range) => { 892 const rangeStart = parseIsoToDate(range.start_date) 893 const rangeEnd = parseIsoToDate(range.end_date) 894 if (!rangeStart || !rangeEnd) return false 895 896 return date >= rangeStart && date <= rangeEnd 897 }) 898 } 899 900 const rentalCalendarSelection = { start: null, end: null } 901 let rentalCalendarWindowStart = null 902 let rentalCalendarDays = [] 903 904 const rentalCalendarUrl = () => sanitizeRentalUrl(rootElement.dataset.rentalCalendarUrl) 905 906 const isoToDate = (iso) => { 907 if (!iso) return null 908 const parsed = new Date(`${iso}T00:00:00`) 909 return Number.isNaN(parsed.getTime()) ? null : parsed 910 } 911 912 const dateToIso = (date) => { 913 const year = date.getFullYear()
914 const month = String(date.getMonth() + 1).padStart(2, '0') 915 const day = String(date.getDate()).padStart(2, '0') 916 return `${year}-${month}-${day}` 917 } 918 919 const addDays = (date, amount) => { 920 const next = new Date(date) 921 next.setDate(next.getDate() + amount) 922 next.setHours(0, 0, 0, 0) 923 return next 924 } 925 926 const startOfWeekMonday = (date) => { 927 const base = new Date(date) 928 const weekday = (base.getDay() + 6) % 7 929 base.setDate(base.getDate() - weekday) 930 base.setHours(0, 0, 0, 0) 931 return base 932 } 933 934 const formatCalendarPrice = (value) => { 935 const numeric = Number(value) 936 if (Number.isNaN(numeric)) return '' 937 return `${numeric.toFixed(2).replace('.', ',')} â¬` 938 } 939 940 const capitalize = (text) => (text ? text.charAt(0).toUpperCase() + text.slice(1) : text) 941 942 const monthLabel = (from, to) => { 943 const formatter = new Intl.DateTimeFormat(rentalCurrentLocale(), { month: 'long', year: 'numeric' }) 944 const fromLabel = capitalize(formatter.format(from)) 945 const toLabel = capitalize(formatter.format(to)) 946 return fromLabel === toLabel ? fromLabel : `${fromLabel} â ${toLabel}` 947 } 948 949 const dateIsInSelection = (iso) => { 950 const { start, end } = rentalCalendarSelection 951 if (!start) return false 952 if (!end) return iso === start 953 return iso >= start && iso <= end 954 } 955 956 const isSelectableCalendarState = (state) => state === 'available' || state === 'partial' 957 958 const rangeContainsBlockedDay = (fromIso, toIso) => rentalCalendarDays.some( 959 (day) => day.date > fromIso && day.date < toIso && !isSelectableCalendarState(day.state) 960 ) 961 962 const applyCalendarSelectionToInputs = () => { 963 const { start, end } = rentalCalendarSelection 964 965 if (!start) { 966 if (startAtInput) startAtInput.value = '' 967 if (endAtInput) endAtInput.value = '' 968 if (startDateInput) startDateInput.value = '' 969 if (endDateInput) endDateInput.value = '' 970 if (rangeInput) rangeInput.value = '' 971 syncRentalTimeSelectBounds(startDateInput, endDateInput, startTimeInput, endTimeInput) 972 return 973 } 974 975 const startTime = startTimeInput?.value || defaultStartTime 976 const endTime = endTimeInput?.value || defaultEndTime 977 978 if (startTimeInput && !startTimeInput.value && startTime) startTimeInput.value = startTime 979 if (endTimeInput && !endTimeInput.value && endTime) endTimeInput.value = endTime 980 981 if (startDateInput) startDateInput.value = start 982 if (endDateInput) endDateInput.value = end || '' 983 984 if (startAtInput) { 985 startAtInput.value = startTime ? combineRentalDateAndTime(start, startTime, '') : '' 986 } 987 988 if (endAtInput) { 989 endAtInput.value = (end && endTime) ? combineRentalDateAndTime(end, endTime, '') : '' 990 } 991 992 const startObj = start ? parseRentalDateTime(`${start}T${startTime}`) : null 993 const endObj = end ? parseRentalDateTime(`${end}T${endTime}`) : null 994 995 if (startTime && startObj) { 996 applyRangeSelectionToFields( 997 { startDateInput, endDateInput, startTimeInput, endTimeInput, startAtInput, endAtInput }, 998 startObj, 999 (end && endTime && endObj) ? endObj : null 1000 ) 1001 } 1002 syncRentalEndDateMin(startDateInput, endDateInput) 1003 1004 if (rangeInput) { 1005 if (startObj && endObj) { 1006 rangeInput.value = `${formatDateTimeEs(startObj)} a ${formatDateTimeEs(endObj)}` 1007 } else if (startObj) { 1008 rangeInput.value = formatDateTimeEs(startObj) 1009 } else { 1010 rangeInput.value = '' 1011 } 1012 } 1013 1014 syncIntervalAwareTimeBounds() 1015 } 1016 1017 const onCalendarDayClick = (iso) => { 1018 const selection = rentalCalendarSelection 1019 const hasSingle = selection.start && selection.end && selection.start === selection.end 1020 const hasRange = selection.start && selection.end && selection.start !== selection.end 1021 1022 if (!selection.start || hasRange) { 1023 // Nueva selección: un solo dÃa (ampliable con un segundo clic). 1024 selection.start = iso 1025 selection.end = iso 1026 } else if (hasSingle && iso === selection.start) { 1027 // Reclicar el dÃa ya elegido lo deselecciona. 1028 selection.start = null 1029 selection.end = null 1030 } else if (iso < selection.start) { 1031 // Clic anterior al inicio: reinicia en ese dÃa. 1032 selection.start = iso 1033 selection.end = iso 1034 } else if (rangeContainsBlockedDay(selection.start, iso)) { 1035 setValidationMessage(feedbackEl, outOfRangeDaysMessage, true) 1036 // Retrasa la comprobación para que el aviso no se sobrescriba de inmediato. 1037 nextCheckDelay = 3000 1038 selection.start = iso 1039 selection.end = iso 1040 } else { 1041 // AmplÃa la selección a un rango. 1042 selection.end = iso 1043 } 1044 1045 applyCalendarSelectionToInputs()
1046 renderRentalCalendar() 1047 1048 if (selection.start && selection.end) { 1049 scheduleCheck() 1050 } else { 1051 setRentalTotalDisplay(null) 1052 } 1053 } 1054 1055 const buildCalendarDayCell = (date, iso, info) => { 1056 const cell = document.createElement('button') 1057 cell.type = 'button' 1058 cell.className = 'rental-day' 1059 cell.dataset.date = iso 1060 1061 const state = info?.state || 'unavailable' 1062 cell.classList.add(`rental-day--${state}`) 1063 1064 if (iso === rentalCalendarSelection.start) cell.classList.add('rental-day--start') 1065 if (iso === rentalCalendarSelection.end) cell.classList.add('rental-day--end') 1066 if (dateIsInSelection(iso)) cell.classList.add('rental-day--in-range') 1067 1068 const number = document.createElement('span') 1069 number.className = 'rental-day__num' 1070 number.textContent = date.getDate() 1071 cell.appendChild(number) 1072 1073 if (info?.price != null && isSelectableCalendarState(state)) { 1074 const price = document.createElement('span') 1075 price.className = 'rental-day__price' 1076 price.textContent = `${formatCalendarPrice(info.price)}${currentIntervalSuffix()}` 1077 cell.appendChild(price) 1078 } 1079 1080 if (isSelectableCalendarState(state)) { 1081 cell.addEventListener('click', () => onCalendarDayClick(iso)) 1082 } else { 1083 cell.disabled = true 1084 } 1085 1086 return cell 1087 } 1088 1089 const fetchCalendarDays = async (variantId, fromIso, toIso) => { 1090 const url = rentalCalendarUrl() 1091 if (!url || !variantId) return { days: [], intervalUnit: 'day', intervalValue: 1, message: null } 1092 1093 try { 1094 const requestUrl = `${url}?variant_id=${encodeURIComponent(variantId)}&start_date=${encodeURIComponent(fromIso)}&end_date=${encodeURIComponent(toIso)}` 1095 const response = await fetch(requestUrl, { headers: { 'Accept': 'application/json' } }) 1096 if (!response.ok) return { days: [], intervalUnit: 'day', intervalValue: 1, message: null } 1097 1098 const payload = await response.json() 1099 const days = Array.isArray(payload?.days) ? payload.days : [] 1100 const intervalUnit = (payload?.interval_unit || 'day').toString() 1101 const intervalValue = Number.isFinite(Number(payload?.interval_value)) && Number(payload.interval_value) > 0 1102 ? Math.floor(Number(payload.interval_value)) 1103 : 1 1104 const message = (typeof payload?.message === 'string' && payload.message.trim() !== '') ? payload.message : null 1105 1106 return { days: days, intervalUnit: intervalUnit, intervalValue: intervalValue, message: message } 1107 } catch (_) { 1108 return { days: [], intervalUnit: 'day', intervalValue: 1, message: null } 1109 } 1110 } 1111 1112 const renderRentalCalendar = async () => { 1113 const container = rootElement.querySelector('.rental-calendar') 1114 if (!container) return 1115 1116 if (!rentalCalendarSelection.start && startDateInput?.value) { 1117 rentalCalendarSelection.start = startDateInput.value 1118 } 1119 if (!rentalCalendarSelection.end && endDateInput?.value) { 1120 rentalCalendarSelection.end = endDateInput.value 1121 } 1122 1123 if (!rentalCalendarWindowStart) { 1124 const seedIso = (startAtInput?.value || '').slice(0, 10) || startDateInput?.value || null 1125 const seedDate = seedIso ? isoToDate(seedIso) : null 1126 rentalCalendarWindowStart = startOfWeekMonday(seedDate || new Date()) 1127 } 1128 1129 const windowStart = rentalCalendarWindowStart 1130 const windowEnd = addDays(windowStart, RENTAL_CALENDAR_WINDOW_DAYS - 1) 1131 const variantId = selectedVariantId() 1132 1133 const calendarPayload = await fetchCalendarDays(variantId, dateToIso(windowStart), dateToIso(windowEnd)) 1134 rentalCalendarDays = calendarPayload.days || [] 1135 rentalIntervalConfig = { 1136 unit: (calendarPayload.intervalUnit || 'day').toString().toLowerCase() === 'hour' ? 'hour' : 'day', 1137 value: Number.isFinite(Number(calendarPayload.intervalValue)) && Number(calendarPayload.intervalValue) > 0 1138 ? Math.floor(Number(calendarPayload.intervalValue)) 1139 : 1 1140 } 1141 syncIntervalAwareTimeBounds() 1142 calendarConfigInvalid = Boolean(calendarPayload.message) 1143 calendarConfigErrorMessage = calendarPayload.message || '' 1144 1145 if (calendarConfigInvalid) {
1146 feedbackEl.dataset.availabilityState = 'invalid' 1147 feedbackEl.innerHTML = `<span style="color:#842029;font-weight:600;">â ${calendarConfigErrorMessage}</span>` 1148 setSubmitDisabled(true) 1149 setRentalTotalDisplay(null) 1150 } 1151 const dayMap = {} 1152 rentalCalendarDays.forEach((day) => { dayMap[day.date] = day }) 1153 1154 container.innerHTML = '' 1155 1156 const header = document.createElement('div') 1157 header.className = 'rental-calendar-header' 1158 1159 const prev = document.createElement('button') 1160 prev.type = 'button' 1161 prev.className = 'rental-calendar-nav rental-calendar-prev' 1162 prev.setAttribute('aria-label', previousWeeksAriaLabel) 1163 prev.textContent = 'â¹' 1164 prev.disabled = dateToIso(windowStart) <= dateToIso(startOfWeekMonday(new Date())) 1165 prev.addEventListener('click', () => { 1166 rentalCalendarWindowStart = addDays(windowStart, -RENTAL_CALENDAR_WINDOW_DAYS) 1167 renderRentalCalendar() 1168 }) 1169 1170 const label = document.createElement('div') 1171 label.className = 'rental-calendar-label' 1172 label.textContent = monthLabel(windowStart, windowEnd) 1173 1174 const next = document.createElement('button') 1175 next.type = 'button' 1176 next.className = 'rental-calendar-nav rental-calendar-next' 1177 next.setAttribute('aria-label', nextWeeksAriaLabel) 1178 next.textContent = 'âº' 1179 next.addEventListener('click', () => { 1180 rentalCalendarWindowStart = addDays(windowStart, RENTAL_CALENDAR_WINDOW_DAYS)
1181 renderRentalCalendar() 1182 }) 1183 1184 header.append(prev, label, next) 1185 container.appendChild(header) 1186 1187 const weekdaysRow = document.createElement('div') 1188 weekdaysRow.className = 'rental-calendar-weekdays' 1189 weekdayLabels.forEach((weekday) => { 1190 const cell = document.createElement('div') 1191 cell.className = 'rental-calendar-weekday' 1192 cell.textContent = weekday 1193 weekdaysRow.appendChild(cell) 1194 }) 1195 container.appendChild(weekdaysRow) 1196 1197 const grid = document.createElement('div') 1198 grid.className = 'rental-calendar-grid' 1199 for (let index = 0; index < RENTAL_CALENDAR_WINDOW_DAYS; index += 1) { 1200 const date = addDays(windowStart, index) 1201 const iso = dateToIso(date) 1202 grid.appendChild(buildCalendarDayCell(date, iso, dayMap[iso])) 1203 } 1204 container.appendChild(grid) 1205 1206 rootElement.querySelector('.rental-datetime-range-wrapper')?.classList.add('rental-calendar-ready') 1207 } 1208 1209 const clearEndDateSelection = () => { 1210 rentalCalendarSelection.start = null 1211 rentalCalendarSelection.end = null 1212 if (startAtInput) startAtInput.value = '' 1213 if (endAtInput) endAtInput.value = '' 1214 if (startDateInput) startDateInput.value = '' 1215 if (endDateInput) endDateInput.value = '' 1216 if (rangeInput) rangeInput.value = '' 1217 syncRentalTimeSelectBounds(startDateInput, endDateInput, startTimeInput, endTimeInput)
1218 renderRentalCalendar() 1219 } 1220 1221 const syncDatePickers = async () => { 1222 await renderRentalCalendar() 1223 } 1224 1225 const loadReservedRanges = async () => { 1226 if (!sanitizedReservedRangesUrl) { 1227 currentReservedRanges = [] 1228 renderReservedRanges(reservedRangesEl, currentReservedRanges) 1229 await syncDatePickers() 1230 return 1231 } 1232 1233 const variantId = selectedVariantId() 1234 if (!variantId) { 1235 currentReservedRanges = [] 1236 renderReservedRanges(reservedRangesEl, currentReservedRanges) 1237 await syncDatePickers() 1238 return 1239 } 1240 1241 syncHiddenVariantInput() 1242 1243 if (!reservedRangesCache[variantId]) { 1244 reservedRangesCache[variantId] = await fetchReservedRanges(variantId, sanitizedReservedRangesUrl) 1245 } 1246 1247 currentReservedRanges = reservedRangesCache[variantId] || [] 1248 rangesLoadedAtLeastOnce = true 1249 renderReservedRanges(reservedRangesEl, currentReservedRanges) 1250 await syncDatePickers() 1251 } 1252 1253 const ensureRangesLoaded = async () => { 1254 if (rangesLoadedAtLeastOnce) return 1255 await loadReservedRanges() 1256 } 1257 1258 const enforceSingleDateAvailability = (inputEl) => { 1259 if (!inputEl) return true 1260 1261 inputEl.setCustomValidity('') 1262 return true 1263 } 1264 1265 const submitControls = () => Array.from( 1266 form.querySelectorAll('button[type="submit"], input[type="submit"], #direct-to-checkout') 1267 ) 1268 1269 const setSubmitDisabled = (disabled) => { 1270 submitControls().forEach((element) => { 1271 element.classList.toggle('disabled', disabled) 1272 element.setAttribute('aria-disabled', disabled ? 'true' : 'false') 1273 if ('disabled' in element) element.disabled = disabled 1274 }) 1275 } 1276 1277 const clearAvailabilityState = () => { 1278 startDateInput.setCustomValidity('') 1279 endDateInput.setCustomValidity('') 1280 startTimeInput?.setCustomValidity('') 1281 endTimeInput?.setCustomValidity('') 1282 feedbackEl.dataset.availabilityState = '' 1283 setSubmitDisabled(false) 1284 } 1285 1286 const markUnavailable = (message) => { 1287 const finalMessage = message || unavailableDatesMessage 1288 startDateInput.setCustomValidity(finalMessage) 1289 endDateInput.setCustomValidity(finalMessage) 1290 startTimeInput?.setCustomValidity(finalMessage) 1291 endTimeInput?.setCustomValidity(finalMessage) 1292 feedbackEl.dataset.availabilityState = 'invalid' 1293 setSubmitDisabled(true) 1294 } 1295 1296 const applyAvailabilityResult = (result) => { 1297 if (!result || result.available == null) { 1298 clearAvailabilityState() 1299 return 1300 } 1301 1302 if (result.available) { 1303 clearAvailabilityState() 1304 return 1305 } 1306 1307 markUnavailable(result.message) 1308 } 1309 1310 const doCheck = async () => { 1311 if (calendarConfigInvalid) { 1312 feedbackEl.dataset.availabilityState = 'invalid' 1313 feedbackEl.innerHTML = `<span style="color:#842029;font-weight:600;">â ${calendarConfigErrorMessage || unavailableDatesMessage}</span>` 1314 setSubmitDisabled(true) 1315 setRentalTotalDisplay(null) 1316 return null 1317 } 1318 1319 const startDate = startDateInput.value 1320 const endDate = endDateInput.value 1321 const startTime = startTimeInput?.value || defaultStartTime 1322 const endTime = endTimeInput?.value || defaultEndTime 1323 if (startTimeInput && !startTimeInput.value && startTime) startTimeInput.value = startTime 1324 if (endTimeInput && !endTimeInput.value && endTime) endTimeInput.value = endTime 1325 const startValue = combineRentalDateAndTime(startDate, startTime, '') 1326 const endValue = combineRentalDateAndTime(endDate, endTime, '') 1327 1328 if (startAtInput) startAtInput.value = startValue || '' 1329 if (endAtInput) endAtInput.value = endValue || '' 1330 const variantId = selectedVariantId() 1331 const requestedUnits = Number(form?.querySelector('input[name="quantity"]')?.value || 1) 1332 const timeRangeValid = rentalDateTimesAreValid( 1333 startDateInput, 1334 endDateInput, 1335 startTimeInput, 1336 endTimeInput, 1337 startAtInput, 1338 endAtInput, 1339 minimumIntervalDurationMinutes() 1340 ) 1341 1342 if (!startDate || !endDate || !startTime || !endTime || !startValue || !endValue || !timeRangeValid) { 1343 feedbackEl.innerHTML = '' 1344 clearAvailabilityState() 1345 setRentalTotalDisplay(null) 1346 return null 1347 } 1348 1349 feedbackEl.dataset.availabilityState = 'loading' 1350 feedbackEl.innerHTML = `<span style="color:#888;">â³ ${loadingAvailabilityMessage}</span>` 1351 1352 if (!sanitizedAvailabilityUrl) { 1353 clearAvailabilityState() 1354 return { available: true } 1355 } 1356 1357 const result = await checkRentalAvailability(variantId, startValue, endValue, sanitizedAvailabilityUrl, requestedUnits) 1358 if (!result) { 1359 feedbackEl.innerHTML = '' 1360 clearAvailabilityState() 1361 setRentalTotalDisplay(null) 1362 return null 1363 } 1364 1365 if (result.available) { 1366 feedbackEl.dataset.availabilityState = 'valid' 1367 const remaining = Number(result.remaining) 1368 const remainingText = Number.isFinite(remaining) ? ` ${remainingUnitsTemplate.replace('%{count}', remaining)}` : '' 1369 feedbackEl.innerHTML = `<span style="color:#1e6a4e;font-weight:600;">â ${availableDatesMessage}${remainingText}</span>` 1370 setRentalTotalDisplay(result.subtotal, result.deposit) 1371 } else {
1372 feedbackEl.dataset.availabilityState = 'invalid' 1373 feedbackEl.innerHTML = `<span style="color:#842029;font-weight:600;">â ${result.message || unavailableDatesMessage}</span>` 1374 setRentalTotalDisplay(null) 1375 } 1376 1377 applyAvailabilityResult(result) 1378 return result 1379 } 1380 1381 const scheduleCheck = () => { 1382 clearTimeout(debounceTimer) 1383 const delay = nextCheckDelay === null ? 400 : nextCheckDelay 1384 nextCheckDelay = null 1385 debounceTimer = setTimeout(doCheck, delay) 1386 } 1387 1388 startDateInput.addEventListener('change', async () => { 1389 await ensureRangesLoaded() 1390 if (!enforceSingleDateAvailability(startDateInput)) return 1391 scheduleCheck() 1392 }) 1393 1394 endDateInput.addEventListener('change', async () => { 1395 await ensureRangesLoaded() 1396 if (!enforceSingleDateAvailability(endDateInput)) return 1397 scheduleCheck() 1398 }) 1399 const onEndRentalTimeChange = () => { 1400 // Recalcula rental_start_at/rental_end_at con la hora nueva y vuelve a comprobar. 1401 if (!rentalCalendarSelection.start && startDateInput?.value) { 1402 rentalCalendarSelection.start = startDateInput.value 1403 rentalCalendarSelection.end = endDateInput?.value || startDateInput.value 1404 } 1405 if (rentalCalendarSelection.start) { 1406 applyCalendarSelectionToInputs() 1407 } 1408 scheduleCheck() 1409 } 1410 1411 const onStartRentalTimeChange = () => { 1412 if (shouldResetEndTimeOnStartChange(startDateInput, endDateInput) && endTimeInput?.value) { 1413 endTimeInput.value = '' 1414 } 1415 if (shouldResetEndTimeOnStartChange(startDateInput, endDateInput) && endAtInput) { 1416 endAtInput.value = '' 1417 } 1418 1419 onEndRentalTimeChange() 1420 } 1421 1422 startTimeInput?.addEventListener('change', onStartRentalTimeChange) 1423 endTimeInput?.addEventListener('change', onEndRentalTimeChange) 1424 rootElement.addEventListener('change', async (event) => { 1425 const target = event.target 1426 if (!target || !target.matches('input[name="talla"], input[name="variant_id"]')) return 1427 1428 rangesLoadedAtLeastOnce = false 1429 await loadReservedRanges() 1430 scheduleCheck() 1431 }) 1432 1433 rootElement.addEventListener('rental:variant-changed', async () => { 1434 rangesLoadedAtLeastOnce = false 1435 await loadReservedRanges() 1436 scheduleCheck() 1437 }) 1438 1439 loadReservedRanges() 1440 1441 form.addEventListener('submit', async (event) => { 1442 if (allowNextSubmit) { 1443 allowNextSubmit = false 1444 return 1445 } 1446 1447 if (!startDateInput.value || !endDateInput.value) return 1448 1449 if (rangeInput && hasPendingRangeSelection) { 1450 event.preventDefault() 1451 rangeInput.setCustomValidity(pendingAcceptMessage) 1452 setValidationMessage(feedbackEl, pendingAcceptMessage, true) 1453 rangeInput.reportValidity() 1454 return 1455 } 1456 1457 if (!rentalDateTimesAreValid( 1458 startDateInput, 1459 endDateInput, 1460 startTimeInput, 1461 endTimeInput, 1462 startAtInput, 1463 endAtInput, 1464 minimumIntervalDurationMinutes() 1465 )) { 1466 event.preventDefault() 1467 const message = minimumIntervalHoursErrorMessage() 1468 endTimeInput?.setCustomValidity(message) 1469 setValidationMessage(feedbackEl, message, true) 1470 if (rangeInput) { 1471 rangeInput.reportValidity() 1472 } else { 1473 endTimeInput?.reportValidity() 1474 } 1475 return 1476 } 1477 1478 event.preventDefault() 1479 const result = await doCheck() 1480 1481 if (result && result.available) { 1482 allowNextSubmit = true 1483 form.requestSubmit() 1484 return 1485 } 1486 1487 if (result && result.available === false) { 1488 endDateInput.reportValidity() 1489 } 1490 }) 1491};
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.