prefix . 'aieo_ratings'; } public static function stats_table() { global $wpdb; return $wpdb->prefix . 'aieo_rating_stats'; } public function maybe_install() { if (get_option(self::SCHEMA_OPTION, '') === self::SCHEMA_VERSION) { if (get_option(self::EXTERNAL_READY_OPTION, '0') !== '1' && false === get_transient(self::SCHEMA_RETRY_TRANSIENT) && (!class_exists('AIEO_Schema_Lock') || AIEO_Schema_Lock::acquire(self::SCHEMA_OPTION))) { set_transient(self::SCHEMA_RETRY_TRANSIENT, 1, DAY_IN_SECONDS); self::install(); } return; } if (class_exists('AIEO_Schema_Lock') && !AIEO_Schema_Lock::acquire(self::SCHEMA_OPTION)) { return; } self::install(); update_option(self::SCHEMA_OPTION, self::SCHEMA_VERSION, false); } public static function install() { global $wpdb; $events = self::events_table(); $stats = self::stats_table(); $charset = function_exists('aieo_get_charset_collate') ? aieo_get_charset_collate() : $wpdb->get_charset_collate(); $sql_events = "CREATE TABLE {$events} ( id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, object_type varchar(20) NOT NULL DEFAULT 'product', product_id bigint(20) UNSIGNED NOT NULL, user_id bigint(20) UNSIGNED NOT NULL DEFAULT 0, session_token varchar(64) NOT NULL DEFAULT '', rating tinyint(4) NOT NULL DEFAULT 0, previous_rating tinyint(4) NOT NULL DEFAULT 0, source varchar(32) NOT NULL DEFAULT '', external_id varchar(64) DEFAULT NULL, user_agent_hash char(16) NOT NULL DEFAULT '', ip_hash char(16) NOT NULL DEFAULT '', created_at datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uniq_external (source, external_id), KEY idx_product (object_type, product_id, created_at), KEY idx_user (user_id, created_at), KEY idx_session (session_token, created_at) ) {$charset};"; $sql_stats = "CREATE TABLE {$stats} ( object_type varchar(20) NOT NULL DEFAULT 'product', product_id bigint(20) UNSIGNED NOT NULL, count_total int(10) UNSIGNED NOT NULL DEFAULT 0, sum_total int(10) UNSIGNED NOT NULL DEFAULT 0, n1 int(10) UNSIGNED NOT NULL DEFAULT 0, n2 int(10) UNSIGNED NOT NULL DEFAULT 0, n3 int(10) UNSIGNED NOT NULL DEFAULT 0, n4 int(10) UNSIGNED NOT NULL DEFAULT 0, n5 int(10) UNSIGNED NOT NULL DEFAULT 0, last_voted_at datetime NULL, PRIMARY KEY (object_type, product_id), KEY idx_count (count_total) ) {$charset};"; if (!function_exists('dbDelta')) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } dbDelta($sql_events); dbDelta($sql_stats); self::ensure_v2_object_type_columns(); $ready = self::ensure_v3_external_id_column(); update_option(self::EXTERNAL_READY_OPTION, $ready ? '1' : '0', true); } private static function ensure_v3_external_id_column() { global $wpdb; $events = self::events_table(); $prev_suppress = isset($wpdb->suppress_errors) ? (bool) $wpdb->suppress_errors : false; $wpdb->suppress_errors(true); $col = $wpdb->get_var("SHOW COLUMNS FROM `{$events}` LIKE 'external_id'"); if (!$col) { $wpdb->query("ALTER TABLE `{$events}` ADD COLUMN `external_id` VARCHAR(64) DEFAULT NULL AFTER `source`"); $col = $wpdb->get_var("SHOW COLUMNS FROM `{$events}` LIKE 'external_id'"); } $has_idx = false; if ($col) { $idx = $wpdb->get_results("SHOW INDEX FROM `{$events}` WHERE Key_name = 'uniq_external'", ARRAY_A); if (empty($idx)) { $wpdb->query("ALTER TABLE `{$events}` ADD UNIQUE KEY `uniq_external` (`source`, `external_id`)"); $idx = $wpdb->get_results("SHOW INDEX FROM `{$events}` WHERE Key_name = 'uniq_external'", ARRAY_A); } if (is_array($idx) && count($idx) > 0) { $has_idx = ((int) $idx[0]['Non_unique'] === 0); } } $wpdb->suppress_errors($prev_suppress); return ($col && $has_idx); } public static function supports_external_ids() { static $ready = null; if ($ready === null) { $ready = (get_option(self::EXTERNAL_READY_OPTION, '0') === '1'); } return $ready; } private static function widget_scope_sql() { return self::supports_external_ids() ? ' AND external_id IS NULL' : ''; } private static function ensure_v2_object_type_columns() { global $wpdb; $events = self::events_table(); $stats = self::stats_table(); $prev_suppress = isset($wpdb->suppress_errors) ? (bool) $wpdb->suppress_errors : false; $wpdb->suppress_errors(true); $col = $wpdb->get_var("SHOW COLUMNS FROM `{$events}` LIKE 'object_type'"); if (!$col) { $wpdb->query("ALTER TABLE `{$events}` ADD COLUMN `object_type` VARCHAR(20) NOT NULL DEFAULT 'product' AFTER `id`"); } $idx = $wpdb->get_results("SHOW INDEX FROM `{$events}` WHERE Key_name = 'idx_product'", ARRAY_A); $needs_idx_rebuild = false; if (is_array($idx) && count($idx) > 0) { $cols_in_idx = array_map(function ($r) { return strtolower((string) $r['Column_name']); }, $idx); if (!in_array('object_type', $cols_in_idx, true)) { $needs_idx_rebuild = true; } } if ($needs_idx_rebuild) { $wpdb->query("ALTER TABLE `{$events}` DROP INDEX `idx_product`, ADD KEY `idx_product` (`object_type`, `product_id`, `created_at`)"); } $col = $wpdb->get_var("SHOW COLUMNS FROM `{$stats}` LIKE 'object_type'"); if (!$col) { $wpdb->query("ALTER TABLE `{$stats}` ADD COLUMN `object_type` VARCHAR(20) NOT NULL DEFAULT 'product' FIRST"); $wpdb->query("ALTER TABLE `{$stats}` DROP PRIMARY KEY, ADD PRIMARY KEY (`object_type`, `product_id`)"); } $wpdb->suppress_errors($prev_suppress); } public static function archive_aggregate_default() { static $default = null; if (null === $default) { $default = (wp_is_block_theme()) ? 1 : 0; } return $default; } public static function get_settings() { $stored = get_option(self::SETTINGS_OPTION, array()); if (!is_array($stored)) { $stored = array(); } return array_merge(self::defaults(), $stored); } public static function chosen_colours_css(array $settings) { $defaults = self::defaults(); $former = array('color_filled' => array('#f46767'), 'color_hover' => array('#f46767'), 'color_pre_rated' => array('#000000')); $chosen = ''; foreach (array('empty' => 'color_empty', 'filled' => 'color_filled', 'hover' => 'color_hover', 'pre-rated' => 'color_pre_rated') as $var => $key) { $value = sanitize_hex_color((string) ($settings[$key] ?? '')); if (!$value || 0 === strcasecmp($value, (string) $defaults[$key]) || in_array(strtolower($value), $former[$key] ?? array(), true)) { continue; } $chosen .= '--aieo-rating-' . $var . '-chosen:' . $value . ';'; } return '' === $chosen ? '' : ':root{' . $chosen . '}'; } public static function defaults() { return array( 'enabled' => 1, 'require_login' => 0, 'login_redirect_mode' => 'redirect', 'display_archive' => 1, 'display_single' => 1, 'archive_position' => 'under_title', 'single_position' => 'under_title', 'aggregate_only_archive' => self::archive_aggregate_default(), 'caption_format' => '%avg (%count)', 'caption_before_rated' => '', 'min_votes_for_display' => 0, 'display_full_until_rated' => 0, 'color_empty' => '#d4d4d8', 'color_filled' => '#F5B301', 'color_hover' => '#FFC93C', 'color_pre_rated' => '#F5B301', 'size' => 20, 'size_mobile' => 24, 'size_single' => 20, 'gap' => 2, 'gap_mobile' => 1, 'caption_size' => 13, 'caption_size_mobile' => 13, 'caption_size_archive' => 12, 'caption_size_archive_mobile' => 11, 'allow_clear' => 1, 'seo_schema_enabled' => 1, ); } private function get_or_create_session_token() { if (isset($_COOKIE[self::COOKIE_NAME]) && (string) $_COOKIE[self::COOKIE_NAME] !== '') { $raw = (string) wp_unslash($_COOKIE[self::COOKIE_NAME]); $token = preg_replace('/[^a-z0-9]/i', '', $raw); if ($token !== '') { return substr($token, 0, 64); } } $token = wp_generate_password(32, false, false); if (!headers_sent()) { setcookie( self::COOKIE_NAME, $token, time() + (self::COOKIE_DAYS * DAY_IN_SECONDS), COOKIEPATH ? COOKIEPATH : '/', COOKIE_DOMAIN ); } $_COOKIE[self::COOKIE_NAME] = $token; return $token; } private function read_session_token_no_create() { if (!isset($_COOKIE[self::COOKIE_NAME])) { return ''; } $raw = (string) wp_unslash($_COOKIE[self::COOKIE_NAME]); $token = preg_replace('/[^a-z0-9]/i', '', $raw); return $token === '' ? '' : substr($token, 0, 64); } private static function short_hash($value) { return substr(md5((string) $value), 0, 16); } public function ajax_save() { check_ajax_referer(self::NONCE_ACTION, 'nonce'); $object_type = isset($_POST['object_type']) ? sanitize_key((string) wp_unslash($_POST['object_type'])) : 'product'; if (!in_array($object_type, array('product', 'brand'), true)) { $object_type = 'product'; } $pid = isset($_POST['product_id']) ? absint(wp_unslash($_POST['product_id'])) : 0; $rating = isset($_POST['rating']) ? (int) wp_unslash($_POST['rating']) : -1; $source = isset($_POST['source']) ? sanitize_key((string) wp_unslash($_POST['source'])) : ''; if ($pid <= 0) { wp_send_json_error(array('message' => __('Invalid id.', 'ai-eshop-optimizer'))); } if ($rating < 0 || $rating > 5) { wp_send_json_error(array('message' => __('Invalid rating value.', 'ai-eshop-optimizer'))); } if ($object_type === 'product') { $post_type = get_post_type($pid); if ($post_type !== 'product' && $post_type !== 'product_variation') { wp_send_json_error(array('message' => __('Not a product.', 'ai-eshop-optimizer'))); } } elseif ($object_type === 'brand') { if (!term_exists($pid, 'product_brand')) { wp_send_json_error(array('message' => __('Invalid brand term ID.', 'ai-eshop-optimizer'))); } } $settings = self::get_settings(); $user_id = get_current_user_id(); if (empty($settings['enabled'])) { wp_send_json_error(array('message' => __('Ratings are disabled.', 'ai-eshop-optimizer'))); } if (!empty($settings['require_login']) && $user_id <= 0) { wp_send_json_error(array( 'message' => __('Please sign in to rate.', 'ai-eshop-optimizer'), 'login_required' => true, 'login_url' => wp_login_url(isset($_SERVER['HTTP_REFERER']) ? esc_url_raw(wp_unslash($_SERVER['HTTP_REFERER'])) : ''), )); } $token = $this->get_or_create_session_token(); $stats = $this->record_event($pid, $rating, $user_id, $token, $source, $object_type); wp_send_json_success(array_merge($stats, array( 'current_rating' => $rating, 'object_type' => $object_type, ))); } public function ajax_get() { check_ajax_referer(self::NONCE_ACTION, 'nonce'); $object_type = isset($_REQUEST['object_type']) ? sanitize_key((string) wp_unslash($_REQUEST['object_type'])) : 'product'; if (!in_array($object_type, array('product', 'brand'), true)) { $object_type = 'product'; } $pid = isset($_REQUEST['product_id']) ? absint(wp_unslash($_REQUEST['product_id'])) : 0; if ($pid <= 0) { wp_send_json_error(array('message' => __('Invalid id.', 'ai-eshop-optimizer'))); } $stats = $this->get_stats($pid, $object_type); $current = $this->get_current_rating_for_viewer($pid, get_current_user_id(), $this->read_session_token_no_create(), $object_type); wp_send_json_success(array_merge($stats, array( 'current_rating' => $current, 'object_type' => $object_type, ))); } public function record_event($product_id, $rating, $user_id, $session_token, $source = '', $object_type = 'product') { global $wpdb; $product_id = (int) $product_id; $rating = max(0, min(5, (int) $rating)); $user_id = max(0, (int) $user_id); $token = (string) $session_token; $object_type = in_array((string) $object_type, array('product', 'brand'), true) ? (string) $object_type : 'product'; $previous = $this->get_current_rating_for_viewer($product_id, $user_id, $token, $object_type); $now = current_time('mysql', true); $ip_raw = isset($_SERVER['REMOTE_ADDR']) ? (string) wp_unslash($_SERVER['REMOTE_ADDR']) : ''; $ua_raw = isset($_SERVER['HTTP_USER_AGENT']) ? (string) wp_unslash($_SERVER['HTTP_USER_AGENT']) : ''; $wpdb->insert( self::events_table(), array( 'object_type' => $object_type, 'product_id' => $product_id, 'user_id' => $user_id, 'session_token' => $token, 'rating' => $rating, 'previous_rating' => (int) $previous, 'source' => (string) $source, 'user_agent_hash' => self::short_hash($ua_raw), 'ip_hash' => self::short_hash($ip_raw), 'created_at' => $now, ), array('%s','%d','%d','%s','%d','%d','%s','%s','%s','%s') ); $count_delta = ($rating > 0 ? 1 : 0) - ($previous > 0 ? 1 : 0); $sum_delta = $rating - (int) $previous; $bucket_decr = array_fill(1, 5, 0); if ($previous > 0) { $bucket_decr[(int) $previous] = -1; } if ($rating > 0) { $bucket_decr[(int) $rating] += 1; } $stats_table = self::stats_table(); $seed_count = max(0, $count_delta); $seed_sum = max(0, $sum_delta); $seed_n = array(1 => max(0, $bucket_decr[1]), 2 => max(0, $bucket_decr[2]), 3 => max(0, $bucket_decr[3]), 4 => max(0, $bucket_decr[4]), 5 => max(0, $bucket_decr[5])); $sql = $wpdb->prepare( "INSERT INTO {$stats_table} (object_type, product_id, count_total, sum_total, n1, n2, n3, n4, n5, last_voted_at) VALUES (%s, %d, %d, %d, %d, %d, %d, %d, %d, %s) ON DUPLICATE KEY UPDATE count_total = GREATEST(0, CAST(count_total AS SIGNED) + %d), sum_total = GREATEST(0, CAST(sum_total AS SIGNED) + %d), n1 = GREATEST(0, CAST(n1 AS SIGNED) + %d), n2 = GREATEST(0, CAST(n2 AS SIGNED) + %d), n3 = GREATEST(0, CAST(n3 AS SIGNED) + %d), n4 = GREATEST(0, CAST(n4 AS SIGNED) + %d), n5 = GREATEST(0, CAST(n5 AS SIGNED) + %d), last_voted_at = VALUES(last_voted_at)", $object_type, $product_id, $seed_count, $seed_sum, $seed_n[1], $seed_n[2], $seed_n[3], $seed_n[4], $seed_n[5], $now, $count_delta, $sum_delta, $bucket_decr[1], $bucket_decr[2], $bucket_decr[3], $bucket_decr[4], $bucket_decr[5] ); $wpdb->query($sql); wp_cache_delete(self::CACHE_PREFIX . $object_type . ':' . $product_id, self::CACHE_GROUP); $fresh = $this->get_stats($product_id, $object_type); if ($object_type === 'product') { $this->write_rating_to_vitals($product_id, $fresh); } return $fresh; } private function write_rating_to_vitals($product_id, $stats) { global $wpdb; $product_id = (int) $product_id; if ($product_id <= 0) { return; } $vitals = $wpdb->prefix . 'aieo_core_product_vitals'; static $has_cols = null; if ($has_cols === null) { $has_cols = ((int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'rating_value'", $vitals ))) > 0; } if (!$has_cols) { return; } $count = isset($stats['count']) ? (int) $stats['count'] : 0; $sum = isset($stats['sum']) ? (float) $stats['sum'] : null; $avg = ($count > 0 && null !== $sum) ? round($sum / $count, 2) : null; if (null === $avg && $count > 0 && isset($stats['avg'])) { $avg = round((float) $stats['avg'], 2); } if ($avg === null) { $wpdb->query($wpdb->prepare("UPDATE `{$vitals}` SET rating_value = NULL, rating_count = %d WHERE product_id = %d", $count, $product_id)); } else { $wpdb->query($wpdb->prepare("UPDATE `{$vitals}` SET rating_value = %f, rating_count = %d WHERE product_id = %d", $avg, $count, $product_id)); } } public static function sync_all_ratings_to_vitals() { global $wpdb; $vitals = $wpdb->prefix . 'aieo_core_product_vitals'; $stats = self::stats_table(); $has_cols = ((int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = 'rating_value'", $vitals ))) > 0; if (!$has_cols) { return; } $wpdb->query( "UPDATE `{$vitals}` v JOIN ( SELECT product_id, ROUND(sum_total / count_total, 2) AS avg_val, count_total FROM `{$stats}` WHERE object_type = 'product' AND count_total > 0 ) s ON s.product_id = v.product_id SET v.rating_value = s.avg_val, v.rating_count = s.count_total" ); } public function record_external_event($source, $external_id, $product_id, $rating, $user_id, $created_at = '', $object_type = 'product') { global $wpdb; if (!self::supports_external_ids()) { return false; } $source = substr((string) $source, 0, 32); $external_id = substr((string) $external_id, 0, 64); $product_id = (int) $product_id; $rating = (int) $rating; $user_id = max(0, (int) $user_id); $object_type = in_array((string) $object_type, array('product', 'brand'), true) ? (string) $object_type : 'product'; if ($source === '' || $external_id === '' || $product_id <= 0 || $rating < 1 || $rating > 5) { return false; } if ($created_at === '' || $created_at === '0000-00-00 00:00:00') { $created_at = current_time('mysql', true); } $events = self::events_table(); $prev_pid = (int) $wpdb->get_var($wpdb->prepare( "SELECT product_id FROM {$events} WHERE source = %s AND external_id = %s LIMIT 1", $source, $external_id )); $ok = $this->upsert_external_rows($source, $object_type, array(array( 'external_id' => $external_id, 'product_id' => $product_id, 'rating' => $rating, 'user_id' => $user_id, 'created_at' => $created_at, ))); if (!$ok) { return false; } $targets = array($product_id); if ($prev_pid > 0 && $prev_pid !== $product_id) { $targets[] = $prev_pid; } $this->recompute_stats($targets, $object_type); return true; } public function record_external_events_bulk($source, array $rows, $object_type = 'product') { global $wpdb; if (!self::supports_external_ids() || empty($rows)) { return 0; } $source = substr((string) $source, 0, 32); $object_type = in_array((string) $object_type, array('product', 'brand'), true) ? (string) $object_type : 'product'; if ($source === '') { return 0; } $clean = array(); $ext_ids = array(); $products = array(); foreach ($rows as $r) { $eid = isset($r['external_id']) ? substr((string) $r['external_id'], 0, 64) : ''; $pid = isset($r['product_id']) ? (int) $r['product_id'] : 0; $rating = isset($r['rating']) ? (int) $r['rating'] : 0; if ($eid === '' || $pid <= 0 || $rating < 1 || $rating > 5) { continue; } $created = isset($r['created_at']) ? (string) $r['created_at'] : ''; if ($created === '' || $created === '0000-00-00 00:00:00') { $created = current_time('mysql', true); } $clean[] = array( 'external_id' => $eid, 'product_id' => $pid, 'rating' => $rating, 'user_id' => isset($r['user_id']) ? max(0, (int) $r['user_id']) : 0, 'created_at' => $created, ); $ext_ids[] = $eid; $products[$pid] = true; } if (empty($clean)) { return 0; } $events = self::events_table(); foreach (array_chunk($ext_ids, 500) as $chunk) { $ph = implode(',', array_fill(0, count($chunk), '%s')); $args = array_merge(array($source), $chunk); $olds = $wpdb->get_col($wpdb->prepare( "SELECT DISTINCT product_id FROM {$events} WHERE source = %s AND external_id IN ({$ph})", $args )); foreach ((array) $olds as $o) { $products[(int) $o] = true; } } $written = 0; foreach (array_chunk($clean, 500) as $chunk) { if ($this->upsert_external_rows($source, $object_type, $chunk)) { $written += count($chunk); } } $this->recompute_stats(array_keys($products), $object_type); return $written; } public function withdraw_external_events($source, array $external_ids, $object_type = 'product') { global $wpdb; if (!self::supports_external_ids() || empty($external_ids)) { return 0; } $source = substr((string) $source, 0, 32); $object_type = in_array((string) $object_type, array('product', 'brand'), true) ? (string) $object_type : 'product'; if ($source === '') { return 0; } $ids = array(); foreach ($external_ids as $e) { $e = substr((string) $e, 0, 64); if ($e !== '') { $ids[$e] = true; } } $ids = array_keys($ids); if (empty($ids)) { return 0; } $events = self::events_table(); $deleted = 0; $products = array(); foreach (array_chunk($ids, 500) as $chunk) { $ph = implode(',', array_fill(0, count($chunk), '%s')); $args = array_merge(array($source), $chunk); $pids = $wpdb->get_col($wpdb->prepare( "SELECT DISTINCT product_id FROM {$events} WHERE source = %s AND external_id IN ({$ph})", $args )); foreach ((array) $pids as $p) { $products[(int) $p] = true; } if (empty($pids)) { continue; } $deleted += (int) $wpdb->query($wpdb->prepare( "DELETE FROM {$events} WHERE source = %s AND external_id IN ({$ph})", $args )); } if (!empty($products)) { $this->recompute_stats(array_keys($products), $object_type); } return $deleted; } private function upsert_external_rows($source, $object_type, array $rows) { global $wpdb; if (empty($rows)) { return false; } $events = self::events_table(); $tuples = array(); $args = array(); foreach ($rows as $r) { $tuples[] = '(%s, %d, %d, %s, %d, 0, %s, %s, %s)'; $args[] = $object_type; $args[] = (int) $r['product_id']; $args[] = (int) $r['user_id']; $args[] = ''; $args[] = (int) $r['rating']; $args[] = $source; $args[] = (string) $r['external_id']; $args[] = (string) $r['created_at']; } $values = implode(', ', $tuples); $sql = $wpdb->prepare( "INSERT INTO {$events} (object_type, product_id, user_id, session_token, rating, previous_rating, source, external_id, created_at) VALUES {$values} ON DUPLICATE KEY UPDATE previous_rating = rating, rating = VALUES(rating), object_type = VALUES(object_type), product_id = VALUES(product_id), user_id = VALUES(user_id), created_at = VALUES(created_at)", $args ); return ($wpdb->query($sql) !== false); } public function recompute_stats(array $product_ids, $object_type = 'product') { global $wpdb; $object_type = in_array((string) $object_type, array('product', 'brand'), true) ? (string) $object_type : 'product'; $ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), function ($x) { return $x > 0; }))); if (empty($ids)) { return 0; } $changed = array(); $events = self::events_table(); $stats = self::stats_table(); $identity = self::supports_external_ids() ? "CASE WHEN external_id IS NOT NULL THEN CONCAT('x:', source, ':', external_id) WHEN user_id > 0 THEN CONCAT('u:', user_id) ELSE CONCAT('s:', session_token) END" : "CASE WHEN user_id > 0 THEN CONCAT('u:', user_id) ELSE CONCAT('s:', session_token) END"; $done = 0; foreach (array_chunk($ids, 500) as $chunk) { $ph = implode(',', array_fill(0, count($chunk), '%d')); $args = array_merge(array($object_type), $chunk); $rows = $wpdb->get_results($wpdb->prepare( "SELECT t.product_id, COUNT(*) AS cnt, SUM(t.rating) AS total, SUM(t.rating = 1) AS n1, SUM(t.rating = 2) AS n2, SUM(t.rating = 3) AS n3, SUM(t.rating = 4) AS n4, SUM(t.rating = 5) AS n5, MAX(t.created_at) AS last_voted_at FROM ( SELECT product_id, rating, created_at, ROW_NUMBER() OVER ( PARTITION BY product_id, {$identity} ORDER BY created_at DESC, id DESC ) AS rn FROM {$events} WHERE object_type = %s AND product_id IN ({$ph}) ) t WHERE t.rn = 1 AND t.rating > 0 GROUP BY t.product_id", $args ), ARRAY_A); $by_pid = array(); foreach ((array) $rows as $r) { $by_pid[(int) $r['product_id']] = $r; } $tuples = array(); $uargs = array(); foreach ($chunk as $pid) { $r = isset($by_pid[$pid]) ? $by_pid[$pid] : null; $lv = ($r && !empty($r['last_voted_at'])) ? '%s' : 'NULL'; $tuples[] = "(%s, %d, %d, %d, %d, %d, %d, %d, %d, {$lv})"; $uargs[] = $object_type; $uargs[] = (int) $pid; $uargs[] = $r ? (int) $r['cnt'] : 0; $uargs[] = $r ? (int) $r['total'] : 0; $uargs[] = $r ? (int) $r['n1'] : 0; $uargs[] = $r ? (int) $r['n2'] : 0; $uargs[] = $r ? (int) $r['n3'] : 0; $uargs[] = $r ? (int) $r['n4'] : 0; $uargs[] = $r ? (int) $r['n5'] : 0; if ($lv === '%s') { $uargs[] = (string) $r['last_voted_at']; } } $values = implode(', ', $tuples); $wpdb->query($wpdb->prepare( "INSERT INTO {$stats} (object_type, product_id, count_total, sum_total, n1, n2, n3, n4, n5, last_voted_at) VALUES {$values} ON DUPLICATE KEY UPDATE count_total = VALUES(count_total), sum_total = VALUES(sum_total), n1 = VALUES(n1), n2 = VALUES(n2), n3 = VALUES(n3), n4 = VALUES(n4), n5 = VALUES(n5), last_voted_at = VALUES(last_voted_at)", $uargs )); foreach ($chunk as $pid) { $r = isset($by_pid[$pid]) ? $by_pid[$pid] : null; $count = $r ? (int) $r['cnt'] : 0; $sum = $r ? (int) $r['total'] : 0; $payload = array( 'count' => $count, 'sum' => $sum, 'avg' => $count > 0 ? (float) round($sum / $count, 1) : 0.0, 'distribution' => array( $r ? (int) $r['n1'] : 0, $r ? (int) $r['n2'] : 0, $r ? (int) $r['n3'] : 0, $r ? (int) $r['n4'] : 0, $r ? (int) $r['n5'] : 0, ), 'last_voted_at' => ($r && !empty($r['last_voted_at'])) ? (string) $r['last_voted_at'] : '', ); $ckey = self::CACHE_PREFIX . $object_type . ':' . $pid; wp_cache_delete($ckey, self::CACHE_GROUP); wp_cache_set($ckey, $payload, self::CACHE_GROUP, 5 * MINUTE_IN_SECONDS); if ($object_type === 'product') { $this->write_rating_to_vitals($pid, $payload); } $done++; $changed[] = $pid; } } if (!empty($changed)) { do_action('aieo_rating_stats_changed', $changed, $object_type); } return $done; } private $viewer_rating_memo = array(); private $viewer_memo_sig = null; public function prime_loop_ratings($query) { if (is_admin()) { return; } if (!($query instanceof WP_Query)) { return; } $typed = in_array('product', (array) $query->get('post_type'), true); $ids = array(); foreach ((array) $query->posts as $p) { if (is_object($p) ? 'product' !== $p->post_type : !$typed) { continue; } $pid = is_object($p) ? (int) $p->ID : (int) $p; if ($pid > 0) { $ids[] = $pid; } } if (count($ids) < 2) { return; } $this->get_stats_batch($ids); $this->prime_viewer_ratings($ids); } private function prime_viewer_ratings(array $ids, $object_type = 'product') { $object_type = in_array((string) $object_type, array('product', 'brand'), true) ? (string) $object_type : 'product'; $user_id = (int) get_current_user_id(); $token = (string) $this->read_session_token_no_create(); $this->viewer_memo_sig = $object_type . '|' . $user_id . '|' . $token; $ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($x) { return $x > 0; }))); if (empty($ids)) { return; } if ($user_id <= 0 && $token === '') { foreach ($ids as $pid) { $this->viewer_rating_memo[$object_type . ':' . $pid] = 0; } return; } global $wpdb; $events = self::events_table(); $ph = implode(',', array_fill(0, count($ids), '%d')); if ($user_id > 0) { $args = array_merge(array($object_type), $ids, array($user_id)); $scope = 'user_id = %d'; } else { $args = array_merge(array($object_type), $ids, array($token)); $scope = 'user_id = 0 AND session_token = %s'; } $widget_only = self::widget_scope_sql(); $rows = $wpdb->get_results($wpdb->prepare( "SELECT product_id, rating FROM ( SELECT product_id, rating, ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at DESC, id DESC) AS rn FROM {$events} WHERE object_type = %s AND product_id IN ({$ph}) AND {$scope}{$widget_only} ) t WHERE rn = 1", $args ), ARRAY_A); $found = array(); foreach ((array) $rows as $r) { $found[(int) $r['product_id']] = (int) $r['rating']; } foreach ($ids as $pid) { $this->viewer_rating_memo[$object_type . ':' . $pid] = isset($found[$pid]) ? $found[$pid] : 0; } } public function get_current_rating_for_viewer($product_id, $user_id, $session_token, $object_type = 'product') { global $wpdb; $product_id = (int) $product_id; $user_id = (int) $user_id; $token = (string) $session_token; $object_type = in_array((string) $object_type, array('product', 'brand'), true) ? (string) $object_type : 'product'; if ($product_id <= 0) { return 0; } $memo_sig = $object_type . '|' . $user_id . '|' . $token; $memo_key = $object_type . ':' . $product_id; if ($this->viewer_memo_sig === $memo_sig && array_key_exists($memo_key, $this->viewer_rating_memo)) { return (int) $this->viewer_rating_memo[$memo_key]; } $events = self::events_table(); $widget_only = self::widget_scope_sql(); if ($user_id > 0) { $row = $wpdb->get_row($wpdb->prepare( "SELECT rating FROM {$events} WHERE object_type = %s AND product_id = %d AND user_id = %d{$widget_only} ORDER BY created_at DESC, id DESC LIMIT 1", $object_type, $product_id, $user_id ), ARRAY_A); } elseif ($token !== '') { $row = $wpdb->get_row($wpdb->prepare( "SELECT rating FROM {$events} WHERE object_type = %s AND product_id = %d AND user_id = 0 AND session_token = %s{$widget_only} ORDER BY created_at DESC, id DESC LIMIT 1", $object_type, $product_id, $token ), ARRAY_A); } else { return 0; } if (!$row) { return 0; } return (int) $row['rating']; } public function get_stats($product_id, $object_type = 'product') { $product_id = (int) $product_id; $object_type = in_array((string) $object_type, array('product', 'brand'), true) ? (string) $object_type : 'product'; if ($product_id <= 0) { return $this->empty_stats(); } $cache_key = self::CACHE_PREFIX . $object_type . ':' . $product_id; $cached = wp_cache_get($cache_key, self::CACHE_GROUP); if (is_array($cached)) { return $cached; } global $wpdb; $row = $wpdb->get_row($wpdb->prepare( "SELECT count_total, sum_total, n1, n2, n3, n4, n5, last_voted_at FROM " . self::stats_table() . " WHERE object_type = %s AND product_id = %d", $object_type, $product_id ), ARRAY_A); if (!$row) { $payload = $this->empty_stats(); } else { $count = (int) $row['count_total']; $sum = (int) $row['sum_total']; $avg = $count > 0 ? round($sum / $count, 1) : 0.0; $payload = array( 'count' => $count, 'sum' => $sum, 'avg' => (float) $avg, 'distribution' => array( (int) $row['n1'], (int) $row['n2'], (int) $row['n3'], (int) $row['n4'], (int) $row['n5'], ), 'last_voted_at' => $row['last_voted_at'] ? (string) $row['last_voted_at'] : '', ); } wp_cache_set($cache_key, $payload, self::CACHE_GROUP, 5 * MINUTE_IN_SECONDS); $payload['object_type'] = $object_type; return $payload; } public function get_stats_batch(array $ids, $object_type = 'product') { $object_type = in_array((string) $object_type, array('product', 'brand'), true) ? (string) $object_type : 'product'; $ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($x) { return $x > 0; }))); if (empty($ids)) { return array(); } $out = array(); $miss = array(); foreach ($ids as $pid) { $cached = wp_cache_get(self::CACHE_PREFIX . $object_type . ':' . $pid, self::CACHE_GROUP); if (is_array($cached)) { $out[$pid] = $cached; } else { $miss[] = $pid; } } if (!empty($miss)) { global $wpdb; $placeholders = implode(',', array_fill(0, count($miss), '%d')); $args = array_merge(array($object_type), $miss); $rows = $wpdb->get_results($wpdb->prepare( "SELECT product_id, count_total, sum_total, n1, n2, n3, n4, n5, last_voted_at FROM " . self::stats_table() . " WHERE object_type = %s AND product_id IN ({$placeholders})", $args ), ARRAY_A); $by_pid = array(); foreach ((array) $rows as $r) { $by_pid[(int) $r['product_id']] = $r; } foreach ($miss as $pid) { if (isset($by_pid[$pid])) { $r = $by_pid[$pid]; $count = (int) $r['count_total']; $sum = (int) $r['sum_total']; $avg = $count > 0 ? round($sum / $count, 1) : 0.0; $out[$pid] = array( 'count' => $count, 'sum' => $sum, 'avg' => (float) $avg, 'distribution' => array( (int) $r['n1'], (int) $r['n2'], (int) $r['n3'], (int) $r['n4'], (int) $r['n5'], ), 'last_voted_at' => $r['last_voted_at'] ? (string) $r['last_voted_at'] : '', ); } else { $out[$pid] = $this->empty_stats(); } wp_cache_set(self::CACHE_PREFIX . $object_type . ':' . $pid, $out[$pid], self::CACHE_GROUP, 5 * MINUTE_IN_SECONDS); } } return $out; } private function empty_stats() { return array( 'count' => 0, 'sum' => 0, 'avg' => 0.0, 'distribution' => array(0, 0, 0, 0, 0), 'last_voted_at' => '', ); } public function render_stars_html($product, $variant = 'single') { $product_id = $this->resolve_product_id($product); if ($product_id <= 0) { return ''; } $settings = self::get_settings(); if (empty($settings['enabled'])) { return ''; } $this->enqueue_assets(true); if ($variant === 'archive' && !empty($settings['aggregate_only_archive'])) { return $this->render_aggregate_html($product_id); } $stats = $this->get_stats($product_id); $current = $this->get_current_rating_for_viewer( $product_id, get_current_user_id(), $this->read_session_token_no_create() ); $pre_rated = !empty($settings['display_full_until_rated']) && $current === 0; $caption = $current > 0 ? $this->build_caption($stats, $settings) : self::caption_before_rated($settings); $root_classes = 'aieo-rating-stars aieo-rating-stars--' . sanitize_html_class($variant); if ('single' === $variant) { $root_classes .= ' rooster-order--rating ff-order--rating'; } if ($pre_rated) { $root_classes .= ' is-unrated-full'; } $stars_html = ''; for ($i = 1; $i <= 5; $i++) { $is_filled = $pre_rated ? true : ($current >= $i); $cls = 'aieo-rating-star'; if ($is_filled) { $cls .= ' is-filled'; } $stars_html .= ''; } $fit_attrs = ''; if ('single' === $variant && has_action('rooster/fit/enqueue')) { do_action('rooster/fit/enqueue'); $fit_attrs = ' data-wp-interactive="rooster/fit" data-wp-init---rooster-fit="rooster/fit::callbacks.init" data-rooster-fit-room'; } $caption_html = '' . esc_html($caption) . ''; return '
' . '
' . $stars_html . '
' . $caption_html . '
'; } public function render_aggregate_html($product) { $product_id = $this->resolve_product_id($product); if ($product_id <= 0) { return ''; } $settings = self::get_settings(); if (empty($settings['enabled'])) { return ''; } $this->enqueue_assets(true); $stats = $this->get_stats($product_id); $current = $this->get_current_rating_for_viewer( $product_id, get_current_user_id(), $this->read_session_token_no_create() ); $pre_rated = !empty($settings['display_full_until_rated']) && $current === 0; if (!$pre_rated) { $min = (int) $settings['min_votes_for_display']; if ($stats['count'] < $min) { return ''; } if ($stats['count'] <= 0) { return ''; } } $avg5 = $pre_rated ? 5.0 : max(0.0, min(5.0, (float) $stats['avg'])); $percent = round(($avg5 / 5) * 100, 1); $caption = $current > 0 ? $this->build_caption($stats, $settings) : self::caption_before_rated($settings); $root_classes = 'aieo-rating-aggregate'; if ($pre_rated) { $root_classes .= ' is-unrated-full'; } $caption_html = '' . esc_html($caption) . ''; return '
' . '' . $caption_html . '
'; } private function build_caption($stats, $settings) { $count = (int) $stats['count']; if ($count <= 0) { return ''; } $fmt = (string) $settings['caption_format']; if ($fmt === '') { return ''; } $avg = number_format_i18n((float) $stats['avg'], 1); return strtr($fmt, array( '%avg5' => $avg . '/5', '%avg' => $avg, '%count' => (string) $count, )); } private static function star_svg($extra_class = '') { $cls = 'aieo-rating-star-icon' . ($extra_class !== '' ? ' ' . $extra_class : ''); return ''; } private function resolve_product_id($product) { if (is_a($product, 'WC_Product')) { return (int) $product->get_id(); } return (int) $product; } private static $placed_in_card = array(); public function place_in_theme_card($html, $block) { if (!is_string($html) || '' === $html || empty($block['blockName']) || 'core/group' !== $block['blockName']) { return $html; } $class = isset($block['attrs']['className']) ? (string) $block['attrs']['className'] : ''; if ('' === $class) { return $html; } $names = array('rooster-product-card', 'ff-product-card'); if (class_exists('AIEO_Theme_Adapters') && method_exists('AIEO_Theme_Adapters', 'class_map')) { $map = AIEO_Theme_Adapters::class_map(); if (!empty($map['product_card'])) { $names = array_filter((array) $map['product_card']); } } $hit = false; foreach ($names as $n) { if (preg_match('/(^|\s)' . preg_quote($n, '/') . '(\s|$)/', $class)) { $hit = true; break; } } if (!$hit) { return $html; } global $product; if (!$product || !is_a($product, 'WC_Product')) { return $html; } $widget = $this->render_stars_html($product, 'archive'); if ('' === $widget) { return $html; } $pos = strrpos($html, ''); if (false === $pos) { return $html; } self::$placed_in_card[(int) $product->get_id()] = true; return substr($html, 0, $pos) . $widget . substr($html, $pos); } public function render_loop_widget() { global $product; if (!$product || !is_a($product, 'WC_Product')) { return; } $pid = (int) $product->get_id(); if (!empty(self::$placed_in_card[$pid])) { unset(self::$placed_in_card[$pid]); return; } echo $this->render_stars_html($product, 'archive'); } public function render_single_widget() { global $product; if (!$product || !is_a($product, 'WC_Product')) { return; } echo $this->render_stars_html($product, 'single'); } public function shortcode_stars($atts = array()) { $atts = shortcode_atts(array( 'product_id' => 0, 'variant' => 'single', ), $atts, 'aieo_rating_stars'); $pid = (int) $atts['product_id']; if ($pid <= 0) { global $product; if ($product && is_a($product, 'WC_Product')) { $pid = (int) $product->get_id(); } } if ($pid <= 0) { return ''; } return $this->render_stars_html($pid, sanitize_key((string) $atts['variant'])); } public function shortcode_aggregate($atts = array()) { $atts = shortcode_atts(array('product_id' => 0), $atts, 'aieo_rating_aggregate'); $pid = (int) $atts['product_id']; if ($pid <= 0) { global $product; if ($product && is_a($product, 'WC_Product')) { $pid = (int) $product->get_id(); } } if ($pid <= 0) { return ''; } return $this->render_aggregate_html($pid); } public static function page_shows_products() { if (!function_exists('is_woocommerce') || is_admin()) { return false; } return is_singular('product') || is_shop() || is_product_taxonomy() || (is_search() && 'product' === get_query_var('post_type')); } public function enqueue_assets($force = false) { static $done = false; if ($done) { return; } if (!$force && !self::page_shows_products()) { return; } $done = true; if (is_admin()) { return; } $settings = self::get_settings(); if (empty($settings['enabled'])) { return; } $current_user_id = get_current_user_id(); $css_uri = ''; $css_path = ''; if (defined('AIEO_RUNTIME_URI') && defined('AIEO_RUNTIME_DIR')) { $candidate_path = AIEO_RUNTIME_DIR . 'assets/css/aieo-rating-stars.css'; if (file_exists($candidate_path)) { $css_uri = AIEO_RUNTIME_URI . 'assets/css/aieo-rating-stars.css'; $css_path = $candidate_path; } } if (!$css_path) { $candidate_path = AIEO_DIR . 'assets/css/aieo-rating-stars.css'; if (file_exists($candidate_path)) { $css_uri = AIEO_URI . 'assets/css/aieo-rating-stars.css'; $css_path = $candidate_path; } } $css_ver = $css_path ? (string) filemtime($css_path) : AIEO_VERSION; if ($css_uri && $css_path && file_exists($css_path)) { wp_enqueue_style('aieo-rating-stars', $css_uri, array(), $css_ver); $size_default = (int) $settings['size']; $size_mobile = (int) ($settings['size_mobile'] ?? 0); $size_single = (int) ($settings['size_single'] ?? 0); $gap_default = max(0, min(20, (int) ($settings['gap'] ?? 2))); $gap_mobile = (int) ($settings['gap_mobile'] ?? 0); $cap_d = max(8, min(48, (int) ($settings['caption_size'] ?? 13))); $cap_m = max(8, min(48, (int) ($settings['caption_size_mobile'] ?? 0) ?: $cap_d)); $cap_arch_d = max(8, min(48, (int) ($settings['caption_size_archive'] ?? 12))); $cap_arch_m = max(8, min(48, (int) ($settings['caption_size_archive_mobile'] ?? 0) ?: $cap_arch_d)); $css = sprintf( ':root{--aieo-rating-empty:%s;--aieo-rating-filled:%s;--aieo-rating-hover:%s;--aieo-rating-pre-rated:%s;--aieo-rating-size:%dpx;--aieo-rating-gap:%dpx;--aieo-rating-cap-fs:%dpx;--aieo-rating-cap-fs-arch:%dpx;}', esc_attr((string) $settings['color_empty']), esc_attr((string) $settings['color_filled']), esc_attr((string) $settings['color_hover']), esc_attr((string) $settings['color_pre_rated']), $size_default, $gap_default, $cap_d, $cap_arch_d ); $css .= self::chosen_colours_css($settings); $css .= sprintf( '@media (max-width:768px){:root{--aieo-rating-cap-fs:%dpx;--aieo-rating-cap-fs-arch:%dpx;}}', $cap_m, $cap_arch_m ); if ($size_single > 0) { $css .= sprintf('body.single-product{--aieo-rating-size:%dpx;--aieo-rating-gap:%dpx;}', $size_single, max($gap_default, 24 - $size_single)); } if ($size_mobile > 0 && $size_mobile !== $size_default) { $css .= sprintf('@media (max-width:768px){:root{--aieo-rating-size:%dpx;}}', $size_mobile); } if ($gap_mobile > 0 && $gap_mobile !== $gap_default) { $gap_mobile = max(0, min(20, $gap_mobile)); $css .= sprintf('@media (max-width:768px){:root{--aieo-rating-gap:%dpx;}}', $gap_mobile); } if ($size_mobile > 0) { $gm = ($gap_mobile > 0) ? $gap_mobile : $gap_default; $css .= sprintf('@media (max-width:768px){body.single-product{--aieo-rating-gap:%dpx;}}', max($gm, 24 - $size_mobile)); } wp_add_inline_style('aieo-rating-stars', $css); } $payload = array( 'ajaxUrl' => admin_url('admin-ajax.php'), 'restRoot' => esc_url_raw(rest_url()), 'restNonce' => wp_create_nonce('wp_rest'), 'nonce' => wp_create_nonce(self::NONCE_ACTION), 'isLoggedIn' => $current_user_id > 0, 'requireLogin' => !empty($settings['require_login']), 'loginUrl' => wp_login_url(), 'loginRedirectMode' => (string) $settings['login_redirect_mode'], 'allowClear' => !empty($settings['allow_clear']), 'captionFormat' => (string) $settings['caption_format'], 'captionBeforeRated' => self::caption_before_rated($settings), 'minVotesForDisplay' => (int) $settings['min_votes_for_display'], 'currentRatings' => array(), ); $iapi_on = method_exists('AIEO_DMM', 'is_iapi_enabled') && AIEO_DMM::is_iapi_enabled('ratings'); $runtime_js_path = ''; $runtime_js_uri = ''; if (defined('AIEO_RUNTIME_DIR') && defined('AIEO_RUNTIME_URI')) { $cand = AIEO_RUNTIME_DIR . 'assets/js/aieo-rating-stars.js'; if (file_exists($cand)) { $runtime_js_path = $cand; $runtime_js_uri = AIEO_RUNTIME_URI . 'assets/js/aieo-rating-stars.js'; } } if (!$runtime_js_path) { $cand = AIEO_DIR . 'assets/js/aieo-rating-stars.js'; if (file_exists($cand)) { $runtime_js_path = $cand; $runtime_js_uri = AIEO_URI . 'assets/js/aieo-rating-stars.js'; } } $iapi_js_path = ''; $iapi_js_uri = ''; if (defined('AIEO_RUNTIME_DIR') && defined('AIEO_RUNTIME_URI')) { $cand = AIEO_RUNTIME_DIR . 'assets/js/aieo-rating-stars-iapi.js'; if (file_exists($cand)) { $iapi_js_path = $cand; $iapi_js_uri = AIEO_Asset_Min::module_src($cand, AIEO_RUNTIME_URI . 'assets/js/aieo-rating-stars-iapi.js'); } } if (!$iapi_js_path) { $cand = AIEO_DIR . 'assets/js/aieo-rating-stars-iapi.js'; if (file_exists($cand)) { $iapi_js_path = $cand; $iapi_js_uri = AIEO_Asset_Min::module_src($cand, AIEO_URI . 'assets/js/aieo-rating-stars-iapi.js'); } } if ($iapi_on && $iapi_js_path && file_exists($iapi_js_path) ) { wp_register_script_module( '@aieo/rating-stars-iapi', $iapi_js_uri, array('@wordpress/interactivity', '@aieo/iapi-bootstrap'), (string) filemtime($iapi_js_path), array( 'fetchpriority' => 'low' ) ); AIEO_Asset_Min::defer_module('@aieo/rating-stars-iapi', $iapi_js_uri, (string) filemtime($iapi_js_path)); add_action('wp_print_footer_scripts', static function () use ($payload) { echo '' . "\n"; }, 5); } elseif ($runtime_js_path && file_exists($runtime_js_path)) { $js_ver = (string) filemtime($runtime_js_path); wp_enqueue_script('aieo-rating-stars', $runtime_js_uri, array('jquery'), $js_ver, array('in_footer' => true, 'strategy' => 'defer')); wp_localize_script('aieo-rating-stars', 'aieoRatings', $payload); } } public function register_admin_menu() { add_submenu_page( 'ai-eshop-optimizer', __('Star Ratings', 'ai-eshop-optimizer'), '⭐ ' . __('Star Ratings', 'ai-eshop-optimizer'), AIEO_CAPABILITY, 'aieo-ratings', array($this, 'render_admin_page') ); } public function render_admin_page() { if (!current_user_can(AIEO_CAPABILITY)) { wp_die(esc_html__('You do not have permission to access this page.', 'ai-eshop-optimizer')); } $s = self::get_settings(); $saved = isset($_GET['aieo_ratings_saved']) && $_GET['aieo_ratings_saved'] === '1'; ?>

⭐

row_checkbox('enabled', $s, __('Enable Star Ratings', 'ai-eshop-optimizer'), __('Master switch for the module.', 'ai-eshop-optimizer')); ?> row_checkbox('require_login', $s, __('Require login to vote', 'ai-eshop-optimizer'), __('When ON, anonymous shoppers see the widget but clicks are gated.', 'ai-eshop-optimizer')); ?> row_select('login_redirect_mode', $s, __('Login gate behavior', 'ai-eshop-optimizer'), array( 'redirect' => __('Redirect to login page', 'ai-eshop-optimizer'), 'event' => __('Emit aieo:rating:login_required event (theme-handled)', 'ai-eshop-optimizer'), )); ?> row_checkbox('display_archive', $s, __('Display on archive cards', 'ai-eshop-optimizer'), ''); ?> row_checkbox('display_single', $s, __('Display on single product page', 'ai-eshop-optimizer'), ''); ?> row_checkbox('aggregate_only_archive', $s, __('Aggregate only on archive', 'ai-eshop-optimizer'), __('When ON, archive cards show the read-only aggregate (no interactive widget).', 'ai-eshop-optimizer')); ?> row_text('caption_format', $s, __('Caption format', 'ai-eshop-optimizer'), __('Tokens: %avg, %count, %avg5. Empty = hide caption.', 'ai-eshop-optimizer')); ?> row_number('min_votes_for_display', $s, __('Minimum votes to show aggregate', 'ai-eshop-optimizer'), __('Hide aggregate until N votes — avoids "5.0 (1)" being misleading.', 'ai-eshop-optimizer')); ?> row_color('color_empty', $s, __('Empty star color', 'ai-eshop-optimizer')); ?> row_color('color_filled', $s, __('Filled star color', 'ai-eshop-optimizer')); ?> row_color('color_hover', $s, __('Hover color', 'ai-eshop-optimizer')); ?> row_number('size', $s, __('Star size — desktop (px)', 'ai-eshop-optimizer'), __('Default size used everywhere. The two overrides below kick in on top of this.', 'ai-eshop-optimizer')); ?> row_number('size_mobile', $s, __('Star size — mobile (px)', 'ai-eshop-optimizer'), __('Applied on viewports ≤ 768px wide. 0 = inherit desktop size.', 'ai-eshop-optimizer')); ?> row_number('size_single', $s, __('Star size — single-product page (px)', 'ai-eshop-optimizer'), __('Applied only on the single-product page. 0 = inherit desktop size. On mobile single-product pages the mobile size wins.', 'ai-eshop-optimizer')); ?> row_checkbox('allow_clear', $s, __('Allow clear', 'ai-eshop-optimizer'), __('Re-click the active star to clear the rating.', 'ai-eshop-optimizer')); ?>

'aieo-ratings', 'aieo_ratings_saved' => '1'), admin_url('admin.php') )); exit; } } }