'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 . '>' . '' . $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(); } })();'; } }