/* ---------------------------------------------------------------------------- * Easy!Appointments - Online Appointment Scheduler * * @package EasyAppointments * @author A.Tselegidis * @copyright Copyright (c) Alex Tselegidis * @license https://opensource.org/licenses/GPL-3.0 - GPLv3 * @link https://easyappointments.org * @since v1.5.0 * ---------------------------------------------------------------------------- */ /** * Booking page. * * This module implements the functionality of the booking page * * Old Name: FrontendBook */ App.Pages.Booking = (function () { const $cookieNoticeLink = $('.cc-link'); const $selectDate = $('#select-date'); const $selectService = $('#select-service'); const $selectProvider = $('#select-provider'); const $selectTimezone = $('#select-timezone'); const $firstName = $('#first-name'); const $lastName = $('#last-name'); const $email = $('#email'); const $phoneNumber = $('#phone-number'); const $address = $('#address'); const $city = $('#city'); const $zipCode = $('#zip-code'); const $notes = $('#notes'); const $captchaTitle = $('.captcha-title'); const $availableHours = $('#available-hours'); const $bookAppointmentSubmit = $('#book-appointment-submit'); const $deletePersonalInformation = $('#delete-personal-information'); const $customField1 = $('#custom-field-1'); const $customField2 = $('#custom-field-2'); const $customField3 = $('#custom-field-3'); const $customField4 = $('#custom-field-4'); const $customField5 = $('#custom-field-5'); const tippy = window.tippy; const moment = window.moment; /** * Determines the functionality of the page. * * @type {Boolean} */ let manageMode = vars('manage_mode') || false; /** * Detect the month step. * * @param previousDateTimeMoment * @param nextDateTimeMoment * * @returns {Number} */ function detectDatepickerMonthChangeStep(previousDateTimeMoment, nextDateTimeMoment) { return previousDateTimeMoment.isAfter(nextDateTimeMoment) ? -1 : 1; } /** * Initialize the module. */ function initialize() { if (Boolean(Number(vars('display_cookie_notice'))) && window?.cookieconsent) { cookieconsent.initialise({ palette: { popup: { background: '#ffffffbd', text: '#666666', }, button: { background: '#429a82', text: '#ffffff', }, }, content: { message: lang('website_using_cookies_to_ensure_best_experience'), dismiss: 'OK', }, }); $cookieNoticeLink.replaceWith( $('', { 'data-toggle': 'modal', 'data-target': '#cookie-notice-modal', 'href': '#', 'class': 'cc-link', 'text': $cookieNoticeLink.text(), }), ); } manageMode = vars('manage_mode'); // Initialize page's components (tooltips, date pickers etc). tippy('[data-tippy-content]'); let monthTimeout; App.Utils.UI.initializeDatePicker($selectDate, { inline: true, minDate: moment().subtract(1, 'day').set({hours: 23, minutes: 59, seconds: 59}).toDate(), maxDate: moment().add(vars('future_booking_limit'), 'days').toDate(), onChange: (selectedDates) => { App.Http.Booking.getAvailableHours(moment(selectedDates[0]).format('YYYY-MM-DD')); App.Pages.Booking.updateConfirmFrame(); }, onMonthChange: (selectedDates, dateStr, instance) => { $selectDate.parent().fadeTo(400, 0.3); // Change opacity during loading if (monthTimeout) { clearTimeout(monthTimeout); } monthTimeout = setTimeout(() => { const previousMoment = moment(instance.selectedDates[0]); const displayedMonthMoment = moment( instance.currentYearElement.value + '-' + String(Number(instance.monthsDropdownContainer.value) + 1).padStart(2, '0') + '-01', ); const monthChangeStep = detectDatepickerMonthChangeStep(previousMoment, displayedMonthMoment); App.Http.Booking.getUnavailableDates( $selectProvider.val(), $selectService.val(), displayedMonthMoment.format('YYYY-MM-DD'), monthChangeStep, ); }, 500); }, onYearChange: (selectedDates, dateStr, instance) => { setTimeout(() => { const previousMoment = moment(instance.selectedDates[0]); const displayedMonthMoment = moment( instance.currentYearElement.value + '-' + (Number(instance.monthsDropdownContainer.value) + 1) + '-01', ); const monthChangeStep = detectDatepickerMonthChangeStep(previousMoment, displayedMonthMoment); App.Http.Booking.getUnavailableDates( $selectProvider.val(), $selectService.val(), displayedMonthMoment.format('YYYY-MM-DD'), monthChangeStep, ); }, 500); }, }); App.Utils.UI.setDateTimePickerValue($selectDate, new Date()); const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const isTimezoneSupported = $selectTimezone.find(`option[value="${browserTimezone}"]`).length > 0; $selectTimezone.val(isTimezoneSupported ? browserTimezone : 'UTC'); // Bind the event handlers (might not be necessary every time we use this class). addEventListeners(); optimizeContactInfoDisplay(); // If the manage mode is true, the appointment data should be loaded by default. if (manageMode) { applyAppointmentData(vars('appointment_data'), vars('provider_data'), vars('customer_data')); $('#wizard-frame-1') .css({ 'visibility': 'visible', 'display': 'none', }) .fadeIn(); } else { // Check if a specific service was selected (via URL parameter). const selectedServiceId = App.Utils.Url.queryParam('service'); if (selectedServiceId && $selectService.find('option[value="' + selectedServiceId + '"]').length > 0) { $selectService.val(selectedServiceId); } $selectService.trigger('change'); // Load the available hours. // Check if a specific provider was selected. const selectedProviderId = App.Utils.Url.queryParam('provider'); if (selectedProviderId && $selectProvider.find('option[value="' + selectedProviderId + '"]').length === 0) { // Select a service of this provider in order to make the provider available in the select box. for (const index in vars('available_providers')) { const provider = vars('available_providers')[index]; if (provider.id === selectedProviderId && provider.services.length > 0) { $selectService.val(provider.services[0]).trigger('change'); } } } if (selectedProviderId && $selectProvider.find('option[value="' + selectedProviderId + '"]').length > 0) { $selectProvider.val(selectedProviderId).trigger('change'); } if ( (selectedServiceId && selectedProviderId) || (vars('available_services').length === 1 && vars('available_providers').length === 1) ) { $('.active-step').removeClass('active-step'); $('#step-2').addClass('active-step'); $('#wizard-frame-1').hide(); $('#wizard-frame-2').fadeIn(); $selectService.closest('.wizard-frame').find('.button-next').trigger('click'); $(document).find('.book-step:first').hide(); $(document).find('.button-back:first').css('visibility', 'hidden'); $(document) .find('.book-step:not(:first)') .each((index, bookStepEl) => $(bookStepEl) .find('strong') .text(index + 1), ); } else { $('#wizard-frame-1') .css({ 'visibility': 'visible', 'display': 'none', }) .fadeIn(); } prefillFromQueryParam('#first-name', 'first_name'); prefillFromQueryParam('#last-name', 'last_name'); prefillFromQueryParam('#email', 'email'); prefillFromQueryParam('#phone-number', 'phone'); prefillFromQueryParam('#address', 'address'); prefillFromQueryParam('#city', 'city'); prefillFromQueryParam('#zip-code', 'zip'); } } function prefillFromQueryParam(field, param) { const $target = $(field); if (!$target.length) { return; } $target.val(App.Utils.Url.queryParam(param)); } /** * Remove empty columns and center elements if needed. */ function optimizeContactInfoDisplay() { // If a column has only one control shown then move the control to the other column. const $firstCol = $('#wizard-frame-3 .field-col:first'); const $firstColControls = $firstCol.find('.form-control'); const $secondCol = $('#wizard-frame-3 .field-col:last'); const $secondColControls = $secondCol.find('.form-control'); if ($firstColControls.length === 1 && $secondColControls.length > 1) { $firstColControls.each((index, controlEl) => { $(controlEl).parent().insertBefore($secondColControls.first().parent()); }); } if ($secondColControls.length === 1 && $firstColControls.length > 1) { $secondColControls.each((index, controlEl) => { $(controlEl).parent().insertAfter($firstColControls.last().parent()); }); } // Hide columns that do not have any controls displayed. const $fieldCols = $(document).find('#wizard-frame-3 .field-col'); $fieldCols.each((index, fieldColEl) => { const $fieldCol = $(fieldColEl); if (!$fieldCol.find('.form-control').length) { $fieldCol.hide(); } }); } /** * Add the page event listeners. */ function addEventListeners() { /** * Event: Timezone "Changed" */ $selectTimezone.on('change', () => { const date = App.Utils.UI.getDateTimePickerValue($selectDate); if (!date) { return; } App.Http.Booking.getAvailableHours(moment(date).format('YYYY-MM-DD')); App.Pages.Booking.updateConfirmFrame(); }); /** * Event: Selected Provider "Changed" * * Whenever the provider changes the available appointment date - time periods must be updated. */ $selectProvider.on('change', (event) => { const $target = $(event.target); const todayDateTimeObject = new Date(); const todayDateTimeMoment = moment(todayDateTimeObject); App.Utils.UI.setDateTimePickerValue($selectDate, todayDateTimeObject); App.Http.Booking.getUnavailableDates( $target.val(), $selectService.val(), todayDateTimeMoment.format('YYYY-MM-DD'), ); App.Pages.Booking.updateConfirmFrame(); }); /** * Event: Selected Service "Changed" * * When the user clicks on a service, its available providers should * become visible. */ $selectService.on('change', (event) => { const $target = $(event.target); const serviceId = $selectService.val(); $selectProvider.empty(); vars('available_providers').forEach((provider) => { // If the current provider is able to provide the selected service, add him to the list box. const canServeService = provider.services.filter((providerServiceId) => Number(providerServiceId) === Number(serviceId)) .length > 0; if (canServeService) { $selectProvider.append(new Option(provider.first_name + ' ' + provider.last_name, provider.id)); } }); // Add the "Any Provider" entry. if ($selectProvider.find('option').length > 1 && vars('display_any_provider') === '1') { $selectProvider.prepend(new Option(lang('any_provider'), 'any-provider', true, true)); } App.Http.Booking.getUnavailableDates( $selectProvider.val(), $target.val(), moment(App.Utils.UI.getDateTimePickerValue($selectDate)).format('YYYY-MM-DD'), ); App.Pages.Booking.updateConfirmFrame(); App.Pages.Booking.updateServiceDescription(serviceId); }); /** * Event: Next Step Button "Clicked" * * This handler is triggered every time the user pressed the "next" button on the book wizard. * Some special tasks might be performed, depending on the current wizard step. */ $('.button-next').on('click', (event) => { const $target = $(event.currentTarget); // If we are on the first step and there is no provider selected do not continue with the next step. if ($target.attr('data-step_index') === '1' && !$selectProvider.val()) { return; } // If we are on the 2nd tab then the user should have an appointment hour selected. if ($target.attr('data-step_index') === '2') { if (!$('.selected-hour').length) { if (!$('#select-hour-prompt').length) { $('
', { 'id': 'select-hour-prompt', 'class': 'text-danger mb-4', 'text': lang('appointment_hour_missing'), }).prependTo('#available-hours'); } return; } } // If we are on the 3rd tab then we will need to validate the user's input before proceeding to the next // step. if ($target.attr('data-step_index') === '3') { if (!App.Pages.Booking.validateCustomerForm()) { return; // Validation failed, do not continue. } else { App.Pages.Booking.updateConfirmFrame(); } } // Display the next step tab (uses jquery animation effect). const nextTabIndex = parseInt($target.attr('data-step_index')) + 1; $target .parents() .eq(1) .fadeOut(() => { $('.active-step').removeClass('active-step'); $('#step-' + nextTabIndex).addClass('active-step'); $('#wizard-frame-' + nextTabIndex).fadeIn(); }); // Scroll to the top of the page. On a small screen, especially on a mobile device, this is very useful. const scrollingElement = document.scrollingElement || document.body; if (window.innerHeight < scrollingElement.scrollHeight) { scrollingElement.scrollTop = 0; } }); /** * Event: Back Step Button "Clicked" * * This handler is triggered every time the user pressed the "back" button on the * book wizard. */ $('.button-back').on('click', (event) => { const prevTabIndex = parseInt($(event.currentTarget).attr('data-step_index')) - 1; $(event.currentTarget) .parents() .eq(1) .fadeOut(() => { $('.active-step').removeClass('active-step'); $('#step-' + prevTabIndex).addClass('active-step'); $('#wizard-frame-' + prevTabIndex).fadeIn(); }); }); /** * Event: Available Hour "Click" * * Triggered whenever the user clicks on an available hour for his appointment. */ $availableHours.on('click', '.available-hour', (event) => { $availableHours.find('.selected-hour').removeClass('selected-hour'); $(event.target).addClass('selected-hour'); App.Pages.Booking.updateConfirmFrame(); }); if (manageMode) { /** * Event: Cancel Appointment Button "Click" * * When the user clicks the "Cancel" button this form is going to be submitted. We need * the user to confirm this action because once the appointment is cancelled, it will be * deleted from the database. * * @param {jQuery.Event} event */ $('#cancel-appointment').on('click', () => { const $cancelAppointmentForm = $('#cancel-appointment-form'); let $cancellationReason; const buttons = [ { text: lang('close'), click: (event, messageModal) => { messageModal.hide(); }, }, { text: lang('confirm'), click: () => { if ($cancellationReason.val() === '') { $cancellationReason.css('border', '2px solid #DC3545'); return; } $cancelAppointmentForm.find('#hidden-cancellation-reason').val($cancellationReason.val()); $cancelAppointmentForm.submit(); }, }, ]; App.Utils.Message.show( lang('cancel_appointment_title'), lang('write_appointment_removal_reason'), buttons, ); $cancellationReason = $('