feat: unified preview eye icon component, enhanced search & preview
- Unify filter group and filter value preview icons into shared .filter-chip-wrapper + .chip-preview-btn component pattern - Remove old .toggle-count.clickable inline eye icon approach - Add dropdown-level event handler for preview buttons (dropdown appended to body, needs separate delegation) - Enhanced EntitySearchEngine with improved product condition resolution and preview data - Add EntityPreviewHandler for richer preview popovers - Various SCSS improvements for chips, groups, and list-preview Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
761
ENTITY-SELECTOR.md
Normal file
761
ENTITY-SELECTOR.md
Normal file
@@ -0,0 +1,761 @@
|
||||
# PrestaShop Entity Selector — Architecture & Developer Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Entity Selector is a Composer package (`myprestarocks/prestashop-entity-selector`) providing a universal entity targeting widget for PrestaShop admin controllers. It supports group-based selection with include/exclude logic, live preview, and 18 entity types with 130+ selection methods.
|
||||
|
||||
**Package:** `vendor/myprestarocks/prestashop-entity-selector/`
|
||||
**Namespace:** `MyPrestaRocks\EntitySelector`
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── EntitySelector.php # Main trait (orchestrator, AJAX routing, asset loading)
|
||||
├── ScheduleConditions.php # Scheduling trait (datetime, weekly, holidays)
|
||||
├── HolidayProviderInterface.php # Holiday provider contract
|
||||
└── EntitySelector/
|
||||
├── EntitySelectorRenderer.php # HTML rendering, layouts, block/group UI
|
||||
├── EntitySearchEngine.php # AJAX search, count, getByIds per entity type
|
||||
├── ProductConditionResolver.php # Product ID resolution for all 40+ methods
|
||||
├── EntityQueryHandler.php # Non-product entity ID resolution
|
||||
└── EntityPreviewHandler.php # Preview data generation for all types
|
||||
|
||||
assets/
|
||||
├── css/admin/
|
||||
│ ├── entity-selector.css # Compiled from SCSS
|
||||
│ └── mpr-modal.css
|
||||
└── js/admin/
|
||||
├── entity-selector.js # Main JS (chips, search, serialization, UI)
|
||||
├── entity-selector.min.js
|
||||
├── entity-list-preview.js
|
||||
└── schedule-conditions.js
|
||||
|
||||
sources/
|
||||
├── scss/ # SCSS source files
|
||||
└── js/admin/entity-selector/ # JS source (compiled via gulp)
|
||||
├── _core.js
|
||||
├── _events.js
|
||||
├── _dropdown.js
|
||||
├── _search.js
|
||||
├── _groups.js
|
||||
├── _chips.js
|
||||
├── _methods.js
|
||||
├── _preview.js
|
||||
├── _filters.js
|
||||
└── _utils.js
|
||||
```
|
||||
|
||||
### Component Roles
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| **EntitySelector** (trait) | Orchestrator. Mixes into admin controllers. Routes AJAX, initializes assets, lazy-loads helpers. |
|
||||
| **EntitySelectorRenderer** | Generates all HTML. Handles 3 layout modes, block tabs, group UI, value pickers. |
|
||||
| **EntitySearchEngine** | Handles `searchTargetEntities` AJAX. Dispatches to `searchTarget{EntityType}()` methods. Returns `{id, name, reference, subtitle, active, image}`. |
|
||||
| **ProductConditionResolver** | Resolves product selection methods to product IDs. 40+ methods from `by_category` to `by_isbn_pattern`. Also handles combination attribute resolution. |
|
||||
| **EntityQueryHandler** | Resolves selection methods for non-product entities (categories, manufacturers, customers, etc.). |
|
||||
| **EntityPreviewHandler** | Generates rich preview data (name, image, reference, attributes) for displaying matched items. |
|
||||
| **entity-selector.js** | Client-side: chips, search dropdowns, serialization, value pickers, keyboard shortcuts, pagination. |
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Controller (uses EntitySelector trait)
|
||||
│
|
||||
├── setMedia() → initEntitySelector() → loads CSS + JS
|
||||
│
|
||||
├── renderForm() → renderEntitySelectorHtml($config, $savedData)
|
||||
│ └── EntitySelectorRenderer → HTML with blocks, groups, pickers
|
||||
│
|
||||
├── ajaxProcess*() → handleEntitySelectorAjax()
|
||||
│ ├── searchTargetEntities → EntitySearchEngine::search()
|
||||
│ ├── getTargetEntitiesByIds → EntitySearchEngine::getByIds()
|
||||
│ ├── countConditionMatches → ProductConditionResolver::getIdsByMethod()
|
||||
│ ├── previewConditionItems → EntityPreviewHandler::getProductPreviewData()
|
||||
│ ├── previewGroupItems → resolve + apply modifiers + preview
|
||||
│ └── ... (20+ AJAX actions)
|
||||
│
|
||||
└── postProcess() → parse JSON from hidden input → resolve IDs → save
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Integration
|
||||
|
||||
```php
|
||||
use MyPrestaRocks\EntitySelector\EntitySelector;
|
||||
|
||||
class AdminMyController extends ModuleAdminController
|
||||
{
|
||||
use EntitySelector;
|
||||
|
||||
public function setMedia($isNewTheme = false)
|
||||
{
|
||||
parent::setMedia($isNewTheme);
|
||||
$this->initEntitySelector(); // loads CSS + JS
|
||||
}
|
||||
|
||||
// AJAX handler — route entity selector requests
|
||||
public function ajaxProcessEntitySelector()
|
||||
{
|
||||
$this->handleEntitySelectorAjax();
|
||||
}
|
||||
|
||||
// Render the widget
|
||||
public function renderForm()
|
||||
{
|
||||
$html = $this->getEntitySelectorRenderer()->renderEntitySelectorHtml(
|
||||
$config, // widget configuration
|
||||
$savedData // previously saved selection data
|
||||
);
|
||||
// embed $html in your form
|
||||
}
|
||||
|
||||
// Required: translate strings for the widget
|
||||
protected function transEntitySelector($string)
|
||||
{
|
||||
return $this->module->l($string, 'EntitySelector');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
```php
|
||||
$config = [
|
||||
// Identity
|
||||
'id' => 'my-selector', // unique DOM ID
|
||||
'name' => 'entity_selector', // form field name
|
||||
'name_prefix' => 'target_', // prefix for generated field names
|
||||
|
||||
// Labels
|
||||
'title' => 'Target Selection',
|
||||
'subtitle' => 'Define which items to target',
|
||||
|
||||
// Which built-in entity blocks to show
|
||||
'show_products' => true,
|
||||
'show_categories' => true,
|
||||
'show_manufacturers' => true,
|
||||
'show_suppliers' => true,
|
||||
'show_cms' => true,
|
||||
'show_cms_categories' => true,
|
||||
|
||||
// Mode
|
||||
'mode' => 'multi', // 'multi' = multiple groups, 'single' = one group only
|
||||
'combination_mode' => 'products', // 'products', 'combinations', or 'toggle' (for by_combination method)
|
||||
'empty_means_all' => false, // if true, empty selection = all items
|
||||
'required' => false, // validation: must have at least one selection
|
||||
'required_message' => '', // custom validation message
|
||||
|
||||
// UI
|
||||
'show_modifiers' => true, // show limit/sort per group
|
||||
'show_all_toggle' => false, // show "select all" toggle
|
||||
|
||||
// Layout: 'standalone' (full widget), 'form-group' (PS form-group), 'form-content' (content only)
|
||||
'layout' => 'standalone',
|
||||
|
||||
// Custom entity blocks (see Custom Entity Types section)
|
||||
'customBlocks' => [],
|
||||
|
||||
// Override default block definitions
|
||||
'blocks' => [],
|
||||
];
|
||||
```
|
||||
|
||||
### Saved Data Format
|
||||
|
||||
```json
|
||||
{
|
||||
"products": {
|
||||
"groups": [
|
||||
{
|
||||
"name": "Group 1",
|
||||
"include": {
|
||||
"method": "by_category",
|
||||
"values": [3, 5, 8]
|
||||
},
|
||||
"excludes": [
|
||||
{
|
||||
"method": "specific",
|
||||
"values": [42, 99]
|
||||
}
|
||||
],
|
||||
"modifiers": {
|
||||
"limit": 10,
|
||||
"sort_by": "sales",
|
||||
"sort_dir": "DESC"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"categories": {
|
||||
"groups": [
|
||||
{
|
||||
"include": { "method": "all", "values": [] },
|
||||
"excludes": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Group Logic
|
||||
|
||||
- **Within a group:** Include MINUS Exclude (set difference)
|
||||
- **Between groups:** OR (union)
|
||||
- **Between blocks:** AND (intersection)
|
||||
|
||||
### Resolving IDs from Saved Data
|
||||
|
||||
```php
|
||||
// Get product IDs from saved selection
|
||||
$data = json_decode(Tools::getValue('entity_selector'), true);
|
||||
$groups = $data['products']['groups'] ?? [];
|
||||
|
||||
$resolver = $this->getProductConditionResolver();
|
||||
$productIds = [];
|
||||
|
||||
foreach ($groups as $group) {
|
||||
// Resolve include
|
||||
$includeIds = $resolver->getIdsByMethod($group['include']['method'], $group['include']['values']);
|
||||
|
||||
// Resolve excludes
|
||||
foreach ($group['excludes'] ?? [] as $exclude) {
|
||||
$excludeIds = $resolver->getIdsByMethod($exclude['method'], $exclude['values']);
|
||||
$includeIds = array_diff($includeIds, $excludeIds);
|
||||
}
|
||||
|
||||
// Apply modifiers
|
||||
if (!empty($group['modifiers'])) {
|
||||
$includeIds = $resolver->applyModifiers($includeIds, $group['modifiers']);
|
||||
}
|
||||
|
||||
$productIds = array_merge($productIds, $includeIds);
|
||||
}
|
||||
|
||||
$productIds = array_unique($productIds);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entity Types & Selection Methods
|
||||
|
||||
### Products (40+ methods)
|
||||
|
||||
**By Entity:**
|
||||
| Method | Value Type | Returns |
|
||||
|--------|-----------|---------|
|
||||
| `all` | none | All active products |
|
||||
| `specific` | entity_search (products) | Selected product IDs |
|
||||
| `by_category` | entity_search (categories) | Products in categories |
|
||||
| `by_manufacturer` | entity_search (manufacturers) | Products by manufacturer |
|
||||
| `by_supplier` | entity_search (suppliers) | Products by supplier |
|
||||
| `by_tag` | entity_search (tags) | Products by tag |
|
||||
| `by_attribute` | entity_search (attributes) | Products with attribute(s) |
|
||||
| `by_feature` | entity_search (features) | Products with feature(s) |
|
||||
| `by_combination` | combination_attributes | Products/combinations by attribute groups |
|
||||
|
||||
**By Property:**
|
||||
| Method | Value Type | Options |
|
||||
|--------|-----------|---------|
|
||||
| `by_condition` | multi_select_tiles | new, used, refurbished |
|
||||
| `by_visibility` | multi_select_tiles | both, catalog, search, none |
|
||||
| `by_active_status` | multi_select_tiles | active, inactive |
|
||||
| `by_stock_status` | multi_select_tiles | in_stock, out_of_stock, low_stock |
|
||||
| `by_on_sale` | boolean | Has on-sale flag |
|
||||
| `by_has_specific_price` | boolean | Has active specific price |
|
||||
| `by_is_virtual` | boolean | Is virtual product |
|
||||
| `by_is_pack` | boolean | Is pack product |
|
||||
| `by_has_combinations` | boolean | Has combinations/variants |
|
||||
| `by_available_for_order` | boolean | Available for order |
|
||||
| `by_online_only` | boolean | Online only |
|
||||
| `by_has_related` | boolean | Has accessories/related |
|
||||
| `by_has_customization` | boolean | Has customization fields |
|
||||
| `by_has_attachments` | boolean | Has attachments |
|
||||
| `by_has_additional_shipping` | boolean | Has additional shipping cost |
|
||||
| `by_out_of_stock_behavior` | multi_select_tiles | deny, allow, default |
|
||||
| `by_delivery_time` | multi_select_tiles | none, default, specific |
|
||||
| `by_carrier_restriction` | multi_select_tiles | restricted, all |
|
||||
|
||||
**By Text Pattern:**
|
||||
| Method | Value Type |
|
||||
|--------|-----------|
|
||||
| `by_name_pattern` | pattern |
|
||||
| `by_reference_pattern` | pattern |
|
||||
| `by_description_pattern` | pattern |
|
||||
| `by_long_description_pattern` | pattern |
|
||||
| `by_ean13_pattern` | pattern |
|
||||
| `by_upc_pattern` | pattern |
|
||||
| `by_isbn_pattern` | pattern |
|
||||
| `by_mpn_pattern` | pattern |
|
||||
| `by_meta_title_pattern` | pattern |
|
||||
| `by_meta_description_pattern` | pattern |
|
||||
|
||||
**By Range:**
|
||||
| Method | Value Type |
|
||||
|--------|-----------|
|
||||
| `by_id_range` | numeric_range |
|
||||
| `by_price_range` | numeric_range |
|
||||
| `by_weight_range` | numeric_range |
|
||||
| `by_quantity_range` | numeric_range |
|
||||
| `by_position_range` | numeric_range |
|
||||
| `by_date_added` | date_range |
|
||||
| `by_date_updated` | date_range |
|
||||
|
||||
### Other Entities (summary)
|
||||
|
||||
| Entity | Key Methods |
|
||||
|--------|-------------|
|
||||
| Categories | all, specific, by_name_pattern, by_product_count, by_depth_level, by_active_status |
|
||||
| Manufacturers | all, specific, by_name_pattern, by_product_count, by_active_status |
|
||||
| Suppliers | all, specific, by_name_pattern, by_product_count, by_active_status |
|
||||
| CMS Pages | all, specific, by_cms_category, by_name_pattern, by_active_status, by_indexable |
|
||||
| CMS Categories | all, specific, by_name_pattern, by_active_status, by_page_count |
|
||||
| Employees | all, specific, by_profile, by_name_pattern, by_active_status |
|
||||
| Customers | all, specific, by_group, by_name_pattern, by_email_pattern, by_company, by_order_count, by_turnover, by_active_status, by_newsletter, by_guest |
|
||||
| Customer Groups | all, specific, by_name_pattern, by_price_display |
|
||||
| Carriers | all, specific, by_name_pattern, by_active_status, by_shipping_handling, by_free_shipping, by_zone, by_customer_group, by_price_range, by_weight_range |
|
||||
| Zones | all, specific, by_name_pattern, by_active_status |
|
||||
| Countries | all, specific, by_zone, by_name_pattern, by_active_status, by_contains_states, by_need_zip_code, by_zip_format, by_need_identification |
|
||||
| Currencies | all, specific, by_name_pattern, by_active_status |
|
||||
| Languages | all, specific, by_name_pattern, by_active_status, by_rtl |
|
||||
| Shops | all, specific, by_name_pattern, by_active_status |
|
||||
| Profiles | all, specific, by_name_pattern |
|
||||
| Order States | all, specific, by_name_pattern, by_paid, by_shipped, by_delivery |
|
||||
| Taxes | all, specific, by_name_pattern, by_rate_range, by_active_status |
|
||||
|
||||
---
|
||||
|
||||
## Value Types
|
||||
|
||||
| Type | UI Component | Input Format | Example |
|
||||
|------|-------------|-------------|---------|
|
||||
| `none` | No input | `[]` | "All products" |
|
||||
| `entity_search` | Search dropdown + chips | `[id1, id2, ...]` | Specific products, By category |
|
||||
| `pattern` | Text input + case toggle | `[{"pattern": "blue*", "caseSensitive": false}]` | Name pattern |
|
||||
| `multi_select_tiles` | Toggle tile buttons | `["new", "used"]` | Condition |
|
||||
| `select` | Single dropdown | `"value"` | Active status |
|
||||
| `numeric_range` | Min/Max inputs | `{"min": 10, "max": 50}` | Price range |
|
||||
| `multi_numeric_range` | Multiple ranges | `[{"min": 0, "max": 10}, ...]` | Price bands |
|
||||
| `date_range` | Date pickers | `{"from": "2024-01-01", "to": "2024-12-31"}` | Date added |
|
||||
| `boolean` | Flag (no input) | `[true]` | Has combinations |
|
||||
| `combination_attributes` | Attribute group picker | `{"mode": "products", "attributes": {"1": [3,4]}}` | By combination |
|
||||
|
||||
---
|
||||
|
||||
## Combination Attributes (by_combination)
|
||||
|
||||
The `by_combination` selection method allows targeting products by their attribute combinations. This is a **selection method** within the products block, not a separate entity type.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. User selects "Combination" from the method dropdown
|
||||
2. UI loads all attribute groups via AJAX (`getAttributeGroups`)
|
||||
3. User clicks a group → lazy-loads values via AJAX (`getAttributeValues`)
|
||||
4. User selects attribute values (tile-based UI with select-all/clear/search)
|
||||
5. Logic: **OR within a group** (Red OR Blue), **AND between groups** (Color AND Size)
|
||||
|
||||
### Combination Modes (`combination_mode` config)
|
||||
|
||||
| Mode | Behavior | Returns |
|
||||
|------|----------|---------|
|
||||
| `'products'` (default) | Attribute selection filters products. Returns product IDs. | `id_product[]` |
|
||||
| `'combinations'` | Returns exact combination IDs (product_attribute). | `id_product_attribute[]` |
|
||||
| `'toggle'` | Shows radio toggle letting user choose per-selection. | Either type |
|
||||
|
||||
### Data Format
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "by_combination",
|
||||
"values": {
|
||||
"mode": "products",
|
||||
"attributes": {
|
||||
"1": [3, 4],
|
||||
"2": [7, 8, 9]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where `"1"` is `id_attribute_group` and `[3, 4]` are `id_attribute` values within that group.
|
||||
|
||||
### Backend Resolution (ProductConditionResolver)
|
||||
|
||||
```php
|
||||
// ProductConditionResolver::getProductIdsByCombinationAttributes($values)
|
||||
// mode = 'products': SELECT DISTINCT pa.id_product FROM ...
|
||||
// mode = 'combinations': SELECT DISTINCT pa.id_product_attribute FROM ...
|
||||
// Uses correlated subqueries for AND-logic between groups, OR within groups
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Entity Blocks
|
||||
|
||||
Custom entity blocks allow modules to add their own entity types (e.g., materials, suppliers, warehouses) to the entity selector widget.
|
||||
|
||||
### Registration via `customBlocks` config
|
||||
|
||||
```php
|
||||
$config['customBlocks'] = [
|
||||
'mpr_materials' => [
|
||||
'label' => 'Materials',
|
||||
'entity_label' => 'material',
|
||||
'entity_label_plural' => 'materials',
|
||||
'icon' => 'icon-cubes',
|
||||
'search_entity' => 'mpr_materials', // entity type key for search routing
|
||||
'selection_methods' => [
|
||||
'all' => [
|
||||
'label' => 'All materials',
|
||||
'icon' => 'icon-asterisk',
|
||||
'value_type' => 'none',
|
||||
'group' => '',
|
||||
],
|
||||
'specific' => [
|
||||
'label' => 'Specific materials',
|
||||
'icon' => 'icon-cube',
|
||||
'value_type' => 'entity_search',
|
||||
'search_entity' => 'mpr_materials',
|
||||
'group' => 'by_entity',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### Search Handler Implementation
|
||||
|
||||
The `EntitySearchEngine` routes searches via `searchTarget{CamelCase}()`. For custom entities, you need to either:
|
||||
|
||||
**Option A:** Subclass EntitySearchEngine and add your search methods, then override the getter in your controller.
|
||||
|
||||
**Option B:** Override `ajaxSearchTargetEntities()` in your controller to intercept custom entity types before delegating to the engine:
|
||||
|
||||
```php
|
||||
protected function ajaxSearchTargetEntities()
|
||||
{
|
||||
$entityType = Tools::getValue('entity_type', '');
|
||||
|
||||
// Handle custom entity types
|
||||
if ($entityType === 'mpr_materials') {
|
||||
$query = trim(Tools::getValue('q', ''));
|
||||
$limit = (int) Tools::getValue('limit', 20);
|
||||
$offset = (int) Tools::getValue('offset', 0);
|
||||
|
||||
// Your search logic
|
||||
$results = $this->searchMaterials($query, $limit, $offset);
|
||||
$total = $this->countMaterials($query);
|
||||
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => true,
|
||||
'results' => $results, // [{id, name, subtitle?, active?, image?}, ...]
|
||||
'total' => $total,
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fall back to built-in handler
|
||||
parent::ajaxSearchTargetEntities();
|
||||
}
|
||||
```
|
||||
|
||||
Similarly override `ajaxGetTargetEntitiesByIds()` and `ajaxGetTargetEntitiesByIdsBulk()` for chip restoration on form load.
|
||||
|
||||
### Search Result Format
|
||||
|
||||
```php
|
||||
[
|
||||
'id' => (int) 42,
|
||||
'name' => 'Display Name', // required
|
||||
'subtitle' => 'Reference or info', // optional, shown below name
|
||||
'active' => true, // optional, dim if false
|
||||
'image' => 'https://...', // optional, thumbnail URL
|
||||
'reference' => 'REF-001', // optional, shown as badge
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout Modes
|
||||
|
||||
### `standalone` (default)
|
||||
Full widget with header, collapse toggle, tabs, groups. Best for dedicated pages or modals.
|
||||
|
||||
### `form-group`
|
||||
Wraps widget in PrestaShop's standard `form-group` structure (`col-lg-3` label + `col-lg-9` content). Best for embedding in existing forms.
|
||||
|
||||
### `form-content`
|
||||
Just the content area (no wrapper). For when you have an existing `form-group` and need only the inner widget. Collapsed by default.
|
||||
|
||||
```php
|
||||
// Standalone
|
||||
$config['layout'] = 'standalone';
|
||||
|
||||
// Inside a form
|
||||
$config['layout'] = 'form-group';
|
||||
|
||||
// Content only (collapsed by default)
|
||||
$config['layout'] = 'form-content';
|
||||
$config['collapsed'] = false; // override default collapse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AJAX Actions
|
||||
|
||||
All AJAX actions route through `handleEntitySelectorAjax()` with `trait=EntitySelector`.
|
||||
|
||||
| Action | Purpose | Key Params |
|
||||
|--------|---------|-----------|
|
||||
| `searchTargetEntities` | Search entities by type | entity_type, q, limit, offset, filters |
|
||||
| `getTargetEntitiesByIds` | Get entity by ID (chip restore) | entity_type, ids |
|
||||
| `getTargetEntitiesByIdsBulk` | Bulk get entities by IDs | entities (JSON object) |
|
||||
| `countConditionMatches` | Count matching items for a method | block_type, method, values |
|
||||
| `countConditionMatchesBulk` | Bulk count multiple conditions | conditions (JSON) |
|
||||
| `previewConditionItems` | Preview items for a condition | block_type, method, values, limit |
|
||||
| `previewGroupItems` | Preview group with modifiers | block_type, group_data, limit |
|
||||
| `countGroupItems` | Count items in a group | block_type, group_data |
|
||||
| `previewEntitySelector` | Preview full entity selection | conditions, block_type, limit |
|
||||
| `previewEntitySelectorBulk` | Bulk preview all entity types | conditions (JSON) |
|
||||
| `getAttributeGroups` | List attribute groups | — |
|
||||
| `getAttributeValues` | Get values for an attribute group | group_id |
|
||||
| `getFeatureGroups` | List feature groups | — |
|
||||
| `getCategoryTree` | Category tree hierarchy | — |
|
||||
| `countPatternMatches` | Count pattern matches | pattern, field, entity_type |
|
||||
| `previewPatternMatches` | Preview pattern matches | pattern, entity_type, limit |
|
||||
|
||||
---
|
||||
|
||||
## Group Modifiers
|
||||
|
||||
Available per group to limit and sort results:
|
||||
|
||||
```php
|
||||
'modifiers' => [
|
||||
'limit' => 5, // max items from this group
|
||||
'sort_by' => 'sales', // sort field
|
||||
'sort_dir' => 'DESC', // sort direction
|
||||
]
|
||||
```
|
||||
|
||||
**Sort fields for products:** name, price, date_add, position, quantity, reference, id, sales
|
||||
|
||||
---
|
||||
|
||||
## JavaScript API
|
||||
|
||||
### Configuration Object
|
||||
|
||||
The JS receives config from PHP via data attributes:
|
||||
|
||||
```javascript
|
||||
// Config keys available in JS
|
||||
this.config = {
|
||||
id: 'selector-id',
|
||||
ajaxUrl: '...',
|
||||
trans: { /* all translation strings */ },
|
||||
methodHelp: { /* help content per method */ },
|
||||
combinationMode: 'products', // 'products', 'combinations', 'toggle'
|
||||
emptyMeansAll: false,
|
||||
};
|
||||
```
|
||||
|
||||
### Events
|
||||
|
||||
```javascript
|
||||
// Selection changed
|
||||
$(document).on('entitySelector:change', function(e, data) { });
|
||||
|
||||
// Block tab switched
|
||||
$(document).on('entitySelector:tabChange', function(e, blockType) { });
|
||||
|
||||
// Group added/removed
|
||||
$(document).on('entitySelector:groupAdd', function(e, groupData) { });
|
||||
$(document).on('entitySelector:groupRemove', function(e, groupIndex) { });
|
||||
```
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Ctrl+A | Select all in dropdown |
|
||||
| Ctrl+D | Deselect all |
|
||||
| Esc | Close dropdown |
|
||||
| Enter | Confirm selection |
|
||||
|
||||
---
|
||||
|
||||
## Schedule Conditions (separate trait)
|
||||
|
||||
```php
|
||||
use MyPrestaRocks\EntitySelector\ScheduleConditions;
|
||||
|
||||
class AdminMyController extends ModuleAdminController
|
||||
{
|
||||
use EntitySelector;
|
||||
use ScheduleConditions;
|
||||
|
||||
public function setMedia($isNewTheme = false)
|
||||
{
|
||||
parent::setMedia($isNewTheme);
|
||||
$this->initEntitySelector();
|
||||
$this->initScheduleConditions();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Configuration: datetime range, weekly schedule (per-day hour ranges), holiday exclusions by country.
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # compile SCSS → CSS, concatenate JS
|
||||
npm run watch # watch mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Planned Feature: Product Selection Level
|
||||
|
||||
> **Status: Planned — Not Yet Implemented**
|
||||
|
||||
### Problem
|
||||
|
||||
Currently, the entity selector's product search always returns **base products**. When a user searches for "Hummingbird sweater", they get one result regardless of how many combinations (Size: S/M/L/XL × Color: White/Black) the product has.
|
||||
|
||||
For many use cases, users need to target **specific combinations** rather than entire products:
|
||||
- **Stock management:** Set stock per combination (Size S, Color White)
|
||||
- **Discounts:** Apply discount to specific variants
|
||||
- **Product links:** Link specific combinations to materials
|
||||
|
||||
### Solution: `product_selection_level` Config
|
||||
|
||||
A new config option that changes how the products block fundamentally works — not just the `by_combination` selection method, but how **all** product-related operations (search, display, selection, preview) behave.
|
||||
|
||||
```php
|
||||
$config['product_selection_level'] = 'product'; // default — current behavior
|
||||
$config['product_selection_level'] = 'combination'; // combination mode
|
||||
$config['product_selection_level'] = 'both'; // combined mode
|
||||
```
|
||||
|
||||
### Three Modes
|
||||
|
||||
#### Mode 1: `product` (Default — Current Behavior)
|
||||
|
||||
Search results show base products. Returns `id_product`. The product "Hummingbird sweater" appears as one item regardless of combinations.
|
||||
|
||||
**Use case:** Product links, category assignments, anywhere you target an entire product.
|
||||
|
||||
**Search result:**
|
||||
```
|
||||
[x] Hummingbird sweater REF-001
|
||||
```
|
||||
|
||||
**Returns:** `id_product = 1`
|
||||
|
||||
#### Mode 2: `combination`
|
||||
|
||||
For products **with combinations**, each combination appears as a separate selectable item. The base product itself is NOT shown. Products without combinations (simple products) still appear as-is.
|
||||
|
||||
**Use case:** Stock management, per-variant pricing, anything that targets individual variants.
|
||||
|
||||
**Search result for "Hummingbird sweater" (4 combinations):**
|
||||
```
|
||||
[x] Hummingbird sweater — Size: S, Color: White REF-001
|
||||
[x] Hummingbird sweater — Size: S, Color: Black REF-001
|
||||
[x] Hummingbird sweater — Size: M, Color: White REF-001
|
||||
[x] Hummingbird sweater — Size: M, Color: Black REF-001
|
||||
```
|
||||
|
||||
**Search result for "Brown bear notebook" (simple product, no combinations):**
|
||||
```
|
||||
[x] Brown bear notebook REF-002
|
||||
```
|
||||
|
||||
**Returns:** `id_product_attribute` for combination products, `id_product` for simple products (needs encoding, e.g., negative ID or composite key).
|
||||
|
||||
#### Mode 3: `both` (Combined)
|
||||
|
||||
Both the full product AND each individual combination appear as selectable items. Users choose what they need — the entire product or specific variants.
|
||||
|
||||
**Use case:** Discounts (apply to whole product OR specific combinations), flexible targeting.
|
||||
|
||||
**Search result for "Hummingbird sweater":**
|
||||
```
|
||||
[x] Hummingbird sweater REF-001 [entire product]
|
||||
[x] — Size: S, Color: White
|
||||
[x] — Size: S, Color: Black
|
||||
[x] — Size: M, Color: White
|
||||
[x] — Size: M, Color: Black
|
||||
```
|
||||
|
||||
**Returns:** Mix of `id_product` (for whole-product selections) and `id_product_attribute` (for specific combinations).
|
||||
|
||||
### Implementation Areas
|
||||
|
||||
This feature requires changes across the full stack:
|
||||
|
||||
#### 1. EntitySearchEngine — `searchTargetProducts()`
|
||||
- When `product_selection_level = 'combination'`: query `ps_product_attribute` joined with `ps_product_attribute_combination` + attribute names
|
||||
- Return combination-level results: `{id: pa.id_product_attribute, name: "Product — Attr1: Val1, Attr2: Val2", ...}`
|
||||
- Simple products (no combinations) still returned with `id_product`
|
||||
|
||||
#### 2. EntitySelectorRenderer — Config Propagation
|
||||
- Pass `product_selection_level` to JS config
|
||||
- Adapt chip display for combination items (show attribute string)
|
||||
|
||||
#### 3. EntityPreviewHandler — Combination Previews
|
||||
- `getCombinationPreviewData()` already exists — extend for search-based previews
|
||||
- Show attribute values, per-combination stock/price if relevant
|
||||
|
||||
#### 4. entity-selector.js — UI Adaptation
|
||||
- Chip display: show attribute string for combination items
|
||||
- Search result rendering: indent combinations under base product (mode 3)
|
||||
- Data serialization: encode combination IDs distinctly from product IDs
|
||||
|
||||
#### 5. ProductConditionResolver — All Selection Methods
|
||||
- `getIdsByMethod()` must respect `product_selection_level` to return the right ID type
|
||||
- For `by_category` in combination mode: return all `id_product_attribute` for products in that category
|
||||
- For `specific` in combination mode: directly store `id_product_attribute` values
|
||||
|
||||
#### 6. ID Encoding Strategy
|
||||
Need a way to distinguish product IDs from combination IDs in the same value space:
|
||||
- **Option A:** Encode as strings: `"p:1"` (product), `"c:42"` (combination)
|
||||
- **Option B:** Encode as negative/positive: positive = `id_product_attribute`, negative = `-id_product` for simple products
|
||||
- **Option C:** Always store tuples: `{id_product, id_product_attribute}` — 0 means "entire product"
|
||||
|
||||
### Relationship to `by_combination` / `combination_mode`
|
||||
|
||||
`by_combination` is a **selection method** (one of 40+ ways to filter products). It uses attribute group tiles to filter products by their combination attributes, controlled by `combination_mode`.
|
||||
|
||||
`product_selection_level` is a **block-level config** that changes how the entire products block works — how search results appear, what IDs are stored in chips, what previews show. It affects ALL selection methods, not just `by_combination`.
|
||||
|
||||
They are complementary:
|
||||
- `product_selection_level = 'product'` + `by_combination` = "Find products that have Red + XL combination" → returns `id_product`
|
||||
- `product_selection_level = 'combination'` + `specific` = "Search and pick individual combinations" → returns `id_product_attribute`
|
||||
- `product_selection_level = 'combination'` + `by_category` = "All combinations of products in Hoodies category" → returns `id_product_attribute[]`
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- PrestaShop 1.7.x, 8.x, or 9.x
|
||||
- PHP 7.1+
|
||||
- Node.js 16+ (for building from source)
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — MyPrestaRocks
|
||||
@@ -799,6 +799,10 @@
|
||||
.entity-selector-trait.single-mode .target-block-container {
|
||||
display: block;
|
||||
}
|
||||
.target-conditions-trait.single-mode .entity-selector-tabs-row .target-block-tabs,
|
||||
.entity-selector-trait.single-mode .entity-selector-tabs-row .target-block-tabs {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.target-conditions-trait .header-actions,
|
||||
.entity-selector-trait .header-actions {
|
||||
@@ -1749,6 +1753,19 @@
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.target-conditions-trait .dropdown-item.is-combination,
|
||||
.entity-selector-trait .dropdown-item.is-combination {
|
||||
padding-left: 28px;
|
||||
}
|
||||
.target-conditions-trait .dropdown-item.is-combination .result-name,
|
||||
.entity-selector-trait .dropdown-item.is-combination .result-name {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.target-conditions-trait .dropdown-item.is-parent-product,
|
||||
.entity-selector-trait .dropdown-item.is-parent-product {
|
||||
background: #f8fafc;
|
||||
font-weight: 500;
|
||||
}
|
||||
.target-conditions-trait .no-results,
|
||||
.entity-selector-trait .no-results {
|
||||
display: flex;
|
||||
@@ -3041,46 +3058,9 @@ body > .target-search-dropdown .filter-group-toggle .toggle-name,
|
||||
}
|
||||
body > .target-search-dropdown .filter-group-toggle .toggle-count,
|
||||
.target-search-dropdown .filter-group-toggle .toggle-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
color: #6c757d;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
body > .target-search-dropdown .filter-group-toggle .toggle-count i,
|
||||
.target-search-dropdown .filter-group-toggle .toggle-count i {
|
||||
font-size: 10px;
|
||||
color: #25b9d7;
|
||||
}
|
||||
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable,
|
||||
.target-search-dropdown .filter-group-toggle .toggle-count.clickable {
|
||||
cursor: pointer;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.2rem;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable:hover,
|
||||
.target-search-dropdown .filter-group-toggle .toggle-count.clickable:hover {
|
||||
background: rgba(37, 185, 215, 0.1);
|
||||
color: #25b9d7;
|
||||
}
|
||||
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable:hover i,
|
||||
.target-search-dropdown .filter-group-toggle .toggle-count.clickable:hover i {
|
||||
color: #25b9d7;
|
||||
}
|
||||
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable.popover-open,
|
||||
.target-search-dropdown .filter-group-toggle .toggle-count.clickable.popover-open {
|
||||
background: #25b9d7;
|
||||
color: #ffffff;
|
||||
}
|
||||
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable.popover-open i,
|
||||
.target-search-dropdown .filter-group-toggle .toggle-count.clickable.popover-open i {
|
||||
color: #ffffff;
|
||||
}
|
||||
body > .target-search-dropdown .filter-group-toggle .toggle-count.clickable.loading i,
|
||||
.target-search-dropdown .filter-group-toggle .toggle-count.clickable.loading i {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip, body > .target-search-dropdown .filter-attr-chip,
|
||||
body > .target-search-dropdown .filter-feat-chip,
|
||||
.target-search-dropdown .filter-chip,
|
||||
@@ -3201,6 +3181,81 @@ body > .target-search-dropdown .filter-chip.active .chip-count,
|
||||
.target-search-dropdown .active.filter-feat-chip .chip-count {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper,
|
||||
.target-search-dropdown .filter-chip-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border-radius: 0.2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .filter-chip,
|
||||
body > .target-search-dropdown .filter-chip-wrapper .filter-group-toggle,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-chip,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-attr-chip,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-feat-chip,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-group-toggle {
|
||||
border-radius: 0.2rem 0 0 0.2rem;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn,
|
||||
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn:focus,
|
||||
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn,
|
||||
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 10px;
|
||||
color: #6c757d;
|
||||
background: #f1f5f9;
|
||||
border-left: 1px solid #dee2e6;
|
||||
border-radius: 0 0.2rem 0.2rem 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn:hover,
|
||||
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn:hover {
|
||||
background: rgba(37, 185, 215, 0.1);
|
||||
color: #25b9d7;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn.popover-open,
|
||||
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn.popover-open {
|
||||
background: #25b9d7;
|
||||
color: #ffffff;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .chip-preview-btn.loading i,
|
||||
.target-search-dropdown .filter-chip-wrapper .chip-preview-btn.loading i {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .filter-chip:last-child,
|
||||
body > .target-search-dropdown .filter-chip-wrapper .filter-group-toggle:last-child,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-chip:last-child,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-attr-chip:last-child,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-feat-chip:last-child,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-group-toggle:last-child {
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .filter-group-toggle.active + .chip-preview-btn,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-group-toggle.active + .chip-preview-btn {
|
||||
border-left-color: #25b9d7;
|
||||
background: rgba(37, 185, 215, 0.05);
|
||||
}
|
||||
body > .target-search-dropdown .filter-chip-wrapper .filter-group-toggle.has-selection + .chip-preview-btn,
|
||||
.target-search-dropdown .filter-chip-wrapper .filter-group-toggle.has-selection + .chip-preview-btn {
|
||||
border-left-color: #28a745;
|
||||
background: rgba(40, 167, 69, 0.03);
|
||||
}
|
||||
body > .target-search-dropdown .dropdown-content,
|
||||
.target-search-dropdown .dropdown-content {
|
||||
max-height: 400px;
|
||||
@@ -4982,6 +5037,16 @@ body > .target-search-dropdown .dropdown-item:not(:last-child) {
|
||||
.entity-selector-trait .chip-name {
|
||||
word-break: break-word;
|
||||
}
|
||||
.target-conditions-trait .chip-attrs,
|
||||
.entity-selector-trait .chip-attrs {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.target-conditions-trait .chip-attrs::before,
|
||||
.entity-selector-trait .chip-attrs::before {
|
||||
content: "— ";
|
||||
}
|
||||
.target-conditions-trait .chip-remove,
|
||||
.entity-selector-trait .chip-remove {
|
||||
padding: 0;
|
||||
@@ -7070,6 +7135,42 @@ body > .target-search-dropdown .dropdown-item:not(:last-child) {
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.25em 1.25em;
|
||||
}
|
||||
.target-conditions-trait[data-mode=single] .groups-container,
|
||||
.target-conditions-trait .mode-single .groups-container,
|
||||
.entity-selector-trait[data-mode=single] .groups-container,
|
||||
.entity-selector-trait .mode-single .groups-container {
|
||||
padding: 0;
|
||||
}
|
||||
.target-conditions-trait[data-mode=single] .group-body,
|
||||
.target-conditions-trait .mode-single .group-body,
|
||||
.entity-selector-trait[data-mode=single] .group-body,
|
||||
.entity-selector-trait .mode-single .group-body {
|
||||
padding: 0;
|
||||
}
|
||||
.target-conditions-trait[data-mode=single] .group-include,
|
||||
.target-conditions-trait .mode-single .group-include,
|
||||
.entity-selector-trait[data-mode=single] .group-include,
|
||||
.entity-selector-trait .mode-single .group-include {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.target-conditions-trait[data-mode=single] .selection-group,
|
||||
.target-conditions-trait .mode-single .selection-group,
|
||||
.entity-selector-trait[data-mode=single] .selection-group,
|
||||
.entity-selector-trait .mode-single .selection-group {
|
||||
background: transparent;
|
||||
border: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.target-conditions-trait[data-mode=single] .group-header,
|
||||
.target-conditions-trait .mode-single .group-header,
|
||||
.entity-selector-trait[data-mode=single] .group-header,
|
||||
.entity-selector-trait .mode-single .group-header {
|
||||
display: none;
|
||||
}
|
||||
.target-conditions-trait .condition-match-count,
|
||||
.entity-selector-trait .condition-match-count {
|
||||
display: inline-flex;
|
||||
@@ -8182,6 +8283,20 @@ body > .target-search-dropdown .dropdown-item:not(:last-child) {
|
||||
right: 20px;
|
||||
transform: none;
|
||||
}
|
||||
.target-preview-popover.position-above::before,
|
||||
.target-list-preview-popover.position-above::before {
|
||||
top: auto;
|
||||
bottom: -8px;
|
||||
border-top: 8px solid #dee2e6;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.target-preview-popover.position-above::after,
|
||||
.target-list-preview-popover.position-above::after {
|
||||
top: auto;
|
||||
bottom: -6px;
|
||||
border-top: 6px solid #ffffff;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -272,9 +272,9 @@
|
||||
!$(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) {
|
||||
!$(e.target).closest('.chip-preview-holidays').length &&
|
||||
!$(e.target).closest('.chip-preview-btn').length) {
|
||||
self.hidePreviewPopover();
|
||||
// Also close holiday popover
|
||||
$('.holiday-preview-popover').remove();
|
||||
@@ -317,6 +317,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Filter chip/group preview eye button (unified handler)
|
||||
this.$wrapper.on('click', '.chip-preview-btn', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var $btn = $(this);
|
||||
|
||||
if ($btn.hasClass('popover-open')) {
|
||||
self.hidePreviewPopover();
|
||||
} else {
|
||||
var valueId = $btn.data('id');
|
||||
var valueType = $btn.data('type');
|
||||
var valueName = $btn.data('name');
|
||||
var groupId = $btn.data('groupId');
|
||||
|
||||
if (valueId) {
|
||||
// Value-level preview (specific attribute/feature value)
|
||||
self.showFilterValuePreviewPopover($btn, valueId, valueType, valueName, groupId);
|
||||
} else if (groupId) {
|
||||
// Group-level preview (entire attribute/feature group)
|
||||
self.showFilterGroupPreviewPopover($btn, groupId, valueType, valueName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
@@ -1196,7 +1221,7 @@
|
||||
if (isSelected) {
|
||||
// Remove from pending selections
|
||||
self.pendingSelections = self.pendingSelections.filter(function(s) {
|
||||
return parseInt(s.id, 10) !== parseInt(id, 10);
|
||||
return String(s.id) !== String(id);
|
||||
});
|
||||
self.removeSelection($picker, id);
|
||||
$item.toggleClass('selected');
|
||||
@@ -1223,7 +1248,7 @@
|
||||
} else {
|
||||
// Add to pending selections
|
||||
var exists = self.pendingSelections.some(function(s) {
|
||||
return parseInt(s.id, 10) === parseInt(id, 10);
|
||||
return String(s.id) === String(id);
|
||||
});
|
||||
if (!exists) {
|
||||
self.pendingSelections.push({ id: id, name: name, data: $item.data() });
|
||||
@@ -1248,7 +1273,7 @@
|
||||
// Also remove from pending selections if dropdown is open
|
||||
if (self.pendingSelections) {
|
||||
self.pendingSelections = self.pendingSelections.filter(function(s) {
|
||||
return parseInt(s.id, 10) !== parseInt(id, 10);
|
||||
return String(s.id) !== String(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1375,7 +1400,7 @@
|
||||
|
||||
// Add to pending selections for Save button
|
||||
var exists = self.pendingSelections.some(function(s) {
|
||||
return parseInt(s.id, 10) === parseInt(id, 10);
|
||||
return String(s.id) === String(id);
|
||||
});
|
||||
if (!exists) {
|
||||
self.pendingSelections.push({
|
||||
@@ -1990,10 +2015,6 @@
|
||||
|
||||
// Toggle filter group - show values
|
||||
this.$dropdown.on('click', '.filter-group-toggle', function(e) {
|
||||
// Ignore clicks on the preview badge
|
||||
if ($(e.target).closest('.toggle-count.clickable').length) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
var $btn = $(this);
|
||||
var groupId = $btn.data('group-id');
|
||||
@@ -2010,20 +2031,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Filter group toggle count badge click for preview popover
|
||||
this.$dropdown.on('click', '.filter-group-toggle .toggle-count.clickable', function(e) {
|
||||
e.stopPropagation();
|
||||
// Filter preview eye button (dropdown-level, since dropdown is appended to body)
|
||||
this.$dropdown.on('click', '.chip-preview-btn', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var $badge = $(this);
|
||||
var groupId = $badge.data('groupId');
|
||||
var groupType = $badge.data('type');
|
||||
var groupName = $badge.data('groupName');
|
||||
var $btn = $(this);
|
||||
|
||||
if ($badge.hasClass('popover-open')) {
|
||||
if ($btn.hasClass('popover-open')) {
|
||||
self.hidePreviewPopover();
|
||||
} else {
|
||||
self.showFilterGroupPreviewPopover($badge, groupId, groupType, groupName);
|
||||
var valueId = $btn.data('id');
|
||||
var valueType = $btn.data('type');
|
||||
var valueName = $btn.data('name');
|
||||
var groupId = $btn.data('groupId');
|
||||
|
||||
if (valueId) {
|
||||
self.showFilterValuePreviewPopover($btn, valueId, valueType, valueName, groupId);
|
||||
} else if (groupId) {
|
||||
self.showFilterGroupPreviewPopover($btn, groupId, valueType, valueName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2182,13 +2209,13 @@
|
||||
// If already pinned, unpin and close
|
||||
if ($wrapper.hasClass('pinned')) {
|
||||
$wrapper.removeClass('pinned');
|
||||
$wrapper.find('.material-icons').text('info_outline');
|
||||
$wrapper.find('.material-icons').text('info');
|
||||
$('.mpr-tooltip-fixed.pinned').remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any other pinned tooltips
|
||||
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info_outline');
|
||||
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info');
|
||||
$('.mpr-tooltip-fixed').remove();
|
||||
|
||||
var content = $wrapper.attr('data-tooltip');
|
||||
@@ -2208,7 +2235,7 @@
|
||||
// Close button click
|
||||
$closeBtn.on('click', function() {
|
||||
$wrapper.removeClass('pinned');
|
||||
$wrapper.find('.material-icons').text('info_outline');
|
||||
$wrapper.find('.material-icons').text('info');
|
||||
$tooltip.remove();
|
||||
});
|
||||
|
||||
@@ -2716,6 +2743,11 @@
|
||||
sort_dir: this.currentSort ? this.currentSort.dir : 'ASC'
|
||||
};
|
||||
|
||||
// Add product_selection_level if set
|
||||
if (this.config.productSelectionLevel && this.config.productSelectionLevel !== 'product') {
|
||||
requestData.product_selection_level = this.config.productSelectionLevel;
|
||||
}
|
||||
|
||||
// Add refine query if present
|
||||
if (this.refineQuery) {
|
||||
requestData.refine = this.refineQuery;
|
||||
@@ -3043,6 +3075,8 @@
|
||||
var isSelected = selectedIds.indexOf(String(item.id)) !== -1;
|
||||
var itemClass = 'dropdown-item' + (isSelected ? ' selected' : '');
|
||||
if (item.type === 'product') itemClass += ' result-item-product';
|
||||
if (item.is_combination) itemClass += ' is-combination';
|
||||
if (item.is_parent) itemClass += ' is-parent-product';
|
||||
|
||||
html += '<div class="' + itemClass + '" ';
|
||||
html += 'data-id="' + self.escapeAttr(item.id) + '" ';
|
||||
@@ -3050,6 +3084,7 @@
|
||||
if (item.image) html += ' data-image="' + self.escapeAttr(item.image) + '"';
|
||||
if (item.subtitle) html += ' data-subtitle="' + self.escapeAttr(item.subtitle) + '"';
|
||||
if (item.iso_code) html += ' data-iso="' + self.escapeAttr(item.iso_code) + '"';
|
||||
if (item.attributes) html += ' data-attributes="' + self.escapeAttr(item.attributes) + '"';
|
||||
html += '>';
|
||||
|
||||
html += '<span class="result-checkbox"><i class="icon-check"></i></span>';
|
||||
@@ -3786,6 +3821,7 @@
|
||||
if (!this.$dropdown || !this.filterableData) return;
|
||||
|
||||
var self = this;
|
||||
var previewLabel = self.config.trans && self.config.trans.preview || 'Preview';
|
||||
|
||||
// Render attribute group toggle buttons
|
||||
var $attrContainer = this.$dropdown.find('.filter-attributes-container');
|
||||
@@ -3793,12 +3829,17 @@
|
||||
|
||||
if (this.filterableData.attributes && this.filterableData.attributes.length > 0) {
|
||||
this.filterableData.attributes.forEach(function(group) {
|
||||
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||||
var html = '<span class="filter-chip-wrapper">';
|
||||
html += '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||||
html += '<span class="toggle-name">' + group.name + '</span>';
|
||||
if (group.count !== undefined) {
|
||||
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
|
||||
html += '<span class="toggle-count">(' + group.count + ')</span>';
|
||||
}
|
||||
html += '</button>';
|
||||
html += '<button type="button" class="chip-preview-btn" data-group-id="' + group.id + '" data-type="attribute" data-name="' + self.escapeAttr(group.name) + '" title="' + previewLabel + '">';
|
||||
html += '<i class="icon-eye"></i>';
|
||||
html += '</button>';
|
||||
html += '</span>';
|
||||
$attrContainer.append(html);
|
||||
});
|
||||
this.$dropdown.find('.filter-row-attributes').show();
|
||||
@@ -3810,12 +3851,17 @@
|
||||
|
||||
if (this.filterableData.features && this.filterableData.features.length > 0) {
|
||||
this.filterableData.features.forEach(function(group) {
|
||||
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||||
var html = '<span class="filter-chip-wrapper">';
|
||||
html += '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||||
html += '<span class="toggle-name">' + group.name + '</span>';
|
||||
if (group.count !== undefined) {
|
||||
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
|
||||
html += '<span class="toggle-count">(' + group.count + ')</span>';
|
||||
}
|
||||
html += '</button>';
|
||||
html += '<button type="button" class="chip-preview-btn" data-group-id="' + group.id + '" data-type="feature" data-name="' + self.escapeAttr(group.name) + '" title="' + previewLabel + '">';
|
||||
html += '<i class="icon-eye"></i>';
|
||||
html += '</button>';
|
||||
html += '</span>';
|
||||
$featContainer.append(html);
|
||||
});
|
||||
this.$dropdown.find('.filter-row-features').show();
|
||||
@@ -3853,6 +3899,7 @@
|
||||
var colorStyle = val.color ? ' style="--chip-color: ' + val.color + '"' : '';
|
||||
var colorClass = val.color ? ' has-color' : '';
|
||||
|
||||
html += '<span class="filter-chip-wrapper">';
|
||||
html += '<button type="button" class="filter-chip ' + chipClass + activeClass + colorClass + '" data-id="' + val.id + '" data-group-id="' + groupId + '"' + colorStyle + '>';
|
||||
if (val.color) {
|
||||
html += '<span class="chip-color-dot"></span>';
|
||||
@@ -3862,6 +3909,10 @@
|
||||
html += '<span class="chip-count">(' + val.count + ')</span>';
|
||||
}
|
||||
html += '</button>';
|
||||
html += '<button type="button" class="chip-preview-btn" data-id="' + val.id + '" data-group-id="' + groupId + '" data-type="' + type + '" data-name="' + self.escapeAttr(val.name) + '" title="' + (self.config.trans && self.config.trans.preview || 'Preview') + '">';
|
||||
html += '<i class="icon-eye"></i>';
|
||||
html += '</button>';
|
||||
html += '</span>';
|
||||
});
|
||||
|
||||
$valuesContainer.html(html);
|
||||
@@ -4378,16 +4429,20 @@
|
||||
console.log('[EntitySelector] Making bulk AJAX request for entities:', JSON.stringify(bulkRequest));
|
||||
|
||||
// Single bulk AJAX call for all entity types
|
||||
var bulkAjaxData = {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIdsBulk',
|
||||
trait: 'EntitySelector',
|
||||
entities: JSON.stringify(bulkRequest)
|
||||
};
|
||||
if (self.config.productSelectionLevel && self.config.productSelectionLevel !== 'product') {
|
||||
bulkAjaxData.product_selection_level = self.config.productSelectionLevel;
|
||||
}
|
||||
$.ajax({
|
||||
url: self.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIdsBulk',
|
||||
trait: 'EntitySelector',
|
||||
entities: JSON.stringify(bulkRequest)
|
||||
},
|
||||
data: bulkAjaxData,
|
||||
success: function(response) {
|
||||
console.log('[EntitySelector] AJAX response:', response);
|
||||
if (!response.success || !response.entities) {
|
||||
@@ -4706,18 +4761,22 @@
|
||||
|
||||
// Handle entity_search type - load via AJAX
|
||||
var searchEntity = $picker.attr('data-search-entity') || blockType;
|
||||
var pickerAjaxData = {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIds',
|
||||
trait: 'EntitySelector',
|
||||
entity_type: searchEntity,
|
||||
ids: JSON.stringify(values)
|
||||
};
|
||||
if (this.config.productSelectionLevel && this.config.productSelectionLevel !== 'product') {
|
||||
pickerAjaxData.product_selection_level = this.config.productSelectionLevel;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIds',
|
||||
trait: 'EntitySelector',
|
||||
entity_type: searchEntity,
|
||||
ids: JSON.stringify(values)
|
||||
},
|
||||
data: pickerAjaxData,
|
||||
success: function(response) {
|
||||
if (response.success && response.entities) {
|
||||
// Track which IDs were actually found (entities may have been deleted)
|
||||
@@ -7167,7 +7226,8 @@
|
||||
clearValidationError: function() {
|
||||
this.$wrapper.removeClass('has-validation-error');
|
||||
this.$wrapper.find('.trait-validation-error').remove();
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
})(jQuery);
|
||||
@@ -7760,7 +7820,7 @@
|
||||
class: 'mpr-info-wrapper',
|
||||
'data-tooltip': helpContent
|
||||
});
|
||||
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info_outline' }));
|
||||
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info' }));
|
||||
$placeholder.append($infoWrapper);
|
||||
}
|
||||
},
|
||||
@@ -8240,23 +8300,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
// Position popover relative to badge (handles viewport overflow)
|
||||
this.positionPopover($popover, $badge);
|
||||
|
||||
// Show with transition
|
||||
$popover.addClass('show');
|
||||
@@ -8264,6 +8309,61 @@
|
||||
return $popover;
|
||||
},
|
||||
|
||||
/**
|
||||
* Position a popover relative to a trigger element.
|
||||
* Handles horizontal and vertical viewport overflow.
|
||||
* @param {jQuery} $popover - The popover element
|
||||
* @param {jQuery} $trigger - The trigger element to position against
|
||||
* @param {number} [zIndex=10000] - Optional z-index
|
||||
*/
|
||||
positionPopover: function($popover, $trigger, zIndex) {
|
||||
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();
|
||||
var gap = 8;
|
||||
|
||||
// Horizontal: center on trigger, then clamp to viewport
|
||||
var left = triggerRect.left + scrollLeft + (triggerRect.width / 2) - (popoverWidth / 2);
|
||||
left = Math.max(10, Math.min(left, windowWidth - popoverWidth - 10));
|
||||
|
||||
// Vertical: prefer below, flip above if it would overflow
|
||||
var top;
|
||||
var positionAbove = false;
|
||||
if (triggerRect.bottom + popoverHeight + gap > windowHeight - 10) {
|
||||
// Position above the trigger
|
||||
top = triggerRect.top + scrollTop - popoverHeight - gap;
|
||||
positionAbove = true;
|
||||
} else {
|
||||
// Position below the trigger
|
||||
top = triggerRect.bottom + scrollTop + gap;
|
||||
}
|
||||
|
||||
$popover.css({
|
||||
position: 'absolute',
|
||||
top: top,
|
||||
left: left,
|
||||
zIndex: zIndex || 10000
|
||||
});
|
||||
|
||||
// Toggle arrow direction class
|
||||
$popover.toggleClass('position-above', positionAbove);
|
||||
|
||||
// Determine horizontal arrow position class
|
||||
var triggerCenter = triggerRect.left + (triggerRect.width / 2);
|
||||
var popoverLeft = left;
|
||||
var popoverCenter = popoverLeft + (popoverWidth / 2);
|
||||
$popover.removeClass('position-left position-right');
|
||||
if (triggerCenter - popoverLeft < popoverWidth * 0.3) {
|
||||
$popover.addClass('position-right');
|
||||
} else if (triggerCenter - popoverLeft > popoverWidth * 0.7) {
|
||||
$popover.addClass('position-left');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update popover after loading more items
|
||||
*/
|
||||
@@ -9228,6 +9328,144 @@
|
||||
});
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// FILTER VALUE PREVIEW (individual attribute/feature value chip)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Show preview popover for a specific filter value (attribute or feature value)
|
||||
* @param {jQuery} $badge - The preview button on the chip
|
||||
* @param {number} valueId - The attribute/feature value ID
|
||||
* @param {string} valueType - 'attribute' or 'feature'
|
||||
* @param {string} valueName - Display name of the value
|
||||
* @param {number} groupId - The parent group ID
|
||||
*/
|
||||
showFilterValuePreviewPopover: function($badge, valueId, valueType, valueName, groupId) {
|
||||
var self = this;
|
||||
|
||||
this.hidePreviewPopover();
|
||||
|
||||
$badge.addClass('popover-open loading');
|
||||
this.$activeBadge = $badge;
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'previewFilterValueProducts',
|
||||
trait: 'EntitySelector',
|
||||
value_id: valueId,
|
||||
value_type: valueType,
|
||||
group_id: groupId,
|
||||
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: 'products',
|
||||
previewType: 'filter-value',
|
||||
context: { valueId: valueId, valueType: valueType, groupId: groupId, valueName: valueName },
|
||||
onLoadMore: function($btn) {
|
||||
self.loadMoreFilterValueItems($btn);
|
||||
},
|
||||
onFilter: function(query) {
|
||||
self.filterFilterValueItems(query);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$badge.removeClass('popover-open');
|
||||
self.$activeBadge = null;
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$badge.removeClass('loading popover-open');
|
||||
self.$activeBadge = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* AJAX filter handler for filter value preview
|
||||
*/
|
||||
filterFilterValueItems: function(query) {
|
||||
var self = this;
|
||||
var ctx = this.previewContext;
|
||||
|
||||
if (!ctx || !ctx.valueId) {
|
||||
self.showFilterLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'previewFilterValueProducts',
|
||||
trait: 'EntitySelector',
|
||||
value_id: ctx.valueId,
|
||||
value_type: ctx.valueType,
|
||||
group_id: ctx.groupId,
|
||||
filter: query,
|
||||
limit: 20
|
||||
},
|
||||
success: function(response) {
|
||||
self.updatePreviewPopoverFiltered(response);
|
||||
},
|
||||
error: function() {
|
||||
self.showFilterLoading(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadMoreFilterValueItems: function($btn) {
|
||||
var self = this;
|
||||
var ctx = this.previewContext;
|
||||
|
||||
if (!ctx || !ctx.valueId) return;
|
||||
|
||||
var loadCount = this.previewLoadCount || 20;
|
||||
|
||||
var ajaxData = {
|
||||
ajax: 1,
|
||||
action: 'previewFilterValueProducts',
|
||||
trait: 'EntitySelector',
|
||||
value_id: ctx.valueId,
|
||||
value_type: ctx.valueType,
|
||||
group_id: ctx.groupId,
|
||||
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('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// CATEGORY ITEMS PREVIEW (products/pages in a category)
|
||||
// =========================================================================
|
||||
@@ -9565,28 +9803,9 @@
|
||||
self.switchToBlock(blockType);
|
||||
});
|
||||
|
||||
// Position popover
|
||||
// Position popover relative to badge (handles viewport overflow)
|
||||
$('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);
|
||||
}
|
||||
this.positionPopover($popover, $badge);
|
||||
|
||||
$popover.hide().fadeIn(150);
|
||||
},
|
||||
|
||||
2
assets/js/admin/entity-selector.min.js
vendored
2
assets/js/admin/entity-selector.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -422,16 +422,20 @@
|
||||
console.log('[EntitySelector] Making bulk AJAX request for entities:', JSON.stringify(bulkRequest));
|
||||
|
||||
// Single bulk AJAX call for all entity types
|
||||
var bulkAjaxData = {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIdsBulk',
|
||||
trait: 'EntitySelector',
|
||||
entities: JSON.stringify(bulkRequest)
|
||||
};
|
||||
if (self.config.productSelectionLevel && self.config.productSelectionLevel !== 'product') {
|
||||
bulkAjaxData.product_selection_level = self.config.productSelectionLevel;
|
||||
}
|
||||
$.ajax({
|
||||
url: self.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIdsBulk',
|
||||
trait: 'EntitySelector',
|
||||
entities: JSON.stringify(bulkRequest)
|
||||
},
|
||||
data: bulkAjaxData,
|
||||
success: function(response) {
|
||||
console.log('[EntitySelector] AJAX response:', response);
|
||||
if (!response.success || !response.entities) {
|
||||
@@ -750,18 +754,22 @@
|
||||
|
||||
// Handle entity_search type - load via AJAX
|
||||
var searchEntity = $picker.attr('data-search-entity') || blockType;
|
||||
var pickerAjaxData = {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIds',
|
||||
trait: 'EntitySelector',
|
||||
entity_type: searchEntity,
|
||||
ids: JSON.stringify(values)
|
||||
};
|
||||
if (this.config.productSelectionLevel && this.config.productSelectionLevel !== 'product') {
|
||||
pickerAjaxData.product_selection_level = this.config.productSelectionLevel;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'getTargetEntitiesByIds',
|
||||
trait: 'EntitySelector',
|
||||
entity_type: searchEntity,
|
||||
ids: JSON.stringify(values)
|
||||
},
|
||||
data: pickerAjaxData,
|
||||
success: function(response) {
|
||||
if (response.success && response.entities) {
|
||||
// Track which IDs were actually found (entities may have been deleted)
|
||||
|
||||
@@ -107,9 +107,9 @@
|
||||
!$(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) {
|
||||
!$(e.target).closest('.chip-preview-holidays').length &&
|
||||
!$(e.target).closest('.chip-preview-btn').length) {
|
||||
self.hidePreviewPopover();
|
||||
// Also close holiday popover
|
||||
$('.holiday-preview-popover').remove();
|
||||
@@ -152,6 +152,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Filter chip/group preview eye button (unified handler)
|
||||
this.$wrapper.on('click', '.chip-preview-btn', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var $btn = $(this);
|
||||
|
||||
if ($btn.hasClass('popover-open')) {
|
||||
self.hidePreviewPopover();
|
||||
} else {
|
||||
var valueId = $btn.data('id');
|
||||
var valueType = $btn.data('type');
|
||||
var valueName = $btn.data('name');
|
||||
var groupId = $btn.data('groupId');
|
||||
|
||||
if (valueId) {
|
||||
// Value-level preview (specific attribute/feature value)
|
||||
self.showFilterValuePreviewPopover($btn, valueId, valueType, valueName, groupId);
|
||||
} else if (groupId) {
|
||||
// Group-level preview (entire attribute/feature group)
|
||||
self.showFilterGroupPreviewPopover($btn, groupId, valueType, valueName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
@@ -1031,7 +1056,7 @@
|
||||
if (isSelected) {
|
||||
// Remove from pending selections
|
||||
self.pendingSelections = self.pendingSelections.filter(function(s) {
|
||||
return parseInt(s.id, 10) !== parseInt(id, 10);
|
||||
return String(s.id) !== String(id);
|
||||
});
|
||||
self.removeSelection($picker, id);
|
||||
$item.toggleClass('selected');
|
||||
@@ -1058,7 +1083,7 @@
|
||||
} else {
|
||||
// Add to pending selections
|
||||
var exists = self.pendingSelections.some(function(s) {
|
||||
return parseInt(s.id, 10) === parseInt(id, 10);
|
||||
return String(s.id) === String(id);
|
||||
});
|
||||
if (!exists) {
|
||||
self.pendingSelections.push({ id: id, name: name, data: $item.data() });
|
||||
@@ -1083,7 +1108,7 @@
|
||||
// Also remove from pending selections if dropdown is open
|
||||
if (self.pendingSelections) {
|
||||
self.pendingSelections = self.pendingSelections.filter(function(s) {
|
||||
return parseInt(s.id, 10) !== parseInt(id, 10);
|
||||
return String(s.id) !== String(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1210,7 +1235,7 @@
|
||||
|
||||
// Add to pending selections for Save button
|
||||
var exists = self.pendingSelections.some(function(s) {
|
||||
return parseInt(s.id, 10) === parseInt(id, 10);
|
||||
return String(s.id) === String(id);
|
||||
});
|
||||
if (!exists) {
|
||||
self.pendingSelections.push({
|
||||
@@ -1825,10 +1850,6 @@
|
||||
|
||||
// Toggle filter group - show values
|
||||
this.$dropdown.on('click', '.filter-group-toggle', function(e) {
|
||||
// Ignore clicks on the preview badge
|
||||
if ($(e.target).closest('.toggle-count.clickable').length) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
var $btn = $(this);
|
||||
var groupId = $btn.data('group-id');
|
||||
@@ -1845,20 +1866,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Filter group toggle count badge click for preview popover
|
||||
this.$dropdown.on('click', '.filter-group-toggle .toggle-count.clickable', function(e) {
|
||||
e.stopPropagation();
|
||||
// Filter preview eye button (dropdown-level, since dropdown is appended to body)
|
||||
this.$dropdown.on('click', '.chip-preview-btn', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
var $badge = $(this);
|
||||
var groupId = $badge.data('groupId');
|
||||
var groupType = $badge.data('type');
|
||||
var groupName = $badge.data('groupName');
|
||||
var $btn = $(this);
|
||||
|
||||
if ($badge.hasClass('popover-open')) {
|
||||
if ($btn.hasClass('popover-open')) {
|
||||
self.hidePreviewPopover();
|
||||
} else {
|
||||
self.showFilterGroupPreviewPopover($badge, groupId, groupType, groupName);
|
||||
var valueId = $btn.data('id');
|
||||
var valueType = $btn.data('type');
|
||||
var valueName = $btn.data('name');
|
||||
var groupId = $btn.data('groupId');
|
||||
|
||||
if (valueId) {
|
||||
self.showFilterValuePreviewPopover($btn, valueId, valueType, valueName, groupId);
|
||||
} else if (groupId) {
|
||||
self.showFilterGroupPreviewPopover($btn, groupId, valueType, valueName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2017,13 +2044,13 @@
|
||||
// If already pinned, unpin and close
|
||||
if ($wrapper.hasClass('pinned')) {
|
||||
$wrapper.removeClass('pinned');
|
||||
$wrapper.find('.material-icons').text('info_outline');
|
||||
$wrapper.find('.material-icons').text('info');
|
||||
$('.mpr-tooltip-fixed.pinned').remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close any other pinned tooltips
|
||||
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info_outline');
|
||||
$('.mpr-info-wrapper.pinned').removeClass('pinned').find('.material-icons').text('info');
|
||||
$('.mpr-tooltip-fixed').remove();
|
||||
|
||||
var content = $wrapper.attr('data-tooltip');
|
||||
@@ -2043,7 +2070,7 @@
|
||||
// Close button click
|
||||
$closeBtn.on('click', function() {
|
||||
$wrapper.removeClass('pinned');
|
||||
$wrapper.find('.material-icons').text('info_outline');
|
||||
$wrapper.find('.material-icons').text('info');
|
||||
$tooltip.remove();
|
||||
});
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@
|
||||
if (!this.$dropdown || !this.filterableData) return;
|
||||
|
||||
var self = this;
|
||||
var previewLabel = self.config.trans && self.config.trans.preview || 'Preview';
|
||||
|
||||
// Render attribute group toggle buttons
|
||||
var $attrContainer = this.$dropdown.find('.filter-attributes-container');
|
||||
@@ -235,12 +236,17 @@
|
||||
|
||||
if (this.filterableData.attributes && this.filterableData.attributes.length > 0) {
|
||||
this.filterableData.attributes.forEach(function(group) {
|
||||
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||||
var html = '<span class="filter-chip-wrapper">';
|
||||
html += '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||||
html += '<span class="toggle-name">' + group.name + '</span>';
|
||||
if (group.count !== undefined) {
|
||||
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="attribute" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
|
||||
html += '<span class="toggle-count">(' + group.count + ')</span>';
|
||||
}
|
||||
html += '</button>';
|
||||
html += '<button type="button" class="chip-preview-btn" data-group-id="' + group.id + '" data-type="attribute" data-name="' + self.escapeAttr(group.name) + '" title="' + previewLabel + '">';
|
||||
html += '<i class="icon-eye"></i>';
|
||||
html += '</button>';
|
||||
html += '</span>';
|
||||
$attrContainer.append(html);
|
||||
});
|
||||
this.$dropdown.find('.filter-row-attributes').show();
|
||||
@@ -252,12 +258,17 @@
|
||||
|
||||
if (this.filterableData.features && this.filterableData.features.length > 0) {
|
||||
this.filterableData.features.forEach(function(group) {
|
||||
var html = '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||||
var html = '<span class="filter-chip-wrapper">';
|
||||
html += '<button type="button" class="filter-group-toggle" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '">';
|
||||
html += '<span class="toggle-name">' + group.name + '</span>';
|
||||
if (group.count !== undefined) {
|
||||
html += '<span class="toggle-count clickable" data-group-id="' + group.id + '" data-type="feature" data-group-name="' + self.escapeAttr(group.name) + '"><i class="icon-eye"></i> ' + group.count + '</span>';
|
||||
html += '<span class="toggle-count">(' + group.count + ')</span>';
|
||||
}
|
||||
html += '</button>';
|
||||
html += '<button type="button" class="chip-preview-btn" data-group-id="' + group.id + '" data-type="feature" data-name="' + self.escapeAttr(group.name) + '" title="' + previewLabel + '">';
|
||||
html += '<i class="icon-eye"></i>';
|
||||
html += '</button>';
|
||||
html += '</span>';
|
||||
$featContainer.append(html);
|
||||
});
|
||||
this.$dropdown.find('.filter-row-features').show();
|
||||
@@ -295,6 +306,7 @@
|
||||
var colorStyle = val.color ? ' style="--chip-color: ' + val.color + '"' : '';
|
||||
var colorClass = val.color ? ' has-color' : '';
|
||||
|
||||
html += '<span class="filter-chip-wrapper">';
|
||||
html += '<button type="button" class="filter-chip ' + chipClass + activeClass + colorClass + '" data-id="' + val.id + '" data-group-id="' + groupId + '"' + colorStyle + '>';
|
||||
if (val.color) {
|
||||
html += '<span class="chip-color-dot"></span>';
|
||||
@@ -304,6 +316,10 @@
|
||||
html += '<span class="chip-count">(' + val.count + ')</span>';
|
||||
}
|
||||
html += '</button>';
|
||||
html += '<button type="button" class="chip-preview-btn" data-id="' + val.id + '" data-group-id="' + groupId + '" data-type="' + type + '" data-name="' + self.escapeAttr(val.name) + '" title="' + (self.config.trans && self.config.trans.preview || 'Preview') + '">';
|
||||
html += '<i class="icon-eye"></i>';
|
||||
html += '</button>';
|
||||
html += '</span>';
|
||||
});
|
||||
|
||||
$valuesContainer.html(html);
|
||||
|
||||
@@ -1852,7 +1852,8 @@
|
||||
clearValidationError: function() {
|
||||
this.$wrapper.removeClass('has-validation-error');
|
||||
this.$wrapper.find('.trait-validation-error').remove();
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
})(jQuery);
|
||||
|
||||
@@ -586,7 +586,7 @@
|
||||
class: 'mpr-info-wrapper',
|
||||
'data-tooltip': helpContent
|
||||
});
|
||||
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info_outline' }));
|
||||
$infoWrapper.append($('<i>', { class: 'material-icons', text: 'info' }));
|
||||
$placeholder.append($infoWrapper);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -192,23 +192,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
// Position popover relative to badge (handles viewport overflow)
|
||||
this.positionPopover($popover, $badge);
|
||||
|
||||
// Show with transition
|
||||
$popover.addClass('show');
|
||||
@@ -216,6 +201,61 @@
|
||||
return $popover;
|
||||
},
|
||||
|
||||
/**
|
||||
* Position a popover relative to a trigger element.
|
||||
* Handles horizontal and vertical viewport overflow.
|
||||
* @param {jQuery} $popover - The popover element
|
||||
* @param {jQuery} $trigger - The trigger element to position against
|
||||
* @param {number} [zIndex=10000] - Optional z-index
|
||||
*/
|
||||
positionPopover: function($popover, $trigger, zIndex) {
|
||||
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();
|
||||
var gap = 8;
|
||||
|
||||
// Horizontal: center on trigger, then clamp to viewport
|
||||
var left = triggerRect.left + scrollLeft + (triggerRect.width / 2) - (popoverWidth / 2);
|
||||
left = Math.max(10, Math.min(left, windowWidth - popoverWidth - 10));
|
||||
|
||||
// Vertical: prefer below, flip above if it would overflow
|
||||
var top;
|
||||
var positionAbove = false;
|
||||
if (triggerRect.bottom + popoverHeight + gap > windowHeight - 10) {
|
||||
// Position above the trigger
|
||||
top = triggerRect.top + scrollTop - popoverHeight - gap;
|
||||
positionAbove = true;
|
||||
} else {
|
||||
// Position below the trigger
|
||||
top = triggerRect.bottom + scrollTop + gap;
|
||||
}
|
||||
|
||||
$popover.css({
|
||||
position: 'absolute',
|
||||
top: top,
|
||||
left: left,
|
||||
zIndex: zIndex || 10000
|
||||
});
|
||||
|
||||
// Toggle arrow direction class
|
||||
$popover.toggleClass('position-above', positionAbove);
|
||||
|
||||
// Determine horizontal arrow position class
|
||||
var triggerCenter = triggerRect.left + (triggerRect.width / 2);
|
||||
var popoverLeft = left;
|
||||
var popoverCenter = popoverLeft + (popoverWidth / 2);
|
||||
$popover.removeClass('position-left position-right');
|
||||
if (triggerCenter - popoverLeft < popoverWidth * 0.3) {
|
||||
$popover.addClass('position-right');
|
||||
} else if (triggerCenter - popoverLeft > popoverWidth * 0.7) {
|
||||
$popover.addClass('position-left');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update popover after loading more items
|
||||
*/
|
||||
@@ -1180,6 +1220,144 @@
|
||||
});
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// FILTER VALUE PREVIEW (individual attribute/feature value chip)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Show preview popover for a specific filter value (attribute or feature value)
|
||||
* @param {jQuery} $badge - The preview button on the chip
|
||||
* @param {number} valueId - The attribute/feature value ID
|
||||
* @param {string} valueType - 'attribute' or 'feature'
|
||||
* @param {string} valueName - Display name of the value
|
||||
* @param {number} groupId - The parent group ID
|
||||
*/
|
||||
showFilterValuePreviewPopover: function($badge, valueId, valueType, valueName, groupId) {
|
||||
var self = this;
|
||||
|
||||
this.hidePreviewPopover();
|
||||
|
||||
$badge.addClass('popover-open loading');
|
||||
this.$activeBadge = $badge;
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'previewFilterValueProducts',
|
||||
trait: 'EntitySelector',
|
||||
value_id: valueId,
|
||||
value_type: valueType,
|
||||
group_id: groupId,
|
||||
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: 'products',
|
||||
previewType: 'filter-value',
|
||||
context: { valueId: valueId, valueType: valueType, groupId: groupId, valueName: valueName },
|
||||
onLoadMore: function($btn) {
|
||||
self.loadMoreFilterValueItems($btn);
|
||||
},
|
||||
onFilter: function(query) {
|
||||
self.filterFilterValueItems(query);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$badge.removeClass('popover-open');
|
||||
self.$activeBadge = null;
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$badge.removeClass('loading popover-open');
|
||||
self.$activeBadge = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* AJAX filter handler for filter value preview
|
||||
*/
|
||||
filterFilterValueItems: function(query) {
|
||||
var self = this;
|
||||
var ctx = this.previewContext;
|
||||
|
||||
if (!ctx || !ctx.valueId) {
|
||||
self.showFilterLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: this.config.ajaxUrl,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: {
|
||||
ajax: 1,
|
||||
action: 'previewFilterValueProducts',
|
||||
trait: 'EntitySelector',
|
||||
value_id: ctx.valueId,
|
||||
value_type: ctx.valueType,
|
||||
group_id: ctx.groupId,
|
||||
filter: query,
|
||||
limit: 20
|
||||
},
|
||||
success: function(response) {
|
||||
self.updatePreviewPopoverFiltered(response);
|
||||
},
|
||||
error: function() {
|
||||
self.showFilterLoading(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadMoreFilterValueItems: function($btn) {
|
||||
var self = this;
|
||||
var ctx = this.previewContext;
|
||||
|
||||
if (!ctx || !ctx.valueId) return;
|
||||
|
||||
var loadCount = this.previewLoadCount || 20;
|
||||
|
||||
var ajaxData = {
|
||||
ajax: 1,
|
||||
action: 'previewFilterValueProducts',
|
||||
trait: 'EntitySelector',
|
||||
value_id: ctx.valueId,
|
||||
value_type: ctx.valueType,
|
||||
group_id: ctx.groupId,
|
||||
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('i').removeClass('icon-spinner icon-spin').addClass('icon-plus');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// CATEGORY ITEMS PREVIEW (products/pages in a category)
|
||||
// =========================================================================
|
||||
@@ -1517,28 +1695,9 @@
|
||||
self.switchToBlock(blockType);
|
||||
});
|
||||
|
||||
// Position popover
|
||||
// Position popover relative to badge (handles viewport overflow)
|
||||
$('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);
|
||||
}
|
||||
this.positionPopover($popover, $badge);
|
||||
|
||||
$popover.hide().fadeIn(150);
|
||||
},
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
sort_dir: this.currentSort ? this.currentSort.dir : 'ASC'
|
||||
};
|
||||
|
||||
// Add product_selection_level if set
|
||||
if (this.config.productSelectionLevel && this.config.productSelectionLevel !== 'product') {
|
||||
requestData.product_selection_level = this.config.productSelectionLevel;
|
||||
}
|
||||
|
||||
// Add refine query if present
|
||||
if (this.refineQuery) {
|
||||
requestData.refine = this.refineQuery;
|
||||
@@ -367,6 +372,8 @@
|
||||
var isSelected = selectedIds.indexOf(String(item.id)) !== -1;
|
||||
var itemClass = 'dropdown-item' + (isSelected ? ' selected' : '');
|
||||
if (item.type === 'product') itemClass += ' result-item-product';
|
||||
if (item.is_combination) itemClass += ' is-combination';
|
||||
if (item.is_parent) itemClass += ' is-parent-product';
|
||||
|
||||
html += '<div class="' + itemClass + '" ';
|
||||
html += 'data-id="' + self.escapeAttr(item.id) + '" ';
|
||||
@@ -374,6 +381,7 @@
|
||||
if (item.image) html += ' data-image="' + self.escapeAttr(item.image) + '"';
|
||||
if (item.subtitle) html += ' data-subtitle="' + self.escapeAttr(item.subtitle) + '"';
|
||||
if (item.iso_code) html += ' data-iso="' + self.escapeAttr(item.iso_code) + '"';
|
||||
if (item.attributes) html += ' data-attributes="' + self.escapeAttr(item.attributes) + '"';
|
||||
html += '>';
|
||||
|
||||
html += '<span class="result-checkbox"><i class="icon-check"></i></span>';
|
||||
|
||||
@@ -343,6 +343,16 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chip-attrs {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-left: 2px;
|
||||
|
||||
&::before {
|
||||
content: '— ';
|
||||
}
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
@include button-reset;
|
||||
display: flex;
|
||||
|
||||
@@ -359,6 +359,20 @@
|
||||
gap: $es-spacing-sm;
|
||||
}
|
||||
|
||||
// Combination-level search results ('both' mode)
|
||||
.dropdown-item.is-combination {
|
||||
padding-left: 28px;
|
||||
|
||||
.result-name {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item.is-parent-product {
|
||||
background: $es-slate-50;
|
||||
font-weight: $es-font-weight-medium;
|
||||
}
|
||||
|
||||
// No results state
|
||||
.no-results {
|
||||
display: flex;
|
||||
@@ -1290,50 +1304,9 @@ body > .target-search-dropdown,
|
||||
font-weight: $es-font-weight-medium;
|
||||
}
|
||||
|
||||
// Count with eye icon (like group-count-badge)
|
||||
.toggle-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
color: $es-text-muted;
|
||||
font-size: 0.65rem;
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
color: $es-primary;
|
||||
}
|
||||
|
||||
// Clickable preview badge
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: $es-radius-sm;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: rgba($es-primary, 0.1);
|
||||
color: $es-primary;
|
||||
|
||||
i {
|
||||
color: $es-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&.popover-open {
|
||||
background: $es-primary;
|
||||
color: $es-white;
|
||||
|
||||
i {
|
||||
color: $es-white;
|
||||
}
|
||||
}
|
||||
|
||||
&.loading {
|
||||
i {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1426,6 +1399,67 @@ body > .target-search-dropdown,
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
// Filter chip wrapper (chip/toggle + preview button)
|
||||
.filter-chip-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border-radius: $es-radius-sm;
|
||||
overflow: hidden;
|
||||
|
||||
// Left element gets left-only border-radius
|
||||
.filter-chip,
|
||||
.filter-group-toggle {
|
||||
border-radius: $es-radius-sm 0 0 $es-radius-sm;
|
||||
}
|
||||
|
||||
// Preview eye button — unified for both value chips and group toggles
|
||||
.chip-preview-btn {
|
||||
@include button-reset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 10px;
|
||||
color: $es-text-muted;
|
||||
background: $es-slate-100;
|
||||
border-left: 1px solid $es-border-color;
|
||||
border-radius: 0 $es-radius-sm $es-radius-sm 0;
|
||||
cursor: pointer;
|
||||
transition: all $es-transition-fast;
|
||||
|
||||
&:hover {
|
||||
background: rgba($es-primary, 0.1);
|
||||
color: $es-primary;
|
||||
}
|
||||
|
||||
&.popover-open {
|
||||
background: $es-primary;
|
||||
color: $es-white;
|
||||
}
|
||||
|
||||
&.loading i {
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// When no preview button, restore full border-radius
|
||||
.filter-chip:last-child,
|
||||
.filter-group-toggle:last-child {
|
||||
border-radius: $es-radius-sm;
|
||||
}
|
||||
|
||||
// Group toggle active/has-selection states propagate to wrapper border
|
||||
.filter-group-toggle.active + .chip-preview-btn {
|
||||
border-left-color: $es-primary;
|
||||
background: rgba($es-primary, 0.05);
|
||||
}
|
||||
|
||||
.filter-group-toggle.has-selection + .chip-preview-btn {
|
||||
border-left-color: $es-success;
|
||||
background: rgba($es-success, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown content
|
||||
.dropdown-content {
|
||||
max-height: 400px;
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
// Single mode specific styles
|
||||
.target-conditions-trait.single-mode,
|
||||
.entity-selector-trait.single-mode {
|
||||
// Hide tabs in standalone layout (has separate header, 1 tab is redundant)
|
||||
.target-block-tabs {
|
||||
display: none;
|
||||
}
|
||||
@@ -375,6 +376,11 @@
|
||||
.target-block-container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// In form-content layout, always show tabs — they serve as the block title
|
||||
.entity-selector-tabs-row .target-block-tabs {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
// Header action buttons
|
||||
|
||||
@@ -931,6 +931,36 @@
|
||||
background-size: 1.25em 1.25em;
|
||||
}
|
||||
|
||||
// Single mode — strip padding, borders, backgrounds for clean single-selection UI
|
||||
&[data-mode=single],
|
||||
.mode-single {
|
||||
.groups-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.group-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.group-include {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.selection-group {
|
||||
background: transparent;
|
||||
border: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Condition match count badge
|
||||
.condition-match-count {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -66,6 +66,23 @@
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Positioned above trigger - arrow pointing down
|
||||
&.position-above {
|
||||
&::before {
|
||||
top: auto;
|
||||
bottom: -8px;
|
||||
border-top: 8px solid $es-border-color;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: auto;
|
||||
bottom: -6px;
|
||||
border-top: 6px solid $es-white;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -156,8 +156,11 @@ trait EntitySelector
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base path for target conditions assets from vendor package
|
||||
* Assets are loaded directly from the package - no manual copying needed
|
||||
* Get base path for entity selector assets from the vendor package.
|
||||
* Auto-detects the correct path via reflection — no manual copying needed.
|
||||
*
|
||||
* DO NOT override this method. DO NOT copy CSS/JS to views/ directories.
|
||||
* The sync script (sync-entity-selector.sh) enforces single-source assets.
|
||||
*
|
||||
* @return string URL path ending with /
|
||||
*/
|
||||
@@ -172,8 +175,17 @@ trait EntitySelector
|
||||
$assetsDir = $packageDir . '/assets/';
|
||||
|
||||
// Convert filesystem path to URL path relative to PS root
|
||||
$psRoot = _PS_ROOT_DIR_ . '/';
|
||||
$relativePath = str_replace($psRoot, '', $assetsDir);
|
||||
// Use realpath() on PS root to handle symlinks consistently
|
||||
$psRoot = realpath(_PS_ROOT_DIR_) . '/';
|
||||
$assetsReal = realpath($packageDir . '/assets');
|
||||
$relativePath = $assetsReal ? str_replace($psRoot, '', $assetsReal . '/') : null;
|
||||
|
||||
// If symlink resolution caused the path to escape PS root, fall back to module URI
|
||||
if (!$relativePath || strpos($relativePath, '/') === 0 || strpos($relativePath, '..') !== false) {
|
||||
// Use module's web-accessible path as base
|
||||
$modulePath = $this->module->getPathUri();
|
||||
return $modulePath . 'vendor/myprestarocks/prestashop-entity-selector/assets/';
|
||||
}
|
||||
|
||||
return __PS_BASE_URI__ . $relativePath;
|
||||
}
|
||||
@@ -275,6 +287,9 @@ trait EntitySelector
|
||||
case 'previewFilterGroupProducts':
|
||||
$this->ajaxPreviewFilterGroupProducts();
|
||||
return true;
|
||||
case 'previewFilterValueProducts':
|
||||
$this->ajaxPreviewFilterValueProducts();
|
||||
return true;
|
||||
case 'previewCategoryProducts':
|
||||
$this->ajaxPreviewCategoryProducts();
|
||||
return true;
|
||||
@@ -837,6 +852,114 @@ trait EntitySelector
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Preview products for a specific attribute/feature value
|
||||
* Used by filter chip eye button
|
||||
*/
|
||||
protected function ajaxPreviewFilterValueProducts()
|
||||
{
|
||||
$valueId = (int) Tools::getValue('value_id');
|
||||
$valueType = Tools::getValue('value_type'); // 'attribute' or 'feature'
|
||||
$groupId = (int) Tools::getValue('group_id');
|
||||
$limit = (int) Tools::getValue('limit', 10);
|
||||
$filter = Tools::getValue('filter', '');
|
||||
|
||||
if (!$valueId || !in_array($valueType, ['attribute', 'feature'])) {
|
||||
die(json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Invalid parameters'
|
||||
]));
|
||||
}
|
||||
|
||||
$idLang = (int) Context::getContext()->language->id;
|
||||
$idShop = (int) Context::getContext()->shop->id;
|
||||
|
||||
try {
|
||||
$db = Db::getInstance();
|
||||
|
||||
if ($valueType === 'attribute') {
|
||||
$sql = new DbQuery();
|
||||
$sql->select('DISTINCT p.id_product');
|
||||
$sql->from('product', 'p');
|
||||
$sql->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . $idShop);
|
||||
$sql->innerJoin('product_attribute', 'pa', 'pa.id_product = p.id_product');
|
||||
$sql->innerJoin('product_attribute_combination', 'pac', 'pac.id_product_attribute = pa.id_product_attribute');
|
||||
$sql->where('pac.id_attribute = ' . $valueId);
|
||||
$sql->where('ps.active = 1');
|
||||
} else {
|
||||
$sql = new DbQuery();
|
||||
$sql->select('DISTINCT p.id_product');
|
||||
$sql->from('product', 'p');
|
||||
$sql->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . $idShop);
|
||||
$sql->innerJoin('feature_product', 'fp', 'fp.id_product = p.id_product');
|
||||
$sql->where('fp.id_feature_value = ' . $valueId);
|
||||
$sql->where('ps.active = 1');
|
||||
}
|
||||
|
||||
$results = $db->executeS($sql);
|
||||
$productIds = array_column($results, 'id_product');
|
||||
|
||||
if (!empty($filter) && !empty($productIds)) {
|
||||
$productIds = $this->getEntityPreviewHandler()->filterProductIdsByQuery($productIds, $filter, $idLang, $idShop);
|
||||
}
|
||||
|
||||
$totalCount = count($productIds);
|
||||
$previewIds = array_slice($productIds, 0, $limit);
|
||||
|
||||
$items = [];
|
||||
if (!empty($previewIds)) {
|
||||
$productSql = new DbQuery();
|
||||
$productSql->select('p.id_product, pl.name, p.reference, i.id_image, m.name as manufacturer');
|
||||
$productSql->from('product', 'p');
|
||||
$productSql->innerJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . $idLang . ' AND pl.id_shop = ' . $idShop);
|
||||
$productSql->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
|
||||
$productSql->leftJoin('manufacturer', 'm', 'm.id_manufacturer = p.id_manufacturer');
|
||||
$productSql->where('p.id_product IN (' . implode(',', array_map('intval', $previewIds)) . ')');
|
||||
|
||||
$products = $db->executeS($productSql);
|
||||
|
||||
$productsById = [];
|
||||
foreach ($products as $product) {
|
||||
$imageUrl = null;
|
||||
if ($product['id_image']) {
|
||||
$imageUrl = Context::getContext()->link->getImageLink(
|
||||
Tools::link_rewrite($product['name']),
|
||||
$product['id_product'] . '-' . $product['id_image'],
|
||||
'small_default'
|
||||
);
|
||||
}
|
||||
|
||||
$productsById[(int) $product['id_product']] = [
|
||||
'id' => (int) $product['id_product'],
|
||||
'name' => $product['name'],
|
||||
'reference' => $product['reference'],
|
||||
'manufacturer' => $product['manufacturer'],
|
||||
'image' => $imageUrl
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($previewIds as $id) {
|
||||
if (isset($productsById[(int) $id])) {
|
||||
$items[] = $productsById[(int) $id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
die(json_encode([
|
||||
'success' => true,
|
||||
'items' => $items,
|
||||
'count' => $totalCount,
|
||||
'hasMore' => $totalCount > count($items)
|
||||
]));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
die(json_encode([
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Preview products in a category
|
||||
* Used by tree view product count click
|
||||
@@ -1279,6 +1402,8 @@ trait EntitySelector
|
||||
// Sorting
|
||||
'sort_by' => $sortBy,
|
||||
'sort_dir' => $sortDir,
|
||||
// Product selection level
|
||||
'product_selection_level' => Tools::getValue('product_selection_level', 'product'),
|
||||
];
|
||||
|
||||
// Delegate to EntitySearchEngine
|
||||
@@ -1302,6 +1427,7 @@ trait EntitySelector
|
||||
protected function ajaxGetTargetEntitiesByIds()
|
||||
{
|
||||
$entityType = Tools::getValue('entity_type', '');
|
||||
$productSelectionLevel = Tools::getValue('product_selection_level', 'product');
|
||||
$ids = Tools::getValue('ids', '');
|
||||
if (is_string($ids)) {
|
||||
$ids = json_decode($ids, true);
|
||||
@@ -1311,7 +1437,7 @@ trait EntitySelector
|
||||
}
|
||||
|
||||
// Delegate to EntitySearchEngine
|
||||
$entities = $this->getEntitySearchEngine()->getByIds($entityType, $ids);
|
||||
$entities = $this->getEntitySearchEngine()->getByIds($entityType, $ids, $productSelectionLevel);
|
||||
|
||||
$this->ajaxDie(json_encode([
|
||||
'success' => true,
|
||||
@@ -1327,7 +1453,7 @@ trait EntitySelector
|
||||
protected function ajaxGetTargetEntitiesByIdsBulk()
|
||||
{
|
||||
$entitiesParam = Tools::getValue('entities', '');
|
||||
\PrestaShopLogger::addLog('[EntitySelector] ajaxGetTargetEntitiesByIdsBulk called, raw param: ' . substr($entitiesParam, 0, 500), 1);
|
||||
$productSelectionLevel = Tools::getValue('product_selection_level', 'product');
|
||||
|
||||
if (is_string($entitiesParam)) {
|
||||
$entitiesParam = json_decode($entitiesParam, true);
|
||||
@@ -1336,21 +1462,20 @@ trait EntitySelector
|
||||
$entitiesParam = [];
|
||||
}
|
||||
|
||||
\PrestaShopLogger::addLog('[EntitySelector] Parsed entities param: ' . json_encode($entitiesParam), 1);
|
||||
|
||||
$result = [];
|
||||
$searchEngine = $this->getEntitySearchEngine();
|
||||
|
||||
foreach ($entitiesParam as $entityType => $ids) {
|
||||
if (!is_array($ids) || empty($ids)) {
|
||||
\PrestaShopLogger::addLog('[EntitySelector] Skipping entityType=' . $entityType . ' - empty or not array', 1);
|
||||
continue;
|
||||
}
|
||||
// Deduplicate and sanitize IDs
|
||||
$ids = array_unique(array_map('intval', $ids));
|
||||
\PrestaShopLogger::addLog('[EntitySelector] Fetching entityType=' . $entityType . ' ids=' . implode(',', $ids), 1);
|
||||
$result[$entityType] = $searchEngine->getByIds($entityType, $ids);
|
||||
\PrestaShopLogger::addLog('[EntitySelector] Got ' . count($result[$entityType]) . ' results for ' . $entityType, 1);
|
||||
// For combination-level products, keep string IDs (c:32, p:17); otherwise intval
|
||||
if ($entityType === 'products' && ($productSelectionLevel === 'combination' || $productSelectionLevel === 'both')) {
|
||||
$ids = array_unique(array_map('strval', $ids));
|
||||
} else {
|
||||
$ids = array_unique(array_map('intval', $ids));
|
||||
}
|
||||
$result[$entityType] = $searchEngine->getByIds($entityType, $ids, $productSelectionLevel);
|
||||
}
|
||||
|
||||
$this->ajaxDie(json_encode([
|
||||
|
||||
@@ -305,6 +305,19 @@ class EntityPreviewHandler
|
||||
return [];
|
||||
}
|
||||
|
||||
// Detect prefixed IDs (c:32, p:17) — route to combination-aware preview
|
||||
$hasPrefixed = false;
|
||||
foreach ($productIds as $id) {
|
||||
if (is_string($id) && (strpos($id, 'c:') === 0 || strpos($id, 'p:') === 0)) {
|
||||
$hasPrefixed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasPrefixed) {
|
||||
return $this->getProductPreviewDataPrefixed($productIds, $idLang, $idShop);
|
||||
}
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('p.id_product, pl.name, p.reference, p.price, p.active');
|
||||
$sql->select('sa.quantity, m.name AS manufacturer_name');
|
||||
@@ -353,6 +366,130 @@ class EntityPreviewHandler
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview data for prefixed IDs (c:32, p:17).
|
||||
* Splits into combination IDs and product IDs, fetches data for each.
|
||||
*
|
||||
* @param array $prefixedIds Prefixed string IDs
|
||||
* @param int $idLang Language ID
|
||||
* @param int $idShop Shop ID
|
||||
* @return array Formatted preview items in original order
|
||||
*/
|
||||
protected function getProductPreviewDataPrefixed(array $prefixedIds, $idLang, $idShop)
|
||||
{
|
||||
$comboIds = [];
|
||||
$productIds = [];
|
||||
|
||||
foreach ($prefixedIds as $prefixedId) {
|
||||
$prefixedId = (string) $prefixedId;
|
||||
if (strpos($prefixedId, 'c:') === 0) {
|
||||
$comboIds[] = (int) substr($prefixedId, 2);
|
||||
} elseif (strpos($prefixedId, 'p:') === 0) {
|
||||
$productIds[] = (int) substr($prefixedId, 2);
|
||||
} else {
|
||||
$productIds[] = (int) $prefixedId;
|
||||
}
|
||||
}
|
||||
|
||||
$db = Db::getInstance();
|
||||
$imageType = $this->getProductImageType();
|
||||
$itemsByKey = [];
|
||||
|
||||
// Fetch combination data
|
||||
if (!empty($comboIds)) {
|
||||
$sql = 'SELECT pa.id_product_attribute, pa.id_product, pl.name, p.reference AS product_reference,
|
||||
pa.reference AS combo_reference, p.price, p.active,
|
||||
IFNULL(sa_c.quantity, 0) AS quantity,
|
||||
m.name AS manufacturer_name, cl.name AS category_name,
|
||||
i.id_image, pai.id_image AS combo_id_image,
|
||||
GROUP_CONCAT(CONCAT(agl.name, \': \', al.name) ORDER BY ag.position ASC SEPARATOR \', \') AS attributes
|
||||
FROM ' . _DB_PREFIX_ . 'product_attribute pa
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product p ON p.id_product = pa.id_product
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_lang pl ON pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop . '
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_combination pac ON pac.id_product_attribute = pa.id_product_attribute
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute a ON a.id_attribute = pac.id_attribute
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute_lang al ON al.id_attribute = a.id_attribute AND al.id_lang = ' . (int) $idLang . '
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group ag ON ag.id_attribute_group = a.id_attribute_group
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group_lang agl ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = ' . (int) $idLang . '
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'manufacturer m ON m.id_manufacturer = p.id_manufacturer
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'category_lang cl ON cl.id_category = p.id_category_default AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $idShop . '
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'stock_available sa_c ON sa_c.id_product = pa.id_product AND sa_c.id_product_attribute = pa.id_product_attribute AND sa_c.id_shop = ' . (int) $idShop . '
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'image i ON i.id_product = p.id_product AND i.cover = 1
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'product_attribute_image pai ON pai.id_product_attribute = pa.id_product_attribute
|
||||
WHERE pa.id_product_attribute IN (' . implode(',', array_map('intval', $comboIds)) . ')
|
||||
GROUP BY pa.id_product_attribute';
|
||||
|
||||
$rows = $db->executeS($sql);
|
||||
if ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
$key = 'c:' . (int) $row['id_product_attribute'];
|
||||
$imageId = !empty($row['combo_id_image']) ? $row['combo_id_image'] : ($row['id_image'] ?? null);
|
||||
$ref = $row['combo_reference'] ?: $row['product_reference'];
|
||||
|
||||
$itemsByKey[$key] = [
|
||||
'id' => $key,
|
||||
'name' => $row['name'] . ', ' . $row['attributes'],
|
||||
'reference' => $ref,
|
||||
'price' => (float) $row['price'],
|
||||
'quantity' => (int) $row['quantity'],
|
||||
'active' => (bool) $row['active'],
|
||||
'manufacturer' => $row['manufacturer_name'],
|
||||
'category' => $row['category_name'],
|
||||
'image' => $imageId ? $this->getProductImageUrl($row['id_product'], $imageId, $imageType) : null,
|
||||
'attributes' => $row['attributes'],
|
||||
'isCombination' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch plain product data
|
||||
if (!empty($productIds)) {
|
||||
$sql = new DbQuery();
|
||||
$sql->select('p.id_product, pl.name, p.reference, p.price, p.active');
|
||||
$sql->select('sa.quantity, m.name AS manufacturer_name');
|
||||
$sql->select('cl.name AS category_name, i.id_image');
|
||||
$sql->from('product', 'p');
|
||||
$sql->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('manufacturer', 'm', 'm.id_manufacturer = p.id_manufacturer');
|
||||
$sql->leftJoin('category_lang', 'cl', 'cl.id_category = p.id_category_default AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('stock_available', 'sa', 'sa.id_product = p.id_product AND sa.id_product_attribute = 0 AND sa.id_shop = ' . (int) $idShop);
|
||||
$sql->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
|
||||
$sql->where('p.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')');
|
||||
|
||||
$rows = $db->executeS($sql);
|
||||
if ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
$key = 'p:' . (int) $row['id_product'];
|
||||
$itemsByKey[$key] = [
|
||||
'id' => $key,
|
||||
'name' => $row['name'],
|
||||
'reference' => $row['reference'],
|
||||
'price' => (float) $row['price'],
|
||||
'quantity' => (int) $row['quantity'],
|
||||
'active' => (bool) $row['active'],
|
||||
'manufacturer' => $row['manufacturer_name'],
|
||||
'category' => $row['category_name'],
|
||||
'image' => $row['id_image'] ? $this->getProductImageUrl($row['id_product'], $row['id_image'], $imageType) : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return in original order
|
||||
$ordered = [];
|
||||
foreach ($prefixedIds as $id) {
|
||||
$key = (string) $id;
|
||||
if (isset($itemsByKey[$key])) {
|
||||
$ordered[] = $itemsByKey[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product image type for thumbnails
|
||||
*
|
||||
|
||||
@@ -58,6 +58,12 @@ class EntitySearchEngine
|
||||
*/
|
||||
public function search($entityType, $query, $limit = 20, $offset = 0, array $filters = [])
|
||||
{
|
||||
// product_selection_level: route product searches to combination methods
|
||||
$psl = $filters['product_selection_level'] ?? 'product';
|
||||
if ($entityType === 'products' && ($psl === 'combination' || $psl === 'both')) {
|
||||
return $this->searchProductCombinations($query, $this->idLang, $this->idShop, $limit, $offset, $filters);
|
||||
}
|
||||
|
||||
$method = 'searchTarget' . $this->camelCase($entityType);
|
||||
|
||||
if (method_exists($this, $method)) {
|
||||
@@ -77,6 +83,11 @@ class EntitySearchEngine
|
||||
*/
|
||||
public function count($entityType, $query, array $filters = [])
|
||||
{
|
||||
$psl = $filters['product_selection_level'] ?? 'product';
|
||||
if ($entityType === 'products' && ($psl === 'combination' || $psl === 'both')) {
|
||||
return $this->countProductCombinations($query, $this->idLang, $this->idShop, $filters);
|
||||
}
|
||||
|
||||
$method = 'countTarget' . $this->camelCase($entityType);
|
||||
|
||||
if (method_exists($this, $method)) {
|
||||
@@ -93,8 +104,13 @@ class EntitySearchEngine
|
||||
* @param array $ids Entity IDs
|
||||
* @return array Entities data
|
||||
*/
|
||||
public function getByIds($entityType, array $ids)
|
||||
public function getByIds($entityType, array $ids, $productSelectionLevel = 'product')
|
||||
{
|
||||
// If product type with combination-level IDs (prefixed strings), use combination handler
|
||||
if ($entityType === 'products' && ($productSelectionLevel === 'combination' || $productSelectionLevel === 'both')) {
|
||||
return $this->getProductCombinationsByIds($ids, $this->idLang, $this->idShop);
|
||||
}
|
||||
|
||||
$method = 'getTarget' . $this->camelCase($entityType) . 'ByIds';
|
||||
|
||||
if (method_exists($this, $method)) {
|
||||
@@ -414,6 +430,492 @@ class EntitySearchEngine
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PRODUCT COMBINATIONS (product_selection_level = combination)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Search products at combination level.
|
||||
* Products WITH combinations return one result per combination (id = "c:{id_product_attribute}").
|
||||
* Simple products (no combinations) return with id = "p:{id_product}".
|
||||
*
|
||||
* @param string $query Search query
|
||||
* @param int $idLang Language ID
|
||||
* @param int $idShop Shop ID
|
||||
* @param int $limit Results limit
|
||||
* @param int $offset Results offset
|
||||
* @param array $filters Additional filters
|
||||
* @return array Search results
|
||||
*/
|
||||
public function searchProductCombinations($query, $idLang, $idShop, $limit = 20, $offset = 0, array $filters = [])
|
||||
{
|
||||
$db = Db::getInstance();
|
||||
$escapedQuery = !empty($query) ? $this->escapePattern($query) : '';
|
||||
|
||||
// Step 1: Find matching products (same query as searchTargetProducts but no limit — we paginate the flat result)
|
||||
$sqlProducts = new DbQuery();
|
||||
$sqlProducts->select('DISTINCT p.id_product, pl.name, p.reference, ps.price AS base_price, ps.active,
|
||||
m.name AS manufacturer_name, cl.name AS category_name, i.id_image,
|
||||
IFNULL(sa.quantity, 0) AS stock_qty');
|
||||
// Sales
|
||||
$sqlProducts->select('(SELECT COALESCE(SUM(od.product_quantity), 0) FROM ' . _DB_PREFIX_ . 'order_detail od
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'orders o ON o.id_order = od.id_order
|
||||
WHERE od.product_id = p.id_product AND o.valid = 1) AS sales_qty');
|
||||
$sqlProducts->from('product', 'p');
|
||||
$sqlProducts->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . (int) $idShop);
|
||||
$sqlProducts->leftJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop);
|
||||
$sqlProducts->leftJoin('manufacturer', 'm', 'm.id_manufacturer = p.id_manufacturer');
|
||||
$sqlProducts->leftJoin('category_lang', 'cl', 'cl.id_category = p.id_category_default AND cl.id_lang = ' . (int) $idLang . ' AND cl.id_shop = ' . (int) $idShop);
|
||||
$sqlProducts->leftJoin('stock_available', 'sa', 'sa.id_product = p.id_product AND sa.id_product_attribute = 0 AND sa.id_shop = ' . (int) $idShop);
|
||||
$sqlProducts->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
|
||||
|
||||
if (!empty($escapedQuery)) {
|
||||
$sqlProducts->where('(pl.name LIKE \'%' . $escapedQuery . '%\' OR p.reference LIKE \'%' . $escapedQuery . '%\' OR p.id_product = ' . (int) $query . ')');
|
||||
}
|
||||
|
||||
$this->applyProductFilters($sqlProducts, $filters, $idLang, $idShop);
|
||||
$sqlProducts->orderBy('pl.name ASC');
|
||||
|
||||
$matchingProducts = $db->executeS($sqlProducts);
|
||||
if (!$matchingProducts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$productIds = array_column($matchingProducts, 'id_product');
|
||||
$productMap = [];
|
||||
foreach ($matchingProducts as $p) {
|
||||
$productMap[(int) $p['id_product']] = $p;
|
||||
}
|
||||
|
||||
// Step 2: Get all combinations with stock, image, attributes
|
||||
$sqlCombos = 'SELECT pa.id_product_attribute, pa.id_product, pa.reference AS combo_reference,
|
||||
pas.price AS combo_price_impact,
|
||||
IFNULL(sa_c.quantity, 0) AS combo_stock,
|
||||
pai.id_image AS combo_id_image,
|
||||
GROUP_CONCAT(CONCAT(agl.name, \': \', al.name) ORDER BY ag.position ASC SEPARATOR \', \') AS attributes
|
||||
FROM ' . _DB_PREFIX_ . 'product_attribute pa
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_combination pac ON pac.id_product_attribute = pa.id_product_attribute
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute a ON a.id_attribute = pac.id_attribute
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute_lang al ON al.id_attribute = a.id_attribute AND al.id_lang = ' . (int) $idLang . '
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group ag ON ag.id_attribute_group = a.id_attribute_group
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group_lang agl ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = ' . (int) $idLang . '
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'stock_available sa_c ON sa_c.id_product = pa.id_product AND sa_c.id_product_attribute = pa.id_product_attribute AND sa_c.id_shop = ' . (int) $idShop . '
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'product_attribute_image pai ON pai.id_product_attribute = pa.id_product_attribute
|
||||
WHERE pa.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')';
|
||||
|
||||
// Filter combinations by selected attribute values
|
||||
$comboAttrIds = $this->extractAttributeFilterIds($filters);
|
||||
if (!empty($comboAttrIds)) {
|
||||
$sqlCombos .= ' AND EXISTS (
|
||||
SELECT 1 FROM ' . _DB_PREFIX_ . 'product_attribute_combination pac_f
|
||||
WHERE pac_f.id_product_attribute = pa.id_product_attribute
|
||||
AND pac_f.id_attribute IN (' . implode(',', $comboAttrIds) . ')
|
||||
)';
|
||||
}
|
||||
|
||||
$sqlCombos .= ' GROUP BY pa.id_product_attribute
|
||||
ORDER BY pa.id_product, pa.id_product_attribute';
|
||||
|
||||
$combinations = $db->executeS($sqlCombos);
|
||||
|
||||
// Group combinations by product
|
||||
$combosByProduct = [];
|
||||
if ($combinations) {
|
||||
foreach ($combinations as $combo) {
|
||||
$combosByProduct[(int) $combo['id_product']][] = $combo;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Build flat result list with full product data
|
||||
$imageType = $this->getProductImageType();
|
||||
$context = Context::getContext();
|
||||
$locale = $context->getCurrentLocale();
|
||||
$currency = $context->currency;
|
||||
$isBothMode = ($filters['product_selection_level'] ?? 'combination') === 'both';
|
||||
|
||||
$allResults = [];
|
||||
foreach ($productIds as $idProduct) {
|
||||
$idProduct = (int) $idProduct;
|
||||
$product = $productMap[$idProduct];
|
||||
|
||||
if (!empty($combosByProduct[$idProduct])) {
|
||||
// In 'both' mode, add the base product entry first
|
||||
if ($isBothMode) {
|
||||
$basePrice = (float) ($product['base_price'] ?? 0);
|
||||
$stockQty = (int) ($product['stock_qty'] ?? 0);
|
||||
$regularPriceFormatted = $locale->formatPrice($basePrice, $currency->iso_code);
|
||||
$finalPrice = \Product::getPriceStatic($idProduct, true, null, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
|
||||
$finalPriceFormatted = $locale->formatPrice($finalPrice, $currency->iso_code);
|
||||
$hasDiscount = ($basePrice > 0 && $finalPrice < $basePrice);
|
||||
$stockStatus = $stockQty <= 0 ? 'out_of_stock' : ($stockQty <= 5 ? 'low_stock' : 'in_stock');
|
||||
|
||||
$allResults[] = [
|
||||
'id' => 'p:' . $idProduct,
|
||||
'type' => 'product',
|
||||
'name' => $product['name'],
|
||||
'subtitle' => $product['reference'] ? 'Ref: ' . $product['reference'] : null,
|
||||
'reference' => $product['reference'],
|
||||
'is_combination' => false,
|
||||
'is_parent' => true,
|
||||
'combination_count' => count($combosByProduct[$idProduct]),
|
||||
'id_product' => $idProduct,
|
||||
'active' => (bool) $product['active'],
|
||||
'manufacturer' => $product['manufacturer_name'],
|
||||
'category' => $product['category_name'],
|
||||
'image' => $product['id_image'] ? $this->getProductImageUrl($idProduct, $product['id_image'], $imageType) : null,
|
||||
'regular_price_formatted' => $regularPriceFormatted,
|
||||
'price_formatted' => $finalPriceFormatted,
|
||||
'has_discount' => $hasDiscount,
|
||||
'stock_qty' => $stockQty,
|
||||
'stock_status' => $stockStatus,
|
||||
'sales_qty' => (int) ($product['sales_qty'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
// Product HAS combinations — one entry per combination
|
||||
foreach ($combosByProduct[$idProduct] as $combo) {
|
||||
$idPA = (int) $combo['id_product_attribute'];
|
||||
$comboStock = (int) $combo['combo_stock'];
|
||||
|
||||
// Price: use Product::getPrice with combination ID for accurate price
|
||||
$comboPrice = \Product::getPriceStatic($idProduct, true, $idPA, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
|
||||
$comboPriceFormatted = $locale->formatPrice($comboPrice, $currency->iso_code);
|
||||
|
||||
// Regular price (without reduction)
|
||||
$comboRegularPrice = \Product::getPriceStatic($idProduct, true, $idPA, 2, null, false, false, 1, false, null, null, null, $dummyNull, true, true, $context);
|
||||
$comboRegularPriceFormatted = $locale->formatPrice($comboRegularPrice, $currency->iso_code);
|
||||
|
||||
$hasDiscount = ($comboRegularPrice > $comboPrice);
|
||||
|
||||
// Image: combination image if exists, otherwise product cover
|
||||
$imageId = !empty($combo['combo_id_image']) ? $combo['combo_id_image'] : ($product['id_image'] ?? null);
|
||||
|
||||
// Stock status
|
||||
$stockStatus = 'in_stock';
|
||||
if ($comboStock <= 0) {
|
||||
$stockStatus = 'out_of_stock';
|
||||
} elseif ($comboStock <= 5) {
|
||||
$stockStatus = 'low_stock';
|
||||
}
|
||||
|
||||
// Name includes attributes: "Product name, Attr1: Val1, Attr2: Val2"
|
||||
$fullName = $product['name'] . ', ' . $combo['attributes'];
|
||||
$ref = $combo['combo_reference'] ?: $product['reference'];
|
||||
|
||||
$allResults[] = [
|
||||
'id' => 'c:' . $idPA,
|
||||
'type' => 'product',
|
||||
'name' => $fullName,
|
||||
'subtitle' => $ref ? 'Ref: ' . $ref : null,
|
||||
'reference' => $ref,
|
||||
'attributes' => $combo['attributes'],
|
||||
'is_combination' => true,
|
||||
'id_product' => $idProduct,
|
||||
'id_product_attribute' => $idPA,
|
||||
'active' => (bool) $product['active'],
|
||||
'manufacturer' => $product['manufacturer_name'],
|
||||
'category' => $product['category_name'],
|
||||
'image' => $imageId ? $this->getProductImageUrl($idProduct, $imageId, $imageType) : null,
|
||||
'regular_price_formatted' => $comboRegularPriceFormatted,
|
||||
'price_formatted' => $comboPriceFormatted,
|
||||
'has_discount' => $hasDiscount,
|
||||
'stock_qty' => $comboStock,
|
||||
'stock_status' => $stockStatus,
|
||||
'sales_qty' => (int) ($product['sales_qty'] ?? 0),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Simple product — no combinations
|
||||
// Skip simple products when attribute filter is active (they can't match)
|
||||
if (!empty($comboAttrIds)) {
|
||||
continue;
|
||||
}
|
||||
$basePrice = (float) ($product['base_price'] ?? 0);
|
||||
$stockQty = (int) ($product['stock_qty'] ?? 0);
|
||||
$salesQty = (int) ($product['sales_qty'] ?? 0);
|
||||
|
||||
$regularPriceFormatted = $locale->formatPrice($basePrice, $currency->iso_code);
|
||||
$finalPrice = \Product::getPriceStatic($idProduct, true, null, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
|
||||
$finalPriceFormatted = $locale->formatPrice($finalPrice, $currency->iso_code);
|
||||
$hasDiscount = ($basePrice > 0 && $finalPrice < $basePrice);
|
||||
|
||||
$stockStatus = 'in_stock';
|
||||
if ($stockQty <= 0) {
|
||||
$stockStatus = 'out_of_stock';
|
||||
} elseif ($stockQty <= 5) {
|
||||
$stockStatus = 'low_stock';
|
||||
}
|
||||
|
||||
$allResults[] = [
|
||||
'id' => 'p:' . $idProduct,
|
||||
'type' => 'product',
|
||||
'name' => $product['name'],
|
||||
'subtitle' => $product['reference'] ? 'Ref: ' . $product['reference'] : null,
|
||||
'reference' => $product['reference'],
|
||||
'is_combination' => false,
|
||||
'id_product' => $idProduct,
|
||||
'active' => (bool) $product['active'],
|
||||
'manufacturer' => $product['manufacturer_name'],
|
||||
'category' => $product['category_name'],
|
||||
'image' => $product['id_image'] ? $this->getProductImageUrl($idProduct, $product['id_image'], $imageType) : null,
|
||||
'regular_price_formatted' => $regularPriceFormatted,
|
||||
'price_formatted' => $finalPriceFormatted,
|
||||
'has_discount' => $hasDiscount,
|
||||
'stock_qty' => $stockQty,
|
||||
'stock_status' => $stockStatus,
|
||||
'sales_qty' => $salesQty,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Apply offset/limit to the flat list
|
||||
return array_slice($allResults, (int) $offset, (int) $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count products at combination level.
|
||||
* Each combination counts as 1. Simple products count as 1.
|
||||
*
|
||||
* @param string $query Search query
|
||||
* @param int $idLang Language ID
|
||||
* @param int $idShop Shop ID
|
||||
* @param array $filters Additional filters
|
||||
* @return int Total count
|
||||
*/
|
||||
public function countProductCombinations($query, $idLang, $idShop, array $filters = [])
|
||||
{
|
||||
$db = Db::getInstance();
|
||||
$escapedQuery = !empty($query) ? $this->escapePattern($query) : '';
|
||||
|
||||
// Get matching product IDs
|
||||
$sqlProducts = new DbQuery();
|
||||
$sqlProducts->select('DISTINCT p.id_product');
|
||||
$sqlProducts->from('product', 'p');
|
||||
$sqlProducts->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . (int) $idShop);
|
||||
$sqlProducts->leftJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop);
|
||||
|
||||
if (!empty($escapedQuery)) {
|
||||
$sqlProducts->where('(pl.name LIKE \'%' . $escapedQuery . '%\' OR p.reference LIKE \'%' . $escapedQuery . '%\' OR p.id_product = ' . (int) $query . ')');
|
||||
}
|
||||
|
||||
$this->applyProductFilters($sqlProducts, $filters, $idLang, $idShop);
|
||||
|
||||
$matchingProducts = $db->executeS($sqlProducts);
|
||||
if (!$matchingProducts) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$productIds = array_column($matchingProducts, 'id_product');
|
||||
|
||||
// Count combinations for these products
|
||||
$sqlComboCount = 'SELECT COUNT(*) FROM ' . _DB_PREFIX_ . 'product_attribute pa
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
|
||||
WHERE pa.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')';
|
||||
|
||||
// Filter combination count by selected attribute values
|
||||
$comboAttrIds = $this->extractAttributeFilterIds($filters);
|
||||
if (!empty($comboAttrIds)) {
|
||||
$sqlComboCount .= ' AND EXISTS (
|
||||
SELECT 1 FROM ' . _DB_PREFIX_ . 'product_attribute_combination pac_f
|
||||
WHERE pac_f.id_product_attribute = pa.id_product_attribute
|
||||
AND pac_f.id_attribute IN (' . implode(',', $comboAttrIds) . ')
|
||||
)';
|
||||
}
|
||||
|
||||
$comboCount = (int) $db->getValue($sqlComboCount);
|
||||
|
||||
// Count simple products (those with no combinations)
|
||||
// Simple products only included when no attribute filter is active
|
||||
// (simple products have no combinations, so they can't match attribute filters)
|
||||
$simpleCount = 0;
|
||||
if (empty($comboAttrIds)) {
|
||||
$sqlSimpleCount = 'SELECT COUNT(*) FROM (' . $sqlProducts->build() . ') AS matched_products
|
||||
WHERE matched_products.id_product NOT IN (
|
||||
SELECT DISTINCT pa2.id_product FROM ' . _DB_PREFIX_ . 'product_attribute pa2
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas2 ON pas2.id_product_attribute = pa2.id_product_attribute AND pas2.id_shop = ' . (int) $idShop . '
|
||||
)';
|
||||
$simpleCount = (int) $db->getValue($sqlSimpleCount);
|
||||
}
|
||||
|
||||
// In 'both' mode, also count parent products (products that have combinations)
|
||||
$isBothMode = ($filters['product_selection_level'] ?? 'combination') === 'both';
|
||||
$parentCount = 0;
|
||||
if ($isBothMode) {
|
||||
$sqlParentCount = 'SELECT COUNT(DISTINCT pa3.id_product) FROM ' . _DB_PREFIX_ . 'product_attribute pa3
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas3 ON pas3.id_product_attribute = pa3.id_product_attribute AND pas3.id_shop = ' . (int) $idShop . '
|
||||
WHERE pa3.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')';
|
||||
$parentCount = (int) $db->getValue($sqlParentCount);
|
||||
}
|
||||
|
||||
return $comboCount + $simpleCount + $parentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product combinations by prefixed IDs.
|
||||
* Handles "c:{id_product_attribute}" and "p:{id_product}" formats.
|
||||
*
|
||||
* @param array $ids Prefixed string IDs
|
||||
* @param int $idLang Language ID
|
||||
* @param int $idShop Shop ID
|
||||
* @return array Entities data
|
||||
*/
|
||||
public function getProductCombinationsByIds(array $ids, $idLang, $idShop = null)
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$idShop = $idShop ?: $this->idShop;
|
||||
$db = Db::getInstance();
|
||||
|
||||
// Parse prefixed IDs
|
||||
$comboIds = [];
|
||||
$productIds = [];
|
||||
foreach ($ids as $prefixedId) {
|
||||
$prefixedId = (string) $prefixedId;
|
||||
if (strpos($prefixedId, 'c:') === 0) {
|
||||
$comboIds[] = (int) substr($prefixedId, 2);
|
||||
} elseif (strpos($prefixedId, 'p:') === 0) {
|
||||
$productIds[] = (int) substr($prefixedId, 2);
|
||||
} else {
|
||||
// Fallback: treat as product ID
|
||||
$productIds[] = (int) $prefixedId;
|
||||
}
|
||||
}
|
||||
|
||||
$imageType = $this->getProductImageType();
|
||||
$context = Context::getContext();
|
||||
$locale = $context->getCurrentLocale();
|
||||
$currency = $context->currency;
|
||||
|
||||
$results = [];
|
||||
|
||||
// Fetch combinations with full data
|
||||
if (!empty($comboIds)) {
|
||||
$sqlCombos = 'SELECT pa.id_product_attribute, pa.id_product, pa.reference AS combo_reference,
|
||||
pl.name, p.reference AS product_reference, i.id_image,
|
||||
IFNULL(sa_c.quantity, 0) AS combo_stock,
|
||||
pai.id_image AS combo_id_image,
|
||||
GROUP_CONCAT(CONCAT(agl.name, \': \', al.name) ORDER BY ag.position ASC SEPARATOR \', \') AS attributes
|
||||
FROM ' . _DB_PREFIX_ . 'product_attribute pa
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product p ON p.id_product = pa.id_product
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_lang pl ON pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop . '
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_combination pac ON pac.id_product_attribute = pa.id_product_attribute
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute a ON a.id_attribute = pac.id_attribute
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute_lang al ON al.id_attribute = a.id_attribute AND al.id_lang = ' . (int) $idLang . '
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group ag ON ag.id_attribute_group = a.id_attribute_group
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'attribute_group_lang agl ON agl.id_attribute_group = a.id_attribute_group AND agl.id_lang = ' . (int) $idLang . '
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'stock_available sa_c ON sa_c.id_product = pa.id_product AND sa_c.id_product_attribute = pa.id_product_attribute AND sa_c.id_shop = ' . (int) $idShop . '
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'product_attribute_image pai ON pai.id_product_attribute = pa.id_product_attribute
|
||||
LEFT JOIN ' . _DB_PREFIX_ . 'image i ON i.id_product = p.id_product AND i.cover = 1
|
||||
WHERE pa.id_product_attribute IN (' . implode(',', array_map('intval', $comboIds)) . ')
|
||||
GROUP BY pa.id_product_attribute';
|
||||
|
||||
$comboRows = $db->executeS($sqlCombos);
|
||||
if ($comboRows) {
|
||||
foreach ($comboRows as $row) {
|
||||
$idPA = (int) $row['id_product_attribute'];
|
||||
$idProduct = (int) $row['id_product'];
|
||||
$key = 'c:' . $idPA;
|
||||
|
||||
// Price
|
||||
$comboPrice = \Product::getPriceStatic($idProduct, true, $idPA, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
|
||||
$comboRegularPrice = \Product::getPriceStatic($idProduct, true, $idPA, 2, null, false, false, 1, false, null, null, null, $dummyNull, true, true, $context);
|
||||
$hasDiscount = ($comboRegularPrice > $comboPrice);
|
||||
|
||||
// Image: combination image if exists, otherwise product cover
|
||||
$imageId = !empty($row['combo_id_image']) ? $row['combo_id_image'] : ($row['id_image'] ?? null);
|
||||
|
||||
// Stock
|
||||
$comboStock = (int) $row['combo_stock'];
|
||||
$stockStatus = $comboStock <= 0 ? 'out_of_stock' : ($comboStock <= 5 ? 'low_stock' : 'in_stock');
|
||||
|
||||
$ref = $row['combo_reference'] ?: $row['product_reference'];
|
||||
|
||||
$results[$key] = [
|
||||
'id' => $key,
|
||||
'type' => 'product',
|
||||
'name' => $row['name'] . ', ' . $row['attributes'],
|
||||
'subtitle' => $ref ? 'Ref: ' . $ref : null,
|
||||
'reference' => $ref,
|
||||
'attributes' => $row['attributes'],
|
||||
'is_combination' => true,
|
||||
'id_product' => $idProduct,
|
||||
'id_product_attribute' => $idPA,
|
||||
'image' => $imageId ? $this->getProductImageUrl($idProduct, $imageId, $imageType) : null,
|
||||
'regular_price_formatted' => $locale->formatPrice($comboRegularPrice, $currency->iso_code),
|
||||
'price_formatted' => $locale->formatPrice($comboPrice, $currency->iso_code),
|
||||
'has_discount' => $hasDiscount,
|
||||
'stock_qty' => $comboStock,
|
||||
'stock_status' => $stockStatus,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch simple products with full data
|
||||
if (!empty($productIds)) {
|
||||
$sqlProducts = new DbQuery();
|
||||
$sqlProducts->select('p.id_product, pl.name, p.reference, ps.price AS base_price,
|
||||
IFNULL(sa.quantity, 0) AS stock_qty, i.id_image');
|
||||
$sqlProducts->from('product', 'p');
|
||||
$sqlProducts->innerJoin('product_shop', 'ps', 'ps.id_product = p.id_product AND ps.id_shop = ' . (int) $idShop);
|
||||
$sqlProducts->leftJoin('product_lang', 'pl', 'pl.id_product = p.id_product AND pl.id_lang = ' . (int) $idLang . ' AND pl.id_shop = ' . (int) $idShop);
|
||||
$sqlProducts->leftJoin('stock_available', 'sa', 'sa.id_product = p.id_product AND sa.id_product_attribute = 0 AND sa.id_shop = ' . (int) $idShop);
|
||||
$sqlProducts->leftJoin('image', 'i', 'i.id_product = p.id_product AND i.cover = 1');
|
||||
$sqlProducts->where('p.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')');
|
||||
|
||||
$productRows = $db->executeS($sqlProducts);
|
||||
if ($productRows) {
|
||||
foreach ($productRows as $row) {
|
||||
$idProduct = (int) $row['id_product'];
|
||||
$key = 'p:' . $idProduct;
|
||||
$stockQty = (int) $row['stock_qty'];
|
||||
|
||||
$finalPrice = \Product::getPriceStatic($idProduct, true, null, 2, null, false, true, 1, false, null, null, null, $dummyNull, true, true, $context);
|
||||
$regularPrice = (float) $row['base_price'];
|
||||
$hasDiscount = ($regularPrice > 0 && $finalPrice < $regularPrice);
|
||||
|
||||
$stockStatus = $stockQty <= 0 ? 'out_of_stock' : ($stockQty <= 5 ? 'low_stock' : 'in_stock');
|
||||
|
||||
$results[$key] = [
|
||||
'id' => $key,
|
||||
'type' => 'product',
|
||||
'name' => $row['name'],
|
||||
'subtitle' => $row['reference'] ? 'Ref: ' . $row['reference'] : null,
|
||||
'reference' => $row['reference'],
|
||||
'is_combination' => false,
|
||||
'id_product' => $idProduct,
|
||||
'image' => $row['id_image'] ? $this->getProductImageUrl($idProduct, $row['id_image'], $imageType) : null,
|
||||
'regular_price_formatted' => $locale->formatPrice($regularPrice, $currency->iso_code),
|
||||
'price_formatted' => $locale->formatPrice($finalPrice, $currency->iso_code),
|
||||
'has_discount' => $hasDiscount,
|
||||
'stock_qty' => $stockQty,
|
||||
'stock_status' => $stockStatus,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return in original order
|
||||
$ordered = [];
|
||||
foreach ($ids as $originalId) {
|
||||
$key = (string) $originalId;
|
||||
if (isset($results[$key])) {
|
||||
$ordered[] = $results[$key];
|
||||
} elseif (isset($results['p:' . $key])) {
|
||||
// Plain integer ID → stored as "p:{id}" (simple product fallback)
|
||||
$ordered[] = $results['p:' . $key];
|
||||
} elseif (isset($results['c:' . $key])) {
|
||||
$ordered[] = $results['c:' . $key];
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply product-specific filters to query
|
||||
*
|
||||
@@ -451,23 +953,40 @@ class EntitySearchEngine
|
||||
$sql->where('ps.active = 1');
|
||||
}
|
||||
|
||||
// Attribute filter
|
||||
// Attribute filter — supports flat array [id1, id2] or grouped [{id_attribute_group, values}]
|
||||
if (!empty($filters['attributes'])) {
|
||||
foreach ($filters['attributes'] as $attrFilter) {
|
||||
if (!empty($attrFilter['values'])) {
|
||||
$attrIds = array_map('intval', $attrFilter['values']);
|
||||
$sql->innerJoin('product_attribute_combination', 'pac_' . (int) $attrFilter['id_attribute_group'], 'pac_' . (int) $attrFilter['id_attribute_group'] . '.id_attribute IN (' . implode(',', $attrIds) . ')');
|
||||
$sql->innerJoin('product_attribute', 'pa_' . (int) $attrFilter['id_attribute_group'], 'pa_' . (int) $attrFilter['id_attribute_group'] . '.id_product_attribute = pac_' . (int) $attrFilter['id_attribute_group'] . '.id_product_attribute AND pa_' . (int) $attrFilter['id_attribute_group'] . '.id_product = p.id_product');
|
||||
$first = reset($filters['attributes']);
|
||||
if (is_numeric($first)) {
|
||||
// Flat array of attribute value IDs (from filter chips)
|
||||
$attrIds = array_map('intval', $filters['attributes']);
|
||||
$sql->innerJoin('product_attribute_combination', 'pac_filter', 'pac_filter.id_attribute IN (' . implode(',', $attrIds) . ')');
|
||||
$sql->innerJoin('product_attribute', 'pa_filter', 'pa_filter.id_product_attribute = pac_filter.id_product_attribute AND pa_filter.id_product = p.id_product');
|
||||
} else {
|
||||
// Grouped format: [{id_attribute_group: X, values: [1,2,3]}, ...]
|
||||
foreach ($filters['attributes'] as $attrFilter) {
|
||||
if (!empty($attrFilter['values'])) {
|
||||
$attrIds = array_map('intval', $attrFilter['values']);
|
||||
$sql->innerJoin('product_attribute_combination', 'pac_' . (int) $attrFilter['id_attribute_group'], 'pac_' . (int) $attrFilter['id_attribute_group'] . '.id_attribute IN (' . implode(',', $attrIds) . ')');
|
||||
$sql->innerJoin('product_attribute', 'pa_' . (int) $attrFilter['id_attribute_group'], 'pa_' . (int) $attrFilter['id_attribute_group'] . '.id_product_attribute = pac_' . (int) $attrFilter['id_attribute_group'] . '.id_product_attribute AND pa_' . (int) $attrFilter['id_attribute_group'] . '.id_product = p.id_product');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Feature filter
|
||||
// Feature filter — supports flat array [id1, id2] or grouped [{id_feature, values}]
|
||||
if (!empty($filters['features'])) {
|
||||
foreach ($filters['features'] as $featFilter) {
|
||||
if (!empty($featFilter['values'])) {
|
||||
$featIds = array_map('intval', $featFilter['values']);
|
||||
$sql->innerJoin('feature_product', 'fp_' . (int) $featFilter['id_feature'], 'fp_' . (int) $featFilter['id_feature'] . '.id_product = p.id_product AND fp_' . (int) $featFilter['id_feature'] . '.id_feature_value IN (' . implode(',', $featIds) . ')');
|
||||
$first = reset($filters['features']);
|
||||
if (is_numeric($first)) {
|
||||
// Flat array of feature value IDs (from filter chips)
|
||||
$featIds = array_map('intval', $filters['features']);
|
||||
$sql->innerJoin('feature_product', 'fp_filter', 'fp_filter.id_product = p.id_product AND fp_filter.id_feature_value IN (' . implode(',', $featIds) . ')');
|
||||
} else {
|
||||
// Grouped format: [{id_feature: X, values: [1,2,3]}, ...]
|
||||
foreach ($filters['features'] as $featFilter) {
|
||||
if (!empty($featFilter['values'])) {
|
||||
$featIds = array_map('intval', $featFilter['values']);
|
||||
$sql->innerJoin('feature_product', 'fp_' . (int) $featFilter['id_feature'], 'fp_' . (int) $featFilter['id_feature'] . '.id_product = p.id_product AND fp_' . (int) $featFilter['id_feature'] . '.id_feature_value IN (' . implode(',', $featIds) . ')');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,6 +1000,33 @@ class EntitySearchEngine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract flat array of attribute value IDs from filters.
|
||||
* Handles both flat [id1, id2] and grouped [{id_attribute_group, values}] formats.
|
||||
*
|
||||
* @param array $filters
|
||||
* @return array Integer attribute IDs
|
||||
*/
|
||||
protected function extractAttributeFilterIds(array $filters)
|
||||
{
|
||||
if (empty($filters['attributes'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$first = reset($filters['attributes']);
|
||||
if (is_numeric($first)) {
|
||||
return array_map('intval', $filters['attributes']);
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($filters['attributes'] as $attrFilter) {
|
||||
if (!empty($attrFilter['values'])) {
|
||||
$ids = array_merge($ids, array_map('intval', $attrFilter['values']));
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get product image type for thumbnails
|
||||
*
|
||||
@@ -3469,6 +4015,130 @@ class EntitySearchEngine
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MPR MATERIALS (module-specific entity type)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Search materials
|
||||
*/
|
||||
public function searchTargetMprMaterials($query, $idLang, $idShop, $limit = 20, $offset = 0, array $filters = [])
|
||||
{
|
||||
$db = Db::getInstance();
|
||||
$escapedQuery = $this->escapePattern($query);
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('m.id_material, m.reference, m.unit, m.unit_cost, m.active,
|
||||
ml.name, ml.description,
|
||||
IFNULL(SUM(ms.quantity), 0) AS stock_qty');
|
||||
$sql->from('mprwarehouserevolution_material', 'm');
|
||||
$sql->leftJoin('mprwarehouserevolution_material_lang', 'ml', 'ml.id_material = m.id_material AND ml.id_lang = ' . (int) $idLang);
|
||||
$sql->leftJoin('mprwarehouserevolution_material_stock', 'ms', 'ms.id_material = m.id_material');
|
||||
|
||||
if ($query !== '') {
|
||||
$sql->where('(ml.name LIKE \'%' . $escapedQuery . '%\' OR m.reference LIKE \'%' . $escapedQuery . '%\' OR m.barcode LIKE \'%' . $escapedQuery . '%\')');
|
||||
}
|
||||
|
||||
$sql->groupBy('m.id_material');
|
||||
$sql->orderBy('ml.name ASC');
|
||||
$sql->limit($limit, $offset);
|
||||
|
||||
$rows = $db->executeS($sql);
|
||||
if (!$rows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($rows as $row) {
|
||||
$stockQty = (int) $row['stock_qty'];
|
||||
$stockStatus = $stockQty <= 0 ? 'out_of_stock' : ($stockQty <= (int) ($row['min_stock'] ?? 5) ? 'low_stock' : 'in_stock');
|
||||
|
||||
$results[] = [
|
||||
'id' => (int) $row['id_material'],
|
||||
'type' => 'mpr_material',
|
||||
'name' => $row['name'],
|
||||
'subtitle' => $row['reference'] ? 'Ref: ' . $row['reference'] : null,
|
||||
'reference' => $row['reference'],
|
||||
'stock_qty' => $stockQty,
|
||||
'stock_status' => $stockStatus,
|
||||
'active' => (bool) $row['active'],
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count materials matching query
|
||||
*/
|
||||
public function countTargetMprMaterials($query, $idLang, $idShop, array $filters = [])
|
||||
{
|
||||
$escapedQuery = $this->escapePattern($query);
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('COUNT(DISTINCT m.id_material)');
|
||||
$sql->from('mprwarehouserevolution_material', 'm');
|
||||
$sql->leftJoin('mprwarehouserevolution_material_lang', 'ml', 'ml.id_material = m.id_material AND ml.id_lang = ' . (int) $idLang);
|
||||
|
||||
if ($query !== '') {
|
||||
$sql->where('(ml.name LIKE \'%' . $escapedQuery . '%\' OR m.reference LIKE \'%' . $escapedQuery . '%\' OR m.barcode LIKE \'%' . $escapedQuery . '%\')');
|
||||
}
|
||||
|
||||
return (int) Db::getInstance()->getValue($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get materials by IDs
|
||||
*/
|
||||
public function getTargetMprMaterialsByIds(array $ids, $idLang, $idShop = null)
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = Db::getInstance();
|
||||
|
||||
$sql = new DbQuery();
|
||||
$sql->select('m.id_material, m.reference, m.unit, m.unit_cost, m.active,
|
||||
ml.name, ml.description,
|
||||
IFNULL(SUM(ms.quantity), 0) AS stock_qty');
|
||||
$sql->from('mprwarehouserevolution_material', 'm');
|
||||
$sql->leftJoin('mprwarehouserevolution_material_lang', 'ml', 'ml.id_material = m.id_material AND ml.id_lang = ' . (int) ($idLang ?: $this->idLang));
|
||||
$sql->leftJoin('mprwarehouserevolution_material_stock', 'ms', 'ms.id_material = m.id_material');
|
||||
$sql->where('m.id_material IN (' . implode(',', array_map('intval', $ids)) . ')');
|
||||
$sql->groupBy('m.id_material');
|
||||
|
||||
$rows = $db->executeS($sql);
|
||||
if (!$rows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$materials = [];
|
||||
foreach ($rows as $row) {
|
||||
$stockQty = (int) $row['stock_qty'];
|
||||
$materials[(int) $row['id_material']] = [
|
||||
'id' => (int) $row['id_material'],
|
||||
'type' => 'mpr_material',
|
||||
'name' => $row['name'],
|
||||
'subtitle' => $row['reference'] ? 'Ref: ' . $row['reference'] : null,
|
||||
'reference' => $row['reference'],
|
||||
'stock_qty' => $stockQty,
|
||||
'stock_status' => $stockQty <= 0 ? 'out_of_stock' : 'in_stock',
|
||||
'active' => (bool) $row['active'],
|
||||
];
|
||||
}
|
||||
|
||||
// Return in original order
|
||||
$ordered = [];
|
||||
foreach ($ids as $id) {
|
||||
if (isset($materials[(int) $id])) {
|
||||
$ordered[] = $materials[(int) $id];
|
||||
}
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FILTERABLE DATA (for UI dropdowns)
|
||||
// =========================================================================
|
||||
|
||||
@@ -121,6 +121,7 @@ class EntitySelectorRenderer
|
||||
'show_cms' => true,
|
||||
'show_cms_categories' => true,
|
||||
'combination_mode' => 'products',
|
||||
'product_selection_level' => 'product',
|
||||
'mode' => 'multi',
|
||||
'blocks' => [],
|
||||
'customBlocks' => [],
|
||||
@@ -139,6 +140,9 @@ class EntitySelectorRenderer
|
||||
if (is_string($savedData)) {
|
||||
$savedData = json_decode($savedData, true) ?: [];
|
||||
}
|
||||
if (!is_array($savedData)) {
|
||||
$savedData = [];
|
||||
}
|
||||
|
||||
// Determine which block is active
|
||||
$enabledBlocks = [];
|
||||
@@ -1542,6 +1546,7 @@ class EntitySelectorRenderer
|
||||
],
|
||||
'methodHelp' => $this->getAllMethodHelpContent(),
|
||||
'combinationMode' => $config['combination_mode'] ?? 'products',
|
||||
'productSelectionLevel' => $config['product_selection_level'] ?? 'product',
|
||||
'emptyMeansAll' => $config['empty_means_all'] ?? true,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -176,7 +176,18 @@ class ProductConditionResolver
|
||||
return $this->getAllActiveProductIds();
|
||||
|
||||
case 'specific':
|
||||
return is_array($values) ? array_map('intval', $values) : [];
|
||||
if (!is_array($values)) {
|
||||
return [];
|
||||
}
|
||||
// Support prefixed string IDs (c:32, p:17) — return as-is
|
||||
$hasPrefixed = false;
|
||||
foreach ($values as $v) {
|
||||
if (is_string($v) && (strpos($v, 'c:') === 0 || strpos($v, 'p:') === 0)) {
|
||||
$hasPrefixed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $hasPrefixed ? array_map('strval', $values) : array_map('intval', $values);
|
||||
|
||||
case 'by_category':
|
||||
return $this->getProductIdsByCategories($values);
|
||||
@@ -2049,4 +2060,62 @@ class ProductConditionResolver
|
||||
'combinations' => $allCombinations,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand product IDs to combination-level prefixed IDs.
|
||||
* Products WITH combinations → "c:{id_product_attribute}" per combination.
|
||||
* Products WITHOUT combinations (simple) → "p:{id_product}".
|
||||
*
|
||||
* @param array $productIds Plain integer product IDs
|
||||
* @param string $mode 'combination' (only combos + simple) or 'both' (parent + combos + simple)
|
||||
* @return array Prefixed string IDs
|
||||
*/
|
||||
public function expandToCombinations(array $productIds, $mode = 'combination')
|
||||
{
|
||||
if (empty($productIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$db = Db::getInstance();
|
||||
$idShop = $this->idShop;
|
||||
|
||||
// Get all combinations for these products
|
||||
$sql = 'SELECT pa.id_product_attribute, pa.id_product
|
||||
FROM ' . _DB_PREFIX_ . 'product_attribute pa
|
||||
INNER JOIN ' . _DB_PREFIX_ . 'product_attribute_shop pas
|
||||
ON pas.id_product_attribute = pa.id_product_attribute AND pas.id_shop = ' . (int) $idShop . '
|
||||
WHERE pa.id_product IN (' . implode(',', array_map('intval', $productIds)) . ')
|
||||
ORDER BY pa.id_product, pa.id_product_attribute';
|
||||
|
||||
$rows = $db->executeS($sql);
|
||||
|
||||
// Group combinations by product
|
||||
$combosByProduct = [];
|
||||
if ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
$combosByProduct[(int) $row['id_product']][] = (int) $row['id_product_attribute'];
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($productIds as $idProduct) {
|
||||
$idProduct = (int) $idProduct;
|
||||
|
||||
if (!empty($combosByProduct[$idProduct])) {
|
||||
// Product has combinations
|
||||
if ($mode === 'both') {
|
||||
// Include parent product entry
|
||||
$result[] = 'p:' . $idProduct;
|
||||
}
|
||||
foreach ($combosByProduct[$idProduct] as $idPA) {
|
||||
$result[] = 'c:' . $idPA;
|
||||
}
|
||||
} else {
|
||||
// Simple product — no combinations
|
||||
$result[] = 'p:' . $idProduct;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user