Created
February 6, 2025 23:04
-
-
Save Alejandro-Bernal-M/381bc0f92d89dd699ac22f822e9771cb to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Controller } from "@hotwired/stimulus" | |
export default class extends Controller { | |
static targets = [ "dates", "eventType", "meetingOptions", "SelectedTimeZone", "contactTimeZone", "dateSelectedForMeeting", "noAvailableImage" ] | |
dateSelected(event) { | |
const date = event.target.dataset.date; | |
this.toggleDateClass(event.target); | |
if (event.target.classList.contains('!bg-blue-500')) { | |
this.addDate(date); | |
} else { | |
this.removeDate(date); | |
} | |
} | |
addDate(date) { | |
const target = this.datesTarget; | |
if (!target) { | |
console.error("Target element not found"); | |
return; | |
} | |
const { year, month, day, monthName } = this.parseDate(date); | |
const spanElement = this.createDateSpan(date, day); | |
let monthElement = target.querySelector(`[data-month="${month}"]`); | |
if (monthElement) { | |
this.insertIntoExistingMonth(monthElement, spanElement, day); | |
} else { | |
this.createNewMonthElement(target, monthName, month, spanElement); | |
} | |
this.cleanCommas(); | |
} | |
// Parses the date into components | |
parseDate(date) { | |
const [year, month, day] = date.split("-"); | |
const monthName = new Date(year, month - 1, day).toLocaleString("en-US", { month: "long" }); | |
return { year, month, day, monthName }; | |
} | |
// Creates a span element for the day | |
createDateSpan(date, day) { | |
const spanElement = document.createElement("span"); | |
spanElement.classList.add("text-xs", "dates-span"); | |
spanElement.dataset.day = day; | |
spanElement.dataset.date = date; | |
spanElement.textContent = day; | |
return spanElement; | |
} | |
// Inserts the date span into an existing month element in the correct order | |
insertIntoExistingMonth(monthElement, spanElement, day) { | |
const monthDiv = monthElement.querySelector("div"); | |
let insertPosition = null; | |
Array.from(monthDiv.childNodes).some((child, index) => { | |
if (parseInt(child.dataset.day) > parseInt(day)) { | |
insertPosition = index; | |
return true; | |
} | |
return false; | |
}); | |
if (insertPosition !== null) { | |
if (insertPosition > 0) { | |
spanElement.textContent = `,${spanElement.textContent},`; | |
}else { | |
spanElement.textContent = `${spanElement.textContent},`; | |
} | |
monthDiv.insertBefore(spanElement, monthDiv.childNodes[insertPosition]); | |
} else { | |
if (monthDiv.childNodes.length > 0) { | |
spanElement.textContent = `,${spanElement.textContent}`; | |
} | |
monthDiv.appendChild(spanElement); | |
} | |
} | |
// Creates a new month element and appends it to the target | |
createNewMonthElement(target, monthName, month, spanElement) { | |
const monthElement = document.createElement("div"); | |
monthElement.dataset.month = month; | |
monthElement.classList.add("font-bold", "text-xs", "text-gray-500", "calendar-month"); | |
monthElement.textContent = monthName; | |
const monthDiv = document.createElement("div"); | |
monthDiv.classList.add("calendar-month-dates"); | |
monthDiv.appendChild(spanElement); | |
monthElement.appendChild(monthDiv); | |
target.appendChild(monthElement); | |
} | |
removeDate(date) { | |
const target = this.datesTarget; | |
if (!target) { | |
console.error("Target element not found"); | |
return; | |
} | |
const [year, month, day] = date.split("-"); | |
const monthElement = target.querySelector(`[data-month="${month}"]`); | |
if (!monthElement) return; // No matching month element | |
const monthDiv = monthElement.querySelector("div"); | |
const spanElement = monthDiv?.querySelector(`[data-day="${day}"]`); | |
if (spanElement) { | |
spanElement.remove(); | |
} | |
// Remove the entire month element if no dates remain | |
if (monthDiv && monthDiv.childNodes.length === 0) { | |
monthElement.remove(); | |
} | |
this.cleanCommas(); | |
} | |
cleanCommas() { | |
const target = this.datesTarget; | |
if (!target) { | |
console.error("Target element not found"); | |
return; | |
} | |
const spans = Array.from(target.querySelectorAll("span")); | |
spans.forEach((span, index) => { | |
// Remove leading comma from the first span | |
if (index === 0 && span.textContent.startsWith(",")) { | |
span.textContent = span.textContent.slice(1); | |
} | |
// Remove trailing comma from the last span | |
if (index === spans.length - 1 && span.textContent.endsWith(",")) { | |
span.textContent = span.textContent.slice(0, -1); | |
} | |
// Replace double commas with a single comma | |
if (span.textContent.includes(",,")) { | |
span.textContent = span.textContent.replace(/,,/g, ","); | |
} | |
// Remove trailing comma if the next span starts with one | |
if ( | |
index < spans.length - 1 && | |
span.textContent.endsWith(",") && | |
spans[index + 1].textContent.startsWith(",") | |
) { | |
span.textContent = span.textContent.slice(0, -1); | |
} | |
}); | |
} | |
addTimeSlot() { | |
const target = this.element.querySelector("#time-availabilities"); | |
if (!target) { | |
console.error("Target element not found"); | |
return; | |
} | |
const template = document.getElementById("calendar-time-slot-template"); | |
if (!template) { | |
console.error("Template element not found"); | |
return; | |
} | |
const lastTimeSlot = target.querySelector(".time-slot:last-child"); | |
if (!lastTimeSlot) { | |
console.error("No existing time slots found"); | |
return; | |
} | |
const endTime = lastTimeSlot.querySelector(".end-time").value; | |
if (!endTime || endTime.length < 5) { | |
console.error("Invalid end time format"); | |
return; | |
} | |
const { newStartTime, newEndTime } = this.calculateNewTimeSlots(endTime); | |
// Clone the template content and append it to the target | |
const clone = document.importNode(template.content, true); | |
target.appendChild(clone); | |
const newTimeSlot = target.querySelector(".time-slot:last-child"); | |
newTimeSlot.querySelector(".start-time").value = newStartTime; | |
newTimeSlot.querySelector(".end-time").value = newEndTime; | |
} | |
removeTimeSlot(event){ | |
event.preventDefault(); | |
const item = event.target.closest(".time-slot"); | |
item.remove(); | |
} | |
toggle(){ | |
const target = document.getElementById("calendar-container"); | |
if (!target) { | |
console.error("Target element not found"); | |
return; | |
} | |
target.classList.toggle('hidden'); | |
} | |
addDateOverrideTimeSlot(event) { | |
event.preventDefault(); | |
const template = document.getElementById("date-override-template"); | |
if (!template) { | |
console.error("Template element not found"); | |
return; | |
} | |
const target = event.target.closest(".date-override-time-slots"); | |
if (!target) { | |
console.error("Target element not found"); | |
return; | |
} | |
const date = this.getDateFromEvent(event); | |
const startTime = this.getLastEndTime(target); | |
if (!startTime) { | |
console.error("Unable to determine the start time"); | |
return; | |
} | |
const { newStartTime, newEndTime } = this.calculateNewTimeSlots(startTime); | |
const content = this.generateTemplateContent(template, new Date().getTime()); | |
this.appendNewOverrideSlot(target, content, date, newStartTime, newEndTime); | |
} | |
getDateFromEvent(event) { | |
return event.target.parentElement.previousElementSibling.value || ""; | |
} | |
getLastEndTime(target) { | |
const visibleOverrides = Array.from( | |
target.querySelectorAll(".date-override:not([style*='display: none'])") | |
); | |
const lastOverride = visibleOverrides.pop(); | |
return lastOverride?.querySelector(".end-time")?.value || null; | |
} | |
calculateNewTimeSlots(startTime) { | |
const startHour = parseInt(startTime.slice(0, 2), 10); | |
const minutes = startTime.slice(2); | |
const newStartHour = startHour.toString().padStart(2, "0"); | |
const newEndHour = ((startHour + 1) % 24).toString().padStart(2, "0"); | |
return { | |
newStartTime: `${newStartHour}${minutes}`, | |
newEndTime: `${newEndHour}${minutes}`, | |
}; | |
} | |
generateTemplateContent(template, uniqueId) { | |
return template.innerHTML.replace(/new_record/g, uniqueId); | |
} | |
appendNewOverrideSlot(target, content, date, newStartTime, newEndTime) { | |
target.insertAdjacentHTML("beforeend", content); | |
const newOverride = target.querySelector(".date-override:last-child"); | |
if (!newOverride) { | |
console.error("Failed to append new override slot"); | |
return; | |
} | |
newOverride.querySelector(".date").value = date; | |
newOverride.querySelector(".start-time").value = newStartTime; | |
newOverride.querySelector(".end-time").value = newEndTime; | |
} | |
removeDateOverrideTimeSlot(event) { | |
event.preventDefault(); | |
const item = event.target.closest(".date-override"); | |
if (!item) { | |
console.error("Date override item not found"); | |
return; | |
} | |
const removeField = item.querySelector(".remove_field"); | |
if (removeField) { | |
this.markForRemoval(item, removeField); | |
} else { | |
item.remove(); | |
} | |
this.cleanUpEmptyContainer(event.target); | |
} | |
markForRemoval(item, removeField) { | |
removeField.value = 1; | |
item.style.display = "none"; | |
} | |
cleanUpEmptyContainer(target) { | |
const timeSlotsContainer = target.closest(".date-override-time-slots"); | |
if (!timeSlotsContainer) { | |
console.error("Time slots container not found"); | |
return; | |
} | |
const visibleOverrides = timeSlotsContainer.querySelectorAll(".date-override:not([style*='display: none'])"); | |
if (visibleOverrides.length === 0) { | |
const overrideContainer = target.closest(".date-override-container"); | |
if (overrideContainer) { | |
overrideContainer.remove(); | |
} else { | |
console.error("Date override container not found"); | |
} | |
} | |
} | |
addSpecificsDates() { | |
const dates = Array.from(document.querySelectorAll('.dates-span')); | |
if (dates.length === 0) return; | |
const datesData = dates.map(span => span.dataset.date); | |
const availability = document.getElementById('switch-component-calendar').checked; | |
const timeSlots = this.collectTimeSlots(); | |
const template = document.getElementById('date-override-template'); | |
const target = document.getElementById('date-overrides-container'); | |
const alreadyAddedDates = this.getAlreadyAddedDates(); | |
datesData.forEach(date => { | |
let container = this.findOrCreateContainer(date, availability, alreadyAddedDates); | |
const timeSlotsContainer = container.querySelector('.date-override-time-slots'); | |
if (alreadyAddedDates.includes(date)) { | |
this.clearExistingTimeSlots(timeSlotsContainer, availability); | |
} | |
this.addTimeSlotsToContainer(timeSlots, timeSlotsContainer, template, date, availability); | |
this.appendUnavailableSpan(container, availability); | |
if (!alreadyAddedDates.includes(date)) { | |
target.appendChild(container); | |
} | |
}); | |
this.cleanPopup(); | |
} | |
collectTimeSlots() { | |
return Array.from(document.querySelectorAll('#time-availabilities .time-slot')).map(slot => ({ | |
start: slot.querySelector('.start-time').value, | |
end: slot.querySelector('.end-time').value, | |
})); | |
} | |
getAlreadyAddedDates() { | |
return Array.from(document.querySelectorAll('.date-override-container:not([style*="display: none"]) .dates-span-data')) | |
.map(span => span.dataset.date); | |
} | |
findOrCreateContainer(date, availability, alreadyAddedDates) { | |
if (alreadyAddedDates.includes(date)) { | |
return document.querySelector(`.date-override-container .dates-span-data[data-date="${date}"]`) | |
.closest('.date-override-container'); | |
} | |
const container = this.createContainer(date, availability); | |
return container; | |
} | |
createContainer(date, availability) { | |
const container = document.createElement('div'); | |
container.classList.add('flex', 'mt-2', 'date-override-container', 'mx-auto', 'w-[480px]'); | |
const removeButton = this.createRemoveButton(); | |
container.appendChild(removeButton); | |
const availabilitySwitch = this.createAvailabilitySwitch(date, availability); | |
container.appendChild(availabilitySwitch); | |
const dateLabel = this.createDateLabel(date); | |
container.appendChild(dateLabel); | |
const timeSlotsContainer = document.createElement('div'); | |
timeSlotsContainer.classList.add('flex', 'flex-col', 'ml-2', 'w-max', 'date-override-time-slots'); | |
container.appendChild(timeSlotsContainer); | |
return container; | |
} | |
createRemoveButton() { | |
const removeButton = document.createElement('img'); | |
removeButton.src = document.getElementById('remove-red').dataset.src; | |
removeButton.classList.add('w-7', 'h-7', 'cursor-pointer', 'mt-2', 'remove-button', 'mr-2'); | |
removeButton.dataset.action = 'click->calendar#removeDateOverride'; | |
removeButton.dataset.controller = 'calendar'; | |
return removeButton; | |
} | |
createAvailabilitySwitch(date, availability) { | |
const switchContainer = document.createElement('div'); | |
switchContainer.classList.add('relative', 'inline-block', 'mt-2'); | |
const checkbox = document.createElement('input'); | |
checkbox.type = 'checkbox'; | |
checkbox.classList.add('hidden', 'peer'); | |
checkbox.checked = availability; | |
checkbox.id = `switch-component-${date}`; | |
checkbox.dataset.controller = 'calendar'; | |
checkbox.dataset.action = 'change->calendar#toggleAvailability'; | |
const label = document.createElement('label'); | |
label.classList.add( | |
'w-11', 'h-5', 'rounded-full', 'flex', 'items-center', | |
'transition-all', 'duration-300', 'bg-slate-100', | |
'peer-checked:bg-slate-800', 'peer-checked:pl-6', 'cursor-pointer' | |
); | |
label.htmlFor = checkbox.id; | |
const switchButton = document.createElement('div'); | |
switchButton.classList.add('w-5', 'h-5', 'bg-white', 'rounded-full', 'border', 'border-slate-300', 'shadow-sm', 'cursor-pointer'); | |
label.appendChild(switchButton); | |
switchContainer.appendChild(checkbox); | |
switchContainer.appendChild(label); | |
return switchContainer; | |
} | |
createDateLabel(date) { | |
const label = document.createElement('span'); | |
label.classList.add('text-xs', 'dates-span-data', 'mt-2', 'ml-2', 'w-20'); | |
label.dataset.date = date; | |
label.textContent = date; | |
return label; | |
} | |
clearExistingTimeSlots(container, availability) { | |
container.querySelectorAll('.date-override').forEach(override => { | |
override.querySelector('.remove_field').value = 1; | |
override.style.display = 'none'; | |
}); | |
const checkbox = container.closest('.date-override-container').querySelector('input'); | |
checkbox.checked = availability; | |
} | |
addTimeSlotsToContainer(timeSlots, container, template, date, availability) { | |
timeSlots.forEach((slot, index) => { | |
const content = this.generateTemplateContent(template, new Date().getTime()); | |
container.insertAdjacentHTML('beforeend', content); | |
const override = container.querySelector('.date-override:last-child'); | |
override.querySelector('.available').checked = availability; | |
override.querySelector('.date').value = date; | |
override.querySelector('.start-time').value = slot.start; | |
override.querySelector('.end-time').value = slot.end; | |
if (index === 0) { | |
override.querySelector('.add-button').classList.remove('hidden'); | |
override.querySelector('.remove-button').classList.add('hidden'); | |
} | |
}); | |
container.classList.toggle('hidden', !availability); | |
container.classList.toggle('flex', availability); | |
} | |
appendUnavailableSpan(container, availability) { | |
let span = container.querySelector('.unavailable-span'); | |
if (!span) { | |
span = document.createElement('span'); | |
span.classList.add('text-sm', 'font-normal', 'text-gray-500', 'mt-2', 'unavailable-span', 'ml-2', 'h-12'); | |
container.appendChild(span); | |
} | |
span.textContent = 'Unavailable'; | |
span.classList.toggle('hidden', availability); | |
span.classList.toggle('block', !availability); | |
} | |
cleanPopup() { | |
const target = document.getElementById('calendar-container'); | |
if (!target) { | |
console.error('Calendar container not found'); | |
return; | |
} | |
// Hide the calendar container | |
target.classList.add('hidden'); | |
// Remove all date spans | |
this.clearElements(target, '.dates-span'); | |
// Reset calendar day styles | |
this.resetCalendarDays(target); | |
// Clear picked dates | |
this.clearPickedDates(target); | |
} | |
clearElements(target, selector) { | |
const elements = target.querySelectorAll(selector); | |
elements.forEach(element => element.remove()); | |
} | |
resetCalendarDays(target) { | |
const days = target.querySelectorAll('.calendar-day'); | |
days.forEach(day => { | |
day.classList.remove('!bg-blue-500', 'text-white'); | |
}); | |
} | |
clearPickedDates(target) { | |
const pickedDatesElement = target.querySelector('#picked-dates'); | |
if (pickedDatesElement) { | |
pickedDatesElement.textContent = ''; | |
} | |
} | |
toggleAvailability(event) { | |
const availabilityChecked = event.target.checked; | |
const targetContainer = event.target.parentElement.nextElementSibling.nextElementSibling; | |
// Toggle visibility of relevant containers | |
this.toggleVisibility(targetContainer); | |
this.toggleVisibility(targetContainer.nextElementSibling); | |
// Update availability status for all date overrides | |
targetContainer.querySelectorAll('.date-override').forEach((dateOverride) => { | |
dateOverride.querySelector('.available').checked = availabilityChecked; | |
}); | |
} | |
toggleVisibility(container) { | |
container.classList.toggle('hidden'); | |
} | |
removeDateOverride(event){ | |
event.preventDefault(); | |
const container = event.target.closest(".date-override-container"); | |
const datesOverrides = container.querySelectorAll('.date-override'); | |
datesOverrides.forEach((dateOverride) => { | |
dateOverride.querySelector('.remove_field').value = 1; | |
}); | |
container.style.display = "none"; | |
} | |
dateSelectedForMeeting(event){ | |
this.cleanTimeOptions(); | |
this.cleanOtherCalendarDays(event.target) | |
const date = event.target.dataset.date; | |
if (!date) return; | |
this.toggleDateClass(event.target); | |
if (event.target.classList.contains('!bg-blue-500')) { | |
this.assingDateSelectedForMeeting(date); | |
this.showTimeOptions(event.target); | |
} else { | |
this.removeDateSelectedForMeeting(); | |
} | |
} | |
cleanTimeOptions(){ | |
const target = this.meetingOptionsTarget; | |
if (!target) { | |
console.error("Target element not found"); | |
return; | |
} | |
target.innerHTML = ""; | |
} | |
cleanOtherCalendarDays(target){ | |
const days = document.querySelectorAll('.calendar-day'); | |
days.forEach(day => { | |
if(day !== target){ | |
day.classList.remove('!bg-blue-500', 'text-white'); | |
} | |
}); | |
} | |
assingDateSelectedForMeeting(date){ | |
this.dateSelectedForMeetingTarget.value = date; | |
} | |
toggleDateClass(target){ | |
if(!target) return; | |
target.classList.toggle('!bg-blue-500'); | |
target.classList.toggle('text-white'); | |
} | |
resetCalendarDays() { | |
const days = document.querySelectorAll('.calendar-day'); | |
days.forEach(day => { | |
day.classList.remove('!bg-blue-500', 'text-white'); | |
}); | |
} | |
removeDateSelectedForMeeting(){ | |
this.dateSelectedForMeetingTarget.value = ''; | |
this.resetCalendarDays(); | |
} | |
showTimeOptions(target){ | |
const selectElement = this.eventTypeTarget; // The <select> element | |
const selectedOption = selectElement.options[selectElement.selectedIndex]; // The currently selected <option> | |
const durationInMinutes = selectedOption.value; // Retrieve the data-duration attribute | |
if(!durationInMinutes){ | |
console.error("Missing data-duration attribute"); | |
return; | |
} | |
const data = target.dataset.timeIntervals; // Retrieve the data-time-intervals attribute | |
const date = target.dataset.date; | |
if (!data || !date) { | |
console.error("Missing data or date attributes"); | |
this.displayNoTimeOptions(date); | |
return; | |
} | |
const intervalsDataArray = JSON.parse(data); | |
const intervals = this.buildTimeIntervals(intervalsDataArray, durationInMinutes, date); | |
this.displayTimeOptions(intervals); | |
} | |
buildTimeIntervals(intervalsDataArray, durationInMinutes){ | |
const intervals = []; | |
intervalsDataArray.forEach(interval => { | |
const startTime = Date.parse(interval.start_time); | |
const endTime = Date.parse(interval.end_time); | |
let currentTime = startTime; | |
while (currentTime < endTime) { | |
// adds the duration in minutes to the current time | |
const nextTime = currentTime + durationInMinutes * 60 * 1000; | |
if(nextTime > endTime) break; | |
intervals.push({ | |
start: new Date(currentTime).toISOString(), | |
end: nextTime <= endTime ? new Date(nextTime).toISOString() : new Date(endTime).toISOString(), | |
}); | |
currentTime = nextTime; | |
} | |
}); | |
return intervals; | |
} | |
displayNoTimeOptions(date){ | |
const target = this.meetingOptionsTarget; | |
if (!target) { | |
console.error("Target element not found"); | |
return; | |
} | |
const dateObject = new Date(date + "T00:00:00"); | |
const newDate = dateObject.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); | |
if(date){ | |
target.innerHTML = `<p class='text-xs text-blue-gray-500 font-bold w-full items-right mb-2'>${newDate}</p>`; | |
} | |
target.innerHTML += "<p class='text-sm text-orange-700 bg-orange-50 font-bold rounded-md w-full p-2 text-center uppercase'>No times available</p>"; | |
const src = this.noAvailableImageTarget.dataset.src; | |
if (src) { | |
target.innerHTML += `<img src="${src}" class="w-40 mx-auto mt-10" alt="No times available" />`; | |
} | |
} | |
displayTimeOptions(intervals){ | |
const target = this.meetingOptionsTarget; | |
if (!target) { | |
console.error("Target element not found"); | |
return; | |
} | |
intervals.forEach((interval) => { | |
const content = this.generateTimeOption(interval); | |
target.insertAdjacentHTML("beforeend", content); | |
}); | |
} | |
generateTimeOption(interval) { | |
return ` | |
<div class="flex items-center mt-2 w-full"> | |
<input | |
type="radio" | |
name="calendar_booking[start_time]" | |
value="${interval.start}" | |
class="hidden peer" | |
id="booking_start_time_${interval.start}" | |
> | |
<label | |
for="booking_start_time_${interval.start}" | |
class="flex item-center justify-center w-full rounded-lg bg-white border border-light-blue-500 p-2 text-light-blue-700 text-sm font-bold text-center cursor-pointer peer-checked:bg-blue-500 peer-checked:text-white" | |
> | |
<span>${this.formatDate(interval.start)} - ${this.formatDate(interval.end)}</span> | |
</label> | |
</div> | |
`; | |
} | |
formatDate(date) { | |
const selectedTimeZone = this.SelectedTimeZoneTarget.value; // Get the selected time zone | |
// Parse the date using the contact's time zone | |
const contactDate = new Date(date); | |
// Format the date in the selected time zone | |
const options = { | |
hour: "2-digit", | |
minute: "2-digit", | |
timeZone: selectedTimeZone, | |
}; | |
return contactDate.toLocaleString("en-US", options); | |
} | |
eventTypeSelected(){ | |
this.cleanTimeOptions(); | |
this.removeDateSelectedForMeeting(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment