import noUiSlider from 'nouislider';
import FilterOption from './filter-option';
import Settings from '../../../../helpers/settings';
import Utils from '../../../../helpers/utils';
import Labels from "../../../../helpers/labels";
import Globals from '../../../../helpers/globals';
import FilterOptionEnum from '../../../../enum/filter-option-enum';
import FilterTreeEnum from '../../../../enum/filter-tree-enum';
import FilterApi from '../../../../api/filter-api';
/**
* Filter option with displayType = 'range'
* We use noUiSlider library: https://refreshless.com/nouislider/
* @extends FilterOption
*/
class FilterOptionRangeSlider extends FilterOption {
init() {
super.init();
this.requestInstantly = this.filterTreeType == FilterTreeEnum.FilterTreeType.VERTICAL || Settings.getSettingValue('general.requestInstantly');
}
/**
* Get the content html template for DisplayType.RANGE
* Depending on 'this.hideInputElement' it returns different templates with and without the input field
* @returns {string} Raw html template
*/
getBlockContentTemplate() {
if (this.hideInputElement) {
return `
<div>
<div class="boost-pfs-filter-option-range-slider"></div>
</div>
`;
} else {
if (this.style == 'style3') {
return `
<div class="boost-pfs-filter-block-content-inner">
<div class="boost-pfs-filter-option-range-slider"></div>
<div class="boost-pfs-filter-option-range-amount">
<input class="boost-pfs-filter-option-range-amount-min" type="text" readonly />
<input class="boost-pfs-filter-option-range-amount-max" type="text" readonly />
</div>
</div>
`;
} else {
return `
<div class="boost-pfs-filter-block-content-inner">
<div class="boost-pfs-filter-option-range-amount">
<input class="boost-pfs-filter-option-range-amount-min" type="text" />
<div class="boost-pfs-filter-option-range-amount-split"> - </div>
<input class="boost-pfs-filter-option-range-amount-max" type="text" />
</div>
<div class="boost-pfs-filter-option-range-slider"></div>
</div>
`;
}
}
}
isRender() {
if (this.status == FilterOptionEnum.Status.ACTIVE) {
var enableOneValueRangeSlider = Settings.getSettingValue('general.oneValueRangeSlider');
var onlyHaveOneValue = this.isNumberRangeSlider ? (this.rangeMax == this.rangeMin) : (this.valuesData.length <= 1);
return (!onlyHaveOneValue || enableOneValueRangeSlider);
}
return false;
}
render() {
super.render();
// Add style class
this.$element.addClass('boost-pfs-filter-option-range-' + this.style);
// Render range and input values
this.renderRangeValue();
this.renderInputField();
}
/**
* Render the range slider using noUiSlider library
*/
renderRangeValue() {
this.setCurrentValues();
if (this.noUiSlider) {
this.noUiSlider.set([this.currentMin, this.currentMax]);
}
}
/**
* Render the input field.
* This won't be called if hideInputElement=true
*/
renderInputField() {
if (this.$element && !this.hideInputElement) {
if (!this.$minInputElement || !this.$maxInputElement) {
this.$minInputElement = this.$element.find('.boost-pfs-filter-option-range-amount-min');
this.$maxInputElement = this.$element.find('.boost-pfs-filter-option-range-amount-max');
}
var formatNumberMin = this.buildNumberLabel(this.currentMin);
var formatNumberMax = this.buildNumberLabel(this.currentMax);
this.$minInputElement.val(formatNumberMin);
this.$maxInputElement.val(formatNumberMax);
}
}
renderTextLabelPosition() {
var valueLabels = this.$element.find('.noUi-value');
var middle = 100 / (this.valuesData.length * 2);
valueLabels.each((index, element) => {
var leftStyle = element.style.left;
if (typeof leftStyle == 'string') {
var left = parseFloat(leftStyle.replace('%', ''));
var updatedLeft = left + middle;
element.style.left = updatedLeft + '%';
}
})
}
bindEvents() {
super.bindEvents();
if (this.$element) {
// Creates slider
this.$rangeSliderElement = this.$element.find('.boost-pfs-filter-option-range-slider');
this.noUiSlider = noUiSlider.create(this.$rangeSliderElement[0], this.getSliderConfig());
// Update the input field while dragging
this.noUiSlider.on('slide', this.onDrag.bind(this));
// Request filter when finished dragging
this.noUiSlider.on('change', this.onFinishDragging.bind(this));
// Bind input box event
if (!this.hideInputElement) {
this.$minInputElement.on('change', this.onChangeInput.bind(this, 'min'));
this.$maxInputElement.on('change', this.onChangeInput.bind(this, 'max'));
}
// Render the label position for text slider (style5)
if (!this.isNumberRangeSlider) {
this.renderTextLabelPosition();
}
// Update aria-label for ADA
this.$element.find('.boost-pfs-filter-option-range-amount-min, .noUi-handle-lower').attr('aria-label', Labels.ada.minValue);
this.$element.find('.boost-pfs-filter-option-range-amount-max, .noUi-handle-upper').attr('aria-label', Labels.ada.maxValue);
}
}
/**
* On changing the input field
* Only works for number
*/
onChangeInput(inputType) {
var minInputValue = inputType == 'min' ? this.$minInputElement.val().trim() : this.currentMin.toString();
var maxInputValue = inputType == 'max' ? this.$maxInputElement.val().trim() : this.currentMax.toString();
// Remove delimiter from value
if (this.thousandSeparator) {
minInputValue = minInputValue.split(this.thousandSeparator).join('');
maxInputValue = maxInputValue.split(this.thousandSeparator).join('');
}
if (this.decimalSeparator && this.decimalSeparator != '.') {
minInputValue = minInputValue.replace(this.decimalSeparator, '.');
maxInputValue = maxInputValue.replace(this.decimalSeparator, '.');
}
// If user enter a wrong value, reset the value to original
if (!this.isNumberRangeSlider
|| minInputValue == null || maxInputValue == null
|| minInputValue == '' || maxInputValue == ''
|| isNaN(minInputValue) || isNaN(maxInputValue)) {
this.renderInputField();
return;
}
minInputValue = parseFloat(minInputValue);
maxInputValue = parseFloat(maxInputValue);
// Min value > max value, return
if (minInputValue > maxInputValue) {
this.renderInputField();
return;
}
// Assigns the current value
this.currentMin = Math.max(minInputValue, this.rangeMin);
this.currentMax = Math.min(maxInputValue, this.rangeMax);
if (this.noUiSlider) {
this.noUiSlider.set([this.currentMin, this.currentMax]);
}
// Call API
this.onFinishDragging();
}
/**
* On dragging the slider, update the input field.
* This is called on noUiSlider's 'slide' event: https://refreshless.com/nouislider/events-callbacks/
*/
onDrag() {
var minMaxValues = this.noUiSlider.get();
if (minMaxValues != null) {
// Two values slider
if (Array.isArray(minMaxValues) && minMaxValues.length == 2) {
if (this.isNumberRangeSlider) {
this.currentMin = minMaxValues[0];
this.currentMax = minMaxValues[1];
} else {
var min = Math.round(minMaxValues[0]);
var max = Math.round(minMaxValues[1]);
if (max <= min) {
this.noUiSlider.set([this.currentMin, this.currentMax]);
} else {
this.currentMin = min;
this.currentMax = max;
}
}
this.renderInputField();
// One value slider
} else if (typeof minMaxValues == 'string' || typeof minMaxValues == 'number') {
this.currentMin = minMaxValues;
this.currentMax = minMaxValues;
}
}
}
/**
* On finish dragging the slider: update the current value, and perform API request (if this.requestInstantly = true).
* This is called on noUiSlider's 'change' event: https://refreshless.com/nouislider/events-callbacks/
*/
onFinishDragging() {
// Set selected filter items
if (this.isNumberRangeSlider) {
this.filterItems.forEach(filterItem => {
filterItem.setValue(this.currentMin, this.currentMax);
})
} else {
// Can't filterItems.foreach and compare index because it's a map.
// So we pick out the selected values in another array.
var selectedValues = [];
if (this.currentMin != this.rangeMin || this.currentMax != this.rangeMax) {
for (var i = this.currentMin; i <= this.currentMax - 1; i++) {
selectedValues.push(this.valuesData[i].key);
}
}
this.filterItems.forEach(filterItem => {
filterItem.isSelected = selectedValues.includes(filterItem.value);
})
}
// Perform API request
if (this.requestInstantly) {
var filterValues = [];
this.filterItems.forEach((filterItem) => {
if (filterItem.isSelected) {
filterValues.push(filterItem.value);
}
})
FilterApi.setParam(this.filterOptionId, filterValues);
FilterApi.setParam('page', 1);
var eventType = 'filter';
var eventInfo = {
filterOptionId: this.filterOptionId,
filterOptionValue: filterValues
}
FilterApi.applyFilter(eventType, eventInfo);
}
}
/**
* Get the noUiSlider config object
* noUiSlider config documentation: https://refreshless.com/nouislider/slider-options/
* @returns {Object} - The noUiSlider config object
*/
getSliderConfig() {
var sliderConfig = {
start: this.isSingleHandle ? [this.currentMin] : [this.currentMin, this.currentMax],
step: this.sliderStep,
connect: true,
snap: !this.isNumberRangeSlider,
animate: true,
animationDuration: 300,
range: this.getSliderRange(),
pips: this.getSliderPipsConfig()
}
if (this.isShowTooltip) {
var tooltipFormat = {
to: this.buildLabel.bind(this)
}
sliderConfig.tooltips = [tooltipFormat, tooltipFormat];
}
return sliderConfig;
}
/**
* Get the noUiSlider range object
* noUiSlider range documentation: https://refreshless.com/nouislider/slider-values/
* @returns {Object} - The noUiSlider range object
*/
getSliderRange() {
var range = {
'min': this.rangeMin,
'max': this.rangeMax
}
if (!this.isNumberRangeSlider) {
for (var i = 1; i < this.valuesData.length; i++) {
var percent = i * (100 / (this.valuesData.length));
range[percent + '%'] = i;
}
}
return range;
}
/**
* Get the noUiSlider pips configs
* Pips are dividers in the range.
* noUiSlider pips documentation: https://refreshless.com/nouislider/pips/
* @returns {Object} - The noUiSlider pips config object
*/
getSliderPipsConfig() {
var numberOfPips = this.isNumberRangeSlider ? (this.sliderRange + 1) : this.valuesData.length + 1;
var pipConfig = {
mode: 'count',
values: numberOfPips,
filter: (value, type) => { return (type == 1) ? type : -1 }, // Only render big pip
format: {
to: this.buildLabel.bind(this)
}
}
return pipConfig;
}
/**
* Build the range slider labels (under each pips - divider)
* @param {string|Number} value - original value
* @returns {string} formatted value
*/
buildLabel(value) {
var label = '';
if (this.isNumberRangeSlider) {
if (this.isPriceFilter) {
label = this.buildMoneyLabel(value);
} else {
label = this.buildNumberLabel(value, true);
}
} else {
if (Number.isInteger(value) && value >= 0 && value < this.valuesData.length) {
label = this.valuesData[value].key;
label = this.buildTextLabel(label);
}
}
return label;
}
/**
* Build the money label. Use in price range slider.
* By default it uses the shop's money format.
* Calls buildNumberLabel() and then replace the {{amount}} with number label.
* @param {Number} value - The price value
* @returns {string} - Formatted value
*/
buildMoneyLabel(value) {
var enable3rdCurrencySupport = Settings.getSettingValue('general.enable3rdCurrencySupport');
var formattedNumber = this.buildNumberLabel(value, true);
var label = this.moneyFormat.replace(/{{\s*(\w+)\s*}}/, formattedNumber);
// Remove currency code
label = label.replace(/[A-Z][A-Z][A-Z]/,'');
if (enable3rdCurrencySupport) {
label = Utils.moneyWrapper(label);
}
return label;
}
/**
* Build number label: separate thousands, decimal, fixed decimal places.
* @param {Number} value
* @param {boolean} isShortenNumber
* @returns {string} Formatted value
*/
buildNumberLabel(value, isShortenNumber) {
if (Settings.getSettingValue('general.shortenPipsRange') && isShortenNumber) {
var ranges = Settings.getSettingValue('general.formatPipsRange');
if (Array.isArray(ranges) && ranges.length > 0) {
var dividedValue = 0;
var remainderValue = 0;
for (var i = ranges.length - 1; i >= 0; i--) {
var range = ranges[i];
if (value >= range.node) {
dividedValue = Math.floor(value / range.node).toString();
remainderValue = Math.round(value % range.node);
if (remainderValue > 0) {
remainderValue = remainderValue.toString();
} else {
remainderValue = '';
}
if (remainderValue.length > range.fix) {
remainderValue = remainderValue.substring(0, range.fix);
}
if (range.suffix) {
return dividedValue + this.decimalSeparator + remainderValue + range.symbol;
} else {
return dividedValue + range.symbol + remainderValue;
}
}
}
}
}
var withoutTrailingZeros = !Settings.getSettingValue('general.removePriceDecimal');
var label = Utils.formatNumberWithSeparator(value, this.precision, this.thousandSeparator, this.decimalSeparator, withoutTrailingZeros);
return label;
}
/**
* Build the text label. Use in advance range slider.
* By default this function trims the text prefix.
* @param {string} value
* @returns {string} Formatted value
*/
buildTextLabel(value) {
if (this.prefix) {
var prefix = this.prefix.replace(/\\/g, '');
value = value.replace(prefix, '').trim();
}
return value;
}
/**
* Get the money format used in building price label.
* Set this.moneyFormat (for label) and this.moneyFormatWithoutCurrency (for input box).
* It takes Globals.moneyFormat value and merge with delimiter settings.
*/
setMoneyFormat() {
if (!this.isPriceFilter) return;
// Get the money format by setting or by shop's moneyFormat
var settingMoneyFormat = Settings.getSettingValue('general.rangeSliderMoneyFormat');
if (settingMoneyFormat) {
this.moneyFormat = settingMoneyFormat;
} else {
this.moneyFormat = '{{amount}}';
}
}
/**
* Set the current values of the range slider.
* Called by setData function
*/
setCurrentValues() {
var currentMin = this.rangeMin;
var currentMax = this.rangeMax;
var selectedValues = null;
if (Globals.queryParams.hasOwnProperty(this.filterOptionId)) {
// Number range slider have min & max value
if (this.isNumberRangeSlider) {
selectedValues = Globals.queryParams[this.filterOptionId][0].split(':');
if (selectedValues && selectedValues.length == 2) {
currentMin = selectedValues[0];
currentMax = selectedValues[1];
}
// Text range slider have array of value
} else {
// Get list of selected values
selectedValues = Globals.queryParams[this.filterOptionId];
if (Array.isArray(selectedValues)) {
// Get list of all values
var values = this.valuesData.map(x => x.key);
currentMin = this.rangeMax;
currentMax = this.rangeMin;
// Loop through selected values so see which range of all values are selected
selectedValues.forEach((selectedValue) => {
var index = values.indexOf(selectedValue);
if (index >= this.rangeMin && index <= this.rangeMax) {
if (index < currentMin) {
currentMin = index;
}
if (index + 1 > currentMax) {
currentMax = index + 1;
}
}
})
}
}
}
this.currentMin = currentMin;
this.currentMax = currentMax;
}
setDisplayStyle() {
// Range slider style: https://www.notion.so/boostcommerce/Range-Slider-and-Range-Slider-Customization-Display-2c5ebeb19b3b46ff91e5eab93338e588
// Style1: number - setting in admin: has input box (default)
// Style2: number - setting in admin: no input box
// Style3: number - setting in code: general.rangeSlidersStyle3
// Style4: number - setting in code: general.rangeSlidersSingleHandle
// Style5: text - setting in code: general.advancedRangeSliders
var style3 = Settings.getSettingValue('general.rangeSlidersStyle3');
var singleHandle = Settings.getSettingValue('general.rangeSlidersSingleHandle');
if (!this.isNumberRangeSlider) {
this.style = 'style5';
this.hideInputElement = true;
} else if (Array.isArray(singleHandle) && singleHandle.includes(this.filterOptionId)) {
this.style = 'style4';
this.hideInputElement = true;
this.isSingleHandle = true;
this.currentMax = this.currentMin;
} else if (Array.isArray(style3) && style3.includes(this.filterOptionId)) {
this.style = 'style3';
this.hideInputElement = false;
} else if (this.hideInputElement) {
this.style = 'style2';
this.isShowTooltip = true;
} else {
this.style = 'style1';
}
// Change to style2 if enable support Currency (3rd party app)
var enable3rdCurrencySupport = Settings.getSettingValue('general.enable3rdCurrencySupport');
if (enable3rdCurrencySupport && ['style1', 'style3'].indexOf(this.style) > -1) {
this.style = 'style2';
this.hideInputElement = true;
this.isShowTooltip = true;
}
}
/**
* Prepare filter option data
*/
prepareFilterOptionData(data) {
//Round up (max value) and round down (min value) to 2 decimal places for range slider
if (data.values.max && data.values.min) {
data.values.max = Math.ceil(data.values.max * 100) / 100;
data.values.min = Math.floor(data.values.min * 100) / 100;
}
// Convert price to active currency
if ((data.filterType == 'price' || data.filterType == 'variants_price') && data.values) {
// Range slider
data.values.min = Utils.convertPriceBasedOnActiveCurrency(data.values.min);
data.values.max = Utils.convertPriceBasedOnActiveCurrency(data.values.max);
}
return data;
}
/**
* Set data for FilterOptionRangeSlider. Override default setData by FilterOption.
* It calls super.setData(), and then set some extra data exclusive to range slider.
* @param data
*/
setData(data) {
// Prepare filter option data
data = this.prepareFilterOptionData(data);
super.setData(data);
//modify label if filter option price
if ((this.filterType == 'price' || this.filterType == 'variants_price') && !Settings.getSettingValue('general.enable3rdCurrencySupport')) {
// Get currency symbol
var moneyFormat = Settings.getSettingValue('general.rangeSliderMoneyFormat');
if (!moneyFormat) moneyFormat = Globals.moneyFormat;
var currencySymbol = moneyFormat.replace(/<.*?>/g, '').replace(/{{.*?}}/, '');
if (currencySymbol.length == 1) {
this.label += " (" + currencySymbol + ")";
}
}
this.isNumberRangeSlider = !Array.isArray(data.values); //data.values.min != null && data.values.max != null;
// Set money format for price slider
this.isPriceFilter = (data.filterType == FilterOptionEnum.FilterType.PRICE || data.filterType == FilterOptionEnum.FilterType.VARIANTS_PRICE);
if (this.isPriceFilter) {
this.setMoneyFormat();
}
// Sort values for text range slider
if (this.isSortValues()) {
this.sortValues(data.values);
}
// Keep a copy of the values
this.valuesData = JSON.parse(JSON.stringify(data.values));
// Number range slider (values are number - like price). Number slider has min and max values.
if (this.isNumberRangeSlider && data.values.min != null && data.values.max != null && data.values.min != data.values.max) {
this.hideInputElement = data.hideTextBoxes;
// Set slider range
this.sliderRange = parseFloat(data.sliderRange);
if (isNaN(this.sliderRange)){
this.sliderRange = 4;
}
// Set slider step
this.sliderStep = parseFloat(data.sliderStep);
if (isNaN(this.sliderStep) || this.sliderStep > data.values.max) {
this.sliderStep = 1;
}
// Set precision (number of decimal places) based on slider step
this.precision = 0;
if (Math.floor(this.sliderStep) != this.sliderStep) {
// Split slider step into decimal and none decimal parts
var parts = this.sliderStep.toString().split(".");
if (parts.length > 1) {
// Get the number of precision
this.precision = parts[1].length;
}
/*
if (this.sliderStep == '0.1' || this.sliderStep == '.1') {
this.precision = 1;
} else if (this.sliderStep == '0.01' || this.sliderStep == '.01') {
this.precision = 2;
} */
}
this.thousandSeparator = data.sliderDelimiter ? data.sliderDelimiter : '';
if (this.thousandSeparator == '.') {
this.decimalSeparator = ',';
} else {
this.decimalSeparator = '.';
}
this.rangeMin = parseFloat(data.values.min);
this.rangeMax = parseFloat(data.values.max);
this.setCurrentValues();
this.setDisplayStyle();
// Text range slider (values are text - like tag or product option). Text slider has array of values.
} else if (Array.isArray(data.values) && data.values.length > 1) {
this.hideInputElement = true;
this.sliderStep = 1;
this.rangeMin = 0;
this.rangeMax = this.valuesData.length;
this.setCurrentValues();
this.setDisplayStyle();
} else {
this.status = FilterOptionEnum.Status.DISABLED;
}
}
}
export default FilterOptionRangeSlider;