/** * Entity Selector - Core Module * Factory, initialization, state management * @partial _core.js * * IMPORTANT: This file must be loaded LAST in the concatenation order * as it combines all mixins from other partials. * * EXTRACTION SOURCE: assets/js/admin/entity-selector.js * Lines: 15-55 (createTargetConditionsInstance, state variables) * 56-110 (init method) * 108-132 (observeNewSelects) * 7889-7951 (Factory object, window export, document ready) * * Contains: * - createTargetConditionsInstance() - Factory function * - State variable initialization * - init() - Main initialization method * - observeNewSelects() - MutationObserver for dynamic selects * - loadExistingSelections() - Restore saved state * - TargetConditions factory object * - window.TargetConditions export * - Document ready auto-initialization */ (function($) { 'use strict'; /** * Create a new TargetConditions instance * Each instance is independent and manages its own wrapper/state */ function createTargetConditionsInstance() { // Base instance object with state variables var instance = { config: {}, $wrapper: null, $dropdown: null, activeGroup: null, // { blockType, groupIndex, section: 'include'|'exclude' } searchTimeout: null, searchResults: [], searchTotal: 0, searchOffset: 0, searchQuery: '', isLoading: false, loadMoreCount: 20, // Sort, filter, view state viewMode: 'list', currentSort: { field: 'name', dir: 'ASC' }, refineQuery: '', refineNegate: false, filters: { inStock: false, discounted: false, priceMin: null, priceMax: null, attributes: [], features: [], // Entity-specific filters productCountMin: null, productCountMax: null, salesMin: null, salesMax: null, turnoverMin: null, turnoverMax: null, depth: null, hasProducts: false, hasDescription: false, hasImage: false, activeOnly: true, // Default to active only attributeGroup: null, featureGroup: null, dateAddFrom: null, dateAddTo: null, lastProductFrom: null, lastProductTo: null, // Country-specific filters hasHolidays: false, containsStates: false, zone: null }, filterableData: null, // Search history searchHistory: {}, searchHistoryMax: 10, searchHistoryKey: 'targetConditionsSearchHistory', // Chips visibility maxVisibleChips: 20, // Method dropdown references $methodDropdownMenu: null, $methodDropdownSelect: null, $methodDropdownTrigger: null, // Preview state $previewPopover: null, $activeBadge: null, $previewList: null, previewLoadedCount: 0, previewBlockType: null, allPreviewData: null, // Count update timeout countUpdateTimeout: null, init: function(options) { this.config = $.extend({ id: 'entity-selector', name: 'target_conditions', namePrefix: 'target_', mode: 'multi', // Global mode: 'multi' or 'single' blocks: {}, ajaxUrl: '', trans: {} }, options); this.$wrapper = $('[data-entity-selector-id="' + this.config.id + '"]'); if (!this.$wrapper.length) { return; } // Global single mode - hide group management if (this.config.mode === 'single') { this.$wrapper.find('.btn-add-group').hide(); this.$wrapper.find('.group-excludes').hide(); this.$wrapper.find('.group-modifiers').hide(); } // Add fullwidth class to parent form-group (skip for form-group layout) var hasLayoutFormGroup = this.$wrapper.hasClass('layout-form-group'); var $entitySelectorFormGroup = this.$wrapper.closest('.entity-selector-form-group'); if (!hasLayoutFormGroup && !$entitySelectorFormGroup.length) { var $formGroup = this.$wrapper.closest('.form-group'); $formGroup.addClass('condition-trait-fullwidth'); $formGroup.find('.col-lg-offset-3').removeClass('col-lg-offset-3'); } else { } this.createDropdown(); this.bindEvents(); this.loadExistingSelections(); this.loadSearchHistory(); // Initialize styled method dropdowns this.initMethodDropdowns(); // Watch for dynamically added selects this.observeNewSelects(); // Update counts on page load var self = this; if (this.config.mode === 'single') { setTimeout(function() { self._updateSingleModeBadges(); }, 100); } else { setTimeout(function() { self.updateTabBadges(); self.updateAllConditionCounts(); }, 100); } }, observeNewSelects: function() { var self = this; if (typeof MutationObserver === 'undefined') { return; } var observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { if (mutation.addedNodes.length) { $(mutation.addedNodes).find('.include-method-select, .exclude-method-select').each(function() { self.enhanceMethodSelect($(this)); }); } }); }); observer.observe(this.$wrapper[0], { childList: true, subtree: true }); }, loadExistingSelections: function() { // TODO: Extract full implementation from original // Reads JSON from hidden input and populates chips } }; // Merge all mixins into the instance // Each mixin adds its methods to window._EntitySelectorMixins var mixins = window._EntitySelectorMixins || {}; // Merge utils mixin if (mixins.utils) { $.extend(instance, mixins.utils); } // Merge events mixin if (mixins.events) { $.extend(instance, mixins.events); } // Merge dropdown mixin if (mixins.dropdown) { $.extend(instance, mixins.dropdown); } // Merge search mixin if (mixins.search) { $.extend(instance, mixins.search); } // Merge filters mixin if (mixins.filters) { $.extend(instance, mixins.filters); } // Merge chips mixin if (mixins.chips) { $.extend(instance, mixins.chips); } // Merge groups mixin if (mixins.groups) { $.extend(instance, mixins.groups); } // Merge methods mixin if (mixins.methods) { $.extend(instance, mixins.methods); } // Merge preview mixin if (mixins.preview) { $.extend(instance, mixins.preview); } // Merge tree mixin if (mixins.tree) { $.extend(instance, mixins.tree); } // Merge validation mixin if (mixins.validation) { $.extend(instance, mixins.validation); } return instance; } // Factory object for creating and managing instances var TargetConditions = { instances: [], // Create and initialize a new instance create: function(options) { var instance = createTargetConditionsInstance(); instance.init(options); this.instances.push(instance); return instance; }, // For backwards compatibility - init creates a new instance init: function(options) { return this.create(options); }, // Validate all instances - returns true if all valid validateAll: function() { var allValid = true; for (var i = 0; i < this.instances.length; i++) { if (!this.instances[i].validate()) { allValid = false; } } return allValid; } }; // Export to window window.TargetConditions = TargetConditions; // Auto-initialize on document ready $(document).ready(function() { // Auto-initialize from data-config attributes on wrapper elements $('[data-entity-selector-id]').each(function() { var configData = $(this).data('config'); if (configData) { TargetConditions.create(configData); } }); // Tips box toggle handler $(document).on('click', '.es-tips-box .tips-header', function(e) { e.preventDefault(); $(this).closest('.es-tips-box').toggleClass('expanded'); }); // Form submission validation for required target conditions $(document).on('submit', 'form', function(e) { var $form = $(this); if ($form.find('.entity-selector-trait[data-required]').length > 0) { if (!TargetConditions.validateAll()) { e.preventDefault(); return false; } } }); }); })(jQuery); /** * Entity Selector - Events Module * All event binding and handlers * @partial _events.js * * Contains event handlers for: * - Tab switching * - Block/group collapse toggle * - Dropdown open/close * - Search input handling * - Item selection/deselection * - Group add/remove * - Exclude row add/remove * - Method select changes * - Filter panel toggles * - Keyboard shortcuts (Ctrl+A, Ctrl+D, Esc, Enter) * - Load more pagination * - Sort controls * - View mode switching * - Tree view events * - Preview badge clicks * - Pattern tag interactions * - Combination picker events * - Group modifier events */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.events = { bindEvents: function() { var self = this; // Tab switching this.$wrapper.on('click', '.es-block-tab', function(e) { e.preventDefault(); var blockType = $(this).data('blockType'); self.switchToBlock(blockType); }); // Tab badge click for preview popover (toggle) this.$wrapper.on('click', '.es-block-tab .tab-badge', function(e) { e.stopPropagation(); e.preventDefault(); var $tab = $(this).closest('.es-block-tab'); var $badge = $(this); if ($badge.hasClass('popover-open')) { self.hidePreviewPopover(); } else { self.showPreviewPopover($tab); } }); // Condition count badge click for preview popover this.$wrapper.on('click', '.condition-match-count.clickable', function(e) { e.stopPropagation(); e.preventDefault(); var $badge = $(this); if ($badge.hasClass('popover-open')) { self.hidePreviewPopover(); } else { self.showConditionPreviewPopover($badge); } }); // Group count badge click for preview popover this.$wrapper.on('click', '.group-count-badge.clickable', function(e) { e.stopPropagation(); e.preventDefault(); var $badge = $(this); if ($badge.hasClass('popover-open')) { self.hidePreviewPopover(); } else { self.showGroupPreviewPopover($badge); } }); // Total count badge click for summary popover this.$wrapper.on('click', '.trait-total-count', function(e) { e.stopPropagation(); e.preventDefault(); var $badge = $(this); if ($badge.hasClass('popover-open')) { self.hidePreviewPopover(); } else { self.showTotalPreviewPopover($badge); } }); // Close popover when clicking outside $(document).on('click', function(e) { if (!$(e.target).closest('.es-preview-popover').length && !$(e.target).closest('.holiday-preview-popover').length && !$(e.target).closest('.tab-badge').length && !$(e.target).closest('.condition-match-count').length && !$(e.target).closest('.group-count-badge').length && !$(e.target).closest('.group-modifiers').length && !$(e.target).closest('.group-preview-badge').length && !$(e.target).closest('.toggle-count.clickable').length && !$(e.target).closest('.trait-total-count').length && !$(e.target).closest('.chip-preview-holidays').length) { self.hidePreviewPopover(); // Also close holiday popover $('.holiday-preview-popover').remove(); } }); // Block-level collapse toggle (click on header) this.$wrapper.on('click', '.condition-trait-header', function(e) { if ($(e.target).closest('.es-block-tabs').length || $(e.target).closest('.trait-header-actions').length || $(e.target).closest('.prestashop-switch').length || $(e.target).closest('.trait-total-count').length) { return; } var $body = self.$wrapper.find('.condition-trait-body'); if ($body.hasClass('es-expanded')) { $body.removeClass('es-expanded'); self.$wrapper.addClass('collapsed'); } else { $body.addClass('es-expanded'); self.$wrapper.removeClass('collapsed'); } }); // Toggle blocks content (form-content layout) this.$wrapper.on('click', '.btn-toggle-blocks', function(e) { e.preventDefault(); var $blocksContent = self.$wrapper.find('.entity-selector-blocks-content'); var $icon = $(this).find('.material-icons'); if ($blocksContent.hasClass('es-expanded')) { $blocksContent.removeClass('es-expanded'); self.$wrapper.addClass('blocks-collapsed'); $icon.text('expand_more'); } else { $blocksContent.addClass('es-expanded'); self.$wrapper.removeClass('blocks-collapsed'); $icon.text('expand_less'); } }); // Custom block input changes — update tab badge when value changes this.$wrapper.on('input change', '.custom-block-content input, .custom-block-content textarea, .custom-block-content select', function() { self.updateTabBadges(); }); // Group-level collapse toggle (click on group header or toggle icon) this.$wrapper.on('click', '.group-header', function(e) { if ($(e.target).closest('.btn-remove-group, .group-name-input').length) { return; } if (self.$wrapper.data('mode') === 'single') { return; } var $group = $(this).closest('.selection-group'); $group.toggleClass('collapsed'); }); // Toggle all groups (single button that switches between expand/collapse) this.$wrapper.on('click', '.trait-header-actions .btn-toggle-groups', function(e) { e.preventDefault(); e.stopPropagation(); var $btn = $(this); var currentState = $btn.attr('data-state') || 'collapsed'; var trans = self.config.trans || {}; if (currentState === 'collapsed') { self.$wrapper.find('.selection-group').removeClass('collapsed'); $btn.attr('data-state', 'expanded'); $btn.attr('title', trans.collapse_all || 'Collapse all groups'); $btn.find('i').removeClass('icon-expand').addClass('icon-compress'); } else { self.$wrapper.find('.selection-group').addClass('collapsed'); $btn.attr('data-state', 'collapsed'); $btn.attr('title', trans.expand_all || 'Expand all groups'); $btn.find('i').removeClass('icon-compress').addClass('icon-expand'); } }); // Show all toggle change (legacy checkbox) this.$wrapper.on('change', '.trait-show-all-toggle .show-all-checkbox', function(e) { e.stopPropagation(); var isChecked = $(this).prop('checked'); if (isChecked) { self.clearAllConditions(); } }); // Target switch change (PrestaShop native switch) this.$wrapper.on('change', '.es-switch-toggle', function(e) { e.stopPropagation(); var value = $(this).val(); if (value === '1') { self.clearAllConditions(); self.$wrapper.find('.condition-trait-body').removeClass('es-expanded'); self.$wrapper.addClass('collapsed'); } else { self.$wrapper.find('.condition-trait-body').addClass('es-expanded'); self.$wrapper.removeClass('collapsed'); } }); // Add group this.$wrapper.on('click', '.btn-add-group', function(e) { e.preventDefault(); var $block = $(this).closest('.es-block'); var blockType = $block.data('blockType'); self.addGroup($block, blockType); }); // Remove group this.$wrapper.on('click', '.btn-remove-group', function(e) { e.preventDefault(); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.es-block'); self.removeGroup($group, $block); }); // Group name input - stop propagation to prevent collapse this.$wrapper.on('click focus', '.group-name-input', function(e) { e.stopPropagation(); }); // Group name change this.$wrapper.on('change blur', '.group-name-input', function() { var $input = $(this); var $group = $input.closest('.selection-group'); var name = $.trim($input.val()); $group.attr('data-group-name', name); self.serializeAllBlocks(); }); // Add exceptions (first exclude row) this.$wrapper.on('click', '.btn-add-exclude', function(e) { e.preventDefault(); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.es-block'); self.addFirstExcludeRow($group, $block); }); // Add another exclude row this.$wrapper.on('click', '.btn-add-another-exclude', function(e) { e.preventDefault(); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.es-block'); self.addExcludeRow($group, $block); }); // Remove individual exclude row this.$wrapper.on('click', '.btn-remove-exclude-row', function(e) { e.preventDefault(); var $excludeRow = $(this).closest('.exclude-row'); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.es-block'); self.removeExcludeRow($excludeRow, $group, $block); }); // Include method change this.$wrapper.on('change', '.include-method-select', function() { var $group = $(this).closest('.selection-group'); var newMethod = $(this).val(); var prevMethod = $group.data('_currentIncludeMethod'); if (prevMethod === newMethod) return; $group.data('_currentIncludeMethod', newMethod); self.hideDropdown(); var $block = $(this).closest('.es-block'); var $row = $group.find('.group-include'); var blockType = $block.data('blockType'); var blockDef = self.config.blocks[blockType] || {}; var methods = blockDef.selection_methods || {}; var $option = $(this).find('option:selected'); var valueType = $option.data('valueType') || 'none'; var searchEntity = $option.data('searchEntity') || ''; var methodOptions = $option.data('options') || null; var $oldPicker = $group.find('.include-picker'); var newPickerHtml = self.buildValuePickerHtml('include', valueType, searchEntity, methods); $oldPicker.replaceWith(newPickerHtml); if (valueType === 'select' && methodOptions) { var $newPicker = $group.find('.include-picker'); var $select = $newPicker.find('.select-value-input'); $select.empty(); $.each(methodOptions, function(key, label) { $select.append(''); }); } if (valueType === 'multi_select_tiles' && methodOptions) { var $newPicker = $group.find('.include-picker'); var isExclusive = $option.data('exclusive') === true; self.populateTiles($newPicker, methodOptions, isExclusive); } if (valueType === 'multi_numeric_range') { var $newPicker = $group.find('.include-picker'); var step = $option.data('step'); var min = $option.data('min'); self.applyRangeInputConstraints($newPicker, step, min); } if (valueType === 'combination_attributes') { var $newPicker = $group.find('.include-picker'); self.loadCombinationAttributeGroups($newPicker); } var selectedMethod = $(this).val(); self.updateMethodInfoPlaceholder($group.find('.method-selector-wrapper'), selectedMethod, blockType); self.updateBlockStatus($block); self.serializeAllBlocks($row); }); // Exclude method change (within an exclude row) this.$wrapper.on('change', '.exclude-method-select', function() { var $excludeRow = $(this).closest('.exclude-row'); var newMethod = $(this).val(); var prevMethod = $excludeRow.data('_currentExcludeMethod'); if (prevMethod === newMethod) return; $excludeRow.data('_currentExcludeMethod', newMethod); self.hideDropdown(); var $group = $(this).closest('.selection-group'); var $block = $(this).closest('.es-block'); var blockType = $block.data('blockType'); var blockDef = self.config.blocks[blockType] || {}; var methods = blockDef.selection_methods || {}; var $option = $(this).find('option:selected'); var valueType = $option.data('valueType') || 'entity_search'; var searchEntity = $option.data('searchEntity') || blockType; var methodOptions = $option.data('options') || null; var $oldPicker = $excludeRow.find('.exclude-picker'); var newPickerHtml = self.buildValuePickerHtml('exclude', valueType, searchEntity, methods); $oldPicker.replaceWith(newPickerHtml); if (valueType === 'select' && methodOptions) { var $newPicker = $excludeRow.find('.exclude-picker'); var $select = $newPicker.find('.select-value-input'); $select.empty(); $.each(methodOptions, function(key, label) { $select.append(''); }); } if (valueType === 'multi_select_tiles' && methodOptions) { var $newPicker = $excludeRow.find('.exclude-picker'); var isExclusive = $option.data('exclusive') === true; self.populateTiles($newPicker, methodOptions, isExclusive); } if (valueType === 'multi_numeric_range') { var $newPicker = $excludeRow.find('.exclude-picker'); var step = $option.data('step'); var min = $option.data('min'); self.applyRangeInputConstraints($newPicker, step, min); } if (valueType === 'combination_attributes') { var $newPicker = $excludeRow.find('.exclude-picker'); self.loadCombinationAttributeGroups($newPicker); } var selectedMethod = $(this).val(); self.updateMethodInfoPlaceholder($excludeRow.find('.exclude-header-row'), selectedMethod, blockType); self.serializeAllBlocks($excludeRow); }); // Handle pattern input Enter key - adds pattern as tag this.$wrapper.on('keydown', '.pattern-input', function(e) { if (e.keyCode === 13) { e.preventDefault(); var $btn = $(this).closest('.draft-tag').find('.btn-add-pattern'); $btn.click(); } }); // Handle add pattern button click (in draft tag) this.$wrapper.on('click', '.draft-tag .btn-add-pattern', function(e) { e.preventDefault(); e.stopPropagation(); var $draftTag = $(this).closest('.draft-tag'); var $picker = $draftTag.closest('.value-picker'); var $row = $draftTag.closest('.group-include, .exclude-row'); var $input = $draftTag.find('.pattern-input'); var pattern = $.trim($input.val()); if (pattern) { var caseSensitive = $draftTag.attr('data-case-sensitive') === '1'; self.addPatternTag($picker, pattern, caseSensitive); $input.val('').focus(); $draftTag.find('.pattern-match-count').removeClass('count-found count-zero').hide(); $draftTag.find('.pattern-match-count .count-value').text(''); self.serializeAllBlocks($row); } }); // Handle pattern input live typing - update match count in draft tag this.$wrapper.on('input', '.pattern-input', function() { var $input = $(this); var $draftTag = $input.closest('.draft-tag'); if (!$draftTag.length) return; var pattern = $.trim($input.val()); if ($input.data('countTimeout')) { clearTimeout($input.data('countTimeout')); } var $matchCount = $draftTag.find('.pattern-match-count'); if (!pattern) { $matchCount.removeClass('count-found count-zero').hide(); $matchCount.find('.count-value').text(''); var $group = $draftTag.closest('.selection-group'); if ($group.length) { self.updateGroupTotalCount($group); } return; } var timeout = setTimeout(function() { var caseSensitive = $draftTag.attr('data-case-sensitive') === '1'; self.updateDraftTagCount($draftTag, pattern, caseSensitive); }, 300); $input.data('countTimeout', timeout); }); // Handle pattern tag remove this.$wrapper.on('click', '.pattern-tag .btn-remove-pattern', function(e) { e.preventDefault(); e.stopPropagation(); var $row = $(this).closest('.group-include, .exclude-row'); $(this).closest('.pattern-tag').remove(); self.serializeAllBlocks($row); }); // Handle pattern tag case-sensitivity toggle this.$wrapper.on('click', '.pattern-tag .btn-toggle-case', function(e) { e.preventDefault(); e.stopPropagation(); var $tag = $(this).closest('.pattern-tag'); var $btn = $(this); var trans = self.config.trans || {}; var isDraftTag = $tag.hasClass('draft-tag'); var isCaseSensitive = $tag.data('caseSensitive') === 1 || $tag.data('caseSensitive') === '1' || $tag.attr('data-case-sensitive') === '1'; var newCaseSensitive = !isCaseSensitive; $tag.data('caseSensitive', newCaseSensitive ? 1 : 0); $tag.attr('data-case-sensitive', newCaseSensitive ? '1' : '0'); $tag.toggleClass('case-sensitive', newCaseSensitive); $btn.find('.case-icon').text(newCaseSensitive ? 'Aa' : 'aa'); var caseTitle = newCaseSensitive ? (trans.case_sensitive || 'Case sensitive - click to toggle') : (trans.case_insensitive || 'Case insensitive - click to toggle'); $btn.attr('title', caseTitle); if (isDraftTag) { var pattern = $.trim($tag.find('.pattern-input').val()); if (pattern) { self.updateDraftTagCount($tag, pattern, newCaseSensitive); } } else { var $row = $tag.closest('.group-include, .exclude-row'); self.serializeAllBlocks($row); } }); // Handle pattern match count click - show preview modal this.$wrapper.on('click', '.pattern-match-count', function(e) { e.preventDefault(); e.stopPropagation(); var $matchCount = $(this); var count = $matchCount.data('count'); var pattern = $matchCount.data('pattern'); var entityType = $matchCount.data('entityType'); var caseSensitive = $matchCount.data('caseSensitive'); if (!count || count <= 0 || !pattern) { return; } self.showPatternPreviewModal(pattern, entityType, caseSensitive, count); }); // Handle pattern tag edit (click on tag text) this.$wrapper.on('click', '.pattern-tag .pattern-tag-text', function(e) { e.preventDefault(); var $tag = $(this).closest('.pattern-tag'); if ($tag.hasClass('editing')) return; var currentPattern = $tag.data('pattern'); var $editInput = $('').val(currentPattern); var $saveBtn = $(''); var $cancelBtn = $(''); var $editActions = $('').append($saveBtn, $cancelBtn); $tag.addClass('editing').find('.pattern-tag-text').hide(); $tag.find('.btn-remove-pattern').hide(); $tag.prepend($editActions).prepend($editInput); $editInput.focus().select(); $editInput.on('keydown', function(ev) { if (ev.keyCode === 13) { ev.preventDefault(); $saveBtn.click(); } else if (ev.keyCode === 27) { ev.preventDefault(); $cancelBtn.click(); } }); }); // Pattern edit - Save button this.$wrapper.on('click', '.pattern-tag .btn-pattern-save', function(e) { e.preventDefault(); e.stopPropagation(); var $tag = $(this).closest('.pattern-tag'); var $editInput = $tag.find('.pattern-tag-edit'); var currentPattern = $tag.data('pattern'); var newPattern = $.trim($editInput.val()); if (newPattern && newPattern !== currentPattern) { $tag.data('pattern', newPattern); $tag.find('.pattern-tag-text').text(newPattern); } $editInput.remove(); $tag.find('.pattern-edit-actions').remove(); $tag.removeClass('editing').find('.pattern-tag-text, .btn-remove-pattern').show(); var $row = $tag.closest('.group-include, .exclude-row'); self.serializeAllBlocks($row); }); // Pattern edit - Cancel button this.$wrapper.on('click', '.pattern-tag .btn-pattern-cancel', function(e) { e.preventDefault(); e.stopPropagation(); var $tag = $(this).closest('.pattern-tag'); $tag.find('.pattern-tag-edit').remove(); $tag.find('.pattern-edit-actions').remove(); $tag.removeClass('editing').find('.pattern-tag-text, .btn-remove-pattern').show(); }); // Handle mpr-info-wrapper tooltip with fixed positioning this.$wrapper.on('mouseenter', '.mpr-info-wrapper[data-details]', function() { var $wrapper = $(this); if ($wrapper.data('tooltip-active')) return; var content = $wrapper.attr('data-details'); var tooltipClass = $wrapper.attr('data-tooltip-class') || ''; var $tooltip = $('
', { class: 'mpr-tooltip mpr-tooltip-fixed ' + tooltipClass, html: content }); $('body').append($tooltip); $wrapper.data('tooltip-active', true); var offset = $wrapper.offset(); var triggerWidth = $wrapper.outerWidth(); var tooltipWidth = $tooltip.outerWidth(); var tooltipHeight = $tooltip.outerHeight(); var left = offset.left + (triggerWidth / 2) - (tooltipWidth / 2); var top = offset.top - tooltipHeight - 10; if (left < 10) left = 10; if (left + tooltipWidth > $(window).width() - 10) { left = $(window).width() - tooltipWidth - 10; } $tooltip.css({ position: 'fixed', left: left + 'px', top: (top - $(window).scrollTop()) + 'px' }); $wrapper.data('tooltip-el', $tooltip); }); this.$wrapper.on('mouseleave', '.mpr-info-wrapper[data-details]', function() { var $wrapper = $(this); var $tooltip = $wrapper.data('tooltip-el'); if ($tooltip) { $tooltip.remove(); } $wrapper.data('tooltip-active', false); $wrapper.data('tooltip-el', null); }); // Handle numeric range input changes this.$wrapper.on('change', '.range-min-input, .range-max-input', function() { var $row = $(this).closest('.group-include, .exclude-row'); self.serializeAllBlocks($row); }); // Handle date range input changes this.$wrapper.on('change', '.date-from-input, .date-to-input', function() { var $row = $(this).closest('.group-include, .exclude-row'); self.serializeAllBlocks($row); }); // Handle select value changes this.$wrapper.on('change', '.select-value-input', function() { var $row = $(this).closest('.group-include, .exclude-row'); self.serializeAllBlocks($row); }); // Handle multi-range add button click this.$wrapper.on('click', '.btn-add-range', function(e) { e.preventDefault(); var $picker = $(this).closest('.value-picker'); var $row = $(this).closest('.group-include, .exclude-row'); var $container = $picker.find('.multi-range-container'); var $chipsContainer = $container.find('.multi-range-chips'); var $minInput = $container.find('.range-min-input'); var $maxInput = $container.find('.range-max-input'); var minVal = $minInput.val().trim(); var maxVal = $maxInput.val().trim(); if (minVal === '' && maxVal === '') { return; } var step = parseFloat($minInput.attr('step')) || 0.01; var minAllowed = $minInput.attr('min'); var hasMinConstraint = typeof minAllowed !== 'undefined' && minAllowed !== ''; minAllowed = hasMinConstraint ? parseFloat(minAllowed) : null; var minNum = minVal !== '' ? parseFloat(minVal) : null; var maxNum = maxVal !== '' ? parseFloat(maxVal) : null; if (hasMinConstraint) { if (minNum !== null && minNum < minAllowed) { self.showRangeInputError($minInput, self.config.trans.min_value_error || 'Minimum value is ' + minAllowed); return; } if (maxNum !== null && maxNum < minAllowed) { self.showRangeInputError($maxInput, self.config.trans.min_value_error || 'Minimum value is ' + minAllowed); return; } } if (minNum !== null && maxNum !== null && minNum > maxNum) { self.showRangeInputError($minInput, self.config.trans.min_greater_than_max || 'Min cannot be greater than max'); return; } var decimals = step < 1 ? String(step).split('.')[1].length : 0; if (minNum !== null) { if (step >= 1) { minNum = Math.round(minNum); } else { minNum = parseFloat(minNum.toFixed(decimals)); } minVal = String(minNum); } if (maxNum !== null) { if (step >= 1) { maxNum = Math.round(maxNum); } else { maxNum = parseFloat(maxNum.toFixed(decimals)); } maxVal = String(maxNum); } var chipText = ''; if (minVal !== '' && maxVal !== '') { chipText = minVal + ' - ' + maxVal; } else if (minVal !== '') { chipText = '≥ ' + minVal; } else { chipText = '≤ ' + maxVal; } var $chip = $('', { class: 'range-chip', 'data-min': minVal, 'data-max': maxVal }); $chip.append($('', { class: 'range-chip-text', text: chipText })); $chip.append($(''; html += '
'; // Group body (collapsible content) html += '
'; // Include section html += '
'; html += '
'; html += '
'; html += ''; html += ''; html += ' 0'; html += '
'; var noItemsText = trans.no_items_selected || 'No items selected - use search below'; html += ''; html += '
'; html += '
'; // Excludes section (collapsed by default) html += '
'; html += ''; html += '
'; // Group-level modifiers (limit & sort) html += '
'; html += ''; html += '' + (trans.limit || 'Limit') + ''; html += ''; html += ''; html += ''; html += '' + (trans.sort || 'Sort') + ''; html += ''; html += ''; html += ''; html += ''; html += ' '; html += ''; html += '
'; html += '
'; // Close group-body html += ''; // Close selection-group $container.append(html); // Find the new group and set method to "all" by default var $newGroup = $container.find('.selection-group[data-group-index="' + groupIndex + '"]'); // Enhance the method select with styled dropdown this.enhanceMethodSelect($newGroup.find('.include-method-select')); $newGroup.find('.include-method-select').val('all').trigger('change'); this.updateBlockStatus($block); this.serializeAllBlocks(); }, removeGroup: function($group, $block) { $group.remove(); var $container = $block.find('.groups-container'); var remainingGroups = $container.find('.selection-group').length; if (remainingGroups === 0) { var emptyText = this.getEmptyStateText($block); var emptyHtml = '
'; emptyHtml += '' + emptyText + ''; emptyHtml += '
'; $container.html(emptyHtml); } this.updateBlockStatus($block); this.serializeAllBlocks(); // Update tab badges and header total count this.updateTabBadges(); }, clearAllConditions: function() { var self = this; // Remove all groups from all blocks this.$wrapper.find('.es-block').each(function() { var $block = $(this); var $container = $block.find('.groups-container'); // Remove all groups $container.find('.selection-group').remove(); // Show empty state var emptyText = self.getEmptyStateText($block); var emptyHtml = '
'; emptyHtml += '' + emptyText + ''; emptyHtml += '
'; $container.html(emptyHtml); self.updateBlockStatus($block); }); // Update serialized data this.serializeAllBlocks(); // Update tab badges and header count this.updateTabBadges(); // Also update header total count immediately (since all cleared) this.updateHeaderTotalCount(); }, switchToBlock: function(blockType) { // Update tabs this.$wrapper.find('.es-block-tab').removeClass('active'); this.$wrapper.find('.es-block-tab[data-block-type="' + blockType + '"]').addClass('active'); // Update blocks this.$wrapper.find('.es-block').removeClass('active').hide(); this.$wrapper.find('.es-block[data-block-type="' + blockType + '"]').addClass('active').show(); // Close dropdown if open this.hideDropdown(); }, /** * Single-mode badge update: read chip counts from DOM, set directly. * No AJAX, no spinners, no updateBlockStatus. */ _updateSingleModeBadges: function() { var self = this; this.$wrapper.find('.es-block-tab').each(function() { var $tab = $(this); var blockType = $tab.data('blockType'); var $block = self.$wrapper.find('.es-block[data-block-type="' + blockType + '"]'); var $badge = $tab.find('.tab-badge'); // Custom blocks: check input value if ($block.hasClass('custom-block')) { var hasVal = false; $block.find('.custom-block-content').find('input, textarea, select').each(function() { if ($(this).val() && $(this).val().trim() !== '') { hasVal = true; return false; } }); if (hasVal) { if (!$badge.length) { $tab.append(''); } else { $badge.html(''); } $tab.addClass('has-data'); } else { $badge.remove(); $tab.removeClass('has-data'); } return; } // Entity blocks: count chips var chipCount = $block.find('.entity-chip:not(.entity-chip-loading)').length; if (chipCount > 0) { if (!$badge.length) { $tab.append(' ' + chipCount + ''); } else { $badge.html(' ' + chipCount); } $tab.addClass('has-data'); } else { $badge.remove(); $tab.removeClass('has-data'); } }); // Also update inline condition count (eye icon next to "Specific products") this.$wrapper.find('.es-block.active').each(function() { var $block = $(this); var chipCount = $block.find('.entity-chip:not(.entity-chip-loading)').length; var $countEl = $block.find('.condition-match-count').first(); self._setBadgeCount($countEl, chipCount); var $groupBadge = $block.find('.group-count-badge').first(); if ($groupBadge.length) { self._setBadgeCount($groupBadge, chipCount); } }); }, /** * Set badge to correct visual state based on count. * ONE function for ALL badge types — no more class juggling in 20 places. */ _setBadgeCount: function($el, count) { if (!$el || !$el.length) return; var html = ' ' + count; if ($el.html() !== html) $el.html(html); $el.removeClass('loading loading-count no-matches clickable'); $el.addClass(count > 0 ? 'clickable' : 'no-matches'); $el.show(); }, updateTabBadges: function() { var self = this; var blockTypesWithData = []; this.$wrapper.find('.es-block-tab').each(function() { var $tab = $(this); var blockType = $tab.data('blockType'); var $block = self.$wrapper.find('.es-block[data-block-type="' + blockType + '"]'); var groupCount = $block.find('.selection-group').length; var $badge = $tab.find('.tab-badge'); if (groupCount > 0) { // Has data — show spinner ONLY if no badge exists yet. // If badge already has a count, keep it visible while AJAX refreshes. if (!$badge.length) { $tab.append(''); } $tab.addClass('has-data'); blockTypesWithData.push(blockType); } else if ($block.hasClass('custom-block')) { var hasCustomValue = false; $block.find('.custom-block-content').find('input, textarea, select').each(function() { if ($(this).val() && $(this).val().trim() !== '') { hasCustomValue = true; return false; } }); if (hasCustomValue) { if (!$badge.length) { $tab.append(''); } else { $badge.removeClass('loading').html(''); } $tab.addClass('has-data'); } else { $badge.remove(); $tab.removeClass('has-data'); } } else { $badge.remove(); $tab.removeClass('has-data'); } }); this.updateTargetSwitchState(); if (blockTypesWithData.length > 0) { this.fetchAllCounts(blockTypesWithData); } }, updateTargetSwitchState: function() { var $switch = this.$wrapper.find('.prestashop-switch'); if (!$switch.length) { return; } // Check if any block has data var hasData = false; this.$wrapper.find('.es-block').each(function() { if ($(this).find('.selection-group').length > 0) { hasData = true; return false; // break } }); // Update switch: value="1" is "Everyone/All/None", value="0" is "Specific/Selected" if (hasData) { $switch.find('input[value="0"]').prop('checked', true); } else { $switch.find('input[value="1"]').prop('checked', true); } }, /** * Fetch counts for all block types in a single bulk AJAX request * @param {Array} blockTypes - Array of block type strings to fetch counts for */ fetchAllCounts: function(blockTypes) { var self = this; var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]'); var savedData = {}; try { savedData = JSON.parse($hiddenInput.val() || '{}'); } catch (e) { savedData = {}; } // Build conditions object for all requested block types var conditions = {}; blockTypes.forEach(function(blockType) { var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : []; if (groups.length > 0) { conditions[blockType] = { groups: groups }; } }); // Blocks with groups but no matchable conditions: show 0 blockTypes.forEach(function(blockType) { if (!(blockType in conditions)) { var $tab = self.$wrapper.find('.es-block-tab[data-block-type="' + blockType + '"]'); var $badge = $tab.find('.tab-badge'); if (!$badge.length) { $badge = $(''); $tab.append($badge); } self._setBadgeCount($badge, 0); $tab.addClass('has-data'); var $block = self.$wrapper.find('.es-block[data-block-type="' + blockType + '"]'); self._setBadgeCount($block.find('.condition-match-count').first(), 0); self._setBadgeCount($block.find('.group-count-badge').first(), 0); } }); if (Object.keys(conditions).length === 0) { return; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewEntitySelectorBulk', trait: 'EntitySelector', conditions: JSON.stringify(conditions) }, success: function(response) { if (response.success && response.counts) { Object.keys(response.counts).forEach(function(blockType) { var count = response.counts[blockType]; var $tab = self.$wrapper.find('.es-block-tab[data-block-type="' + blockType + '"]'); var $badge = $tab.find('.tab-badge'); self._setBadgeCount($badge, count); $tab.data('previewData', { count: count, success: true }); }); // Also update inline + group badges Object.keys(response.counts).forEach(function(blockType) { var count = response.counts[blockType]; var $block = self.$wrapper.find('.es-block[data-block-type="' + blockType + '"]'); self._setBadgeCount($block.find('.condition-match-count').first(), count); self._setBadgeCount($block.find('.group-count-badge').first(), count); }); blockTypes.forEach(function(blockType) { if (!(blockType in response.counts)) { var $tab = self.$wrapper.find('.es-block-tab[data-block-type="' + blockType + '"]'); $tab.find('.tab-badge').remove(); $tab.removeClass('has-data'); } }); self.updateHeaderTotalCount(); } else { console.error('[EntitySelector] Bulk preview failed:', response.error || 'Unknown error'); // Remove loading spinners on error blockTypes.forEach(function(blockType) { var $tab = self.$wrapper.find('.es-block-tab[data-block-type="' + blockType + '"]'); $tab.find('.tab-badge').remove(); }); } }, error: function(xhr, status, error) { console.error('[EntitySelector] Bulk AJAX error:', status, error); // Remove loading spinners on error blockTypes.forEach(function(blockType) { var $tab = self.$wrapper.find('.es-block-tab[data-block-type="' + blockType + '"]'); $tab.find('.tab-badge').remove(); }); } }); }, /** * Fetch count for a single block type (legacy, used for single updates) */ fetchProductCount: function(blockType, $tab) { var self = this; var data = {}; // Read from hidden input (contains full saved data or freshly serialized data) var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]'); var savedData = {}; try { savedData = JSON.parse($hiddenInput.val() || '{}'); } catch (e) { savedData = {}; } // Get groups for the requested block type var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : []; if (groups.length === 0) { $tab.find('.tab-badge').remove(); $tab.removeClass('has-data'); $tab.removeData('previewData'); return; } // Show loading state var $badge = $tab.find('.tab-badge'); if (!$badge.length) { $badge = $(''); $tab.append($badge); } else { $badge.addClass('loading').html(''); } $tab.addClass('has-data'); data[blockType] = { groups: groups }; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewEntitySelector', trait: 'EntitySelector', conditions: JSON.stringify(data), block_type: blockType, limit: 10 }, success: function(response) { if (response.success) { var $badge = $tab.find('.tab-badge'); $badge.removeClass('loading').html(' ' + response.count); // Store preview data for popover $tab.data('previewData', response); // Update header total count self.updateHeaderTotalCount(); } else { console.error('[EntitySelector] Preview failed for', blockType, ':', response.error || 'Unknown error'); $tab.find('.tab-badge').remove(); } }, error: function(xhr, status, error) { console.error('[EntitySelector] AJAX error for', blockType, ':', status, error); $tab.find('.tab-badge').remove(); self.updateHeaderTotalCount(); } }); }, updateHeaderTotalCount: function() { var self = this; var total = 0; // Sum up all tab badge counts this.$wrapper.find('.es-block-tab .tab-badge').each(function() { var $badge = $(this); if (!$badge.hasClass('loading')) { var count = parseInt($badge.text(), 10); if (!isNaN(count)) { total += count; } } }); var $totalBadge = this.$wrapper.find('.trait-total-count'); if (total > 0) { $totalBadge.find('.count-value').text(total); $totalBadge.show(); } else { $totalBadge.hide(); } // Update show-all toggle state this.updateShowAllToggle(); }, updateShowAllToggle: function() { var $toggle = this.$wrapper.find('.trait-show-all-toggle'); if (!$toggle.length) return; var $checkbox = $toggle.find('.show-all-checkbox'); var hasData = this.$wrapper.find('.es-block-tab.has-data').length > 0; // If there's data, uncheck (not showing to all), otherwise check $checkbox.prop('checked', !hasData); }, updateBlockStatus: function($block) { var $status = $block.find('.block-status'); var blockType = $block.data('blockType'); var blockDef = this.config.blocks[blockType] || {}; var trans = this.config.trans || {}; var groups = this.getBlockGroups($block); if (groups.length === 0) { var emptyMeansAll = this.config.emptyMeansAll !== false; if (emptyMeansAll) { $status.text((trans.all || 'All') + ' ' + (blockDef.entity_label_plural || 'items')); } else { $status.text(trans.nothing_selected || 'Nothing selected'); } } else { $status.text(groups.length + ' ' + (groups.length === 1 ? (trans.group || 'group') : (trans.groups || 'groups'))); } }, getEmptyStateText: function($block) { var blockType = $block.data('blockType'); var blockMode = $block.data('mode') || 'multi'; var blockDef = this.config.blocks[blockType] || {}; var trans = this.config.trans || {}; var emptyMeansAll = this.config.emptyMeansAll !== false; if (blockMode === 'single') { return trans.no_item_selected || 'No item selected'; } if (emptyMeansAll) { return (trans.all || 'All') + ' ' + (blockDef.entity_label_plural || 'items') + ' ' + (trans.included || 'included'); } return trans.nothing_selected || 'Nothing selected'; }, serializeGroup: function($group, blockType) { var self = this; // Include var includeMethod = $group.find('.include-method-select').val() || 'all'; var $includePicker = $group.find('.include-picker'); var includeValues = this.getPickerValues($includePicker); // Excludes (multiple rows) var excludes = []; var $excludesSection = $group.find('.group-excludes.has-excludes'); if ($excludesSection.length) { $group.find('.exclude-row').each(function() { var $row = $(this); var excludeMethod = $row.find('.exclude-method-select').val() || null; var $excludePicker = $row.find('.exclude-picker'); var excludeValues = self.getPickerValues($excludePicker); if (excludeMethod && excludeValues && (Array.isArray(excludeValues) ? excludeValues.length > 0 : true)) { excludes.push({ method: excludeMethod, values: excludeValues }); } }); } var groupData = { include: { method: includeMethod, values: includeValues } }; if (excludes.length > 0) { groupData.excludes = excludes; } // Add modifiers if present var modifiers = this.getGroupModifiers($group); if (modifiers.limit || modifiers.sort_by) { groupData.modifiers = modifiers; } return groupData; }, serializeAllBlocks: function($changedRow) { var self = this; var data = {}; this.$wrapper.find('.es-block').each(function() { var $block = $(this); var blockType = $block.data('blockType'); var groups = self.getBlockGroups($block); if (groups.length > 0) { data[blockType] = { groups: groups }; } self.updateBlockStatus($block); }); var $input = this.$wrapper.find('input[name="' + this.config.name + '"]'); var jsonData = JSON.stringify(data); // Skip if data hasn't changed since last serialization if (this._lastSerializedData === jsonData) return; this._lastSerializedData = jsonData; $input.val(jsonData); // Single mode: update badges from chip count directly, no AJAX if (this.config.mode === 'single') { this._updateSingleModeBadges(); return; } // Multi mode: debounce ALL visual updates so rapid serialize calls collapse if (this._visualUpdateTimer) clearTimeout(this._visualUpdateTimer); this._visualUpdateTimer = setTimeout(function() { self.updateTabBadges(); if ($changedRow && $changedRow.length) { self.updateConditionCount($changedRow); var $group = $changedRow.closest('.selection-group'); if ($group.length) { self.updateGroupTotalCount($group); } } else { self.updateAllConditionCounts(); } }, 300); }, getBlockGroups: function($block) { var self = this; var groups = []; $block.find('.selection-group').each(function() { var $group = $(this); // Include var includeMethod = $group.find('.include-method-select').val() || 'all'; var $includePicker = $group.find('.include-picker'); var includeValues = self.getPickerValues($includePicker); // Skip groups with invalid include conditions (e.g., "specific products" with none selected) if (!self.isConditionValid(includeMethod, includeValues, $includePicker)) { return true; // continue to next group } // Excludes (multiple rows) - only include valid ones var excludes = []; var $excludesSection = $group.find('.group-excludes.has-excludes'); if ($excludesSection.length) { $group.find('.exclude-row').each(function() { var $row = $(this); var excludeMethod = $row.find('.exclude-method-select').val() || null; var $excludePicker = $row.find('.exclude-picker'); var excludeValues = self.getPickerValues($excludePicker); // Only include valid exclude conditions if (excludeMethod && self.isConditionValid(excludeMethod, excludeValues, $excludePicker)) { excludes.push({ method: excludeMethod, values: excludeValues }); } }); } var groupData = { include: { method: includeMethod, values: includeValues } }; // Group name (optional, for organizational purposes) var groupName = $.trim($group.attr('data-group-name') || ''); if (groupName) { groupData.name = groupName; } if (excludes.length > 0) { groupData.excludes = excludes; } // Group-level modifiers var modifiers = self.getGroupModifiers($group); if (modifiers.limit || modifiers.sort_by) { groupData.modifiers = modifiers; } groups.push(groupData); }); return groups; }, getGroupModifiers: function($group) { var limit = $group.find('.group-modifier-limit').val(); var sortBy = $group.find('.group-modifier-sort').val() || 'sales'; var $sortDirBtn = $group.find('.group-modifiers .btn-sort-dir'); var sortDir = $sortDirBtn.data('dir') || 'DESC'; return { limit: limit ? parseInt(limit, 10) : null, sort_by: sortBy || null, sort_dir: sortDir || 'DESC' }; }, getPickerValues: function($picker) { var valueType = $picker.attr('data-value-type') || 'entity_search'; var values = []; switch (valueType) { case 'entity_search': $picker.find('.entity-chip').each(function() { var id = $(this).data('id'); values.push(isNaN(id) ? id : Number(id)); }); break; case 'pattern': values = this.getPatternTags($picker); // Also include draft pattern if it has content (not yet added as tag) var $draftInput = $picker.find('.draft-tag .pattern-input'); var draftPattern = $.trim($draftInput.val()); if (draftPattern) { var draftCaseSensitive = $draftInput.closest('.draft-tag').attr('data-case-sensitive') === '1'; values.push({ pattern: draftPattern, caseSensitive: draftCaseSensitive }); } break; case 'numeric_range': var min = $picker.find('.range-min-input').val(); var max = $picker.find('.range-max-input').val(); if (min !== '' || max !== '') { values = { min: min !== '' ? parseFloat(min) : null, max: max !== '' ? parseFloat(max) : null }; } break; case 'date_range': var from = $picker.find('.date-from-input').val(); var to = $picker.find('.date-to-input').val(); if (from || to) { values = { from: from || null, to: to || null }; } break; case 'select': var selectVal = $picker.find('.select-value-input').val(); if (selectVal) { values = [selectVal]; } break; case 'boolean': values = [true]; break; case 'multi_numeric_range': var ranges = []; $picker.find('.range-chip').each(function() { var $chip = $(this); var minVal = $chip.data('min'); var maxVal = $chip.data('max'); ranges.push({ min: minVal !== '' && minVal !== undefined ? parseFloat(minVal) : null, max: maxVal !== '' && maxVal !== undefined ? parseFloat(maxVal) : null }); }); if (ranges.length > 0) { values = ranges; } break; case 'multi_select_tiles': $picker.find('.tile-option.selected').each(function() { values.push($(this).data('value')); }); break; case 'combination_attributes': // Returns object: { mode: 'products'|'combinations', attributes: { groupId: [valueId1, valueId2], ... } } var combAttrs = {}; $picker.find('.comb-attr-value.selected').each(function() { var groupId = $(this).data('groupId').toString(); var valueId = $(this).data('valueId'); if (!combAttrs[groupId]) { combAttrs[groupId] = []; } combAttrs[groupId].push(valueId); }); if (Object.keys(combAttrs).length > 0) { // Get mode: from radio if toggle exists, otherwise from config var $combPicker = $picker.find('.combination-attributes-picker'); var configMode = $combPicker.data('combinationMode') || this.config.combinationMode || 'products'; var combMode; if (configMode === 'toggle') { combMode = $picker.find('.comb-mode-radio:checked').val() || 'products'; } else { combMode = configMode; } values = { mode: combMode, attributes: combAttrs }; } break; } return values; }, isConditionValid: function(method, values, $picker) { // 'all' method never needs values if (method === 'all') { return true; } // Boolean methods are always valid (the value is implicit true) var valueType = $picker.attr('data-value-type') || 'entity_search'; if (valueType === 'boolean') { return true; } // For other methods, check if values are meaningful if (Array.isArray(values)) { return values.length > 0; } // For object values (ranges, combination_attributes), check if meaningful if (typeof values === 'object' && values !== null) { // Special handling for combination_attributes: { mode, attributes } if (valueType === 'combination_attributes' && values.attributes !== undefined) { return Object.keys(values.attributes).length > 0; } // For ranges and other objects, check if at least one bound is set return Object.keys(values).some(function(key) { return values[key] !== null && values[key] !== ''; }); } return false; }, /** * Update all condition counts using a single bulk AJAX request */ updateAllConditionCounts: function() { var self = this; var conditions = {}; var conditionElements = {}; var conditionIndex = 0; // Collect all conditions from all active groups this.$wrapper.find('.es-block.active .selection-group').each(function() { var $group = $(this); var $block = $group.closest('.es-block'); var blockType = $block.data('blockType') || 'products'; // Process include row var $include = $group.find('.group-include'); if ($include.length) { var includeData = self.getConditionData($include, blockType); if (includeData) { var id = 'c' + conditionIndex++; conditions[id] = includeData.condition; conditionElements[id] = includeData.$countEl; } } // Process exclude rows $group.find('.exclude-row').each(function() { var excludeData = self.getConditionData($(this), blockType); if (excludeData) { var id = 'c' + conditionIndex++; conditions[id] = excludeData.condition; conditionElements[id] = excludeData.$countEl; } }); }); // If no conditions, nothing to do if (Object.keys(conditions).length === 0) { return; } // Make single bulk AJAX request $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countConditionMatchesBulk', trait: 'EntitySelector', conditions: JSON.stringify(conditions) }, success: function(response) { if (response && response.success && response.counts) { // Update each count element with its result Object.keys(response.counts).forEach(function(id) { var count = response.counts[id] || 0; var $countEl = conditionElements[id]; if ($countEl && $countEl.length) { $countEl.removeClass('no-matches clickable'); if (count === 0) { $countEl.find('.preview-count').text(count); self._setBadgeCount($countEl, 0); } else { self._setBadgeCount($countEl, count); } } }); } // Note: Group totals are updated on-demand when user interacts, not on initial load }, error: function() { // Hide all count elements on error Object.keys(conditionElements).forEach(function(id) { var $countEl = conditionElements[id]; if ($countEl && $countEl.length) { self._setBadgeCount($countEl, 0); } }); } }); }, /** * Extract condition data from a row for bulk counting */ getConditionData: function($row, blockType) { var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first(); if (!$countEl.length) return null; var isExclude = $row.hasClass('exclude-row'); var $methodSelect = isExclude ? $row.find('.exclude-method-select') : $row.find('.include-method-select'); var method = $methodSelect.val(); if (!method) { self._setBadgeCount($countEl, 0); return null; } var $picker = isExclude ? $row.find('.exclude-picker') : $row.find('.include-picker'); var valueType = $picker.data('valueType') || $picker.attr('data-value-type') || 'none'; // Special case: "All countries" method - needs separate handling for holidays if (valueType === 'none' && blockType === 'countries' && method === 'all') { // Trigger separate update for this special case (uses nested AJAX) var self = this; setTimeout(function() { self.updateConditionCount($row, blockType); }, 0); return null; // Skip bulk processing, handled separately } // Special case: Specific countries with entity_search - needs holiday counting, not entity counting var searchEntity = $picker.attr('data-search-entity') || ''; if (blockType === 'countries' && valueType === 'entity_search' && searchEntity === 'countries') { var self = this; setTimeout(function() { self.updateConditionCount($row, blockType); }, 0); return null; // Skip bulk processing, handled separately } // "All" type methods (valueType === 'none') — store conditionData for preview, count comes from fetchAllCounts if (valueType === 'none') { $countEl.data('conditionData', { method: method, values: [], blockType: blockType, isExclude: isExclude }); return null; } var values = this.getPickerValues($picker, valueType); // Don't count if no values (except for boolean methods) var hasNoValues = !values || (Array.isArray(values) && values.length === 0) || (typeof values === 'object' && !Array.isArray(values) && ( (valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) || (valueType !== 'combination_attributes' && Object.keys(values).length === 0) )); if (valueType !== 'boolean' && hasNoValues) { self._setBadgeCount($countEl, 0); return null; } // Show loading state — keep existing count visible, just dim it if ($countEl.hasClass('no-matches')) { $countEl.addClass('loading-count'); } $countEl.show(); // Store condition data on badge for popover $countEl.data('conditionData', { method: method, values: values, blockType: blockType, isExclude: isExclude }); return { condition: { method: method, values: values, block_type: blockType }, $countEl: $countEl }; }, updateGroupCounts: function($group) { var self = this; var $block = $group.closest('.es-block'); var blockType = $block.data('blockType') || 'products'; // Update include count var $include = $group.find('.group-include'); if ($include.length) { this.updateConditionCount($include, blockType); } // Update each exclude row count $group.find('.exclude-row').each(function() { self.updateConditionCount($(this), blockType); }); // Update group total count (include - excludes) this.updateGroupTotalCount($group); }, /** * Update a single condition count (used for individual updates after user changes) */ updateConditionCount: function($row, blockType) { var self = this; var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row .condition-match-count').first(); if (!$countEl.length) { return; } var isExclude = $row.hasClass('exclude-row'); var $methodSelect = isExclude ? $row.find('.exclude-method-select') : $row.find('.include-method-select'); var method = $methodSelect.val(); if (!method) { self._setBadgeCount($countEl, 0); return; } var $picker = isExclude ? $row.find('.exclude-picker') : $row.find('.include-picker'); var valueType = $picker.data('valueType') || 'none'; var searchEntity = $picker.attr('data-search-entity') || ''; // Get the block type to check if this is a countries block if (!blockType) { var $block = $row.closest('.es-block'); blockType = $block.data('blockType') || 'products'; } // Special case: "All countries" method - fetch holidays for all countries if (valueType === 'none' && blockType === 'countries' && method === 'all') { if ($countEl.hasClass('no-matches')) { $countEl.addClass('loading-count'); } $countEl.show(); // First fetch all active country IDs, then get holidays $.ajax({ url: self.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'searchTargetEntities', trait: 'EntitySelector', entity_type: 'countries', query: '', limit: 500 }, success: function(response) { var items = response.results || response.items || []; if (response && response.success && items.length > 0) { var allCountryIds = items.map(function(item) { return item.id; }); // Store condition data for click handler $countEl.data('conditionData', { method: method, values: allCountryIds, blockType: blockType, isExclude: isExclude, isCountryHolidays: true, countryIds: allCountryIds, isAllCountries: true }); // Now fetch holiday count $.ajax({ url: self.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getHolidaysForCountries', trait: 'EntitySelector', country_ids: allCountryIds.join(','), count_only: 1 }, success: function(holidayResponse) { if (holidayResponse && holidayResponse.success) { var count = holidayResponse.total_count || 0; $countEl.removeClass('no-matches clickable'); $countEl.addClass('country-holidays'); if (count === 0) { $countEl.find('.preview-count').text(count); self._setBadgeCount($countEl, 0); } else { self._setBadgeCount($countEl, count); } $countEl.data('countriesInfo', holidayResponse.countries || []); } else { self._setBadgeCount($countEl, 0); } }, error: function() { self._setBadgeCount($countEl, 0); } }); } else { self._setBadgeCount($countEl, 0); } }, error: function() { self._setBadgeCount($countEl, 0); } }); return; } // "All" type methods (valueType === 'none') — store conditionData for preview if (valueType === 'none') { $countEl.data('conditionData', { method: method, values: [], blockType: blockType, isExclude: isExclude }); return; } var values = this.getPickerValues($picker, valueType); var hasNoValues = !values || (Array.isArray(values) && values.length === 0) || (typeof values === 'object' && !Array.isArray(values) && ( (valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) || (valueType !== 'combination_attributes' && Object.keys(values).length === 0) )); if (valueType !== 'boolean' && hasNoValues) { self._setBadgeCount($countEl, 0); return; } if (!blockType) { var $block = $row.closest('.es-block'); blockType = $block.data('blockType') || 'products'; } // Check if this is a country selection - show holiday count instead var isCountrySelection = (searchEntity === 'countries' && valueType === 'entity_search'); if ($countEl.hasClass('no-matches')) { $countEl.addClass('loading-count'); } $countEl.show(); // For countries, fetch holiday count if (isCountrySelection && Array.isArray(values) && values.length > 0) { $countEl.data('conditionData', { method: method, values: values, blockType: blockType, isExclude: isExclude, isCountryHolidays: true, countryIds: values }); $.ajax({ url: self.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getHolidaysForCountries', trait: 'EntitySelector', country_ids: values.join(','), count_only: 1 }, success: function(response) { if (response && response.success) { var count = response.total_count || 0; $countEl.removeClass('no-matches clickable'); $countEl.addClass('country-holidays'); if (count === 0) { $countEl.find('.preview-count').text(count); self._setBadgeCount($countEl, 0); } else { self._setBadgeCount($countEl, count); } // Store countries info for popover $countEl.data('countriesInfo', response.countries || []); } else { self._setBadgeCount($countEl, 0); } }, error: function() { self._setBadgeCount($countEl, 0); } }); return; } // Default: count entities $countEl.data('conditionData', { method: method, values: values, blockType: blockType, isExclude: isExclude }); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countConditionMatches', trait: 'EntitySelector', method: method, values: JSON.stringify(values), block_type: blockType }, success: function(response) { if (response && response.success) { var count = response.count || 0; $countEl.removeClass('no-matches clickable'); if (count === 0) { $countEl.find('.preview-count').text(count); self._setBadgeCount($countEl, 0); } else { self._setBadgeCount($countEl, count); } } else { self._setBadgeCount($countEl, 0); } }, error: function() { self._setBadgeCount($countEl, 0); } }); }, updateGroupTotalCount: function($group) { var self = this; var $block = $group.closest('.es-block'); var blockType = $block.data('blockType') || 'products'; var $badge = $group.find('.group-header .group-count-badge'); var $limitInput = $group.find('.group-modifier-limit'); // Build group data for AJAX var groupData = this.serializeGroup($group, blockType); // Check if include has valid data if (!groupData.include || !groupData.include.method) { $badge.hide(); $limitInput.attr('placeholder', '–'); return; } // Show loading if (!$badge.hasClass('clickable')) { $badge.addClass('loading-count'); } $badge.show(); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countGroupItems', trait: 'EntitySelector', group_data: JSON.stringify(groupData), block_type: blockType }, success: function(response) { if (response && response.success) { var finalCount = response.final_count || 0; var excludeCount = response.exclude_count || 0; var badgeHtml = ' ' + finalCount; if (excludeCount > 0) { badgeHtml += ' (-' + excludeCount + ')'; } if ($badge.html() !== badgeHtml) { $badge.html(badgeHtml); } self._setBadgeCount($badge, finalCount); // Store group data on badge for preview popover $badge.data('groupData', groupData); $badge.data('blockType', blockType); $badge.data('finalCount', finalCount); // Update limit placeholder with the count $limitInput.attr('placeholder', finalCount); // Also update the group-preview-badge count (apply limit if set) var $previewBadge = $group.find('.group-preview-badge .preview-count'); if ($previewBadge.length) { var limit = parseInt($limitInput.val(), 10); var displayCount = (limit > 0 && limit < finalCount) ? limit : finalCount; $previewBadge.text(displayCount); } } else { self._setBadgeCount($badge, 0); $limitInput.attr('placeholder', '–'); } }, error: function() { $badge.hide(); $limitInput.attr('placeholder', '–'); } }); }, // Exclude row management addFirstExcludeRow: function($group, $block) { var $excludesDiv = $group.find('.group-excludes'); var trans = this.config.trans || {}; // Build the full excludes structure with first row var html = '
'; html += ' ' + (trans.except || 'EXCEPT') + ''; html += '
'; html += '
'; html += this.buildExcludeRowHtml($block, 0); html += '
'; html += ''; $excludesDiv.addClass('has-excludes').html(html); // Enhance the first exclude method select with styled dropdown var $firstRow = $excludesDiv.find('.exclude-row[data-exclude-index="0"]'); var $firstSelect = $firstRow.find('.exclude-method-select'); this.enhanceMethodSelect($firstSelect); // Update method info placeholder for initial selection var blockType = $block.data('blockType'); var initialMethod = $firstSelect.val(); this.updateMethodInfoPlaceholder($firstRow.find('.method-selector-wrapper'), initialMethod, blockType); this.updateMethodSelectorLock($group, true); this.serializeAllBlocks(); }, addExcludeRow: function($group, $block) { var $container = $group.find('.exclude-rows-container'); // Get next exclude index var maxIndex = -1; $container.find('.exclude-row').each(function() { var idx = parseInt($(this).data('excludeIndex'), 10); if (idx > maxIndex) maxIndex = idx; }); var excludeIndex = maxIndex + 1; var html = this.buildExcludeRowHtml($block, excludeIndex); $container.append(html); // Enhance the exclude method select with styled dropdown var $newRow = $container.find('.exclude-row[data-exclude-index="' + excludeIndex + '"]'); var $newSelect = $newRow.find('.exclude-method-select'); this.enhanceMethodSelect($newSelect); // Update method info placeholder for initial selection var blockType = $block.data('blockType'); var initialMethod = $newSelect.val(); this.updateMethodInfoPlaceholder($newRow.find('.method-selector-wrapper'), initialMethod, blockType); this.serializeAllBlocks(); }, buildExcludeRowHtml: function($block, excludeIndex) { var blockType = $block.data('blockType'); var blockDef = this.config.blocks[blockType] || {}; var methods = blockDef.selection_methods || {}; var trans = this.config.trans || {}; // Build exclude method options with optgroups (no "all") var excludeMethodOptions = this.buildMethodOptions(methods, true); // Find first non-all method for default search entity var firstSearchEntity = blockType; var firstValueType = 'entity_search'; $.each(methods, function(methodKey, methodDef) { if (methodKey === 'all') return true; firstSearchEntity = methodDef.search_entity || blockType; firstValueType = methodDef.value_type || 'entity_search'; return false; // break }); var html = '
'; // Header row with method select wrapped in method-selector-wrapper (same as include) html += '
'; html += '
'; html += ''; html += ' 0'; html += ''; html += '
'; html += ''; html += '
'; // Value picker based on first method's value type html += this.buildValuePickerHtml('exclude', firstValueType, firstSearchEntity, methods); html += '
'; return html; }, removeExcludeRow: function($excludeRow, $group, $block) { var $container = $group.find('.exclude-rows-container'); var trans = this.config.trans || {}; $excludeRow.remove(); // Check if there are remaining exclude rows var remainingRows = $container.find('.exclude-row').length; if (remainingRows === 0) { // Remove entire excludes section and show "Add exceptions" button var $excludesDiv = $group.find('.group-excludes'); $excludesDiv.removeClass('has-excludes').html( '' ); // Unlock the method selector since no excludes exist this.updateMethodSelectorLock($group, false); } this.serializeAllBlocks(); }, // Method options building buildMethodOptions: function(methods, excludeAll) { var self = this; var trans = this.config.trans || {}; var html = ''; // Group labels var groupLabels = { 'select_by': trans.select_by || 'Select by...', 'filter_by': trans.filter_by || 'Filter by...' }; // Separate methods by group var grouped = {}; var ungrouped = {}; $.each(methods, function(methodKey, methodDef) { if (excludeAll && methodKey === 'all') return true; // skip var group = methodDef.group || ''; if (group) { if (!grouped[group]) { grouped[group] = {}; } grouped[group][methodKey] = methodDef; } else { ungrouped[methodKey] = methodDef; } }); // Render ungrouped options first $.each(ungrouped, function(methodKey, methodDef) { html += self.buildMethodOption(methodKey, methodDef); }); // Render grouped options with optgroups $.each(grouped, function(groupKey, groupMethods) { var groupLabel = groupLabels[groupKey] || groupKey.replace(/_/g, ' '); html += ''; $.each(groupMethods, function(methodKey, methodDef) { html += self.buildMethodOption(methodKey, methodDef); }); html += ''; }); return html; }, buildMethodOption: function(methodKey, methodDef) { var html = ''); } } // Ensure dropdown-actions are visible and history button is deactivated this.$dropdown.find('.dropdown-actions').show(); this.$dropdown.find('.btn-show-history').removeClass('active'); // Disable history button if no search history for current entity type var entityType = this.activeGroup ? this.activeGroup.searchEntity : null; var hasHistory = entityType && this.getSearchHistory(entityType).length > 0; this.$dropdown.find('.btn-show-history').prop('disabled', !hasHistory); }, // NOTE: Tree methods (loadCategoryTree, renderCategoryTree, filterCategoryTree, // findTreeDescendants, findTreeAncestors, updateSelectChildrenButtons) are // defined in _tree.js which is merged later and takes precedence. // ========================================================================= // Search History // ========================================================================= loadSearchHistory: function() { try { var stored = localStorage.getItem(this.searchHistoryKey); this.searchHistory = stored ? JSON.parse(stored) : {}; } catch (e) { this.searchHistory = {}; } }, saveSearchHistory: function() { try { localStorage.setItem(this.searchHistoryKey, JSON.stringify(this.searchHistory)); } catch (e) { // localStorage might be full or unavailable } }, addToSearchHistory: function(entityType, query) { if (!query || query.length < 2) return; if (!this.searchHistory[entityType]) { this.searchHistory[entityType] = []; } var history = this.searchHistory[entityType]; // Remove if already exists (will re-add at top) var existingIndex = history.indexOf(query); if (existingIndex !== -1) { history.splice(existingIndex, 1); } // Add at beginning history.unshift(query); // Trim to max if (history.length > this.searchHistoryMax) { history = history.slice(0, this.searchHistoryMax); } this.searchHistory[entityType] = history; this.saveSearchHistory(); }, removeFromSearchHistory: function(entityType, query) { if (!this.searchHistory[entityType]) return; var index = this.searchHistory[entityType].indexOf(query); if (index !== -1) { this.searchHistory[entityType].splice(index, 1); this.saveSearchHistory(); } }, getSearchHistory: function(entityType) { return this.searchHistory[entityType] || []; }, showSearchHistory: function(entityType) { var history = this.getSearchHistory(entityType); var trans = this.config.trans || {}; var $container = this.$dropdown.find('.dropdown-results'); // Update header this.$dropdown.find('.results-count').text(trans.recent_searches || 'Recent searches'); // Hide filters, actions, and results header for history view this.$dropdown.find('.dropdown-actions').hide(); this.$dropdown.find('.filter-panel').removeClass('show'); this.$dropdown.find('.btn-toggle-filters').removeClass('active'); this.$dropdown.find('.results-header').hide(); if (!history.length) { // No history - just do a regular search this.performSearch(); return; } // Build history items var html = '
'; for (var i = 0; i < history.length; i++) { var query = history[i]; html += '
'; html += ''; html += '' + this.escapeHtml(query) + ''; html += ''; html += '
'; } html += '
'; $container.html(html); this.$dropdown.addClass('show'); }, // ========================================================================= // Filter Methods // ========================================================================= refreshSearch: function() { // In tree view mode, re-filter the tree instead of doing a flat AJAX search if (this.viewMode === 'tree') { this.filterCategoryTree(this.searchQuery || ''); return; } this.searchOffset = 0; this.loadMoreCount = 20; // Reset load more select to default if (this.$dropdown) { this.$dropdown.find('.load-more-select').val('20'); // Remove the dynamic "All" option this.$dropdown.find('.load-more-select option[data-all="true"]').remove(); } this.performSearch(false); }, clearFilters: function() { this.refineQuery = ''; this.refineNegate = false; this.filters = { inStock: false, discounted: false, priceMin: null, priceMax: null, attributes: [], features: [], // Entity-specific filters productCountMin: null, productCountMax: null, salesMin: null, salesMax: null, turnoverMin: null, turnoverMax: null, depth: null, hasProducts: false, hasDescription: false, hasImage: false, activeOnly: true, attributeGroup: null, featureGroup: null, dateAddFrom: null, dateAddTo: null, lastProductFrom: null, lastProductTo: null, // Country-specific filters hasHolidays: false, containsStates: false, zone: null }; if (this.$dropdown) { var trans = this.config.trans || {}; this.$dropdown.find('.refine-input').val('').attr('placeholder', trans.refine_short || 'Refine...'); this.$dropdown.find('.btn-clear-refine').hide(); this.$dropdown.find('.btn-refine-negate').removeClass('active'); this.$dropdown.find('.filter-in-stock').prop('checked', false); this.$dropdown.find('.filter-discounted').prop('checked', false); this.$dropdown.find('.filter-price-min').val(''); this.$dropdown.find('.filter-price-max').val(''); this.$dropdown.find('.filter-attr-chip').removeClass('active'); this.$dropdown.find('.filter-feat-chip').removeClass('active'); this.$dropdown.find('.filter-group-toggle').removeClass('active has-selection'); this.$dropdown.find('.filter-row-values').hide(); // Clear entity-specific filter inputs this.$dropdown.find('.filter-product-count-min, .filter-product-count-max').val(''); this.$dropdown.find('.filter-sales-min, .filter-sales-max').val(''); this.$dropdown.find('.filter-turnover-min, .filter-turnover-max').val(''); this.$dropdown.find('.filter-date-add-from, .filter-date-add-to').val(''); this.$dropdown.find('.filter-last-product-from, .filter-last-product-to').val(''); this.$dropdown.find('.filter-depth-select').val(''); this.$dropdown.find('.filter-has-products').prop('checked', false); this.$dropdown.find('.filter-has-description').prop('checked', false); this.$dropdown.find('.filter-has-image').prop('checked', false); this.$dropdown.find('.filter-active-only').prop('checked', true); this.$dropdown.find('.filter-attribute-group-select, .filter-feature-group-select').val(''); // Country filters this.$dropdown.find('.filter-has-holidays').prop('checked', false); this.$dropdown.find('.filter-contains-states').prop('checked', false); this.$dropdown.find('.filter-zone-select').val(''); } this.refreshSearch(); }, // Reset filters without triggering a search (used when switching entity types) resetFiltersWithoutSearch: function() { this.refineQuery = ''; this.refineNegate = false; this.filters = { inStock: false, discounted: false, priceMin: null, priceMax: null, attributes: [], features: [], productCountMin: null, productCountMax: null, salesMin: null, salesMax: null, turnoverMin: null, turnoverMax: null, depth: null, hasProducts: false, hasDescription: false, hasImage: false, activeOnly: true, attributeGroup: null, featureGroup: null, dateAddFrom: null, dateAddTo: null, lastProductFrom: null, lastProductTo: null, // Country-specific filters hasHolidays: false, containsStates: false, zone: null }; if (this.$dropdown) { var trans = this.config.trans || {}; this.$dropdown.find('.refine-input').val('').attr('placeholder', trans.refine_short || 'Refine...'); this.$dropdown.find('.btn-clear-refine').hide(); this.$dropdown.find('.btn-refine-negate').removeClass('active'); this.$dropdown.find('.filter-in-stock').prop('checked', false); this.$dropdown.find('.filter-discounted').prop('checked', false); this.$dropdown.find('.filter-price-min').val(''); this.$dropdown.find('.filter-price-max').val(''); this.$dropdown.find('.filter-attr-chip').removeClass('active'); this.$dropdown.find('.filter-feat-chip').removeClass('active'); this.$dropdown.find('.filter-group-toggle').removeClass('active has-selection'); this.$dropdown.find('.filter-row-values').hide(); this.$dropdown.find('.filter-product-count-min, .filter-product-count-max').val(''); this.$dropdown.find('.filter-sales-min, .filter-sales-max').val(''); this.$dropdown.find('.filter-turnover-min, .filter-turnover-max').val(''); this.$dropdown.find('.filter-date-add-from, .filter-date-add-to').val(''); this.$dropdown.find('.filter-last-product-from, .filter-last-product-to').val(''); this.$dropdown.find('.filter-depth-select').val(''); this.$dropdown.find('.filter-has-products').prop('checked', false); this.$dropdown.find('.filter-has-description').prop('checked', false); this.$dropdown.find('.filter-has-image').prop('checked', false); this.$dropdown.find('.filter-active-only').prop('checked', true); this.$dropdown.find('.filter-attribute-group-select, .filter-feature-group-select').val(''); // Country filters this.$dropdown.find('.filter-has-holidays').prop('checked', false); this.$dropdown.find('.filter-contains-states').prop('checked', false); this.$dropdown.find('.filter-zone-select').val(''); } // Note: Does NOT call refreshSearch() - caller handles search/load }, updateFilterPanelForEntity: function(entityType) { if (!this.$dropdown) { return; } var $panel = this.$dropdown.find('.filter-panel'); // Hide all filter rows first $panel.find('.filter-row').hide(); // Show/hide tree view option based on entity type var $treeOption = this.$dropdown.find('.view-mode-select option.tree-view-option'); if (entityType === 'categories' || entityType === 'cms_categories') { $treeOption.prop('disabled', false).prop('hidden', false); // Auto-switch to tree view for categories if (this.viewMode !== 'tree') { this.viewMode = 'tree'; this.$dropdown.find('.view-mode-select').val('tree'); this.$dropdown.removeClass('view-list view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-tree'); this.loadCategoryTree(); } else { this.loadCategoryTree(); } } else { $treeOption.prop('disabled', true).prop('hidden', true); // If currently in tree mode, switch back to list if (this.viewMode === 'tree') { this.viewMode = 'list'; this.$dropdown.find('.view-mode-select').val('list'); this.$dropdown.removeClass('view-tree').addClass('view-list'); } } // Show entity-specific filter row (prepare visibility, but don't auto-expand panel) if (entityType === 'products') { // Prepare the correct rows to be visible when panel is shown $panel.find('.filter-row-quick').show(); // Show attribute/feature rows if we have cached data if (this.filterableData) { if (this.filterableData.attributes && this.filterableData.attributes.length > 0) { this.$dropdown.find('.filter-row-attributes').show(); } if (this.filterableData.features && this.filterableData.features.length > 0) { this.$dropdown.find('.filter-row-features').show(); } } } else if (entityType === 'categories') { $panel.find('.filter-row-entity-categories').show(); } else if (entityType === 'manufacturers') { $panel.find('.filter-row-entity-manufacturers').show(); } else if (entityType === 'suppliers') { $panel.find('.filter-row-entity-suppliers').show(); } else if (entityType === 'attributes') { $panel.find('.filter-row-entity-attributes').show(); this.loadAttributeGroups(); } else if (entityType === 'features') { $panel.find('.filter-row-entity-features').show(); } else if (entityType === 'cms') { $panel.find('.filter-row-entity-cms').show(); } else if (entityType === 'cms_categories') { $panel.find('.filter-row-entity-cms-categories').show(); } else if (entityType === 'countries') { $panel.find('.filter-row-entity-countries').show(); this.loadZonesForCountryFilter(); } }, loadAttributeGroups: function() { var self = this; var $select = this.$dropdown.find('.filter-attribute-group-select'); // Already loaded? if ($select.find('option').length > 1) return; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getAttributeGroups', trait: 'EntitySelector' }, success: function(response) { if (response.success && response.groups) { $.each(response.groups, function(i, group) { $select.append(''); }); } } }); }, loadFeatureGroups: function() { var self = this; var $select = this.$dropdown.find('.filter-feature-group-select'); // Already loaded? if ($select.find('option').length > 1) return; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getFeatureGroups', trait: 'EntitySelector' }, success: function(response) { if (response.success && response.groups) { $.each(response.groups, function(i, group) { $select.append(''); }); } } }); } }; })(jQuery); /** * Entity Selector - Dropdown Module * Search dropdown UI creation and positioning * @partial _dropdown.js */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.dropdown = { createDropdown: function() { this.$wrapper.find('.es-search-dropdown').remove(); var trans = this.config.trans || {}; var html = '
'; // Header with results count, actions, sort controls, view mode html += ''; // End dropdown-header // Filter panel html += '
'; // Quick filters row (for products) html += '
'; html += ''; html += ''; // Price range html += '
'; html += '' + (trans.price || 'Price') + ':'; html += ''; html += '-'; html += ''; html += '
'; html += ''; html += '
'; // Attribute/Feature filter toggles for products html += ''; html += ''; html += ''; html += ''; // Entity-specific filters: Categories html += ''; // Entity-specific filters: Manufacturers html += ''; // Entity-specific filters: Suppliers html += ''; // Entity-specific filters: Attributes html += ''; // Entity-specific filters: Features html += ''; // Entity-specific filters: CMS Pages html += ''; // Entity-specific filters: CMS Categories html += ''; // Entity-specific filters: Countries html += ''; html += '
'; // End filter-panel // Results header for list view (product columns) html += '
'; html += ''; html += '' + (trans.product || 'Product') + ''; html += '' + (trans.price || 'Price') + ''; html += '' + (trans.sale || 'Sale') + ''; html += '' + (trans.stock || 'Stock') + ''; html += '' + (trans.sold || 'Sold') + ''; html += '
'; // Results html += ''; // Footer - unified load more + actions html += ''; html += '
'; this.$dropdown = $(html); $('body').append(this.$dropdown); }, hideDropdown: function() { if (this.$dropdown) { this.$dropdown.removeClass('show'); } this.activeGroup = null; }, positionDropdown: function($input) { if (!this.$dropdown) return; var $picker = $input.closest('.value-picker'); var $searchBox = $input.closest('.entity-search-box'); // Get absolute positions (dropdown is appended to body) var searchBoxOffset = $searchBox.offset(); var searchBoxHeight = $searchBox.outerHeight(); var pickerOffset = $picker.offset(); var pickerWidth = $picker.outerWidth(); // Calculate position relative to document var dropdownTop = searchBoxOffset.top + searchBoxHeight + 4; var dropdownLeft = pickerOffset.left; var dropdownWidth = Math.max(pickerWidth, 400); // Ensure dropdown doesn't overflow the viewport horizontally var viewportWidth = $(window).width(); if (dropdownLeft + dropdownWidth > viewportWidth - 10) { dropdownWidth = viewportWidth - dropdownLeft - 10; } // Ensure dropdown doesn't overflow viewport vertically var viewportHeight = $(window).height(); var scrollTop = $(window).scrollTop(); var maxHeight = viewportHeight - (dropdownTop - scrollTop) - 20; maxHeight = Math.max(maxHeight, 400); this.$dropdown.css({ position: 'absolute', top: dropdownTop, left: dropdownLeft, width: dropdownWidth, maxHeight: maxHeight, zIndex: 10000 }); // Show the dropdown this.$dropdown.addClass('show'); } }; })(jQuery); /** * Entity Selector - Chips Module * Entity chip rendering, selection management, and pattern tag handling * @partial _chips.js * * EXTRACTION SOURCE: assets/js/admin/entity-selector.js * * Contains: * - addSelection() / addSelectionNoUpdate() - Add entity chip to picker * - removeSelection() - Remove chip and update state * - updateChipsVisibility() - Show/hide based on count * - loadExistingSelections() - Load saved values on init * - collectPickerEntities() / loadPickerValues() - Entity loading helpers * - Pattern tag methods: addPatternTag, getPatternTags, updateDraftTagCount * - Single mode: getCurrentSingleSelection, showReplaceConfirmation * - Count updates: updateConditionCount, updateGroupCounts, updateGroupTotalCount */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.chips = { // ========================================================================= // Selection Methods (Entity Chips) // ========================================================================= addSelection: function($picker, id, name, data) { this.addSelectionNoUpdate($picker, id, name, data); if (this.config.mode !== 'single') { var $chips = $picker.find('.entity-chips'); this.updateChipsVisibility($chips); } }, addSelectionNoUpdate: function($picker, id, name, data) { var $chips = $picker.find('.entity-chips'); var $block = $picker.closest('.es-block'); // Check for global single mode (only ONE item across ALL entity types) var globalMode = this.config.mode || 'multi'; if (globalMode === 'single') { // Clear ALL selections in ALL blocks (across all entity types) this.$wrapper.find('.entity-chips .entity-chip').remove(); // Clear all selected states in dropdown if (this.$dropdown) { this.$dropdown.find('.dropdown-item.selected, .tree-item.selected').removeClass('selected'); } } else { // Check if this block is in per-block single mode var blockMode = $block.data('mode') || 'multi'; // In per-block single mode, clear chips in THIS block only if (blockMode === 'single') { $chips.find('.entity-chip').remove(); // Also deselect all items in dropdown if (this.$dropdown) { this.$dropdown.find('.dropdown-item.selected, .tree-item.selected').removeClass('selected'); } } } if ($chips.find('.entity-chip[data-id="' + id + '"]').length) { return; } // Remove empty state placeholder var hadEmpty = $chips.find('.chips-empty-state').length > 0; $chips.find('.chips-empty-state').remove(); // Check if this is a country entity (for flag and holiday preview) var blockType = $block.data('blockType') || ''; var searchEntity = $picker.attr('data-search-entity') || blockType; var isCountry = (searchEntity === 'countries'); var html = ''; } else if (data && data.image) { html += ''; } else { // Entity type icon from block config var blockIcon = ''; if (this.config && this.config.blocks) { var bt = $block.data('blockType') || ''; var blockDef = this.config.blocks[bt]; if (blockDef && blockDef.icon) { blockIcon = blockDef.icon; } } if (blockIcon) { html += ''; } } html += '' + this.escapeHtml(name) + ''; // Country: add holiday preview button if (isCountry) { html += ''; } html += ''; html += ''; $chips.append(html); }, removeSelection: function($picker, id) { var $chips = $picker.find('.entity-chips'); $picker.find('.entity-chip[data-id="' + id + '"]').remove(); this.updateChipsVisibility($chips); }, updateChipsVisibility: function($chips) { var self = this; var trans = this.config.trans || {}; var $picker = $chips.closest('.value-picker'); var $allChips = $chips.find('.entity-chip'); var totalCount = $allChips.length; // Ensure wrapper always exists this.ensureChipsWrapper($chips); if (totalCount === 0) { // Show empty state, hide toolbar var placeholder = $chips.data('placeholder') || trans.no_items_selected || 'No items selected'; if (!$chips.find('.chips-empty-state').length) { $chips.html('' + self.escapeHtml(placeholder) + ''); } var $wrapper = $chips.closest('.chips-wrapper'); if ($wrapper.length) { $wrapper.find('.chips-toolbar').removeClass('has-chips').hide(); $wrapper.find('.chips-load-more').hide(); } return; } // Has chips — show toolbar, remove empty state $chips.find('.chips-empty-state').remove(); var $wrapper = $chips.closest('.chips-wrapper'); var $toolbar = $wrapper.find('.chips-toolbar'); var $loadMore = $wrapper.find('.chips-load-more'); // Get current search filter var searchTerm = $toolbar.find('.chips-search-input').val() || ''; searchTerm = searchTerm.toLowerCase().trim(); // Filter and paginate chips var visibleCount = 0; var filteredCount = 0; var isExpanded = $chips.hasClass('chips-expanded'); var maxVisible = isExpanded ? 999999 : (this.maxVisibleChips || 12); $allChips.each(function() { var $chip = $(this); var chipName = ($chip.find('.chip-name').text() || '').toLowerCase(); var matchesFilter = !searchTerm || chipName.indexOf(searchTerm) !== -1; $chip.removeClass('chip-filtered-out chip-paginated-out'); if (!matchesFilter) { $chip.addClass('chip-filtered-out'); } else { filteredCount++; if (filteredCount > maxVisible) { $chip.addClass('chip-paginated-out'); } else { visibleCount++; } } }); // Update toolbar (always show when we have chips) $toolbar.addClass('has-chips').show(); this.updateChipsToolbar($toolbar, totalCount, filteredCount, searchTerm); // Update load more select dropdown var hiddenByPagination = filteredCount - visibleCount; if (hiddenByPagination > 0 && !isExpanded) { var loadText = trans.load || 'Load'; var remainingText = (trans.remaining || '{count} remaining').replace('{count}', hiddenByPagination); var loadMoreHtml = '' + loadText + '' + '' + '' + remainingText + ''; $loadMore.html(loadMoreHtml).show(); } else if (isExpanded && filteredCount > (this.maxVisibleChips || 12)) { var collapseText = trans.collapse || 'Collapse'; $loadMore.html( '' ).show(); } else { $loadMore.hide(); } }, ensureChipsWrapper: function($chips) { // Always create wrapper if missing var $wrapper = $chips.closest('.chips-wrapper'); if (!$wrapper.length) { $chips.wrap('
'); $wrapper = $chips.closest('.chips-wrapper'); $wrapper.prepend(''); $wrapper.append(''); } // Skip toolbar POPULATION for single mode if (this.config.mode === 'single') { return; } var $block = $chips.closest('.es-block'); if ($block.data('mode') === 'single') { return; } // If toolbar already populated, nothing to do var $toolbar = $wrapper.find('.chips-toolbar'); if ($toolbar.children().length) { return; } // Populate toolbar content var trans = this.config.trans || {}; var toolbarHtml = '' + '' + '' + ''; $toolbar.html(toolbarHtml); // Bind toolbar events this.bindChipsToolbarEvents($wrapper); }, bindChipsToolbarEvents: function($wrapper) { var self = this; var $chips = $wrapper.find('.entity-chips'); var searchTimeout; // Search input $wrapper.on('input', '.chips-search-input', function() { clearTimeout(searchTimeout); searchTimeout = setTimeout(function() { // Collapse when searching to show filtered results from start $chips.removeClass('chips-expanded'); self.updateChipsVisibility($chips); }, 150); }); // Sort select $wrapper.on('change', '.chips-sort-select', function() { var sortBy = $(this).val(); self.sortChips($chips, sortBy); }); // Clear all button $wrapper.on('click', '.btn-chips-clear', function() { var searchTerm = $wrapper.find('.chips-search-input').val() || ''; var $chipsToRemove; if (searchTerm.trim()) { // Remove only filtered (visible) chips $chipsToRemove = $chips.find('.entity-chip:not(.chip-filtered-out)'); } else { // Remove all chips $chipsToRemove = $chips.find('.entity-chip'); } $chipsToRemove.each(function() { $(this).find('.chip-remove').trigger('click'); }); // Clear search $wrapper.find('.chips-search-input').val(''); self.updateChipsVisibility($chips); }); // Load more select dropdown $wrapper.on('change', '.load-more-select', function() { var loadCount = $(this).val(); if (loadCount === 'all') { $chips.addClass('chips-expanded'); self.maxVisibleChips = 999999; } else { self.maxVisibleChips = (self.maxVisibleChips || 12) + parseInt(loadCount, 10); } self.updateChipsVisibility($chips); }); // Collapse button $wrapper.on('click', '.btn-collapse-chips', function() { $chips.removeClass('chips-expanded'); self.maxVisibleChips = 12; self.updateChipsVisibility($chips); }); }, /** * Sort chips by specified criteria */ sortChips: function($chips, sortBy) { var $allChips = $chips.find('.entity-chip'); if ($allChips.length < 2) return; var sorted = $allChips.toArray().sort(function(a, b) { var $a = $(a); var $b = $(b); switch (sortBy) { case 'name_asc': var nameA = ($a.find('.chip-name').text() || '').toLowerCase(); var nameB = ($b.find('.chip-name').text() || '').toLowerCase(); return nameA.localeCompare(nameB); case 'name_desc': var nameA2 = ($a.find('.chip-name').text() || '').toLowerCase(); var nameB2 = ($b.find('.chip-name').text() || '').toLowerCase(); return nameB2.localeCompare(nameA2); case 'added': default: // Keep original DOM order (order added) return 0; } }); // Re-append in sorted order $.each(sorted, function(i, chip) { $chips.append(chip); }); this.updateChipsVisibility($chips); }, updateChipsToolbar: function($toolbar, totalCount, filteredCount, searchTerm) { var trans = this.config.trans || {}; var $count = $toolbar.find('.chips-count'); var $clearBtn = $toolbar.find('.btn-chips-clear'); var $clearText = $clearBtn.find('.clear-text'); // Update count display if (searchTerm) { $count.addClass('has-filter').html( '' + filteredCount + '' + '/' + '' + totalCount + '' ); $clearText.text((trans.clear || 'Clear') + ' ' + filteredCount); } else { $count.removeClass('has-filter').html(totalCount); $clearText.text(trans.clear_all || 'Clear all'); } // Show/hide clear button if (searchTerm && filteredCount === 0) { $clearBtn.hide(); } else if (totalCount > 0) { $clearBtn.show(); } else { $clearBtn.hide(); } }, // ========================================================================= // Loading/Initialization // ========================================================================= loadExistingSelections: function() { var self = this; // Collect all entity IDs to load, grouped by entity type var entitiesToLoad = {}; // { entity_type: { ids: [], pickers: [] } } this.$wrapper.find('.selection-group').each(function() { var $group = $(this); var $block = $group.closest('.es-block'); var blockType = $block.data('blockType'); // Load include values var $includePicker = $group.find('.include-picker'); self.collectPickerEntities($includePicker, blockType, entitiesToLoad); // Enhance the include method select if not already enhanced self.enhanceMethodSelect($group.find('.include-method-select')); // Load exclude values from each exclude row $group.find('.exclude-row').each(function() { var $excludeRow = $(this); self.collectPickerEntities($excludeRow.find('.exclude-picker'), blockType, entitiesToLoad); // Enhance the exclude method select if not already enhanced self.enhanceMethodSelect($excludeRow.find('.exclude-method-select')); }); // Lock method selector if excludes exist var hasExcludes = $group.find('.group-excludes.has-excludes').length > 0; if (hasExcludes) { self.updateMethodSelectorLock($group, true); } }); // Build bulk request: { entityType: [uniqueIds], ... } var bulkRequest = {}; var hasEntities = false; Object.keys(entitiesToLoad).forEach(function(entityType) { var data = entitiesToLoad[entityType]; if (data.ids.length === 0) return; // Deduplicate IDs var uniqueIds = data.ids.filter(function(id, index, arr) { return arr.indexOf(id) === index; }); bulkRequest[entityType] = uniqueIds; hasEntities = true; }); // Skip AJAX if no entities to load if (!hasEntities) { return; } // Single bulk AJAX call for all entity types $.ajax({ url: self.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getTargetEntitiesByIdsBulk', trait: 'EntitySelector', entities: JSON.stringify(bulkRequest) }, success: function(response) { if (!response.success || !response.entities) { return; } try { // Process each entity type's results Object.keys(entitiesToLoad).forEach(function(entityType) { var data = entitiesToLoad[entityType]; var entities = response.entities[entityType] || []; // Build a map of id -> entity for quick lookup var entityMap = {}; entities.forEach(function(entity) { entityMap[entity.id] = entity; }); // Update each picker that requested this entity type data.pickers.forEach(function(pickerData) { var $picker = pickerData.$picker; var $chips = $picker.find('.entity-chips'); var $dataInput = $picker.find('.include-values-data, .exclude-values-data'); var validIds = []; // Check if this is a country entity var isCountry = (entityType === 'countries'); // Replace loading chips with real data pickerData.ids.forEach(function(id) { var $loadingChip = $chips.find('.entity-chip-loading[data-id="' + id + '"]'); if (entityMap[id]) { var entity = entityMap[id]; validIds.push(entity.id); // Create real chip var html = ''; } else if (entity.image) { html += ''; } else { var bt = $block.data('blockType') || ''; var blockDef = self.config.blocks && self.config.blocks[bt]; if (blockDef && blockDef.icon) { html += ''; } } html += '' + self.escapeHtml(entity.name) + ''; // Country: add holiday preview button if (isCountry) { html += ''; } html += ''; html += ''; $loadingChip.replaceWith(html); } else { // Entity not found, remove loading chip $loadingChip.remove(); } }); // Update chips visibility self.updateChipsVisibility($chips); // If some entities were not found, update the hidden input if (validIds.length !== pickerData.ids.length) { $dataInput.val(JSON.stringify(validIds)); self.serializeAllBlocks(); } self.updateBlockStatus($picker.closest('.es-block')); }); }); // Update condition counts after chips are loaded (for holiday counts, etc.) self.updateAllConditionCounts(); } catch (e) { console.error('[EntitySelector] Error processing AJAX response:', e); } }, error: function(xhr, status, error) { console.error('[EntitySelector] AJAX request failed:', status, error, xhr.responseText); } }); }, /** * Collect entity IDs from a picker for bulk loading * Also shows loading placeholders for entity_search types */ collectPickerEntities: function($picker, blockType, entitiesToLoad) { if (!$picker.length) { return; } var self = this; var $dataInput = $picker.find('.include-values-data, .exclude-values-data'); if (!$dataInput.length) { return; } var valueType = $picker.attr('data-value-type'); var rawValue = $dataInput.val() || '[]'; var values = []; try { values = JSON.parse(rawValue); } catch (e) { return; } // Handle non-entity types synchronously if (valueType === 'multi_numeric_range') { if (!Array.isArray(values) || values.length === 0) return; var $chipsContainer = $picker.find('.multi-range-chips'); values.forEach(function(range) { if (!range || (range.min === null && range.max === null)) return; var chipText = ''; if (range.min !== null && range.max !== null) { chipText = range.min + ' - ' + range.max; } else if (range.min !== null) { chipText = '≥ ' + range.min; } else { chipText = '≤ ' + range.max; } var $chip = $('', { class: 'range-chip', 'data-min': range.min !== null ? range.min : '', 'data-max': range.max !== null ? range.max : '' }); $chip.append($('', { class: 'range-chip-text', text: chipText })); $chip.append($(''; html += '' + this.escapeHtml(pattern) + ''; html += ''; html += ''; $chipsContainer.append(html); }, /** * Get all pattern tags from a wrapper * Returns array of objects: { pattern: string, caseSensitive: boolean } */ getPatternTags: function($wrapper) { var patterns = []; // Exclude draft-tag which is the input field, not a saved pattern $wrapper.find('.pattern-tag:not(.draft-tag)').each(function() { var pattern = $(this).data('pattern'); var caseSensitive = $(this).data('caseSensitive') === 1 || $(this).data('caseSensitive') === '1'; if (pattern) { patterns.push({ pattern: pattern, caseSensitive: caseSensitive }); } }); return patterns; }, /** * Update the match count displayed in the draft tag while typing * Shows live preview with current case sensitivity setting */ updateDraftTagCount: function($draftTag, pattern, caseSensitive) { var self = this; var $matchCount = $draftTag.find('.pattern-match-count'); var $countValue = $matchCount.find('.count-value'); // Get entity type from block var $block = $draftTag.closest('.es-block'); var entityType = $block.data('blockType') || 'products'; // Show loading state — keep existing count visible if ($matchCount.hasClass('no-matches')) { $matchCount.addClass('loading-count'); } $matchCount.show(); // Store pattern for click handler $matchCount.data('pattern', pattern); $matchCount.data('caseSensitive', caseSensitive); $matchCount.data('entityType', entityType); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countPatternMatches', trait: 'EntitySelector', pattern: pattern, field: 'name', entity_type: entityType, case_sensitive: caseSensitive ? 1 : 0 }, success: function(response) { if (response.success) { var count = parseInt(response.count, 10) || 0; $countValue.text(count); $matchCount.show(); // Add visual feedback based on count $matchCount.removeClass('count-zero count-found'); $matchCount.addClass(count === 0 ? 'count-zero' : 'count-found'); // Store count for preview $matchCount.data('count', count); // Update group total count to reflect draft pattern in calculation var $group = $draftTag.closest('.selection-group'); if ($group.length) { self.updateGroupTotalCount($group); } } else { $countValue.text('?'); $matchCount.show(); } }, error: function() { $countValue.text('?'); $matchCount.show(); } }); }, /** * Update condition count with a pending pattern (typed but not yet added as tag) * This shows a live preview of what the count would be if the user pressed Enter */ updateConditionCountWithPendingPattern: function($row, pendingPattern) { var self = this; var trans = this.config.trans || {}; // Find the count element - in method-selector-wrapper for include, in exclude-header-row for exclude var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row > .condition-match-count').first(); if (!$countEl.length) return; var isExclude = $row.hasClass('exclude-row'); var $methodSelect = isExclude ? $row.find('.exclude-method-select') : $row.find('.include-method-select'); var method = $methodSelect.val(); if (!method) { self._setBadgeCount($countEl, 0); return; } var $picker = isExclude ? $row.find('.exclude-picker') : $row.find('.include-picker'); var valueType = $picker.data('valueType') || 'none'; // Only process for pattern value types if (valueType !== 'pattern') { return; } // Get existing pattern tags var values = this.getPatternTags($picker); // Add the pending pattern as a temporary tag (case-insensitive by default) if (pendingPattern) { values.push({ pattern: pendingPattern, caseSensitive: false }); } if (values.length === 0) { self._setBadgeCount($countEl, 0); return; } var $block = $row.closest('.es-block'); var blockType = $block.data('blockType') || 'products'; // Show loading state — keep existing count visible if ($countEl.hasClass('no-matches')) { $countEl.addClass('loading-count'); } $countEl.show(); // Store condition data on badge for popover $countEl.data('conditionData', { method: method, values: values, blockType: blockType, isExclude: isExclude }); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countConditionMatches', trait: 'EntitySelector', method: method, values: JSON.stringify(values), block_type: blockType }, success: function(response) { if (response && response.success) { var count = response.count || 0; self._setBadgeCount($countEl, count); } else { self._setBadgeCount($countEl, 0); } }, error: function() { self._setBadgeCount($countEl, 0); } }); }, /** * Fetch pattern match count via AJAX */ fetchPatternMatchCount: function($picker, pattern, $countEl) { var self = this; // Determine field type from method select // Check if we're in an exclude row first, then fall back to include var $excludeRow = $picker.closest('.exclude-row'); var $methodSelect; if ($excludeRow.length) { $methodSelect = $excludeRow.find('.exclude-method-select'); } else { var $group = $picker.closest('.selection-group'); $methodSelect = $group.find('.include-method-select'); } var method = $methodSelect.val() || ''; var field = method.indexOf('reference') !== -1 ? 'reference' : 'name'; // Get entity type from block var $block = $picker.closest('.es-block'); var entityType = $block.data('blockType') || 'products'; // Show loading state $countEl.html(''); $countEl.removeClass('clickable no-matches').addClass('loading-count').show(); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countPatternMatches', trait: 'EntitySelector', pattern: pattern, field: field, entity_type: entityType, case_sensitive: 0 }, success: function(response) { if (response && response.success) { var count = response.count || 0; self._setBadgeCount($countEl, count); } else { self._setBadgeCount($countEl, 0); } }, error: function() { self._setBadgeCount($countEl, 0); } }); }, // ========================================================================= // Picker Value Extraction // ========================================================================= /** * Get values from a picker based on its type */ getPickerValues: function($picker, valueType) { switch (valueType) { case 'entity_search': var ids = []; $picker.find('.entity-chip').each(function() { var id = $(this).data('id'); if (id) ids.push(id); }); return ids; case 'pattern': var patternValues = this.getPatternTags($picker); // Also include draft pattern if it has content (not yet added as tag) var $draftPatternInput = $picker.find('.draft-tag .pattern-input'); var draftPatternVal = $.trim($draftPatternInput.val()); if (draftPatternVal) { var draftCaseSens = $draftPatternInput.closest('.draft-tag').attr('data-case-sensitive') === '1'; patternValues.push({ pattern: draftPatternVal, caseSensitive: draftCaseSens }); } return patternValues; case 'numeric_range': var min = $picker.find('.range-min-input').val(); var max = $picker.find('.range-max-input').val(); return { min: min || null, max: max || null }; case 'date_range': var from = $picker.find('.date-from-input').val(); var to = $picker.find('.date-to-input').val(); return { from: from || null, to: to || null }; case 'select': return [$picker.find('.select-value-input').val()]; case 'boolean': return [true]; default: return []; } }, // ========================================================================= // Count/Status Updates // ========================================================================= /** * Fetch and update condition match count for a row (include or exclude) */ updateConditionCount: function($row) { var self = this; var trans = this.config.trans || {}; // Find the count element - in method-selector-wrapper for include, in exclude-header-row for exclude var $countEl = $row.find('.method-selector-wrapper > .condition-match-count, > .exclude-header-row > .condition-match-count').first(); if (!$countEl.length) return; // Determine if this is an include or exclude row var isExclude = $row.hasClass('exclude-row'); var $methodSelect = isExclude ? $row.find('.exclude-method-select') : $row.find('.include-method-select'); var method = $methodSelect.val(); if (!method) { self._setBadgeCount($countEl, 0); return; } // Get the picker and extract values var $picker = isExclude ? $row.find('.exclude-picker') : $row.find('.include-picker'); var valueType = $picker.data('valueType') || 'none'; var values = this.getPickerValues($picker, valueType); // Don't count if no values (except for boolean/all methods) var hasNoValues = !values || (Array.isArray(values) && values.length === 0) || (typeof values === 'object' && !Array.isArray(values) && ( // For combination_attributes, check if attributes object is empty (valueType === 'combination_attributes' && values.attributes !== undefined && Object.keys(values.attributes).length === 0) || // For other objects, check if completely empty (valueType !== 'combination_attributes' && Object.keys(values).length === 0) )); if (valueType !== 'none' && valueType !== 'boolean' && hasNoValues) { self._setBadgeCount($countEl, 0); return; } // Get block type var $block = $row.closest('.es-block'); var blockType = $block.data('blockType') || 'products'; // Show loading state — keep existing count visible if ($countEl.hasClass('no-matches')) { $countEl.addClass('loading-count'); } $countEl.show(); // Store condition data on badge for popover $countEl.data('conditionData', { method: method, values: values, blockType: blockType, isExclude: isExclude }); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countConditionMatches', trait: 'EntitySelector', method: method, values: JSON.stringify(values), block_type: blockType }, success: function(response) { if (response && response.success) { var count = response.count || 0; self._setBadgeCount($countEl, count); } else { self._setBadgeCount($countEl, 0); } }, error: function() { self._setBadgeCount($countEl, 0); } }); }, /** * Update all condition counts in a group */ updateGroupCounts: function($group) { var self = this; // Update include count var $include = $group.find('.group-include'); if ($include.length) { this.updateConditionCount($include); } // Update each exclude row count $group.find('.exclude-row').each(function() { self.updateConditionCount($(this)); }); // Update group total count (include - excludes) this.updateGroupTotalCount($group); }, /** * Update the group total count badge (include - excludes) * Also updates the limit input placeholder */ updateGroupTotalCount: function($group) { var self = this; var $block = $group.closest('.es-block'); var blockType = $block.data('blockType') || 'products'; var $badge = $group.find('.group-header .group-count-badge'); var $limitInput = $group.find('.group-modifier-limit'); // Build group data for AJAX var groupData = this.serializeGroup($group, blockType); // Check if include has valid data if (!groupData.include || !groupData.include.method) { $badge.hide(); $limitInput.attr('placeholder', '–'); return; } // Show loading $badge.html('').show(); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'countGroupItems', trait: 'EntitySelector', group_data: JSON.stringify(groupData), block_type: blockType }, success: function(response) { if (response && response.success) { var finalCount = response.final_count || 0; var excludeCount = response.exclude_count || 0; // Update badge with eye icon and count (custom HTML for exclude info) var badgeHtml = ' ' + finalCount; if (excludeCount > 0) { badgeHtml += ' (-' + excludeCount + ')'; } if ($badge.html() !== badgeHtml) { $badge.html(badgeHtml); } self._setBadgeCount($badge, finalCount); // Store group data on badge for preview popover $badge.data('groupData', groupData); $badge.data('blockType', blockType); $badge.data('finalCount', finalCount); // Update limit placeholder with the count $limitInput.attr('placeholder', finalCount); // Also update the group-preview-badge count (apply limit if set) var $previewBadge = $group.find('.group-preview-badge .preview-count'); if ($previewBadge.length) { var limit = parseInt($limitInput.val(), 10); var displayCount = (limit > 0 && limit < finalCount) ? limit : finalCount; $previewBadge.text(displayCount); } } else { self._setBadgeCount($badge, 0); $limitInput.attr('placeholder', '–'); } }, error: function() { self._setBadgeCount($badge, 0); $limitInput.attr('placeholder', '–'); } }); }, /** * Update all condition counts for all visible groups */ updateAllConditionCounts: function() { var self = this; this.$wrapper.find('.es-block.active .selection-group').each(function() { self.updateGroupCounts($(this)); }); }, /** * Fetch category names by IDs and add chips to the picker * Used when adding selections from the tree modal * @param {jQuery} $picker - Picker element * @param {Array} ids - Category IDs to add * @param {string} entityType - 'categories' or 'cms_categories' * @param {Function} callback - Called when done */ fetchCategoryNamesAndAddChips: function($picker, ids, entityType, callback) { var self = this; if (!ids || ids.length === 0) { if (typeof callback === 'function') { callback(); } return; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getTargetEntitiesByIds', trait: 'EntitySelector', entity_type: entityType, ids: JSON.stringify(ids) }, success: function(response) { if (response.success && response.entities) { response.entities.forEach(function(entity) { self.addSelectionNoUpdate($picker, entity.id, entity.name, entity); }); } if (typeof callback === 'function') { callback(); } }, error: function() { if (typeof callback === 'function') { callback(); } } }); } }; })(jQuery); /** * Entity Selector - Preview Module * Reusable preview popover component with filter and load more * @partial _preview.js */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.preview = { // ========================================================================= // HEADER & TOGGLE UPDATES // ========================================================================= updateHeaderTotalCount: function() { var self = this; var total = 0; this.$wrapper.find('.es-block-tab .tab-badge').each(function() { var $badge = $(this); if (!$badge.hasClass('loading')) { var count = parseInt($badge.text(), 10); if (!isNaN(count)) { total += count; } } }); var $totalBadge = this.$wrapper.find('.trait-total-count'); if (total > 0) { // Update count while preserving icon structure var $countValue = $totalBadge.find('.count-value'); if ($countValue.length) { $countValue.text(total); } else { // Fallback: set HTML with icon $totalBadge.html(' ' + total + ''); } $totalBadge.show(); } else { $totalBadge.hide(); } this.updateShowAllToggle(); }, updateShowAllToggle: function() { var $toggle = this.$wrapper.find('.trait-show-all-toggle'); if (!$toggle.length) return; var $checkbox = $toggle.find('.show-all-checkbox'); var hasData = this.$wrapper.find('.es-block-tab.has-data').length > 0; $checkbox.prop('checked', !hasData); }, // ========================================================================= // REUSABLE PREVIEW POPOVER COMPONENT // ========================================================================= /** * Create and show a reusable preview popover * @param {Object} options Configuration options * @param {jQuery} options.$badge - The badge element to position against * @param {Array} options.items - Array of items to display * @param {number} options.totalCount - Total count of items * @param {boolean} options.hasMore - Whether more items are available * @param {string} options.entityLabel - Label for items (e.g., "products") * @param {string} options.previewType - Type identifier (e.g., "condition", "filter-group") * @param {Function} options.onLoadMore - Callback when load more is clicked * @param {Function} options.onFilter - Callback for AJAX filtering (receives query string) * @param {Object} options.context - Context data for load more */ createPreviewPopover: function(options) { var self = this; var trans = this.config.trans || {}; var $badge = options.$badge; var items = options.items || []; var totalCount = options.totalCount || 0; var hasMore = options.hasMore || false; var entityLabel = options.entityLabel || 'products'; var previewType = options.previewType || 'default'; // Build popover HTML var html = '
'; // Header with count and close button html += '
'; html += '' + totalCount + ' ' + entityLabel + ''; html += ''; html += '
'; // Filter input html += '
'; html += ''; html += '
'; // Items list if (items.length > 0) { html += '
'; html += this.renderPreviewItems(items); html += '
'; // Load more footer with select dropdown if (hasMore) { var remaining = totalCount - items.length; html += ''; } } else { html += '
' + (trans.no_preview || 'No items to preview') + '
'; } html += '
'; // Create and append popover var $popover = $(html); $('body').append($popover); // Store references this.$previewPopover = $popover; this.$previewList = $popover.find('.preview-list'); this.previewLoadedCount = items.length; this.previewTotalCount = totalCount; this.previewContext = options.context || {}; this.previewOnLoadMore = options.onLoadMore || null; this.previewOnFilter = options.onFilter || null; this.previewCurrentFilter = ''; this.previewEntityLabel = entityLabel; // Event handlers $popover.find('.preview-close').on('click', function() { self.hidePreviewPopover(); }); // Filter input with AJAX support var $filterInput = $popover.find('.preview-filter-input'); if (options.onFilter) { // Use AJAX filtering with debounce var debouncedFilter = this.debounce(function(query) { self.previewCurrentFilter = query; self.showFilterLoading(true); options.onFilter.call(self, query); }, 300); $filterInput.on('input', function() { var query = $(this).val().trim(); if (query === self.previewCurrentFilter) return; debouncedFilter(query); }); } else { // Fallback to client-side filtering $filterInput.on('input', function() { var query = $(this).val().toLowerCase().trim(); self.filterPreviewItems(query); }); } if (options.onLoadMore) { $popover.find('.btn-load-more').on('click', function() { var $btn = $(this); var $controls = $btn.closest('.load-more-controls'); var $select = $controls.find('.load-more-select'); if ($btn.hasClass('loading')) return; $btn.addClass('loading'); $btn.find('i').removeClass('icon-plus').addClass('icon-spinner icon-spin'); $select.prop('disabled', true); // Get selected load count var loadCount = parseInt($select.val(), 10) || 20; self.previewLoadCount = loadCount; options.onLoadMore.call(self, $btn); }); } // Position popover below badge var badgeOffset = $badge.offset(); var badgeHeight = $badge.outerHeight(); var badgeWidth = $badge.outerWidth(); var popoverWidth = $popover.outerWidth(); var leftPos = badgeOffset.left + (badgeWidth / 2) - (popoverWidth / 2); var minLeft = 10; var maxLeft = $(window).width() - popoverWidth - 10; leftPos = Math.max(minLeft, Math.min(leftPos, maxLeft)); $popover.css({ position: 'absolute', top: badgeOffset.top + badgeHeight + 8, left: leftPos, zIndex: 10000 }); // Show with transition $popover.addClass('show'); return $popover; }, /** * Update popover after loading more items */ updatePreviewPopover: function(items, hasMore) { var trans = this.config.trans || {}; // Update list this.$previewList.html(this.renderPreviewItems(items)); this.previewLoadedCount = items.length; // Update or remove load more controls var $footer = this.$previewPopover.find('.preview-footer'); if (hasMore) { var remaining = this.previewTotalCount - items.length; var $controls = $footer.find('.load-more-controls'); var $btn = $controls.find('.btn-load-more'); var $select = $controls.find('.load-more-select'); // Reset button state $btn.removeClass('loading'); $btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus'); $select.prop('disabled', false); // Update remaining count $controls.find('.remaining-count').text(remaining); // Update select options $select.empty(); if (remaining >= 10) $select.append(''); if (remaining >= 20) $select.append(''); if (remaining >= 50) $select.append(''); if (remaining >= 100) $select.append(''); $select.append(''); } else { $footer.remove(); } // Re-apply filter if active var filterQuery = this.$previewPopover.find('.preview-filter-input').val(); if (filterQuery) { this.filterPreviewItems(filterQuery.toLowerCase().trim()); } }, /** * Render preview items HTML with consistent format */ renderPreviewItems: function(items) { var self = this; var html = ''; for (var i = 0; i < items.length; i++) { var item = items[i]; var itemClass = 'preview-item'; if (item.isCombination) itemClass += ' is-combination'; // Build data attributes for filtering var dataAttrs = ''; dataAttrs += ' data-name="' + this.escapeAttr((item.name || '').toLowerCase()) + '"'; dataAttrs += ' data-ref="' + this.escapeAttr((item.reference || '').toLowerCase()) + '"'; if (item.attributes) { dataAttrs += ' data-attrs="' + this.escapeAttr((item.attributes || '').toLowerCase()) + '"'; } html += '
'; // Image or placeholder if (item.image) { html += ''; } else { html += '
inventory_2
'; } // Info section html += '
'; html += '
' + this.escapeHtml(item.name || 'Unnamed') + '
'; // Meta line (reference, manufacturer, category, attributes) var meta = []; if (item.reference) { meta.push('Ref: ' + item.reference); } if (item.manufacturer) { meta.push(item.manufacturer); } if (item.category) { meta.push(item.category); } if (item.attributes) { meta.push(item.attributes); } if (meta.length > 0) { html += '
' + this.escapeHtml(meta.join(' • ')) + '
'; } html += '
'; // .preview-item-info // Price column (always show if available) if (typeof item.price !== 'undefined' && item.price !== null) { html += '
' + this.formatPrice(item.price) + '
'; } else if (item.price_formatted) { html += '
' + this.escapeHtml(item.price_formatted) + '
'; } // Status badge if inactive if (typeof item.active !== 'undefined' && !item.active) { html += 'Inactive'; } html += '
'; // .preview-item } return html; }, /** * Filter preview items by query (client-side fallback) */ filterPreviewItems: function(query) { if (!this.$previewList) return; var $items = this.$previewList.find('.preview-item'); if (!query) { $items.show(); return; } $items.each(function() { var $item = $(this); var name = $item.data('name') || ''; var ref = $item.data('ref') || ''; var attrs = $item.data('attrs') || ''; var matches = name.indexOf(query) !== -1 || ref.indexOf(query) !== -1 || attrs.indexOf(query) !== -1; $item.toggle(matches); }); }, /** * Show/hide loading indicator during AJAX filter */ showFilterLoading: function(show) { if (!this.$previewPopover) return; var $list = this.$previewList; if (!$list) return; if (show) { // Lock the popover width before filtering to prevent resize if (!this.previewLockedWidth) { this.previewLockedWidth = this.$previewPopover.outerWidth(); this.$previewPopover.css('width', this.previewLockedWidth + 'px'); } $list.addClass('filtering'); // Add overlay if not exists if (!$list.find('.filter-loading-overlay').length) { $list.append('
'); } } else { $list.removeClass('filtering'); $list.find('.filter-loading-overlay').remove(); } }, /** * Update preview popover with filtered AJAX results * @param {Object} response - AJAX response with items, count, hasMore */ updatePreviewPopoverFiltered: function(response) { var trans = this.config.trans || {}; this.showFilterLoading(false); if (!response.success) { return; } var items = response.items || []; var filteredCount = response.count || 0; var hasMore = response.hasMore || false; // Update header count to show filtered count var $header = this.$previewPopover.find('.preview-header'); var entityLabel = this.previewEntityLabel || 'items'; $header.find('.preview-count').text(filteredCount + ' ' + entityLabel); // Update list if (items.length > 0) { this.$previewList.html(this.renderPreviewItems(items)); this.previewLoadedCount = items.length; this.previewTotalCount = filteredCount; } else { var noResultsText = trans.no_filter_results || 'No matching items found'; this.$previewList.html('
' + noResultsText + '
'); this.previewLoadedCount = 0; this.previewTotalCount = 0; } // Update or create footer for load more var $footer = this.$previewPopover.find('.preview-footer'); if (hasMore && items.length > 0) { var remaining = filteredCount - items.length; if ($footer.length) { var $controls = $footer.find('.load-more-controls'); var $btn = $controls.find('.btn-load-more'); var $select = $controls.find('.load-more-select'); $btn.removeClass('loading'); $btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus'); $select.prop('disabled', false); $controls.find('.remaining-count').text(remaining); $select.empty(); if (remaining >= 10) $select.append(''); if (remaining >= 20) $select.append(''); if (remaining >= 50) $select.append(''); if (remaining >= 100) $select.append(''); $select.append(''); } else { // Create footer var footerHtml = ''; var $newFooter = $(footerHtml); this.$previewList.after($newFooter); // Rebind load more click var self = this; if (this.previewOnLoadMore) { $newFooter.find('.btn-load-more').on('click', function() { var $btn = $(this); var $controls = $btn.closest('.load-more-controls'); var $select = $controls.find('.load-more-select'); if ($btn.hasClass('loading')) return; $btn.addClass('loading'); $btn.find('i').removeClass('icon-plus').addClass('icon-spinner icon-spin'); $select.prop('disabled', true); var loadCount = parseInt($select.val(), 10) || 20; self.previewLoadCount = loadCount; self.previewOnLoadMore.call(self, $btn); }); } } } else { $footer.remove(); } }, /** * Format price for display */ formatPrice: function(price) { if (typeof price !== 'number') { price = parseFloat(price) || 0; } // Use currency format from config if available var currencySign = (this.config && this.config.currency_sign) || '€'; var currencyFormat = (this.config && this.config.currency_format) || 'right'; var formatted = price.toFixed(2); if (currencyFormat === 'left') { return currencySign + ' ' + formatted; } else { return formatted + ' ' + currencySign; } }, /** * Hide and clean up preview popover */ hidePreviewPopover: function() { if (this.$activeBadge) { this.$activeBadge.removeClass('popover-open loading'); this.$activeBadge = null; } if (this.$previewPopover) { this.$previewPopover.remove(); this.$previewPopover = null; } this.$previewList = null; this.previewContext = null; this.previewOnLoadMore = null; this.previewOnFilter = null; this.previewCurrentFilter = ''; this.previewEntityLabel = null; this.previewLockedWidth = null; }, // ========================================================================= // TAB PREVIEW (Block tab badge click) // ========================================================================= showPreviewPopover: function($tab) { var self = this; var previewData = $tab.data('previewData'); if (!previewData) { return; } this.hidePreviewPopover(); var $badge = $tab.find('.tab-badge'); $badge.addClass('popover-open'); this.$activeBadge = $badge; var items = previewData.items || previewData.products || []; var blockType = $tab.data('blockType'); var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {}; var entityLabelPlural = blockConfig.entity_label_plural || 'items'; this.previewBlockType = blockType; // If items not loaded yet, fetch them first if (items.length === 0 && previewData.count > 0) { $badge.addClass('loading'); this.fetchTabPreviewItems($tab, function(fetchedItems, hasMore) { $badge.removeClass('loading'); self.createPreviewPopover({ $badge: $badge, items: fetchedItems, totalCount: previewData.count, hasMore: hasMore, entityLabel: entityLabelPlural, previewType: 'tab', context: { $tab: $tab, blockType: blockType }, onLoadMore: function($btn) { self.loadMoreTabPreviewItems($tab, $btn); }, onFilter: function(query) { self.filterTabPreviewItems($tab, query); } }); }); return; } this.createPreviewPopover({ $badge: $badge, items: items, totalCount: previewData.count, hasMore: previewData.hasMore, entityLabel: entityLabelPlural, previewType: 'tab', context: { $tab: $tab, blockType: blockType }, onLoadMore: function($btn) { self.loadMoreTabPreviewItems($tab, $btn); }, onFilter: function(query) { self.filterTabPreviewItems($tab, query); } }); }, /** * Fetch preview items for a tab via AJAX */ fetchTabPreviewItems: function($tab, callback) { var self = this; var blockType = $tab.data('blockType'); var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]'); var savedData = {}; try { savedData = JSON.parse($hiddenInput.val() || '{}'); } catch (e) { callback([], false); return; } var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : []; if (groups.length === 0) { callback([], false); return; } var data = {}; data[blockType] = { groups: groups }; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewEntitySelector', trait: 'EntitySelector', conditions: JSON.stringify(data), block_type: blockType, limit: 20, offset: 0 }, success: function(response) { var items = response.items || response.products || []; var hasMore = response.hasMore || (response.count > items.length); // Update stored preview data with items $tab.data('previewData', response); callback(items, hasMore); }, error: function() { callback([], false); } }); }, /** * AJAX filter handler for tab preview */ filterTabPreviewItems: function($tab, query) { var self = this; var blockType = this.previewBlockType; var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]'); var savedData = {}; try { savedData = JSON.parse($hiddenInput.val() || '{}'); } catch (e) { self.showFilterLoading(false); return; } var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : []; if (groups.length === 0) { self.showFilterLoading(false); return; } var data = {}; data[blockType] = { groups: groups }; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewTargetConditions', trait: 'TargetConditions', conditions: JSON.stringify(data), block_type: blockType, filter: query, limit: 20, offset: 0 }, success: function(response) { self.updatePreviewPopoverFiltered(response); }, error: function() { self.showFilterLoading(false); } }); }, loadMoreTabPreviewItems: function($tab, $btn) { var self = this; var blockType = this.previewBlockType; var $hiddenInput = this.$wrapper.find('input[name="' + this.config.name + '"]'); var savedData = {}; try { savedData = JSON.parse($hiddenInput.val() || '{}'); } catch (e) { return; } var groups = (savedData[blockType] && savedData[blockType].groups) ? savedData[blockType].groups : []; if (groups.length === 0) return; var data = {}; data[blockType] = { groups: groups }; var loadCount = this.previewLoadCount || 20; // Include current filter in load more request var ajaxData = { ajax: 1, action: 'previewTargetConditions', trait: 'TargetConditions', conditions: JSON.stringify(data), block_type: blockType, limit: self.previewLoadedCount + loadCount, offset: 0 }; if (self.previewCurrentFilter) { ajaxData.filter = self.previewCurrentFilter; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: ajaxData, success: function(response) { var items = response.items || response.products || []; if (response.success && items.length > 0) { $tab.data('previewData', response); self.previewTotalCount = response.count; self.updatePreviewPopover(items, response.hasMore); } }, error: function() { var $controls = $btn.closest('.load-more-controls'); var $select = $controls.find('.load-more-select'); $btn.removeClass('loading'); $btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus'); $select.prop('disabled', false); } }); }, // ========================================================================= // CONDITION PREVIEW (Single condition badge click) // ========================================================================= showConditionPreviewPopover: function($badge) { var self = this; var conditionData = $badge.data('conditionData'); if (!conditionData) { return; } // Check if this is a country holidays badge if (conditionData.isCountryHolidays && conditionData.countryIds) { this.showCountriesHolidayPreview($badge, conditionData.countryIds); return; } this.hidePreviewPopover(); $badge.addClass('popover-open loading'); this.$activeBadge = $badge; var blockType = conditionData.blockType || 'products'; var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {}; var entityLabelPlural = blockConfig.entity_label_plural || 'products'; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewConditionItems', trait: 'EntitySelector', method: conditionData.method, values: JSON.stringify(conditionData.values), block_type: blockType, limit: 10 }, success: function(response) { $badge.removeClass('loading'); if (response.success) { self.createPreviewPopover({ $badge: $badge, items: response.items || [], totalCount: response.count, hasMore: response.hasMore, entityLabel: entityLabelPlural, previewType: 'condition', context: { conditionData: conditionData, blockType: blockType }, onLoadMore: function($btn) { self.loadMoreConditionItems($btn); }, onFilter: function(query) { self.filterConditionItems(query); } }); } else { $badge.removeClass('popover-open'); self.$activeBadge = null; } }, error: function() { $badge.removeClass('loading popover-open'); self.$activeBadge = null; } }); }, /** * AJAX filter handler for condition preview */ filterConditionItems: function(query) { var self = this; var ctx = this.previewContext; if (!ctx || !ctx.conditionData) { self.showFilterLoading(false); return; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewConditionItems', trait: 'EntitySelector', method: ctx.conditionData.method, values: JSON.stringify(ctx.conditionData.values), block_type: ctx.blockType, filter: query, limit: 20 }, success: function(response) { self.updatePreviewPopoverFiltered(response); }, error: function() { self.showFilterLoading(false); } }); }, loadMoreConditionItems: function($btn) { var self = this; var ctx = this.previewContext; if (!ctx || !ctx.conditionData) return; var loadCount = this.previewLoadCount || 20; // Include current filter in load more request var ajaxData = { ajax: 1, action: 'previewConditionItems', trait: 'EntitySelector', method: ctx.conditionData.method, values: JSON.stringify(ctx.conditionData.values), block_type: ctx.blockType, limit: self.previewLoadedCount + loadCount }; if (self.previewCurrentFilter) { ajaxData.filter = self.previewCurrentFilter; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: ajaxData, success: function(response) { if (response.success) { self.previewTotalCount = response.count; self.updatePreviewPopover(response.items || [], response.hasMore); } }, error: function() { var $controls = $btn.closest('.load-more-controls'); var $select = $controls.find('.load-more-select'); $btn.removeClass('loading'); $btn.find('i').removeClass('icon-spinner icon-spin').addClass('icon-plus'); $select.prop('disabled', false); } }); }, // ========================================================================= // GROUP PREVIEW (Selection group badge click) // ========================================================================= showGroupPreviewPopover: function($badge, $group, blockType) { var self = this; if (!$group) { $group = $badge.closest('.selection-group'); } if (!blockType) { var $block = $badge.closest('.es-block'); blockType = $block.data('blockType') || 'products'; } var groupData = $badge.data('groupData'); if (!groupData) { groupData = this.serializeGroup($group, blockType); } if (!groupData || !groupData.include) { return; } this.hidePreviewPopover(); $badge.addClass('popover-open loading'); this.$activeBadge = $badge; var blockConfig = this.config.blocks && this.config.blocks[blockType] ? this.config.blocks[blockType] : {}; var entityLabelPlural = blockConfig.entity_label_plural || 'products'; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewGroupItems', trait: 'EntitySelector', group_data: JSON.stringify(groupData), block_type: blockType, limit: 10 }, success: function(response) { $badge.removeClass('loading'); if (response.success) { self.createPreviewPopover({ $badge: $badge, items: response.items || [], totalCount: response.count, hasMore: response.hasMore, entityLabel: entityLabelPlural, previewType: 'group', context: { groupData: groupData, blockType: blockType, $group: $group }, onLoadMore: function($btn) { self.loadMoreGroupItems($btn); }, onFilter: function(query) { self.filterGroupItems(query); } }); } else { $badge.removeClass('popover-open'); self.$activeBadge = null; } }, error: function() { $badge.removeClass('loading popover-open'); self.$activeBadge = null; } }); }, /** * AJAX filter handler for group preview */ filterGroupItems: function(query) { var self = this; var ctx = this.previewContext; if (!ctx || !ctx.groupData) { self.showFilterLoading(false); return; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewGroupItems', trait: 'EntitySelector', group_data: JSON.stringify(ctx.groupData), block_type: ctx.blockType, filter: query, limit: 20 }, success: function(response) { self.updatePreviewPopoverFiltered(response); }, error: function() { self.showFilterLoading(false); } }); }, loadMoreGroupItems: function($btn) { var self = this; var ctx = this.previewContext; if (!ctx || !ctx.groupData) return; var loadCount = this.previewLoadCount || 20; // Include current filter in load more request var ajaxData = { ajax: 1, action: 'previewGroupItems', trait: 'EntitySelector', group_data: JSON.stringify(ctx.groupData), block_type: ctx.blockType, limit: self.previewLoadedCount + loadCount }; if (self.previewCurrentFilter) { ajaxData.filter = self.previewCurrentFilter; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: ajaxData, success: function(response) { if (response.success) { self.previewTotalCount = response.count; self.updatePreviewPopover(response.items || [], response.hasMore); } }, error: function() { $btn.removeClass('loading'); $btn.find('.load-more-text').show(); $btn.find('.load-more-loading').hide(); } }); }, // ========================================================================= // FILTER GROUP PREVIEW (Attribute/Feature group toggle badge) // ========================================================================= showFilterGroupPreviewPopover: function($badge, groupId, groupType, groupName) { var self = this; this.hidePreviewPopover(); $badge.addClass('popover-open loading'); this.$activeBadge = $badge; var entityLabelPlural = 'products'; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewFilterGroupProducts', trait: 'EntitySelector', group_id: groupId, group_type: groupType, limit: 10 }, success: function(response) { $badge.removeClass('loading'); if (response.success) { self.createPreviewPopover({ $badge: $badge, items: response.items || [], totalCount: response.count || 0, hasMore: response.hasMore || false, entityLabel: entityLabelPlural, previewType: 'filter-group', context: { groupId: groupId, groupType: groupType, groupName: groupName }, onLoadMore: function($btn) { self.loadMoreFilterGroupItems($btn); }, onFilter: function(query) { self.filterFilterGroupItems(query); } }); } else { $badge.removeClass('popover-open'); self.$activeBadge = null; } }, error: function() { $badge.removeClass('loading popover-open'); self.$activeBadge = null; } }); }, /** * AJAX filter handler for filter group preview */ filterFilterGroupItems: function(query) { var self = this; var ctx = this.previewContext; if (!ctx || !ctx.groupId) { self.showFilterLoading(false); return; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewFilterGroupProducts', trait: 'EntitySelector', group_id: ctx.groupId, group_type: ctx.groupType, filter: query, limit: 20 }, success: function(response) { self.updatePreviewPopoverFiltered(response); }, error: function() { self.showFilterLoading(false); } }); }, loadMoreFilterGroupItems: function($btn) { var self = this; var ctx = this.previewContext; if (!ctx || !ctx.groupId) return; var loadCount = this.previewLoadCount || 20; // Include current filter in load more request var ajaxData = { ajax: 1, action: 'previewFilterGroupProducts', trait: 'EntitySelector', group_id: ctx.groupId, group_type: ctx.groupType, limit: self.previewLoadedCount + loadCount }; if (self.previewCurrentFilter) { ajaxData.filter = self.previewCurrentFilter; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: ajaxData, success: function(response) { if (response.success) { self.previewTotalCount = response.count; self.updatePreviewPopover(response.items || [], response.hasMore); } }, error: function() { $btn.removeClass('loading'); $btn.find('.load-more-text').show(); $btn.find('.load-more-loading').hide(); } }); }, // ========================================================================= // CATEGORY ITEMS PREVIEW (products/pages in a category) // ========================================================================= showCategoryItemsPreview: function($badge, categoryId, categoryName, entityType) { var self = this; this.hidePreviewPopover(); $badge.addClass('popover-open loading'); this.$activeBadge = $badge; var isProducts = (entityType === 'categories'); var entityLabelPlural = isProducts ? 'products' : 'pages'; var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages'; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: action, trait: 'EntitySelector', category_id: categoryId, limit: 10 }, success: function(response) { $badge.removeClass('loading'); if (response.success) { self.createPreviewPopover({ $badge: $badge, items: response.items || [], totalCount: response.count || 0, hasMore: response.hasMore || false, entityLabel: entityLabelPlural, previewType: 'category-items', context: { categoryId: categoryId, categoryName: categoryName, entityType: entityType }, onLoadMore: function($btn) { self.loadMoreCategoryItems($btn); }, onFilter: function(query) { self.filterCategoryItems(query); } }); } else { $badge.removeClass('popover-open'); self.$activeBadge = null; } }, error: function() { $badge.removeClass('loading popover-open'); self.$activeBadge = null; } }); }, loadMoreCategoryItems: function($btn) { var self = this; var ctx = this.previewContext; if (!ctx || !ctx.categoryId) return; var isProducts = (ctx.entityType === 'categories'); var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages'; $btn.prop('disabled', true).find('i').addClass('icon-spin'); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: action, trait: 'EntitySelector', category_id: ctx.categoryId, offset: this.previewOffset, limit: 10, query: this.previewFilterQuery || '' }, success: function(response) { $btn.prop('disabled', false).find('i').removeClass('icon-spin'); if (response.success && response.items) { self.appendPreviewItems(response.items); self.previewOffset += response.items.length; if (!response.hasMore) { $btn.hide(); } } }, error: function() { $btn.prop('disabled', false).find('i').removeClass('icon-spin'); } }); }, filterCategoryItems: function(query) { var self = this; var ctx = this.previewContext; if (!ctx || !ctx.categoryId) { self.showFilterLoading(false); return; } var isProducts = (ctx.entityType === 'categories'); var action = isProducts ? 'previewCategoryProducts' : 'previewCategoryPages'; $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: action, trait: 'EntitySelector', category_id: ctx.categoryId, query: query, limit: 10 }, success: function(response) { self.showFilterLoading(false); if (response.success) { self.replacePreviewItems(response.items || [], response.count || 0, response.hasMore || false); self.previewOffset = response.items ? response.items.length : 0; self.previewFilterQuery = query; } }, error: function() { self.showFilterLoading(false); } }); }, // ========================================================================= // PATTERN PREVIEW MODAL (for regex/pattern matching) // ========================================================================= showPatternPreviewModal: function(pattern, entityType, caseSensitive, count) { var self = this; var trans = this.config.trans || {}; var blockConfig = this.config.blocks && this.config.blocks[entityType] ? this.config.blocks[entityType] : {}; var entityLabelPlural = blockConfig.entity_label_plural || 'items'; var entityLabelSingular = blockConfig.entity_label || 'item'; var html = '
'; html += '
'; html += '
'; html += ''; html += ' ' + (trans.preview || 'Preview') + ': ' + this.escapeHtml(pattern) + ''; html += ''; html += '' + count + ' ' + (count === 1 ? entityLabelSingular : entityLabelPlural) + ''; html += ''; html += '
'; html += '
'; html += '
' + (trans.loading || 'Loading...') + '
'; html += '
'; html += '
'; html += '
'; var $modal = $(html); $('body').append($modal); $modal.find('.pattern-preview-close').on('click', function() { $modal.remove(); }); $modal.on('click', function(e) { if ($(e.target).hasClass('pattern-preview-modal-overlay')) { $modal.remove(); } }); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewPatternMatches', trait: 'TargetConditions', pattern: pattern, entity_type: entityType, case_sensitive: caseSensitive ? 1 : 0, limit: 50 }, success: function(response) { if (response.success && response.items) { var items = response.items; var listHtml = '
'; if (items.length === 0) { listHtml += '
' + (trans.no_matches || 'No matches found') + '
'; } else { for (var i = 0; i < items.length; i++) { var item = items[i]; listHtml += '
'; if (item.image) { listHtml += ''; } listHtml += '' + self.escapeHtml(item.name) + ''; if (item.id) { listHtml += '#' + item.id + ''; } listHtml += '
'; } if (count > items.length) { listHtml += '
... ' + (trans.and || 'and') + ' ' + (count - items.length) + ' ' + (trans.more || 'more') + '
'; } } listHtml += '
'; $modal.find('.pattern-preview-content').html(listHtml); } else { $modal.find('.pattern-preview-content').html('
' + (trans.error_loading || 'Error loading preview') + '
'); } }, error: function() { $modal.find('.pattern-preview-content').html('
' + (trans.error_loading || 'Error loading preview') + '
'); } }); }, // ========================================================================= // HELPER METHODS // ========================================================================= refreshGroupPreviewIfOpen: function($group) { if (!this.$activeBadge || !this.$previewPopover) { return; } var $badge = $group.find('.group-count-badge.popover-open, .group-preview-badge.popover-open'); if (!$badge.length) { return; } var self = this; var $block = $badge.closest('.es-block'); var blockType = $block.data('blockType') || 'products'; var groupData = this.serializeGroup($group, blockType); if (!groupData || !groupData.include) { return; } // Show loading state in list var $list = this.$previewPopover.find('.preview-list'); $list.css('opacity', '0.5'); $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'previewGroupItems', trait: 'EntitySelector', group_data: JSON.stringify(groupData), block_type: blockType, limit: 10 }, success: function(response) { if (response.success && self.$previewPopover) { // Update count in header var blockConfig = self.config.blocks && self.config.blocks[blockType] ? self.config.blocks[blockType] : {}; var entityLabel = blockConfig.entity_label_plural || 'products'; self.$previewPopover.find('.preview-header .preview-count').text(response.count + ' ' + entityLabel); // Rebuild list items using existing renderer $list.html(self.renderPreviewItems(response.items || [])).css('opacity', '1'); // Update context if (self.previewContext) { self.previewContext.groupData = groupData; } // Update load more var $footer = self.$previewPopover.find('.preview-footer'); if (response.hasMore) { var remaining = response.count - (response.items || []).length; $footer.find('.remaining-count').text(remaining); $footer.show(); } else { $footer.hide(); } } else { $list.css('opacity', '1'); } }, error: function() { $list.css('opacity', '1'); } }); }, /** * Escape HTML special characters */ escapeHtml: function(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, /** * Escape attribute value */ escapeAttr: function(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(/"/g, '"') .replace(/'/g, '''); }, // ========================================================================= // TOTAL COUNT PREVIEW (Header total badge click) // ========================================================================= /** * Show preview popover for total count badge * Displays a summary of all entity types with their counts */ showTotalPreviewPopover: function($badge) { var self = this; var trans = this.config.trans || {}; this.hidePreviewPopover(); $badge.addClass('popover-open'); this.$activeBadge = $badge; // Collect all entity types with data var summaryItems = []; this.$wrapper.find('.es-block-tab.has-data').each(function() { var $tab = $(this); var blockType = $tab.data('blockType'); var $tabBadge = $tab.find('.tab-badge'); var countText = $tabBadge.text().replace(/[^0-9]/g, ''); var count = parseInt(countText, 10) || 0; if (count > 0) { var blockConfig = self.config.blocks && self.config.blocks[blockType] ? self.config.blocks[blockType] : {}; var icon = $tab.find('.tab-label').prev('i').attr('class') || 'icon-cube'; var label = $tab.find('.tab-label').text() || blockType; summaryItems.push({ blockType: blockType, label: label, icon: icon, count: count }); } }); // Build popover HTML var totalCount = parseInt($badge.find('.count-value').text(), 10) || 0; var popoverHtml = '
'; popoverHtml += '
'; popoverHtml += '' + (trans.total_summary || 'Selection Summary') + ''; popoverHtml += '' + totalCount + ' ' + (trans.total_items || 'total items') + ''; popoverHtml += '
'; popoverHtml += '
'; popoverHtml += '
    '; for (var i = 0; i < summaryItems.length; i++) { var item = summaryItems[i]; popoverHtml += '
  • '; popoverHtml += ''; popoverHtml += '' + self.escapeHtml(item.label) + ''; popoverHtml += '' + item.count + ''; popoverHtml += '
  • '; } popoverHtml += '
'; popoverHtml += '
'; popoverHtml += '
'; var $popover = $(popoverHtml); this.$previewPopover = $popover; // Click on item to switch to that tab $popover.on('click', '.total-summary-item', function() { var blockType = $(this).data('blockType'); self.hidePreviewPopover(); self.switchToBlock(blockType); }); // Position popover $('body').append($popover); var badgeOffset = $badge.offset(); var badgeHeight = $badge.outerHeight(); var popoverWidth = $popover.outerWidth(); $popover.css({ position: 'absolute', top: badgeOffset.top + badgeHeight + 5, left: badgeOffset.left - (popoverWidth / 2) + ($badge.outerWidth() / 2), zIndex: 10000 }); // Adjust if off screen var windowWidth = $(window).width(); var popoverRight = $popover.offset().left + popoverWidth; if (popoverRight > windowWidth - 10) { $popover.css('left', windowWidth - popoverWidth - 10); } if ($popover.offset().left < 10) { $popover.css('left', 10); } $popover.hide().fadeIn(150); }, // ========================================================================= // HOLIDAY PREVIEW (Country chip eye button click) // ========================================================================= /** * Show holiday preview popover for a country * @param {number} countryId - Country ID * @param {string} countryName - Country name * @param {string} countryIso - Country ISO code * @param {jQuery} $trigger - The button element that triggered this */ showHolidayPreview: function(countryId, countryName, countryIso, $trigger) { var self = this; var trans = this.config.trans || {}; // Close any existing holiday popover $('.holiday-preview-popover').remove(); // Create popover HTML var popoverHtml = '
'; popoverHtml += '
'; popoverHtml += ''; if (countryIso) { popoverHtml += ' '; } popoverHtml += this.escapeHtml(countryName) + ' - ' + (trans.holidays || 'Holidays'); popoverHtml += ''; popoverHtml += ''; popoverHtml += '
'; popoverHtml += '
'; popoverHtml += '
sync ' + (trans.loading || 'Loading...') + '
'; popoverHtml += '
'; popoverHtml += '
'; var $popover = $(popoverHtml); $('body').append($popover); // Position popover near the trigger button var triggerRect = $trigger[0].getBoundingClientRect(); var scrollTop = $(window).scrollTop(); var scrollLeft = $(window).scrollLeft(); var popoverWidth = $popover.outerWidth(); var popoverHeight = $popover.outerHeight(); var windowWidth = $(window).width(); var windowHeight = $(window).height(); // Default: position below and to the right of the trigger var top = triggerRect.bottom + scrollTop + 8; var left = triggerRect.left + scrollLeft; // Adjust horizontal position if it goes off-screen if (left + popoverWidth > windowWidth - 10) { left = windowWidth - popoverWidth - 10; } if (left < 10) { left = 10; } // Adjust vertical position if it goes below viewport if (triggerRect.bottom + popoverHeight > windowHeight - 10) { // Position above the trigger instead top = triggerRect.top + scrollTop - popoverHeight - 8; } $popover.css({ position: 'absolute', top: top, left: left, zIndex: 10001 }); // Close button handler $popover.find('.popover-close').on('click', function() { $popover.remove(); }); // Fetch holidays via AJAX $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getHolidaysPreview', trait: 'EntitySelector', id_country: countryId }, success: function(response) { if (response.success && response.holidays && response.holidays.length > 0) { var listHtml = '
'; for (var i = 0; i < response.holidays.length; i++) { var h = response.holidays[i]; var typeClass = h.type ? 'holiday-type-' + h.type.toLowerCase().replace(/\s+/g, '-') : ''; listHtml += '
'; listHtml += '
'; listHtml += '' + self.escapeHtml(h.date_formatted || h.date) + ''; if (h.day_of_week) { listHtml += '' + self.escapeHtml(h.day_of_week) + ''; } listHtml += '
'; listHtml += '
'; listHtml += '' + self.escapeHtml(h.name) + ''; if (h.type) { listHtml += '' + self.escapeHtml(h.type) + ''; } listHtml += '
'; listHtml += '
'; } listHtml += '
'; if (response.total_count) { listHtml += '
' + response.total_count + ' ' + (trans.upcoming_holidays || 'upcoming holidays') + '
'; } $popover.find('.popover-body').html(listHtml); } else { var noDataHtml = '
'; noDataHtml += 'event_busy'; noDataHtml += '

' + (trans.no_holidays || 'No holidays found') + '

'; noDataHtml += '
'; $popover.find('.popover-body').html(noDataHtml); } // Re-adjust position after content loaded var newPopoverHeight = $popover.outerHeight(); if (triggerRect.bottom + newPopoverHeight > windowHeight - 10) { var newTop = triggerRect.top + scrollTop - newPopoverHeight - 8; if (newTop > 10) { $popover.css('top', newTop); } } }, error: function() { var errorHtml = '
'; errorHtml += 'error_outline'; errorHtml += '

' + (trans.error_loading || 'Error loading holidays') + '

'; errorHtml += '
'; $popover.find('.popover-body').html(errorHtml); } }); }, // ========================================================================= // COUNTRIES HOLIDAY PREVIEW (Condition badge for multiple countries) // ========================================================================= /** * Show holiday preview popover for multiple selected countries * @param {jQuery} $badge - The condition-match-count badge element * @param {Array} countryIds - Array of country IDs */ showCountriesHolidayPreview: function($badge, countryIds) { var self = this; var trans = this.config.trans || {}; // Close any existing popovers this.hidePreviewPopover(); $('.holiday-preview-popover').remove(); // Create popover HTML with placeholder title (will update after AJAX) var popoverHtml = '
'; popoverHtml += '
'; popoverHtml += 'sync ' + (trans.loading || 'Loading...') + ''; popoverHtml += ''; popoverHtml += '
'; popoverHtml += '
'; popoverHtml += 'search'; popoverHtml += ''; popoverHtml += '
'; popoverHtml += '
'; popoverHtml += '
sync ' + (trans.loading || 'Loading...') + '
'; popoverHtml += '
'; popoverHtml += '
'; var $popover = $(popoverHtml); $('body').append($popover); // Position popover near the badge var badgeRect = $badge[0].getBoundingClientRect(); var scrollTop = $(window).scrollTop(); var scrollLeft = $(window).scrollLeft(); var popoverWidth = $popover.outerWidth(); var popoverHeight = $popover.outerHeight(); var windowWidth = $(window).width(); var windowHeight = $(window).height(); // Default: position below the badge var top = badgeRect.bottom + scrollTop + 8; var left = badgeRect.left + scrollLeft; // Adjust horizontal position if (left + popoverWidth > windowWidth - 10) { left = windowWidth - popoverWidth - 10; } if (left < 10) { left = 10; } // Adjust vertical position if it goes below viewport if (badgeRect.bottom + popoverHeight > windowHeight - 10) { top = badgeRect.top + scrollTop - popoverHeight - 8; } $popover.css({ position: 'absolute', top: top, left: left, zIndex: 10001 }); // Mark badge as open $badge.addClass('popover-open'); this.$activeBadge = $badge; // Close button handler $popover.find('.popover-close').on('click', function() { $popover.remove(); $badge.removeClass('popover-open'); self.$activeBadge = null; }); // Fetch holidays via AJAX $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getHolidaysForCountries', trait: 'EntitySelector', country_ids: countryIds.join(','), count_only: 0 }, success: function(response) { if (response.success && response.holidays && response.holidays.length > 0) { // Count holidays per country to determine which flags to show var countryHolidayCounts = {}; var countriesMap = {}; // Build map of countries from response if (response.countries) { for (var ci = 0; ci < response.countries.length; ci++) { var cInfo = response.countries[ci]; countriesMap[cInfo.id] = cInfo; countryHolidayCounts[cInfo.id] = 0; } } // Count holidays per country for (var hi = 0; hi < response.holidays.length; hi++) { var hol = response.holidays[hi]; if (hol.country_id && typeof countryHolidayCounts[hol.country_id] !== 'undefined') { countryHolidayCounts[hol.country_id]++; } } // Sort countries by holiday count (descending) var sortedCountries = Object.keys(countriesMap).sort(function(a, b) { return (countryHolidayCounts[b] || 0) - (countryHolidayCounts[a] || 0); }).map(function(id) { return countriesMap[id]; }); // Build header title with flags for countries with most holidays var titleHtml = ''; var numCountries = sortedCountries.length; if (numCountries <= 3) { for (var fi = 0; fi < numCountries; fi++) { var fc = sortedCountries[fi]; if (fc && fc.iso_code) { titleHtml += '' + self.escapeAttr(fc.iso_code) + ' '; } } } else { // Show top 2 flags (countries with most holidays) + count for (var fj = 0; fj < 2; fj++) { var fcc = sortedCountries[fj]; if (fcc && fcc.iso_code) { titleHtml += ' '; } } titleHtml += '+' + (numCountries - 2) + ' '; } titleHtml += response.total_count + ' ' + (trans.holidays || 'Holidays'); // Update header title $popover.find('.popover-title').html(titleHtml); var listHtml = '
'; for (var i = 0; i < response.holidays.length; i++) { var h = response.holidays[i]; var typeClass = h.type ? 'holiday-type-' + h.type.toLowerCase().replace(/\s+/g, '-') : ''; // Build search text for filtering var searchText = [ h.name || '', h.date_formatted || h.date || '', h.day_of_week || '', h.country_name || '', h.type || '' ].join(' ').toLowerCase(); listHtml += '
'; listHtml += '
'; listHtml += '' + self.escapeHtml(h.date_formatted || h.date) + ''; if (h.day_of_week) { listHtml += '' + self.escapeHtml(h.day_of_week) + ''; } listHtml += '
'; listHtml += '
'; // Show country flag before holiday name when multiple countries if (h.country_iso && countryIds.length > 1) { listHtml += ' '; } listHtml += '' + self.escapeHtml(h.name) + ''; if (h.type) { listHtml += '' + self.escapeHtml(h.type) + ''; } listHtml += '
'; listHtml += '
'; } listHtml += '
'; if (response.total_count && countryIds.length > 1) { var noteText = (trans.across_countries || 'across') + ' ' + countryIds.length + ' ' + (trans.countries || 'countries'); listHtml += '
' + noteText + '
'; } $popover.find('.popover-body').html(listHtml); // Setup filter input handler $popover.find('.holiday-filter-input').on('input', function() { var query = $(this).val().toLowerCase().trim(); var $items = $popover.find('.holiday-item'); var visibleCount = 0; $items.each(function() { var searchData = $(this).attr('data-search') || ''; if (!query || searchData.indexOf(query) !== -1) { $(this).show(); visibleCount++; } else { $(this).hide(); } }); // Update note with filtered count var $note = $popover.find('.holiday-preview-note'); if (query && $note.length) { $note.text(visibleCount + ' ' + (trans.matches || 'matches')); } else if ($note.length && countryIds.length > 1) { var noteText = (trans.across_countries || 'across') + ' ' + countryIds.length + ' ' + (trans.countries || 'countries'); $note.text(noteText); } }); } else { // Update header for empty state $popover.find('.popover-title').html('0 ' + (trans.holidays || 'Holidays')); var noDataHtml = '
'; noDataHtml += 'event_busy'; noDataHtml += '

' + (trans.no_holidays || 'No holidays found') + '

'; noDataHtml += '
'; $popover.find('.popover-body').html(noDataHtml); } // Re-adjust position after content loaded var newPopoverHeight = $popover.outerHeight(); if (badgeRect.bottom + newPopoverHeight > windowHeight - 10) { var newTop = badgeRect.top + scrollTop - newPopoverHeight - 8; if (newTop > 10) { $popover.css('top', newTop); } } }, error: function() { // Update header for error state $popover.find('.popover-title').html('error_outline ' + (trans.error || 'Error')); var errorHtml = '
'; errorHtml += 'error_outline'; errorHtml += '

' + (trans.error_loading || 'Error loading holidays') + '

'; errorHtml += '
'; $popover.find('.popover-body').html(errorHtml); } }); } }; })(jQuery); /** * Entity Selector - Filters Module * Filter panel, filter state management * @partial _filters.js * * EXTRACTION SOURCE: assets/js/admin/entity-selector.js * Lines: 6605-6758 (filter methods) * * Contains: * - clearFilters() - Reset all filters * - resetFiltersWithoutSearch() - Reset without triggering search * - updateFilterPanelForEntity() - Show/hide filters based on entity type * - loadFilterableData() - Load attributes/features for filter panel * - renderFilterDropdowns() - Render attribute/feature group toggles * - showFilterGroupValues() - Show values for a filter group * - hideFilterGroupValues() - Hide filter values row * - updateFilterToggleStates() - Update toggle states based on selections */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.filters = { clearFilters: function() { this.refineQuery = ''; this.refineNegate = false; this.filters = { inStock: false, discounted: false, priceMin: null, priceMax: null, attributes: [], features: [], productCountMin: null, productCountMax: null, salesMin: null, salesMax: null, turnoverMin: null, turnoverMax: null, depth: null, hasProducts: false, hasDescription: false, hasImage: false, activeOnly: true, attributeGroup: null, featureGroup: null, dateAddFrom: null, dateAddTo: null, lastProductFrom: null, lastProductTo: null, // Country-specific filters hasHolidays: false, containsStates: false, zone: null }; if (this.$dropdown) { var trans = this.config.trans || {}; this.$dropdown.find('.refine-input').val(''); this.$dropdown.find('.btn-refine-negate').removeClass('active'); this.$dropdown.find('.filter-in-stock').prop('checked', false); this.$dropdown.find('.filter-discounted').prop('checked', false); this.$dropdown.find('.filter-price-min, .filter-price-max').val(''); this.$dropdown.find('.filter-attr-chip, .filter-feat-chip').removeClass('active'); this.$dropdown.find('.filter-product-count-min, .filter-product-count-max').val(''); this.$dropdown.find('.filter-sales-min, .filter-sales-max').val(''); this.$dropdown.find('.filter-depth-select').val(''); this.$dropdown.find('.filter-has-products').prop('checked', false); this.$dropdown.find('.filter-active-only').prop('checked', true); // Country filters this.$dropdown.find('.filter-has-holidays').prop('checked', false); this.$dropdown.find('.filter-contains-states').prop('checked', false); this.$dropdown.find('.filter-zone-select').val(''); } this.refreshSearch(); }, resetFiltersWithoutSearch: function() { // Same as clearFilters but doesn't trigger search // Used when switching entity types this.refineQuery = ''; this.refineNegate = false; this.filters = { inStock: false, discounted: false, priceMin: null, priceMax: null, attributes: [], features: [], productCountMin: null, productCountMax: null, salesMin: null, salesMax: null, turnoverMin: null, turnoverMax: null, depth: null, hasProducts: false, hasDescription: false, hasImage: false, activeOnly: true, attributeGroup: null, featureGroup: null, dateAddFrom: null, dateAddTo: null, lastProductFrom: null, lastProductTo: null, // Country-specific filters hasHolidays: false, containsStates: false, zone: null }; }, updateFilterPanelForEntity: function(entityType) { if (!this.$dropdown) { return; } var $panel = this.$dropdown.find('.filter-panel'); // Hide all entity-specific filter rows $panel.find('.filter-row').hide(); // Show filters for current entity type $panel.find('.filter-row[data-entity="' + entityType + '"]').show(); $panel.find('.filter-row-entity-' + entityType.replace('_', '-')).show(); // Show/hide tree view option based on entity type var isCategory = (entityType === 'categories' || entityType === 'cms_categories'); this.$dropdown.find('.tree-view-option').toggle(isCategory); // Default to tree view for categories (only if currently on list mode) if (isCategory && this.viewMode === 'list') { this.viewMode = 'tree'; this.$dropdown.find('.view-mode-select').val('tree'); this.$dropdown.removeClass('view-list view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-tree'); } else if (!isCategory && this.viewMode === 'tree') { // If switching away from categories while in tree mode, switch to list this.viewMode = 'list'; this.$dropdown.find('.view-mode-select').val('list'); this.$dropdown.removeClass('view-tree view-cols-2 view-cols-3 view-cols-4 view-cols-5 view-cols-6 view-cols-7 view-cols-8').addClass('view-list'); } // Load zones for countries filter if (entityType === 'countries') { this.loadZonesForCountryFilter(); } // Update sort options for entity type this.updateSortOptionsForEntity(entityType); }, /** * Show/hide sort options based on entity type * Options with data-entities attribute are only shown for matching entities */ updateSortOptionsForEntity: function(entityType) { if (!this.$dropdown) { return; } var $select = this.$dropdown.find('.sort-field-select'); var currentValue = $select.val(); var hasCurrentOption = false; $select.find('option').each(function() { var $option = $(this); var entities = $option.data('entities'); // Options without data-entities are universal (always shown) if (!entities) { $option.show(); if ($option.val() === currentValue) { hasCurrentOption = true; } return; } // Check if this entity type is in the allowed list var allowedEntities = entities.split(','); var isAllowed = allowedEntities.indexOf(entityType) !== -1; $option.toggle(isAllowed); if (isAllowed && $option.val() === currentValue) { hasCurrentOption = true; } }); // If current sort field is not available for this entity, reset to 'name' if (!hasCurrentOption) { $select.val('name'); this.currentSort.field = 'name'; } }, loadFilterableData: function() { var self = this; if (this.filterableData) { this.renderFilterDropdowns(); return; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', data: { ajax: 1, action: 'getTargetFilterableAttributes', trait: 'EntitySelector' }, dataType: 'json', success: function(response) { if (response.success && response.data) { self.filterableData = response.data; self.renderFilterDropdowns(); } } }); }, renderFilterDropdowns: function() { if (!this.$dropdown || !this.filterableData) return; var self = this; // Render attribute group toggle buttons var $attrContainer = this.$dropdown.find('.filter-attributes-container'); $attrContainer.empty(); if (this.filterableData.attributes && this.filterableData.attributes.length > 0) { this.filterableData.attributes.forEach(function(group) { var html = ''; $attrContainer.append(html); }); this.$dropdown.find('.filter-row-attributes').show(); } // Render feature group toggle buttons var $featContainer = this.$dropdown.find('.filter-features-container'); $featContainer.empty(); if (this.filterableData.features && this.filterableData.features.length > 0) { this.filterableData.features.forEach(function(group) { var html = ''; $featContainer.append(html); }); this.$dropdown.find('.filter-row-features').show(); } }, showFilterGroupValues: function(groupId, type) { if (!this.filterableData) return; var self = this; var groups = type === 'attribute' ? this.filterableData.attributes : this.filterableData.features; var group = groups.find(function(g) { return g.id == groupId; }); if (!group) return; // Hide all values rows first, then show the correct one this.$dropdown.find('.filter-row-values').hide(); // Target the correct values row based on type var valuesRowClass = type === 'attribute' ? '.filter-row-attr-values' : '.filter-row-feat-values'; var $filterRowValues = this.$dropdown.find(valuesRowClass); var $valuesContainer = $filterRowValues.find('.filter-values-container'); $valuesContainer.empty(); // Add group label var html = '' + group.name + ':'; // Add chips group.values.forEach(function(val) { var isActive = type === 'attribute' ? self.filters.attributes.indexOf(val.id) !== -1 : self.filters.features.indexOf(val.id) !== -1; var activeClass = isActive ? ' active' : ''; var chipClass = type === 'attribute' ? 'filter-attr-chip' : 'filter-feat-chip'; var colorStyle = val.color ? ' style="--chip-color: ' + val.color + '"' : ''; var colorClass = val.color ? ' has-color' : ''; html += ''; }); $valuesContainer.html(html); // Add close button as sibling (outside filter-values-container, inside filter-row-values) $filterRowValues.find('.btn-close-values').remove(); $filterRowValues.append(''); $filterRowValues.show(); // Scroll into view if needed var rowValues = $filterRowValues[0]; if (rowValues) { rowValues.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } }, hideFilterGroupValues: function() { this.$dropdown.find('.filter-row-values').hide(); this.$dropdown.find('.filter-group-toggle').removeClass('active'); }, updateFilterToggleStates: function() { if (!this.$dropdown || !this.filterableData) return; var self = this; // Update attribute group toggles if (this.filterableData.attributes) { this.filterableData.attributes.forEach(function(group) { var $toggle = self.$dropdown.find('.filter-group-toggle[data-group-id="' + group.id + '"][data-type="attribute"]'); var hasActiveInGroup = group.values.some(function(val) { return self.filters.attributes.indexOf(val.id) !== -1; }); $toggle.toggleClass('has-selection', hasActiveInGroup); }); } // Update feature group toggles if (this.filterableData.features) { this.filterableData.features.forEach(function(group) { var $toggle = self.$dropdown.find('.filter-group-toggle[data-group-id="' + group.id + '"][data-type="feature"]'); var hasActiveInGroup = group.values.some(function(val) { return self.filters.features.indexOf(val.id) !== -1; }); $toggle.toggleClass('has-selection', hasActiveInGroup); }); } }, /** * Load zones for country filter dropdown */ loadZonesForCountryFilter: function() { var self = this; if (this.zonesLoaded || !this.$dropdown) { return; } var $select = this.$dropdown.find('.filter-zone-select'); if (!$select.length) { return; } $.ajax({ url: this.config.ajaxUrl, type: 'POST', dataType: 'json', data: { ajax: 1, action: 'getZonesForFilter', trait: 'EntitySelector' }, success: function(response) { if (response.success && response.zones && response.zones.length > 0) { var trans = self.config.trans || {}; $select.empty(); $select.append(''); response.zones.forEach(function(zone) { $select.append(''); }); self.zonesLoaded = true; } } }); } }; })(jQuery); /** * Entity Selector - Methods Module * Method dropdown rendering, value pickers, combination picker * @partial _methods.js * * EXTRACTION SOURCE: assets/js/admin/entity-selector.js * Lines: 6760-6848 (initMethodDropdowns, enhanceMethodSelect) * 6849-7051 (showMethodDropdownMenu, buildMethodDropdownMenuHtml, closeMethodDropdownMenu) * 7053-7138 (populateTiles, applyRangeInputConstraints, showRangeInputError) * 7139-7380 (combination picker methods) * 7382-7550 (updateMethodInfoPlaceholder, getBuiltInMethodHelp) * 7748-7888 (buildSortOptions, updateModifierButtonState, updateMethodSelectorLock) * * Contains: * - initMethodDropdowns() - Initialize styled dropdowns * - enhanceMethodSelect() - Convert select to styled dropdown * - showMethodDropdownMenu() - Show method selection menu * - buildMethodDropdownMenuHtml() - Build menu HTML * - closeMethodDropdownMenu() - Close dropdown menu * - updateMethodTrigger() - Update trigger display * - populateTiles() - Build multi-select tiles * - applyRangeInputConstraints() - Set numeric input constraints * - showRangeInputError() - Display validation error * - loadCombinationAttributeGroups() - Load attribute groups for picker * - loadCombinationAttributeValues() - Load values for attribute group * - restoreCombinationSelections() - Restore saved combination state * - updateCombinationData() - Save combination selection * - updateCombinationGroupCounts() - Update selection counts * - updateMethodInfoPlaceholder() - Show method help * - getBuiltInMethodHelp() - Get help text for methods * - buildSortOptions() - Build sort dropdown options * - updateModifierButtonState() - Update modifier toggle state * - updateMethodSelectorLock() - Lock/unlock method selector */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.methods = { /** * Initialize styled method dropdowns for all method selects */ initMethodDropdowns: function() { var self = this; this.$wrapper.find('.include-method-select').each(function() { self.enhanceMethodSelect($(this)); }); this.$wrapper.find('.exclude-method-select').each(function() { self.enhanceMethodSelect($(this)); }); this.initMethodInfoPlaceholders(); }, /** * Initialize info placeholders for all existing method selects */ initMethodInfoPlaceholders: function() { var self = this; this.$wrapper.find('.selection-group').each(function() { var $group = $(this); var $block = $group.closest('.es-block'); var blockType = $block.data('blockType') || 'products'; // Include method info var includeMethod = $group.find('.include-method-select').val() || 'all'; self.updateMethodInfoPlaceholder($group.find('.method-selector-wrapper'), includeMethod, blockType); // Exclude methods info $group.find('.exclude-row').each(function() { var $row = $(this); var excludeMethod = $row.find('.exclude-method-select').val(); if (excludeMethod) { self.updateMethodInfoPlaceholder($row.find('.method-selector-wrapper'), excludeMethod, blockType); } }); }); }, /** * Enhance a single method select with styled dropdown */ enhanceMethodSelect: function($select) { var self = this; if (!$select.length || $select.data('methodDropdownInit')) { return; } // Skip if only 1 option (nothing to switch between) if ($select.find('option').length <= 1) { $select.data('methodDropdownInit', true); return; } $select.data('methodDropdownInit', true); $select.addClass('method-select-hidden'); var $selectedOption = $select.find('option:selected'); var selectedIcon = $selectedOption.data('icon') || 'icon-caret-down'; var selectedLabel = $selectedOption.text(); var triggerHtml = '
'; triggerHtml += ''; triggerHtml += '' + this.escapeHtml(selectedLabel) + ''; triggerHtml += ''; triggerHtml += '
'; var $trigger = $(triggerHtml); $select.after($trigger); $trigger.on('click', function(e) { e.preventDefault(); e.stopPropagation(); var $wrapper = $select.closest('.method-selector-wrapper'); if ($wrapper.hasClass('selector-locked')) { return; } self.showMethodDropdownMenu($select, $trigger); }); $select.on('change.methodDropdown', function() { self.updateMethodTrigger($select, $trigger); }); }, /** * Update the trigger display to match current selection */ updateMethodTrigger: function($select, $trigger) { var $selectedOption = $select.find('option:selected'); var selectedIcon = $selectedOption.data('icon') || 'icon-caret-down'; var selectedLabel = $selectedOption.text(); $trigger.find('.method-trigger-icon').attr('class', selectedIcon + ' method-trigger-icon'); $trigger.find('.method-trigger-label').text(selectedLabel); }, /** * Show the method dropdown menu */ showMethodDropdownMenu: function($select, $trigger) { var self = this; this.closeMethodDropdownMenu(); var menuHtml = this.buildMethodDropdownMenuHtml($select); var $menu = $(menuHtml); var triggerOffset = $trigger.offset(); var triggerWidth = $trigger.outerWidth(); var triggerHeight = $trigger.outerHeight(); $menu.css({ position: 'absolute', top: triggerOffset.top + triggerHeight + 2, left: triggerOffset.left, minWidth: triggerWidth, zIndex: 10001 }); $('body').append($menu); this.$methodDropdownMenu = $menu; this.$methodDropdownSelect = $select; this.$methodDropdownTrigger = $trigger; $menu.on('click', '.method-dropdown-item', function(e) { e.preventDefault(); e.stopPropagation(); var value = $(this).data('value'); $select.val(value).trigger('change'); self.closeMethodDropdownMenu(); }); $(document).on('click.methodDropdown', function(e) { if (!$(e.target).closest('.method-dropdown-menu, .method-dropdown-trigger').length) { self.closeMethodDropdownMenu(); } }); $(document).on('keydown.methodDropdown', function(e) { if (e.keyCode === 27) { self.closeMethodDropdownMenu(); } }); }, /** * Build the dropdown menu HTML */ buildMethodDropdownMenuHtml: function($select) { var self = this; var html = '
'; // Render ungrouped options first $select.children('option').each(function() { var $el = $(this); var icon = $el.data('icon') || 'icon-asterisk'; var label = $el.text(); var value = $el.val(); var isSelected = $el.is(':selected'); html += '
'; html += ''; html += '' + self.escapeHtml(label) + ''; if (isSelected) { html += ''; } html += '
'; }); // Render optgroups $select.children('optgroup').each(function() { var $optgroup = $(this); var groupLabel = $optgroup.attr('label') || ''; html += '
'; html += '
' + self.escapeHtml(groupLabel) + '
'; html += '
'; $optgroup.children('option').each(function() { var $el = $(this); var icon = $el.data('icon') || 'icon-cog'; var label = $el.text(); var value = $el.val(); var isSelected = $el.is(':selected'); html += '
'; html += ''; html += '' + self.escapeHtml(label) + ''; if (isSelected) { html += ''; } html += '
'; }); html += '
'; // close items html += '
'; // close optgroup }); html += '
'; return html; }, /** * Close the method dropdown menu */ closeMethodDropdownMenu: function() { if (this.$methodDropdownMenu) { this.$methodDropdownMenu.remove(); this.$methodDropdownMenu = null; } this.$methodDropdownSelect = null; this.$methodDropdownTrigger = null; $(document).off('click.methodDropdown keydown.methodDropdown'); }, /** * Populate tiles for multi_select_tiles value picker */ populateTiles: function($picker, options, exclusive) { var self = this; var $container = $picker.find('.multi-select-tiles'); $container.empty(); if (exclusive) { $container.attr('data-exclusive', 'true'); } else { $container.removeAttr('data-exclusive'); } $.each(options, function(key, optData) { var label = typeof optData === 'object' ? optData.label : optData; var icon = typeof optData === 'object' && optData.icon ? optData.icon : null; var color = typeof optData === 'object' && optData.color ? optData.color : null; var tileClass = 'tile-option'; if (color) { tileClass += ' tile-color-' + color; } var $tile = $(''; html += ''; html += ''; // Tree items html += '
'; html += this.renderTreeItems(this.treeData, 0, selectedIds); html += '
'; html += ''; $container.html(html); // Update count var totalCount = this.treeFlatData ? this.treeFlatData.length : 0; var selectedCount = selectedIds.length; var categoryLabel = entityType === 'cms_categories' ? 'CMS categories' : 'categories'; var countText = totalCount + ' ' + categoryLabel; if (selectedCount > 0) { countText += ' (' + selectedCount + ' selected)'; } this.$dropdown.find('.results-count').text(countText); // Update select children button states this.updateSelectChildrenButtons(this.$dropdown.find('.tree-item')); }, /** * Render tree items recursively * @param {Array} nodes - Tree nodes * @param {number} level - Current depth level * @param {Array} selectedIds - Currently selected IDs * @returns {string} HTML string */ renderTreeItems: function(nodes, level, selectedIds) { var self = this; var html = ''; var trans = this.config.trans || {}; nodes.forEach(function(node) { var hasChildren = node.children && node.children.length > 0; var isSelected = selectedIds.indexOf(parseInt(node.id, 10)) !== -1; var indent = level * 20; var itemClass = 'tree-item'; if (hasChildren) itemClass += ' has-children'; if (isSelected) itemClass += ' selected'; if (!node.active) itemClass += ' inactive'; html += '
'; // Indentation html += ''; // Toggle button (expand/collapse) if (hasChildren) { html += ''; // Select with children button (next to toggle on the left) html += ''; } else { html += ''; } // Checkbox indicator html += ''; // Category icon html += ''; // Name html += '' + self.escapeHtml(node.name) + ''; // Product/page count with clickable preview var itemCount = node.product_count || node.page_count || 0; if (itemCount > 0) { var countLabel = node.page_count ? (trans.pages || 'pages') : (trans.products || 'products'); html += ''; html += ' ' + itemCount; html += ''; } // Inactive badge if (!node.active) { html += '' + self.escapeHtml(trans.inactive || 'Inactive') + ''; } html += '
'; // Render children if (hasChildren) { html += '
'; html += self.renderTreeItems(node.children, level + 1, selectedIds); html += '
'; } }); return html; }, /** * Get selected IDs from the current picker's chips * @returns {Array} Array of selected IDs */ getSelectedIdsFromChips: function() { var selectedIds = []; if (!this.activeGroup) return selectedIds; var $block = this.$wrapper.find('.es-block[data-block-type="' + this.activeGroup.blockType + '"]'); var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]'); var $picker; if (this.activeGroup.section === 'include') { $picker = $group.find('.include-picker'); } else { var $excludeRow = $group.find('.exclude-row[data-exclude-index="' + this.activeGroup.excludeIndex + '"]'); $picker = $excludeRow.find('.exclude-picker'); } $picker.find('.entity-chip').each(function() { selectedIds.push(parseInt($(this).data('id'), 10)); }); return selectedIds; }, /** * Filter category tree by search query * @param {string} query - Search query */ filterCategoryTree: function(query) { var $tree = this.$dropdown.find('.category-tree'); if (!$tree.length) return; var $items = $tree.find('.tree-item'); var $children = $tree.find('.tree-children'); query = (query || '').toLowerCase().trim(); // Remove any inline display styles set by jQuery .toggle() $items.css('display', ''); if (!query) { $items.removeClass('filtered-out filter-match'); $children.removeClass('filter-expanded'); return; } // Mark all as filtered out first $items.addClass('filtered-out').removeClass('filter-match'); // Find matching items and show them with their parents $items.each(function() { var $item = $(this); var name = ($item.data('name') || '').toLowerCase(); if (name.indexOf(query) !== -1) { $item.removeClass('filtered-out'); // Show parent containers $item.parents('.tree-children').addClass('filter-expanded'); $item.parents('.tree-item').removeClass('filtered-out'); // Show children of matching item $item.next('.tree-children').find('.tree-item').removeClass('filtered-out'); $item.next('.tree-children').addClass('filter-expanded'); } }); }, /** * Find all descendant tree items of a given item * @param {jQuery} $item - Parent tree item * @param {jQuery} $allItems - All tree items (for performance) * @returns {Array} Array of descendant jQuery elements */ findTreeDescendants: function($item, $allItems) { var descendants = []; var parentId = parseInt($item.data('id'), 10); var level = parseInt($item.data('level'), 10); // Find immediate children first var $next = $item.next('.tree-children'); if ($next.length) { $next.find('.tree-item').each(function() { descendants.push(this); }); } return descendants; }, /** * Update the state of select-children buttons based on selection * @param {jQuery} $allItems - All tree items */ updateSelectChildrenButtons: function($allItems) { var self = this; var trans = this.config.trans || {}; $allItems.filter('.has-children').each(function() { var $item = $(this); var $btn = $item.find('.btn-select-children'); if (!$btn.length) return; var $children = $item.next('.tree-children'); if (!$children.length) return; var $childItems = $children.find('.tree-item'); var isParentSelected = $item.hasClass('selected'); var allChildrenSelected = true; $childItems.each(function() { if (!$(this).hasClass('selected')) { allChildrenSelected = false; return false; } }); if (isParentSelected && allChildrenSelected) { $btn.find('i').removeClass('icon-plus-square').addClass('icon-minus-square'); $btn.attr('title', trans.deselect_with_children || 'Deselect with all children'); } else { $btn.find('i').removeClass('icon-minus-square').addClass('icon-plus-square'); $btn.attr('title', trans.select_with_children || 'Select with all children'); } }); } }; })(jQuery); /** * Entity Selector - Utilities Module * Helper functions: escape, validation, icons, search history * @partial _utils.js (must be loaded first) * * EXTRACTION SOURCE: assets/js/admin/entity-selector.js * Lines: 7552-7570 (escapeHtml, escapeAttr) * 7577-7590 (getEntityTypeLabel) * 6289-6350 (validate, showValidationError, clearValidationError) * 7115-7137 (showRangeInputError) * 7728-7745 (getBlockMode, isBlockSingleMode) * 7707-7723 (getCurrentSingleSelection) * 5411-5467 (search history methods) */ (function($) { 'use strict'; // Create mixin namespace window._EntitySelectorMixins = window._EntitySelectorMixins || {}; // Utility functions mixin window._EntitySelectorMixins.utils = { /** * Debounce function - delays execution until after wait milliseconds * @param {Function} func - Function to debounce * @param {number} wait - Milliseconds to wait * @returns {Function} Debounced function */ debounce: function(func, wait) { var timeout; return function() { var context = this; var args = arguments; clearTimeout(timeout); timeout = setTimeout(function() { func.apply(context, args); }, wait); }; }, escapeHtml: function(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, escapeAttr: function(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, getEntityTypeIcon: function(entityType) { var icons = { 'products': 'icon-shopping-cart', 'categories': 'icon-folder-open', 'manufacturers': 'icon-building', 'suppliers': 'icon-truck', 'attributes': 'icon-list-alt', 'features': 'icon-tags', 'cms': 'icon-file-text', 'cms_categories': 'icon-folder' }; return icons[entityType] || 'icon-cube'; }, getEntityTypeLabel: function(entityType) { var trans = this.config.trans || {}; var labels = { 'products': trans.product || 'Product', 'categories': trans.category || 'Category', 'manufacturers': trans.manufacturer || 'Manufacturer', 'suppliers': trans.supplier || 'Supplier', 'attributes': trans.attribute || 'Attribute', 'features': trans.feature || 'Feature', 'cms': trans.cms_page || 'CMS Page', 'cms_categories': trans.cms_category || 'CMS Category' }; return labels[entityType] || entityType; }, validate: function() { var isRequired = this.$wrapper.data('required') === 1 || this.$wrapper.data('required') === '1'; if (!isRequired) return true; var hasData = false; this.$wrapper.find('.es-block').each(function() { if ($(this).find('.selection-group').length > 0) { hasData = true; return false; } }); if (!hasData) { this.showValidationError(); return false; } this.clearValidationError(); return true; }, showValidationError: function() { this.$wrapper.addClass('has-validation-error'); var message = this.$wrapper.data('required-message') || 'Please select at least one item'; this.$wrapper.find('.trait-validation-error').remove(); var $error = $('
', { class: 'trait-validation-error', html: ' ' + message }); this.$wrapper.find('.condition-trait-header').after($error); $('html, body').animate({ scrollTop: this.$wrapper.offset().top - 100 }, 300); if (!this.$wrapper.find('.condition-trait-body').hasClass('es-expanded')) { this.$wrapper.find('.condition-trait-body').addClass('es-expanded'); this.$wrapper.removeClass('collapsed'); } }, clearValidationError: function() { this.$wrapper.removeClass('has-validation-error'); this.$wrapper.find('.trait-validation-error').remove(); }, getBlockMode: function(blockType) { var blockDef = this.config.blocks[blockType]; return (blockDef && blockDef.mode) ? blockDef.mode : 'multi'; }, isBlockSingleMode: function(blockType) { return this.getBlockMode(blockType) === 'single'; }, getCurrentSingleSelection: function(blockType) { // Global single mode — check any chip across all blocks if ((this.config.mode || 'multi') === 'single') { var $chip = this.$wrapper.find('.entity-chips .entity-chip').first(); if ($chip.length) { var $block = $chip.closest('.es-block'); return { name: $chip.find('.chip-name').text() || $chip.data('id'), entityType: $block.data('block-type') || 'item' }; } return null; } // Per-block single mode — check active block or specified blockType if (blockType) { if (this.getBlockMode(blockType) !== 'single') return null; var $block = this.$wrapper.find('.es-block[data-block-type="' + blockType + '"]'); var $chip = $block.find('.entity-chips .entity-chip').first(); if ($chip.length) { return { name: $chip.find('.chip-name').text() || $chip.data('id'), entityType: blockType }; } } return null; }, showReplaceConfirmation: function(currentSelection, newSelection, onConfirm) { // Close the search dropdown so modal is accessible if (typeof this.hideDropdown === 'function') { this.hideDropdown(); } if (typeof MPRModal === 'undefined') { if (confirm('Replace "' + currentSelection.name + '" with "' + newSelection.name + '"?')) { onConfirm(); } return; } var t = this.config.trans || {}; var currentTypeLabel = this.getEntityTypeLabel(currentSelection.entityType); var newTypeLabel = this.getEntityTypeLabel(newSelection.entityType); var modal = MPRModal.create({ id: 'mpr-entity-replace-modal' }); modal.setHeader('warning', 'swap_horiz', t.replace_title || 'Replace selection?'); modal.setBody( '

' + this.escapeHtml(t.replace_message || 'Only one item is allowed. Replace the current selection?') + '

' + '
' + '
' + this.escapeHtml(t.replace_current || 'Current') + '
' + '
' + this.escapeHtml(currentTypeLabel) + ': ' + this.escapeHtml(currentSelection.name) + '
' + '
' + '
arrow_downward
' + '
' + '
' + this.escapeHtml(t.replace_new || 'New') + '
' + '
' + this.escapeHtml(newTypeLabel) + ': ' + this.escapeHtml(newSelection.name) + '
' + '
' ); modal.setFooter([ { type: 'cancel', label: t.cancel || 'Cancel' }, { type: 'primary', label: t.replace || 'Replace', icon: 'check', onClick: function() { modal.hide(); onConfirm(); }} ]); modal.show(); }, /** * Check if entity type supports tree browsing */ supportsTreeBrowsing: function(entityType) { return entityType === 'categories' || entityType === 'cms_categories'; }, /** * Build a standardized empty state element * @param {string} text - Message to display * @param {string} [icon] - Optional icon class (e.g. 'icon-info-circle') * @returns {string} HTML string */ _buildEmptyState: function(text, icon) { var iconHtml = icon ? ' ' : ''; return '' + iconHtml + this.escapeHtml(text) + ''; }, /** * Build a standardized search box (icon + input + spinner) * @param {string} placeholder - Input placeholder text * @param {string} [extraClass] - Optional additional CSS class * @returns {string} HTML string */ _buildSearchBoxHtml: function(placeholder, extraClass) { var cls = extraClass ? ' ' + extraClass : ''; return '
' + '' + '' + '' + '
'; }, /** * Build a standardized info tooltip * @param {string} content - Tooltip content * @param {string} [type] - 'details' for data-details attr, default uses data-tooltip * @returns {string} HTML string */ _buildInfoTooltip: function(content, type) { var attr = (type === 'details') ? 'data-details' : 'data-tooltip'; return '' + 'info_outline'; } }; })(jQuery); /** * Entity Selector - Validation Module * Conflict detection and prevention for entity selections * @partial _validation.js * * Features: * - Same entity in include & exclude detection * - Parent-child conflict detection for tree entities * - Redundant selection detection * - Error message display */ (function($) { 'use strict'; window._EntitySelectorMixins = window._EntitySelectorMixins || {}; window._EntitySelectorMixins.validation = { /** * Validate a selection before adding it * Returns { valid: true } or { valid: false, error: 'message', type: 'conflict_type' } * * @param {number|string} id - Entity ID to validate * @param {string} name - Entity name (for error messages) * @param {string} section - 'include' or 'exclude' * @param {Object} data - Additional data (parent_id, etc.) * @returns {Object} Validation result */ validateSelection: function(id, name, section, data) { if (!this.activeGroup) { return { valid: true }; } var trans = this.config.trans || {}; id = parseInt(id, 10); var $block = this.$wrapper.find('.es-block[data-block-type="' + this.activeGroup.blockType + '"]'); var $group = $block.find('.selection-group[data-group-index="' + this.activeGroup.groupIndex + '"]'); // Get include chips var includeIds = this.getChipIds($group.find('.include-picker')); // Get all exclude chips (from all exclude rows) var excludeIds = []; $group.find('.exclude-row').each(function() { var $excludePicker = $(this).find('.exclude-picker'); var ids = []; $excludePicker.find('.entity-chip').each(function() { ids.push(parseInt($(this).data('id'), 10)); }); excludeIds = excludeIds.concat(ids); }); // 1. Check for same entity in include & exclude var conflictResult = this.checkIncludeExcludeConflict(id, name, section, includeIds, excludeIds, trans); if (!conflictResult.valid) { return conflictResult; } // 2. Check for redundant selection (already selected in same section) var redundantResult = this.checkRedundantSelection(id, name, section, includeIds, excludeIds, trans); if (!redundantResult.valid) { return redundantResult; } // 3. Check for parent-child conflicts (only for tree entities) var searchEntity = this.activeGroup.searchEntity; if (searchEntity === 'categories' || searchEntity === 'cms_categories') { var treeResult = this.checkTreeConflicts(id, name, section, data, includeIds, excludeIds, trans); if (!treeResult.valid) { return treeResult; } } return { valid: true }; }, /** * Check if entity is in both include and exclude */ checkIncludeExcludeConflict: function(id, name, section, includeIds, excludeIds, trans) { if (section === 'include' && excludeIds.indexOf(id) !== -1) { return { valid: false, type: 'include_exclude_conflict', error: (trans.error_in_exclude || '"{name}" is already in the exclude list. Remove it from exclude first.').replace('{name}', name) }; } if (section === 'exclude' && includeIds.indexOf(id) !== -1) { return { valid: false, type: 'include_exclude_conflict', error: (trans.error_in_include || '"{name}" is already in the include list. Remove it from include first.').replace('{name}', name) }; } return { valid: true }; }, /** * Check for redundant selection (already selected) */ checkRedundantSelection: function(id, name, section, includeIds, excludeIds, trans) { if (section === 'include' && includeIds.indexOf(id) !== -1) { return { valid: false, type: 'redundant', error: (trans.error_already_selected || '"{name}" is already selected.').replace('{name}', name) }; } if (section === 'exclude' && excludeIds.indexOf(id) !== -1) { return { valid: false, type: 'redundant', error: (trans.error_already_excluded || '"{name}" is already in an exclude list.').replace('{name}', name) }; } return { valid: true }; }, /** * Check for parent-child conflicts in tree entities */ checkTreeConflicts: function(id, name, section, data, includeIds, excludeIds, trans) { // Need tree data for parent-child lookups if (!this.treeFlatData) { return { valid: true }; } var parentId = data && data.parentId ? parseInt(data.parentId, 10) : null; // Build lookup for quick access var lookup = {}; this.treeFlatData.forEach(function(item) { lookup[parseInt(item.id, 10)] = item; }); // Get all ancestor IDs var ancestorIds = this.getAncestorIds(id, lookup); // Get all descendant IDs var descendantIds = this.getDescendantIds(id, lookup); if (section === 'include') { // Check if any ancestor is excluded for (var i = 0; i < ancestorIds.length; i++) { if (excludeIds.indexOf(ancestorIds[i]) !== -1) { var ancestorName = lookup[ancestorIds[i]] ? lookup[ancestorIds[i]].name : 'Parent'; return { valid: false, type: 'parent_excluded', error: (trans.error_parent_excluded || 'Cannot include "{name}" because its parent "{parent}" is excluded.').replace('{name}', name).replace('{parent}', ancestorName) }; } } // Check if any descendant is excluded for (var j = 0; j < descendantIds.length; j++) { if (excludeIds.indexOf(descendantIds[j]) !== -1) { var descendantName = lookup[descendantIds[j]] ? lookup[descendantIds[j]].name : 'Child'; return { valid: false, type: 'child_excluded', error: (trans.error_child_excluded || 'Cannot include "{name}" because its child "{child}" is excluded. Remove the child from exclude first.').replace('{name}', name).replace('{child}', descendantName) }; } } } if (section === 'exclude') { // Check if any ancestor is included for (var k = 0; k < ancestorIds.length; k++) { if (includeIds.indexOf(ancestorIds[k]) !== -1) { var parentName = lookup[ancestorIds[k]] ? lookup[ancestorIds[k]].name : 'Parent'; return { valid: false, type: 'parent_included', error: (trans.error_parent_included || 'Cannot exclude "{name}" because its parent "{parent}" is included. This would create a contradiction.').replace('{name}', name).replace('{parent}', parentName) }; } } // Check if any descendant is included (warning about implicit exclusion) var includedDescendants = []; for (var m = 0; m < descendantIds.length; m++) { if (includeIds.indexOf(descendantIds[m]) !== -1) { var childName = lookup[descendantIds[m]] ? lookup[descendantIds[m]].name : 'Child'; includedDescendants.push(childName); } } if (includedDescendants.length > 0) { return { valid: false, type: 'children_included', error: (trans.error_children_included || 'Cannot exclude "{name}" because its children ({children}) are included. Remove them from include first.').replace('{name}', name).replace('{children}', includedDescendants.slice(0, 3).join(', ') + (includedDescendants.length > 3 ? '...' : '')) }; } } return { valid: true }; }, /** * Get all ancestor IDs for a given item */ getAncestorIds: function(id, lookup) { var ancestors = []; var current = lookup[id]; while (current && current.parent_id) { var parentId = parseInt(current.parent_id, 10); if (parentId && lookup[parentId]) { ancestors.push(parentId); current = lookup[parentId]; } else { break; } } return ancestors; }, /** * Get all descendant IDs for a given item */ getDescendantIds: function(id, lookup) { var descendants = []; var self = this; // Find direct children Object.keys(lookup).forEach(function(key) { var item = lookup[key]; if (parseInt(item.parent_id, 10) === id) { var childId = parseInt(item.id, 10); descendants.push(childId); // Recursively get children's descendants var childDescendants = self.getDescendantIds(childId, lookup); descendants = descendants.concat(childDescendants); } }); return descendants; }, /** * Get chip IDs from a picker */ getChipIds: function($picker) { var ids = []; $picker.find('.entity-chip').each(function() { ids.push(parseInt($(this).data('id'), 10)); }); return ids; }, /** * Validate pending selections (for tree view bulk operations) * Returns array of invalid items */ validatePendingSelections: function(pendingSelections, section) { var self = this; var errors = []; if (!pendingSelections || !pendingSelections.length) { return errors; } pendingSelections.forEach(function(sel) { var result = self.validateSelection(sel.id, sel.name, section, sel.data || {}); if (!result.valid) { errors.push({ id: sel.id, name: sel.name, error: result.error, type: result.type }); } }); return errors; }, /** * Show validation error toast */ showValidationError: function(message) { var trans = this.config.trans || {}; var title = trans.validation_error || 'Selection Conflict'; // Remove existing toast $('.es-validation-toast').remove(); // Create toast HTML var html = '
'; html += '
'; html += '
'; html += '
' + this.escapeHtml(title) + '
'; html += '
' + this.escapeHtml(message) + '
'; html += '
'; html += ''; html += '
'; var $toast = $(html); $('body').append($toast); // Position near dropdown if visible if (this.$dropdown && this.$dropdown.hasClass('show')) { var dropdownOffset = this.$dropdown.offset(); $toast.css({ position: 'fixed', top: dropdownOffset.top - $toast.outerHeight() - 10, left: dropdownOffset.left, zIndex: 10001 }); } else { $toast.css({ position: 'fixed', top: 20, right: 20, zIndex: 10001 }); } // Animate in $toast.hide().fadeIn(200); // Auto-dismiss after 5 seconds setTimeout(function() { $toast.fadeOut(200, function() { $(this).remove(); }); }, 5000); // Close button $toast.on('click', '.es-toast-close', function() { $toast.fadeOut(200, function() { $(this).remove(); }); }); }, /** * Validate and add selection (wrapper that validates before adding) * Returns true if added successfully, false if validation failed */ validateAndAddSelection: function($picker, id, name, data, section) { var result = this.validateSelection(id, name, section, data || {}); if (!result.valid) { this.showValidationError(result.error); return false; } // Validation passed, add the selection this.addSelection($picker, id, name, data); return true; } }; })(jQuery);