feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run
- Base: wintercms/winter branch 1.2 (full framework) - Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS - Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication - Partials: hero (offline-first), features, modes (offline/nube toggle), screenshots, pricing (3 planes), comparison, FAQ, CTA - Plugin VivesPOS.Site with ContactForm - Dockerfile: PHP 8.2 Apache, port 80, healthcheck - Added winter/wn-pages, blog, sitemap, seo plugins - Active theme set to vivespos
This commit is contained in:
196
modules/system/assets/js/snowboard/extras/AssetLoader.js
Normal file
196
modules/system/assets/js/snowboard/extras/AssetLoader.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import Singleton from '../abstracts/Singleton';
|
||||
|
||||
/**
|
||||
* Asset Loader.
|
||||
*
|
||||
* Provides simple asset loading functionality for Snowboard, making it easy to pre-load images or
|
||||
* include JavaScript or CSS assets on the fly.
|
||||
*
|
||||
* By default, this loader will listen to any assets that have been requested to load in an AJAX
|
||||
* response, such as responses from a component.
|
||||
*
|
||||
* You can also load assets manually by calling the following:
|
||||
*
|
||||
* ```js
|
||||
* Snowboard.addPlugin('assetLoader', AssetLoader);
|
||||
* Snowboard.assetLoader().processAssets(assets);
|
||||
* ```
|
||||
*
|
||||
* @copyright 2021 Winter.
|
||||
* @author Ben Thomson <git@alfreido.com>
|
||||
*/
|
||||
export default class AssetLoader extends Singleton {
|
||||
/**
|
||||
* Event listeners.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
listens() {
|
||||
return {
|
||||
ajaxLoadAssets: 'load',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies.
|
||||
*
|
||||
* @returns {Array}
|
||||
*/
|
||||
dependencies() {
|
||||
return [
|
||||
'url',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and load assets.
|
||||
*
|
||||
* The `assets` property of this method requires an object with any of the following keys and an
|
||||
* array of paths:
|
||||
*
|
||||
* - `js`: An array of JavaScript URLs to load
|
||||
* - `css`: An array of CSS stylesheet URLs to load
|
||||
* - `img`: An array of image URLs to pre-load
|
||||
*
|
||||
* Both `js` and `css` files will be automatically injected, however `img` files will not.
|
||||
*
|
||||
* This method will return a Promise that resolves when all required assets are loaded. If an
|
||||
* asset fails to load, this Promise will be rejected.
|
||||
*
|
||||
* ESLint *REALLY* doesn't like this code, but ignore it. It's the only way it works.
|
||||
*
|
||||
* @param {Object} assets
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async load(assets) {
|
||||
if (assets.js && assets.js.length > 0) {
|
||||
for (const script of assets.js) {
|
||||
try {
|
||||
await this.loadScript(script);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.css && assets.css.length > 0) {
|
||||
for (const style of assets.css) {
|
||||
try {
|
||||
await this.loadStyle(style);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (assets.img && assets.img.length > 0) {
|
||||
for (const image of assets.img) {
|
||||
try {
|
||||
await this.loadImage(image);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects and loads a JavaScript URL into the DOM.
|
||||
*
|
||||
* The script will be appended before the closing `</body>` tag.
|
||||
*
|
||||
* @param {String} script
|
||||
* @returns {Promise}
|
||||
*/
|
||||
loadScript(script) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Resolve script URL
|
||||
script = this.snowboard.url().asset(script);
|
||||
|
||||
// Check that script is not already loaded
|
||||
const loaded = document.querySelector(`script[src="${script}"]`);
|
||||
if (loaded) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create script
|
||||
const domScript = document.createElement('script');
|
||||
domScript.setAttribute('type', 'text/javascript');
|
||||
domScript.setAttribute('src', script);
|
||||
domScript.addEventListener('load', () => {
|
||||
this.snowboard.globalEvent('assetLoader.loaded', 'script', script, domScript);
|
||||
resolve();
|
||||
});
|
||||
domScript.addEventListener('error', () => {
|
||||
this.snowboard.globalEvent('assetLoader.error', 'script', script, domScript);
|
||||
reject(new Error(`Unable to load script file: "${script}"`));
|
||||
});
|
||||
document.body.append(domScript);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects and loads a CSS stylesheet into the DOM.
|
||||
*
|
||||
* The stylesheet will be appended before the closing `</head>` tag.
|
||||
*
|
||||
* @param {String} style
|
||||
* @returns {Promise}
|
||||
*/
|
||||
loadStyle(style) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Resolve style URL
|
||||
style = this.snowboard.url().asset(style);
|
||||
|
||||
// Check that stylesheet is not already loaded
|
||||
const loaded = document.querySelector(`link[rel="stylesheet"][href="${style}"]`);
|
||||
if (loaded) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create stylesheet
|
||||
const domCss = document.createElement('link');
|
||||
domCss.setAttribute('rel', 'stylesheet');
|
||||
domCss.setAttribute('href', style);
|
||||
domCss.addEventListener('load', () => {
|
||||
this.snowboard.globalEvent('assetLoader.loaded', 'style', style, domCss);
|
||||
resolve();
|
||||
});
|
||||
domCss.addEventListener('error', () => {
|
||||
this.snowboard.globalEvent('assetLoader.error', 'style', style, domCss);
|
||||
reject(new Error(`Unable to load stylesheet file: "${style}"`));
|
||||
});
|
||||
document.head.append(domCss);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-loads an image.
|
||||
*
|
||||
* The image will not be injected into the DOM.
|
||||
*
|
||||
* @param {String} image
|
||||
* @returns {Promise}
|
||||
*/
|
||||
loadImage(image) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Resolve script URL
|
||||
image = this.snowboard.url().asset(image);
|
||||
|
||||
const img = new Image();
|
||||
img.addEventListener('load', () => {
|
||||
this.snowboard.globalEvent('assetLoader.loaded', 'image', image, img);
|
||||
resolve();
|
||||
});
|
||||
img.addEventListener('error', () => {
|
||||
this.snowboard.globalEvent('assetLoader.error', 'image', image, img);
|
||||
reject(new Error(`Unable to load image file: "${image}"`));
|
||||
});
|
||||
img.src = image;
|
||||
});
|
||||
}
|
||||
}
|
||||
70
modules/system/assets/js/snowboard/extras/AttachLoading.js
Normal file
70
modules/system/assets/js/snowboard/extras/AttachLoading.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import Singleton from '../abstracts/Singleton';
|
||||
|
||||
/**
|
||||
* Allows attaching a loading class on elements that an AJAX request is targeting.
|
||||
*
|
||||
* @copyright 2021 Winter.
|
||||
* @author Ben Thomson <git@alfreido.com>
|
||||
*/
|
||||
export default class AttachLoading extends Singleton {
|
||||
/**
|
||||
* Defines dependenices.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
dependencies() {
|
||||
return ['request'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines listeners.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
listens() {
|
||||
return {
|
||||
ajaxStart: 'ajaxStart',
|
||||
ajaxDone: 'ajaxDone',
|
||||
};
|
||||
}
|
||||
|
||||
ajaxStart(promise, request) {
|
||||
if (!request.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.element.tagName === 'FORM') {
|
||||
const loadElements = request.element.querySelectorAll('[data-attach-loading]');
|
||||
if (loadElements.length > 0) {
|
||||
loadElements.forEach((element) => {
|
||||
element.classList.add(this.getLoadingClass(element));
|
||||
});
|
||||
}
|
||||
} else if (request.element.dataset.attachLoading !== undefined) {
|
||||
request.element.classList.add(this.getLoadingClass(request.element));
|
||||
}
|
||||
}
|
||||
|
||||
ajaxDone(data, request) {
|
||||
if (!request.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.element.tagName === 'FORM') {
|
||||
const loadElements = request.element.querySelectorAll('[data-attach-loading]');
|
||||
if (loadElements.length > 0) {
|
||||
loadElements.forEach((element) => {
|
||||
element.classList.remove(this.getLoadingClass(element));
|
||||
});
|
||||
}
|
||||
} else if (request.element.dataset.attachLoading !== undefined) {
|
||||
request.element.classList.remove(this.getLoadingClass(request.element));
|
||||
}
|
||||
}
|
||||
|
||||
getLoadingClass(element) {
|
||||
return (element.dataset.attachLoading !== undefined && element.dataset.attachLoading !== '')
|
||||
? element.dataset.attachLoading
|
||||
: 'wn-loading';
|
||||
}
|
||||
}
|
||||
222
modules/system/assets/js/snowboard/extras/DataConfig.js
Normal file
222
modules/system/assets/js/snowboard/extras/DataConfig.js
Normal file
@@ -0,0 +1,222 @@
|
||||
import PluginBase from '../abstracts/PluginBase';
|
||||
|
||||
/**
|
||||
* Data configuration provider.
|
||||
*
|
||||
* Provides a mechanism for passing configuration data through an element's data attributes. This
|
||||
* is generally used for widgets or UI interactions to configure them.
|
||||
*
|
||||
* @copyright 2022 Winter.
|
||||
* @author Ben Thomson <git@alfreido.com>
|
||||
*/
|
||||
export default class DataConfig extends PluginBase {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param {PluginBase} instance
|
||||
* @param {HTMLElement} element
|
||||
* @param {Object} localConfig
|
||||
*/
|
||||
construct(instance, element, localConfig) {
|
||||
if (instance instanceof PluginBase === false) {
|
||||
throw new Error('You must provide a Snowboard plugin to enable data configuration');
|
||||
}
|
||||
if (element instanceof HTMLElement === false) {
|
||||
throw new Error('Data configuration can only be extracted from HTML elements');
|
||||
}
|
||||
|
||||
this.instance = instance;
|
||||
this.element = element;
|
||||
this.localConfig = localConfig || {};
|
||||
this.instanceConfig = {};
|
||||
this.acceptedConfigs = {};
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the config for this instance.
|
||||
*
|
||||
* If the `config` parameter is unspecified, returns the entire configuration.
|
||||
*
|
||||
* @param {string} config
|
||||
*/
|
||||
get(config) {
|
||||
if (config === undefined) {
|
||||
return this.instanceConfig;
|
||||
}
|
||||
|
||||
if (this.instanceConfig[config] !== undefined) {
|
||||
return this.instanceConfig[config];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the config for this instance.
|
||||
*
|
||||
* This allows you to override, at runtime, any configuration value as necessary.
|
||||
*
|
||||
* @param {string} config
|
||||
* @param {any} value
|
||||
* @param {boolean} persist
|
||||
*/
|
||||
set(config, value, persist) {
|
||||
if (config === undefined) {
|
||||
throw new Error('You must provide a configuration key to set');
|
||||
}
|
||||
|
||||
this.instanceConfig[config] = value;
|
||||
|
||||
if (persist === true) {
|
||||
this.element.dataset[config] = value;
|
||||
this.localConfig[config] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the configuration from the element.
|
||||
*
|
||||
* This will allow you to make changes to the data config on a DOM level and re-apply them
|
||||
* to the config on the JavaScript side.
|
||||
*/
|
||||
refresh() {
|
||||
this.acceptedConfigs = this.getAcceptedConfigs();
|
||||
this.instanceConfig = this.processConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the available configurations that can be set through the data config.
|
||||
*
|
||||
* If an instance has an `acceptAllDataConfigs` property, set to `true`, then all data
|
||||
* attributes will be available as configuration values. This can be a security concern, so
|
||||
* tread carefully.
|
||||
*
|
||||
* Otherwise, available configurations will be determined by the keys available in an object
|
||||
* returned by a `defaults()` method in the instance.
|
||||
*
|
||||
* @returns {string[]|boolean}
|
||||
*/
|
||||
getAcceptedConfigs() {
|
||||
if (
|
||||
this.instance.acceptAllDataConfigs !== undefined
|
||||
&& this.instance.acceptAllDataConfigs === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
this.instance.defaults !== undefined
|
||||
&& typeof this.instance.defaults === 'function'
|
||||
&& typeof this.instance.defaults() === 'object'
|
||||
) {
|
||||
return Object.keys(this.instance.defaults());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default values for the instance.
|
||||
*
|
||||
* This will be an empty object if the instance either does not have a `defaults()` method, or
|
||||
* the method itself does not return an object.
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
getDefaults() {
|
||||
if (
|
||||
this.instance.defaults !== undefined
|
||||
&& typeof this.instance.defaults === 'function'
|
||||
&& typeof this.instance.defaults() === 'object'
|
||||
) {
|
||||
return this.instance.defaults();
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the configuration.
|
||||
*
|
||||
* Loads up the defaults, then populates it with any configuration values provided by the data
|
||||
* attributes, based on the rules of the accepted configurations.
|
||||
*
|
||||
* This configuration object is then cached and available through `config.get()` calls.
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
processConfig() {
|
||||
const config = this.getDefaults();
|
||||
|
||||
if (this.acceptedConfigs === false) {
|
||||
return config;
|
||||
}
|
||||
|
||||
/* eslint-disable */
|
||||
for (const key in this.element.dataset) {
|
||||
if (this.acceptedConfigs === true || this.acceptedConfigs.includes(key)) {
|
||||
config[key] = this.coerceValue(this.element.dataset[key]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in this.localConfig) {
|
||||
if (this.acceptedConfigs === true || this.acceptedConfigs.includes(key)) {
|
||||
config[key] = this.localConfig[key];
|
||||
}
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerces configuration values for JavaScript.
|
||||
*
|
||||
* Takes the string value returned from the data attribute and coerces it into a more suitable
|
||||
* type for JavaScript processing.
|
||||
*
|
||||
* @param {*} value
|
||||
* @returns {*}
|
||||
*/
|
||||
coerceValue(value) {
|
||||
const stringValue = String(value);
|
||||
|
||||
// Null value
|
||||
if (stringValue === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Undefined value
|
||||
if (stringValue === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Base64 value
|
||||
if (stringValue.startsWith('base64:')) {
|
||||
const base64str = stringValue.replace(/^base64:/, '');
|
||||
const decoded = atob(base64str);
|
||||
return this.coerceValue(decoded);
|
||||
}
|
||||
|
||||
// Boolean value
|
||||
if (['true', 'yes'].includes(stringValue.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (['false', 'no'].includes(stringValue.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Numeric value
|
||||
if (/^[-+]?[0-9]+(\.[0-9]+)?$/.test(stringValue)) {
|
||||
return Number(stringValue);
|
||||
}
|
||||
|
||||
// JSON value
|
||||
try {
|
||||
return this.snowboard.jsonParser().parse(stringValue);
|
||||
} catch (e) {
|
||||
return (stringValue === '') ? true : stringValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
150
modules/system/assets/js/snowboard/extras/Flash.js
Normal file
150
modules/system/assets/js/snowboard/extras/Flash.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import PluginBase from '../abstracts/PluginBase';
|
||||
|
||||
/**
|
||||
* Provides flash messages for the CMS.
|
||||
*
|
||||
* Flash messages will pop up at the top center of the page and will remain for 7 seconds by default. Hovering over
|
||||
* the message will reset and pause the timer. Clicking on the flash message will dismiss it.
|
||||
*
|
||||
* Arguments:
|
||||
* - "message": The content of the flash message. HTML is accepted.
|
||||
* - "type": The type of flash message. This is appended as a class to the flash message itself.
|
||||
* - "duration": How long the flash message will stay visible for, in seconds. Default: 7 seconds.
|
||||
*
|
||||
* Usage:
|
||||
* Snowboard.flash('This is a flash message', 'info', 8);
|
||||
*
|
||||
* @copyright 2021 Winter.
|
||||
* @author Ben Thomson <git@alfreido.com>
|
||||
*/
|
||||
export default class Flash extends PluginBase {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param {string} message
|
||||
* @param {string} type
|
||||
* @param {Number} duration
|
||||
*/
|
||||
construct(message, type, duration) {
|
||||
this.message = message;
|
||||
this.type = type || 'default';
|
||||
this.duration = Number(duration || 7);
|
||||
|
||||
if (this.duration < 0) {
|
||||
throw new Error('Flash duration must be a positive number, or zero');
|
||||
}
|
||||
|
||||
this.clear();
|
||||
this.timer = null;
|
||||
this.flashTimer = null;
|
||||
this.create();
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines dependencies.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
dependencies() {
|
||||
return ['transition'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructor.
|
||||
*
|
||||
* This will ensure the flash message is removed and timeout is cleared if the module is removed.
|
||||
*/
|
||||
destruct() {
|
||||
if (this.timer !== null) {
|
||||
window.clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
if (this.flashTimer) {
|
||||
this.flashTimer.remove();
|
||||
}
|
||||
|
||||
if (this.flash) {
|
||||
this.flash.remove();
|
||||
this.flash = null;
|
||||
this.flashTimer = null;
|
||||
}
|
||||
|
||||
super.destruct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the flash message.
|
||||
*/
|
||||
create() {
|
||||
this.snowboard.globalEvent('flash.create', this);
|
||||
|
||||
this.flash = document.createElement('DIV');
|
||||
this.flash.innerHTML = this.message;
|
||||
this.flash.classList.add('flash-message', this.type);
|
||||
this.flash.removeAttribute('data-control');
|
||||
this.flash.addEventListener('click', () => this.remove());
|
||||
this.flash.addEventListener('mouseover', () => this.stopTimer());
|
||||
this.flash.addEventListener('mouseout', () => this.startTimer());
|
||||
|
||||
if (this.duration > 0) {
|
||||
this.flashTimer = document.createElement('DIV');
|
||||
this.flashTimer.classList.add('flash-timer');
|
||||
this.flash.appendChild(this.flashTimer);
|
||||
} else {
|
||||
this.flash.classList.add('no-timer');
|
||||
}
|
||||
|
||||
// Add to body
|
||||
document.body.appendChild(this.flash);
|
||||
|
||||
this.snowboard.transition(this.flash, 'show', () => {
|
||||
this.startTimer();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the flash message.
|
||||
*/
|
||||
remove() {
|
||||
this.snowboard.globalEvent('flash.remove', this);
|
||||
|
||||
this.stopTimer();
|
||||
|
||||
this.snowboard.transition(this.flash, 'hide', () => {
|
||||
this.flash.remove();
|
||||
this.flash = null;
|
||||
this.destruct();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all flash messages available on the page.
|
||||
*/
|
||||
clear() {
|
||||
document.querySelectorAll('body > div.flash-message').forEach((element) => element.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the timer for this flash message.
|
||||
*/
|
||||
startTimer() {
|
||||
if (this.duration === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timerTrans = this.snowboard.transition(this.flashTimer, 'timeout', null, `${this.duration}.0s`, true);
|
||||
this.timer = window.setTimeout(() => this.remove(), this.duration * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the timer for this flash message.
|
||||
*/
|
||||
stopTimer() {
|
||||
if (this.timerTrans) {
|
||||
this.timerTrans.cancel();
|
||||
}
|
||||
if (this.timer) {
|
||||
window.clearTimeout(this.timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
modules/system/assets/js/snowboard/extras/FlashListener.js
Normal file
72
modules/system/assets/js/snowboard/extras/FlashListener.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import Singleton from '../abstracts/Singleton';
|
||||
|
||||
/**
|
||||
* Defines a default listener for flash events.
|
||||
*
|
||||
* Connects the Flash plugin to various events that use flash messages.
|
||||
*
|
||||
* @copyright 2021 Winter.
|
||||
* @author Ben Thomson <git@alfreido.com>
|
||||
*/
|
||||
export default class FlashListener extends Singleton {
|
||||
/**
|
||||
* Defines dependenices.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
dependencies() {
|
||||
return ['flash'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines listeners.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
listens() {
|
||||
return {
|
||||
ready: 'ready',
|
||||
ajaxErrorMessage: 'ajaxErrorMessage',
|
||||
ajaxFlashMessages: 'ajaxFlashMessages',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Do flash messages for PHP flash responses.
|
||||
*/
|
||||
ready() {
|
||||
document.querySelectorAll('[data-control="flash-message"]').forEach((element) => {
|
||||
this.snowboard.flash(
|
||||
element.innerHTML,
|
||||
element.dataset.flashType,
|
||||
element.dataset.flashDuration,
|
||||
);
|
||||
|
||||
element.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a flash message for AJAX errors.
|
||||
*
|
||||
* @param {string} message
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
ajaxErrorMessage(message) {
|
||||
this.snowboard.flash(message, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows flash messages returned directly from AJAX functionality.
|
||||
*
|
||||
* @param {Object} messages
|
||||
*/
|
||||
ajaxFlashMessages(messages) {
|
||||
Object.entries(messages).forEach((entry) => {
|
||||
const [cssClass, message] = entry;
|
||||
this.snowboard.flash(message, cssClass);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
215
modules/system/assets/js/snowboard/extras/FormValidation.js
Normal file
215
modules/system/assets/js/snowboard/extras/FormValidation.js
Normal file
@@ -0,0 +1,215 @@
|
||||
import Singleton from '../abstracts/Singleton';
|
||||
|
||||
/**
|
||||
* Adds AJAX-driven form validation to Snowboard requests.
|
||||
*
|
||||
* Documentation for this feature can be found here:
|
||||
* https://wintercms.com/docs/snowboard/extras#ajax-validation
|
||||
*
|
||||
* @copyright 2022 Winter.
|
||||
* @author Ben Thomson <git@alfreido.com>
|
||||
*/
|
||||
export default class FormValidation extends Singleton {
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
construct() {
|
||||
this.errorBags = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines listeners.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
listens() {
|
||||
return {
|
||||
ready: 'ready',
|
||||
ajaxStart: 'clearValidation',
|
||||
ajaxValidationErrors: 'doValidation',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready event handler.
|
||||
*/
|
||||
ready() {
|
||||
this.collectErrorBags(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves validation errors from an AJAX response and passes them through to the error bags.
|
||||
*
|
||||
* This handler returns false to cancel any further validation handling, and prevents the flash
|
||||
* message that is displayed by default for field errors in AJAX requests from showing.
|
||||
*
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {Object} invalidFields
|
||||
* @param {Request} request
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
doValidation(form, invalidFields, request) {
|
||||
if (request.element && request.element.dataset.requestValidate === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorBags = this.errorBags.filter((errorBag) => errorBag.form === form);
|
||||
errorBags.forEach((errorBag) => {
|
||||
this.showErrorBag(errorBag, invalidFields);
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any validation errors in the given form.
|
||||
*
|
||||
* @param {Promise} promise
|
||||
* @param {Request} request
|
||||
* @returns {void}
|
||||
*/
|
||||
clearValidation(promise, request) {
|
||||
if (request.element && request.element.dataset.requestValidate === undefined) {
|
||||
return;
|
||||
}
|
||||
if (!request.form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorBags = this.errorBags.filter((errorBag) => errorBag.form === request.form);
|
||||
errorBags.forEach((errorBag) => {
|
||||
this.hideErrorBag(errorBag);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects error bags (elements with "data-validate-error" attribute) and links them to a
|
||||
* placeholder and form.
|
||||
*
|
||||
* The error bags will be initially hidden, and will only show when validation errors occur.
|
||||
*
|
||||
* @param {HTMLElement} rootNode
|
||||
*/
|
||||
collectErrorBags(rootNode) {
|
||||
rootNode.querySelectorAll('[data-validate-error], [data-validate-for]').forEach((errorBag) => {
|
||||
const form = errorBag.closest('form[data-request-validate]');
|
||||
|
||||
// If this error bag does not reside within a validating form, remove it
|
||||
if (!form) {
|
||||
errorBag.parentNode.removeChild(errorBag);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find message list node, if available
|
||||
let messageListElement = null;
|
||||
if (errorBag.matches('[data-validate-error]')) {
|
||||
messageListElement = errorBag.querySelector('[data-message]');
|
||||
}
|
||||
|
||||
// Create a placeholder node
|
||||
const placeholder = document.createComment('');
|
||||
|
||||
// Register error bag and replace with placeholder
|
||||
const errorBagData = {
|
||||
element: errorBag,
|
||||
form,
|
||||
validateFor: (errorBag.dataset.validateFor)
|
||||
? errorBag.dataset.validateFor.split(/\s*,\s*/)
|
||||
: '*',
|
||||
placeholder,
|
||||
messageListElement: (messageListElement)
|
||||
? messageListElement.cloneNode(true)
|
||||
: null,
|
||||
messageListAnchor: null,
|
||||
customMessage: (errorBag.dataset.validateFor)
|
||||
? (errorBag.textContent !== '' || errorBag.childNodes.length > 0)
|
||||
: false,
|
||||
};
|
||||
|
||||
// If an message list element exists, create another placeholder to act as an anchor point
|
||||
if (messageListElement) {
|
||||
const messageListAnchor = document.createComment('');
|
||||
messageListElement.parentNode.replaceChild(messageListAnchor, messageListElement);
|
||||
errorBagData.messageListAnchor = messageListAnchor;
|
||||
}
|
||||
|
||||
errorBag.parentNode.replaceChild(placeholder, errorBag);
|
||||
|
||||
this.errorBags.push(errorBagData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides an error bag, replacing the error messages with a placeholder node.
|
||||
*
|
||||
* @param {Object} errorBag
|
||||
*/
|
||||
hideErrorBag(errorBag) {
|
||||
if (errorBag.element.isConnected) {
|
||||
errorBag.element.parentNode.replaceChild(errorBag.placeholder, errorBag.element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error bag with the given invalid fields.
|
||||
*
|
||||
* @param {Object} errorBag
|
||||
* @param {Object} invalidFields
|
||||
*/
|
||||
showErrorBag(errorBag, invalidFields) {
|
||||
if (!this.errorBagValidatesField(errorBag, invalidFields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!errorBag.element.isConnected) {
|
||||
errorBag.placeholder.parentNode.replaceChild(errorBag.element, errorBag.placeholder);
|
||||
}
|
||||
|
||||
if (errorBag.validateFor !== '*') {
|
||||
if (!errorBag.customMessage) {
|
||||
const firstField = Object.keys(invalidFields)
|
||||
.filter((field) => errorBag.validateFor.includes(field))
|
||||
.shift();
|
||||
[errorBag.element.innerHTML] = invalidFields[firstField];
|
||||
}
|
||||
} else if (errorBag.messageListElement) {
|
||||
// Remove previous error messages
|
||||
errorBag.element.querySelectorAll('[data-validation-message]').forEach((message) => {
|
||||
message.parentNode.removeChild(message);
|
||||
});
|
||||
|
||||
Object.entries(invalidFields).forEach((entry) => {
|
||||
const [, errors] = entry;
|
||||
|
||||
errors.forEach((error) => {
|
||||
const messageElement = errorBag.messageListElement.cloneNode(true);
|
||||
messageElement.dataset.validationMessage = '';
|
||||
messageElement.innerHTML = error;
|
||||
errorBag.messageListAnchor.after(messageElement);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
[errorBag.element.innerHTML] = invalidFields[Object.keys(invalidFields).shift()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a given error bag applies for the given invalid fields.
|
||||
*
|
||||
* @param {Object} errorBag
|
||||
* @param {Object} invalidFields
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
errorBagValidatesField(errorBag, invalidFields) {
|
||||
if (errorBag.validateFor === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.keys(invalidFields)
|
||||
.filter((field) => errorBag.validateFor.includes(field))
|
||||
.length > 0;
|
||||
}
|
||||
}
|
||||
94
modules/system/assets/js/snowboard/extras/StripeLoader.js
Normal file
94
modules/system/assets/js/snowboard/extras/StripeLoader.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import Singleton from '../abstracts/Singleton';
|
||||
|
||||
/**
|
||||
* Displays a stripe at the top of the page that indicates loading.
|
||||
*
|
||||
* @copyright 2021 Winter.
|
||||
* @author Ben Thomson <git@alfreido.com>
|
||||
*/
|
||||
export default class StripeLoader extends Singleton {
|
||||
/**
|
||||
* Defines dependenices.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
dependencies() {
|
||||
return ['request'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines listeners.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
listens() {
|
||||
return {
|
||||
ready: 'ready',
|
||||
ajaxStart: 'ajaxStart',
|
||||
};
|
||||
}
|
||||
|
||||
ready() {
|
||||
this.counter = 0;
|
||||
|
||||
this.createStripe();
|
||||
}
|
||||
|
||||
ajaxStart(promise, request) {
|
||||
if (request.loading === false || request.options.stripe === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.show();
|
||||
|
||||
promise.then(() => {
|
||||
this.hide();
|
||||
}).catch(() => {
|
||||
this.hide();
|
||||
});
|
||||
}
|
||||
|
||||
createStripe() {
|
||||
this.indicator = document.createElement('DIV');
|
||||
this.stripe = document.createElement('DIV');
|
||||
this.stripeLoaded = document.createElement('DIV');
|
||||
|
||||
this.indicator.classList.add('stripe-loading-indicator', 'loaded');
|
||||
this.stripe.classList.add('stripe');
|
||||
this.stripeLoaded.classList.add('stripe-loaded');
|
||||
|
||||
this.indicator.appendChild(this.stripe);
|
||||
this.indicator.appendChild(this.stripeLoaded);
|
||||
|
||||
document.body.appendChild(this.indicator);
|
||||
}
|
||||
|
||||
show() {
|
||||
this.counter += 1;
|
||||
|
||||
const newStripe = this.stripe.cloneNode(true);
|
||||
this.indicator.appendChild(newStripe);
|
||||
this.stripe.remove();
|
||||
this.stripe = newStripe;
|
||||
|
||||
if (this.counter > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.indicator.classList.remove('loaded');
|
||||
document.body.classList.add('wn-loading');
|
||||
}
|
||||
|
||||
hide(force) {
|
||||
this.counter -= 1;
|
||||
|
||||
if (force === true) {
|
||||
this.counter = 0;
|
||||
}
|
||||
|
||||
if (this.counter <= 0) {
|
||||
this.indicator.classList.add('loaded');
|
||||
document.body.classList.remove('wn-loading');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Singleton from '../abstracts/Singleton';
|
||||
|
||||
/**
|
||||
* Embeds the "extras" stylesheet into the page, if it is not loaded through the theme.
|
||||
*
|
||||
* @copyright 2021 Winter.
|
||||
* @author Ben Thomson <git@alfreido.com>
|
||||
*/
|
||||
export default class StylesheetLoader extends Singleton {
|
||||
/**
|
||||
* Defines listeners.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
listens() {
|
||||
return {
|
||||
ready: 'ready',
|
||||
};
|
||||
}
|
||||
|
||||
ready() {
|
||||
let stylesLoaded = false;
|
||||
|
||||
// Determine if stylesheet is already loaded
|
||||
document.querySelectorAll('link[rel="stylesheet"]').forEach((css) => {
|
||||
if (css.href.endsWith('/modules/system/assets/css/snowboard.extras.css')) {
|
||||
stylesLoaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!stylesLoaded) {
|
||||
const stylesheet = document.createElement('link');
|
||||
stylesheet.setAttribute('rel', 'stylesheet');
|
||||
stylesheet.setAttribute('href', this.snowboard.url().asset('/modules/system/assets/css/snowboard.extras.css'));
|
||||
document.head.appendChild(stylesheet);
|
||||
}
|
||||
}
|
||||
}
|
||||
206
modules/system/assets/js/snowboard/extras/Transition.js
Normal file
206
modules/system/assets/js/snowboard/extras/Transition.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import PluginBase from '../abstracts/PluginBase';
|
||||
|
||||
/**
|
||||
* Provides transition support for elements.
|
||||
*
|
||||
* Transition allows CSS transitions to be controlled and callbacks to be run once completed. It works similar to Vue
|
||||
* transitions with 3 stages of transition, and classes assigned to the element with the transition name suffixed with
|
||||
* the stage of transition:
|
||||
*
|
||||
* - `in`: A class assigned to the element for the first frame of the transition, removed afterwards. This should be
|
||||
* used to define the initial state of the transition.
|
||||
* - `active`: A class assigned to the element for the duration of the transition. This should be used to define the
|
||||
* transition itself.
|
||||
* - `out`: A class assigned to the element after the first frame of the transition and kept to the end of the
|
||||
* transition. This should define the end state of the transition.
|
||||
*
|
||||
* Usage:
|
||||
* Snowboard.transition(document.element, 'transition', () => {
|
||||
* console.log('Remove element after 7 seconds');
|
||||
* this.remove();
|
||||
* }, '7s');
|
||||
*
|
||||
* @copyright 2021 Winter.
|
||||
* @author Ben Thomson <git@alfreido.com>
|
||||
*/
|
||||
export default class Transition extends PluginBase {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param {HTMLElement} element The element to transition
|
||||
* @param {string} transition The name of the transition, this prefixes the stages of transition.
|
||||
* @param {Function} callback An optional callback to call when the transition ends.
|
||||
* @param {Number} duration An optional override on the transition duration. Must be specified as 's' (secs) or 'ms' (msecs).
|
||||
* @param {Boolean} trailTo If true, the "out" class will remain after the end of the transition.
|
||||
*/
|
||||
construct(element, transition, callback, duration, trailTo) {
|
||||
if (element instanceof HTMLElement === false) {
|
||||
throw new Error('A HTMLElement must be provided for transitioning');
|
||||
}
|
||||
this.element = element;
|
||||
|
||||
if (typeof transition !== 'string') {
|
||||
throw new Error('Transition name must be specified as a string');
|
||||
}
|
||||
this.transition = transition;
|
||||
|
||||
if (callback && typeof callback !== 'function') {
|
||||
throw new Error('Callback must be a valid function');
|
||||
}
|
||||
this.callback = callback;
|
||||
|
||||
if (duration) {
|
||||
this.duration = this.parseDuration(duration);
|
||||
} else {
|
||||
this.duration = null;
|
||||
}
|
||||
|
||||
this.trailTo = (trailTo === true);
|
||||
|
||||
this.doTransition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps event classes to the given transition state.
|
||||
*
|
||||
* @param {...any} args
|
||||
* @returns {Array}
|
||||
*/
|
||||
eventClasses(...args) {
|
||||
const eventClasses = {
|
||||
in: `${this.transition}-in`,
|
||||
active: `${this.transition}-active`,
|
||||
out: `${this.transition}-out`,
|
||||
};
|
||||
|
||||
if (args.length === 0) {
|
||||
return Object.values(eventClasses);
|
||||
}
|
||||
|
||||
const returnClasses = [];
|
||||
Object.entries(eventClasses).forEach((entry) => {
|
||||
const [key, value] = entry;
|
||||
|
||||
if (args.indexOf(key) !== -1) {
|
||||
returnClasses.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
return returnClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the transition.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
doTransition() {
|
||||
// Add duration override
|
||||
if (this.duration !== null) {
|
||||
this.element.style.transitionDuration = this.duration;
|
||||
}
|
||||
|
||||
this.resetClasses();
|
||||
|
||||
// Start transition - show "in" and "active" classes
|
||||
this.eventClasses('in', 'active').forEach((eventClass) => {
|
||||
this.element.classList.add(eventClass);
|
||||
});
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
// Ensure a transition exists
|
||||
if (window.getComputedStyle(this.element)['transition-duration'] !== '0s') {
|
||||
// Listen for the transition to end
|
||||
this.element.addEventListener('transitionend', () => this.onTransitionEnd(), {
|
||||
once: true,
|
||||
});
|
||||
window.requestAnimationFrame(() => {
|
||||
this.element.classList.remove(this.eventClasses('in')[0]);
|
||||
this.element.classList.add(this.eventClasses('out')[0]);
|
||||
});
|
||||
} else {
|
||||
this.resetClasses();
|
||||
|
||||
if (this.callback) {
|
||||
this.callback.apply(this.element);
|
||||
}
|
||||
|
||||
this.destruct();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function when the transition ends.
|
||||
*
|
||||
* When a transition ends, the instance of the transition is automatically destructed.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
onTransitionEnd() {
|
||||
this.eventClasses('active', (!this.trailTo) ? 'out' : '').forEach((eventClass) => {
|
||||
this.element.classList.remove(eventClass);
|
||||
});
|
||||
|
||||
if (this.callback) {
|
||||
this.callback.apply(this.element);
|
||||
}
|
||||
|
||||
// Remove duration override
|
||||
if (this.duration !== null) {
|
||||
this.element.style.transitionDuration = null;
|
||||
}
|
||||
|
||||
this.destruct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a transition.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
cancel() {
|
||||
this.element.removeEventListener('transitionend', () => this.onTransitionEnd, {
|
||||
once: true,
|
||||
});
|
||||
|
||||
this.resetClasses();
|
||||
|
||||
// Remove duration override
|
||||
if (this.duration !== null) {
|
||||
this.element.style.transitionDuration = null;
|
||||
}
|
||||
|
||||
// Call destructor
|
||||
this.destruct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the classes, removing any transition classes.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
resetClasses() {
|
||||
this.eventClasses().forEach((eventClass) => {
|
||||
this.element.classList.remove(eventClass);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a given duration and converts it to a "ms" value.
|
||||
*
|
||||
* @param {String} duration
|
||||
* @returns {String}
|
||||
*/
|
||||
parseDuration(duration) {
|
||||
const parsed = /^([0-9]+(\.[0-9]+)?)(m?s)?$/.exec(duration);
|
||||
const amount = Number(parsed[1]);
|
||||
const unit = (parsed[3] === 's')
|
||||
? 'sec'
|
||||
: 'msec';
|
||||
|
||||
return (unit === 'sec')
|
||||
? `${amount * 1000}ms`
|
||||
: `${Math.floor(amount)}ms`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user