is_enabled()) {
return;
}
if ($this->hooked) {
return;
}
$this->hooked = true;
$this->ensure_cron_scheduled();
add_filter('rooster/header/actions', static function ($actions) {
$actions = is_array($actions) ? $actions : array();
$actions['navigator'] = array(
'label' => __('Find a product', 'ai-eshop-optimizer'),
'icon' => 'compass',
'open' => 'navigator',
'render' => static function (array $args) {
return str_replace(' 'intent:[data-rooster-open="navigator"],[data-aieo-navigator-trigger],.aieo-navigator-trigger,a[href$="#rooster-navigator"],.aieo-sc-bar-burger,.ff-header__menu-toggle,.rooster-header__menu-toggle');
return $handles;
});
add_action('woocommerce_product_query', array($this, 'scope_need_archive_to_category'));
add_filter('query_loop_block_query_vars', array($this, 'scope_product_collection_query'), 9, 3);
add_filter('wp_robots', array($this, 'noindex_scoped_need_archive'));
add_action('wp_footer', array($this, 'render_modal_shell'), 90);
add_filter('rooster/navigator/available', '__return_true');
add_action('save_post_page', array($this, 'mark_dirty'), 99);
add_action('save_post_post', array($this, 'mark_dirty'), 99);
add_action('save_post_product', array($this, 'mark_dirty'), 99);
add_action('wp_trash_post', array($this, 'mark_dirty'), 99);
add_action('untrash_post', array($this, 'mark_dirty'), 99);
add_action('deleted_post', array($this, 'mark_dirty'), 99);
add_action('created_term', array($this, 'on_term_change'), 99, 3);
add_action('edited_term', array($this, 'on_term_change'), 99, 3);
add_action('delete_term', array($this, 'on_term_change'), 99, 3);
add_action('set_object_terms', array($this, 'on_set_object_terms'), 99, 6);
add_action('wp_update_nav_menu', array($this, 'mark_dirty'), 99);
add_action('wp_create_nav_menu', array($this, 'mark_dirty'), 99);
add_action('wp_delete_nav_menu', array($this, 'mark_dirty'), 99);
add_action('shutdown', array($this, 'maybe_regenerate'), 5);
add_filter('rocket_cache_reject_uri', array($this, 'wp_rocket_exclude'));
add_filter('rocket_cdn_reject_files', array($this, 'wp_rocket_exclude'));
add_filter('rocket_exclude_js', array($this, 'wp_rocket_exclude_js'));
add_filter('rocket_minify_excluded_external_paths', array($this, 'wp_rocket_exclude_js'));
add_action('init', array($this, 'send_nocache_headers_for_json'));
}
public function wp_rocket_exclude($paths) {
if (!is_array($paths)) { $paths = array(); }
$paths[] = '/wp-content/uploads/aieo/navigator.json';
$paths[] = '(.*)/aieo/navigator\.json(.*)';
return $paths;
}
public function wp_rocket_exclude_js($paths) {
if (!is_array($paths)) { $paths = array(); }
$paths[] = '/wp-content/plugins/ai-eshop-optimizer/assets/js/aieo-navigator';
return $paths;
}
public function send_nocache_headers_for_json() {
if (!isset($_SERVER['REQUEST_URI'])) { return; }
$uri = (string) sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) );
if (strpos($uri, '/wp-content/uploads/aieo/navigator.json') === false) { return; }
if (headers_sent()) { return; }
nocache_headers();
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0', true);
header('Pragma: no-cache', true);
header('Expires: Thu, 01 Jan 1970 00:00:00 GMT', true);
}
public function scope_need_archive_to_category($q) {
if (is_admin() || !is_object($q) || !method_exists($q, 'is_main_query') || !$q->is_main_query()) {
return;
}
if (!$this->needs_scoped_enabled()) {
return;
}
if (!method_exists($q, 'is_tax') || !$q->is_tax(array('pa_need', 'pa-need', 'product_brand', 'pwb-brand'))) {
return;
}
if ($q->get('post__in')) {
return;
}
if (function_exists('doing_action') && doing_action('dynamic_sidebar')) {
return;
}
$cat_id = isset($_GET['in_cat']) ? absint(wp_unslash($_GET['in_cat'])) : 0;
if ($cat_id <= 0) {
return;
}
$term = get_term($cat_id, 'product_cat');
if (!$term || is_wp_error($term)) {
return;
}
$tax_query = $q->get('tax_query');
if (!is_array($tax_query)) {
$tax_query = array();
}
$tax_query[] = array(
'taxonomy' => 'product_cat',
'field' => 'term_id',
'terms' => array($cat_id),
'include_children' => true,
);
$q->set('tax_query', $tax_query);
}
public function scope_product_collection_query($query, $block, $page) {
if (is_admin() || !$this->needs_scoped_enabled()) {
return $query;
}
if (!is_object($block) || empty($block->context['query']['inherit'])) {
return $query;
}
if (!function_exists('is_tax') || !is_tax(array('pa_need', 'pa-need', 'product_brand', 'pwb-brand'))) {
return $query;
}
$cat_id = isset($_GET['in_cat']) ? absint(wp_unslash($_GET['in_cat'])) : 0;
if ($cat_id <= 0) {
return $query;
}
$term = get_term($cat_id, 'product_cat');
if (!$term || is_wp_error($term)) {
return $query;
}
$tax_query = (isset($query['tax_query']) && is_array($query['tax_query'])) ? $query['tax_query'] : array();
$tax_query[] = array(
'taxonomy' => 'product_cat',
'field' => 'term_id',
'terms' => array($cat_id),
'include_children' => true,
);
$query['tax_query'] = $tax_query;
return $query;
}
public function noindex_scoped_need_archive($robots) {
if (is_admin() || !$this->needs_scoped_enabled()) {
return $robots;
}
if (empty($_GET['in_cat'])) {
return $robots;
}
if (function_exists('is_tax') && is_tax(array('pa_need', 'pa-need', 'product_brand', 'pwb-brand'))) {
$robots['noindex'] = true;
$robots['follow'] = true;
}
return $robots;
}
private function excluded_term_keys() {
$raw = (string) AIEO_DMM_Settings::get('navigator_excluded_terms', '');
$aliases = array(
'product_brand' => array('product_brand', 'pwb-brand', 'pa_brand', 'pa-brand'),
'pwb-brand' => array('product_brand', 'pwb-brand', 'pa_brand', 'pa-brand'),
'pa_brand' => array('product_brand', 'pwb-brand', 'pa_brand', 'pa-brand'),
'pa-brand' => array('product_brand', 'pwb-brand', 'pa_brand', 'pa-brand'),
'pa-need' => array('pa-need', 'pa_need'),
'pa_need' => array('pa-need', 'pa_need'),
);
$out = array();
foreach (preg_split('/[,\s]+/', $raw) as $tok) {
$tok = trim($tok);
if ($tok === '') continue;
if (strpos($tok, ':') === false) {
$out[] = 'product_cat:' . (int) $tok;
continue;
}
list($tax, $id) = explode(':', $tok, 2);
$tax = sanitize_key($tax);
$id = (int) $id;
$taxes = isset($aliases[$tax]) ? $aliases[$tax] : array($tax);
foreach ($taxes as $alias) {
$out[] = $alias . ':' . $id;
}
}
return array_flip(array_unique($out));
}
public function is_enabled() {
if (method_exists('AIEO_DMM_Settings', 'is_module_enabled')) {
return (bool) AIEO_DMM_Settings::is_module_enabled('navigator');
}
return (bool) AIEO_DMM_Settings::get('navigator_enabled', true);
}
private function needs_scoped_enabled() {
return (bool) AIEO_DMM_Settings::get('navigator_needs_scoped', false);
}
public static function group_catalog() {
return array(
array('slug' => 'filters', 'label' => __('Filters (FiboFilters)', 'ai-eshop-optimizer'), 'default_placement' => 'main'),
array('slug' => 'categories', 'label' => __('Categories', 'ai-eshop-optimizer'), 'default_placement' => 'main'),
array('slug' => 'needs', 'label' => __('Wishes', 'ai-eshop-optimizer'), 'default_placement' => 'main'),
array('slug' => 'brands', 'label' => __('Brands', 'ai-eshop-optimizer'), 'default_placement' => 'main'),
array('slug' => 'pages', 'label' => __('Pages', 'ai-eshop-optimizer'), 'default_placement' => 'main'),
array('slug' => 'blog-categories', 'label' => __('Blog categories', 'ai-eshop-optimizer'), 'default_placement' => 'main'),
array('slug' => 'blog-posts', 'label' => __('Blog', 'ai-eshop-optimizer'), 'default_placement' => 'main'),
array('slug' => 'actions', 'label' => __('Tools', 'ai-eshop-optimizer'), 'default_placement' => 'rail'),
);
}
private function resolve_groups_config() {
$catalog = self::group_catalog();
$by_slug = array();
foreach ($catalog as $g) { $by_slug[$g['slug']] = $g; }
$saved = AIEO_DMM_Settings::get('navigator_groups_config', null);
if (!is_array($saved)) { $saved = array(); }
$resolved = array();
$seen = array();
foreach ($saved as $row) {
if (!is_array($row) || empty($row['slug'])) { continue; }
$slug = sanitize_key($row['slug']);
if (!isset($by_slug[$slug]) || isset($seen[$slug])) { continue; }
$place = isset($row['placement']) ? sanitize_key($row['placement']) : $by_slug[$slug]['default_placement'];
if (!in_array($place, array('main', 'rail', 'off'), true)) {
$place = $by_slug[$slug]['default_placement'];
}
$resolved[] = array('slug' => $slug, 'placement' => $place);
$seen[$slug] = true;
}
foreach ($catalog as $g) {
if (isset($seen[$g['slug']])) { continue; }
$resolved[] = array('slug' => $g['slug'], 'placement' => $g['default_placement']);
}
return $resolved;
}
public function mobile_tabs_config() {
$allowed = array('categories', 'brands', 'needs', 'filters');
$saved = AIEO_DMM_Settings::get('navigator_mobile_tabs', null);
if (is_string($saved) && $saved !== '') {
$saved = explode(',', $saved);
}
if (!is_array($saved)) {
return array('categories', 'filters');
}
$clean = array_values(array_intersect($allowed, array_map('sanitize_key', $saved)));
if (!in_array('categories', $clean, true)) { array_unshift($clean, 'categories'); }
$ordered = array('categories');
foreach ($this->resolve_groups_config() as $g) {
if ('categories' !== $g['slug'] && in_array($g['slug'], $clean, true)) {
$ordered[] = $g['slug'];
}
}
foreach ($clean as $slug) {
if (!in_array($slug, $ordered, true)) { $ordered[] = $slug; }
}
return $ordered;
}
private static function stores_archive_path() {
foreach (array('aieo_store', 'store_location') as $pt) {
if (!post_type_exists($pt)) { continue; }
$url = get_post_type_archive_link($pt);
if (!$url) { continue; }
$path = wp_parse_url($url, PHP_URL_PATH);
if (is_string($path) && '' !== $path) { return $path; }
}
return '/katastimata/';
}
private function default_actions() {
return apply_filters('aieo_dmm_navigator_actions', array(
array('id'=>'act-wishlist', 'label'=>__('Wishlist', 'ai-eshop-optimizer'), 'url'=>'/wishlist/', 'icon'=>'heart', 'event'=>'aieo:side-cart:open:favorites'),
array('id'=>'act-orders', 'label'=>__('My orders', 'ai-eshop-optimizer'), 'url'=>'/my-account/orders/', 'icon'=>'package'),
array('id'=>'act-account', 'label'=>__('My account', 'ai-eshop-optimizer'), 'url'=>'/my-account/', 'icon'=>'user'),
array('id'=>'act-cart', 'label'=>__('Cart', 'ai-eshop-optimizer'), 'url'=>'/cart/', 'icon'=>'shopping-bag', 'event'=>'aieo:side-cart:open:cart'),
array('id'=>'act-rv', 'label'=>__('Recent products', 'ai-eshop-optimizer'), 'url'=>'#', 'icon'=>'clock', 'event'=>'aieo:side-cart:open:recently-viewed'),
array('id'=>'act-fastfinder', 'label'=>__('Fast Finder', 'ai-eshop-optimizer'), 'url'=>'/fast-finder/', 'icon'=>'search'),
array('id'=>'act-brands', 'label'=>__('Brands', 'ai-eshop-optimizer'), 'url'=>'/brand-shopping/', 'icon'=>'tag'),
array('id'=>'act-competitions', 'label'=>__('Competitions', 'ai-eshop-optimizer'), 'url'=>'/competitions/', 'icon'=>'zap'),
array('id'=>'act-pros', 'label'=>__('Professional registration', 'ai-eshop-optimizer'), 'url'=>'/epaggelmaties-spoudastes/', 'icon'=>'user'),
array('id'=>'act-stores', 'label'=>__('Stores', 'ai-eshop-optimizer'), 'url'=>self::stores_archive_path(), 'icon'=>'map-pin'),
array('id'=>'act-contact', 'label'=>__('Contact', 'ai-eshop-optimizer'), 'url'=>'/contact/', 'icon'=>'mail'),
array('id'=>'act-faq', 'label'=>__('Frequently Asked Questions', 'ai-eshop-optimizer'), 'url'=>'/faq/', 'icon'=>'help-circle'),
));
}
const SOON_HOOK = 'aieo_dmm_navigator_regen_soon';
private $dirty = false;
public function mark_dirty() {
$this->dirty = true;
}
public function on_term_change($term_id, $tt_id = 0, $taxonomy = '') {
$watched = array('product_cat', 'product_brand', 'pa-need', 'category');
if (in_array($taxonomy, $watched, true)) {
$this->mark_dirty();
}
}
public function on_set_object_terms($object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids) {
$watched = array('product_cat', 'product_brand', 'pa-need', 'category');
if (in_array($taxonomy, $watched, true)) {
$this->mark_dirty();
}
}
public function maybe_regenerate() {
if (!$this->dirty) {
return;
}
$this->dirty = false;
$now = wp_doing_cron() || (defined('WP_CLI') && WP_CLI) || current_user_can('edit_posts');
if (!$now) {
if (!wp_next_scheduled(self::SOON_HOOK)) {
wp_schedule_single_event(time() + MINUTE_IN_SECONDS, self::SOON_HOOK);
}
return;
}
$this->generate_json();
}
const CRON_HOOK = 'aieo_dmm_navigator_cron_regen';
private function daily_time() {
$t = (string) AIEO_DMM_Settings::get('navigator_daily_time', '03:00');
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $t)) {
$t = '03:00';
}
return $t;
}
private function daily_enabled() {
return (bool) AIEO_DMM_Settings::get('navigator_daily_enabled', true);
}
private function next_run_timestamp($time_hhmm) {
$tz = wp_timezone();
try {
$dt = new DateTime('today ' . $time_hhmm, $tz);
} catch (Exception $e) {
$dt = new DateTime('today 03:00', $tz);
}
if ($dt->getTimestamp() <= time()) {
$dt->modify('+1 day');
}
return $dt->getTimestamp();
}
public function ensure_cron_scheduled() {
if (!$this->daily_enabled()) {
wp_clear_scheduled_hook(self::CRON_HOOK);
return;
}
$target = $this->next_run_timestamp($this->daily_time());
$existing = wp_next_scheduled(self::CRON_HOOK);
if (!$existing || abs($existing - $target) > 300) {
wp_clear_scheduled_hook(self::CRON_HOOK);
wp_schedule_event($target, 'daily', self::CRON_HOOK);
}
}
public function cron_regenerate() {
if (!$this->is_enabled()) return;
$this->generate_json();
}
public function generate_json($force = false) {
$locale = get_locale();
$switched = function_exists('determine_locale')
&& $locale !== determine_locale()
&& function_exists('switch_to_locale')
&& switch_to_locale($locale);
try {
return $this->build_json($force);
} finally {
if ($switched && function_exists('restore_previous_locale')) {
restore_previous_locale();
}
}
}
private function build_json($force = false) {
$upload = wp_upload_dir();
$dir = trailingslashit($upload['basedir']) . 'aieo';
if (!file_exists($dir)) {
wp_mkdir_p($dir);
}
$version = $this->next_version();
$builders = array(
'categories' => array($this, 'build_categories_group'),
'needs' => array($this, 'build_needs_group'),
'brands' => array($this, 'build_brands_group'),
'pages' => array($this, 'build_pages_group'),
'blog-categories' => array($this, 'build_blog_categories_group'),
'blog-posts' => array($this, 'build_blog_posts_group'),
'actions' => array($this, 'build_actions_group'),
);
$groups = array();
foreach ($this->resolve_groups_config() as $cfg) {
if ($cfg['placement'] === 'off') { continue; }
if (empty($builders[$cfg['slug']])) { continue; }
$g = call_user_func($builders[$cfg['slug']]);
if (empty($g['items'])) { continue; }
$g['placement'] = $cfg['placement'];
$groups[] = $g;
}
$built_slugs = array_flip(array_column($groups, 'slug'));
$spine = array('categories' => true, 'brands' => true);
$missing_spine = array();
foreach ($spine as $slug => $_) {
if (empty($builders[$slug])) { continue; }
if (isset($built_slugs[$slug])) { continue; }
$turned_off = false;
foreach ($this->resolve_groups_config() as $cfg) {
if ($cfg['slug'] === $slug && $cfg['placement'] === 'off') { $turned_off = true; break; }
}
if (!$turned_off) { $missing_spine[] = $slug; }
}
if (!empty($missing_spine) && !$force && file_exists($dir . '/navigator.json')) {
set_transient('aieo_dmm_navigator_last_abort', array(
'at' => time(),
'missing' => $missing_spine,
), HOUR_IN_SECONDS);
return false;
}
$payload = array(
'version' => $version,
'generated_at' => time(),
'site_url' => untrailingslashit(home_url('/')),
'groups' => $groups,
'highlights' => array(
'brands' => $this->top_brands(10),
'needs' => $this->top_needs(10),
),
);
if ($this->needs_scoped_enabled()) {
$scope_maps = $this->build_category_scope_maps();
$payload['needsByCategory'] = $scope_maps['needs'];
$payload['brandsByCategory'] = $scope_maps['brands'];
}
$path = $dir . '/navigator.json';
$json = wp_json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return false;
}
$ok = (false !== @file_put_contents($path, $json));
if ($ok) {
update_option('aieo_dmm_navigator_version', $version, true);
update_option('aieo_dmm_navigator_generated_at', time(), true);
}
return $ok;
}
private function next_version() {
$current = (string) get_option('aieo_dmm_navigator_version', '1.0.0');
$parts = explode('.', $current);
$parts[2] = isset($parts[2]) ? ((int) $parts[2] + 1) : 1;
return implode('.', $parts);
}
private function build_categories_group() {
$items = $this->terms_to_items('product_cat', 3);
return array(
'slug' => 'categories',
'label' => __('Categories', 'ai-eshop-optimizer'),
'icon' => 'grid',
'items' => $items,
);
}
private function build_brands_group() {
$items = $this->popularity_brands();
if (empty($items)) {
$tax = taxonomy_exists('product_brand') ? 'product_brand'
: (taxonomy_exists('pwb-brand') ? 'pwb-brand' : null);
$items = $tax ? $this->terms_to_items($tax, 1) : array();
}
return array(
'slug' => 'brands',
'label' => __('Brands', 'ai-eshop-optimizer'),
'icon' => 'tag',
'items' => $items,
);
}
private function popularity_table() {
global $wpdb;
return get_option('aieo_core_category_brand_popularity_table', $wpdb->prefix . 'aieo_core_category_brand_popularity');
}
private function popularity_table_exists() {
global $wpdb;
$tbl = $this->popularity_table();
$found = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $tbl));
return ($found === $tbl);
}
private function popularity_categories() {
if (!$this->popularity_table_exists()) {
return array();
}
$this->build_term_url_map('product_cat');
global $wpdb;
$tbl = $this->popularity_table();
$order_by = (string) AIEO_DMM_Settings::get('navigator_order_metric', 'turnover');
$col = ($order_by === 'units') ? 'total_sales' : 'total_turnover';
$rows = $wpdb->get_results(
"SELECT category_id, category_name, category_level, parent_category_id, product_count, total_sales, total_turnover
FROM {$tbl}
WHERE category_id IS NOT NULL AND (brand_id IS NULL OR brand_id = 0)
AND product_count > 0
ORDER BY category_level ASC, {$col} DESC"
);
if (empty($rows)) {
return array();
}
$excluded = $this->excluded_term_keys();
$items = array();
foreach ($rows as $r) {
if (isset($excluded['product_cat:' . (int) $r->category_id])) {
continue;
}
$term = get_term((int) $r->category_id, 'product_cat');
if (!$term || is_wp_error($term)) {
continue;
}
$url = $this->resolve_term_url($term, 'product_cat');
$level = max(0, (int) $r->category_level - 1);
$parent_key = ($r->parent_category_id) ? ('product_cat-' . (int) $r->parent_category_id) : null;
$items[] = array(
'id' => 'product_cat-' . (int) $term->term_id,
'label' => $r->category_name ?: $term->name,
'url' => $url,
'level' => $level,
'parent' => $parent_key,
'count' => (int) $r->product_count,
);
}
return $items;
}
private function resolve_term_url($term, $taxonomy = '') {
if (!$term || is_wp_error($term)) {
return '';
}
$term_id = is_object($term) ? (int) $term->term_id : 0;
$tax = $taxonomy ?: (is_object($term) ? (string) $term->taxonomy : '');
if ($term_id > 0 && $tax !== '' && isset($this->term_url_maps[$tax][$term_id])) {
return (string) $this->term_url_maps[$tax][$term_id];
}
$url = get_term_link($term);
if (!is_wp_error($url) && is_string($url) && $url !== '') {
return $url;
}
$slug = is_object($term) ? (string) $term->slug : '';
if ($slug === '') { return ''; }
switch ($tax) {
case 'product_cat':
return home_url('/product-category/' . rawurlencode($slug) . '/');
case 'product_brand':
case 'pwb-brand':
return home_url('/brand/' . rawurlencode($slug) . '/');
case 'pa-need':
case 'pa_need':
return home_url('/need/' . rawurlencode($slug) . '/');
case 'category':
return home_url('/category/' . rawurlencode($slug) . '/');
default:
return $tax
? home_url('/' . rawurlencode($tax) . '/' . rawurlencode($slug) . '/')
: '';
}
}
private $term_url_maps = array();
private function build_term_url_map($taxonomy) {
if (isset($this->term_url_maps[$taxonomy])) {
return $this->term_url_maps[$taxonomy];
}
$this->term_url_maps[$taxonomy] = array();
if (!taxonomy_exists($taxonomy)) { return $this->term_url_maps[$taxonomy]; }
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT t.term_id, t.slug, tt.parent
FROM {$wpdb->terms} t
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id
WHERE tt.taxonomy = %s",
$taxonomy
));
if (empty($rows)) { return $this->term_url_maps[$taxonomy]; }
$slug_by_id = array();
$parent_by_id = array();
foreach ($rows as $r) {
$tid = (int) $r->term_id;
$slug_by_id[$tid] = (string) $r->slug;
$parent_by_id[$tid] = (int) $r->parent;
}
$base = $this->taxonomy_url_base($taxonomy);
$hierarchical = in_array($taxonomy, array('product_cat', 'category'), true);
foreach ($slug_by_id as $tid => $slug) {
if ($slug === '') { $this->term_url_maps[$taxonomy][$tid] = ''; continue; }
if (!$hierarchical) {
$this->term_url_maps[$taxonomy][$tid] = home_url($base . '/' . rawurlencode($slug) . '/');
continue;
}
$chain = array($slug);
$cursor = $parent_by_id[$tid];
$guard = 0;
while ($cursor > 0 && $guard < 32) {
if (!isset($slug_by_id[$cursor])) { break; }
array_unshift($chain, $slug_by_id[$cursor]);
$cursor = isset($parent_by_id[$cursor]) ? $parent_by_id[$cursor] : 0;
$guard++;
}
$path = implode('/', array_map('rawurlencode', $chain));
$this->term_url_maps[$taxonomy][$tid] = home_url($base . '/' . $path . '/');
}
return $this->term_url_maps[$taxonomy];
}
private function taxonomy_url_base($taxonomy) {
if ($taxonomy === 'product_cat') {
$perm = get_option('woocommerce_permalinks', array());
$base = is_array($perm) && !empty($perm['category_base']) ? (string) $perm['category_base'] : 'product-category';
return '/' . trim($base, '/');
}
if ($taxonomy === 'product_brand' || $taxonomy === 'pwb-brand') {
return '/brand';
}
if ($taxonomy === 'pa-need' || $taxonomy === 'pa_need') {
return '/need';
}
if ($taxonomy === 'category') { return '/category'; }
return '/' . trim($taxonomy, '/');
}
private function popularity_brands() {
if (!$this->popularity_table_exists()) {
return array();
}
$tax = taxonomy_exists('product_brand') ? 'product_brand'
: (taxonomy_exists('pwb-brand') ? 'pwb-brand' : null);
if ($tax) { $this->build_term_url_map($tax); }
global $wpdb;
$tbl = $this->popularity_table();
$extra = $wpdb->prefix . 'aieo_dmm_brand_extras';
$order_by = (string) AIEO_DMM_Settings::get('navigator_order_metric', 'turnover');
$col = ($order_by === 'units') ? 'total_sales' : 'total_turnover';
$rows = $wpdb->get_results(
"SELECT pop.brand_id, pop.brand_name, pop.brand_slug, pop.product_count,
pop.total_sales, pop.total_turnover,
be.logo_image_id, be.hero_image_id
FROM {$tbl} pop
LEFT JOIN {$extra} be ON be.term_id = pop.brand_id AND be.taxonomy = 'product_brand'
WHERE (pop.category_id IS NULL OR pop.category_id = 0)
AND pop.brand_id IS NOT NULL AND pop.brand_id > 0
AND pop.product_count > 0
ORDER BY pop.{$col} DESC"
);
if (empty($rows)) {
return array();
}
$tax = taxonomy_exists('product_brand') ? 'product_brand'
: (taxonomy_exists('pwb-brand') ? 'pwb-brand' : null);
$excluded = $this->excluded_term_keys();
$items = array();
foreach ($rows as $r) {
$bid = (int) $r->brand_id;
if ($tax && (
isset($excluded[$tax . ':' . $bid]) ||
isset($excluded['product_brand:' . $bid]) ||
isset($excluded['pwb-brand:' . $bid])
)) {
continue;
}
$url = '';
$term = null;
if ($tax) {
$term = get_term($bid, $tax);
if ($term && !is_wp_error($term)) {
$url = $this->resolve_term_url($term, $tax);
}
}
if (!$url && !empty($r->brand_slug)) {
$url = home_url('/brand/' . rawurlencode($r->brand_slug) . '/');
}
$logo_url = '';
if (!empty($r->logo_image_id)) {
$logo_url = (string) wp_get_attachment_image_url((int) $r->logo_image_id, 'medium');
}
if (!$logo_url) {
$thumb_id = (int) get_term_meta($bid, 'thumbnail_id', true);
if ($thumb_id > 0) {
$logo_url = (string) wp_get_attachment_image_url($thumb_id, 'medium');
}
}
$item = array(
'id' => 'brand-' . $bid,
'label' => $r->brand_name,
'url' => $url,
'level' => 0,
'count' => (int) $r->product_count,
);
if ($logo_url !== '') { $item['image'] = $logo_url; }
$items[] = $item;
}
return $items;
}
private function favourite_brands_for_viewer($limit = 5) {
if (!is_user_logged_in()) { return array(); }
if (!class_exists('AIEO_Customer_Intelligence_Query')) { return array(); }
$uid = get_current_user_id();
$top_ids = AIEO_Customer_Intelligence_Query::get_top_brands($uid, $limit);
if (empty($top_ids)) { return array(); }
$all = $this->popularity_brands();
$by_id = array();
foreach ($all as $it) {
if (preg_match('/^brand-(\d+)$/', (string) $it['id'], $m)) {
$by_id[(int) $m[1]] = $it;
}
}
$out = array();
foreach ($top_ids as $bid) {
$bid = (int) $bid;
if (!isset($by_id[$bid])) { continue; }
$it = $by_id[$bid];
$it['id'] = 'fav-brand-' . $bid;
$it['favourite'] = 1;
$out[] = $it;
}
return $out;
}
private function top_brands($limit = 10) {
$items = $this->popularity_brands();
if (empty($items)) {
$tax = taxonomy_exists('product_brand') ? 'product_brand'
: (taxonomy_exists('pwb-brand') ? 'pwb-brand' : null);
$items = $tax ? $this->terms_to_items($tax, 1) : array();
}
return array_slice($items, 0, max(0, (int) $limit));
}
private function top_needs($limit = 10) {
$tax = taxonomy_exists('pa-need') ? 'pa-need'
: (taxonomy_exists('pa_need') ? 'pa_need' : null);
if (!$tax) {
return array();
}
$terms = get_terms(array(
'taxonomy' => $tax,
'hide_empty' => true,
'orderby' => 'count',
'order' => 'DESC',
'number' => max(0, (int) $limit),
));
if (is_wp_error($terms) || empty($terms)) {
return array();
}
$excluded = $this->excluded_term_keys();
$items = array();
foreach ($terms as $t) {
if (isset($excluded[$tax . ':' . (int) $t->term_id])) continue;
$items[] = array(
'id' => $tax . '-' . (int) $t->term_id,
'label' => $t->name,
'url' => get_term_link($t),
'level' => 0,
'count' => (int) $t->count,
);
}
return $items;
}
private function build_needs_group() {
$tax = taxonomy_exists('pa-need') ? 'pa-need'
: (taxonomy_exists('pa_need') ? 'pa_need' : null);
if (!$tax) {
return array('slug' => 'needs', 'label' => __('Wishes', 'ai-eshop-optimizer'), 'items' => array());
}
return array(
'slug' => 'needs',
'label' => __('Wishes', 'ai-eshop-optimizer'),
'icon' => 'sparkles',
'items' => $this->terms_to_items($tax, 1),
);
}
private function build_term_by_category_map($child_taxonomy) {
global $wpdb;
if (!$child_taxonomy || !taxonomy_exists($child_taxonomy)) {
return new stdClass();
}
$pairs = $wpdb->get_results($wpdb->prepare(
"SELECT ttc.term_id AS cat_id, ttx.term_id AS child_id, COUNT(*) AS cnt
FROM {$wpdb->term_relationships} trc
INNER JOIN {$wpdb->term_taxonomy} ttc
ON ttc.term_taxonomy_id = trc.term_taxonomy_id AND ttc.taxonomy = 'product_cat'
INNER JOIN {$wpdb->posts} p
ON p.ID = trc.object_id AND p.post_type = 'product' AND p.post_status = 'publish'
INNER JOIN {$wpdb->term_relationships} trx
ON trx.object_id = p.ID
INNER JOIN {$wpdb->term_taxonomy} ttx
ON ttx.term_taxonomy_id = trx.term_taxonomy_id AND ttx.taxonomy = %s
GROUP BY ttc.term_id, ttx.term_id",
$child_taxonomy
));
if (empty($pairs)) {
return new stdClass();
}
$direct = array();
foreach ($pairs as $r) {
$cid = (int) $r->cat_id;
$xid = (int) $r->child_id;
if ($cid > 0 && $xid > 0) {
$direct[$cid][$xid] = (int) $r->cnt;
}
}
$cat_rows = $wpdb->get_results(
"SELECT term_id, parent FROM {$wpdb->term_taxonomy} WHERE taxonomy = 'product_cat'"
);
$children = array();
$cat_ids = array();
foreach ((array) $cat_rows as $cr) {
$tid = (int) $cr->term_id;
$pid = (int) $cr->parent;
$children[$pid][] = $tid;
$cat_ids[$tid] = true;
}
$memo = array();
$resolve = function ($cat) use (&$resolve, &$memo, $children, $direct) {
if (isset($memo[$cat])) {
return $memo[$cat];
}
$memo[$cat] = array();
$set = isset($direct[$cat]) ? $direct[$cat] : array();
if (!empty($children[$cat])) {
foreach ($children[$cat] as $kid) {
foreach ($resolve($kid) as $xid => $cnt) {
$set[$xid] = (isset($set[$xid]) ? $set[$xid] : 0) + $cnt;
}
}
}
$memo[$cat] = $set;
return $set;
};
$out = array();
foreach (array_keys($cat_ids) as $cat) {
$set = $resolve($cat);
if (!empty($set)) {
ksort($set);
$clean = array();
foreach ($set as $xid => $cnt) {
$clean[(string) $xid] = (int) $cnt;
}
$out[(string) $cat] = $clean;
}
}
return empty($out) ? new stdClass() : $out;
}
private function build_category_scope_maps() {
global $wpdb;
$vitals = $wpdb->prefix . 'aieo_core_product_vitals';
if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $vitals)) === $vitals) {
$v = $this->build_scope_maps_from_vitals($vitals);
if (is_array($v)) {
return $v;
}
}
$need_tax = taxonomy_exists('pa_need') ? 'pa_need'
: (taxonomy_exists('pa-need') ? 'pa-need' : '');
$brand_tax = taxonomy_exists('product_brand') ? 'product_brand'
: (taxonomy_exists('pwb-brand') ? 'pwb-brand' : '');
return array(
'needs' => $this->build_term_by_category_map($need_tax),
'brands' => $this->build_term_by_category_map($brand_tax),
);
}
private function build_scope_maps_from_vitals($vitals) {
global $wpdb;
$cols = $wpdb->get_col("SHOW COLUMNS FROM {$vitals}");
if (!in_array('category_ids', $cols, true) || !in_array('parent_id', $cols, true) || !in_array('status', $cols, true)) {
return null;
}
$has_need = in_array('primary_need_id', $cols, true);
$has_brand = in_array('brand_id', $cols, true);
if (!$has_need && !$has_brand) {
return null;
}
$cat_rows = $wpdb->get_results("SELECT term_id, parent FROM {$wpdb->term_taxonomy} WHERE taxonomy = 'product_cat'");
$parent = array();
foreach ((array) $cat_rows as $cr) {
$parent[(int) $cr->term_id] = (int) $cr->parent;
}
$anc_cache = array();
$ancestors = function ($cat) use (&$anc_cache, $parent) {
if (isset($anc_cache[$cat])) {
return $anc_cache[$cat];
}
$chain = array($cat);
$p = isset($parent[$cat]) ? $parent[$cat] : 0;
$guard = 0;
while ($p > 0 && $guard < 40) {
$chain[] = $p;
$p = isset($parent[$p]) ? $parent[$p] : 0;
$guard++;
}
$anc_cache[$cat] = $chain;
return $chain;
};
$hidden_slugs = array('exclude-from-catalog');
if ('yes' === get_option('woocommerce_hide_out_of_stock_items')) {
$hidden_slugs[] = 'outofstock';
}
$hidden = array();
$ph = implode(',', array_fill(0, count($hidden_slugs), '%s'));
$hidden_ids = $wpdb->get_col($wpdb->prepare(
"SELECT DISTINCT tr.object_id
FROM {$wpdb->term_relationships} tr
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
WHERE tt.taxonomy = 'product_visibility' AND t.slug IN ({$ph})",
...$hidden_slugs
));
foreach ((array) $hidden_ids as $hid) {
$hidden[(int) $hid] = true;
}
$sel = 'vv.product_id, vv.category_ids';
if ($has_brand) { $sel .= ', vv.brand_id'; }
if ($has_need) { $sel .= ', vv.primary_need_id, vv.secondary_need_id, vv.other_needs_ids'; }
$rows = $wpdb->get_results(
"SELECT {$sel}
FROM {$vitals} vv
INNER JOIN {$wpdb->posts} p ON p.ID = vv.product_id AND p.post_type = 'product' AND p.post_status = 'publish'
WHERE vv.product_id = vv.parent_id"
);
if (empty($rows)) {
return null;
}
$needs = array();
$brands = array();
foreach ($rows as $r) {
if (isset($hidden[(int) $r->product_id])) {
continue;
}
$cats = array();
foreach (explode(',', (string) $r->category_ids) as $cid) {
$cid = (int) $cid;
if ($cid > 0) {
foreach ($ancestors($cid) as $a) { $cats[$a] = true; }
}
}
if (empty($cats)) {
continue;
}
$prod_needs = array();
if ($has_need) {
foreach (array($r->primary_need_id, $r->secondary_need_id) as $nid) {
$nid = (int) $nid;
if ($nid > 0) { $prod_needs[$nid] = true; }
}
foreach (explode(',', (string) $r->other_needs_ids) as $nid) {
$nid = (int) $nid;
if ($nid > 0) { $prod_needs[$nid] = true; }
}
}
$bid = $has_brand ? (int) $r->brand_id : 0;
foreach (array_keys($cats) as $cat) {
foreach (array_keys($prod_needs) as $nid) {
$needs[$cat][$nid] = (isset($needs[$cat][$nid]) ? $needs[$cat][$nid] : 0) + 1;
}
if ($bid > 0) {
$brands[$cat][$bid] = (isset($brands[$cat][$bid]) ? $brands[$cat][$bid] : 0) + 1;
}
}
}
return array(
'needs' => $this->shape_scope_map($needs),
'brands' => $this->shape_scope_map($brands),
);
}
private function shape_scope_map($map) {
$out = array();
foreach ($map as $cat => $items) {
if (empty($items)) {
continue;
}
ksort($items);
$clean = array();
foreach ($items as $id => $cnt) {
$clean[(string) $id] = (int) $cnt;
}
$out[(string) $cat] = $clean;
}
return empty($out) ? new stdClass() : $out;
}
private function build_pages_group() {
return array(
'slug' => 'pages',
'label' => __('Pages', 'ai-eshop-optimizer'),
'icon' => 'file-text',
'items' => $this->posts_to_items('page', 200, 'menu_order title', 'ASC'),
);
}
private function build_blog_posts_group() {
return array(
'slug' => 'blog-posts',
'label' => __('Blog', 'ai-eshop-optimizer'),
'icon' => 'book-open',
'items' => $this->posts_to_items('post', 100, 'date', 'DESC'),
);
}
private function posts_to_items($post_type, $limit, $orderby, $order) {
$ids = get_posts(array(
'post_type' => $post_type,
'post_status' => 'publish',
'posts_per_page' => $limit,
'orderby' => $orderby,
'order' => $order,
'no_found_rows' => true,
'fields' => 'ids',
));
$items = array();
foreach ($ids as $pid) {
$excerpt = get_the_excerpt($pid);
if (!$excerpt) {
$excerpt = wp_trim_words(wp_strip_all_tags(get_post_field('post_content', $pid)), 30, '…');
}
$img = get_the_post_thumbnail_url($pid, 'thumbnail') ?: '';
$items[] = array(
'id' => $post_type . '-' . $pid,
'label' => get_the_title($pid),
'url' => get_permalink($pid),
'level' => 0,
'excerpt' => $excerpt,
'image' => $img,
);
}
return $items;
}
private function build_blog_categories_group() {
$parent_slug = (string) AIEO_DMM_Settings::get('navigator_blog_parent_slug', 'blog');
$parent_id = 0;
if ($parent_slug !== '') {
$term = get_term_by('slug', $parent_slug, 'category');
if ($term && !is_wp_error($term)) {
$parent_id = (int) $term->term_id;
}
}
return array(
'slug' => 'blog-categories',
'label' => __('Blog categories', 'ai-eshop-optimizer'),
'icon' => 'folder',
'items' => $this->terms_to_items('category', 2, $parent_id),
);
}
private function build_actions_group() {
$items = array();
foreach ($this->default_actions() as $a) {
$items[] = array_merge(array('level' => 0), $a);
}
return array(
'slug' => 'actions',
'label' => __('Tools', 'ai-eshop-optimizer'),
'icon' => 'zap',
'items' => $items,
);
}
private function terms_to_items($taxonomy, $max_depth = 3, $root_parent_id = 0) {
if (!taxonomy_exists($taxonomy)) {
return array();
}
$this->build_term_url_map($taxonomy);
$terms = get_terms(array(
'taxonomy' => $taxonomy,
'hide_empty' => true,
'orderby' => 'name',
'order' => 'ASC',
));
if (is_wp_error($terms) || empty($terms)) {
$terms = get_terms(array(
'taxonomy' => $taxonomy,
'hide_empty' => false,
'orderby' => 'name',
'order' => 'ASC',
));
}
if (is_wp_error($terms) || empty($terms)) {
global $wpdb;
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT t.term_id, t.name, t.slug, tt.parent, tt.count, tt.description, tt.taxonomy
FROM {$wpdb->terms} t
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id
WHERE tt.taxonomy = %s
ORDER BY t.name ASC",
$taxonomy
));
$terms = array();
if (is_array($rows)) {
foreach ($rows as $r) {
$t = new stdClass();
$t->term_id = (int) $r->term_id;
$t->name = (string) $r->name;
$t->slug = (string) $r->slug;
$t->parent = (int) $r->parent;
$t->count = (int) $r->count;
$t->description = (string) $r->description;
$t->taxonomy = (string) $r->taxonomy;
$terms[] = $t;
}
}
}
if (is_wp_error($terms) || empty($terms)) {
return array();
}
$excluded = $this->excluded_term_keys();
$by_parent = array();
foreach ($terms as $t) {
$by_parent[(int) $t->parent][] = $t;
}
if ($taxonomy === 'product_cat' || $taxonomy === 'product_brand' || $taxonomy === 'pwb-brand') {
$sales_by_id = $this->popularity_sales_map($taxonomy);
if (!empty($sales_by_id)) {
foreach ($by_parent as $pid => &$kids) {
usort($kids, function ($a, $b) use ($sales_by_id) {
$sa = isset($sales_by_id[(int) $a->term_id]) ? (float) $sales_by_id[(int) $a->term_id] : 0.0;
$sb = isset($sales_by_id[(int) $b->term_id]) ? (float) $sales_by_id[(int) $b->term_id] : 0.0;
if ($sa === $sb) {
$ca = (int) $a->count;
$cb = (int) $b->count;
if ($ca !== $cb) return $cb - $ca;
return strcmp((string) $a->name, (string) $b->name);
}
return ($sa < $sb) ? 1 : -1;
});
}
unset($kids);
}
}
if ($root_parent_id > 0) {
$excluded[$taxonomy . ':1'] = true;
}
$thumb_map = $this->batch_term_thumbnails($terms);
$out = array();
$this->walk_terms($by_parent, (int) $root_parent_id, 0, $max_depth, $taxonomy, $out, null, $excluded, $thumb_map);
return $out;
}
private function batch_term_thumbnails(array $terms) {
if (empty($terms)) { return array(); }
global $wpdb;
$term_ids = array();
foreach ($terms as $t) { $term_ids[] = (int) $t->term_id; }
if (empty($term_ids)) { return array(); }
$placeholders = implode(',', array_fill(0, count($term_ids), '%d'));
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT term_id, meta_value AS thumb_id
FROM {$wpdb->termmeta}
WHERE meta_key = 'thumbnail_id' AND term_id IN ({$placeholders}) AND meta_value > 0",
...$term_ids
));
if (empty($rows)) { return array(); }
$term_to_thumb = array();
$thumb_ids = array();
foreach ($rows as $r) {
$tid = (int) $r->thumb_id;
if ($tid <= 0) { continue; }
$term_to_thumb[(int) $r->term_id] = $tid;
$thumb_ids[$tid] = true;
}
if (empty($thumb_ids)) { return array(); }
$thumb_id_list = array_keys($thumb_ids);
$ph2 = implode(',', array_fill(0, count($thumb_id_list), '%d'));
$files = $wpdb->get_results($wpdb->prepare(
"SELECT post_id, meta_value AS file
FROM {$wpdb->postmeta}
WHERE meta_key = '_wp_attached_file' AND post_id IN ({$ph2})",
...$thumb_id_list
));
$upload_url = '';
if (function_exists('wp_get_upload_dir')) {
$u = wp_get_upload_dir();
$upload_url = isset($u['baseurl']) ? trailingslashit((string) $u['baseurl']) : '';
}
$thumb_id_to_url = array();
foreach ((array) $files as $r) {
$thumb_id_to_url[(int) $r->post_id] = $upload_url . (string) $r->file;
}
$out = array();
foreach ($term_to_thumb as $term_id => $thumb_id) {
if (isset($thumb_id_to_url[$thumb_id])) {
$out[$term_id] = $thumb_id_to_url[$thumb_id];
}
}
return $out;
}
private function popularity_sales_map($taxonomy) {
if (!$this->popularity_table_exists()) {
return array();
}
global $wpdb;
$tbl = $this->popularity_table();
$order_by = (string) AIEO_DMM_Settings::get('navigator_order_metric', 'turnover');
$col = ($order_by === 'units') ? 'total_sales' : 'total_turnover';
$rows = array();
if ($taxonomy === 'product_cat') {
$rows = $wpdb->get_results(
"SELECT category_id AS id, {$col} AS metric
FROM {$tbl}
WHERE category_id IS NOT NULL AND (brand_id IS NULL OR brand_id = 0)"
);
} elseif ($taxonomy === 'product_brand' || $taxonomy === 'pwb-brand') {
$rows = $wpdb->get_results(
"SELECT brand_id AS id, {$col} AS metric
FROM {$tbl}
WHERE (category_id IS NULL OR category_id = 0) AND brand_id IS NOT NULL AND brand_id > 0"
);
}
$map = array();
foreach ((array) $rows as $r) {
$map[(int) $r->id] = (float) $r->metric;
}
return $map;
}
private function walk_terms(array $by_parent, $parent_id, $depth, $max_depth, $taxonomy, array &$out, $parent_key, array $excluded, array $thumb_map = array()) {
if ($depth >= $max_depth || empty($by_parent[$parent_id])) {
return;
}
foreach ($by_parent[$parent_id] as $t) {
$key = $taxonomy . '-' . $t->term_id;
if (isset($excluded[$taxonomy . ':' . (int) $t->term_id])) {
continue;
}
if ((int) $t->count === 0) {
continue;
}
$img = isset($thumb_map[(int) $t->term_id]) ? (string) $thumb_map[(int) $t->term_id] : '';
$out[] = array(
'id' => $key,
'label' => $t->name,
'url' => $this->resolve_term_url($t, $taxonomy),
'level' => $depth,
'parent' => $parent_key,
'count' => (int) $t->count,
'description' => wp_strip_all_tags((string) $t->description),
'image' => $img,
);
$this->walk_terms($by_parent, (int) $t->term_id, $depth + 1, $max_depth, $taxonomy, $out, $key, $excluded, $thumb_map);
}
}
private function queue_generate() {
if (function_exists('as_enqueue_async_action')) {
if (!function_exists('as_has_scheduled_action') || !as_has_scheduled_action('aieo_navigator_generate_json')) {
as_enqueue_async_action('aieo_navigator_generate_json', array(), 'aieo');
}
return;
}
if (!wp_next_scheduled('aieo_navigator_generate_json')) {
wp_schedule_single_event(time() + 5, 'aieo_navigator_generate_json');
}
}
public function enqueue_assets() {
$upload = wp_upload_dir();
$json_path = trailingslashit($upload['basedir']) . 'aieo/navigator.json';
if (!file_exists($json_path)) {
$this->queue_generate();
return;
}
$version = (string) get_option('aieo_dmm_navigator_version', '1.0.0');
$url = trailingslashit($upload['baseurl']) . 'aieo/navigator.json?v=' . rawurlencode($version);
$css_path = AIEO_DIR . 'assets/css/aieo-navigator.css';
$js_path = AIEO_DIR . 'assets/js/aieo-navigator.js';
$css_ver = file_exists($css_path) ? (string) filemtime($css_path) : $version;
$js_ver = file_exists($js_path) ? (string) filemtime($js_path) : $version;
$inline_panel = false;
if (is_singular()) {
$post_obj = get_post();
$inline_panel = $post_obj && (has_block('aieo/navigator', $post_obj) || has_shortcode((string) $post_obj->post_content, 'aieo_navigator'));
}
if ($inline_panel) {
wp_enqueue_style(
'aieo-dmm-navigator',
AIEO_URI . 'assets/css/aieo-navigator.css',
array(),
$css_ver
);
}
$css_url = add_query_arg('ver', $css_ver, AIEO_URI . 'assets/css/aieo-navigator.css');
if (class_exists('AIEO_Asset_Min')) { $css_url = AIEO_Asset_Min::rewrite($css_url); }
wp_enqueue_script(
'aieo-dmm-navigator',
AIEO_URI . 'assets/js/aieo-navigator.js',
array(),
$js_ver,
array('in_footer' => true, 'strategy' => 'defer')
);
wp_localize_script('aieo-dmm-navigator', 'aieoNavigator', array(
'jsonUrl' => $url,
'cssUrl' => $inline_panel ? '' : $css_url,
'cacheVersion' => $version,
'context' => $this->current_context(),
'groupsOrder' => $this->resolve_groups_config(),
'i18n' => array(
'placeholder' => __('Type to search…', 'ai-eshop-optimizer'),
'noResults' => __('No results found.', 'ai-eshop-optimizer'),
'kbdNavigate' => __('navigate', 'ai-eshop-optimizer'),
'kbdOpen' => _x('open', 'navigator keyboard hint', 'ai-eshop-optimizer'),
'kbdBack' => __('back', 'ai-eshop-optimizer'),
'kbdClose' => __('close', 'ai-eshop-optimizer'),
'children' => __('Subcategories', 'ai-eshop-optimizer'),
'open' => __('Open', 'ai-eshop-optimizer'),
'topBrands' => __('Popular brands', 'ai-eshop-optimizer'),
'topNeeds' => __('Popular needs', 'ai-eshop-optimizer'),
'categoryNeeds' => __('Needs in this category', 'ai-eshop-optimizer'),
'categoryBrands' => __('Brands in this category', 'ai-eshop-optimizer'),
'filters' => __('Filters', 'ai-eshop-optimizer'),
'categories' => __('Categories', 'ai-eshop-optimizer'),
'brands' => __('Brands', 'ai-eshop-optimizer'),
'needs' => __('Wishes', 'ai-eshop-optimizer'),
),
'mobileTabs' => $this->mobile_tabs_config(),
));
}
private function current_context() {
$boost = array();
if (function_exists('is_product') && is_product()) {
$pid = get_queried_object_id();
if ($pid) {
$cats = wp_get_object_terms($pid, 'product_cat', array('fields' => 'ids'));
if (!is_wp_error($cats)) {
foreach ($cats as $cid) { $boost[] = 'product_cat-' . (int) $cid; }
}
$brands = wp_get_object_terms($pid, 'product_brand', array('fields' => 'ids'));
if (!is_wp_error($brands) && !empty($brands)) {
foreach ($brands as $bid) { $boost[] = 'brand-' . (int) $bid; }
}
}
} elseif (function_exists('is_product_category') && is_product_category()) {
$term = get_queried_object();
if ($term && isset($term->term_id)) {
$boost[] = 'product_cat-' . (int) $term->term_id;
$parent = (int) $term->parent;
while ($parent > 0) {
$boost[] = 'product_cat-' . $parent;
$p = get_term($parent, 'product_cat');
$parent = $p && !is_wp_error($p) ? (int) $p->parent : 0;
}
}
} elseif (is_singular('post')) {
$cats = wp_get_object_terms(get_queried_object_id(), 'category', array('fields' => 'ids'));
if (!is_wp_error($cats)) {
foreach ($cats as $cid) { $boost[] = 'category-' . (int) $cid; }
}
} elseif (is_category()) {
$term = get_queried_object();
if ($term && isset($term->term_id)) {
$boost[] = 'category-' . (int) $term->term_id;
}
} elseif (function_exists('is_tax')
&& is_tax(array('pa_need', 'pa-need', 'product_brand', 'pwb-brand'))
&& ! empty($_GET['in_cat'])) {
$cat_id = absint(wp_unslash($_GET['in_cat']));
if ($cat_id > 0) {
$boost[] = 'product_cat-' . $cat_id;
$p = get_term($cat_id, 'product_cat');
$parent = ($p && !is_wp_error($p)) ? (int) $p->parent : 0;
$guard = 0;
while ($parent > 0 && $guard < 32) {
$boost[] = 'product_cat-' . $parent;
$pp = get_term($parent, 'product_cat');
$parent = ($pp && !is_wp_error($pp)) ? (int) $pp->parent : 0;
$guard++;
}
}
}
$ctx = array('boost' => array_values(array_unique($boost)));
if ($this->needs_scoped_enabled()) {
$ctx['needsScoped'] = true;
$scope_cat = $this->resolve_scope_category();
if ($scope_cat) {
$ctx['category'] = $scope_cat;
}
}
return $ctx;
}
private function resolve_scope_category() {
if (function_exists('is_product_category') && is_product_category()) {
$term = get_queried_object();
if ($term && isset($term->term_id, $term->slug)) {
return array('id' => (int) $term->term_id, 'slug' => (string) $term->slug);
}
} elseif (function_exists('is_product') && is_product()) {
$cat_id = $this->primary_product_cat_id(get_queried_object_id());
if ($cat_id > 0) {
$term = get_term($cat_id, 'product_cat');
if ($term && !is_wp_error($term)) {
return array('id' => (int) $term->term_id, 'slug' => (string) $term->slug);
}
}
} elseif (function_exists('is_tax')
&& is_tax(array('pa_need', 'pa-need', 'product_brand', 'pwb-brand'))
&& ! empty($_GET['in_cat'])) {
$cat_id = absint(wp_unslash($_GET['in_cat']));
if ($cat_id > 0) {
$term = get_term($cat_id, 'product_cat');
if ($term && !is_wp_error($term)) {
return array('id' => (int) $term->term_id, 'slug' => (string) $term->slug);
}
}
}
return null;
}
private function primary_product_cat_id($product_id) {
$product_id = (int) $product_id;
if ($product_id <= 0) { return 0; }
if (class_exists('AIEO_SEO_Store')) {
$cat_id = (int) AIEO_SEO_Store::get_primary_cat_id('product', $product_id);
if ($cat_id > 0) {
$t = get_term($cat_id, 'product_cat');
if ($t && !is_wp_error($t)) { return $cat_id; }
}
}
$primary = (int) get_post_meta($product_id, '_yoast_wpseo_primary_product_cat', true);
if ($primary > 0) {
$t = get_term($primary, 'product_cat');
if ($t && !is_wp_error($t)) { return $primary; }
}
$cats = wp_get_object_terms($product_id, 'product_cat', array('fields' => 'ids'));
if (!is_wp_error($cats) && !empty($cats)) {
return (int) $cats[0];
}
return 0;
}
private function shell_style_vars() {
$bw = (int) AIEO_DMM_Settings::get('navigator_border_width', 1);
$br = (int) AIEO_DMM_Settings::get('navigator_border_radius', 12);
$bc = (string) AIEO_DMM_Settings::get('navigator_border_color', '');
$bg = (string) AIEO_DMM_Settings::get('navigator_bg_color', '');
$fg = (string) AIEO_DMM_Settings::get('navigator_fg_color', '');
$acc = (string) AIEO_DMM_Settings::get('navigator_accent_color', '');
$pad = (int) AIEO_DMM_Settings::get('navigator_padding', 16);
$pairs = array(
'--aieo-nav-border-width:' . $bw . 'px',
'--aieo-nav-border-radius:' . $br . 'px',
'--aieo-nav-padding:' . $pad . 'px',
);
if ($bc !== '' && strtolower($bc) !== '#e5e7eb') { $pairs[] = '--aieo-nav-border-color:' . $bc; }
if ($bg !== '') { $pairs[] = '--aieo-nav-bg:' . $bg; }
if ($fg !== '') { $pairs[] = '--aieo-nav-fg:' . $fg; }
if ($acc !== '') { $pairs[] = '--aieo-nav-accent:' . $acc; }
$sel_bg = (string) AIEO_DMM_Settings::get('navigator_selection_bg', '');
$hov_bg = (string) AIEO_DMM_Settings::get('navigator_hover_bg', '');
if ($sel_bg !== '') { $pairs[] = '--aieo-nav-selection-bg:' . $sel_bg; $pairs[] = '--aieo-nav-selection-fg:#ffffff'; }
if ($hov_bg !== '') { $pairs[] = '--aieo-nav-hover-bg:' . $hov_bg; }
$count_bg = (string) AIEO_DMM_Settings::get('navigator_count_bg', '');
$count_fg = (string) AIEO_DMM_Settings::get('navigator_count_fg', '');
if ($count_bg !== '') { $pairs[] = '--aieo-nav-count-bg:' . $count_bg; }
if ($count_fg !== '') { $pairs[] = '--aieo-nav-count-fg:' . $count_fg; }
return implode(';', $pairs);
}
private function mobile_visibility_css() {
$hide_right = (bool) AIEO_DMM_Settings::get('navigator_mobile_hide_right_pane', false);
$hide_needs = (bool) AIEO_DMM_Settings::get('navigator_mobile_hide_top_needs', false);
$hide_brand = (bool) AIEO_DMM_Settings::get('navigator_mobile_hide_top_brands', false);
$bp_px = (int) AIEO_DMM_Settings::get('navigator_mobile_breakpoint', 768);
if ($bp_px < 320 || $bp_px > 1280) { $bp_px = 768; }
$rules = array();
if ($hide_right) {
$rules[] = '.aieo-navigator-body .aieo-navigator-right{display:none !important;}';
$rules[] = '.aieo-navigator-body{grid-template-columns:1fr !important;}';
}
if ($hide_needs) {
$rules[] = '.aieo-nav-highlight-section[data-section="needs"]{display:none !important;}';
}
if ($hide_brand) {
$rules[] = '.aieo-nav-highlight-section[data-section="brands"]{display:none !important;}';
}
if (empty($rules)) { return ''; }
return '@media (max-width:' . $bp_px . 'px){' . implode('', $rules) . '}';
}
public function render_modal_shell() {
if (AIEO_Device::is_mobile()) {
}
$style = $this->shell_style_vars();
$count_pill = (AIEO_DMM_Settings::get('navigator_count_style', 'plain') === 'pill')
|| ('' !== (string) AIEO_DMM_Settings::get('navigator_count_bg', ''))
|| ('' !== (string) AIEO_DMM_Settings::get('navigator_count_fg', ''));
$mobile_css = $this->mobile_visibility_css();
if ($mobile_css !== '') {
echo '';
}
?>
$modules);
if (isset($_POST['excluded_terms'])) {
$patch['navigator_excluded_terms'] = sanitize_textarea_field( wp_unslash( (string) $_POST['excluded_terms'] ) );
}
if (isset($_POST['daily_enabled'])) {
$patch['navigator_daily_enabled'] = !empty($_POST['daily_enabled']);
}
if (isset($_POST['daily_time'])) {
$t = sanitize_text_field(wp_unslash($_POST['daily_time']));
if (preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $t)) {
$patch['navigator_daily_time'] = $t;
}
}
if (isset($_POST['blog_parent_slug'])) {
$patch['navigator_blog_parent_slug'] = sanitize_title(wp_unslash($_POST['blog_parent_slug']));
}
if (isset($_POST['order_metric'])) {
$om = sanitize_key(wp_unslash($_POST['order_metric']));
$patch['navigator_order_metric'] = in_array($om, array('turnover', 'units'), true) ? $om : 'turnover';
}
if (isset($_POST['needs_scoped'])) {
$patch['navigator_needs_scoped'] = !empty($_POST['needs_scoped']) ? 1 : 0;
}
if (isset($_POST['groups_config'])) {
$raw = wp_unslash((string) sanitize_text_field( wp_unslash( $_POST['groups_config'] ) ));
$rows = json_decode($raw, true);
if (is_array($rows)) {
$known = array();
foreach (self::group_catalog() as $g) { $known[$g['slug']] = true; }
$clean = array();
$seen = array();
foreach ($rows as $r) {
if (!is_array($r) || empty($r['slug'])) { continue; }
$slug = sanitize_key($r['slug']);
$place = isset($r['placement']) ? sanitize_key($r['placement']) : 'main';
if (!isset($known[$slug]) || isset($seen[$slug])) { continue; }
if (!in_array($place, array('main', 'rail', 'off'), true)) { $place = 'main'; }
$clean[] = array('slug' => $slug, 'placement' => $place);
$seen[$slug] = true;
}
$patch['navigator_groups_config'] = $clean;
}
}
$color_keys = array(
'border_color' => 'navigator_border_color',
'bg_color' => 'navigator_bg_color',
'fg_color' => 'navigator_fg_color',
'accent_color' => 'navigator_accent_color',
'selection_bg' => 'navigator_selection_bg',
'hover_bg' => 'navigator_hover_bg',
'count_bg' => 'navigator_count_bg',
'count_fg' => 'navigator_count_fg',
);
foreach ($color_keys as $post_key => $opt_key) {
if (!isset($_POST[$post_key])) { continue; }
$raw_val = trim((string) wp_unslash($_POST[$post_key]));
if ($raw_val === '') {
$patch[$opt_key] = '';
continue;
}
$val = class_exists('AIEO_Admin_Color') ? AIEO_Admin_Color::sanitize($raw_val) : (string) sanitize_hex_color($raw_val);
$patch[$opt_key] = ($val !== '') ? $val : (string) AIEO_DMM_Settings::get($opt_key, '');
}
if (isset($_POST['count_style'])) {
$cs = sanitize_key(wp_unslash((string) $_POST['count_style']));
$patch['navigator_count_style'] = ($cs === 'pill') ? 'pill' : 'plain';
}
if (isset($_POST['border_width'])) { $patch['navigator_border_width'] = max(0, min(20, (int) $_POST['border_width'])); }
if (isset($_POST['border_radius'])) { $patch['navigator_border_radius'] = max(0, min(64, (int) $_POST['border_radius'])); }
if (isset($_POST['padding'])) { $patch['navigator_padding'] = max(0, min(64, (int) $_POST['padding'])); }
if (!empty($_POST['mobile_tabs_form'])) {
$tabs_in = isset($_POST['mobile_tabs']) && is_array($_POST['mobile_tabs'])
? array_map('sanitize_key', wp_unslash($_POST['mobile_tabs']))
: array();
$allowed = array('categories', 'brands', 'needs', 'filters');
$tabs_out = array_values(array_intersect($allowed, $tabs_in));
if (!in_array('categories', $tabs_out, true)) { array_unshift($tabs_out, 'categories'); }
$patch['navigator_mobile_tabs'] = implode(',', $tabs_out);
}
if (isset($_POST['mobile_hide_right_pane']) || isset($_POST['mobile_visibility_form'])) {
$patch['navigator_mobile_hide_right_pane'] = !empty($_POST['mobile_hide_right_pane']) ? 1 : 0;
}
if (isset($_POST['mobile_hide_top_needs']) || isset($_POST['mobile_visibility_form'])) {
$patch['navigator_mobile_hide_top_needs'] = !empty($_POST['mobile_hide_top_needs']) ? 1 : 0;
}
if (isset($_POST['mobile_hide_top_brands']) || isset($_POST['mobile_visibility_form'])) {
$patch['navigator_mobile_hide_top_brands'] = !empty($_POST['mobile_hide_top_brands']) ? 1 : 0;
}
if (isset($_POST['mobile_breakpoint'])) {
$patch['navigator_mobile_breakpoint'] = max(320, min(1280, (int) $_POST['mobile_breakpoint']));
}
AIEO_DMM_Settings::update($patch);
$this->ensure_cron_scheduled();
$this->generate_json();
wp_send_json_success(array(
'enabled' => $enabled ? 1 : 0,
'next_run' => wp_next_scheduled(self::CRON_HOOK),
));
}
public function ajax_regenerate() {
AIEO_DMM::verify_ajax_request();
if (isset($_POST['excluded_terms'])) {
AIEO_DMM_Settings::update(array(
'navigator_excluded_terms' => sanitize_textarea_field( wp_unslash( (string) $_POST['excluded_terms'] ) ),
));
}
global $wp_filter;
$saved_clauses = isset($wp_filter['terms_clauses']) ? $wp_filter['terms_clauses'] : null;
$saved_pre = isset($wp_filter['pre_get_terms']) ? $wp_filter['pre_get_terms'] : null;
if ($saved_clauses !== null) { unset($wp_filter['terms_clauses']); }
if ($saved_pre !== null) { unset($wp_filter['pre_get_terms']); }
$ok = $this->generate_json(true);
if ($saved_clauses !== null) { $wp_filter['terms_clauses'] = $saved_clauses; }
if ($saved_pre !== null) { $wp_filter['pre_get_terms'] = $saved_pre; }
if (!$ok) {
$abort = get_transient('aieo_dmm_navigator_last_abort');
if ($abort && !empty($abort['missing'])) {
wp_send_json_error(array(
'message' => sprintf(
__('Regeneration aborted (existing JSON preserved). These spine groups came back empty: %s. The on-disk file is unchanged. Try `wp eval \'AIEO_DMM::instance()->get_module("navigator")->generate_json();\'` from CLI to bypass admin-side query filters.', 'ai-eshop-optimizer'),
implode(', ', $abort['missing'])
),
));
return;
}
wp_send_json_error(array('message' => __('Regeneration failed. Check error log.', 'ai-eshop-optimizer')));
}
delete_transient('aieo_dmm_navigator_last_abort');
wp_send_json_success(array(
'version' => get_option('aieo_dmm_navigator_version', '1.0.0'),
'generated_at' => get_option('aieo_dmm_navigator_generated_at', 0),
));
}
public function enqueue_admin_assets() {
}
public function render_subtab() {
$enabled = $this->is_enabled();
$excluded = (string) AIEO_DMM_Settings::get('navigator_excluded_terms', '');
$version = (string) get_option('aieo_dmm_navigator_version', '1.0.0');
$generated_at = (int) get_option('aieo_dmm_navigator_generated_at', 0);
$upload = wp_upload_dir();
$json_path = trailingslashit($upload['basedir']) . 'aieo/navigator.json';
$exists = file_exists($json_path);
$size = $exists ? size_format(filesize($json_path)) : '—';
$daily_enabled = $this->daily_enabled();
$daily_time = $this->daily_time();
$next_run_ts = wp_next_scheduled(self::CRON_HOOK);
$next_run_label = $next_run_ts
? wp_date(get_option('date_format') . ' ' . get_option('time_format'), $next_run_ts)
: __('—', 'ai-eshop-optimizer');
$catalog = self::group_catalog();
$catalog_labels = array();
foreach ($catalog as $g) { $catalog_labels[$g['slug']] = $g['label']; }
$groups_config = $this->resolve_groups_config();
$blog_parent_slug = (string) AIEO_DMM_Settings::get('navigator_blog_parent_slug', 'blog');
$order_metric = (string) AIEO_DMM_Settings::get('navigator_order_metric', 'turnover');
$needs_scoped = (bool) AIEO_DMM_Settings::get('navigator_needs_scoped', false);
$bw = (int) AIEO_DMM_Settings::get('navigator_border_width', 1);
$br = (int) AIEO_DMM_Settings::get('navigator_border_radius', 12);
$pad = (int) AIEO_DMM_Settings::get('navigator_padding', 16);
$bc = (string) AIEO_DMM_Settings::get('navigator_border_color', '#e5e7eb');
$bg = (string) AIEO_DMM_Settings::get('navigator_bg_color', '');
$fg = (string) AIEO_DMM_Settings::get('navigator_fg_color', '');
$acc = (string) AIEO_DMM_Settings::get('navigator_accent_color', '');
$selbg = (string) AIEO_DMM_Settings::get('navigator_selection_bg', '');
$hovbg = (string) AIEO_DMM_Settings::get('navigator_hover_bg', '');
$count_style = (string) AIEO_DMM_Settings::get('navigator_count_style', 'plain');
$count_bg = (string) AIEO_DMM_Settings::get('navigator_count_bg', '');
$count_fg = (string) AIEO_DMM_Settings::get('navigator_count_fg', '');
?>
2));
list($open, $close) = AIEO_DMM_UI::card(array(
'anchor' => 'navigator-status-regeneration',
'class' => 'aieo-card-navigator',
'icon' => 'refresh-cw',
'title' => __('Status and regeneration', 'ai-eshop-optimizer'),
'description' => __('Ctrl+K / ⌘K command palette that surfaces all categories, brands, needs, pages, posts, and functional shortcuts. JSON is auto-regenerated whenever a watched content type changes.', 'ai-eshop-optimizer'),
'col' => 1,
));
echo $open;
$json_html = $exists
? 'wp-content/uploads/aieo/navigator.json ' . \Rooster\Admin\States::status('active') . ' ' . esc_html($size) . '
'
. sprintf(esc_html__('Version %1$s — generated %2$s ago', 'ai-eshop-optimizer'), esc_html($version), esc_html(human_time_diff($generated_at)))
: \Rooster\Admin\States::status('not-run', array('label' => __('Not yet generated', 'ai-eshop-optimizer')));
echo '
'
. $row(array(
'id' => 'aieo-navigator-enabled',
'label' => __('Status', 'ai-eshop-optimizer'),
'control' => \Rooster\Admin\Ui::toggle('', $enabled, array('id' => 'aieo-navigator-enabled', 'label' => __('Enabled', 'ai-eshop-optimizer'))),
))
. $row(array(
'group' => true,
'label' => __('JSON file', 'ai-eshop-optimizer'),
'control' => '
' . $json_html . '
',
))
. $row(array(
'id' => 'aieo-navigator-daily-enabled',
'label' => __('Daily auto-regenerate', 'ai-eshop-optimizer'),
'control' => \Rooster\Admin\Ui::toggle('', $daily_enabled, array('id' => 'aieo-navigator-daily-enabled', 'label' => __('Enabled', 'ai-eshop-optimizer'))),
))
. $row(array(
'id' => 'aieo-navigator-daily-time',
'label' => __('Time of day', 'ai-eshop-optimizer'),
'control' => \Rooster\Admin\Ui::input('', (string) $daily_time, array('id' => 'aieo-navigator-daily-time', 'type' => 'time', 'step' => 60)),
'help_html' => sprintf(esc_html__('Site timezone. Next scheduled run: %s', 'ai-eshop-optimizer'), '
' . esc_html($next_run_label) . ''),
))
. $row(array(
'group' => true,
'label' => __('Manual regenerate', 'ai-eshop-optimizer'),
'control' => \Rooster\Admin\Ui::button(__('Regenerate Now', 'ai-eshop-optimizer'), array('variant' => 'primary', 'attrs' => array('id' => 'aieo-navigator-regen')))
. '
',
))
. '
';
echo $close;
list($open, $close) = AIEO_DMM_UI::card(array(
'anchor' => 'navigator-content-ordering',
'class' => 'aieo-card-navigator',
'icon' => 'grip-vertical',
'title' => __('Content and ordering', 'ai-eshop-optimizer'),
'description' => __('Which groups the store navigator shows and in what order, in its main column or its right rail.', 'ai-eshop-optimizer'),
'col' => 2,
));
echo $open;
echo \Rooster\Admin\Guidance::text(__('Drag to reorder. "Main" = primary list column. "Right rail" = compact side strip (good for Tools / shortcuts). "Off" hides the group entirely.', 'ai-eshop-optimizer'), 'guidance', array('about' => __('Group order and placement', 'ai-eshop-optimizer')));
$aieo_nav_rows = array();
foreach ($groups_config as $cfg) {
$label = isset($catalog_labels[$cfg['slug']]) ? $catalog_labels[$cfg['slug']] : $cfg['slug'];
$aieo_nav_rows[] = array(
'_id' => (string) $cfg['slug'],
'_name' => (string) $label,
'_attrs' => array('data-slug' => (string) $cfg['slug']),
'group' => (string) $label,
'placement' => array('html' => \Rooster\Admin\Ui::select(
'',
array('main' => __('Main', 'ai-eshop-optimizer'), 'rail' => __('Right rail', 'ai-eshop-optimizer'), 'off' => __('Off', 'ai-eshop-optimizer')),
(string) $cfg['placement'],
array(
'id' => 'aieo-nav-placement-' . sanitize_key((string) $cfg['slug']),
'aria-label' => sprintf(__('Placement of %s', 'ai-eshop-optimizer'), $label),
'attrs' => array('data-aieo-nav-placement' => ''),
)
)),
);
}
echo '';
echo \Rooster\Admin\Ui::table(
array(
array('key' => 'group', 'label' => __('Group', 'ai-eshop-optimizer'), 'type' => 'text'),
array('key' => 'placement', 'label' => __('Placement', 'ai-eshop-optimizer'), 'type' => 'html'),
),
$aieo_nav_rows,
array(
'id' => 'aieo-nav-groups',
'caption' => __('Group order and placement', 'ai-eshop-optimizer'),
'move' => array('label' => __('Group order and placement', 'ai-eshop-optimizer')),
)
);
echo '
';
echo ''
. $row(array(
'id' => 'aieo-navigator-excluded',
'label' => __('Hidden terms', 'ai-eshop-optimizer'),
'control' => \Rooster\Admin\Ui::textarea('', $excluded, array('id' => 'aieo-navigator-excluded', 'rows' => 3, 'code' => true, 'placeholder' => '42, 1234, product_brand:99')),
'help' => __('Comma- or whitespace-separated term IDs to omit from the navigator (and all their descendants). Bare integers default to product_cat. For other taxonomies use `taxonomy:id`, e.g. `product_brand:99`.', 'ai-eshop-optimizer'),
'help_id' => 'aieo-navigator-excluded-help',
))
. $row(array(
'id' => 'aieo-nav-blog-parent',
'label' => __('Blog parent slug', 'ai-eshop-optimizer'),
'control' => \Rooster\Admin\Ui::input('', $blog_parent_slug, array('id' => 'aieo-nav-blog-parent', 'medium' => true)),
'help' => __('The Blog Categories group lists only this term\'s direct children, skipping the parent itself and "Uncategorized". Default: blog. Leave empty to show every category at the WP root.', 'ai-eshop-optimizer'),
))
. $row(array(
'id' => 'aieo-nav-order-metric',
'label' => __('Order metric', 'ai-eshop-optimizer'),
'control' => \Rooster\Admin\Ui::select('', array('turnover' => __('Turnover (revenue) — default', 'ai-eshop-optimizer'), 'units' => __('Unit sales (quantity sold)', 'ai-eshop-optimizer')), $order_metric, array('id' => 'aieo-nav-order-metric')),
'help' => __('Which metric ranks subcategories + brands in the navigator. "Turnover" puts the highest-revenue items first (good for surfacing premium ranges); "Unit sales" puts the most-bought items first (good for surfacing fast-moving SKUs). Both columns are populated nightly by the popularity aggregator.', 'ai-eshop-optimizer'),
))
. $row(array(
'id' => 'aieo-nav-needs-scoped',
'label' => __('Needs scope', 'ai-eshop-optimizer'),
'control' => \Rooster\Admin\Ui::toggle('', $needs_scoped, array('id' => 'aieo-nav-needs-scoped', 'label' => __('Scope needs to the current product category', 'ai-eshop-optimizer'))),
'help' => __('When on: inside a product category (or on a product page) the Needs group shows only the needs that have products in that category and its subcategories, and clicking a need opens its archive limited to that category subtree. Off pages with no category (home, search) keep the full global needs list. When off, the navigator shows the global needs list everywhere — the original behaviour. Changing this rebuilds the navigator file.', 'ai-eshop-optimizer'),
))
. '
';
echo $close;
list($open, $close) = AIEO_DMM_UI::card(array(
'anchor' => 'navigator-mobile',
'class' => 'aieo-card-navigator',
'icon' => 'app-window',
'title' => __('Mobile', 'ai-eshop-optimizer'),
'description' => __('Which tabs the store navigator shows on phones, and whether its second pane is hidden there.', 'ai-eshop-optimizer'),
'col' => 1,
));
echo $open;
$mb_tabs = $this->mobile_tabs_config();
$mb_hide_right = (bool) AIEO_DMM_Settings::get('navigator_mobile_hide_right_pane', false);
$mb_hide_needs = (bool) AIEO_DMM_Settings::get('navigator_mobile_hide_top_needs', false);
$mb_hide_brands = (bool) AIEO_DMM_Settings::get('navigator_mobile_hide_top_brands', false);
$mb_breakpoint = (int) AIEO_DMM_Settings::get('navigator_mobile_breakpoint', 768);
$mb_checks = \Rooster\Admin\Ui::checks('mobile_tabs', array('brands' => __('Brands', 'ai-eshop-optimizer'), 'needs' => __('Wishes', 'ai-eshop-optimizer'), 'filters' => __('Filters', 'ai-eshop-optimizer')), $mb_tabs, array('inline' => true));
$mb_at = strpos($mb_checks, '>') + 1;
$mb_checks = substr($mb_checks, 0, $mb_at)
. '' . esc_html__('Categories', 'ai-eshop-optimizer')
. '' . esc_html__('(always on: the first tab)', 'ai-eshop-optimizer') . ' '
. substr($mb_checks, $mb_at);
$mb_switch = static function ($name, $id, $on, $label) {
return \Rooster\Admin\Ui::toggle($name, $on, array('id' => $id, 'hidden' => false, 'label' => $label));
};
echo ''
. $row(array(
'group' => true,
'id' => 'aieo-nav-mobile-tabs',
'label' => __('Mobile tabs', 'ai-eshop-optimizer'),
'control' => '
' . $mb_checks,
'help' => __('On phones the panel opens with these as tabs across the top; Categories is always the first tab and the active tab hides its own section header. Tab order follows the "Group order and placement" list above: drag Filters (FiboFilters) higher there to place its tab right after Categories. A group left unticked keeps its normal place in the list, and a tab with nothing to show on a page (no filters, no brands) simply does not render there. Desktop is unaffected.', 'ai-eshop-optimizer'),
))
. $row(array(
'group' => true,
'id' => 'aieo-nav-mobile-visibility',
'label' => __('Mobile visibility', 'ai-eshop-optimizer'),
'control' => '
'
. $mb_switch('mobile_hide_right_pane', 'aieo-nav-mobile-hide-right', $mb_hide_right, __('Hide the second pane (item details + Top Needs + Top Brands)', 'ai-eshop-optimizer'))
. $mb_switch('mobile_hide_top_needs', 'aieo-nav-mobile-hide-needs', $mb_hide_needs, __('Hide "Top Needs" highlights only (keeps the rest of the second pane)', 'ai-eshop-optimizer'))
. $mb_switch('mobile_hide_top_brands', 'aieo-nav-mobile-hide-brands', $mb_hide_brands, __('Hide "Top Brands" highlights only', 'ai-eshop-optimizer'))
. '
',
))
. $row(array(
'id' => 'aieo-nav-mobile-breakpoint',
'label' => __('Mobile breakpoint', 'ai-eshop-optimizer'),
'control' => \Rooster\Admin\Ui::input('mobile_breakpoint', (string) (int) $mb_breakpoint, array('id' => 'aieo-nav-mobile-breakpoint', 'type' => 'number', 'xs' => true, 'unit' => 'px', 'min' => 320, 'max' => 1280, 'step' => 1)),
'help' => __('Rules apply via @media (max-width: …px). Default 768px matches the standard mobile/tablet boundary used elsewhere in the navigator CSS. Set 0 / blank values stay off — default is everything visible on mobile.', 'ai-eshop-optimizer'),
))
. '
';
echo $close;
rooster_admin_band_close();
list($open, $close) = AIEO_DMM_UI::card(array(
'anchor' => 'navigator-appearance',
'class' => 'aieo-card-navigator',
'icon' => 'palette',
'title' => __('Navigator colours and sizes', 'ai-eshop-optimizer'),
'description' => __('Colours for background, text, links, border, selection, hover and the count pill, whether counts show plain or as a pill, plus border width, radius and padding.', 'ai-eshop-optimizer'),
));
echo $open;
$nav_colour_rows = array(
array('id' => 'bg_color', 'label' => __('Background', 'ai-eshop-optimizer'), 'name' => 'bg_color', 'value' => $bg, 'kind' => 'color', 'default' => ''),
array('id' => 'fg_color', 'label' => __('Text', 'ai-eshop-optimizer'), 'name' => 'fg_color', 'value' => $fg, 'kind' => 'color', 'default' => ''),
array('id' => 'accent_color', 'label' => __('Accent (links)', 'ai-eshop-optimizer'), 'name' => 'accent_color', 'value' => $acc, 'kind' => 'color', 'default' => ''),
array('id' => 'border_color', 'label' => __('Border color', 'ai-eshop-optimizer'), 'name' => 'border_color', 'value' => $bc, 'kind' => 'color', 'default' => '#e5e7eb'),
array('id' => 'selection_bg', 'label' => __('Selection background', 'ai-eshop-optimizer'), 'name' => 'selection_bg', 'value' => $selbg, 'kind' => 'color', 'default' => ''),
array('id' => 'hover_bg', 'label' => __('Hover background', 'ai-eshop-optimizer'), 'name' => 'hover_bg', 'value' => $hovbg, 'kind' => 'color', 'default' => ''),
array('id' => 'count_style', 'label' => __('Category counts', 'ai-eshop-optimizer'), 'name' => 'count_style', 'value' => $count_style, 'kind' => 'count_style'),
array('id' => 'count_bg', 'label' => __('Count pill background', 'ai-eshop-optimizer'), 'name' => 'count_bg', 'value' => $count_bg, 'kind' => 'color', 'default' => ''),
array('id' => 'count_fg', 'label' => __('Count pill text', 'ai-eshop-optimizer'), 'name' => 'count_fg', 'value' => $count_fg, 'kind' => 'color', 'default' => ''),
array('id' => 'border_width', 'label' => __('Border width', 'ai-eshop-optimizer'), 'name' => 'border_width', 'value' => (int) $bw, 'kind' => 'number', 'min' => 0, 'max' => 20),
array('id' => 'border_radius', 'label' => __('Border radius', 'ai-eshop-optimizer'), 'name' => 'border_radius', 'value' => (int) $br, 'kind' => 'number', 'min' => 0, 'max' => 64),
array('id' => 'padding', 'label' => __('Inner padding', 'ai-eshop-optimizer'), 'name' => 'padding', 'value' => (int) $pad, 'kind' => 'number', 'min' => 0, 'max' => 64),
);
$nav_rows = '';
foreach ($nav_colour_rows as $nav_r) {
$nav_id = 'aieo-nav-' . $nav_r['id'];
if ('count_style' === $nav_r['kind']) {
$nav_control = \Rooster\Admin\Ui::select($nav_r['name'], array('plain' => __('Plain numbers', 'ai-eshop-optimizer'), 'pill' => __('Pill (badge)', 'ai-eshop-optimizer')), (string) $nav_r['value'], array('id' => $nav_id));
} elseif ('number' === $nav_r['kind']) {
$nav_control = \Rooster\Admin\Ui::input($nav_r['name'], (string) $nav_r['value'], array('id' => $nav_id, 'type' => 'number', 'xs' => true, 'unit' => 'px', 'min' => (int) $nav_r['min'], 'max' => (int) $nav_r['max'], 'step' => 1, 'class' => 'aieo-nav-num'));
} else {
$nav_control = \Rooster\Admin\Fields::color($nav_r['name'], (string) $nav_r['value'], array('id' => $nav_id, 'alpha' => true, 'default' => (string) $nav_r['default'], 'label' => (string) $nav_r['label']));
}
$nav_rows .= $row(array('id' => $nav_id, 'label' => $nav_r['label'], 'control' => $nav_control));
}
echo '' . $nav_rows . '
';
echo \Rooster\Admin\Guidance::text(__('Color fields left empty fall back to theme tokens (--wp--preset--color--bg / fg / accent / border). Hard-coded values override the theme.', 'ai-eshop-optimizer'), 'guidance', array('about' => __('Navigator colours and sizes', 'ai-eshop-optimizer')));
echo $close;
?>
render_cache_config_footer(); ?>
'navigator-cache-configuration',
'icon' => 'gauge',
'title' => __('Cache configuration', 'ai-eshop-optimizer'),
'description' => __('Settings and notes that keep a CDN or page cache from serving customers an old copy of the navigator data.', 'ai-eshop-optimizer'),
));
echo $open;
?>
%s is regenerated on every term/post/menu change. If a CDN or page-cache holds the old copy, customers see stale categories and broken hierarchical URLs. WP Rocket is excluded automatically; Cloudflare and Nginx need manual setup below.', 'ai-eshop-optimizer'),
esc_html($json_rel)
),
array('code' => array(), 'strong' => array())
);
echo \Rooster\Admin\Guidance::text(wp_strip_all_tags($aieo_nav_cache), 'guidance', array('html' => $aieo_nav_cache));
?>
true, 'class' => 'aieo-nav-notes'));
ob_start();
?>
—
: URI Path contains
: Bypass cache
—
: *your-domain.com*
: Cache Level: Bypass
'aieo-nav-notes'));
ob_start();
?>
location ~* /wp-content/uploads/aieo/navigator\.json$ {
add_header Cache-Control "no-store, must-revalidate, max-age=0" always;
add_header Pragma "no-cache" always;
expires off;
etag off;
if_modified_since off;
}
nginx -t && systemctl reload nginx
'aieo-nav-notes'));
ob_start();
?>
fetch('?_=' + Date.now())
.then(r => console.log(
'cf-cache-status:', r.headers.get('cf-cache-status'),
'last-modified:', r.headers.get('last-modified')
));
cf-cache-status: BYPASS (or DYNAMIC), and last-modified matching the most recent term/post/menu change. If last-modified is older than your last edit, the cache layer is still serving stale data — recheck the CF rule and the Nginx location block.', 'ai-eshop-optimizer'),
array('code' => array())
);
?>
'aieo-nav-notes'));
echo $close;
}
}