PageSourceSearch

https://lolitamoda.com/assets/store/utils/rental_dates

js lolitamoda.com collected 2026-10-07 07:30:52 UTC 58,960 bytes, 1,491 lines download raw bytes

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.