array( 'label' => __('Searchable + alphabetised Brands metabox', 'ai-eshop-optimizer'), 'desc' => __('On the product edit page, sort the Brands list A→Z and add a live search filter above it. Fixes the WP/Woo metabox\'s popular-first-then-by-ID order which is painful with hundreds of brands.', 'ai-eshop-optimizer'), 'default' => 1, ), ); } public function register_hooks() { add_action('wp_ajax_aieo_dmm_tools_save', array($this, 'ajax_save')); add_action('wp_ajax_aieo_dmm_tools_save_branding', array($this, 'ajax_save_branding')); add_action('wp_ajax_aieo_dmm_tools_save_caps', array($this, 'ajax_save_caps')); add_action('wp_ajax_aieo_dmm_tools_save_quiet', array($this, 'ajax_save_quiet')); add_action('wp_ajax_aieo_dmm_tools_save_stock_hold', array($this, 'ajax_save_stock_hold')); if (is_admin()) { add_filter('get_user_option_edit_orders_per_page', array($this, 'cap_orders_per_page')); add_filter('get_user_option_edit_shop_order_per_page', array($this, 'cap_orders_per_page')); add_filter('get_user_option_edit_product_per_page', array($this, 'cap_products_per_page')); } add_action('wp_ajax_aieo_dmm_tools_sku_lookup', array($this, 'ajax_sku_lookup')); add_action('wp_ajax_aieo_dmm_tools_barcode_lookup', array($this, 'ajax_barcode_lookup')); add_action('admin_enqueue_scripts', array($this, 'enqueue_brand_metabox_tool')); } public function enqueue_admin_assets() { $css = 'assets/css/aieo-tools-admin.css'; wp_enqueue_style('aieo-tools-admin', AIEO_URI . $css, array(), file_exists(AIEO_DIR . $css) ? (string) filemtime(AIEO_DIR . $css) : '1'); } public function get_settings() { $stored = get_option(self::OPTION_KEY, array()); if (!is_array($stored)) { $stored = array(); } $defaults = array(); foreach ($this->get_tools() as $slug => $info) { $defaults[$slug] = !empty($info['default']) ? 1 : 0; } return array_merge($defaults, $stored); } public function is_enabled($slug) { $cfg = $this->get_settings(); return !empty($cfg[$slug]); } public function get_caps() { $stored = get_option(self::OPTION_CAPS, array()); if (!is_array($stored)) { $stored = array(); } $defaults = array( 'orders_max' => 200, 'products_max' => 200, ); $merged = array_merge($defaults, $stored); $merged['orders_max'] = max(0, (int) $merged['orders_max']); $merged['products_max'] = max(0, (int) $merged['products_max']); return $merged; } public function cap_orders_per_page($result) { $caps = $this->get_caps(); $max = (int) $caps['orders_max']; if ($max <= 0) { return $result; } $val = (int) $result; if ($val <= 0) { return $result; } return $val > $max ? $max : $val; } public function cap_products_per_page($result) { $caps = $this->get_caps(); $max = (int) $caps['products_max']; if ($max <= 0) { return $result; } $val = (int) $result; if ($val <= 0) { return $result; } return $val > $max ? $max : $val; } public function ajax_save_caps() { AIEO_DMM::verify_ajax_request(); if (!current_user_can('install_plugins')) { wp_send_json_error(array('message' => __('Insufficient permissions.', 'ai-eshop-optimizer'))); } $orders = isset($_POST['orders_max']) ? max(0, (int) $_POST['orders_max']) : 200; $products = isset($_POST['products_max']) ? max(0, (int) $_POST['products_max']) : 200; $orders = min(10000, $orders); $products = min(10000, $products); update_option(self::OPTION_CAPS, array( 'orders_max' => $orders, 'products_max' => $products, ), false); wp_send_json_success(array( 'message' => __('Per-page caps saved. Operators see the new ceiling on their next list-table render.', 'ai-eshop-optimizer'), 'orders_max' => $orders, 'products_max' => $products, )); } public function enqueue_brand_metabox_tool($hook) { $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen || $screen->post_type !== 'product') { return; } if (!in_array($hook, array('post.php', 'post-new.php'), true)) { return; } if (!$this->is_enabled('brand_metabox_enhance')) { return; } $handle = 'aieo-tools-brand-metabox'; wp_enqueue_script( $handle, plugins_url('assets/js/aieo-tools-brand-metabox.js', AIEO_FILE), array(), defined('AIEO_VERSION') ? AIEO_VERSION : '1.0', true ); wp_localize_script($handle, 'aieoToolsBrand', array( 'placeholder' => __('Search brands…', 'ai-eshop-optimizer'), 'noResults' => __('No brands match.', 'ai-eshop-optimizer'), )); } public function ajax_save() { AIEO_DMM::verify_ajax_request(); $patch = array(); $tools = $this->get_tools(); foreach ($tools as $slug => $info) { $patch[$slug] = !empty($_POST[$slug]) ? 1 : 0; } update_option(self::OPTION_KEY, $patch, false); wp_send_json_success(array('message' => __('Tools settings saved.', 'ai-eshop-optimizer'))); } public function ajax_save_quiet() { AIEO_DMM::verify_ajax_request(); $norm = static function ($v, $def) { $v = trim((string) $v); if (preg_match('/^(\d{1,2}):(\d{2})$/', $v, $m) && (int) $m[1] <= 23 && (int) $m[2] <= 59) { return sprintf('%02d:%02d', (int) $m[1], (int) $m[2]); } return $def; }; $start = $norm(isset($_POST['quiet_start']) ? wp_unslash($_POST['quiet_start']) : '', '22:00'); $end = $norm(isset($_POST['quiet_end']) ? wp_unslash($_POST['quiet_end']) : '', '09:00'); AIEO_DMM_Settings::update(array( 'contact_quiet_start' => $start, 'contact_quiet_end' => $end, )); wp_send_json_success(array( 'message' => __('Quiet hours saved — applied to web-push now, and to newsletters / non-transactional email going forward.', 'ai-eshop-optimizer'), 'quiet_start' => $start, 'quiet_end' => $end, )); } public function ajax_save_stock_hold() { AIEO_DMM::verify_ajax_request(); $raw = isset($_POST['stock_reservation_minutes']) ? wp_unslash($_POST['stock_reservation_minutes']) : ''; $min = max(0, min(10080, (int) $raw)); AIEO_DMM_Settings::update(array('stock_reservation_minutes' => $min)); wp_send_json_success(array( 'message' => 0 === $min ? __('Saved. WooCommerce\'s own hold value is now used unchanged.', 'ai-eshop-optimizer') : sprintf( __('Saved. Checkout now reserves stock for at most %d minutes. Unpaid-order cancellation is unchanged.', 'ai-eshop-optimizer'), $min ), 'minutes' => $min, )); } private function render_fee_guard_card() { global $wpdb; $stats = get_option('aieo_fee_guard_stats', array()); if (!is_array($stats)) { $stats = array(); } $total = isset($stats['total']) ? (int) $stats['total'] : 0; list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => 'tools-payment-fee-guard', 'icon' => 'credit-card', 'title' => __('Payment Fee Guard', 'ai-eshop-optimizer'), 'description' => __('Keeps a payment-method surcharge, such as the cash-on-delivery fee, in step with the payment method the order was actually placed with. The block checkout works out the surcharge before it knows which method the customer finally chose, so an order can be created without a surcharge it should carry, or carrying one it should not. Every order is checked and corrected before the customer is charged, on the block checkout, the classic checkout, and when someone returns to pay for an order whose payment failed the first time.', 'ai-eshop-optimizer'), 'col' => 2, )); echo $open; echo self::why(__('Orders your staff create by hand are never touched, so phone orders keep whatever you entered.', 'ai-eshop-optimizer')); if ($total < 1) { $notice = \Rooster\Admin\Ui::notice( esc_html__('The guard is active and checking every order.', 'ai-eshop-optimizer'), 'success', array( 'title' => __('No corrections have been needed yet.', 'ai-eshop-optimizer'), 'inline' => true, ) ); echo $notice; echo $close; return; } $labels = array( 'added' => __('Surcharge added to an order that was missing it', 'ai-eshop-optimizer'), 'corrected' => __('Wrong amount corrected', 'ai-eshop-optimizer'), 'removed' => __('Surcharge removed from an order that should not carry one', 'ai-eshop-optimizer'), 'deduplicated' => __('Duplicate surcharge lines collapsed into one', 'ai-eshop-optimizer'), ); $fix_rows = array(); foreach ($labels as $key => $label) { $n = isset($stats[$key]) ? (int) $stats[$key] : 0; if ($n < 1) { continue; } $fix_rows[] = array('what' => $label, 'n' => number_format_i18n($n)); } echo \Rooster\Admin\Ui::table( array( array('key' => 'what', 'label' => __('Correction', 'ai-eshop-optimizer'), 'type' => 'text'), array('key' => 'n', 'label' => __('Orders', 'ai-eshop-optimizer'), 'type' => 'num'), ), $fix_rows, array( 'id' => 'aieo-tools-surcharge-stats', 'totals' => array('n' => number_format_i18n($total)), 'totals_label' => esc_html__('Total orders corrected', 'ai-eshop-optimizer'), ) ); if (!empty($stats['last_gmt'])) { echo '
' . esc_html(sprintf( __('Most recent correction: %s', 'ai-eshop-optimizer'), (string) $stats['last_gmt'] )) . '
'; } $recent = isset($stats['recent']) && is_array($stats['recent']) ? $stats['recent'] : array(); if ($recent) { echo '' . esc_html__('Most recent corrections', 'ai-eshop-optimizer') . '
'; $recent_rows = array(); $methods = array(); foreach ($recent as $i => $r) { if (!is_array($r)) { continue; } $oid = isset($r['order_id']) ? (int) $r['order_id'] : 0; $act = isset($r['action']) ? (string) $r['action'] : ''; $method = isset($r['method']) ? (string) $r['method'] : ''; $what = isset($labels[$act]) ? $labels[$act] : $act; $gmt = isset($r['gmt']) ? (string) $r['gmt'] : ''; $link = $oid && function_exists('wc_get_order') && wc_get_order($oid) ? admin_url('admin.php?page=wc-orders&action=edit&id=' . $oid) : ''; if ('' !== $method) { $methods[$method] = $method; } $recent_rows[] = array( '_id' => (string) $i, '_attrs' => array( 'data-ra-f' => array('payment_method' => array($method)), 'data-ra-s' => array('order' => $oid, 'gmt' => $gmt, 'method' => $method, 'what' => $what, '_id' => (int) $i), 'data-ra-text' => '#' . $oid . ' ' . $method . ' ' . $what, ), 'order' => $link ? array('title' => '#' . $oid, 'url' => $link) : '#' . $oid, 'gmt' => $gmt, 'method' => $method, 'what' => $what, ); } ksort($methods, SORT_NATURAL | SORT_FLAG_CASE); $fg_sort = static function ($key, $type) { return array('sort' => 'none', 'sort_type' => $type, 'sort_url' => add_query_arg(array('fg_orderby' => $key, 'fg_order' => 'asc'))); }; echo \Rooster\Admin\Ui::table( array( array('key' => 'order', 'label' => __('Order', 'ai-eshop-optimizer'), 'type' => 'text') + $fg_sort('order', 'number'), array('key' => 'gmt', 'label' => __('When (UTC)', 'ai-eshop-optimizer'), 'type' => 'date') + $fg_sort('gmt', 'date'), array('key' => 'method', 'label' => __('Payment method', 'ai-eshop-optimizer'), 'type' => 'text') + $fg_sort('method', 'text'), array('key' => 'what', 'label' => __('What changed', 'ai-eshop-optimizer'), 'type' => 'text') + $fg_sort('what', 'text'), ), $recent_rows, array( 'id' => 'aieo-tools-surcharge-recent', 'caption' => __('Most recent corrections', 'ai-eshop-optimizer'), 'local' => array('size' => 0, 'prefix' => 'fg_'), 'filters' => count($methods) > 1 ? array( array('key' => 'payment_method', 'label' => __('Payment method', 'ai-eshop-optimizer'), 'options' => $methods, 'any' => __('Any payment method', 'ai-eshop-optimizer')), ) : array(), ) ); } echo $close; } private function render_stock_reservation_card($nonce, $ajax) { $minutes = (int) AIEO_DMM_Settings::get('stock_reservation_minutes', 60); $wc_hold = (int) get_option('woocommerce_hold_stock_minutes', 60); $anchor = 'tools-stock-reservation-window'; list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => $anchor, 'icon' => 'timer', 'title' => __('Checkout stock reservation', 'ai-eshop-optimizer'), 'description' => __('How long a checkout in progress may hold stock. When a shopper reaches checkout, WooCommerce reserves their items so nobody else can buy them mid-payment. If the shopper abandons the payment, that reservation stays behind and can block the next customer — and, on the last unit of a product, it blocks the original shopper too when they come back to try again.', 'ai-eshop-optimizer'), 'col' => 2, 'footer' => self::save_footer($anchor, array('label' => __('Save reservation window', 'ai-eshop-optimizer'))), )); echo $open; $why = __('Why this is here and not in WooCommerce: the WooCommerce field %2$s uses ONE number for four different jobs — reserving stock at checkout, deciding when an unpaid order is cancelled, scheduling that cancellation, and holding coupons. Shops that accept bank transfer set it high on purpose so customers have days to pay. Yours is set to %1$d minutes. Lowering it there to shorten a stock hold would also start cancelling unpaid bank-transfer orders that soon. This setting changes only the reservation, through the supported WooCommerce filter, and leaves your Hold stock value and your cancellation window exactly as they are.', 'ai-eshop-optimizer'); echo self::why(sprintf($why, $wc_hold, __('WooCommerce > Settings > Products > Inventory > Hold stock (minutes)', 'ai-eshop-optimizer')), '', __('Why not in WooCommerce?', 'ai-eshop-optimizer')); echo self::why(__('This only ever shortens the reservation, never lengthens it. Set it to 0 to switch it off and use the WooCommerce value unchanged.', 'ai-eshop-optimizer'), '', __('Can it make the hold longer?', 'ai-eshop-optimizer')); ob_start(); echo ''; echo ' ' . '' . '' . esc_html__('minutes', 'ai-eshop-optimizer') . ''; echo '
'; echo self::form('aieo_dmm_tools_save_stock_hold', $anchor); echo $close; } private function render_quiet_hours_card($nonce, $ajax) { $start = (string) AIEO_DMM_Settings::get('contact_quiet_start', '22:00'); $end = (string) AIEO_DMM_Settings::get('contact_quiet_end', '09:00'); $anchor = 'tools-quiet-hours-customer-contact-window'; list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => $anchor, 'icon' => 'moon', 'title' => __('Quiet hours: marketing messages', 'ai-eshop-optimizer'), 'description' => __('No non-transactional message is sent during these hours (Europe/Athens). This is the GLOBAL window — it governs web-push now and newsletters / marketing email going forward. Transactional mail (order confirmations etc.) is never affected. Set the two times equal to disable quiet hours.', 'ai-eshop-optimizer'), 'col' => 2, 'footer' => self::save_footer($anchor, array('label' => __('Save quiet hours', 'ai-eshop-optimizer'))), )); echo $open; ob_start(); echo ''; echo ' '; echo ' '; echo '
'; echo self::form('aieo_dmm_tools_save_quiet', $anchor); echo $close; } public function render_subtab() { rooster_admin_band_open(array('cols' => 2)); $aieo_dmm_i = AIEO_DMM::instance(); $aieo_mine = method_exists($aieo_dmm_i, 'get_module') ? $aieo_dmm_i->get_module('my_settings') : null; if ($aieo_mine instanceof AIEO_DMM_Module_Base) { $aieo_mine->render_subtab(); } $cfg = $this->get_settings(); $tools = $this->get_tools(); $nonce = wp_create_nonce(AIEO_DMM::NONCE_ACTION); $ajax = admin_url('admin-ajax.php'); $this->render_branding_card($nonce, $ajax); $this->render_per_page_caps_card($nonce, $ajax); $this->render_stock_reservation_card($nonce, $ajax); $this->render_fee_guard_card(); $this->render_quiet_hours_card($nonce, $ajax); $anchor = 'tools-overview'; list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => $anchor, 'title' => __('Tools', 'ai-eshop-optimizer'), 'description' => __('Optional admin-side conveniences. Each tool has its own on/off toggle — they\'re independent of the rest of Rooster One and add no overhead when disabled.', 'ai-eshop-optimizer'), 'col' => 1, 'footer' => self::save_footer($anchor, array('label' => __('Save tools', 'ai-eshop-optimizer'))), )); echo $open; ob_start(); foreach ($tools as $slug => $info) { $on = !empty($cfg[$slug]); echo \Rooster\Admin\Ui::toggle($slug, (bool) $on, array('hidden' => false, 'id' => 'aieo-tool-' . $slug, 'label' => (string) $info['label'], 'attrs' => array('data-aieo-tool-slug' => $slug, 'aria-describedby' => 'aieo-tool-' . $slug . '-desc'))); echo \Rooster\Admin\Guidance::text((string) $info['desc'], 'guidance', array('id' => 'aieo-tool-' . $slug . '-desc')); } echo self::form('aieo_dmm_tools_save', $anchor, false, __('Reload any open product editor to apply.', 'ai-eshop-optimizer')); echo $close; $this->render_documentation_card(); self::print_saved_js(); $this->render_lookup_cards($nonce, $ajax); $this->render_wpdt_pages_card($nonce, $ajax); rooster_admin_band_close(); } private function render_documentation_card() { if (!class_exists('AIEO_Docs_Registry') || !class_exists('AIEO_Capability_Registry')) { return; } $cov = AIEO_Docs_Registry::coverage(); $state = AIEO_Docs_Registry::stale(); $stats = AIEO_Capability_Registry::stats(); list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => 'tools-documentation-coverage', 'icon' => 'book-open-check', 'title' => __('Documentation coverage', 'ai-eshop-optimizer'), 'description' => __('How much of Rooster One the manual on rooster.systems covers, and what changed after its pages were published.', 'ai-eshop-optimizer'), 'col' => 1, )); echo $open; echo \Rooster\Admin\Ui::facts(array( array( 'label' => __('Capabilities documented', 'ai-eshop-optimizer'), 'value' => sprintf( _x('%1$s of %2$s (%3$s%%)', 'documented capabilities out of all', 'ai-eshop-optimizer'), number_format_i18n((int) $cov['documented']), number_format_i18n((int) $cov['total']), number_format_i18n((float) $cov['percent'], 1) ), ), array('label' => __('Need re-publishing', 'ai-eshop-optimizer'), 'value' => number_format_i18n(count((array) $state['stale']))), array('label' => __('Pages', 'ai-eshop-optimizer'), 'value' => number_format_i18n((int) $stats['pages'])), array('label' => __('In-page tabs', 'ai-eshop-optimizer'), 'value' => number_format_i18n((int) $stats['tabs'])), array('label' => __('Described actions', 'ai-eshop-optimizer'), 'value' => number_format_i18n((int) $stats['actions'])), ), array('columns' => 2)); if (!empty($state['stale'])) { echo '' . esc_html(sprintf( __('…and %d more.', 'ai-eshop-optimizer'), count($state['stale']) - 12 )) . '
'; } } if (!empty($state['missing'])) { echo '' . esc_html(sprintf( __('%d capabilities have no manual page at all.', 'ai-eshop-optimizer'), count($state['missing']) )) . '
'; } echo $close; } private function render_wpdt_pages_card($nonce, $ajax) { $pages = (array) get_option('aieo_wpdt_enhance_pages', array()); $anchor = 'tools-report-page-enhancements-wpdatatables'; list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => $anchor, 'icon' => 'table-properties', 'title' => __('wpDataTables enhancements', 'ai-eshop-optimizer'), 'description' => __('Page IDs that get the glass-tooltip (hidden "logger" column on hover) + origin-user tagging treatment on their wpDataTables. Comma-separated WordPress page IDs.', 'ai-eshop-optimizer'), 'col' => 1, 'footer' => self::save_footer($anchor, array('label' => __('Save pages', 'ai-eshop-optimizer'))), )); echo $open; ob_start(); echo ''; echo self::form('aieo_analytics_save_pages', $anchor); echo $close; } private function render_branding_card($nonce, $ajax) { $current = AIEO_DMM::get_menu_title(); $anchor = 'tools-admin-menu-branding'; list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => $anchor, 'icon' => 'palette', 'title' => __('Admin menu branding', 'ai-eshop-optimizer'), 'description' => __('Choose the name shown on the admin sidebar\'s pill and in page titles, such as your own shop name.', 'ai-eshop-optimizer'), 'col' => 2, 'footer' => self::save_footer($anchor), )); echo $open; $why = __('Custom label for the admin sidebar pill (and the page title). Defaults to %s — set it to your own shop name, or any per-client wording. Takes effect on the next admin page load.', 'ai-eshop-optimizer'); echo self::why(sprintf($why, AIEO_DMM::MENU_TITLE_DEFAULT), sprintf(esc_html($why), '' . esc_html(AIEO_DMM::MENU_TITLE_DEFAULT) . ''));
ob_start();
echo '' . ('' !== $html ? $html : esc_html($text)) . '
'; } private static function print_saved_js() { echo ''; } public function ajax_save_branding() { AIEO_DMM::verify_ajax_request(); $title = isset($_POST['menu_title']) ? sanitize_text_field((string) wp_unslash($_POST['menu_title'])) : ''; $title = mb_substr(trim($title), 0, 60); update_option(AIEO_DMM::MENU_TITLE_OPTION, $title, false); wp_send_json_success(array( 'message' => __('Saved. Reloading to refresh the admin menu…', 'ai-eshop-optimizer'), 'effective_title' => AIEO_DMM::get_menu_title(), )); } public static function suppress_bulk_product_save_hooks() { if (class_exists('AIEO_DMM_Brand_Margins')) { AIEO_DMM_Brand_Margins::suppress_bulk_product_save_hooks(); } } public static function apply_cogs_batch($offset, $batch = 150) { if (class_exists('AIEO_DMM_Brand_Margins')) { return AIEO_DMM_Brand_Margins::apply_cogs_batch($offset, $batch); } return array('updated' => 0, 'skipped' => 0, 'next_offset' => (int) $offset, 'total' => 0, 'done' => true); } private function render_per_page_caps_card($nonce, $ajax) { $caps = $this->get_caps(); $is_admin = current_user_can('install_plugins'); $disabled = $is_admin ? '' : ' disabled'; $lock_cls = $is_admin ? '' : ' aieo-caps-locked'; $anchor = 'tools-admin-list-table-per-page-caps'; $why_id = 'aieo-tools-caps-save-reason'; if ($is_admin) { $foot = self::save_footer($anchor, array('label' => __('Save caps', 'ai-eshop-optimizer'), 'attrs' => array('id' => 'aieo-tools-caps-save'))); } else { $foot = class_exists('\Rooster\Admin\Ui') ? \Rooster\Admin\Ui::button( __('Save caps', 'ai-eshop-optimizer'), array( 'variant' => 'primary', 'type' => 'submit', 'reason' => __('Administrator only', 'ai-eshop-optimizer'), 'attrs' => array('id' => 'aieo-tools-caps-save', 'form' => self::form_id($anchor)), ) ) : ''; } list($open, $close) = AIEO_DMM_UI::card(array( 'anchor' => $anchor, 'icon' => 'list', 'title' => __('Admin list-table per-page caps', 'ai-eshop-optimizer'), 'description' => __('Hard ceiling on the "Items per page" Screen Options for the All Orders and All Products admin lists. Operators below the cap keep their personal value untouched; only those above it get clamped on the next list render. Stops 500–600 setups from grinding the admin on every refresh. Set a cap to 0 to disable enforcement for that screen.', 'ai-eshop-optimizer'), 'class' => trim($lock_cls), 'col' => 1, 'footer' => $foot, )); echo $open; echo '_global_unique_id'));
echo '