Forked from prestashop-target-conditions Renamed all references from target-conditions to entity-selector
1150 lines
46 KiB
JavaScript
Executable File
1150 lines
46 KiB
JavaScript
Executable File
/**
|
|
* Target Conditions List Preview
|
|
*
|
|
* Handles showing item previews in admin list views when clicking on target count badges.
|
|
* Uses the TargetConditions trait's AJAX endpoint to load preview data.
|
|
*
|
|
* Note: Uses native event capturing to intercept clicks before PrestaShop's
|
|
* inline onclick handlers on parent td elements can navigate away.
|
|
*/
|
|
|
|
(function($) {
|
|
'use strict';
|
|
|
|
var TargetListPreview = {
|
|
$popover: null,
|
|
$backdrop: null,
|
|
currentTrigger: null,
|
|
currentConditions: null,
|
|
ajaxUrl: '',
|
|
trans: {},
|
|
currentEntityType: null,
|
|
filterQuery: '',
|
|
filterTimeout: null,
|
|
loadedData: {},
|
|
itemsPerPage: 20,
|
|
|
|
init: function(config) {
|
|
this.ajaxUrl = config.ajaxUrl || '';
|
|
this.trans = config.trans || {};
|
|
this.createPopover();
|
|
this.bindEvents();
|
|
this.loadBadgeCounts();
|
|
},
|
|
|
|
loadBadgeCounts: function() {
|
|
var self = this;
|
|
var $triggers = $('.target-preview-trigger');
|
|
|
|
if ($triggers.length === 0) {
|
|
return;
|
|
}
|
|
|
|
$triggers.each(function() {
|
|
var $trigger = $(this);
|
|
var conditions = $trigger.data('conditions');
|
|
|
|
if (!conditions) {
|
|
return;
|
|
}
|
|
|
|
if (typeof conditions === 'string') {
|
|
try {
|
|
conditions = JSON.parse(conditions);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check for summary badge (more than 3 entity types)
|
|
var $summaryBadge = $trigger.find('.badge[data-entity-type="summary"]');
|
|
if ($summaryBadge.length > 0) {
|
|
self.fetchSummaryCount($trigger, conditions, $summaryBadge);
|
|
return;
|
|
}
|
|
|
|
// Find entity types with groups
|
|
for (var entityType in conditions) {
|
|
if (conditions[entityType] && conditions[entityType].groups && conditions[entityType].groups.length > 0) {
|
|
self.fetchEntityCount($trigger, entityType, conditions[entityType]);
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
fetchSummaryCount: function($trigger, conditions, $badge) {
|
|
var self = this;
|
|
var entityTypes = ($badge.data('entity-types') || '').split(',').filter(function(t) { return t; });
|
|
var pendingCount = entityTypes.length;
|
|
var totalSum = 0;
|
|
var hasAll = $badge.data('has-all') === 1 || $badge.data('has-all') === '1';
|
|
|
|
if (pendingCount === 0) {
|
|
return;
|
|
}
|
|
|
|
entityTypes.forEach(function(entityType) {
|
|
var conditionsData = conditions[entityType];
|
|
if (!conditionsData || !conditionsData.groups || conditionsData.groups.length === 0) {
|
|
pendingCount--;
|
|
if (pendingCount === 0) {
|
|
self.updateSummaryBadge($badge, totalSum, hasAll);
|
|
}
|
|
return;
|
|
}
|
|
|
|
$.ajax({
|
|
url: self.ajaxUrl,
|
|
type: 'POST',
|
|
dataType: 'json',
|
|
data: {
|
|
ajax: 1,
|
|
action: 'previewTargetConditions',
|
|
trait: 'TargetConditions',
|
|
block_type: entityType,
|
|
conditions: JSON.stringify(conditionsData),
|
|
limit: 0
|
|
}
|
|
}).done(function(response) {
|
|
if (response.success && typeof response.total !== 'undefined') {
|
|
totalSum += response.total;
|
|
}
|
|
}).always(function() {
|
|
pendingCount--;
|
|
if (pendingCount === 0) {
|
|
self.updateSummaryBadge($badge, totalSum, hasAll);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
updateSummaryBadge: function($badge, total, hasAll) {
|
|
var displayText;
|
|
if (hasAll) {
|
|
displayText = (this.trans.multiple || 'Multiple') + ' (' + total + ')';
|
|
} else {
|
|
displayText = total + ' ' + (this.trans.items || 'items');
|
|
}
|
|
|
|
$badge.empty()
|
|
.append($('<i>', { class: 'icon-th-list' }))
|
|
.append(' ' + displayText);
|
|
|
|
$badge.attr('title', displayText);
|
|
|
|
if (total === 0) {
|
|
$badge.removeClass('badge-info badge-success badge-warning').addClass('badge-danger');
|
|
}
|
|
},
|
|
|
|
fetchEntityCount: function($trigger, entityType, conditionsData) {
|
|
var self = this;
|
|
|
|
// Skip AJAX if no badge exists for this entity type
|
|
var $badge = $trigger.find('.badge[data-entity-type="' + entityType + '"]');
|
|
if ($badge.length === 0) {
|
|
return;
|
|
}
|
|
|
|
$.ajax({
|
|
url: this.ajaxUrl,
|
|
type: 'POST',
|
|
dataType: 'json',
|
|
data: {
|
|
ajax: 1,
|
|
action: 'previewTargetConditions',
|
|
trait: 'TargetConditions',
|
|
block_type: entityType,
|
|
conditions: JSON.stringify(conditionsData),
|
|
limit: 0
|
|
}
|
|
}).done(function(response) {
|
|
if (response.success && typeof response.total !== 'undefined') {
|
|
self.updateBadgeCount($trigger, entityType, response.total);
|
|
}
|
|
});
|
|
},
|
|
|
|
updateBadgeCount: function($trigger, entityType, total) {
|
|
// Find badge for this specific entity type only - no fallback
|
|
var $badge = $trigger.find('.badge[data-entity-type="' + entityType + '"]');
|
|
if ($badge.length === 0) {
|
|
return;
|
|
}
|
|
|
|
var hasAll = $badge.data('has-all') === 1 || $badge.data('has-all') === '1';
|
|
var excludes = parseInt($badge.data('excludes') || 0, 10);
|
|
|
|
// Entity icon mapping
|
|
var iconMap = {
|
|
'products': 'icon-cube',
|
|
'categories': 'icon-folder-open',
|
|
'manufacturers': 'icon-building',
|
|
'suppliers': 'icon-truck',
|
|
'cms': 'icon-file-text',
|
|
'cms_categories': 'icon-folder'
|
|
};
|
|
|
|
// Get entity icon (first <i> that's not the spinner)
|
|
var $existingIcon = $badge.find('i').not('.icon-spinner').first();
|
|
var iconClass = $existingIcon.length ? $existingIcon.attr('class') : (iconMap[entityType] || 'icon-list');
|
|
var $icon = $('<i>', { class: iconClass });
|
|
|
|
// Build display text
|
|
var displayText;
|
|
var entityLabels = this.trans || {};
|
|
var singular = entityLabels[entityType + '_singular'] || entityType.replace(/_/g, ' ').replace(/s$/, '');
|
|
var plural = entityLabels[entityType + '_plural'] || entityType.replace(/_/g, ' ');
|
|
|
|
if (hasAll) {
|
|
// "All products (49)" or "All products"
|
|
var allLabel = entityLabels[entityType + '_all'] || ('All ' + plural);
|
|
displayText = allLabel + ' (' + total + ')';
|
|
} else {
|
|
// "5 products"
|
|
displayText = total + ' ' + (total === 1 ? singular : plural);
|
|
}
|
|
|
|
// Update badge content
|
|
$badge.empty().append($icon).append(' ' + displayText);
|
|
|
|
// Update tooltip
|
|
var tooltip = displayText;
|
|
if (excludes > 0) {
|
|
tooltip += ' (with ' + excludes + ' exclusion' + (excludes === 1 ? '' : 's') + ')';
|
|
}
|
|
$badge.attr('title', tooltip);
|
|
|
|
// Update color based on count
|
|
if (total === 0) {
|
|
$badge.removeClass('badge-info badge-success badge-warning').addClass('badge-danger');
|
|
}
|
|
},
|
|
|
|
createPopover: function() {
|
|
this.$popover = $('<div>', {
|
|
class: 'target-list-preview-popover',
|
|
css: { display: 'none' }
|
|
}).appendTo('body');
|
|
|
|
this.$backdrop = $('<div>', {
|
|
class: 'target-list-preview-backdrop',
|
|
css: { display: 'none' }
|
|
}).appendTo('body');
|
|
|
|
this.initDragAndResize();
|
|
},
|
|
|
|
setPopoverContent: function(html) {
|
|
this.$popover.html(html + '<div class="popover-resize-handle"></div>');
|
|
},
|
|
|
|
initDragAndResize: function() {
|
|
var self = this;
|
|
var isDragging = false;
|
|
var isResizing = false;
|
|
var startX, startY, startLeft, startTop, startWidth, startHeight;
|
|
|
|
// Drag by header
|
|
this.$popover.on('mousedown', '.preview-header', function(e) {
|
|
if ($(e.target).closest('.preview-close').length) return;
|
|
|
|
isDragging = true;
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
startLeft = parseInt(self.$popover.css('left'), 10) || 0;
|
|
startTop = parseInt(self.$popover.css('top'), 10) || 0;
|
|
|
|
self.$popover.addClass('dragging');
|
|
e.preventDefault();
|
|
});
|
|
|
|
// Resize by handle
|
|
this.$popover.on('mousedown', '.popover-resize-handle', function(e) {
|
|
isResizing = true;
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
startWidth = self.$popover.outerWidth();
|
|
startHeight = self.$popover.outerHeight();
|
|
|
|
self.$popover.addClass('resizing');
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
});
|
|
|
|
$(document).on('mousemove', function(e) {
|
|
if (isDragging) {
|
|
var dx = e.clientX - startX;
|
|
var dy = e.clientY - startY;
|
|
var newLeft = startLeft + dx;
|
|
var newTop = startTop + dy;
|
|
|
|
// Keep within viewport
|
|
var maxLeft = $(window).width() - self.$popover.outerWidth() - 10;
|
|
var maxTop = $(window).height() - 50;
|
|
newLeft = Math.max(10, Math.min(newLeft, maxLeft));
|
|
newTop = Math.max(10, Math.min(newTop, maxTop));
|
|
|
|
self.$popover.css({ left: newLeft, top: newTop });
|
|
}
|
|
|
|
if (isResizing) {
|
|
var dx = e.clientX - startX;
|
|
var dy = e.clientY - startY;
|
|
var newWidth = Math.max(300, Math.min(startWidth + dx, $(window).width() - 40));
|
|
var newHeight = Math.max(200, Math.min(startHeight + dy, $(window).height() - 40));
|
|
|
|
self.$popover.css({
|
|
width: newWidth,
|
|
height: newHeight
|
|
});
|
|
}
|
|
});
|
|
|
|
$(document).on('mouseup', function() {
|
|
if (isDragging) {
|
|
isDragging = false;
|
|
self.$popover.removeClass('dragging');
|
|
}
|
|
if (isResizing) {
|
|
isResizing = false;
|
|
self.$popover.removeClass('resizing');
|
|
}
|
|
});
|
|
},
|
|
|
|
bindEvents: function() {
|
|
var self = this;
|
|
|
|
// Use native event listener with capture phase to intercept BEFORE
|
|
// the inline onclick on parent td elements can fire
|
|
document.addEventListener('click', function(e) {
|
|
var trigger = e.target.closest('.target-preview-trigger');
|
|
if (trigger) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
|
|
// Also remove inline onclick from parent td to prevent navigation
|
|
var td = trigger.closest('td');
|
|
if (td && td.onclick) {
|
|
td._originalOnclick = td.onclick;
|
|
td.onclick = null;
|
|
// Restore after a tick
|
|
setTimeout(function() {
|
|
if (td._originalOnclick) {
|
|
td.onclick = td._originalOnclick;
|
|
delete td._originalOnclick;
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
self.showPreview($(trigger));
|
|
return false;
|
|
}
|
|
}, true); // true = capture phase
|
|
|
|
// Click backdrop to close
|
|
this.$backdrop.on('click', function() {
|
|
self.hidePreview();
|
|
});
|
|
|
|
// ESC to close
|
|
$(document).on('keydown', function(e) {
|
|
if (e.key === 'Escape' && self.$popover.is(':visible')) {
|
|
self.hidePreview();
|
|
}
|
|
});
|
|
|
|
// Close button
|
|
this.$popover.on('click', '.preview-close', function() {
|
|
self.hidePreview();
|
|
});
|
|
|
|
// Tab switching
|
|
this.$popover.on('click', '.preview-tab', function() {
|
|
var $tab = $(this);
|
|
var entityType = $tab.data('entity');
|
|
|
|
self.$popover.find('.preview-tab').removeClass('active');
|
|
$tab.addClass('active');
|
|
|
|
self.$popover.find('.preview-content').removeClass('active');
|
|
self.$popover.find('.preview-content[data-entity="' + entityType + '"]').addClass('active');
|
|
|
|
self.currentEntityType = entityType;
|
|
self.filterQuery = '';
|
|
self.$popover.find('.preview-filter-input').val('');
|
|
});
|
|
|
|
// Filter input with debounce
|
|
this.$popover.on('input', '.preview-filter-input', function() {
|
|
var query = $(this).val().trim().toLowerCase();
|
|
clearTimeout(self.filterTimeout);
|
|
self.filterTimeout = setTimeout(function() {
|
|
self.filterQuery = query;
|
|
self.applyFilter();
|
|
}, 150);
|
|
});
|
|
|
|
// Load more button click
|
|
this.$popover.on('click', '.btn-load-more', function() {
|
|
var $content = $(this).closest('.preview-content');
|
|
var entityType = $content.data('entity');
|
|
var limit = parseInt($content.find('.load-more-select').val(), 10);
|
|
self.loadMoreItems(entityType, limit);
|
|
});
|
|
|
|
// Rule badge click - show entity names
|
|
this.$popover.on('click', '.preview-rule', function(e) {
|
|
e.stopPropagation();
|
|
var $rule = $(this);
|
|
|
|
// If clicking on same rule with open popup, close it
|
|
if (self.$currentRuleElement && self.$currentRuleElement.is($rule)) {
|
|
$('.rule-detail-popup').remove();
|
|
self.$currentRuleElement = null;
|
|
self.$currentRulePopup = null;
|
|
return;
|
|
}
|
|
|
|
self.showRuleDetails($rule);
|
|
});
|
|
|
|
// Close rule popup when clicking elsewhere
|
|
$(document).on('click', function(e) {
|
|
if (!$(e.target).closest('.preview-rule').length && !$(e.target).closest('.rule-detail-popup').length) {
|
|
$('.rule-detail-popup').remove();
|
|
self.$currentRuleElement = null;
|
|
self.$currentRulePopup = null;
|
|
}
|
|
});
|
|
},
|
|
|
|
showRuleDetails: function($rule) {
|
|
var self = this;
|
|
var entityType = $rule.data('entity-type');
|
|
var method = $rule.data('method');
|
|
var values = $rule.data('values');
|
|
|
|
// Methods that don't have entity IDs to resolve
|
|
var nonEntityMethods = ['all', 'by_price_range', 'by_quantity_range', 'by_condition',
|
|
'by_visibility', 'by_active_status', 'by_stock_status', 'by_on_sale', 'by_is_virtual',
|
|
'by_is_pack', 'by_has_combinations', 'by_name_pattern', 'by_reference_pattern',
|
|
'by_date_added', 'by_date_updated', 'by_weight_range', 'by_depth', 'by_active',
|
|
'by_newsletter', 'by_optin'];
|
|
|
|
if (nonEntityMethods.indexOf(method) !== -1 || !entityType || !values || values.length === 0) {
|
|
// Show simple info popup with properly formatted values
|
|
var valuesDisplay = this.formatValuesForDisplay(values);
|
|
this.showRulePopup($rule, '<div class="rule-detail-info">' +
|
|
'<strong>' + this.humanizeMethod(method) + '</strong>' +
|
|
(valuesDisplay ? '<br><small>' + valuesDisplay + '</small>' : '') +
|
|
'</div>');
|
|
return;
|
|
}
|
|
|
|
// Show loading state
|
|
this.showRulePopup($rule, '<div class="rule-detail-loading"><i class="icon-spinner icon-spin"></i> Loading...</div>');
|
|
|
|
// Fetch entity names
|
|
$.ajax({
|
|
url: this.ajaxUrl,
|
|
type: 'POST',
|
|
dataType: 'json',
|
|
data: {
|
|
ajax: 1,
|
|
action: 'getTargetEntitiesByIds',
|
|
trait: 'TargetConditions',
|
|
entity_type: entityType,
|
|
ids: JSON.stringify(values)
|
|
}
|
|
}).done(function(response) {
|
|
if (response.success && response.entities) {
|
|
var icon = self.getEntityIcon(entityType);
|
|
var html = '<div class="rule-detail-list">';
|
|
response.entities.forEach(function(entity) {
|
|
html += '<div class="rule-detail-item">';
|
|
if (entity.image) {
|
|
html += '<img src="' + entity.image + '" class="rule-detail-img">';
|
|
} else {
|
|
html += '<span class="rule-detail-icon"><i class="' + icon + '"></i></span>';
|
|
}
|
|
html += '<span>' + self.escapeHtml(entity.name || entity.title || 'ID: ' + entity.id) + '</span>';
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
self.updateRulePopup(html);
|
|
} else {
|
|
self.updateRulePopup('<div class="rule-detail-error">Failed to load names</div>');
|
|
}
|
|
}).fail(function() {
|
|
self.updateRulePopup('<div class="rule-detail-error">Failed to load names</div>');
|
|
});
|
|
},
|
|
|
|
formatValuesForDisplay: function(values) {
|
|
if (!values || values.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
var formatted = [];
|
|
for (var i = 0; i < values.length && i < 10; i++) {
|
|
var val = values[i];
|
|
if (typeof val === 'object' && val !== null) {
|
|
// Handle object values (like {min: x, max: y} for ranges)
|
|
if (val.min !== undefined || val.max !== undefined) {
|
|
formatted.push((val.min || '0') + ' - ' + (val.max || '∞'));
|
|
} else if (val.pattern !== undefined) {
|
|
formatted.push('"' + val.pattern + '"');
|
|
} else if (val.from !== undefined || val.to !== undefined) {
|
|
formatted.push((val.from || 'start') + ' → ' + (val.to || 'now'));
|
|
} else {
|
|
// Generic object - show key: value pairs
|
|
var parts = [];
|
|
for (var key in val) {
|
|
if (val.hasOwnProperty(key)) {
|
|
parts.push(key + ': ' + val[key]);
|
|
}
|
|
}
|
|
formatted.push(parts.join(', '));
|
|
}
|
|
} else {
|
|
formatted.push(String(val));
|
|
}
|
|
}
|
|
|
|
var result = formatted.join(', ');
|
|
if (values.length > 10) {
|
|
result += ' (+' + (values.length - 10) + ' more)';
|
|
}
|
|
return result;
|
|
},
|
|
|
|
showRulePopup: function($rule, content) {
|
|
// Remove any existing popup
|
|
$('.rule-detail-popup').remove();
|
|
|
|
// Create popup and append to body for proper positioning
|
|
var $popup = $('<div>', { class: 'rule-detail-popup' }).html(content);
|
|
$('body').append($popup);
|
|
|
|
// Position below the rule badge
|
|
var offset = $rule.offset();
|
|
var ruleHeight = $rule.outerHeight();
|
|
var popupWidth = $popup.outerWidth();
|
|
var windowWidth = $(window).width();
|
|
|
|
var left = offset.left;
|
|
var top = offset.top + ruleHeight + 4;
|
|
|
|
// Keep popup within viewport
|
|
if (left + popupWidth > windowWidth - 10) {
|
|
left = windowWidth - popupWidth - 10;
|
|
}
|
|
if (left < 10) {
|
|
left = 10;
|
|
}
|
|
|
|
$popup.css({
|
|
position: 'fixed',
|
|
top: top - $(window).scrollTop(),
|
|
left: left
|
|
});
|
|
|
|
this.$currentRulePopup = $popup;
|
|
this.$currentRuleElement = $rule;
|
|
},
|
|
|
|
updateRulePopup: function(content) {
|
|
if (this.$currentRulePopup) {
|
|
this.$currentRulePopup.html(content);
|
|
}
|
|
},
|
|
|
|
showPreview: function($trigger) {
|
|
var self = this;
|
|
var conditions = $trigger.data('conditions');
|
|
|
|
if (!conditions) {
|
|
return;
|
|
}
|
|
|
|
if (typeof conditions === 'string') {
|
|
try {
|
|
conditions = JSON.parse(conditions);
|
|
} catch (e) {
|
|
console.error('[TargetListPreview] Failed to parse conditions:', e);
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.currentTrigger = $trigger;
|
|
this.currentConditions = conditions;
|
|
this.loadedData = {};
|
|
this.filterQuery = '';
|
|
|
|
// Show loading state
|
|
this.setPopoverContent(this.renderLoading());
|
|
this.$popover.show();
|
|
this.$backdrop.show();
|
|
|
|
// Position popover
|
|
this.positionPopover();
|
|
|
|
// Load preview data
|
|
this.loadPreview(conditions);
|
|
},
|
|
|
|
hidePreview: function() {
|
|
this.$popover.hide();
|
|
this.$backdrop.hide();
|
|
this.currentTrigger = null;
|
|
this.currentConditions = null;
|
|
this.currentEntityType = null;
|
|
// Clean up any open rule popups
|
|
$('.rule-detail-popup').remove();
|
|
this.$currentRuleElement = null;
|
|
this.$currentRulePopup = null;
|
|
},
|
|
|
|
positionPopover: function() {
|
|
if (!this.currentTrigger) return;
|
|
|
|
var $trigger = this.currentTrigger;
|
|
var offset = $trigger.offset();
|
|
var triggerWidth = $trigger.outerWidth();
|
|
var triggerHeight = $trigger.outerHeight();
|
|
var windowWidth = $(window).width();
|
|
var windowHeight = $(window).height();
|
|
var scrollTop = $(window).scrollTop();
|
|
var scrollLeft = $(window).scrollLeft();
|
|
|
|
var popoverWidth = this.$popover.outerWidth() || 380;
|
|
var popoverHeight = this.$popover.outerHeight() || 500;
|
|
|
|
// Convert document offset to viewport position (for position: fixed)
|
|
var triggerViewportLeft = offset.left - scrollLeft;
|
|
var triggerViewportTop = offset.top - scrollTop;
|
|
|
|
// Center popover below trigger
|
|
var left = triggerViewportLeft + (triggerWidth / 2) - (popoverWidth / 2);
|
|
var top = triggerViewportTop + triggerHeight + 8;
|
|
|
|
// Keep within viewport bounds
|
|
if (left < 10) left = 10;
|
|
if (left + popoverWidth > windowWidth - 10) {
|
|
left = windowWidth - popoverWidth - 10;
|
|
}
|
|
|
|
// Calculate space available above and below
|
|
var spaceBelow = windowHeight - (triggerViewportTop + triggerHeight + 8);
|
|
var spaceAbove = triggerViewportTop - 8;
|
|
|
|
// Only flip to above if:
|
|
// 1. Not enough space below for popover AND
|
|
// 2. More space above than below
|
|
if (spaceBelow < popoverHeight && spaceAbove > spaceBelow) {
|
|
top = triggerViewportTop - popoverHeight - 8;
|
|
// Ensure not above viewport
|
|
if (top < 10) top = 10;
|
|
} else {
|
|
// Show below, constrain to viewport if needed
|
|
if (top + popoverHeight > windowHeight - 10) {
|
|
top = windowHeight - popoverHeight - 10;
|
|
}
|
|
}
|
|
|
|
this.$popover.css({ left: left, top: top });
|
|
},
|
|
|
|
loadPreview: function(conditions) {
|
|
var self = this;
|
|
|
|
var entityTypes = [];
|
|
for (var type in conditions) {
|
|
if (conditions[type] && conditions[type].groups && conditions[type].groups.length > 0) {
|
|
entityTypes.push(type);
|
|
}
|
|
}
|
|
|
|
if (entityTypes.length === 0) {
|
|
this.setPopoverContent(this.renderEmpty());
|
|
return;
|
|
}
|
|
|
|
var promises = [];
|
|
var results = {};
|
|
|
|
entityTypes.forEach(function(entityType) {
|
|
var promise = $.ajax({
|
|
url: self.ajaxUrl,
|
|
type: 'POST',
|
|
dataType: 'json',
|
|
data: {
|
|
ajax: 1,
|
|
action: 'previewTargetConditions',
|
|
trait: 'TargetConditions',
|
|
block_type: entityType,
|
|
conditions: JSON.stringify(conditions[entityType]),
|
|
limit: self.itemsPerPage
|
|
}
|
|
}).then(function(response) {
|
|
results[entityType] = response;
|
|
self.loadedData[entityType] = {
|
|
items: response.items || [],
|
|
total: response.total || 0,
|
|
offset: response.items ? response.items.length : 0
|
|
};
|
|
});
|
|
|
|
promises.push(promise);
|
|
});
|
|
|
|
$.when.apply($, promises).then(function() {
|
|
self.currentEntityType = entityTypes[0];
|
|
self.setPopoverContent(self.renderPreview(entityTypes, results));
|
|
self.positionPopover();
|
|
}).fail(function() {
|
|
self.setPopoverContent(self.renderError());
|
|
});
|
|
},
|
|
|
|
loadMoreItems: function(entityType, limit) {
|
|
var self = this;
|
|
var data = this.loadedData[entityType];
|
|
|
|
if (!data || data.offset >= data.total) return;
|
|
|
|
var requestLimit = limit || this.itemsPerPage;
|
|
var $content = this.$popover.find('.preview-content[data-entity="' + entityType + '"]');
|
|
var $loadMore = $content.find('.preview-footer');
|
|
|
|
$loadMore.html('<span class="loading-text"><i class="icon-spinner icon-spin"></i> ' + (this.trans.loading || 'Loading...') + '</span>');
|
|
|
|
$.ajax({
|
|
url: this.ajaxUrl,
|
|
type: 'POST',
|
|
dataType: 'json',
|
|
data: {
|
|
ajax: 1,
|
|
action: 'previewTargetConditions',
|
|
trait: 'TargetConditions',
|
|
block_type: entityType,
|
|
conditions: JSON.stringify(this.currentConditions[entityType]),
|
|
limit: requestLimit,
|
|
offset: data.offset
|
|
}
|
|
}).done(function(response) {
|
|
if (response.success && response.items) {
|
|
data.items = data.items.concat(response.items);
|
|
data.offset += response.items.length;
|
|
|
|
var $list = $content.find('.preview-items');
|
|
response.items.forEach(function(item) {
|
|
$list.append(self.renderItem(item));
|
|
});
|
|
|
|
self.updateFooter(entityType);
|
|
self.applyFilter();
|
|
}
|
|
}).fail(function() {
|
|
$loadMore.html('<span class="error-text"><i class="icon-warning"></i> ' + (self.trans.error || 'Failed to load') + '</span>');
|
|
});
|
|
},
|
|
|
|
applyFilter: function() {
|
|
var query = this.filterQuery;
|
|
var $content = this.$popover.find('.preview-content.active');
|
|
var $items = $content.find('.preview-item');
|
|
|
|
if (!query) {
|
|
$items.show();
|
|
return;
|
|
}
|
|
|
|
$items.each(function() {
|
|
var $item = $(this);
|
|
var name = ($item.find('.preview-item-name').text() || '').toLowerCase();
|
|
var ref = ($item.find('.preview-item-ref').text() || '').toLowerCase();
|
|
|
|
if (name.indexOf(query) !== -1 || ref.indexOf(query) !== -1) {
|
|
$item.show();
|
|
} else {
|
|
$item.hide();
|
|
}
|
|
});
|
|
},
|
|
|
|
updateFooter: function(entityType) {
|
|
var data = this.loadedData[entityType];
|
|
var $content = this.$popover.find('.preview-content[data-entity="' + entityType + '"]');
|
|
var $footer = $content.find('.preview-footer');
|
|
|
|
if (data.offset >= data.total) {
|
|
$footer.remove();
|
|
} else {
|
|
var remaining = data.total - data.offset;
|
|
var html = '<div class="load-more-controls">';
|
|
html += '<span class="load-more-label">Load</span>';
|
|
html += '<select class="load-more-select">';
|
|
if (remaining >= 10) html += '<option value="10">10</option>';
|
|
if (remaining >= 20) html += '<option value="20">20</option>';
|
|
if (remaining >= 50) html += '<option value="50">50</option>';
|
|
if (remaining >= 100) html += '<option value="100">100</option>';
|
|
html += '<option value="' + remaining + '" data-all="true">All (' + remaining + ')</option>';
|
|
html += '</select>';
|
|
html += '<span class="load-more-of">of <span class="remaining-count">' + remaining + '</span> remaining</span>';
|
|
html += '<button type="button" class="btn-load-more"><i class="icon-plus"></i></button>';
|
|
html += '</div>';
|
|
$footer.html(html);
|
|
}
|
|
},
|
|
|
|
renderLoading: function() {
|
|
return '<div class="preview-loading">' +
|
|
'<i class="icon-spinner icon-spin"></i> ' +
|
|
(this.trans.loading || 'Loading...') +
|
|
'</div>';
|
|
},
|
|
|
|
renderEmpty: function() {
|
|
return '<div class="preview-header">' +
|
|
'<span class="preview-title">' + (this.trans.items_preview || 'Items Preview') + '</span>' +
|
|
'<button type="button" class="preview-close"><i class="icon-times"></i></button>' +
|
|
'</div>' +
|
|
'<div class="preview-empty">' +
|
|
'<i class="icon-info-circle"></i> ' +
|
|
(this.trans.no_items || 'No items selected') +
|
|
'</div>';
|
|
},
|
|
|
|
renderError: function() {
|
|
return '<div class="preview-header">' +
|
|
'<span class="preview-title">' + (this.trans.items_preview || 'Items Preview') + '</span>' +
|
|
'<button type="button" class="preview-close"><i class="icon-times"></i></button>' +
|
|
'</div>' +
|
|
'<div class="preview-error">' +
|
|
'<i class="icon-warning"></i> ' +
|
|
(this.trans.error || 'Failed to load preview') +
|
|
'</div>';
|
|
},
|
|
|
|
renderPreview: function(entityTypes, results) {
|
|
var self = this;
|
|
var html = '<div class="preview-header">';
|
|
html += '<span class="preview-title">' + (this.trans.items_preview || 'Items Preview') + '</span>';
|
|
html += '<button type="button" class="preview-close"><i class="icon-times"></i></button>';
|
|
html += '</div>';
|
|
|
|
// Tabs if multiple entity types
|
|
if (entityTypes.length > 1) {
|
|
html += '<div class="preview-tabs">';
|
|
entityTypes.forEach(function(type, index) {
|
|
var result = results[type];
|
|
var count = result && result.total ? result.total : 0;
|
|
var icon = self.getEntityIcon(type);
|
|
var label = self.getEntityLabel(type);
|
|
var activeClass = index === 0 ? ' active' : '';
|
|
html += '<button type="button" class="preview-tab' + activeClass + '" data-entity="' + type + '" title="' + label + '">';
|
|
html += '<i class="' + icon + '"></i> ' + count;
|
|
html += '</button>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
|
|
// Filter input
|
|
html += '<div class="preview-filter">';
|
|
html += '<i class="icon-search"></i>';
|
|
html += '<input type="text" class="preview-filter-input" placeholder="' + (this.trans.filter || 'Filter...') + '">';
|
|
html += '</div>';
|
|
|
|
// Content for each entity type
|
|
html += '<div class="preview-contents">';
|
|
entityTypes.forEach(function(type, index) {
|
|
var activeClass = index === 0 ? ' active' : '';
|
|
html += '<div class="preview-content' + activeClass + '" data-entity="' + type + '">';
|
|
html += self.renderEntityPreview(type, results[type]);
|
|
html += '</div>';
|
|
});
|
|
html += '</div>';
|
|
|
|
return html;
|
|
},
|
|
|
|
renderEntityPreview: function(entityType, result) {
|
|
if (!result || !result.success) {
|
|
return '<div class="preview-error-inline"><i class="icon-warning"></i> ' + (this.trans.error || 'Failed to load') + '</div>';
|
|
}
|
|
|
|
var items = result.items || [];
|
|
var total = result.total || 0;
|
|
var self = this;
|
|
var html = '';
|
|
|
|
// Render rules summary if conditions are available
|
|
var conditions = this.currentConditions && this.currentConditions[entityType];
|
|
if (conditions && conditions.groups && conditions.groups.length > 0) {
|
|
html += this.renderRulesSummary(entityType, conditions.groups);
|
|
}
|
|
|
|
if (items.length === 0) {
|
|
return html + '<div class="preview-empty-inline"><i class="icon-info-circle"></i> ' + (this.trans.no_items || 'No items') + '</div>';
|
|
}
|
|
|
|
html += '<div class="preview-items">';
|
|
|
|
items.forEach(function(item) {
|
|
html += self.renderItem(item);
|
|
});
|
|
|
|
html += '</div>';
|
|
|
|
if (total > items.length) {
|
|
var remaining = total - items.length;
|
|
html += '<div class="preview-footer">';
|
|
html += '<div class="load-more-controls">';
|
|
html += '<span class="load-more-label">Load</span>';
|
|
html += '<select class="load-more-select">';
|
|
if (remaining >= 10) html += '<option value="10">10</option>';
|
|
if (remaining >= 20) html += '<option value="20">20</option>';
|
|
if (remaining >= 50) html += '<option value="50">50</option>';
|
|
if (remaining >= 100) html += '<option value="100">100</option>';
|
|
html += '<option value="' + remaining + '" data-all="true">All (' + remaining + ')</option>';
|
|
html += '</select>';
|
|
html += '<span class="load-more-of">of <span class="remaining-count">' + remaining + '</span> remaining</span>';
|
|
html += '<button type="button" class="btn-load-more"><i class="icon-plus"></i></button>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
}
|
|
|
|
return html;
|
|
},
|
|
|
|
renderRulesSummary: function(entityType, groups) {
|
|
var self = this;
|
|
var html = '<div class="preview-rules">';
|
|
|
|
groups.forEach(function(group, index) {
|
|
var include = group.include || {};
|
|
var excludes = group.excludes || [];
|
|
var method = include.method || 'unknown';
|
|
var values = include.values || [];
|
|
|
|
html += '<div class="preview-rule-group">';
|
|
|
|
// Group label
|
|
if (groups.length > 1) {
|
|
html += '<span class="preview-group-label">Group ' + (index + 1) + '</span>';
|
|
}
|
|
|
|
// Include rule - clickable to show details
|
|
var entityTypeForMethod = self.getEntityTypeForMethod(entityType, method);
|
|
html += '<div class="preview-rule include" ';
|
|
html += 'data-method="' + self.escapeAttr(method) + '" ';
|
|
html += 'data-entity-type="' + self.escapeAttr(entityTypeForMethod) + '" ';
|
|
html += 'data-values="' + self.escapeAttr(JSON.stringify(values)) + '">';
|
|
html += '<span class="rule-icon"><i class="icon-check"></i></span>';
|
|
html += '<span class="rule-text">' + self.formatRuleMethod(method, values.length) + '</span>';
|
|
html += '</div>';
|
|
|
|
// Exclusion rules
|
|
if (excludes.length > 0) {
|
|
excludes.forEach(function(exclude) {
|
|
var exMethod = exclude.method || 'unknown';
|
|
var exValues = exclude.values || [];
|
|
var exEntityType = self.getEntityTypeForMethod(entityType, exMethod);
|
|
html += '<div class="preview-rule exclude" ';
|
|
html += 'data-method="' + self.escapeAttr(exMethod) + '" ';
|
|
html += 'data-entity-type="' + self.escapeAttr(exEntityType) + '" ';
|
|
html += 'data-values="' + self.escapeAttr(JSON.stringify(exValues)) + '">';
|
|
html += '<span class="rule-icon"><i class="icon-minus"></i></span>';
|
|
html += '<span class="rule-text">' + self.formatRuleMethod(exMethod, exValues.length) + '</span>';
|
|
html += '</div>';
|
|
});
|
|
}
|
|
|
|
html += '</div>';
|
|
});
|
|
|
|
html += '</div>';
|
|
return html;
|
|
},
|
|
|
|
getEntityTypeForMethod: function(baseEntityType, method) {
|
|
// Map method to entity type for resolving names
|
|
var methodToEntity = {
|
|
'specific': baseEntityType,
|
|
'by_category': 'categories',
|
|
'by_manufacturer': 'manufacturers',
|
|
'by_supplier': 'suppliers',
|
|
'by_attribute': 'attributes',
|
|
'by_feature': 'features',
|
|
'by_parent': 'categories',
|
|
'by_cms_category': 'cms_categories',
|
|
'by_group': 'customer_groups',
|
|
'by_profile': 'profiles',
|
|
'by_zone': 'zones',
|
|
'by_country': 'countries'
|
|
};
|
|
return methodToEntity[method] || null;
|
|
},
|
|
|
|
buildRuleTooltip: function(type, method, values) {
|
|
var tooltip = type + ': ' + this.humanizeMethod(method);
|
|
if (values && values.length > 0) {
|
|
tooltip += ' (' + values.length + ' selected)';
|
|
}
|
|
return tooltip;
|
|
},
|
|
|
|
escapeAttr: function(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
},
|
|
|
|
formatRuleMethod: function(method, valueCount) {
|
|
var countStr = valueCount !== undefined ? ' (' + valueCount + ')' : '';
|
|
var methodLabels = {
|
|
// Basic methods
|
|
'all': 'All',
|
|
'specific': 'Specific' + countStr,
|
|
// Product selection methods
|
|
'by_category': 'Category' + countStr,
|
|
'by_manufacturer': 'Manufacturer' + countStr,
|
|
'by_supplier': 'Supplier' + countStr,
|
|
'by_attribute': 'Attribute' + countStr,
|
|
'by_feature': 'Feature' + countStr,
|
|
'by_price_range': 'Price range',
|
|
'by_quantity_range': 'Quantity range',
|
|
'by_condition': 'Condition' + countStr,
|
|
'by_visibility': 'Visibility' + countStr,
|
|
'by_active_status': 'Active status',
|
|
'by_stock_status': 'Stock status',
|
|
'by_on_sale': 'On sale',
|
|
'by_is_virtual': 'Virtual products',
|
|
'by_is_pack': 'Pack products',
|
|
'by_has_combinations': 'Has combinations',
|
|
'by_name_pattern': 'Name pattern',
|
|
'by_reference_pattern': 'Reference pattern',
|
|
'by_date_added': 'Date added',
|
|
'by_date_updated': 'Date updated',
|
|
'by_weight_range': 'Weight range',
|
|
// Category methods
|
|
'by_parent': 'Parent category' + countStr,
|
|
'by_depth': 'Depth level',
|
|
'by_active': 'Active status',
|
|
// CMS methods
|
|
'by_cms_category': 'CMS category' + countStr,
|
|
// Customer methods
|
|
'by_group': 'Customer group' + countStr,
|
|
'by_newsletter': 'Newsletter',
|
|
'by_optin': 'Opt-in',
|
|
// Employee methods
|
|
'by_profile': 'Profile' + countStr,
|
|
// Generic
|
|
'by_zone': 'Zone' + countStr,
|
|
'by_country': 'Country' + countStr
|
|
};
|
|
return methodLabels[method] || this.humanizeMethod(method) + countStr;
|
|
},
|
|
|
|
humanizeMethod: function(method) {
|
|
return method
|
|
.replace(/^by_/, '')
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, function(l) { return l.toUpperCase(); });
|
|
},
|
|
|
|
renderItem: function(item) {
|
|
var html = '<div class="preview-item">';
|
|
if (item.image) {
|
|
html += '<img src="' + item.image + '" alt="" class="preview-item-image">';
|
|
} else {
|
|
html += '<span class="preview-item-no-image"><i class="icon-picture-o"></i></span>';
|
|
}
|
|
html += '<div class="preview-item-info">';
|
|
html += '<span class="preview-item-name">' + this.escapeHtml(item.name || item.title || 'Item #' + item.id) + '</span>';
|
|
if (item.reference) {
|
|
html += '<span class="preview-item-ref">' + this.escapeHtml(item.reference) + '</span>';
|
|
}
|
|
if (item.price_formatted) {
|
|
html += '<span class="preview-item-price">' + item.price_formatted + '</span>';
|
|
}
|
|
html += '</div>';
|
|
html += '</div>';
|
|
return html;
|
|
},
|
|
|
|
escapeHtml: function(text) {
|
|
var div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
},
|
|
|
|
getEntityIcon: function(entityType) {
|
|
var icons = {
|
|
'products': 'icon-cube',
|
|
'categories': 'icon-folder-open',
|
|
'manufacturers': 'icon-building',
|
|
'suppliers': 'icon-truck',
|
|
'cms': 'icon-file-text',
|
|
'cms_categories': 'icon-folder',
|
|
'carriers': 'icon-truck',
|
|
'zones': 'icon-globe',
|
|
'countries': 'icon-flag',
|
|
'currencies': 'icon-money',
|
|
'languages': 'icon-language',
|
|
'customer_groups': 'icon-users',
|
|
'shops': 'icon-shopping-cart',
|
|
'shop_groups': 'icon-th',
|
|
'customers': 'icon-user',
|
|
'employees': 'icon-user-secret',
|
|
'profiles': 'icon-id-card',
|
|
'order_states': 'icon-check-circle',
|
|
'taxes': 'icon-percent'
|
|
};
|
|
return icons[entityType] || 'icon-list';
|
|
},
|
|
|
|
getEntityLabel: function(entityType) {
|
|
var labels = {
|
|
'products': this.trans.products || 'Products',
|
|
'categories': this.trans.categories || 'Categories',
|
|
'manufacturers': this.trans.manufacturers || 'Manufacturers',
|
|
'suppliers': this.trans.suppliers || 'Suppliers',
|
|
'cms': this.trans.cms_pages || 'CMS Pages',
|
|
'cms_categories': this.trans.cms_categories || 'CMS Categories',
|
|
'carriers': this.trans.carriers || 'Carriers',
|
|
'zones': this.trans.zones || 'Zones',
|
|
'countries': this.trans.countries || 'Countries',
|
|
'currencies': this.trans.currencies || 'Currencies',
|
|
'languages': this.trans.languages || 'Languages',
|
|
'customer_groups': this.trans.customer_groups || 'Customer Groups',
|
|
'shops': this.trans.shops || 'Shops',
|
|
'shop_groups': this.trans.shop_groups || 'Shop Groups',
|
|
'customers': this.trans.customers || 'Customers',
|
|
'employees': this.trans.employees || 'Employees',
|
|
'profiles': this.trans.profiles || 'Profiles',
|
|
'order_states': this.trans.order_states || 'Order States',
|
|
'taxes': this.trans.taxes || 'Taxes'
|
|
};
|
|
return labels[entityType] || entityType;
|
|
}
|
|
};
|
|
|
|
// Initialize when config is available
|
|
$(document).ready(function() {
|
|
if (typeof targetListPreviewConfig !== 'undefined') {
|
|
TargetListPreview.init(targetListPreviewConfig);
|
|
}
|
|
});
|
|
|
|
window.TargetListPreview = TargetListPreview;
|
|
|
|
})(jQuery);
|