'aieo_display_same_needs',
'crosssells' => 'aieo_display_cross_sells',
'upsells' => 'aieo_display_upsells',
'related' => 'aieo_display_related',
'recently_viewed' => 'aieo_display_recently_viewed_products',
);
}
public static function orderable_cluster_ids() {
return array( 'role_pricing_login', 'product_meta', 'product_categories', 'brand_display', 'payment_logos', 'trust_badges', 'product_needs', 'complementary', 'waitlist', 'product_requests_display', 'brand_gifts', 'wishlist' );
}
public function get_id() { return 'product_display'; }
public function get_icon() { return 'layout-dashboard'; }
public function get_label() { return __('Product page', 'ai-eshop-optimizer'); }
public function get_priority() { return 5; }
public function get_group() { return 'product_display'; }
public function register_hooks() {
add_action('wp_ajax_aieo_dmm_product_display_toggle', array($this, 'ajax_toggle'));
add_action('wp_ajax_aieo_dmm_product_display_save_order', array($this, 'ajax_save_order'));
add_action('wp_ajax_aieo_dmm_recommendations_save_order', array($this, 'ajax_save_reco_order'));
add_action('wp_ajax_aieo_dmm_recommendations_save_conditions', array($this, 'ajax_save_reco_conditions'));
add_action('wp_ajax_aieo_dmm_save_tab_order', array($this, 'ajax_save_tab_order'));
add_filter('woocommerce_product_tabs', array($this, 'apply_tab_order'), 98);
add_action('wp_enqueue_scripts', array($this, 'enqueue_cluster_order_css'));
add_action('wp_enqueue_scripts', array($this, 'enqueue_recommendations_order_css'));
add_action('wp_enqueue_scripts', array($this, 'register_static_reco_surfaces'));
add_action('wp', array($this, 'reorder_classic_theme_recos'), 100);
if ( ! defined( 'AIEO_RUNTIME_OWNS_AJAX' ) ) {
add_action('wp', array($this, 'reorder_classic_theme_summary'), 100);
add_filter('template_include', array($this, 'stand_down_classic_on_block_templates'), 100);
}
add_filter('render_block', array($this, 'wrap_cluster_block_in_template'), 10, 2);
}
private function modules_catalog() {
return array(
array(
'id' => 'payment_logos',
'label' => __('Payment Logos', 'ai-eshop-optimizer'),
'description' => __('Show payment-method icons (Visa, Mastercard, COD, etc.) under the Add-to-Cart button.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_payment_logos_settings',
'icon' => 'dashicons-money-alt',
),
array(
'id' => 'product_labels',
'label' => __('Product Labels', 'ai-eshop-optimizer'),
'description' => __('"Sale", "New", "Best seller" badges over product images on shop + single pages.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_product_labels_settings',
'icon' => 'dashicons-tag',
),
array(
'id' => 'add_to_cart_text',
'label' => __('Add to Cart Text', 'ai-eshop-optimizer'),
'description' => __('Customise "Add to cart" / "Select options" / "Out of stock" labels per product type.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_add_to_cart_text_settings',
'icon' => 'dashicons-cart',
),
array(
'id' => 'trust_badges',
'label' => __('Trust Badges', 'ai-eshop-optimizer'),
'description' => __('Reassurance badges (free shipping, easy returns, secure checkout) on product pages.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_trust_badges_settings',
'icon' => 'dashicons-shield',
),
array(
'id' => 'product_meta',
'label' => __('Product Meta', 'ai-eshop-optimizer'),
'description' => __('Custom display of SKU, categories, brand, tags and other product meta on the single page.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_product_meta_settings',
'icon' => 'dashicons-info',
),
array(
'id' => 'product_categories',
'label' => __('Product Categories', 'ai-eshop-optimizer'),
'description' => __('Clickable, comma-separated list of the product’s categories (each links to its archive). Restores the line WooCommerce’s native meta used to show.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_product_categories_settings',
'icon' => 'dashicons-category',
),
array(
'id' => 'role_pricing_login',
'label' => __('Role-Pricing Login Message', 'ai-eshop-optimizer'),
'description' => __('The “log in to see your price” message for role-based pricing, shown in the info column. Enable it from the Role Pricing tab (tick “Position in the product info column”).', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_role_pricing_login_settings',
'icon' => 'dashicons-lock',
),
array(
'id' => 'brand_display',
'label' => __('Brand Display', 'ai-eshop-optimizer'),
'description' => __('Show the product brand on single product pages — as text or as the brand logo image. Position is configurable (before/in/after the title row, or after the post-meta).', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_brand_display_settings',
'icon' => 'dashicons-awards',
),
array(
'id' => 'product_video',
'label' => __('Product Video', 'ai-eshop-optimizer'),
'description' => __('Embed a video (YouTube / Vimeo / self-hosted) into the product gallery.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_product_video_settings',
'icon' => 'dashicons-video-alt3',
),
array(
'id' => 'variations_gallery',
'label' => __('Variations Gallery', 'ai-eshop-optimizer'),
'description' => __('Per-variation image galleries — switching attributes swaps the gallery for that variation.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_variations_gallery_settings',
'icon' => 'dashicons-images-alt2',
),
array(
'id' => 'product_needs',
'label' => __('Product Needs', 'ai-eshop-optimizer'),
'description' => __('"This product covers:" badge listing the linked pa_need terms. Renders inside the after-cart cluster on FSE templates.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_product_needs_display',
'icon' => 'dashicons-yes-alt',
),
array(
'id' => 'complementary',
'label' => __('Complementary Products', 'ai-eshop-optimizer'),
'description' => __('"Complete your purchase with…" picker. Renders inside the after-cart cluster on FSE templates (was hooked into the WC add-to-cart form on classic themes).', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_complementary_settings',
'icon' => 'dashicons-plus-alt',
),
array(
'id' => 'waitlist',
'label' => __('Back-in-Stock Waitlist', 'ai-eshop-optimizer'),
'description' => __('Email-subscribe form shown on out-of-stock products. Renders inside the cluster on FSE templates (was hooked into the WC add-to-cart form on classic themes).', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_waitlist',
'icon' => 'dashicons-email-alt',
),
array(
'id' => 'product_requests_display',
'label' => __('Product Requests', 'ai-eshop-optimizer'),
'description' => __('"Ask us about this product" card, for catalogue sites where nothing is purchasable. Renders inside the cluster on FSE templates; two dedicated blocks cover pages, category templates and headers, which the bridge cannot reach.', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_product_requests',
'icon' => 'dashicons-email-alt',
),
array(
'id' => 'brand_gifts',
'label' => __('Free Brand Gifts', 'ai-eshop-optimizer'),
'description' => __('"Buy ≥ X and get…" gift offers from the product\'s brand. Renders inside the cluster on FSE templates (was hooked into the WC product summary on classic themes).', 'ai-eshop-optimizer'),
'option_key' => 'aieo_dmm_brand_gifts_settings',
'icon' => 'dashicons-products',
),
);
}
public static function module_is_enabled($option_key) {
$opt = get_option($option_key, array());
if (!is_array($opt)) { $opt = array(); }
if (self::REQUESTS_OPTION === $option_key) {
return self::is_yes($opt['enabled'] ?? 'no') && self::is_yes($opt['frontend_enabled'] ?? 'no');
}
return self::is_yes($opt['enabled'] ?? false);
}
const REQUESTS_OPTION = 'aieo_dmm_product_requests';
private static function is_yes($value) {
return is_string($value) ? in_array(strtolower(trim($value)), array('yes', 'on', 'true', '1'), true) : !empty($value);
}
public function ajax_toggle() {
AIEO_DMM::verify_ajax_request();
$module = isset($_POST['module']) ? sanitize_key(wp_unslash($_POST['module'])) : '';
$enabled = !empty($_POST['enabled']);
$catalog = $this->modules_catalog();
foreach ($catalog as $m) {
if ($m['id'] !== $module) { continue; }
$stored = get_option($m['option_key'], array());
if (!is_array($stored)) { $stored = array(); }
if (self::REQUESTS_OPTION === $m['option_key']) {
$stored['enabled'] = $enabled ? 'yes' : 'no';
$stored['frontend_enabled'] = $enabled ? 'yes' : 'no';
} else {
$stored['enabled'] = $enabled;
}
update_option($m['option_key'], $stored, false);
wp_send_json_success(array(
'enabled' => $enabled,
'message' => $enabled
? __('Module enabled.', 'ai-eshop-optimizer')
: __('Module disabled.', 'ai-eshop-optimizer'),
));
return;
}
wp_send_json_error(array('message' => __('Unknown module.', 'ai-eshop-optimizer')));
}
public static function get_cluster_order() {
$defaults = self::orderable_cluster_ids();
$stored = get_option(self::ORDER_OPTION, array());
if (!is_array($stored) || empty($stored)) { return $defaults; }
$known = array_values(array_intersect($stored, $defaults));
foreach ($defaults as $id) {
if (!in_array($id, $known, true)) { $known[] = $id; }
}
return $known;
}
const ZONES_OPTION = 'aieo_dmm_product_display_zones';
const LAYOUT_ORDER_OPTION = 'aieo_dmm_pdp_layout_order';
public static function layout_catalog() {
return array(
'title' => array('label' => __('Product Title', 'ai-eshop-optimizer'), 'sel' => ':is(.is-style-rooster-order-title,.rooster-order--title,.ff-order--title)', 'zoneable' => false),
'rating' => array('label' => __('Product Rating', 'ai-eshop-optimizer'), 'sel' => ':is(.is-style-rooster-order-rating,.rooster-order--rating,.ff-order--rating)', 'zoneable' => false),
'summary' => array('label' => __('Short Description', 'ai-eshop-optimizer'), 'sel' => ':is(.is-style-rooster-order-summary,.rooster-order--summary,.ff-order--summary)', 'zoneable' => false),
'sale_msg' => array('label' => __('Sale-eligibility message', 'ai-eshop-optimizer'), 'sel' => ':is(.is-style-rooster-order-sale_msg,.rooster-order--sale_msg,.ff-order--sale_msg)', 'zoneable' => false),
'free_shipping' => array('label' => __('Free-shipping message', 'ai-eshop-optimizer'), 'sel' => ':is(.is-style-rooster-order-free_shipping,.rooster-order--free_shipping,.ff-order--free_shipping)','zoneable' => false),
'lowest_price' => array('label' => __('Lowest-price (sale) message', 'ai-eshop-optimizer'), 'sel' => ':is(.is-style-rooster-order-lowest_price,.rooster-order--lowest_price,.ff-order--lowest_price)', 'zoneable' => false),
'price' => array('label' => __('Price', 'ai-eshop-optimizer'), 'sel' => ':is(.is-style-rooster-order-price,.rooster-order--price,.ff-order--price)', 'zoneable' => false),
'add_to_cart' => array('label' => __('Add to Cart', 'ai-eshop-optimizer'), 'sel' => ':is(.is-style-rooster-order-add_to_cart,.rooster-order--add_to_cart,.ff-order--add_to_cart)', 'zoneable' => false),
'product_meta' => array('label' => __('Product Meta (SKU / barcode)', 'ai-eshop-optimizer'), 'sel' => '.wp-block-aieo-post-meta', 'zoneable' => true),
'product_categories' => array('label' => __('Product Categories', 'ai-eshop-optimizer'), 'sel' => '.aieo-dmm-bridge--product_categories', 'zoneable' => true),
'role_pricing_login' => array('label' => __('Role-Pricing Login Message', 'ai-eshop-optimizer'),'sel' => '.aieo-dmm-bridge--role_pricing', 'zoneable' => true),
'brand_display' => array('label' => __('Brand Display', 'ai-eshop-optimizer'), 'sel' => '.aieo-dmm-bridge--brand_display', 'zoneable' => true),
'payment_logos' => array('label' => __('Payment Logos', 'ai-eshop-optimizer'), 'sel' => '.aieo-dmm-bridge--payment_logos', 'zoneable' => true),
'trust_badges' => array('label' => __('Trust Badges', 'ai-eshop-optimizer'), 'sel' => '.aieo-dmm-bridge--trust_badges', 'zoneable' => true),
'product_needs' => array('label' => __('Product Needs', 'ai-eshop-optimizer'), 'sel' => '.aieo-dmm-bridge--product_needs', 'zoneable' => true),
'complementary' => array('label' => __('Complementary Products', 'ai-eshop-optimizer'), 'sel' => '.aieo-dmm-bridge--complementary', 'zoneable' => true),
'waitlist' => array('label' => __('Back-in-Stock Waitlist', 'ai-eshop-optimizer'), 'sel' => '.aieo-dmm-bridge--waitlist', 'zoneable' => true),
'product_requests_display' => array('label' => __('Product Requests', 'ai-eshop-optimizer'), 'sel' => '.aieo-dmm-bridge--product_requests_display', 'zoneable' => true),
'brand_gifts' => array('label' => __('Free Brand Gifts', 'ai-eshop-optimizer'), 'sel' => '.aieo-dmm-bridge--brand_gifts', 'zoneable' => true),
'wishlist' => array('label' => __('Add to Wishlist', 'ai-eshop-optimizer'), 'sel' => '.aieo-wishlist-heart-wrap', 'zoneable' => true),
);
}
public static function get_layout_order() {
$defaults = array_keys(self::layout_catalog());
$stored = get_option(self::LAYOUT_ORDER_OPTION, array());
if (!is_array($stored) || empty($stored)) { return $defaults; }
$known = array_values(array_intersect($stored, $defaults));
foreach ($defaults as $id) { if (!in_array($id, $known, true)) { $known[] = $id; } }
return $known;
}
public static function get_cluster_zones() {
$stored = get_option(self::ZONES_OPTION, array());
if (!is_array($stored)) { $stored = array(); }
$out = array();
foreach (self::orderable_cluster_ids() as $id) {
if ($id === 'wishlist' || $id === 'brand_display') {
if (isset($stored[$id])) {
$z = (string) $stored[$id];
$def = ($id === 'wishlist') ? 'beside_title' : 'info';
$out[$id] = in_array($z, array('info', 'under', 'beside_title'), true) ? $z : $def;
} elseif ($id === 'wishlist') {
$out[$id] = 'beside_title';
} else {
$bopt = get_option('aieo_dmm_brand_display_settings', array());
$bpos = (is_array($bopt) && isset($bopt['position'])) ? (string) $bopt['position'] : 'under_title';
$out[$id] = in_array($bpos, array('in_title_row', 'mobile_before_desktop_inline'), true) ? 'beside_title' : 'info';
}
} else {
$out[$id] = (isset($stored[$id]) && $stored[$id] === 'under') ? 'under' : 'info';
}
}
return $out;
}
public function ajax_save_order() {
AIEO_DMM::verify_ajax_request();
$raw = isset($_POST['order']) ? wp_unslash($_POST['order']) : array();
if (!is_array($raw)) { $raw = array(); }
$allowed = array_keys(self::layout_catalog());
$clean = array();
foreach ($raw as $id) {
$id = sanitize_key($id);
if (in_array($id, $allowed, true) && !in_array($id, $clean, true)) {
$clean[] = $id;
}
}
if (empty($clean)) {
wp_send_json_error(array('message' => __('Empty order payload.', 'ai-eshop-optimizer')));
}
update_option(self::LAYOUT_ORDER_OPTION, $clean, false);
if (isset($_POST['zones'])) {
$catalog = self::layout_catalog();
$rawz = is_array($_POST['zones']) ? wp_unslash($_POST['zones']) : array();
$zones = array();
foreach ($rawz as $id => $z) {
$id = sanitize_key((string) $id);
if (isset($catalog[$id]) && !empty($catalog[$id]['zoneable'])) {
$z = sanitize_key((string) $z);
$bt_def = array('wishlist' => 'beside_title', 'brand_display' => 'info');
if (isset($bt_def[$id])) {
$zones[$id] = in_array($z, array('info', 'under', 'beside_title'), true) ? $z : $bt_def[$id];
} else {
$zones[$id] = ($z === 'under') ? 'under' : 'info';
}
}
}
update_option(self::ZONES_OPTION, $zones, false);
}
wp_send_json_success(array(
'order' => $clean,
'message' => __('Layout saved.', 'ai-eshop-optimizer'),
));
}
private function cluster_block_names() {
return array('aieo/post-meta', 'aieo/dmm-bridge');
}
public function wrap_cluster_block_in_template($block_content, $block) {
if ( ! function_exists('is_product') || ! is_product() ) { return $block_content; }
$name = isset($block['blockName']) ? (string) $block['blockName'] : '';
if ( ! in_array($name, $this->cluster_block_names(), true) ) { return $block_content; }
if ($name === 'aieo/dmm-bridge') {
$mid = isset($block['attrs']['moduleId']) ? (string) $block['attrs']['moduleId'] : '';
$cluster_bridge_ids = array('brand_display', 'payment_logos', 'trust_badges', 'product_needs', 'complementary', 'waitlist', 'product_requests_display', 'brand_gifts');
if ( ! in_array($mid, $cluster_bridge_ids, true) ) { return $block_content; }
if ($mid === 'brand_display') {
$method = isset($block['attrs']['renderMethod']) ? (string) $block['attrs']['renderMethod'] : 'output';
if ($method !== 'render_after_post_meta') { return $block_content; }
}
}
if ( trim($block_content) === '' ) { return $block_content; }
if ( self::prints_live($block_content) ) { return $block_content; }
if ( ! preg_match('/^(\s*)(
]*)(>)(.*)(<\/div>\s*)$/s', $block_content, $m) ) {
return $block_content;
}
$lead = $m[1];
$open_tag = $m[2];
$inner = $m[4];
$close_tag = $m[5];
if ( preg_match('/class\s*=\s*"([^"]*)"/', $open_tag) ) {
$open_tag = preg_replace(
'/class\s*=\s*"([^"]*)"/',
'class="$1 aieo-cluster-lazy"',
$open_tag,
1
);
} else {
$open_tag .= ' class="aieo-cluster-lazy"';
}
$open_tag .= ' data-aieo-cluster-mount="1"';
$key = $this->mount_key($name === 'aieo/dmm-bridge' ? (isset($block['attrs']['moduleId']) ? (string) $block['attrs']['moduleId'] : '') : 'post_meta');
$open_tag .= ' data-aieo-mount-id="' . esc_attr($key) . '"';
$held = class_exists('\Rooster\Core\Defer') ? \Rooster\Core\Defer::learned_height($key) : null;
if (null !== $held && $held > 0) {
$var = '--aieo-mount-height:' . (int) $held . 'px';
$open_tag = preg_match('/\sstyle\s*=\s*"/', $open_tag)
? preg_replace('/(\sstyle\s*=\s*")/', '$1' . $var . ';', $open_tag, 1)
: $open_tag . ' style="' . $var . '"';
}
return $lead
. $open_tag . '>'
. '' . $inner . ''
. $close_tag;
}
public function render_title_row() {
$zones = self::get_cluster_zones();
$out = '';
if (isset($zones['brand_display']) && $zones['brand_display'] === 'beside_title' && class_exists('AIEO_DMM_Brand_Display')) {
$brand = AIEO_DMM::instance()->get_module('brand_display');
if (is_object($brand) && method_exists($brand, 'render_in_title_row')) {
ob_start();
$brand->render_in_title_row();
$out .= (string) ob_get_clean();
}
}
if (isset($zones['wishlist']) && $zones['wishlist'] === 'beside_title' && class_exists('AIEO_Wishlist')) {
$out .= AIEO_Wishlist::instance()->title_row_heart_html();
}
echo $out;
}
private $mount_keys = array();
private function mount_key($module) {
$base = 'aieo-mount-' . substr(preg_replace('/[^a-z0-9_]/', '', strtolower('' !== $module ? $module : 'part')), 0, 30);
$n = isset($this->mount_keys[ $base ]) ? $this->mount_keys[ $base ] + 1 : 1;
$this->mount_keys[ $base ] = $n;
return $n > 1 ? $base . '_' . $n : $base;
}
public static function renders_gallery() {
return class_exists('AIEO_Gallery_Lightbox_Frontend')
&& AIEO_Gallery_Lightbox_Frontend::renders_gallery();
}
public static function gallery_sizes_attr() {
return class_exists('AIEO_Gallery_Lightbox_Frontend')
? AIEO_Gallery_Lightbox_Frontend::gallery_sizes_attr()
: (string) apply_filters('aieo_pdp_gallery_image_sizes', '(max-width: 991px) 100vw, 48vw');
}
const GALLERY_RENDERS_OPTION = 'aieo_wc_gallery_renders';
public function enqueue_cluster_order_css() {
if (!function_exists('is_product') || !is_product()) { return; }
$catalog = self::layout_catalog();
$order = self::get_layout_order();
$zones = self::get_cluster_zones();
$col = '.wp-block-column:is(.rooster-product__info,.ff-product-info)';
$under = '.wp-block-group:is(.rooster-product__under-media,.ff-product-under-image-cluster)';
$rules = array(
$col . '{display:flex !important;flex-direction:column !important;}',
$col . ' > .wp-block-group:is(.rooster-product__cluster,.ff-product-display-cluster){display:contents !important;}',
$under . '{display:flex;flex-direction:column;}',
$col . ' > *{order:500;}',
$under . ' > *{order:500;}',
'.rooster-product__title-row > .aieo-dmm-bridge--product_display{display:flex;align-items:center;gap:var(--wp--preset--spacing--2,8px);margin:0;}',
'.rooster-product__title-row .aieo-wishlist-heart-wrap--title-row{display:inline-flex;margin:0;}',
'.aieo-cluster-lazy:not(.is-hydrated){min-block-size:var(--aieo-mount-height,0px);}',
);
$title_ord = 10;
foreach ($order as $i => $id) { if ($id === 'title') { $title_ord = ($i + 1) * 10; } }
$brand_pos = '';
if (class_exists('AIEO_DMM_Brand_Display')) {
$bs = (new AIEO_DMM_Brand_Display())->get_settings();
$brand_pos = isset($bs['position']) ? (string) $bs['position'] : '';
}
$brand_glued = in_array($brand_pos, array('in_title_row'), true);
$any_under = false;
foreach ($order as $i => $id) {
if (!isset($catalog[$id])) { continue; }
$sel = (string) $catalog[$id]['sel'];
$ord = ($i + 1) * 10;
$zoneable = !empty($catalog[$id]['zoneable']);
$zone = ($zoneable && isset($zones[$id]) && $zones[$id] === 'under') ? 'under' : 'info';
if ($id === 'brand_display' && $brand_glued) {
$rules[] = sprintf('%s .aieo-dmm-bridge--brand_display{order:%d !important;}', $col, $title_ord - 1);
continue;
}
if ($zone === 'under') {
$any_under = true;
$rules[] = sprintf('%s > %s{order:%d !important;}', $under, $sel, $ord);
continue;
}
$rules[] = sprintf('%s %s{order:%d !important;}', $col, $sel, $ord);
if ($id === 'title') {
$rules[] = sprintf('%s > .ff-product-title-row{order:%d !important;}', $col, $ord);
}
}
if (!$any_under) { $rules[] = $under . '{display:none !important;}'; }
$css = implode('', $rules);
wp_register_style('aieo-dmm-product-display-order', false, array(), defined('AIEO_VERSION') ? AIEO_VERSION : '1.0');
wp_enqueue_style('aieo-dmm-product-display-order');
wp_add_inline_style('aieo-dmm-product-display-order', $css);
wp_register_script('aieo-dmm-cluster-hydrate', false, array(), defined('AIEO_VERSION') ? AIEO_VERSION : '1.0', true);
wp_enqueue_script('aieo-dmm-cluster-hydrate');
wp_add_inline_script('aieo-dmm-cluster-hydrate', $this->cluster_hydrate_js());
$heights = class_exists('\Rooster\Core\Defer') ? \Rooster\Core\Defer::heights_config() : array();
if ($heights) {
wp_add_inline_script('aieo-dmm-cluster-hydrate', 'window.aieoMountHeights=' . wp_json_encode($heights) . ';', 'before');
}
}
private function cluster_hydrate_js() {
return <<<'JS'
(function(){
"use strict";
function hydrateOne(mount) {
if (mount.__aieoHydrated) return;
var tpl = mount.querySelector(":scope > template.aieo-cluster-tpl");
if (!tpl || !tpl.content) return;
mount.__aieoHydrated = true;
var held = parseFloat(getComputedStyle(mount).getPropertyValue("--aieo-mount-height")) || 0;
mount.appendChild(tpl.content.cloneNode(true));
if (tpl.parentNode) tpl.parentNode.removeChild(tpl);
mount.classList.add("is-hydrated");
queueReport(mount, held);
try {
mount.dispatchEvent(new CustomEvent("aieo:cluster:hydrated", { bubbles: true, detail: { mount: mount } }));
} catch (e) {}
}
// Learned heights (the engine's Defer): 2 s after the last mount, the parts more than 8 px off what they held
// are measured (24 px when what they held was already a learned height: phones of other widths differ a little),
// and sent once, when the visitor leaves the page (pagehide, or the page hidden), off the load timeline: the
// owner's GTmetrix showed the beacon at 2.2 s as the page's last request (29 Oct 2026). Signed-out visitors only.
var cfg = window.aieoMountHeights, queued = [], timer = 0, sizes = null, sent = false;
function queueReport(mount, held) {
if (!cfg || !cfg.url || !cfg.nonce || !navigator.sendBeacon || sent) return;
queued.push([mount, held]);
clearTimeout(timer);
timer = setTimeout(measure, 2000);
}
function measure() {
queued.forEach(function (q) {
var id = q[0].getAttribute("data-aieo-mount-id");
var now = Math.round(q[0].getBoundingClientRect().height);
if (id && Math.abs(now - q[1]) > (q[1] > 0 ? 24 : 8)) { sizes = sizes || {}; sizes[id] = now; }
});
queued = [];
}
function send() {
if (sent || !sizes || !cfg) return;
sent = true;
var body = new FormData();
body.set("action", "rooster_defer_heights");
body.set("nonce", cfg.nonce);
body.set("path", location.pathname + location.search);
body.set("device", cfg.device);
body.set("h", JSON.stringify(sizes));
navigator.sendBeacon(cfg.url, body);
}
window.addEventListener("pagehide", send);
document.addEventListener("visibilitychange", function () { if (document.visibilityState === "hidden") send(); });
function init() {
// Scan the whole document for lazy mounts. Originally this was
// scoped to `.ff-product-display-cluster` (4 modules render
// inside that wrapper), but lazy markers now also appear OUTSIDE
// the cluster — e.g. complementary's picker is hooked into
// `woocommerce_before_add_to_cart_button` and lands inside WC's
// add-to-cart-form, not the cluster. Document-wide scan keeps
// the hydrator the single source of truth regardless of where
// a module's `data-aieo-cluster-mount` lands.
var mounts = document.querySelectorAll("[data-aieo-cluster-mount]");
if (!mounts.length) return;
// No IO support → hydrate immediately (graceful degradation).
if (!("IntersectionObserver" in window)) {
mounts.forEach(hydrateOne);
return;
}
// Per-mount observer: each mount hydrates only when ITS region
// nears the viewport. Cheaper than waiting for one wrapper to
// intersect (which would over-hydrate when mounts are scattered
// across the page) and lets above-fold mounts hydrate even
// before any user scroll.
mounts.forEach(function(mount) {
var io = new IntersectionObserver(function(entries) {
for (var i = 0; i < entries.length; i++) {
if (entries[i].isIntersecting) {
hydrateOne(mount);
io.disconnect();
return;
}
}
}, { rootMargin: "200px 0px" });
io.observe(mount);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
JS;
}
public static function reco_catalog($with_labels = true) {
$built_in = array(
'same_needs' => $with_labels ? __('Products for the same needs', 'ai-eshop-optimizer') : '',
'crosssells' => $with_labels ? __('Cross-sells', 'ai-eshop-optimizer') : '',
'upsells' => $with_labels ? __('Up-sells', 'ai-eshop-optimizer') : '',
'related' => $with_labels ? __('Related (by WooCommerce)', 'ai-eshop-optimizer') : '',
'recently_viewed' => $with_labels ? __('Recently Viewed', 'ai-eshop-optimizer') : '',
);
$catalog = array();
foreach ($built_in as $id => $label) {
$catalog[] = array(
'id' => $id,
'label' => $label,
'kind' => 'recos',
);
}
$semantic_engine = class_exists('AIEO_Semantic_Artifacts');
$semantic_data = class_exists('AIEO_Semantic_Reco_Source') && AIEO_Semantic_Reco_Source::available();
$catalog[] = array(
'id' => 'semantic_related',
'label' => $with_labels ? __('Semantic related content', 'ai-eshop-optimizer') : '',
'kind' => 'semantic',
'default_visible' => false,
'engine_loaded' => $semantic_engine,
'has_data' => $semantic_data,
'ready' => ($semantic_engine || $semantic_data),
);
if (class_exists('AIEO_DMM_Custom_Slots')) {
foreach (AIEO_DMM_Custom_Slots::get_all_slots() as $slot) {
$catalog[] = array(
'id' => (string) $slot['id'],
'label' => $slot['name'] !== '' ? (string) $slot['name'] : (string) $slot['id'],
'kind' => 'slot',
'slot_type' => (string) $slot['type'],
'slot_enabled' => !empty($slot['enabled']),
);
}
}
return apply_filters('aieo_dmm_reco_catalog', $catalog);
}
public static function reco_surface_visible($id) {
foreach (self::get_reco_order() as $row) {
if ((string) $row['id'] === (string) $id) {
return !empty($row['visible']);
}
}
return false;
}
public static function get_reco_order() {
$catalog = self::reco_catalog(false);
$by_id = array();
foreach ($catalog as $c) { $by_id[$c['id']] = $c; }
$stored = get_option(self::RECO_ORDER_OPTION, null);
$is_first_run = !is_array($stored);
if (!is_array($stored)) { $stored = array(); }
$seen = array();
$out = array();
foreach ($stored as $row) {
if (!is_array($row) || empty($row['id'])) { continue; }
$id = (string) $row['id'];
if (!isset($by_id[$id]) || isset($seen[$id])) { continue; }
$seen[$id] = true;
$out[] = array(
'id' => $id,
'visible' => !empty($row['visible']),
'conditions' => isset($row['conditions']) && is_array($row['conditions'])
? (class_exists('AIEO_Display_Conditions')
? AIEO_Display_Conditions::sanitize($row['conditions'])
: $row['conditions'])
: (class_exists('AIEO_Display_Conditions') ? AIEO_Display_Conditions::defaults() : array()),
);
}
$legacy_keys = self::builtin_reco_legacy_visibility_keys();
foreach ($catalog as $c) {
if (isset($seen[$c['id']])) { continue; }
$default_visible = array_key_exists('default_visible', $c) ? (bool) $c['default_visible'] : true;
if ($is_first_run && $c['kind'] === 'recos' && isset($legacy_keys[$c['id']])) {
$default_visible = (bool) get_option($legacy_keys[$c['id']]);
}
$out[] = array(
'id' => $c['id'],
'visible' => $default_visible,
'conditions' => class_exists('AIEO_Display_Conditions') ? AIEO_Display_Conditions::defaults() : array(),
);
}
return $out;
}
public static function reco_surface_conditions($id) {
foreach (self::get_reco_order() as $row) {
if ((string) $row['id'] === (string) $id) {
return isset($row['conditions']) && is_array($row['conditions']) ? $row['conditions'] : array();
}
}
return array();
}
public function ajax_save_reco_conditions() {
AIEO_DMM::verify_ajax_request();
$id = isset($_POST['surface']) ? sanitize_key(wp_unslash($_POST['surface'])) : '';
if ($id === '') { wp_send_json_error(array('message' => __('Missing surface.', 'ai-eshop-optimizer'))); }
$catalog = self::reco_catalog();
$known = false;
foreach ($catalog as $c) { if ($c['id'] === $id) { $known = true; break; } }
if (!$known) { wp_send_json_error(array('message' => __('Unknown rail.', 'ai-eshop-optimizer'))); }
$raw = isset($_POST['conditions']) ? wp_unslash($_POST['conditions']) : '';
$row = is_string($raw) ? json_decode($raw, true) : null;
if (!is_array($row)) { $row = array(); }
$rows = self::get_reco_order();
foreach ($rows as $i => $r) {
if ((string) $r['id'] !== $id) { continue; }
$rows[$i]['conditions'] = class_exists('AIEO_Display_Conditions_UI')
? AIEO_Display_Conditions_UI::collect($row, is_array($r['conditions']) ? $r['conditions'] : array())
: $r['conditions'];
}
update_option(self::RECO_ORDER_OPTION, $rows, false);
self::purge_page_cache();
wp_send_json_success(array('message' => __('Display rules saved.', 'ai-eshop-optimizer')));
}
public static function conditions_summary($conditions) {
if (!class_exists('AIEO_Display_Conditions')) { return ''; }
$cfg = AIEO_Display_Conditions::sanitize(is_array($conditions) ? $conditions : array());
$parts = array();
if (!empty($cfg['maxPerSession'])) {
$parts[] = sprintf(__('%d per session', 'ai-eshop-optimizer'), (int) $cfg['maxPerSession']);
}
if (!empty($cfg['maxImpr'])) {
$parts[] = sprintf(__('%d lifetime', 'ai-eshop-optimizer'), (int) $cfg['maxImpr']);
}
if ('all' !== $cfg['device']) { $parts[] = 'mobile' === $cfg['device'] ? __('mobile', 'ai-eshop-optimizer') : __('desktop', 'ai-eshop-optimizer'); }
if ('all' !== $cfg['visitor']) { $parts[] = __('audience rule', 'ai-eshop-optimizer'); }
if ('always' !== $cfg['freq']) { $parts[] = __('frequency rule', 'ai-eshop-optimizer'); }
if ('any' !== $cfg['instigator']) { $parts[] = __('traffic source', 'ai-eshop-optimizer'); }
if (!empty($cfg['start']) || !empty($cfg['end'])) { $parts[] = __('scheduled', 'ai-eshop-optimizer'); }
return implode(' · ', $parts);
}
private static function reco_rules_dialog($id, $label, $conditions) {
if (!class_exists('AIEO_Display_Conditions_UI')) { return ''; }
ob_start();
AIEO_Display_Conditions_UI::render_rows(
is_array($conditions) ? $conditions : array(),
array('maxPerSession', 'freq', 'freqDays', 'maxImpr', 'device', 'visitor', 'instigator', 'instigatorRef'),
array('prefix' => 'cond_', 'id' => 'aieo-reco-rules-' . $id . '-')
);
$rows = (string) ob_get_clean();
return \Rooster\Admin\Ui::dialog(
'aieo-reco-rules-' . $id,
sprintf(__('When to show “%s”', 'ai-eshop-optimizer'), $label),
'
' . esc_html__('These rules are decided in the visitor\'s browser before the page is drawn, so a rail that is not going to appear never appears and then vanishes — the page does not move. Leave everything at its default and this rail behaves exactly as it does today.', 'ai-eshop-optimizer') . '
'
. '
' . $rows . '
',
array(
'confirm' => __('Save rules', 'ai-eshop-optimizer'),
'wide' => true,
'dialog_attrs' => array('data-aieo-reco-rules' => $id),
)
);
}
private static function purge_page_cache() {
if (class_exists('AIEO_DMM_Cache') && method_exists('AIEO_DMM_Cache', 'bump_version')) {
AIEO_DMM_Cache::bump_version('popups');
}
do_action('aieo_purge_page_cache');
}
public function register_static_reco_surfaces() {
if (is_admin() || !class_exists('AIEO_Display_Conditions')) { return; }
if (!function_exists('is_product') || !is_product()) { return; }
$anchor = function_exists('get_the_ID') ? (int) get_the_ID() : 0;
foreach (self::get_reco_order() as $row) {
$id = isset($row['id']) ? (string) $row['id'] : '';
if ($id === '' || empty($row['visible'])) { continue; }
if (strpos($id, 'slot_') === 0) { continue; }
AIEO_Display_Conditions::register_static_surface(
$id,
isset($row['conditions']) && is_array($row['conditions']) ? $row['conditions'] : array(),
'[data-aieo-reco="' . $id . '"]',
array('id' => 0, 'v' => 0),
$anchor
);
}
if (AIEO_Display_Conditions::has_static_surfaces()) {
add_filter('rocket_defer_inline_exclusions', array($this, 'rocket_inline_exclusions'));
}
}
public function rocket_inline_exclusions($excluded) {
if (!class_exists('AIEO_Display_Conditions')) { return $excluded; }
$ours = AIEO_Display_Conditions::rocket_needles();
return is_array($excluded) ? array_merge($excluded, $ours) : $ours;
}
public function ajax_save_reco_order() {
AIEO_DMM::verify_ajax_request();
$raw_order = isset($_POST['order']) ? wp_unslash($_POST['order']) : array();
if (!is_array($raw_order)) { $raw_order = array(); }
$raw_visible = isset($_POST['visible']) ? wp_unslash($_POST['visible']) : array();
if (!is_array($raw_visible)) { $raw_visible = array(); }
$catalog = self::reco_catalog();
$allowed = array();
foreach ($catalog as $c) { $allowed[$c['id']] = true; }
$existing = array();
foreach (self::get_reco_order() as $r) {
if (!empty($r['id'])) { $existing[(string) $r['id']] = $r; }
}
$seen = array();
$rows = array();
foreach ($raw_order as $id) {
$id = sanitize_key($id);
if ($id === '' || !isset($allowed[$id]) || isset($seen[$id])) { continue; }
$seen[$id] = true;
$v = isset($raw_visible[$id]) ? (string) $raw_visible[$id] : '0';
$rows[] = array(
'id' => $id,
'visible' => in_array($v, array('1', 'true', 'on', 'yes'), true),
'conditions' => isset($existing[$id]['conditions']) && is_array($existing[$id]['conditions'])
? $existing[$id]['conditions']
: array(),
);
}
if (empty($rows)) {
wp_send_json_error(array('message' => __('Empty order payload.', 'ai-eshop-optimizer')));
}
update_option(self::RECO_ORDER_OPTION, $rows, false);
wp_send_json_success(array(
'order' => $rows,
'message' => __('Recommendations order saved.', 'ai-eshop-optimizer'),
));
}
public static function tab_catalog() {
$catalog = array(
array( 'id' => 'description', 'label' => __( 'Description', 'ai-eshop-optimizer' ), 'kind' => 'core' ),
array( 'id' => 'additional_information', 'label' => __( 'Additional information', 'ai-eshop-optimizer' ), 'kind' => 'core' ),
array( 'id' => 'reviews', 'label' => __( 'Reviews', 'ai-eshop-optimizer' ), 'kind' => 'core' ),
);
if ( class_exists( 'AIEO_DMM_Custom_Tabs' ) ) {
foreach ( AIEO_DMM_Custom_Tabs::get_enabled_slots() as $i => $slot ) {
$label = ( isset( $slot['label'] ) && $slot['label'] !== '' )
? (string) $slot['label']
: sprintf( __( 'Custom Tab %d', 'ai-eshop-optimizer' ), (int) $i );
$catalog[] = array(
'id' => 'aieo_custom_tab_' . (int) $i,
'label' => $label,
'kind' => 'custom',
);
}
}
return $catalog;
}
public static function get_tab_order() {
$catalog = self::tab_catalog();
$allowed = array();
foreach ( $catalog as $c ) { $allowed[ $c['id'] ] = true; }
$stored = get_option( self::TAB_ORDER_OPTION, null );
if ( ! is_array( $stored ) ) { $stored = array(); }
$seen = array();
$out = array();
foreach ( $stored as $id ) {
$id = (string) $id;
if ( ! isset( $allowed[ $id ] ) || isset( $seen[ $id ] ) ) { continue; }
$seen[ $id ] = true;
$out[] = $id;
}
foreach ( $catalog as $c ) {
if ( isset( $seen[ $c['id'] ] ) ) { continue; }
$out[] = $c['id'];
}
return $out;
}
public function apply_tab_order( $tabs ) {
if ( ! is_array( $tabs ) || empty( $tabs ) ) { return $tabs; }
$order = get_option( self::TAB_ORDER_OPTION, null );
if ( ! is_array( $order ) || empty( $order ) ) { return $tabs; }
$base = 10;
$seen = array();
foreach ( $order as $id ) {
$id = (string) $id;
if ( isset( $tabs[ $id ] ) && ! isset( $seen[ $id ] ) ) {
$tabs[ $id ]['priority'] = $base;
$base += 10;
$seen[ $id ] = true;
}
}
foreach ( $tabs as $id => $tab ) {
if ( ! isset( $seen[ $id ] ) ) {
$tabs[ $id ]['priority'] = $base;
$base += 10;
}
}
return $tabs;
}
public function ajax_save_tab_order() {
AIEO_DMM::verify_ajax_request();
$raw = isset( $_POST['order'] ) ? wp_unslash( $_POST['order'] ) : array();
if ( ! is_array( $raw ) ) { $raw = array(); }
$catalog = self::tab_catalog();
$allowed = array();
foreach ( $catalog as $c ) { $allowed[ $c['id'] ] = true; }
$seen = array();
$rows = array();
foreach ( $raw as $id ) {
$id = preg_replace( '/[^a-z0-9_]/', '', (string) $id );
if ( $id === '' || ! isset( $allowed[ $id ] ) || isset( $seen[ $id ] ) ) { continue; }
$seen[ $id ] = true;
$rows[] = $id;
}
if ( empty( $rows ) ) {
wp_send_json_error( array( 'message' => __( 'Empty order payload.', 'ai-eshop-optimizer' ) ) );
}
update_option( self::TAB_ORDER_OPTION, $rows, false );
wp_send_json_success( array(
'order' => $rows,
'message' => __( 'Tab order saved.', 'ai-eshop-optimizer' ),
) );
}
public function reorder_classic_theme_recos() {
if (wp_is_block_theme()) { return; }
if (!function_exists('is_singular') || !is_singular('product')) { return; }
global $wp_filter;
if (empty($wp_filter['woocommerce_after_single_product_summary'])) { return; }
$hook = $wp_filter['woocommerce_after_single_product_summary'];
$order = self::get_reco_order();
if (empty($order)) { return; }
$builtin_methods = array(
'same_needs' => 'aieo_show_same_needs_placeholder',
'crosssells' => 'aieo_show_cross_sell_placeholder',
'upsells' => 'aieo_show_upsell_placeholder',
'related' => 'aieo_show_related_placeholder',
'recently_viewed' => 'aieo_show_recently_viewed_placeholder',
);
$found_builtins = array();
$found_slots_cb = array();
foreach ($hook->callbacks as $prio => $cbs) {
foreach ($cbs as $cb) {
$fn = isset($cb['function']) ? $cb['function'] : null;
if (!is_array($fn) || !is_object($fn[0])) { continue; }
$method = (string) $fn[1];
$class = get_class($fn[0]);
foreach ($builtin_methods as $sid => $name) {
if ($method === $name && !isset($found_builtins[$sid])) {
$found_builtins[$sid] = array('priority' => $prio, 'callback' => $fn);
}
}
if ($class === 'AIEO_DMM_Custom_Slots' && $method === 'render_classic_theme_region') {
$found_slots_cb[] = array('priority' => $prio, 'callback' => $fn);
}
}
}
foreach ($found_slots_cb as $entry) {
remove_action('woocommerce_after_single_product_summary', $entry['callback'], $entry['priority']);
}
$new_prio = 10;
foreach ($order as $row) {
$id = isset($row['id']) ? (string) $row['id'] : '';
if ($id === '') { continue; }
$visible = !empty($row['visible']);
if (isset($found_builtins[$id])) {
$entry = $found_builtins[$id];
remove_action('woocommerce_after_single_product_summary', $entry['callback'], $entry['priority']);
if ($visible) {
add_action('woocommerce_after_single_product_summary', $entry['callback'], $new_prio);
}
$new_prio += 10;
continue;
}
if (self::SEMANTIC_SURFACE === $id) {
if (class_exists('AIEO_Semantic_Related_Placement')) {
$cb = array('AIEO_Semantic_Related_Placement', 'place_classic');
remove_action('woocommerce_after_single_product_summary', $cb, 21);
if ($visible) {
add_action('woocommerce_after_single_product_summary', $cb, $new_prio);
}
}
$new_prio += 10;
continue;
}
if (strpos($id, 'slot_') === 0 && $visible) {
$sid = $id;
add_action('woocommerce_after_single_product_summary', static function () use ($sid) {
if (!class_exists('AIEO_DMM_Custom_Slots')) { return; }
$slot = AIEO_DMM_Custom_Slots::get_slot($sid);
if (!$slot) { return; }
echo AIEO_DMM_Custom_Slots::slot_section_html($slot);
}, $new_prio);
}
$new_prio += 10;
}
}
public static function classic_summary_callback_map() {
return array(
'title' => array(array('fn', 'woocommerce_template_single_title')),
'rating' => array(
array('fn', 'woocommerce_template_single_rating'),
array('method', 'AIEO_Ratings', 'render_single_widget'),
),
'summary' => array(array('fn', 'woocommerce_template_single_excerpt')),
'price' => array(array('fn', 'woocommerce_template_single_price')),
'add_to_cart' => array(array('fn', 'woocommerce_template_single_add_to_cart')),
'brand_display' => array(
array('method', 'AIEO_DMM_Brand_Display', 'output_classic_before_title'),
array('method', 'AIEO_DMM_Brand_Display', 'output_classic_under_title'),
),
'brand_gifts' => array(array('method', 'AIEO_DMM_Brand_Gifts', 'render_single_product_section')),
'payment_logos' => array(array('method', 'AIEO_DMM_Payment_Logos', 'output')),
'trust_badges' => array(array('method', 'AIEO_DMM_Trust_Badges', 'output')),
'product_meta' => array(array('method', 'AIEO_DMM_Product_Meta', 'output')),
'product_categories' => array(array('method', 'AIEO_DMM_Product_Categories', 'output')),
'product_requests_display' => array(array('method', 'AIEO_Product_Request_Form', 'render_hook')),
);
}
public function stand_down_classic_on_block_templates($template) {
global $_wp_current_template_content, $wp_filter;
if (!wp_is_block_theme() || !is_singular('product') || !is_string($_wp_current_template_content) || '' === $_wp_current_template_content) {
return $template;
}
$blocks = parse_blocks($_wp_current_template_content);
if (function_exists('resolve_pattern_blocks')) {
$blocks = resolve_pattern_blocks($blocks);
}
$placed = self::modules_placed_by_blocks($blocks);
if (empty($placed)) { return $template; }
$callbacks = array();
foreach (self::classic_summary_callback_map() as $id => $matchers) {
foreach ($matchers as $m) {
if ('method' === $m[0] && isset($placed[$id])) {
$callbacks[] = array('woocommerce_single_product_summary', $m[1], $m[2]);
}
}
}
if (isset($placed['brand_display'])) {
$callbacks[] = array('woocommerce_product_meta_end', 'AIEO_DMM_Brand_Display', 'output_classic_after_post_meta');
}
foreach ($callbacks as $c) {
if (empty($wp_filter[$c[0]])) { continue; }
foreach ($wp_filter[$c[0]]->callbacks as $prio => $cbs) {
foreach ($cbs as $cb) {
$fn = isset($cb['function']) ? $cb['function'] : null;
if (is_array($fn) && isset($fn[0], $fn[1]) && $c[2] === (string) $fn[1]
&& $c[1] === (is_object($fn[0]) ? get_class($fn[0]) : (string) $fn[0])) {
remove_action($c[0], $fn, $prio);
}
}
}
}
return $template;
}
private static function modules_placed_by_blocks(array $blocks) {
$placed = array();
foreach ($blocks as $block) {
$name = isset($block['blockName']) ? (string) $block['blockName'] : '';
if ('aieo/dmm-bridge' === $name && !empty($block['attrs']['moduleId'])) {
$placed[str_replace('-', '_', sanitize_key((string) $block['attrs']['moduleId']))] = true;
} elseif ('aieo/post-meta' === $name) {
$placed['product_meta'] = true;
}
if (!empty($block['innerBlocks'])) {
$placed += self::modules_placed_by_blocks($block['innerBlocks']);
}
}
return $placed;
}
public function reorder_classic_theme_summary() {
if (wp_is_block_theme()) { return; }
if (!function_exists('is_singular') || !is_singular('product')) { return; }
global $wp_filter;
if (empty($wp_filter['woocommerce_single_product_summary'])) { return; }
$map = self::classic_summary_callback_map();
$hook = $wp_filter['woocommerce_single_product_summary'];
$found = array();
foreach ($hook->callbacks as $prio => $cbs) {
foreach ($cbs as $cb) {
$fn = isset($cb['function']) ? $cb['function'] : null;
if ($fn === null) { continue; }
$aa = isset($cb['accepted_args']) ? (int) $cb['accepted_args'] : 1;
foreach ($map as $id => $matchers) {
foreach ($matchers as $m) {
$hit = false;
if ($m[0] === 'fn') {
if (is_string($fn) && $fn === $m[1]) { $hit = true; }
} elseif ($m[0] === 'method') {
if (is_array($fn) && isset($fn[0], $fn[1]) && (string) $fn[1] === $m[2]) {
$cls = is_object($fn[0]) ? get_class($fn[0]) : (is_string($fn[0]) ? $fn[0] : '');
if ($cls === $m[1]) { $hit = true; }
}
}
if ($hit) {
$found[$id][] = array('priority' => (int) $prio, 'callback' => $fn, 'accepted_args' => $aa);
}
}
}
}
}
if (empty($found)) { return; }
$order = self::get_layout_order();
$rank = 0;
foreach ($order as $id) {
if (empty($found[$id])) { continue; }
$new_prio = 5 + ($rank * 4);
foreach ($found[$id] as $entry) {
if ((int) $entry['priority'] !== $new_prio) {
remove_action('woocommerce_single_product_summary', $entry['callback'], $entry['priority']);
add_action('woocommerce_single_product_summary', $entry['callback'], $new_prio, (int) $entry['accepted_args']);
}
}
$rank++;
}
}
public function enqueue_recommendations_order_css() {
if (!function_exists('is_product') || !is_product()) { return; }
$order = self::get_reco_order();
if (empty($order)) { return; }
$rules = array(
'.wp-block-group:is(.rooster-recos-cluster,.ff-recommendations-cluster){display:flex !important;flex-direction:column !important;}',
);
foreach ($order as $i => $row) {
$selector = '.wp-block-group:is(.rooster-recos-cluster,.ff-recommendations-cluster) > [data-aieo-reco="' . esc_attr($row['id']) . '"]';
$rules[] = sprintf('%s{order:%d;}', $selector, ($i + 1) * 10);
if (empty($row['visible'])) {
$rules[] = sprintf('%s{display:none !important;}', $selector);
}
}
$css = implode('', $rules);
wp_register_style('aieo-dmm-recommendations-order', false, array(), defined('AIEO_VERSION') ? AIEO_VERSION : '1.0');
wp_enqueue_style('aieo-dmm-recommendations-order');
wp_add_inline_style('aieo-dmm-recommendations-order', $css);
}
public function render_subtab() {
$catalog = $this->modules_catalog();
$nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION);
$ajax = admin_url('admin-ajax.php');
$base_admin_url = admin_url('admin.php?page=' . AIEO_DMM::MENU_SLUG . '&dmm_tab=');
rooster_admin_band_open(array('cols' => 1));
list($aieo_pd_open, $aieo_pd_close) = AIEO_DMM_UI::card(array(
'anchor' => 'product-display-overview',
'flush' => true,
'title' => __('Product page features', 'ai-eshop-optimizer'),
'description' => __('Central control panel for product-page features. Flip the switch to enable a feature globally; click "Configure" to open its dedicated settings page.', 'ai-eshop-optimizer'),
));
echo $aieo_pd_open;
$pd_rows = array();
foreach ($catalog as $m) {
$pd_rows[] = array(
'id' => (string) $m['id'],
'label' => (string) $m['label'],
'description' => (string) $m['description'],
'on' => (bool) self::module_is_enabled($m['option_key']),
'configure' => $base_admin_url . $m['id'],
);
}
AIEO_Table::render(array(
'columns' => array(
array(
'key' => 'on',
'label' => __('On', 'ai-eshop-optimizer'),
'type' => 'control',
'width' => '60px',
'format' => array(
'render' => static function ($row) {
return '';
},
),
),
array(
'key' => 'label',
'label' => __('Module', 'ai-eshop-optimizer'),
'type' => 'text',
'width' => '200px',
),
array(
'key' => 'description',
'label' => __('Description', 'ai-eshop-optimizer'),
'type' => 'text',
),
),
'rows' => $pd_rows,
'row_key' => 'id',
'row_data' => array('module' => 'id'),
'actions' => array(
array(
'id' => 'configure',
'label' => __('Configure', 'ai-eshop-optimizer') . ' →',
'variant' => 'secondary',
'href' => static function ($row) {
return $row['configure'];
},
),
),
'page_id' => 'product_display',
));
echo \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-pd-status'));
echo $aieo_pd_close;
rooster_admin_band_close();
$catalog_by = array();
foreach ($catalog as $m) { $catalog_by[$m['id']] = $m; }
rooster_admin_band_open(array('cols' => 2));
list($aieo_pd_open, $aieo_pd_close) = AIEO_DMM_UI::card(array(
'anchor' => 'display-order',
'icon' => 'grip-vertical',
'title' => __('Product info-column layout', 'ai-eshop-optimizer'),
'description' => __('Drag to set the vertical order of EVERYTHING in the single-product info column — the core elements (Title, Price, Add-to-Cart, messages) and the modules (SKU, badges, categories…), in one sequence. Modules also have a “Beside / Under image” dropdown to move them under the gallery; core elements always sit beside the image.', 'ai-eshop-optimizer'),
'col' => 1,
));
echo $aieo_pd_open;
$lcat = self::layout_catalog();
$zones = self::get_cluster_zones();
$layout_rows = array();
foreach (self::get_layout_order() as $id) {
if (!isset($lcat[$id])) { continue; }
$L = $lcat[$id];
if (empty($L['zoneable'])) {
$layout_rows[] = array('key' => $id, 'label' => $L['label'], 'origin' => __('core element', 'ai-eshop-optimizer'));
continue;
}
if ($id === 'wishlist') {
$wlopt = get_option('aieo_wishlist_display', array());
$on = (!is_array($wlopt) || !isset($wlopt['show_on_single'])) ? true : ($wlopt['show_on_single'] === 'yes');
} else {
$on = isset($catalog_by[$id]) && self::module_is_enabled($catalog_by[$id]['option_key']);
}
$places = array('info' => __('Beside image', 'ai-eshop-optimizer'));
if ($id === 'wishlist' || $id === 'brand_display') {
$places['beside_title'] = __('Beside the product title', 'ai-eshop-optimizer');
}
$places['under'] = __('Under image', 'ai-eshop-optimizer');
$place_for = sprintf(__('Placement of %s', 'ai-eshop-optimizer'), $L['label']);
$layout_rows[] = array(
'key' => $id,
'label' => $L['label'],
'control' => \Rooster\Admin\Ui::select('zones[' . $id . ']', $places, isset($zones[$id]) ? (string) $zones[$id] : 'info', array('id' => 'aieo-pd-zone-' . $id, 'aria-label' => $place_for)),
'state' => $on ? array(__('enabled', 'ai-eshop-optimizer'), 'success') : array(__('disabled', 'ai-eshop-optimizer'), 'mono'),
);
}
$aieo_pd_list = \Rooster\Admin\Fields::ordered_list($layout_rows, array(
'id' => 'aieo-pd-order-list',
'label' => __('Product info-column layout', 'ai-eshop-optimizer'),
'default' => array_keys($lcat),
));
echo $aieo_pd_list;
echo $aieo_pd_close;
$tab_catalog = self::tab_catalog();
$tab_by_id = array();
$has_custom_tabs = false;
foreach ($tab_catalog as $c) {
$tab_by_id[$c['id']] = $c;
$has_custom_tabs = $has_custom_tabs || $c['kind'] === 'custom';
}
list($aieo_pd_open, $aieo_pd_close) = AIEO_DMM_UI::card(array(
'anchor' => 'tab-order',
'icon' => 'table-properties',
'title' => __('Product tab order', 'ai-eshop-optimizer'),
'description' => __('Drag to set the order of the tabs under each product — the WooCommerce tabs (Description, Additional information, Reviews) and your Custom Tabs, all in one sequence. Tabs that have no content for a given product (e.g. an empty custom tab, or Reviews when reviews are off) are simply skipped — the order still holds for the ones that show.', 'ai-eshop-optimizer'),
'col' => 2,
));
echo $aieo_pd_open;
if (!$has_custom_tabs) {
$aieo_pd_hint = \Rooster\Admin\Guidance::text('', 'guidance', array('html' => wp_kses_post(sprintf(
__('Enable a tab under %s to add it to this list.', 'ai-eshop-optimizer'),
'' . esc_html__('Custom Tabs', 'ai-eshop-optimizer') . ''
))));
echo $aieo_pd_hint;
}
$tab_rows = array();
foreach (self::get_tab_order() as $id) {
if (!isset($tab_by_id[$id])) { continue; }
$tab_rows[] = array(
'key' => $id,
'label' => $tab_by_id[$id]['label'],
'origin' => $tab_by_id[$id]['kind'] === 'custom' ? __('custom tab', 'ai-eshop-optimizer') : __('WooCommerce', 'ai-eshop-optimizer'),
);
}
$aieo_pd_list = \Rooster\Admin\Fields::ordered_list($tab_rows, array(
'id' => 'aieo-tab-order-list',
'label' => __('Product tab order', 'ai-eshop-optimizer'),
'default' => wp_list_pluck($tab_catalog, 'id'),
));
echo $aieo_pd_list;
echo $aieo_pd_close;
$reco_catalog = self::reco_catalog();
$reco_by_id = array();
foreach ($reco_catalog as $c) { $reco_by_id[$c['id']] = $c; }
list($aieo_pd_open, $aieo_pd_close) = AIEO_DMM_UI::card(array(
'anchor' => 'recommendations-order',
'icon' => 'sparkles',
'title' => __('Recommendations Ordering', 'ai-eshop-optimizer'),
'description' => __('Drag the rows to set the vertical order of the recommendation rails on every single-product page. Switch a rail off to hide it without removing it. Custom slots you add in Custom Slots appear here automatically: drag them anywhere among the built-in rails.', 'ai-eshop-optimizer'),
'col' => 2,
));
echo $aieo_pd_open;
if (class_exists('AIEO_DMM_Product_Card')) {
$aieo_ds = AIEO_DMM_Product_Card::design_source();
$card_url = add_query_arg(
array('page' => 'ai-eshop-dmm', 'dmm_tab' => 'product_card'),
admin_url('admin.php')
);
$aieo_ds_label = __('Neutral', 'ai-eshop-optimizer');
if ('aieo' === $aieo_ds) {
$aieo_ds_label = __('Rooster One design', 'ai-eshop-optimizer');
} elseif ('theme' === $aieo_ds) {
$aieo_ds_label = __('Theme design', 'ai-eshop-optimizer');
}
$aieo_ds_notice = \Rooster\Admin\Ui::notice(
'' . esc_html($aieo_ds_label) . '' . esc_html__('change', 'ai-eshop-optimizer') . ' '
. esc_html__('“Theme design” — the default — hands your precomputed recommendations to the theme’s own related-products block, so the cards are the theme’s. “Rooster One design” styles the cards itself, identically on any theme. “Neutral” ships no card stylesheet, which stops us imposing a look but does NOT make your theme style these cards: most themes load their product-card CSS only on shop and category pages, so on a product page the cards will simply look plain. If you only want to change colours or sizing, use Rooster One design and edit the values.', 'ai-eshop-optimizer'),
'mono',
array('title' => __('Card design', 'ai-eshop-optimizer'), 'inline' => true)
);
echo $aieo_ds_notice;
}
$reco_rows = array();
$reco_kept = array();
$reco_dialogs = '';
$reco_notes = '';
foreach (self::get_reco_order() as $row) {
$id = $row['id'];
if (!isset($reco_by_id[$id])) { continue; }
$c = $reco_by_id[$id];
$item = array('key' => $id, 'label' => $c['label']);
$inert = ('semantic' === $c['kind'] && empty($c['engine_loaded']) && empty($c['has_data']));
if ($inert) {
$reco_kept[$id] = !empty($row['visible']);
} else {
$item['show'] = !empty($row['visible']);
$item['show_name'] = '';
}
if ($c['kind'] === 'slot') {
$item['origin'] = $c['slot_type'] === 'tolstoy' ? 'Tolstoy' : 'HTML';
$item['state'] = !empty($c['slot_enabled']) ? array(__('configured', 'ai-eshop-optimizer'), 'success') : array(__('not configured', 'ai-eshop-optimizer'), 'mono');
} elseif ($c['kind'] === 'semantic') {
$engine = !empty($c['engine_loaded']);
$data = !empty($c['has_data']);
if ($engine && $data) {
$item['state'] = array(__('semantic engine · ready', 'ai-eshop-optimizer'), 'success');
} elseif ($engine) {
$item['state'] = array(__('semantic engine · no data yet', 'ai-eshop-optimizer'), 'warning');
$reco_notes .= \Rooster\Admin\Ui::notice(esc_html__('No related content has been computed yet: run the initial index and sync under Promote → Semantic Internal Linking. Until then this rail shows nothing.', 'ai-eshop-optimizer'), 'warning', array('title' => $c['label'], 'inline' => true));
} elseif ($data) {
$item['state'] = array(__('Rooster One Pro inactive · showing saved data', 'ai-eshop-optimizer'), 'warning');
$reco_notes .= \Rooster\Admin\Ui::notice(esc_html__('Rooster One Pro is not active. This rail keeps showing the related content already saved on this site, but nothing will update it — new products will not appear and changes will not be picked up until you activate Pro again.', 'ai-eshop-optimizer'), 'warning', array('title' => $c['label'], 'inline' => true));
} else {
$item['state'] = array(__('needs Rooster One Pro', 'ai-eshop-optimizer'), 'mono');
$reco_notes .= \Rooster\Admin\Ui::notice(esc_html__('Needs the Greek semantic engine (Rooster One Pro) — activate it and this rail becomes available.', 'ai-eshop-optimizer'), 'mono', array('title' => $c['label'], 'inline' => true));
}
} else {
$item['origin'] = __('built-in', 'ai-eshop-optimizer');
}
if (!$inert && 'slot' !== $c['kind']) {
$summary = self::conditions_summary(isset($row['conditions']) ? $row['conditions'] : array());
$item['action'] = \Rooster\Admin\Ui::button(__('Rules', 'ai-eshop-optimizer'), array('size' => 'sm', 'attrs' => array('data-ra-dialog' => 'aieo-reco-rules-' . $id)))
. \Rooster\Admin\Ui::badge('' === $summary ? __('no rules', 'ai-eshop-optimizer') : $summary, '' === $summary ? 'mono' : 'warning', array('sm' => true));
$reco_dialogs .= self::reco_rules_dialog($id, $c['label'], isset($row['conditions']) ? $row['conditions'] : array());
}
$reco_rows[] = $item;
}
$aieo_pd_list = \Rooster\Admin\Fields::ordered_list($reco_rows, array(
'id' => 'aieo-reco-order-list',
'label' => __('Recommendations Ordering', 'ai-eshop-optimizer'),
'default' => wp_list_pluck($reco_catalog, 'id'),
));
echo $aieo_pd_list;
echo $reco_notes;
echo $reco_dialogs;
echo $aieo_pd_close;
rooster_admin_band_close();
$aieo_pd_js = array(
'ajax' => $ajax,
'nonce' => $nonce,
'kept' => (object) $reco_kept,
'failed' => __('Save failed.', 'ai-eshop-optimizer'),
);
?>
(function(){function aieoPdInit(){
var status = document.getElementById("aieo-pd-status");
document.querySelectorAll("[data-aieo-pd-toggle]").forEach(function(input){
input.addEventListener("change", function(){
var fd = new FormData();
fd.set("action", "aieo_dmm_product_display_toggle");
fd.set("nonce", "' . esc_js($nonce) . '");
fd.set("module", input.getAttribute("data-aieo-pd-toggle"));
fd.set("enabled", input.checked ? "1" : "0");
// The outcome\'s tone is its data-tone (none while it saves), never a colour painted from here.
delete status.dataset.tone;
status.textContent = "' . esc_js(__('Saving…', 'ai-eshop-optimizer')) . '";
fetch("' . esc_js($ajax) . '", {method:"POST", credentials:"same-origin", body:fd})
.then(function(r){return r.json();})
.then(function(j){
if (j && j.success) {
status.dataset.tone = "success";
status.textContent = j.data.message;
} else {
status.dataset.tone = "destructive";
status.textContent = (j && j.data && j.data.message) || "' . esc_js(__('Failed.', 'ai-eshop-optimizer')) . '";
input.checked = !input.checked; // revert
}
});
});
});
}
if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", aieoPdInit); } else { aieoPdInit(); }
})();';
}
}