'', 'name' => '', 'type' => 'html', 'enabled' => false, 'conditions' => class_exists('AIEO_Display_Conditions') ? AIEO_Display_Conditions::defaults() : array(), 'placement' => class_exists('AIEO_Display_Conditions') ? AIEO_Display_Conditions::sanitize_placement(array('pages' => array('product'))) : array(), 'title' => '', 'show_title' => false, 'tolstoy_widget_id' => '', 'tolstoy_account_uuid' => '', 'raw_html' => '', ), $row); } return $out; } public static function placement_ok($slot) { if (!class_exists('AIEO_Display_Conditions')) { return function_exists('is_singular') && is_singular('product'); } $rules = (is_array($slot) && isset($slot['placement']) && is_array($slot['placement'])) ? $slot['placement'] : array('pages' => array('product')); return AIEO_Display_Conditions::placement_matches($rules, true); } public static function any_placement_ok($slots = null) { $slots = is_array($slots) ? $slots : self::get_all_slots(); foreach ($slots as $slot) { if (empty($slot['enabled'])) { continue; } if (self::placement_ok($slot)) { return true; } } return false; } public static function get_slot($slot_id) { $slot_id = (string) $slot_id; if ($slot_id === '') { return null; } foreach (self::get_all_slots() as $row) { if ($row['id'] === $slot_id) { return $row; } } return null; } public static function update_all_slots(array $slots) { $reserved = array('upsells', 'crosssells', 'related', 'recently_viewed', 'same_needs'); $clean = array(); $seen = array(); $unfiltered = current_user_can('unfiltered_html'); $stored = array(); foreach (self::get_all_slots() as $was) { $stored[(string) $was['id']] = (string) $was['raw_html']; } foreach ($slots as $row) { if (!is_array($row)) { continue; } $id = isset($row['id']) ? sanitize_key($row['id']) : ''; $type = isset($row['type']) && in_array($row['type'], self::TYPES, true) ? $row['type'] : 'html'; if ($id === '' || in_array($id, $reserved, true) || isset($seen[$id])) { continue; } $seen[$id] = true; $clean[] = array( 'id' => $id, 'name' => isset($row['name']) ? sanitize_text_field((string) $row['name']) : '', 'type' => $type, 'enabled' => !empty($row['enabled']), 'title' => isset($row['title']) ? sanitize_text_field((string) $row['title']) : '', 'show_title' => !empty($row['show_title']), 'tolstoy_widget_id' => isset($row['tolstoy_widget_id']) ? sanitize_text_field((string) $row['tolstoy_widget_id']) : '', 'tolstoy_account_uuid' => isset($row['tolstoy_account_uuid']) ? sanitize_text_field((string) $row['tolstoy_account_uuid']) : '', 'raw_html' => self::raw_html_to_store($row, isset($stored[$id]) ? $stored[$id] : null, $unfiltered), 'conditions' => class_exists('AIEO_Display_Conditions_UI') ? AIEO_Display_Conditions_UI::collect( $row, isset($row['conditions']) && is_array($row['conditions']) ? $row['conditions'] : array() ) : (isset($row['conditions']) && is_array($row['conditions']) ? $row['conditions'] : array()), 'placement' => class_exists('AIEO_Display_Conditions_UI') ? AIEO_Display_Conditions_UI::collect_placement( $row, isset($row['placement']) && is_array($row['placement']) ? $row['placement'] : array('pages' => array('product')), self::PLACEMENT_TOKENS ) : (isset($row['placement']) && is_array($row['placement']) ? $row['placement'] : array()), ); } update_option(self::OPTION_KEY, $clean, false); self::purge_page_cache(); } private static function raw_html_to_store($row, $stored, $unfiltered) { $raw = isset($row['raw_html']) && is_scalar($row['raw_html']) ? (string) $row['raw_html'] : ''; if (null !== $stored && ($raw === $stored || wp_unslash($raw) === $stored)) { return $stored; } $html = (string) wp_unslash($raw); return $unfiltered ? $html : wp_kses_post($html); } 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 static function add_slot($type) { if (!in_array($type, self::TYPES, true)) { $type = 'html'; } $slots = self::get_all_slots(); $id = sanitize_key('slot_' . wp_generate_password(8, false, false)); $slots[] = array( 'id' => $id, 'name' => $type === 'tolstoy' ? __('Tolstoy widget', 'ai-eshop-optimizer') : __('Custom block', 'ai-eshop-optimizer'), 'type' => $type, 'enabled' => false, ); self::update_all_slots($slots); return $id; } public static function delete_slot($slot_id) { $slots = self::get_all_slots(); $kept = array(); foreach ($slots as $row) { if ($row['id'] === $slot_id) { continue; } $kept[] = $row; } self::update_all_slots($kept); } public static function is_client_armed($slot) { if (!is_array($slot) || empty($slot['enabled'])) { return false; } if (($slot['type'] ?? '') !== 'tolstoy') { return false; } return '' !== self::clean_widget_id($slot); } private static function clean_widget_id($slot) { $widget = isset($slot['tolstoy_widget_id']) ? trim((string) $slot['tolstoy_widget_id']) : ''; return (string) preg_replace('/[^A-Za-z0-9]/', '', $widget); } public static function slot_client_config($slot) { $slot_id = (string) $slot['id']; $conditions = array(); if (class_exists('AIEO_Display_Conditions')) { $stored = isset($slot['conditions']) && is_array($slot['conditions']) ? $slot['conditions'] : array(); $conditions = AIEO_Display_Conditions::client_config($stored, array( 'id' => 0, 'surface' => $slot_id, 'v' => (int) crc32(wp_json_encode(array( $slot['tolstoy_widget_id'] ?? '', $slot['tolstoy_account_uuid'] ?? '', $slot['title'] ?? '', !empty($slot['show_title']), ))), )); } $title = ''; if (!empty($slot['show_title']) && trim((string) ($slot['title'] ?? '')) !== '') { $title = (string) $slot['title']; } return array( 'conditions' => $conditions, 'payload' => array( 'type' => 'tolstoy', 'widget' => self::clean_widget_id($slot), 'account' => preg_replace('/[^A-Za-z0-9\-]/', '', (string) ($slot['tolstoy_account_uuid'] ?? '')), 'productId' => function_exists('get_the_ID') ? (int) get_the_ID() : 0, 'title' => $title, 'titleClass' => 'aieo-recommendations-title', ), ); } public static function container_attrs($slot) { $cfg = self::slot_client_config($slot); return array( 'data-aieo-surface' => (string) $slot['id'], 'data-aieo-conditions' => (string) wp_json_encode($cfg['conditions']), 'data-aieo-payload' => (string) wp_json_encode($cfg['payload']), ); } public static function slot_section_html($slot, $wrapper_attrs = null) { if (!is_array($slot) || empty($slot['enabled'])) { return ''; } if (!self::placement_ok($slot)) { return ''; } $armed = self::is_client_armed($slot); $html = $armed ? '' : self::render_slot((string) $slot['id']); if (!$armed && $html === '') { return ''; } if (null === $wrapper_attrs) { $slot_id = (string) $slot['id']; $attrs = array( 'class' => 'aieo-custom-slot aieo-custom-slot--' . sanitize_html_class($slot_id) . ' aieo-recommendations', 'data-aieo-reco' => $slot_id, 'data-section' => $slot_id, ); if ($armed) { $attrs = array_merge($attrs, self::container_attrs($slot)); } $pairs = array(); foreach ($attrs as $k => $v) { $pairs[] = esc_attr($k) . '="' . esc_attr($v) . '"'; } $wrapper_attrs = implode(' ', $pairs); } return '
' . $html . '
'; } public static function render_slot($slot_id) { $slot = self::get_slot((string) $slot_id); if (!$slot || empty($slot['enabled'])) { return ''; } if (self::is_client_armed($slot)) { return ''; } $title_html = ''; if (!empty($slot['show_title']) && trim((string) $slot['title']) !== '') { $title_html = '

' . esc_html((string) $slot['title']) . '

'; } $body_html = ''; if ($slot['type'] === 'tolstoy') { $widget = trim((string) $slot['tolstoy_widget_id']); $widget = preg_replace('/[^A-Za-z0-9]/', '', $widget); if ($widget === '') { $raw_fallback = trim((string) $slot['raw_html']); if ($raw_fallback === '') { return ''; } $body_html = do_shortcode(apply_filters('aieo_dmm_custom_slot_html', $raw_fallback, $slot)); } else { $product_id = function_exists('get_the_ID') ? (int) get_the_ID() : 0; $body_html = sprintf( '', esc_attr($widget), $product_id ); } } else { $raw = (string) $slot['raw_html']; $raw = apply_filters('aieo_dmm_custom_slot_html', $raw, $slot); $body_html = do_shortcode($raw); } if ($body_html === '') { return ''; } return $title_html . $body_html; } public function enqueue_tolstoy_scripts() { if (is_admin()) { return; } $slots = self::get_all_slots(); if (!self::any_placement_ok($slots)) { return; } $attr_path = AIEO_DIR . 'assets/js/aieo-tolstoy-attribution.js'; $attr_ver = file_exists($attr_path) ? (string) filemtime($attr_path) : (defined('AIEO_VERSION') ? AIEO_VERSION : '1.0'); wp_enqueue_script( 'aieo-tolstoy-attribution', AIEO_URI . 'assets/js/aieo-tolstoy-attribution.js', array(), $attr_ver, false ); $armable = false; foreach ($slots as $slot) { if (self::is_client_armed($slot) && self::placement_ok($slot)) { $armable = true; break; } } if (!$armable) { return; } $deps = array(); if (class_exists('AIEO_Popup_Display') && AIEO_Popup_Display::ensure_conditions()) { AIEO_Display_Conditions::enqueue_runtime(); $deps[] = AIEO_Display_Conditions::HANDLE; $deps[] = AIEO_Display_Conditions::TRIGGERS_HANDLE; } $arm_path = AIEO_DIR . 'assets/js/aieo-custom-slots.js'; $arm_ver = file_exists($arm_path) ? (string) filemtime($arm_path) : (defined('AIEO_VERSION') ? AIEO_VERSION : '1.0'); wp_enqueue_script( 'aieo-custom-slots', AIEO_URI . 'assets/js/aieo-custom-slots.js', $deps, $arm_ver, true ); } public function rocket_js_exclusions($excluded) { $ours = array('aieo-custom-slots'); if (class_exists('AIEO_Display_Conditions')) { $ours = array_merge($ours, AIEO_Display_Conditions::rocket_needles()); } return is_array($excluded) ? array_merge($excluded, $ours) : $ours; } public function ajax_save() { AIEO_DMM::verify_ajax_request(); $raw = isset($_POST['slots']) ? wp_unslash($_POST['slots']) : array(); if (is_string($raw)) { $decoded = json_decode($raw, true); $raw = is_array($decoded) ? $decoded : array(); } if (!is_array($raw)) { $raw = array(); } self::update_all_slots($raw); wp_send_json_success(array( 'slots' => self::get_all_slots(), 'message' => __('Slots saved.', 'ai-eshop-optimizer'), )); } public function ajax_add() { AIEO_DMM::verify_ajax_request(); $type = isset($_POST['type']) ? sanitize_key(wp_unslash($_POST['type'])) : 'html'; $id = self::add_slot($type); wp_send_json_success(array( 'id' => $id, 'slots' => self::get_all_slots(), )); } public function ajax_delete() { AIEO_DMM::verify_ajax_request(); $id = isset($_POST['id']) ? sanitize_key(wp_unslash($_POST['id'])) : ''; if ($id !== '') { self::delete_slot($id); } wp_send_json_success(array( 'slots' => self::get_all_slots(), )); } public function render_subtab() { $slots = self::get_all_slots(); $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => 'custom-slots-overview', 'title' => __('Tolstoy or HTML in recommendations', 'ai-eshop-optimizer'), 'description' => __('Define extra content blocks that render inside the single-product recommendations region. Two kinds of slots: (1) Tolstoy — paste a widget id + account UUID and Rooster One assembles the embed for you; (2) HTML/Code — paste any raw HTML, JavaScript, or shortcode (e.g. [products limit="6"]). Each slot appears as a draggable row in the "Recommendations Ordering" card on the Dashboard so you can position it among the built-in Up-sells / Cross-sells / Related / Recently Viewed / Same-Needs rails.', 'ai-eshop-optimizer'), 'footer' => '' . ' ' . \Rooster\Admin\States::outcome('', '', array('id' => 'aieo-cs-status')), )); echo $open; echo '
'; if (empty($slots)) { echo \Rooster\Admin\States::state('first-use', array('title' => __('No custom slots yet. Use the buttons below to add one.', 'ai-eshop-optimizer'))); } else { foreach ($slots as $slot) { self::render_slot_row($slot); } } echo '
'; echo '

'; echo ' '; echo ''; echo '

'; echo $close; echo ''; } private static function render_slot_row($slot) { $id = (string) $slot['id']; $type = (string) $slot['type']; $uid = 'aieo-cs-' . sanitize_key($id) . '-'; $aieo_cs_dialog = 'aieo-cs-delete-' . sanitize_key($id); $aieo_cs_label = trim((string) ($slot['name'] ?? '')) !== '' ? (string) $slot['name'] : $id; $aieo_cs_open = \Rooster\Admin\Ui::card_open( $type === 'tolstoy' ? __('Tolstoy slot', 'ai-eshop-optimizer') : __('HTML / code slot', 'ai-eshop-optimizer'), array( 'id' => $uid . 'slot', 'level' => 3, 'class' => 'is-sub aieo-cs-slot', 'icon' => 'layout-template', 'meta' => \Rooster\Admin\Ui::badge($id, 'mono', array('sm' => true)), 'tools' => \Rooster\Admin\Ui::button(__('Delete', 'ai-eshop-optimizer'), array('variant' => 'destructive', 'size' => 'sm', 'attrs' => array('data-aieo-cs-delete' => $id) + \Rooster\Admin\States::confirm_attrs($aieo_cs_dialog))), 'attrs' => array('data-aieo-cs-slot' => $id), ) ); echo $aieo_cs_open; $aieo_cs_ask = \Rooster\Admin\States::confirm( $aieo_cs_dialog, 'delete', sprintf(__('Delete the slot "%s"?', 'ai-eshop-optimizer'), $aieo_cs_label), '

' . esc_html__('The slot and everything typed into it are removed at once. This cannot be undone.', 'ai-eshop-optimizer') . '

' ); echo $aieo_cs_ask; echo ''; $row = static function ($id, $label, $control, $help = '') { return \Rooster\Admin\Ui::field_row(array('id' => $id, 'label' => $label, 'control' => $control, 'help' => $help)); }; $check = static function ($field, $id, $on, $text) { return ''; }; $text = static function ($field, $id, $value, array $opts = array()) { return \Rooster\Admin\Ui::input('', (string) $value, $opts + array('id' => $id, 'medium' => true, 'attrs' => array('data-field' => $field))); }; $fields = $row($uid . 'name', __('Name', 'ai-eshop-optimizer'), $text('name', $uid . 'name', $slot['name'], array('placeholder' => __('Internal label, shown in the Recommendations Ordering list', 'ai-eshop-optimizer')))) . $row($uid . 'enabled', __('Enabled', 'ai-eshop-optimizer'), $check('enabled', $uid . 'enabled', !empty($slot['enabled']), __('Render this slot. Where it may appear is set below.', 'ai-eshop-optimizer'))) . $row($uid . 'title', __('Title', 'ai-eshop-optimizer'), $text('title', $uid . 'title', $slot['title'], array('placeholder' => __('e.g. "Moments you must see"', 'ai-eshop-optimizer'))), __('Operator-set heading rendered above the slot content. Styled identically to the built-in "RECENTLY VIEWED PRODUCTS" / "PRODUCTS FOR THE SAME NEEDS" titles (heading + underline) via the shared aieo-recommendations-title class.', 'ai-eshop-optimizer')) . $row($uid . 'show-title', __('Show title', 'ai-eshop-optimizer'), $check('show_title', $uid . 'show-title', !empty($slot['show_title']), __('Display the title above this slot', 'ai-eshop-optimizer'))); if ($type === 'tolstoy') { $fields .= $row($uid . 'widget', __('Widget ID', 'ai-eshop-optimizer'), $text('tolstoy_widget_id', $uid . 'widget', $slot['tolstoy_widget_id'], array('code' => true, 'placeholder' => '01HZ7XMP3VBQR8TKWY5JCDFN24')), __('The base32 ID inside data-tolstoy-widget-id="…" on the embed snippet gotolstoy.com gives you.', 'ai-eshop-optimizer')) . $row($uid . 'uuid', __('Account UUID', 'ai-eshop-optimizer'), $text('tolstoy_account_uuid', $uid . 'uuid', $slot['tolstoy_account_uuid'], array('code' => true, 'medium' => false, 'placeholder' => 'a7c2f1e4-9b53-4d6e-bc01-5f8e7a3c2b94')), __('The UUID at the start of the script URL — https://ai-widgets.gotolstoy.com///… — without https:// or any slashes.', 'ai-eshop-optimizer')) . $row($uid . 'snippet', __('Or paste the full snippet', 'ai-eshop-optimizer'), \Rooster\Admin\Ui::textarea('', (string) $slot['raw_html'], array('id' => $uid . 'snippet', 'rows' => 4, 'code' => true, 'placeholder' => __('Paste the entire block here as a shortcut. Rooster One will not auto-fill the two fields above from it — they take precedence at render time.', 'ai-eshop-optimizer'), 'attrs' => array('data-field' => 'raw_html'))), __('Optional shortcut. If you fill in Widget ID + Account UUID above, Rooster One ignores this field. Useful when you have a one-off snippet that doesn\'t follow the standard URL shape.', 'ai-eshop-optimizer')); } else { $editor_id = 'aieo_cs_editor_' . sanitize_key($id); ob_start(); wp_editor( (string) $slot['raw_html'], $editor_id, array( 'textarea_name' => $editor_id, 'media_buttons' => true, 'teeny' => false, 'textarea_rows' => 12, 'tinymce' => array( 'wpautop' => false, 'forced_root_block' => false, 'force_br_newlines' => false, 'force_p_newlines' => false, 'verify_html' => false, 'extended_valid_elements' => '*[*]', ), 'quicktags' => true, ) ); $editor = str_replace( array(' aria-controls="wp-media-modal"', 'class="button insert-media add_media"'), array('', 'class="rooster-admin-button rooster-admin-button--sm insert-media add_media"'), (string) ob_get_clean() ); $aieo_cs_editor_note = \Rooster\Admin\Guidance::text(__('Switch between Visual (rich text) and Text (raw HTML / code) tabs at the top right of the editor. Shortcodes like [products limit="6"] are processed at render time, and wpautop is disabled for this editor. Custom elements (e.g. ) and