/** * VikBooking Core v1.6.8 * Copyright (C) 2024 E4J s.r.l. All Rights Reserved. * http://www.gnu.org/licenses/gpl-2.0.html GNU/GPL * https://vikwp.com | https://e4j.com | https://e4jconnect.com */ (function($, w) { 'use strict'; w['VBOCore'] = class VBOCore { /** * Proxy to support static injection of params. */ constructor(params) { if (typeof params === 'object') { VBOCore.setOptions(params); } } /** * Inject options by overriding default properties. * * @param object params * * @return self */ static setOptions(params) { if (typeof params === 'object') { VBOCore.options = Object.assign(VBOCore.options, params); } return VBOCore; } /** * Getter for admin_widgets private options property. * * @return array */ static get admin_widgets() { return VBOCore.options.admin_widgets; } /** * Getter for multitask open event private property. * * @return string */ static get multitask_open_event() { return VBOCore.options.multitask_open_event; } /** * Getter for multitask close event private property. * * @return string */ static get multitask_close_event() { return VBOCore.options.multitask_close_event; } /** * Getter for multitask shortcut event private property. * * @return string */ static get multitask_shortcut_event() { return VBOCore.options.multitask_shortcut_ev; } /** * Getter for multitask seach focus shortcut event private property. * * @return string */ static get multitask_searchfs_event() { return VBOCore.options.multitask_searchfs_ev; } /** * Getter for multitask event widget modal rendered. * * @return string */ static get widget_modal_rendered() { return VBOCore.options.widget_modal_rendered; } /** * Getter for widget modal dismiss event. * * @return string */ static get widget_modal_dismissed() { return VBOCore.options.widget_modal_dismissed; } /** * Getter for the service worker file path. * * @return string */ static get service_worker_path() { return VBOCore.options.service_worker_path; } /** * Getter for the service worker scope. * * @return string */ static get service_worker_scope() { return VBOCore.options.service_worker_scope; } /** * Getter for the push application key. * * @return string */ static get push_application_key() { return VBOCore.options.push.application_key; } /** * Getter for the push storage endpoint identifier. * * @return string */ static get push_storage_endpoint_id() { return VBOCore.options.push_options.storage_endp_id; } /** * Parses an AJAX response error object. * * @param object err * * @return bool */ static isConnectionLostError(err) { if (!err || !err.hasOwnProperty('status')) { return false; } return ( err.statusText == 'error' && err.status == 0 && (err.readyState == 0 || err.readyState == 4) && (!err.hasOwnProperty('responseText') || err.responseText == '') ); } /** * Ensures AJAX requests that fail due to connection errors are retried automatically. * * @param string url * @param object data * @param function success * @param function failure * @param number attempt */ static doAjax(url, data, success, failure, attempt) { const AJAX_MAX_ATTEMPTS = 3; if (attempt === undefined) { attempt = 1; } return $.ajax({ type: 'POST', url: url, data: data }).done(function(resp) { if (success !== undefined) { // launch success callback function success(resp); } }).fail(function(err) { /** * If the error is caused by a site connection lost, and if the number * of retries is lower than max attempts, retry the same AJAX request. */ if (attempt < AJAX_MAX_ATTEMPTS && VBOCore.isConnectionLostError(err)) { // delay the retry by half second setTimeout(function() { // re-launch same request and increase number of attempts console.log('Retrying previous AJAX request'); VBOCore.doAjax(url, data, success, failure, (attempt + 1)); }, 500); } else { // launch the failure callback otherwise if (failure !== undefined) { failure(err); } } if (!err.status || err.status == 500) { // log the error in console console.error('AJAX request failed' + (err.status == 500 ? ' (' + err.responseText + ')' : ''), err); } }); } /** * Matches a keyword against a text. * * @param string search the keyword to search. * @param string text the text to compare. * * @return bool */ static matchString(search, text) { return ((text + '').indexOf(search) >= 0); } /** * Converts a base64 URL for a server key into an array of 8-bit unsigned integers. * * @param string key the base64 encoded URL key. * * @return Uint8Array */ static base64ToUint8Array(key) { const padding = ('=').repeat((4 - (key.length % 4)) % 4); const base64 = (key + padding).replace(/\-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); const uint8_arr = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { uint8_arr[i] = rawData.charCodeAt(i); } return uint8_arr; } /** * Builds an object containing the Push subscription details and data. * * @param PushSubscription pushSubscription the registration data. * @param object data the extra data to merge. * * @return object */ static buildPushSubscriptionData(pushSubscription, data) { if (!(pushSubscription instanceof PushSubscription)) { return data; } let key = pushSubscription.getKey('p256dh'); let token = pushSubscription.getKey('auth'); let contentEncoding = (PushManager.supportedContentEncodings || ['aesgcm'])[0]; let details = { endpoint: pushSubscription.endpoint, publicKey: key ? btoa(String.fromCharCode.apply(null, new Uint8Array(key))) : null, authToken: token ? btoa(String.fromCharCode.apply(null, new Uint8Array(token))) : null, encoding: contentEncoding, }; return Object.assign(details, data); } /** * Attempts to install (and eventually update) the VikBooking's Service Worker. * * @param bool update if defined, it will update the Service Worker Registration. * * @return Promise */ static installServiceWorker(update) { // check if support for Web App badge is available if (typeof navigator.setAppBadge !== 'undefined') { // always clear the app badge navigator.clearAppBadge(); } return new Promise((resolve, reject) => { if (!('serviceWorker' in navigator)) { reject('Service workers are not supported by this browser'); return; } if (!('PushManager' in window)) { reject('Push notifications are not supported by this browser'); return; } if (!VBOCore.service_worker_path) { reject('Service Worker path not configured'); return; } if (!VBOCore.notificationsEnabled()) { reject('Notifications are disabled, and so the service worker will not be installed'); return; } navigator.serviceWorker.register(VBOCore.service_worker_path, {scope: VBOCore.service_worker_scope}).then( (registration) => { if (typeof update !== 'undefined') { // attempt to update the registration for un-caching purposes registration.update().then((updated) => { // all good }).catch((e) => { // updating should never fail after registering with success console.error(`Service worker registration update failed: ${e}`); }); } // listen to the ServiceWorker messages for the client VBOCore.listenServiceWorkerMessages(); // resolve the Promise resolve(registration); }, (e) => { console.error(`Service worker registration failed: ${e}`); reject('Service worker registration failed'); } ); }); } /** * Handles the Push subscription state. To be called after the * Service Worker installation (registration) is ready. * * @param ServiceWorkerRegistration registration sw registration object. * * @return Promise */ static handlePushSubscription(registration) { return new Promise((resolve, reject) => { if (!(registration instanceof ServiceWorkerRegistration)) { reject('Missing service worker registration'); return; } if (!VBOCore.push_application_key) { reject('Missing application key for Push because not supported'); return; } // check if a push subscription is available registration.pushManager.getSubscription().then((pushSubscription) => { if (pushSubscription) { // we are subscribed to Push let previous_endpoint = VBOCore.storageGetItem(VBOCore.push_storage_endpoint_id); if (previous_endpoint && pushSubscription.endpoint != previous_endpoint) { // update subscription data VBOCore.doAjax( VBOCore.options.push.ajax_url, VBOCore.buildPushSubscriptionData(pushSubscription, { agent: window.navigator.userAgent, type: 'update', }) ); } // resolve the promise with the Push subscription data resolve(pushSubscription); return; } // push subscription options const pushOptions = { userVisibleOnly: true, applicationServerKey: VBOCore.base64ToUint8Array(VBOCore.push_application_key), }; // subscribe to Push registration.pushManager.subscribe(pushOptions).then( (pushSubscription) => { // store subscription data VBOCore.storageSetItem(VBOCore.push_storage_endpoint_id, pushSubscription.endpoint); VBOCore.doAjax( VBOCore.options.push.ajax_url, VBOCore.buildPushSubscriptionData(pushSubscription, { agent: window.navigator.userAgent, type: 'new', }) ); // resolve the promise with the Push subscription data resolve(pushSubscription); }, (e) => { console.error(`Error handling the Push subscription state: ${e}`); reject('Error handling the Push subscription state'); } ); }) .catch((e) => { console.error(`Error getting the Push subscription state: ${e}`); reject('Error getting the Push subscription state'); }); }); } /** * Starts listening to the "message" events posted by the ServiceWorker. * * @return void */ static listenServiceWorkerMessages() { if (!('serviceWorker' in navigator)) { return; } try { // add listener to the message event for the data posted by the ServiceWorker to the client navigator.serviceWorker.addEventListener('message', (event) => { VBOCore.handleServiceWorkerMessage(event.data); }); } catch(e) { // do nothing } } /** * Handles a message data posted by the ServiceWorker upon clicking a Push notification. * * @param object data the event.notification.data object posted by the ServiceWorker. * * @return void */ static handleServiceWorkerMessage(data) { let notif_type = data.type || ''; if (!notif_type || !VBOCore.options.push_options.allowed_notif_types.includes(notif_type)) { console.error(data); throw new Error('Unsupported message type'); } // determine the proper widget for dispatching the message data let widget_id = 'booking_details'; if (notif_type == 'Chat') { widget_id = 'guest_messages'; } // the raw notification content let raw_content = data.content || {}; // multitask data options let options = Object.assign({ _push: 1, title: data.title || '', message: data.message || '', }, raw_content); // dispatch (Push) message data on widget VBOCore.handleDisplayWidgetNotification({widget_id: widget_id}, options, true); // update pushed data map for the next watching event in the current browsing context VBOCore.widgets_pushed_data.push(raw_content); // post message onto broadcast channel for any other browsing context if (VBOCore.broadcast_push_data) { // the next watch-data interval will receive the pushed data information VBOCore.broadcast_push_data.postMessage(raw_content); } // let the window reload the badge counter in case it must be updated VBOCore.emitEvent('vbo-badge-count-reload'); } /** * Initializes the multitasking panel for the admin widgets. * * @param object params the panel object params. * * @return bool */ static prepareMultitasking(params) { var panel_opts = { selector: "", sclass_l_small: "vbo-sidepanel-right", sclass_l_large: "vbo-sidepanel-large", btn_trigger: "", search_selector: "#vbo-sidepanel-search-input", search_nores: ".vbo-sidepanel-add-widgets-nores", close_selector: ".vbo-sidepanel-dismiss-btn", t_layout_small: ".vbo-sidepanel-layout-small", t_layout_large: ".vbo-sidepanel-layout-large", wclass_base_sel: ".vbo-admin-widgets-widget-output", wclass_l_small: "vbo-admin-widgets-container-small", wclass_l_large: "vbo-admin-widgets-container-large", addws_selector: ".vbo-sidepanel-add-widgets", addw_selector: ".vbo-sidepanel-add-widget", addw_modal_cls: "vbo-widget-render-modal", addw_def_cls: "vbo-widget-render-regular", addwfs_selector: ".vbo-sidepanel-add-widget-focussed", wtags_selector: ".vbo-sidepanel-widget-tags", addw_data_attr: "data-vbowidgetid", actws_selector: ".vbo-sidepanel-active-widgets", editw_selector: ".vbo-sidepanel-edit-widgets-trig", rmwidget_class: "vbo-admin-widgets-widget-remove", rmwidget_icn: "", dtcwidget_class: "vbo-admin-widgets-widget-detach", dtctarget_class: "vbo-admin-widget-head", dtcwidget_icn: "", notif_selector: ".vbo-sidepanel-notifications-btn", notif_on_class: "vbo-sidepanel-notifications-on", notif_off_class: "vbo-sidepanel-notifications-off", open_class: "vbo-sidepanel-open", close_class: "vbo-sidepanel-close", cur_widget_cls: "vbo-admin-widgets-container-small", }; if (typeof params === 'object') { panel_opts = Object.assign(panel_opts, params); } if (!panel_opts.btn_trigger || !panel_opts.selector) { console.error('Got no trigger or selector'); return false; } // push panel options VBOCore.setOptions({ panel_opts: panel_opts, }); // setup browser notifications VBOCore.setupNotifications(); // count active widgets on current page var tot_active_widgets = VBOCore.options.admin_widgets.length; if (tot_active_widgets > 0) { // hide add-widgets container $(panel_opts.addws_selector).hide(); // register listener for input search blur VBOCore.registerSearchWidgetsBlur(); } // register click event on trigger button $(VBOCore.options.panel_opts.btn_trigger).on('click', function() { var side_panel = $(VBOCore.options.panel_opts.selector); if (side_panel.hasClass(VBOCore.options.panel_opts.open_class)) { // hide panel VBOCore.side_panel_on = false; VBOCore.emitMultitaskEvent(VBOCore.multitask_close_event); side_panel.addClass(VBOCore.options.panel_opts.close_class).removeClass(VBOCore.options.panel_opts.open_class); // always hide add-widgets container $(VBOCore.options.panel_opts.addws_selector).hide(); // check if we are currently editing var is_editing = ($('.' + VBOCore.options.panel_opts.editmode_class).length > 0); if (is_editing) { // deactivate editing mode VBOCore.toggleWidgetsPanelEditing(null); } } else { // show panel VBOCore.side_panel_on = true; VBOCore.emitMultitaskEvent(VBOCore.multitask_open_event); side_panel.addClass(VBOCore.options.panel_opts.open_class).removeClass(VBOCore.options.panel_opts.close_class); if (!VBOCore.options.admin_widgets.length) { // set focus on search widgets input with delay for the opening animation setTimeout(function() { $(VBOCore.options.panel_opts.search_selector).focus(); }, 300); } } }); // register close/dismiss button $(VBOCore.options.panel_opts.close_selector).on('click', function() { $(VBOCore.options.panel_opts.btn_trigger).trigger('click'); }); // register toggle layout buttons $(VBOCore.options.panel_opts.t_layout_large).on('click', function() { // large layout $(VBOCore.options.panel_opts.selector).addClass(VBOCore.options.panel_opts.sclass_l_large).removeClass(VBOCore.options.panel_opts.sclass_l_small); $(VBOCore.options.panel_opts.wclass_base_sel).addClass(VBOCore.options.panel_opts.wclass_l_large).removeClass(VBOCore.options.panel_opts.wclass_l_small); VBOCore.options.panel_opts.cur_widget_cls = VBOCore.options.panel_opts.sclass_l_large; }); $(VBOCore.options.panel_opts.t_layout_small).on('click', function() { // small layout $(VBOCore.options.panel_opts.selector).addClass(VBOCore.options.panel_opts.sclass_l_small).removeClass(VBOCore.options.panel_opts.sclass_l_large); $(VBOCore.options.panel_opts.wclass_base_sel).addClass(VBOCore.options.panel_opts.wclass_l_small).removeClass(VBOCore.options.panel_opts.wclass_l_large); VBOCore.options.panel_opts.cur_widget_cls = VBOCore.options.panel_opts.sclass_l_small; }); // register listener for esc key pressed $(document).keyup(function(e) { if (!VBOCore.side_panel_on) { return; } if ((e.key && e.key === "Escape") || (e.keyCode && e.keyCode == 27)) { $(VBOCore.options.panel_opts.btn_trigger).trigger('click'); } }); // register listener for input search focus $(VBOCore.options.panel_opts.search_selector).on('focus', function() { // always show add-widgets container var widget_focus_class = VBOCore.options.panel_opts.addwfs_selector.replace('.', ''); $(VBOCore.options.panel_opts.addw_selector).removeClass(widget_focus_class); $(VBOCore.options.panel_opts.addws_selector).show(); }); // register listener on input search widget $(VBOCore.options.panel_opts.search_selector).keyup(function(e) { // get the keyword to look for var keyword = $(this).val(); // counting matching widgets var matching = 0; var first_matched = null; var widget_focus_class = VBOCore.options.panel_opts.addwfs_selector.replace('.', ''); // adjust widgets to be displayed if (!keyword.length) { // show all widgets for selection $(VBOCore.options.panel_opts.addw_selector).show(); // hide "no results" $(VBOCore.options.panel_opts.search_nores).hide(); // all widgets are matching matching = $(VBOCore.options.panel_opts.addw_selector).length; } else { // make the keyword lowercase keyword = (keyword + '').toLowerCase(); // parse all widget's description tags $(VBOCore.options.panel_opts.addw_selector).each(function() { var elem = $(this); var descr = elem.find(VBOCore.options.panel_opts.wtags_selector).text(); if (VBOCore.matchString(keyword, descr)) { elem.show(); matching++; if (!first_matched) { // store the first widget that matched first_matched = elem.attr(VBOCore.options.panel_opts.addw_data_attr); } } else { elem.hide(); } }); // check how many widget matched if (matching > 0) { // hide "no results" $(VBOCore.options.panel_opts.search_nores).hide(); } else { // show "no results" $(VBOCore.options.panel_opts.search_nores).show(); } } // check for shortcuts if (!e.key) { return; } // handle Enter key press to add a widget if (e.key === "Enter") { // on Enter key pressed, open/add the first matching widget or the focussed one var load_wid_id = null; var focussed_wid = $(VBOCore.options.panel_opts.addwfs_selector + ':visible').first(); if (focussed_wid.length) { load_wid_id = focussed_wid.attr(VBOCore.options.panel_opts.addw_data_attr); } else if (first_matched) { load_wid_id = first_matched; } if (!load_wid_id) { // no widget to render found return; } if (e.shiftKey === true) { // widget multitask panel rendering VBOCore.addWidgetToPanel(load_wid_id); $(VBOCore.options.panel_opts.search_selector).trigger('blur'); return; } // widget modal rendering is the default method VBOCore.renderModalWidget(load_wid_id); return; } // handle arrow keys selection if (matching > 0 && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { // on arrow key pressed, select the next or prev widget var addws_element = $(VBOCore.options.panel_opts.addws_selector); var addws_cont_pos = addws_element.offset().top; var addws_otheight = addws_element.outerHeight(); var addws_scrolltp = addws_element.scrollTop(); if (e.key === 'ArrowDown') { var default_widg = $(VBOCore.options.panel_opts.addw_selector + ':visible').first(); } else { var default_widg = $(VBOCore.options.panel_opts.addw_selector + ':visible').last(); } var focussed_wid = $(VBOCore.options.panel_opts.addwfs_selector + ':visible').first(); var addw_height = default_widg.outerHeight(); var focussed_pos = default_widg.offset().top + addw_height; if (focussed_wid.length) { focussed_wid.removeClass(widget_focus_class); if (e.key === 'ArrowDown') { var goto_wid = focussed_wid.nextAll(VBOCore.options.panel_opts.addw_selector + ':visible').first(); } else { var goto_wid = focussed_wid.prevAll(VBOCore.options.panel_opts.addw_selector + ':visible').first(); } if (goto_wid.length) { goto_wid.addClass(widget_focus_class); focussed_pos = goto_wid.offset().top + addw_height; } else { default_widg.addClass(widget_focus_class); } } else { default_widg.addClass(widget_focus_class); } if (focussed_pos > (addws_cont_pos + addws_otheight)) { addws_element.scrollTop(focussed_pos - addws_cont_pos - addw_height + addws_scrolltp); } else if (focussed_pos < 0) { addws_element.scrollTop(0); } } }); // register listener for adding widgets $(VBOCore.options.panel_opts.addw_selector).on('click', function(e) { var widget_id = $(this).attr(VBOCore.options.panel_opts.addw_data_attr); if (!widget_id || !widget_id.length) { return false; } // determine widget rendering method if (e && e.target) { let cktarget = $(e.target); if (e.shiftKey === true || (cktarget.hasClass(VBOCore.options.panel_opts.addw_modal_cls) || cktarget.parent().hasClass(VBOCore.options.panel_opts.addw_modal_cls))) { // widget modal rendering VBOCore.renderModalWidget(widget_id); return; } } // widget multitask panel rendering VBOCore.addWidgetToPanel(widget_id); }); // register listener for updating multitask sidepanel with debounce document.addEventListener(VBOCore.options.multitask_save_event, VBOCore.debounceEvent(VBOCore.saveMultitasking, 1000)); // subscribe to event for multitask shortcut document.addEventListener(VBOCore.multitask_shortcut_event, function() { // toggle multitask panel $(VBOCore.options.panel_opts.btn_trigger).trigger('click'); }); // subscribe to event for multitask search focus shortcut document.addEventListener(VBOCore.multitask_searchfs_event, function() { // focus search multitask widgets $(VBOCore.options.panel_opts.search_selector).trigger('focus'); }); // register click event on edit widgets button $(VBOCore.options.panel_opts.editw_selector).on('click', function() { VBOCore.toggleWidgetsPanelEditing(null); }); // register detach widget buttons $('.' + VBOCore.options.panel_opts.dtcwidget_class).each(function() { let widget_wrapper = $(this).parent(VBOCore.options.panel_opts.wclass_base_sel); let detach_widget_id = widget_wrapper.attr(VBOCore.options.panel_opts.addw_data_attr); let detach_to_target = widget_wrapper.find('.' + VBOCore.options.panel_opts.dtctarget_class); if (detach_to_target.length) { // move detach wrapper onto the target (widget head) $(this).prependTo(detach_to_target); } if (!detach_widget_id) { return; } // register detach action $(this).on('click', function() { // detach widget, meaning do a modal rendering VBOCore.renderModalWidget(detach_widget_id); }); }); } /** * Registers the blur event for the search widgets input. */ static registerSearchWidgetsBlur() { if (VBOCore.options.active_listeners.hasOwnProperty('registerSearchWidgetsBlur')) { // listener is already registered return true; } $(VBOCore.options.panel_opts.search_selector).on('blur', function(e) { if (e && e.relatedTarget) { if (e.relatedTarget.classList.contains(VBOCore.options.panel_opts.addw_selector.replace('.', ''))) { // add new widget was clicked, abort hiding process or click event won't fire on target element return; } } var keyword = $(this).val(); if (!keyword.length) { // hide add-widgets container $(VBOCore.options.panel_opts.addws_selector).hide(); } }); // register flag for listener active VBOCore.options.active_listeners['registerSearchWidgetsBlur'] = 1; } /** * Removes the blur event handler for the search widgets input. */ static unregisterSearchWidgetsBlur() { if (!VBOCore.options.active_listeners.hasOwnProperty('registerSearchWidgetsBlur')) { // nothing to unregister return true; } $(VBOCore.options.panel_opts.search_selector).off('blur'); // delete flag for listener active delete VBOCore.options.active_listeners['registerSearchWidgetsBlur']; } /** * Adds a widget identifier to the multitask panel. * * @param string widget_id the widget identifier string to add. */ static addWidgetToPanel(widget_id) { if (!VBOCore.options.widget_ajax_uri || !VBOCore.options.panel_opts || !Object.keys(VBOCore.options.panel_opts).length) { throw new Error('Multitask panel options are missing'); } // prepend container to panel var widget_classes = [VBOCore.options.panel_opts.wclass_base_sel.replace('.', ''), VBOCore.options.panel_opts.cur_widget_cls]; var widget_div = '
'; var widget_elem = $(widget_div); $(VBOCore.options.panel_opts.actws_selector).prepend(widget_elem); // always hide add-widgets container $(VBOCore.options.panel_opts.addws_selector).hide(); // trigger debounced map saving event VBOCore.emitMultitaskEvent(); // register listener for input search blur VBOCore.registerSearchWidgetsBlur(); // render widget var call_method = 'render'; VBOCore.doAjax( VBOCore.options.widget_ajax_uri, { widget_id: widget_id, call: call_method, vbo_page: VBOCore.options.current_page, vbo_uri: VBOCore.options.current_page_uri, multitask: 1, }, (response) => { // display widgets editing button VBOCore.toggleWidgetsPanelEditing(true); // parse response try { var obj_res = typeof response === 'string' ? JSON.parse(response) : response; if (obj_res.hasOwnProperty(call_method)) { // populate widget HTML content and display it widget_elem.html(obj_res[call_method]).fadeIn(); // always scroll active widgets list to top $(VBOCore.options.panel_opts.actws_selector).scrollTop(0); // register detach widget button setTimeout(() => { let detach_elem = $('').addClass(VBOCore.options.panel_opts.dtcwidget_class).html(VBOCore.options.panel_opts.dtcwidget_icn); let detach_to_target = widget_elem.find('.' + VBOCore.options.panel_opts.dtctarget_class); if (detach_to_target.length) { // move detach wrapper onto the target (widget head) detach_elem.prependTo(detach_to_target); } // register detach action detach_elem.on('click', function() { // detach widget, meaning do a modal rendering VBOCore.renderModalWidget(widget_id); }); }, 500); } else { console.error('Unexpected JSON response', obj_res); } } catch(err) { console.error('could not parse JSON response', err, response); } }, (error) => { console.error(error.responseText); } ); } /** * Toggles the edit mode of the multitask widgets panel. * * @param bool added true if a widget was just added, false if it was just removed. */ static toggleWidgetsPanelEditing(added) { // check if we are currently editing var is_editing = ($('.' + VBOCore.options.panel_opts.editmode_class).length > 0); // the triggerer button var triggerer = $(VBOCore.options.panel_opts.editw_selector); // check added action status if (added === true) { // show button for edit mode triggerer.show(); return; } // grab all widgets var editing_widgets = $(VBOCore.options.panel_opts.wclass_base_sel); if (added === false) { if (!editing_widgets.length) { // hide button for edit mode after removing the last widget triggerer.removeClass(VBOCore.options.panel_opts.editw_selector.substr(1) + '-active').hide(); } return; } if (is_editing) { // deactivate editing mode editing_widgets.removeClass(VBOCore.options.panel_opts.editmode_class); $('.' + VBOCore.options.panel_opts.rmwidget_class).remove(); // toggle triggerer button active class triggerer.removeClass(VBOCore.options.panel_opts.editw_selector.substr(1) + '-active'); } else { // activate editing mode by looping through all widgets editing_widgets.each(function() { // build remove-widget element var rm_widget = $('').addClass(VBOCore.options.panel_opts.rmwidget_class).on('click', function() { VBOCore.removeWidgetFromPanel(this); }).html(VBOCore.options.panel_opts.rmwidget_icn); // add editing class and prepend removing element $(this).addClass(VBOCore.options.panel_opts.editmode_class).prepend(rm_widget); }); // toggle triggerer button active class triggerer.addClass(VBOCore.options.panel_opts.editw_selector.substr(1) + '-active'); } } /** * Handles the removal of a widget from the multitask panel. * * @param object element */ static removeWidgetFromPanel(element) { if (!element) { console.error('Invalid widget element to remove', element); return false; } var widget_cont = $(element).parent(VBOCore.options.panel_opts.wclass_base_sel); if (!widget_cont || !widget_cont.length) { console.error('Could not find widget container to remove', element); return false; } var widget_id = widget_cont.attr(VBOCore.options.panel_opts.addw_data_attr); if (!widget_id || !widget_id.length) { console.error('Empty widget id to remove', element); return false; } // find the index of the widget to remove in the panel var widget_index = $(VBOCore.options.panel_opts.wclass_base_sel).index(widget_cont); if (widget_index < 0) { console.error('Empty widget index to remove', widget_cont); return false; } // make sure the index in the array matches the id if (!VBOCore.options.admin_widgets.hasOwnProperty(widget_index) || VBOCore.options.admin_widgets[widget_index]['id'] != widget_id) { console.error('Unmatching widget index or id', VBOCore.options.admin_widgets, widget_index, widget_id); return false; } // remove this widget from the array VBOCore.options.admin_widgets.splice(widget_index, 1); // remove element from document widget_cont.remove(); // check widgets editing button status VBOCore.toggleWidgetsPanelEditing(false); // trigger debounced map saving event VBOCore.emitMultitaskEvent(); if (!VBOCore.options.admin_widgets.length) { // unregister listener for input search blur VBOCore.unregisterSearchWidgetsBlur(); } } /** * Emits an event related to the multitask features or a custom event, with optional data. */ static emitMultitaskEvent(ev_name, ev_data) { var def_ev_name = VBOCore.options.multitask_save_event; if (typeof ev_name === 'string') { def_ev_name = ev_name; } if (typeof ev_data !== 'undefined' && ev_data) { // trigger the custom event document.dispatchEvent(new CustomEvent(def_ev_name, {bubbles: true, detail: ev_data})); return; } // trigger the event document.dispatchEvent(new Event(def_ev_name)); } /** * Proxy for dispatching an event to the document with optional data. */ static emitEvent(ev_name, ev_data) { if (typeof ev_name !== 'string' || !ev_name.length) { return; } return VBOCore.emitMultitaskEvent(ev_name, ev_data); } /** * Attempts to save the multitask widgets for this page. */ static saveMultitasking() { // gather the list of active widgets var active_widgets = []; var cur_admin_widgets = []; $(VBOCore.options.panel_opts.actws_selector).find(VBOCore.options.panel_opts.wclass_base_sel).each(function() { var widget_id = $(this).attr(VBOCore.options.panel_opts.addw_data_attr); if (widget_id && widget_id.length) { // push id in list active_widgets.push(widget_id); // push object with dummy name for global widgets cur_admin_widgets.push({ id: widget_id, name: widget_id, }); } }); // update multitask widgets map for this page VBOCore.doAjax( VBOCore.options.multitask_ajax_uri, { call: 'updateMultitaskingMap', call_args: [ VBOCore.options.current_page, active_widgets, 0 ], }, (response) => { try { var obj_res = typeof response === 'string' ? JSON.parse(response) : response; if (obj_res.hasOwnProperty('result') && obj_res['result']) { // set current widgets VBOCore.setOptions({ admin_widgets: cur_admin_widgets }); } else { console.error('Unexpected or invalid JSON response', response); } } catch(err) { console.error('could not parse JSON response', err, response); } }, (error) => { console.error(error.responseText); } ); } /** * Sets up the browser notifications within the multitask panel, if supported. */ static setupNotifications() { if (!('Notification' in window)) { // browser does not support notifications $(VBOCore.options.panel_opts.notif_selector).hide(); return false; } if (Notification.permission && Notification.permission === 'granted') { // permissions were granted already $(VBOCore.options.panel_opts.notif_selector) .addClass(VBOCore.options.panel_opts.notif_on_class) .attr('title', VBOCore.options.tn_texts.notifs_enabled) .on('click', function() { // show congratulations notification VBOCore.dispatchCongratulations(); // attempt to update the service worker installation VBOCore.installServiceWorker(true).then(() => { // all good }).catch((error) => { console.error(error); }); }); return true; } // notifications supported, but perms not granted $(VBOCore.options.panel_opts.notif_selector) .addClass(VBOCore.options.panel_opts.notif_off_class) .attr('title', VBOCore.options.tn_texts.notifs_disabled); // register click-event listener on button to enable notifications $(VBOCore.options.panel_opts.notif_selector).on('click', function() { VBOCore.requestNotifPerms(); }); // subscribe to the multitask-panel-open event to show the status of the notifications document.addEventListener(VBOCore.multitask_open_event, function() { if (VBOCore.notificationsEnabled() === false) { // add "shaking" class to notifications button $(VBOCore.options.panel_opts.notif_selector).addClass('shaking'); } }); // subscribe to the multitask-panel-close event to update the status of the notifications document.addEventListener(VBOCore.multitask_close_event, function() { // always remove "shaking" class from notifications button $(VBOCore.options.panel_opts.notif_selector).removeClass('shaking'); }); } /** * Sets up the browser notifications within the given selector, if supported. * * @param string selector the element query selector. */ static suggestNotifications(selector) { if (!selector) { return false; } if (!('Notification' in window)) { // browser does not support notifications $(selector).hide(); return false; } if (Notification.permission && Notification.permission === 'granted') { // permissions were granted already $(selector) .addClass(VBOCore.options.panel_opts.notif_on_class) .attr('title', VBOCore.options.tn_texts.notifs_enabled) .on('click', function() { VBOCore.dispatchCongratulations(); }); return true; } // notifications supported, but perms not granted $(selector) .addClass(VBOCore.options.panel_opts.notif_off_class) .attr('title', VBOCore.options.tn_texts.notifs_disabled); // register click-event listener on button to enable notifications $(selector).on('click', function() { VBOCore.requestNotifPerms(selector); }); // add "shaking" class to make the selector more appealing $(selector).addClass('shaking'); // remove the "shaking" class after some time setTimeout(() => { $(selector).removeClass('shaking'); }, 2000); } /** * Tells whether the notifications are enabled, disabled, not supported. */ static notificationsEnabled() { if (!('Notification' in window)) { // not supported return null; } if (Notification.permission && Notification.permission === 'granted') { // enabled return true; } // disabled return false; } /** * Attempts to request the notifications permissions to the browser. * For security reasons, this should run upon a user gesture (click). * * @param string selector optional element query selector. */ static requestNotifPerms(selector) { if (!('Notification' in window)) { // browser does not support notifications return false; } // run permissions request in a try-catch statement to support all browsers try { // handle promise-based version to request permissions Notification.requestPermission().then((permission) => { VBOCore.handleNotifPerms(permission, selector); }); } catch(e) { // run the callback-based version Notification.requestPermission(function(permission) { VBOCore.handleNotifPerms(permission, selector); }); } } /** * Handles the notifications permission response (from callback or promise resolved). * * @param any permission permission status string or NotificationPermission object. * @param string selector optional element query selector. */ static handleNotifPerms(permission, selector) { // check the permission status from the Notification object interface if ((Notification.permission && Notification.permission === 'granted') || (typeof permission === 'string' && permission === 'granted')) { // permissions granted! $((selector || VBOCore.options.panel_opts.notif_selector)) .removeClass(VBOCore.options.panel_opts.notif_off_class) .addClass(VBOCore.options.panel_opts.notif_on_class) .attr('title', VBOCore.options.tn_texts.notifs_enabled); // dispatch an immediate notification to congratulate with the activation VBOCore.dispatchCongratulations(); return true; } else { // permissions denied :( $((selector || VBOCore.options.panel_opts.notif_selector)) .removeClass(VBOCore.options.panel_opts.notif_on_class) .addClass(VBOCore.options.panel_opts.notif_off_class) .attr('title', VBOCore.options.tn_texts.notifs_disabled); // show alert message console.error('Permission denied for enabling browser notifications', permission); alert(VBOCore.options.tn_texts.notifs_disabled_help); } return false; } /** * Dispatches an immediate notification with a congratulations text. * * @return void */ static dispatchCongratulations() { try { let notif = new Notification(VBOCore.options.tn_texts.congrats, { body: VBOCore.options.tn_texts.notifs_enabled, tag: 'vbo_notification_congrats', silent: false }); } catch(error) { alert(error); } } /** * Given a date-time string, returns a Date object representation. * * @param string dtime_str the date-time string in "Y-m-d H:i:s" format. */ static getDateTimeObject(dtime_str) { // instantiate a new date object var date_obj = new Date(); // parse date-time string let dtime_parts = dtime_str.split(' '); let date_parts = dtime_parts[0].split('-'); if (dtime_parts.length != 2 || date_parts.length != 3) { // invalid military format return date_obj; } let time_parts = dtime_parts[1].split(':'); // set accurate date-time values date_obj.setFullYear(date_parts[0]); date_obj.setMonth((parseInt(date_parts[1]) - 1)); date_obj.setDate(parseInt(date_parts[2])); date_obj.setHours(parseInt(time_parts[0])); date_obj.setMinutes(parseInt(time_parts[1])); date_obj.setSeconds(0); date_obj.setMilliseconds(0); // return the accurate date object return date_obj; } /** * Given a list of schedules, enqueues notifications to watch. * * @param array|object schedules list of or one notification object(s). * * @return bool */ static enqueueNotifications(schedules) { if (!Array.isArray(schedules) || !schedules.length) { if (typeof schedules === 'object' && schedules.hasOwnProperty('dtime')) { // convert the single schedule to an array schedules = [schedules]; } else { // invalid argument passed return false; } } for (var i in schedules) { if (!schedules.hasOwnProperty(i) || typeof schedules[i] !== 'object') { continue; } VBOCore.notifications.push(schedules[i]); } // setup the timeouts to schedule the notifications return VBOCore.scheduleNotifications(); } /** * Schedule the trigger timings for each notification. */ static scheduleNotifications() { if (!VBOCore.notifications.length) { // no notifications to be scheduled return false; } if (VBOCore.notificationsEnabled() !== true) { // notifications not enabled console.info('Browser notifications disabled or unsupported.'); return false; } // gather current date-timing information const now_date = new Date(); const now_time = now_date.getTime(); // parse all notifications to schedule the timers if not set for (let i = 0; i < VBOCore.notifications.length; i++) { let notif = VBOCore.notifications[i]; if (typeof notif !== 'object' || !notif.hasOwnProperty('dtime')) { // invalid notification object, unset it VBOCore.notifications.splice(i, 1); continue; } // check if timer has been set if (!notif.hasOwnProperty('id_timer')) { // estimate trigger timing let in_ms = 0; // check for imminent notifications if (typeof notif.dtime === 'string' && notif.dtime.indexOf('now') >= 0) { // imminent ones will be delayed by one second in_ms = 1000; } else { // check overdue date-time (notif.dtime can also be a Date object instance) let nexp = VBOCore.getDateTimeObject(notif.dtime); in_ms = nexp.getTime() - now_time; } if (in_ms > 0) { // schedule notification trigger let id_timer = setTimeout(() => { VBOCore.dispatchNotification(notif); }, in_ms); // set timer on notification object VBOCore.notifications[i]['id_timer'] = id_timer; } } } return true; } /** * Deregister all scheduled notifications. */ static unscheduleNotifications() { if (!VBOCore.notifications.length) { // no notifications scheduled return false; } for (let i = 0; i < VBOCore.notifications.length; i++) { let notif = VBOCore.notifications[i]; if (typeof notif === 'object' && notif.hasOwnProperty('id_timer')) { // unset timeout for this notification clearTimeout(notif['id_timer']); } } // reset pool VBOCore.notifications = []; } /** * Update or delete a previously scheduled notification. * * @param object match_props map of properties to match. * @param string|number newdtime the new date time to schedule (0 for deleting). * * @return null|bool true only if a notification matched. */ static updateNotification(match_props, newdtime) { if (!VBOCore.notifications.length) { // no notifications set, terminate return null; } if (typeof match_props !== 'object') { // no properties to match the notification return null; } // gather current date-timing information const now_date = new Date(); const now_time = now_date.getTime(); // parse all notifications scheduled for (let i = 0; i < VBOCore.notifications.length; i++) { let notif = VBOCore.notifications[i]; let all_matched = true; let to_matching = false; for (let prop in match_props) { if (!match_props.hasOwnProperty(prop)) { continue; } to_matching = true; if (!notif.hasOwnProperty(prop) || notif[prop] != match_props[prop]) { all_matched = false; break; } } if (all_matched && to_matching) { // notification object found if (notif.hasOwnProperty('id_timer')) { // unset previous timeout for this notification clearTimeout(notif['id_timer']); } // update or delete scheduled notification if (newdtime === 0) { // delete notification from queue VBOCore.notifications.splice(i, 1); } else { // update timing scheduler let in_ms = 0; // check for imminent notifications if (typeof newdtime === 'string' && newdtime.indexOf('now') >= 0) { // imminent ones will be delayed by one second in_ms = 1000; } else { // check overdue date-time (newdtime can also be a Date object instance) let nexp = VBOCore.getDateTimeObject(newdtime); in_ms = nexp.getTime() - now_time; } if (in_ms > 0) { // schedule notification trigger let id_timer = setTimeout(() => { VBOCore.dispatchNotification(notif); }, in_ms); // set timer on notification object VBOCore.notifications[i]['id_timer'] = id_timer; } // update date-time value on notification object VBOCore.notifications[i]['dtime'] = newdtime; } // terminate parsing and return true return true; } } // notification object not found return false; } /** * Dispatch the notification object. * Expected notification properties: * * { * id: number * type: string * dtime: string|Date * build_url: string|null * } * * @param object notif the notification object. */ static dispatchNotification(notif) { if (typeof notif !== 'object') { return false; } // subscribe to building notification data VBOCore.buildNotificationData(notif).then((data) => { // dispatch the notification // check if the click event should be registered let func_nodes; if (data.onclick && typeof data.onclick === 'string') { let callback_parts = data.onclick.split('.'); while (callback_parts.length) { // compose window static method string to avoid using eval() let tmp = callback_parts.shift(); if (!func_nodes) { func_nodes = window[tmp]; } else { func_nodes = func_nodes[tmp]; } } } // prepare properties to delete the notification from queue let match_props = {}; for (let prop in notif) { if (!notif.hasOwnProperty(prop) || prop == 'id_timer') { continue; } match_props[prop] = notif[prop]; } // check browser Notifications API if (VBOCore.notificationsEnabled() !== true) { // notifications not enabled, fallback to toast message let toast_notif_data = { title: data.title, body: data.message, icon: data.icon, delay: { min: 6000, max: 20000, tolerance: 4000, }, action: () => { VBOToast.dispose(true); }, sound: VBOCore.options.notif_audio_url }; if (func_nodes) { toast_notif_data.action = function() { func_nodes(data); VBOToast.dispose(true); }; } else if (typeof data.onclick === 'function') { toast_notif_data.action = function() { data.onclick.call(data); VBOToast.dispose(true); }; } VBOToast.enqueue(new VBOToastMessage(toast_notif_data)); // delete dispatched notification from queue VBOCore.updateNotification(match_props, 0); return; } // use the browser's native Notifications API let browser_notif = new Notification(data.title, { body: data.message, icon: data.icon, tag: 'vbo_notification', silent: false }); // check if support for Web App badge is available if (typeof navigator.setAppBadge !== 'undefined') { navigator.setAppBadge(1); } browser_notif.addEventListener('click', (e) => { // check if support for Web App badge is available if (typeof navigator.setAppBadge !== 'undefined') { navigator.clearAppBadge(); } // close the notification e.target.close(); }); if (func_nodes) { // register notification click event browser_notif.addEventListener('click', () => { func_nodes(data); }); } else if (typeof data.onclick === 'function') { browser_notif.addEventListener('click', () => { data.onclick.call(data); }); } // delete dispatched notification from queue VBOCore.updateNotification(match_props, 0); }).catch((error) => { console.error(error); }); } /** * Asynchronous build of the notification data object for dispatch. * Minimum expected notification display data properties: * * { * title: string * message: string * icon: string * onclick: function * } * * @param object notif the scheduled notification object. * * @return Promise */ static buildNotificationData(notif) { return new Promise((resolve, reject) => { // notification object validation if (typeof notif !== 'object') { reject('Invalid notification object'); return; } if (!notif.hasOwnProperty('build_url') || !notif.build_url) { // building callback not necessary if (!notif.title && !notif.message) { reject('Unexpected notification object'); return; } // we expect the notification to be built already resolve(notif); return; } // build the notification data VBOCore.doAjax( notif.build_url, { payload: JSON.stringify(notif) }, (response) => { // parse response try { var obj_res = typeof response === 'string' ? JSON.parse(response) : response; if (obj_res.hasOwnProperty('title')) { resolve(obj_res); } else { reject('Unexpected JSON response'); } } catch(err) { reject('could not parse JSON response'); } }, (error) => { reject(error.responseText); } ); }); } /** * Handle a navigation towards a given URL. * Common handler for browser notifications click. * * @param object data notification display data payload. */ static handleGoto(data) { if (typeof data !== 'object' || !data.hasOwnProperty('gotourl') || !data.gotourl) { console.error('Could not handle the goto operation', data); return; } if (typeof data.openWindow !== 'undefined' || typeof document === 'undefined') { // open a new window window.open(data.gotourl); return; } // redirect document.location.href = data.gotourl; } /** * Opens a new window with the URI to render the Push notification data. * * @param object options admin widget rendering options to bind. * * @return void */ static openAdminPushURI(options) { if (typeof options !== 'object' || !options) { throw new Error('Unable to build Admin Push URI'); } let vbo_admin_push_uri = VBOCore.options.root_uri; if (VBOCore.options.cms === 'wordpress') { vbo_admin_push_uri += 'wp-admin/admin.php?page=vikbooking'; } else { vbo_admin_push_uri += 'administrator/index.php?option=com_vikbooking'; } // build Push notification payload for rendering let push_data = { type: options.type || '', title: options.title || '', message: options.message || '', content: options, }; // open page in a new window window.open(vbo_admin_push_uri + '&push_notification=' + btoa(JSON.stringify(push_data)), '_blank'); } /** * Handle the display of a notification through a widget. * Common handler for browser notifications displayed through * a widget modal rendering. * * @param object data notification display data payload. * @param object options optional extra options for the admin widget. * @param boolean is_push whether we are coming from a Push notification. * * @return void */ static handleDisplayWidgetNotification(data, options, is_push) { try { // validate handler if (!VBOCore.options.is_vbo && !VBOCore.options.is_vcm) { throw new Error('Unable to handle the notification display from the current page'); } // validate payload if (typeof data !== 'object' || !data.hasOwnProperty('widget_id') || !data['widget_id']) { throw new Error('Invalid widget payload'); } // parse handler if (VBOCore.options.is_vcm || (is_push && !VBOCore.options.is_vbo)) { // the operation is handled by VCM (or by an external admin resource, if it's a Push notification) VBOCore.loadAdminWidgetAssets(data).then((assets) => { // append assets to DOM assets.forEach((asset) => { if (!$('link#' + asset['id']).length) { $('head').append(''); } }); // widget modal rendering handled by VCM (or an external admin resource) let modal_data = VBOCore.renderModalWidget(data['widget_id'], data, options, false); if (modal_data.hasOwnProperty('dismissed_event')) { // register event to unload all assets document.addEventListener(modal_data['dismissed_event'], () => { assets.forEach((asset) => { if ($('link#' + asset['id']).length) { $('link#' + asset['id']).remove(); } }); }); } }).catch((error) => { throw new Error(error); }); } else { // widget modal rendering handled by Vik Booking VBOCore.renderModalWidget(data['widget_id'], data, options, false); } } catch(e) { if (is_push && options.type) { // fallback to opening a new window to render the clicked Push notification VBOCore.openAdminPushURI(options); } else { // fallback to a regular link opening, if set VBOCore.handleGoto(data); } } } /** * Asynchronous loading of CSS assets required to render * an admin widget outside Vik Booking. * * @param object data the optional widget payload data. * * @return Promise */ static loadAdminWidgetAssets(data) { return new Promise((resolve, reject) => { // the remote assets URI must be set if (!VBOCore.options.assets_ajax_uri) { reject('Missing remote assets URI'); return; } if (typeof data !== 'object') { data = {}; } // make the request VBOCore.doAjax( VBOCore.options.assets_ajax_uri, data, (response) => { // parse response try { var obj_res = typeof response === 'string' ? JSON.parse(response) : response; if (!Array.isArray(obj_res)) { reject('Unexpected JSON response'); } resolve(obj_res); } catch(err) { reject('could not parse JSON response'); } }, (error) => { reject(error.responseText); } ); }); } /** * Register the latest data to watch for the preloaded admin widgets. * * @param object watch_data */ static registerWatchData(watch_data) { if (typeof watch_data !== 'object' || watch_data == null) { VBOCore.widgets_watch_data = null; return false; } // set watch-data map VBOCore.widgets_watch_data = watch_data; // schedule watching interval if (VBOCore.watch_data_interval == null) { VBOCore.watch_data_interval = window.setInterval(VBOCore.watchWidgetsData, 60000); } // set up broadcast channels to connect all browsing contexts if (typeof BroadcastChannel !== 'undefined') { // set up watch-data broadcast channel if (!VBOCore.broadcast_watch_data) { // start broadcast channel VBOCore.broadcast_watch_data = new BroadcastChannel('vikbooking_watch_data'); // register to the "on broadcast message received" event VBOCore.broadcast_watch_data.onmessage = (event) => { if (event && event.data) { // update watch data map for next schedule to avoid dispatching duplicate notifications VBOCore.widgets_watch_data = event.data; } }; } // set up push-data broadcast channel if (!VBOCore.broadcast_push_data) { // start broadcast channel VBOCore.broadcast_push_data = new BroadcastChannel('vikbooking_push_data'); // register to the "on broadcast message received" event VBOCore.broadcast_push_data.onmessage = (event) => { if (event && event.data) { // update pushed data map for the next watching event VBOCore.widgets_pushed_data.push(event.data); } }; } // set up watch-events broadcast channel if (!VBOCore.broadcast_watch_events) { // start broadcast channel VBOCore.broadcast_watch_events = new BroadcastChannel('vikbooking_watch_events'); // register to the "on broadcast message received" event VBOCore.broadcast_watch_events.onmessage = (event) => { if (event && event.data && typeof event.data === 'object') { // scan and dispatch the events data received let events_data = event.data; for (let ev_name in events_data) { if (!events_data.hasOwnProperty(ev_name)) { continue; } // emit the event VBOCore.emitEvent(ev_name, events_data[ev_name]); } } }; } } } /** * Periodic widgets data watching for new events. */ static watchWidgetsData() { if (typeof VBOCore.widgets_watch_data !== 'object' || VBOCore.widgets_watch_data == null) { return; } // call on new events VBOCore.doAjax( VBOCore.options.watchdata_ajax_uri, { watch_data: JSON.stringify(VBOCore.widgets_watch_data), pushed_data: JSON.stringify(VBOCore.widgets_pushed_data), }, (response) => { try { var obj_res = typeof response === 'string' ? JSON.parse(response) : response; if (obj_res.hasOwnProperty('watch_data')) { // update watch data map for next schedule VBOCore.widgets_watch_data = obj_res['watch_data']; // check for notifications if (obj_res.hasOwnProperty('notifications') && Array.isArray(obj_res['notifications'])) { // dispatch notifications for (var i = 0; i < obj_res['notifications'].length; i++) { VBOCore.dispatchNotification(obj_res['notifications'][i]); } // post message onto broadcast channel for any other browsing context if (VBOCore.broadcast_watch_data && obj_res['notifications'].length) { // this will avoid dispatching duplicate notifications VBOCore.broadcast_watch_data.postMessage(VBOCore.widgets_watch_data); } } // check for notification events to dispatch if (obj_res.hasOwnProperty('events') && Array.isArray(obj_res['events'])) { // parse and dispatch all the events obj_res['events'].forEach((events_data) => { if (typeof events_data !== 'object') { return; } // parse all the events for this widget watched data for (let ev_name in events_data) { if (!events_data.hasOwnProperty(ev_name)) { continue; } // emit the event VBOCore.emitEvent(ev_name, events_data[ev_name]); } // post message onto broadcast channel for any other browsing context if (VBOCore.broadcast_watch_events) { // this will trigger the events on any other browsing context VBOCore.broadcast_watch_events.postMessage(events_data); } }); } } else { console.error('Unexpected or invalid JSON response', response); } } catch(err) { console.error('could not parse JSON response', err, response); } }, (error) => { console.error(error.responseText); } ); } /** * Widget modal rendering. * * @param string widget_id the widget identifier to render. * @param any data the optional multitask data to inject. * @param object options optional list of extra options to render the widget with Multitask data. * @param bool hide_panel if false, the multitask panel elements will remain unchanged. * * @return object the multitask data injected object merged with modal options. */ static renderModalWidget(widget_id, data, options, hide_panel) { /** * Adjust arguments for BC with previous ordering between options * and hide_panel, which used to be inverted. Useful also to shorten * the way the method can be invoked in case no options are needed. */ if (typeof options === 'boolean') { // switch argument values hide_panel = options; options = null; } if (typeof data !== 'object' || data == null) { // always treat data as an object data = {}; } if (typeof options !== 'object' || options == null) { // always treat options as an object options = data._options || {}; } // build the default widget payload let modal_js_id = Math.floor(Math.random() * 100000); let modal_title = options._push && options.title ? options.title : VBOCore.options.tn_texts.admin_widget; let widget_data = { _modalRendering: 1, _modalJsId: modal_js_id, _modalTitle: modal_title, _options: options, }; // merge default payload options with given options data = Object.assign(widget_data, data); // define unique modal event names to avoid conflicts let dismiss_event = 'vbo-dismiss-widget-modal' + modal_js_id; let loading_event = 'vbo-loading-widget-modal' + modal_js_id; // define the modal options let modal_options = { suffix: 'widget_modal', extra_class: 'vbo-modal-widget vbo-modal-rounded vbo-modal-tall vbo-modal-nofooter', title: data._modalTitle, body_prepend: true, lock_scroll: true, draggable: true, dismiss_event: dismiss_event, loading_event: loading_event, loading_body: VBOCore.options.default_loading_body, dismissed_event: VBOCore.options.widget_modal_dismissed + modal_js_id, event_data: widget_data, }; // check if options should rewrite some modal-options if (options.modal_options) { modal_options = Object.assign(modal_options, options.modal_options); } // display modal let widget_modal = VBOCore.displayModal(modal_options); if (hide_panel !== false) { // blur search widget input, hide multitask panel $(VBOCore.options.panel_opts.search_selector).trigger('blur'); VBOCore.emitEvent(VBOCore.multitask_shortcut_event); } // start loading VBOCore.emitEvent(loading_event); // render admin widget VBOCore.renderAdminWidget(widget_id, data).then((content) => { // stop loading and append widget content to modal VBOCore.emitEvent(loading_event); widget_modal.append(content); // register an admin menu action for the rendered widget VBOCore.fetchWidgetDetails(widget_id).then((details) => { try { // work on Local Storage to register the widget data VBOCore.registerAdminMenuAction({ name: details['name'], href: 'JavaScript: void(0);', widget: widget_id, icon: details['icon'], style: details['style'], }, 'widgets'); } catch(e) { console.error(e); } }).catch((error) => { console.error(error); }); }).catch((error) => { // dismiss modal and display error VBOCore.emitEvent(dismiss_event); alert(error); }); return Object.assign(data, modal_options); } /** * Renders an admin widget. * * @param string widget_id the widget identifier string to add. * @param any data the optional multitask data to inject. * * @return Promise */ static renderAdminWidget(widget_id, data) { return new Promise((resolve, reject) => { if (!VBOCore.options.widget_ajax_uri) { reject('Could not load admin widget'); return; } var call_method = 'render'; VBOCore.doAjax( VBOCore.options.widget_ajax_uri, { widget_id: widget_id, call: call_method, vbo_page: VBOCore.options.current_page, vbo_uri: VBOCore.options.current_page_uri, multitask: 1, multitask_data: data, }, (response) => { // parse response try { var obj_res = typeof response === 'string' ? JSON.parse(response) : response; if (!obj_res.hasOwnProperty(call_method)) { reject('Unexpected JSON response'); return; } resolve(obj_res[call_method]); } catch(err) { reject('could not parse JSON response'); } }, (error) => { reject(error.responseText); } ); }); } /** * Fetches the details for a specific widget. * * @param string widget_id the widget identifier string to fetch. * * @return Promise * * @since 1.6.7 */ static fetchWidgetDetails(widget_id) { return new Promise((resolve, reject) => { if (!VBOCore.options.widget_ajax_uri) { reject('Could not load admin widget'); return; } var call_method = 'getWidgetDetails'; VBOCore.doAjax( VBOCore.options.widget_ajax_uri, { widget_id: widget_id, call: call_method, return: 1, }, (response) => { // parse response try { var obj_res = typeof response === 'string' ? JSON.parse(response) : response; if (!obj_res.hasOwnProperty(call_method)) { reject('Unexpected JSON response'); return; } resolve(obj_res[call_method]); } catch(err) { reject('could not parse JSON response'); } }, (error) => { reject(error.responseText); } ); }); } /** * Helper method used to copy the text of an * input element within the clipboard. * * Clipboard copy will take effect only in case the * function is handled by a DOM event explicitly * triggered by the user, such as a "click". * * @param any input The input containing the text to copy. * * @return Promise */ static copyToClipboard(input) { // register and return promise return new Promise((resolve, reject) => { // define a fallback function var fallback = function(input) { // focus the input input.focus(); // select the text inside the input input.select(); try { // try to copy with shell command var copy = document.execCommand('copy'); if (copy) { // copied successfully resolve(copy); } else { // unable to copy reject(copy); } } catch (error) { // unable to exec the command reject(error); } }; // look for navigator clipboard if (!navigator || !navigator.clipboard) { // navigator clipboard not supported, use fallback fallback(input); return; } // try to copy within the clipboard by using the navigator navigator.clipboard.writeText((input.val ? input.val() : input.value)).then(() => { // copied successfully resolve(true); }).catch((error) => { // revert to the fallback fallback(input); }); }); } /** * Helper method used to display a modal window dinamycally. * * @param object options The options to render the modal. * * @return object The modal content element wrapper. */ static displayModal(options) { var def_options = { suffix: (Math.floor(Math.random() * 100000)) + '', extra_class: null, title: '', body: '', body_prepend: false, lock_scroll: false, draggable: false, footer_left: null, footer_right: null, dismiss_event: null, dismissed_event: null, onDismiss: null, loading_event: null, loading_body: VBOCore.options.default_loading_body, event_data: null, }; // merge default options with given options options = Object.assign(def_options, options); // create the modal dismiss function var modal_dismiss_fn = (e) => { custom_modal.fadeOut(400, () => { // invoke callback for onDismiss if (typeof options.onDismiss === 'function') { options.onDismiss.call(custom_modal, e); } // check if modal did register to the loading event if (options.loading_event) { // we can now un-register from the loading event until a new modal is displayed and will register to it again document.removeEventListener(options.loading_event, modal_handle_loading_event_fn); } // check if we should fire the given modal dismissed event if (options.dismissed_event) { VBOCore.emitEvent(options.dismissed_event, options.event_data); } // check if body scroll lock should be removed if (options.lock_scroll) { $('body').removeClass('vbo-modal-lock-scroll'); } // remove modal from DOM custom_modal.remove(); }); }; // create the modal loading event handler function var modal_handle_loading_event_fn = (e) => { // toggle modal loading if ($('.vbo-modal-overlay-content-backdrop').length) { // hide loading $('.vbo-modal-overlay-content-backdrop').remove(); // do not proceed return; } // show loading var modal_loading = $('').addClass('vbo-modal-overlay-content-backdrop'); var modal_loading_body = $('').addClass('vbo-modal-overlay-content-backdrop-body'); if (options.loading_body) { modal_loading_body.append(options.loading_body); } modal_loading.append(modal_loading_body); // append backdrop loading to modal content modal_content.prepend(modal_loading); }; // start the modal position variables var modal_pos_x = 0, modal_pos_y = 0; // create the modal drag-start (mousedown) event handler function var modal_dragstart_fn = (e) => { e = e || window.event; e.preventDefault(); if (typeof e.clientX === 'undefined' || typeof e.clientY === 'undefined') { // unsupported return; } if (e.target) { e.target.style.cursor = 'move'; } // store the initial modal (cursor) position modal_pos_x = e.clientX; modal_pos_y = e.clientY; // register mouseup and mousemove events document.onmouseup = modal_dragstop_fn; document.onmousemove = modal_dragmove_fn; }; // create the modal drag-stop (mouseup) event handler function var modal_dragstop_fn = (e) => { e = e || window.event; if (e.target) { e.target.style.cursor = 'auto'; } // unregister mousemove event if (document.onmousemove == modal_dragmove_fn) { document.onmousemove = null; } // unregister mouseup event if (document.onmouseup == modal_dragstop_fn) { document.onmouseup = null; } }; // create the modal drag-move (mousemove) event handler function var modal_dragmove_fn = (e) => { e = e || window.event; e.preventDefault(); if (typeof e.clientX === 'undefined' || typeof e.clientY === 'undefined') { // unsupported return; } // calculate the new modal (cursor) position let new_modal_pos_x = modal_pos_x - e.clientX; let new_modal_pos_y = modal_pos_y - e.clientY; // update current modal (cursor) position modal_pos_x = e.clientX; modal_pos_y = e.clientY; // find the modal element let modal_element = e.target.closest('.vbo-modal-overlay-content'); if (!modal_element) { return; } // set the modal position modal_element.style.top = (modal_element.offsetTop - new_modal_pos_y) + 'px'; modal_element.style.left = (modal_element.offsetLeft - new_modal_pos_x) + 'px'; }; // build modal content var custom_modal = $('').addClass('vbo-modal-overlay-block vbo-modal-overlay-' + options.suffix).css('display', 'block'); var modal_dismiss = $('').addClass('vbo-modal-overlay-close'); modal_dismiss.on('click', modal_dismiss_fn); custom_modal.append(modal_dismiss); var modal_content = $('').addClass('vbo-modal-overlay-content vbo-modal-overlay-content-' + options.suffix); if (options.extra_class && typeof options.extra_class === 'string') { modal_content.addClass(options.extra_class); } var modal_head = $('').addClass('vbo-modal-overlay-content-head'); var modal_head_close = $('').addClass('vbo-modal-overlay-close-times').html('×'); modal_head_close.on('click', modal_dismiss_fn); modal_head.append(options.title).append(modal_head_close); // check if the modal head should be draggable if (options.draggable) { // register the event to allow dragging modal_head.addClass('vbo-modal-head-draggable'); modal_head.on('mousedown', modal_dragstart_fn); } var modal_body = $('').addClass('vbo-modal-overlay-content-body vbo-modal-overlay-content-body-scroll'); var modal_content_wrapper = $('').addClass('vbo-modal-' + options.suffix + '-wrap'); if (typeof options.body === 'string') { modal_content_wrapper.html(options.body); } else { modal_content_wrapper.append(options.body); } modal_body.append(modal_content_wrapper); // modal footer var modal_footer = null; if (options.footer_left || options.footer_right) { modal_footer = $('').addClass('vbo-modal-overlay-content-footer'); if (options.footer_left) { var modal_footer_left = $('').addClass('vbo-modal-overlay-content-footer-left').append(options.footer_left); modal_footer.append(modal_footer_left); } if (options.footer_right) { var modal_footer_right = $('').addClass('vbo-modal-overlay-content-footer-right').append(options.footer_right); modal_footer.append(modal_footer_right); } } // finalize modal contents modal_content.append(modal_head).append(modal_body); if (modal_footer) { modal_content.append(modal_footer); } custom_modal.append(modal_content); // register to the dismiss event if (options.dismiss_event) { // listen to the event that will dismiss the modal document.addEventListener(options.dismiss_event, function vbo_core_handle_dismiss_event(e) { // make sure the same event won't propagate again, unless a new modal is displayed (multiple displayModal calls) e.target.removeEventListener(e.type, vbo_core_handle_dismiss_event); // invoke the modal dismiss function modal_dismiss_fn(e); }); // declare the function to detect the Escape key pressed const vbo_core_dismiss_event_modal_escape = (e) => { if (!e.key || e.key != 'Escape') { return; } // immediately unregister from this event once fired document.removeEventListener(e.type, vbo_core_dismiss_event_modal_escape); // trigger the actual dismiss event VBOCore.emitEvent(options.dismiss_event); }; // listen to the Escape keyup event to dismiss the modal document.addEventListener('keyup', vbo_core_dismiss_event_modal_escape); } // register to the toggle-loading event if (options.loading_event) { // let a function handle it so that removing the event listener will be doable document.addEventListener(options.loading_event, modal_handle_loading_event_fn); } // append (or prepend) modal to body if ($('.vbo-modal-overlay-' + options.suffix).length) { $('.vbo-modal-overlay-' + options.suffix).remove(); } if (options.body_prepend) { // prepend to body if ($('body > .vbo-modal-overlay-block').length) { // we've got other modals prepended to the body, so go after the last one $('body > .vbo-modal-overlay-block').last().after(custom_modal); } else { // place the modal right as the first child node of body $('body').prepend(custom_modal); } } else { // append to body $('body').append(custom_modal); } // check if scroll should be locked on the whole page body for a "sticky" modal if (options.lock_scroll) { $('body').addClass('vbo-modal-lock-scroll'); } // return the content wrapper element of the new modal return modal_content_wrapper; } /** * Debounce technique to group a flurry of events into one single event. */ static debounceEvent(func, wait, immediate) { var timeout; return function() { var context = this, args = arguments; var later = function() { timeout = null; if (!immediate) func.apply(context, args); }; var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) { func.apply(context, args); } } } /** * Throttle guarantees a constant flow of events at a given time interval. * Runs immediately when the event takes place, but can be delayed. */ static throttleEvent(method, delay) { var time = Date.now(); return function() { if ((time + delay - Date.now()) < 0) { method(); time = Date.now(); } } } /** * Alternative throttle technique for event listeners. * * @param function callback the callback to invoke. * @param int time the time (in ms) to throttle. * * @return void * * @since 1.6.8 */ static throttleTimer(callback, time) { if (VBOCore.throttle_timer) { // prevent more executions return; } // turn throttle timer on VBOCore.throttle_timer = true; setTimeout(() => { // run the callback with the given delay callback(); // turn throttle timer off VBOCore.throttle_timer = false; }, time); } /** * Tells whether localStorage is supported. * * @return boolean */ static storageSupported() { return typeof localStorage !== 'undefined'; } /** * Gets an item from localStorage. * * @param string keyName the storage key identifier. * * @return any */ static storageGetItem(keyName) { if (!VBOCore.storageSupported()) { return null; } return localStorage.getItem(keyName); } /** * Sets an item to localStorage. * * @param string keyName the storage key identifier. * @param any value the value to store. * * @return boolean */ static storageSetItem(keyName, value) { if (!VBOCore.storageSupported()) { return false; } try { if (typeof value === 'object') { value = JSON.stringify(value); } localStorage.setItem(keyName, value); } catch(e) { return false; } return true; } /** * Removes an item from localStorage. * * @param string keyName the storage key identifier. * * @return boolean */ static storageRemoveItem(keyName) { if (!VBOCore.storageSupported()) { return false; } localStorage.removeItem(keyName); return true; } /** * Returns the name of the storage identifier for the given scope. * * @param string scope the admin menu scope. * * @return string the requested admin menu storage identifier. */ static getStorageScopeName(scope) { let storage_scope_name = VBOCore.options.admin_menu_actions_nm; if (typeof scope === 'string' && scope.length) { if (scope.indexOf('.') !== 0) { scope = '.' + scope; } storage_scope_name += scope; } return storage_scope_name; } /** * Returns a list of admin menu action objects or an empty array. * * @param string scope the admin menu scope. * * @return Array */ static getAdminMenuActions(scope) { let menu_actions = VBOCore.storageGetItem(VBOCore.getStorageScopeName(scope)); if (!menu_actions) { return []; } try { menu_actions = JSON.parse(menu_actions); if (!Array.isArray(menu_actions) || !menu_actions.length) { menu_actions = []; } } catch(e) { return []; } return menu_actions; } /** * Builds an admin menu action object with a proper href property. * * @param object action the action to build. * * @return object */ static buildAdminMenuAction(action) { if (typeof action !== 'object' || !action || !action.hasOwnProperty('name')) { throw new Error('Invalid action object'); } var action_base = action.hasOwnProperty('href') && typeof action['href'] == 'string' ? action['href'] : window.location.href; var action_url; if (action_base.toLowerCase().indexOf('javascript') === 0 && action.hasOwnProperty('widget')) { // no need to prepare the action URL ("JavaScript: void(0)") as the link will use a JS callback return action; } if (action_base.indexOf('http') !== 0) { // relative URL action_url = new URL(action_base, window.location.href); } else { // absolute URL action_url = new URL(action_base); } // build proper href with a relative URL action['href'] = action_url.pathname + action_url.search; return action; } /** * Registers an admin menu action object. * * @param object action the action to build. * @param string scope the admin menu scope. * * @return boolean */ static registerAdminMenuAction(action, scope) { // build menu action object let menu_action_entry = VBOCore.buildAdminMenuAction(action); let menu_actions = VBOCore.getAdminMenuActions(scope); // make sure we are not pushing a duplicate and count pinned actions let pinned_actions = 0; let unpinned_index = []; for (let i = 0; i < menu_actions.length; i++) { // avoid duplicate entries if (!menu_action_entry.hasOwnProperty('widget') && menu_actions[i]['href'] == menu_action_entry['href']) { // duplicate link return false; } if (menu_action_entry.hasOwnProperty('widget') && menu_actions[i].hasOwnProperty('widget') && menu_actions[i]['href'] == menu_action_entry['widget']) { // duplicate widget return false; } if (menu_actions[i].hasOwnProperty('pinned') && menu_actions[i]['pinned']) { pinned_actions++; } else { unpinned_index.push(i); } } if (pinned_actions >= VBOCore.options.admin_menu_maxactions) { // no more space to register a new menu action for this admin menu return false; } // splice or pop before prepending to keep current indexes let tot_menu_actions = menu_actions.length; if (++tot_menu_actions > VBOCore.options.admin_menu_maxactions) { if (unpinned_index.length) { menu_actions.splice(unpinned_index[unpinned_index.length - 1], 1); } else { menu_actions.pop(); } } // prepend new admin menu action menu_actions.unshift(menu_action_entry); return VBOCore.storageSetItem(VBOCore.getStorageScopeName(scope), menu_actions); } /** * Updates an existing admin menu action object. * * @param object action the action to build. * @param string scope the admin menu scope. * @param number index optional menu action index. * * @return boolean */ static updateAdminMenuAction(action, scope, index) { // build menu action object let menu_action_entry = VBOCore.buildAdminMenuAction(action); let menu_actions = VBOCore.getAdminMenuActions(scope); if (!menu_actions.length) { return false; } if (typeof index === 'undefined') { // find the proper index to update by href for (let i = 0; i < menu_actions.length; i++) { if (menu_actions[i].hasOwnProperty('widget') && menu_action_entry.hasOwnProperty('widget') && menu_actions[i]['widget'] == menu_action_entry['widget']) { // existing entry for widget found index = i; break; } else if (!menu_action_entry.hasOwnProperty('widget') && menu_actions[i]['href'] == menu_action_entry['href']) { // existing entry for link found index = i; break; } } } if (isNaN(index) || !(index in menu_actions)) { // menu entry index not found return false; } menu_actions[index] = menu_action_entry; return VBOCore.storageSetItem(VBOCore.getStorageScopeName(scope), menu_actions); } /** * Checks if the current menu actions should be filled with some other actions. * No update is made over the Local Storage through this method. * * @param array menu_actions menu actions to fill. * @param array widgets_actions default actions to use for filling. * @param string scope the admin menu scope. * * @return array the menu actions eventually filled. */ static fillAdminMenuActions(menu_actions, widgets_actions, scope) { if (!Array.isArray(menu_actions) || !Array.isArray(widgets_actions)) { return []; } if (!menu_actions.length || !widgets_actions.length || menu_actions.length >= VBOCore.options.admin_menu_maxactions) { return menu_actions; } var current_widgets = []; menu_actions.forEach((action, index) => { current_widgets.push(action['widget']); }); for (var i = 0; i < widgets_actions.length; i++) { if (menu_actions.length >= VBOCore.options.admin_menu_maxactions) { // abort for filling completed break; } if (!widgets_actions[i].hasOwnProperty('widget')) { // action must be for a widget continue; } if (current_widgets.includes(widgets_actions[i]['widget'])) { // there cannot be duplicate entries continue; } // fill menu action menu_actions.push(widgets_actions[i]); } return menu_actions; } } /** * These used to be private static properties (static #options), * but they are only supported by quite recent browsers (especially Safari). * It's too premature, so we decided to keep the class properties public * without declaring them as static inside the class declaration. * * @var object */ VBOCore.options = { is_vbo: false, is_vcm: false, cms: 'wordpress', widget_ajax_uri: null, assets_ajax_uri: null, multitask_ajax_uri: null, watchdata_ajax_uri: null, current_page: null, current_page_uri: null, root_uri: '/', client: 'admin', panel_opts: {}, admin_widgets: [], notif_audio_url: '', active_listeners: {}, tn_texts: { notifs_enabled: 'Browser notifications are enabled!', notifs_disabled: 'Browser notifications are disabled', notifs_disabled_help: "Could not enable browser notifications.\nThis feature is available only in secure contexts (HTTPS).", admin_widget: 'Admin widget', congrats: 'Congratulations!', }, default_loading_body: '....', multitask_save_event: 'vbo-admin-multitask-save', multitask_open_event: 'vbo-admin-multitask-open', multitask_close_event: 'vbo-admin-multitask-close', multitask_shortcut_ev: 'vbo_multitask_shortcut', multitask_searchfs_ev: 'vbo_multitask_search_focus', widget_modal_rendered: 'vbo-admin-widget-modal-rendered', widget_modal_dismissed: 'vbo-widget-modal-dismissed', admin_menu_maxactions: 3, admin_menu_actions_nm: 'vikbooking.admin_menu.actions', service_worker_path: '', service_worker_scope: './', push: { application_key: '', ajax_url: '', }, push_options: { storage_endp_id: 'vbo_push_subscr_endp', allowed_notif_types: [ 'Book', 'Modify', 'Cancel', 'Request', 'CancelRequest', 'Inquiry', 'CancelInquiry', 'Chat', 'Info', ], }, }; /** * @var bool */ VBOCore.side_panel_on = false; /** * @var bool * @since 1.6.8 */ VBOCore.throttle_timer = false; /** * @var array */ VBOCore.notifications = []; /** * @var object */ VBOCore.widgets_watch_data = null; /** * @var number */ VBOCore.watch_data_interval = null; /** * @var object * @since 1.6.3 */ VBOCore.broadcast_watch_data = null; /** * @var object * @since 1.6.5 */ VBOCore.broadcast_push_data = null; /** * @var object * @since 1.6.8 */ VBOCore.broadcast_watch_events = null; /** * @var array