scanner = $scanner; $this->scorer = $scorer; $this->license = $license; $this->fixer = $fixer; $this->order_tracking = $order_tracking; add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); // Score badge in the admin toolbar (front-end + admin), for authorized users. add_action( 'admin_bar_menu', array( $this, 'admin_bar_node' ), 100 ); // AJAX: scan a batch, and clear results. add_action( 'wp_ajax_arwc_scan_batch', array( $this, 'ajax_scan_batch' ) ); add_action( 'wp_ajax_arwc_clear_scan', array( $this, 'ajax_clear_scan' ) ); // AJAX: fixer (preview / generate AI proposal / apply / undo). add_action( 'wp_ajax_arwc_fix_preview', array( $this, 'ajax_fix_preview' ) ); add_action( 'wp_ajax_arwc_fix_generate', array( $this, 'ajax_fix_generate' ) ); add_action( 'wp_ajax_arwc_fix_apply', array( $this, 'ajax_fix_apply' ) ); add_action( 'wp_ajax_arwc_fix_undo', array( $this, 'ajax_fix_undo' ) ); // AJAX: store-wide "apply safe fixes to all products", one batch per call. add_action( 'wp_ajax_arwc_bulk_fix_all', array( $this, 'ajax_bulk_fix_all' ) ); // AJAX: set a product's featured image from the inline media picker. add_action( 'wp_ajax_arwc_set_image', array( $this, 'ajax_set_image' ) ); // AJAX: "how an AI agent sees this product" preview. add_action( 'wp_ajax_arwc_agent_preview', array( $this, 'ajax_agent_preview' ) ); // AJAX: live OpenRouter model list + key test for the Settings page. add_action( 'wp_ajax_arwc_ai_models', array( $this, 'ajax_ai_models' ) ); add_action( 'wp_ajax_arwc_ai_test', array( $this, 'ajax_ai_test' ) ); // Settings save (license key + OpenRouter key) via admin-post. add_action( 'admin_post_arwc_save_settings', array( $this, 'handle_save_settings' ) ); // Agent Access settings save (crawler rules + llms.txt toggles) via admin-post. add_action( 'admin_post_arwc_save_access', array( $this, 'handle_save_access' ) ); // One-click deactivation of conflicting agent-access plugins. add_action( 'admin_post_arwc_deactivate_conflicts', array( $this, 'handle_deactivate_conflicts' ) ); // One-click removal of a static llms.txt that shadows our dynamic one. add_action( 'admin_post_arwc_delete_static_llms', array( $this, 'handle_delete_static_llms' ) ); // CSV export of agent orders (Pro). add_action( 'admin_post_arwc_export_agent_orders', array( $this, 'handle_export_agent_orders' ) ); // Bulk SKU/GTIN: download a CSV template + import a filled-in one. add_action( 'admin_post_arwc_identifiers_template', array( $this, 'handle_identifiers_template' ) ); add_action( 'admin_post_arwc_import_identifiers', array( $this, 'handle_import_identifiers' ) ); // Helpful "Audit" action link on the Plugins screen. add_filter( 'plugin_action_links_' . ARWC_PLUGIN_BASENAME, array( $this, 'plugin_action_links' ) ); // The AI Sales page's canonical slug is arwc-orders; accept the merchant- // friendly "arwc-ai-sales" as an alias so direct links don't 403. add_action( 'admin_init', array( $this, 'redirect_ai_sales_alias' ) ); } /** * Register the plugin's own top-level menu (Audit / Fix / Settings). * * Sits just below WooCommerce in the admin sidebar with the AgenticTrack * mark. The Audit page reuses the parent slug so it is the default landing * submenu; Fix and Settings hang off it. Hook suffixes are captured for the * asset enqueue guard. * * @return void */ public function register_menu() { $this->hook_dashboard = add_menu_page( __( 'Agent Readiness', 'agentictrack-agent-readiness-for-woocommerce' ), __( 'AgenticTrack', 'agentictrack-agent-readiness-for-woocommerce' ), self::CAPABILITY, self::PAGE_SLUG, array( $this, 'render_page' ), $this->menu_icon(), 56 // Just below WooCommerce (registered around position 55.5). ); // Re-add the parent slug as a submenu so the first entry reads "Audit" // instead of repeating the top-level "AgenticTrack" label. add_submenu_page( self::PAGE_SLUG, __( 'Agent Readiness', 'agentictrack-agent-readiness-for-woocommerce' ), __( 'Audit', 'agentictrack-agent-readiness-for-woocommerce' ), self::CAPABILITY, self::PAGE_SLUG, array( $this, 'render_page' ) ); $this->hook_get_started = add_submenu_page( self::PAGE_SLUG, __( 'Agent Readiness — Get Started', 'agentictrack-agent-readiness-for-woocommerce' ), __( 'Get Started', 'agentictrack-agent-readiness-for-woocommerce' ), self::CAPABILITY, self::GET_STARTED_PAGE_SLUG, array( $this, 'render_get_started_page' ) ); $this->hook_fix = add_submenu_page( self::PAGE_SLUG, __( 'Agent Readiness — Fix', 'agentictrack-agent-readiness-for-woocommerce' ), __( 'Fix', 'agentictrack-agent-readiness-for-woocommerce' ), self::CAPABILITY, self::FIX_PAGE_SLUG, array( $this, 'render_fix_page' ) ); $this->hook_access = add_submenu_page( self::PAGE_SLUG, __( 'Agent Readiness — Agent Access', 'agentictrack-agent-readiness-for-woocommerce' ), __( 'Agent Access', 'agentictrack-agent-readiness-for-woocommerce' ), self::CAPABILITY, self::ACCESS_PAGE_SLUG, array( $this, 'render_agent_access_page' ) ); $this->hook_orders = add_submenu_page( self::PAGE_SLUG, __( 'Agent Readiness — AI Sales', 'agentictrack-agent-readiness-for-woocommerce' ), __( 'AI Sales', 'agentictrack-agent-readiness-for-woocommerce' ), self::CAPABILITY, self::ORDERS_PAGE_SLUG, array( $this, 'render_orders_page' ) ); $this->hook_settings = add_submenu_page( self::PAGE_SLUG, __( 'Agent Readiness — Settings', 'agentictrack-agent-readiness-for-woocommerce' ), __( 'Settings', 'agentictrack-agent-readiness-for-woocommerce' ), self::CAPABILITY, self::SETTINGS_PAGE_SLUG, array( $this, 'render_settings_page' ) ); // Once the merchant has run their first audit, the Get Started guide is no // longer prime menu real estate — hide it from the sidebar (the page route // stays registered, so "Replay the setup wizard" and any deep links still // work; it's just out of the way). if ( $this->has_completed_onboarding() ) { remove_submenu_page( self::PAGE_SLUG, self::GET_STARTED_PAGE_SLUG ); } } /** * Whether the merchant is past first-run onboarding (has run an audit). Used to * de-clutter the menu once the Get Started guide has served its purpose. * * @return bool */ protected function has_completed_onboarding() { return null !== $this->scanner->get_state(); } /** * The AgenticTrack menu icon as a base64 SVG data URI. * * The brand radar mark (concentric rings + pointer) rendered in white so it * reads on the dark admin sidebar. WordPress paints menu icon data URIs as a * background image (no recolouring), so the fill is baked in. * * @return string */ protected function menu_icon() { $svg = '' . '' . '' . '' . '' . '' . '' . '' . ''; return 'data:image/svg+xml;base64,' . base64_encode( $svg ); } /** * Whether the current admin screen is our dashboard. * * @param string $hook_suffix Current admin page hook. * @return bool */ protected function is_dashboard_screen( $hook_suffix ) { return '' !== $this->hook_dashboard && $hook_suffix === $this->hook_dashboard; } /** * Whether the current admin screen is the Fix page. * * @param string $hook_suffix Current admin page hook. * @return bool */ protected function is_fix_screen( $hook_suffix ) { return '' !== $this->hook_fix && $hook_suffix === $this->hook_fix; } /** * Whether the current admin screen is the Settings page. * * @param string $hook_suffix Current admin page hook. * @return bool */ protected function is_settings_screen( $hook_suffix ) { return '' !== $this->hook_settings && $hook_suffix === $this->hook_settings; } /** * Whether the current admin screen is the Agent Access page. * * @param string $hook_suffix Current admin page hook. * @return bool */ protected function is_access_screen( $hook_suffix ) { return '' !== $this->hook_access && $hook_suffix === $this->hook_access; } /** * Whether the current admin screen is the Agent Orders page. * * @param string $hook_suffix Current admin page hook. * @return bool */ protected function is_orders_screen( $hook_suffix ) { return '' !== $this->hook_orders && $hook_suffix === $this->hook_orders; } /** * Whether the current admin screen is the Get Started page. * * @param string $hook_suffix Current admin page hook. * @return bool */ protected function is_get_started_screen( $hook_suffix ) { return '' !== $this->hook_get_started && $hook_suffix === $this->hook_get_started; } /** * Enqueue CSS/JS only on our screens. * * @param string $hook_suffix Current admin page hook. * @return void */ public function enqueue_assets( $hook_suffix ) { $is_dashboard = $this->is_dashboard_screen( $hook_suffix ); $is_fix = $this->is_fix_screen( $hook_suffix ); $is_settings = $this->is_settings_screen( $hook_suffix ); $is_access = $this->is_access_screen( $hook_suffix ); $is_orders = $this->is_orders_screen( $hook_suffix ); $is_get_started = $this->is_get_started_screen( $hook_suffix ); if ( ! $is_dashboard && ! $is_fix && ! $is_settings && ! $is_access && ! $is_orders && ! $is_get_started ) { return; } // Brand fonts (Bricolage Grotesque / IBM Plex Sans / IBM Plex Mono), // loaded only on the plugin's own admin screens. wp_enqueue_style( 'arwc-fonts', 'https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,500;12..96,600;12..96,700;12..96,800&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap', array(), ARWC_VERSION ); // Shared stylesheet powers the audit, fix, and settings views. wp_enqueue_style( 'arwc-admin', ARWC_PLUGIN_URL . 'admin/css/admin.css', array( 'arwc-fonts' ), ARWC_VERSION ); if ( $is_dashboard ) { wp_enqueue_script( 'arwc-admin', ARWC_PLUGIN_URL . 'admin/js/admin.js', array( 'jquery' ), ARWC_VERSION, true ); wp_localize_script( 'arwc-admin', 'ARWC', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE_ACTION ), 'batchSize' => $this->scanner->get_batch_size(), 'dashboardUrl' => admin_url( 'admin.php?page=' . self::PAGE_SLUG ), 'i18n' => array( 'scanning' => __( 'Scanning…', 'agentictrack-agent-readiness-for-woocommerce' ), 'completed' => __( 'Scan complete.', 'agentictrack-agent-readiness-for-woocommerce' ), 'error' => __( 'Something went wrong. Please try again.', 'agentictrack-agent-readiness-for-woocommerce' ), 'confirm' => __( 'Clear the current scan results?', 'agentictrack-agent-readiness-for-woocommerce' ), 'storeScore' => __( 'Store score', 'agentictrack-agent-readiness-for-woocommerce' ), /* translators: %d: store agent-readiness score 0-100. */ 'scoreBadge' => __( 'Agent Readiness · %d', 'agentictrack-agent-readiness-for-woocommerce' ), ), ) ); } if ( $is_fix ) { // The media library powers the inline "set featured image" picker. wp_enqueue_media(); wp_enqueue_script( 'arwc-fix', ARWC_PLUGIN_URL . 'admin/js/fix.js', array( 'jquery' ), ARWC_VERSION, true ); wp_localize_script( 'arwc-fix', 'ARWC_FIX', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::FIX_NONCE_ACTION ), 'i18n' => array( 'loading' => __( 'Loading suggestions…', 'agentictrack-agent-readiness-for-woocommerce' ), 'applying' => __( 'Applying…', 'agentictrack-agent-readiness-for-woocommerce' ), 'applied' => __( 'Applied.', 'agentictrack-agent-readiness-for-woocommerce' ), 'undoing' => __( 'Undoing…', 'agentictrack-agent-readiness-for-woocommerce' ), 'undone' => __( 'Reverted.', 'agentictrack-agent-readiness-for-woocommerce' ), 'error' => __( 'Something went wrong. Please try again.', 'agentictrack-agent-readiness-for-woocommerce' ), 'nothing' => __( 'No automatic fixes available for this product.', 'agentictrack-agent-readiness-for-woocommerce' ), 'generate' => __( 'Generate preview', 'agentictrack-agent-readiness-for-woocommerce' ), 'generating' => __( 'Generating…', 'agentictrack-agent-readiness-for-woocommerce' ), 'reviewLabel' => __( 'Proposed — review before saving', 'agentictrack-agent-readiness-for-woocommerce' ), 'saveReviewed' => __( 'Apply this', 'agentictrack-agent-readiness-for-woocommerce' ), 'save' => __( 'Save', 'agentictrack-agent-readiness-for-woocommerce' ), 'saved' => __( 'Saved.', 'agentictrack-agent-readiness-for-woocommerce' ), 'regenerate' => __( 'Regenerate', 'agentictrack-agent-readiness-for-woocommerce' ), 'confirmApply' => __( 'Apply this change to the live product?', 'agentictrack-agent-readiness-for-woocommerce' ), 'confirmBulk' => __( 'Apply all safe fixes for this product? Each change is saved and can be undone individually.', 'agentictrack-agent-readiness-for-woocommerce' ), 'enterValue' => __( 'Please enter a value first.', 'agentictrack-agent-readiness-for-woocommerce' ), 'upgrade' => __( 'Upgrade to Pro', 'agentictrack-agent-readiness-for-woocommerce' ), /* translators: 1: remaining free AI fixes, 2: daily free allowance. */ 'freeAiLeft' => __( '%1$d of %2$d free AI fixes left today · Pro is unlimited', 'agentictrack-agent-readiness-for-woocommerce' ), 'setImage' => __( 'Set featured image', 'agentictrack-agent-readiness-for-woocommerce' ), 'chooseImage' => __( 'Choose a featured image', 'agentictrack-agent-readiness-for-woocommerce' ), 'useImage' => __( 'Use this image', 'agentictrack-agent-readiness-for-woocommerce' ), 'imageSet' => __( 'Featured image set.', 'agentictrack-agent-readiness-for-woocommerce' ), 'agentTitle' => __( 'How an AI agent sees this product', 'agentictrack-agent-readiness-for-woocommerce' ), 'agentIntro' => __( 'This is the structured data an AI shopping agent receives. Missing fields (in red) are why agents skip or can’t confidently recommend a product.', 'agentictrack-agent-readiness-for-woocommerce' ), 'agentDesc' => __( 'Description text agents read', 'agentictrack-agent-readiness-for-woocommerce' ), 'agentNoDesc' => __( 'No description — agents get nothing to read here.', 'agentictrack-agent-readiness-for-woocommerce' ), 'close' => __( 'Close', 'agentictrack-agent-readiness-for-woocommerce' ), /* translators: 1: number of fixes applied so far, 2: total fixes to apply. */ 'progress' => __( 'Applied %1$d of %2$d.', 'agentictrack-agent-readiness-for-woocommerce' ), 'applyAll' => __( 'Apply all safe fixes', 'agentictrack-agent-readiness-for-woocommerce' ), /* translators: %1$d: number of safe fixes that can be applied together. */ 'bulkReady' => __( '%1$d safe fixes can be applied together.', 'agentictrack-agent-readiness-for-woocommerce' ), 'applyAllProducts' => __( 'Apply safe fixes to all products', 'agentictrack-agent-readiness-for-woocommerce' ), 'confirmAllProducts' => __( 'Apply the safe automatic fixes (schema summary + attribute tidy-up) to every published product? Each change is saved and can be undone individually per product. AI and manual fixes are not included.', 'agentictrack-agent-readiness-for-woocommerce' ), /* translators: 1: products processed so far, 2: total products. */ 'progressAllProducts' => __( 'Processed %1$d of %2$d products…', 'agentictrack-agent-readiness-for-woocommerce' ), /* translators: 1: number of fixes applied, 2: number of products changed. */ 'doneAllProducts' => __( 'Done — applied %1$d fixes across %2$d products. Re-run the audit to see updated scores.', 'agentictrack-agent-readiness-for-woocommerce' ), 'doneAllNone' => __( 'All done — no products needed a safe automatic fix.', 'agentictrack-agent-readiness-for-woocommerce' ), /* translators: 1: free cap (e.g. 5), 2: total published products. */ 'bulkCapped' => __( 'Free covers the first %1$d products. Upgrade to Pro to bulk-fix all %2$d — individual fixes stay unlimited on Free.', 'agentictrack-agent-readiness-for-woocommerce' ), ), ) ); } if ( $is_settings ) { wp_enqueue_script( 'arwc-settings', ARWC_PLUGIN_URL . 'admin/js/settings.js', array( 'jquery' ), ARWC_VERSION, true ); wp_localize_script( 'arwc-settings', 'ARWC_SETTINGS', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::MODELS_NONCE_ACTION ), 'i18n' => array( 'loading' => __( 'Loading live models…', 'agentictrack-agent-readiness-for-woocommerce' ), /* translators: %d: number of live models loaded. */ 'count' => __( '%d live models loaded — start typing to search.', 'agentictrack-agent-readiness-for-woocommerce' ), 'refresh' => __( 'Refresh model list', 'agentictrack-agent-readiness-for-woocommerce' ), 'error' => __( 'Could not load the live model list. You can still type a model id.', 'agentictrack-agent-readiness-for-woocommerce' ), 'testing' => __( 'Testing key…', 'agentictrack-agent-readiness-for-woocommerce' ), 'testErr' => __( 'Could not test the key. Please try again.', 'agentictrack-agent-readiness-for-woocommerce' ), ), ) ); } } /** * Render the dashboard page. * * @return void */ public function render_page() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } // Data exposed to the view. $summary = $this->scanner->get_summary(); $checks = $this->scorer->get_checks(); $license = $this->license; $total = $this->scanner->get_total_products(); // Score-history trend: Free sees 30 days, Pro sees a full year. $arwc_history_days = $license->is_pro() ? 365 : 30; $history = $this->scanner->get_history( $arwc_history_days ); require ARWC_PLUGIN_DIR . 'admin/views/dashboard.php'; } /** * Redirect the friendly "arwc-ai-sales" alias to the canonical AI Sales page * (arwc-orders), so direct links using the nav label as a slug don't 403. * * @return void */ public function redirect_ai_sales_alias() { if ( ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only nav redirect, no state change. return; } if ( 'arwc-ai-sales' !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only nav redirect, no state change. return; } if ( ! current_user_can( self::CAPABILITY ) ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=' . self::ORDERS_PAGE_SLUG ) ); exit; } /** * Render the Get Started onboarding guide. * * @return void */ public function render_get_started_page() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } // Data exposed to the view. $summary = $this->scanner->get_summary(); $license = $this->license; $total = $this->scanner->get_total_products(); require ARWC_PLUGIN_DIR . 'admin/views/get-started.php'; } /** * Render the Fix page. * * @return void */ public function render_fix_page() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } // Data exposed to the view. $summary = $this->scanner->get_summary(); $fixer = $this->fixer; $license = $this->license; require ARWC_PLUGIN_DIR . 'admin/views/fix.php'; } /** * Render the Settings page (license + AI key). * * @return void */ public function render_settings_page() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $license = $this->license; require ARWC_PLUGIN_DIR . 'admin/views/settings.php'; } /** * Render the Agent Access page (crawler rules + llms.txt). * * @return void */ public function render_agent_access_page() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $access = ARWC_Agent_Access::instance( $this->license ); $status = $access->status(); require ARWC_PLUGIN_DIR . 'admin/views/agent-access.php'; } /** * Render the Agent Orders page (Pro analytics; free = locked upsell + teaser). * * @return void */ public function render_orders_page() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $license = $this->license; $tracking = $this->order_tracking; $is_pro = $license->is_pro(); // Free sees a 7-day window; Pro sees the full 90-day history (+ CSV export). $arwc_window = $is_pro ? 90 : 7; $stats = $tracking->get_stats( $arwc_window ); require ARWC_PLUGIN_DIR . 'admin/views/orders.php'; } /** * Handle the Agent Access settings form (crawler + llms.txt toggles). * * @return void */ public function handle_save_access() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } check_admin_referer( 'arwc_save_access' ); $crawlers = isset( $_POST['arwc_allow_ai_crawlers'] ) ? '1' : '0'; $llms = isset( $_POST['arwc_serve_llms_txt'] ) ? '1' : '0'; update_option( ARWC_Agent_Access::OPT_CRAWLERS, $crawlers ); update_option( ARWC_Agent_Access::OPT_LLMS, $llms ); // Rebuild the llms.txt cache so changes take effect immediately. delete_transient( ARWC_Agent_Access::LLMS_TRANSIENT ); $redirect = add_query_arg( array( 'page' => self::ACCESS_PAGE_SLUG, 'arwc_settings' => 'saved', ), admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect ); exit; } /** * Deactivate the plugins that conflict with AgenticTrack's agent-access * (they also serve /llms.txt or add crawler rules), so AgenticTrack owns them. * * @return void */ public function handle_deactivate_conflicts() { if ( ! current_user_can( 'activate_plugins' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } check_admin_referer( 'arwc_deactivate_conflicts' ); $access = ARWC_Agent_Access::instance( $this->license ); $count = $access->deactivate_conflicts(); $redirect = add_query_arg( array( 'page' => self::ACCESS_PAGE_SLUG, 'arwc_deactivated' => (int) $count, ), admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect ); exit; } /** * Delete a static llms.txt at the site root so AgenticTrack's dynamic, * product-aware map takes over. * * @return void */ public function handle_delete_static_llms() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } check_admin_referer( 'arwc_delete_static_llms' ); $access = ARWC_Agent_Access::instance( $this->license ); $result = $access->delete_static_llms(); // If we deleted the file (or there was none) but the LIVE /llms.txt still // isn't ours, another source (a different plugin or a rewrite rule) is // shadowing it — say so, rather than a premature "all good". if ( in_array( $result, array( 'deleted', 'absent' ), true ) && 'no' === $access->llms_served_by_us() ) { $result = 'shadowed'; } $redirect = add_query_arg( array( 'page' => self::ACCESS_PAGE_SLUG, 'arwc_static_llms' => sanitize_key( $result ), ), admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect ); exit; } /** * Stream a CSV of agent-attributed orders (Pro only). No customer PII. * * @return void */ public function handle_export_agent_orders() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } check_admin_referer( 'arwc_export_agent_orders' ); if ( ! $this->license->is_pro() ) { wp_die( esc_html__( 'Exporting AI Sales is a Pro feature.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $rows = $this->order_tracking->get_agent_order_rows( 90 ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=agentictrack-ai-sales.csv' ); $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming CSV to the output buffer, not a file. fputcsv( $out, array( 'Order', 'Date', 'Tier', 'Agent', 'Confidence', 'Amount', 'Currency' ) ); foreach ( $rows as $row ) { fputcsv( $out, array( $row['order'], $row['date'], $row['tier'], $row['agent'], $row['confidence'], $row['amount'], $row['currency'], ) ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- writing to php://output stream, not a file. exit; } /** * Stream a CSV template for bulk SKU/GTIN entry: one row per published product, * pre-filled with its ID, name and current SKU/GTIN. The merchant edits the SKU * and GTIN columns and re-uploads it. * * @return void */ public function handle_identifiers_template() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } check_admin_referer( 'arwc_identifiers_template' ); $products = wc_get_products( array( 'status' => 'publish', 'type' => array_keys( wc_get_product_types() ), 'limit' => (int) apply_filters( 'arwc_identifiers_template_limit', 5000 ), 'orderby' => 'ID', 'order' => 'ASC', 'return' => 'objects', ) ); $products = is_array( $products ) ? $products : array(); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=agentictrack-skus-gtins.csv' ); $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming CSV to output. fputcsv( $out, array( 'Product ID', 'Product Name', 'SKU', 'GTIN' ) ); foreach ( $products as $product ) { if ( ! $product instanceof WC_Product ) { continue; } $gtin = is_callable( array( $product, 'get_global_unique_id' ) ) ? (string) $product->get_global_unique_id() : ''; if ( '' === $gtin ) { $gtin = (string) $product->get_meta( '_global_unique_id', true ); } fputcsv( $out, array( $product->get_id(), $product->get_name(), $product->get_sku(), $gtin, ) ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- output stream. exit; } /** * Import a filled-in SKU/GTIN CSV, applying each change through the identifiers * fix (so every write is validated, snapshotted and undoable). Redirects back to * the Fix page with a summary. * * @return void */ public function handle_import_identifiers() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } check_admin_referer( 'arwc_import_identifiers' ); $redirect = admin_url( 'admin.php?page=' . self::FIX_PAGE_SLUG ); // Basic upload validation. if ( empty( $_FILES['arwc_csv']['tmp_name'] ) || ! empty( $_FILES['arwc_csv']['error'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- validated below. wp_safe_redirect( add_query_arg( 'arwc_import', 'nofile', $redirect ) ); exit; } $tmp = sanitize_text_field( wp_unslash( $_FILES['arwc_csv']['tmp_name'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- path validated via is_uploaded_file next. if ( ! is_uploaded_file( $tmp ) ) { wp_safe_redirect( add_query_arg( 'arwc_import', 'nofile', $redirect ) ); exit; } $max_rows = (int) apply_filters( 'arwc_import_identifiers_max_rows', 5000 ); $updated = 0; $skipped = 0; $errors = 0; $rows = 0; $handle = fopen( $tmp, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- reading the uploaded temp file. if ( false === $handle ) { wp_safe_redirect( add_query_arg( 'arwc_import', 'unreadable', $redirect ) ); exit; } // Header row → column indexes (case-insensitive by name; fall back to order). $header = fgetcsv( $handle ); $cols = array( 'id' => 0, 'sku' => 2, 'gtin' => 3, ); if ( is_array( $header ) ) { foreach ( $header as $i => $name ) { $key = strtolower( trim( (string) $name ) ); if ( false !== strpos( $key, 'id' ) ) { $cols['id'] = $i; } elseif ( 'sku' === $key ) { $cols['sku'] = $i; } elseif ( false !== strpos( $key, 'gtin' ) || false !== strpos( $key, 'barcode' ) || false !== strpos( $key, 'upc' ) || false !== strpos( $key, 'ean' ) ) { $cols['gtin'] = $i; } } } while ( false !== ( $row = fgetcsv( $handle ) ) ) { if ( $rows >= $max_rows ) { break; } $rows++; $product_id = isset( $row[ $cols['id'] ] ) ? absint( $row[ $cols['id'] ] ) : 0; $sku = isset( $row[ $cols['sku'] ] ) ? sanitize_text_field( (string) $row[ $cols['sku'] ] ) : ''; $gtin = isset( $row[ $cols['gtin'] ] ) ? sanitize_text_field( (string) $row[ $cols['gtin'] ] ) : ''; if ( ! $product_id ) { $skipped++; continue; } // Only send fields that are non-empty AND differ from the current value, // so re-uploading the template unchanged is a safe no-op. $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { $errors++; continue; } $input = array(); $current_gtin = is_callable( array( $product, 'get_global_unique_id' ) ) ? (string) $product->get_global_unique_id() : ''; if ( '' === $current_gtin ) { $current_gtin = (string) $product->get_meta( '_global_unique_id', true ); } if ( '' !== $sku && $sku !== (string) $product->get_sku() ) { $input['sku'] = $sku; } if ( '' !== $gtin && preg_replace( '/\D+/', '', $gtin ) !== preg_replace( '/\D+/', '', $current_gtin ) ) { $input['gtin'] = $gtin; } if ( empty( $input ) ) { $skipped++; continue; } $result = $this->fixer->apply_fix( 'identifiers', $product_id, $input ); if ( is_wp_error( $result ) ) { $errors++; continue; } $this->scanner->update_product( $product_id ); $updated++; } fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- uploaded temp file. $redirect = add_query_arg( array( 'arwc_import' => 'done', 'arwc_import_updated' => $updated, 'arwc_import_skipped' => $skipped, 'arwc_import_errors' => $errors, ), $redirect ); wp_safe_redirect( $redirect ); exit; } /** * Handle the settings form submission (license key + OpenRouter key). * * @return void */ public function handle_save_settings() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } check_admin_referer( 'arwc_save_settings' ); $license_key = isset( $_POST['arwc_license_key'] ) ? sanitize_text_field( wp_unslash( $_POST['arwc_license_key'] ) ) : ''; $openrouter_key = isset( $_POST['arwc_openrouter_key'] ) ? sanitize_text_field( wp_unslash( $_POST['arwc_openrouter_key'] ) ) : ''; $openrouter_model = isset( $_POST['arwc_openrouter_model'] ) ? sanitize_text_field( wp_unslash( $_POST['arwc_openrouter_model'] ) ) : ''; // AI provider selection + the merchant's own per-provider keys (direct mode). $allowed_providers = array( 'auto', 'openrouter', 'openai', 'anthropic', 'gemini' ); $ai_provider = isset( $_POST['arwc_ai_provider'] ) ? sanitize_key( wp_unslash( $_POST['arwc_ai_provider'] ) ) : 'auto'; if ( ! in_array( $ai_provider, $allowed_providers, true ) ) { $ai_provider = 'auto'; } // Store 'auto' as an empty string (the default) so it reads as "unset". $ai_provider = ( 'auto' === $ai_provider ) ? '' : $ai_provider; $openai_key = isset( $_POST['arwc_openai_key'] ) ? sanitize_text_field( wp_unslash( $_POST['arwc_openai_key'] ) ) : ''; $anthropic_key = isset( $_POST['arwc_anthropic_key'] ) ? sanitize_text_field( wp_unslash( $_POST['arwc_anthropic_key'] ) ) : ''; $gemini_key = isset( $_POST['arwc_gemini_key'] ) ? sanitize_text_field( wp_unslash( $_POST['arwc_gemini_key'] ) ) : ''; update_option( ARWC_License::OPTION_LICENSE_KEY, $license_key ); update_option( ARWC_OpenRouter::OPTION_API_KEY, $openrouter_key ); update_option( ARWC_OpenRouter::OPTION_MODEL, $openrouter_model ); update_option( ARWC_OpenRouter::OPTION_PROVIDER, $ai_provider ); update_option( ARWC_OpenRouter::OPTION_OPENAI_KEY, $openai_key ); update_option( ARWC_OpenRouter::OPTION_ANTHROPIC_KEY, $anthropic_key ); update_option( ARWC_OpenRouter::OPTION_GEMINI_KEY, $gemini_key ); // Force an immediate re-validation so the verdict reflects the new key, and // capture the outcome so the merchant gets clear feedback (rather than a // silent stay-on-Free when the check couldn't reach the server). $fresh = $this->license->refresh(); if ( '' === $license_key ) { $check = 'none'; } elseif ( null === $fresh ) { $check = 'unreachable'; // network/timeout/non-200 — last-known-good kept. } elseif ( ! empty( $fresh['valid'] ) ) { $check = 'ok'; } else { $check = 'invalid'; // server said the key isn't valid. } $redirect = add_query_arg( array( 'page' => self::SETTINGS_PAGE_SLUG, 'arwc_settings' => 'saved', 'arwc_license_check' => $check, ), admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect ); exit; } /** * AJAX: process a single scan batch. * * @return void */ public function ajax_scan_batch() { $this->require_capability(); check_ajax_referer( self::NONCE_ACTION, 'nonce' ); $page = isset( $_POST['page'] ) ? absint( wp_unslash( $_POST['page'] ) ) : 1; $result = $this->scanner->scan_batch( $page ); // When finished, hand back the rendered summary fragments. $payload = array( 'progress' => $result, ); if ( $result['done'] ) { $payload['summaryHtml'] = $this->get_summary_html(); // Fresh store score/grade so the branded header chip and toolbar badge // can update in place (they are rendered server-side on load and would // otherwise stay stale until the next navigation). $summary = $this->scanner->get_summary(); if ( is_array( $summary ) ) { $payload['summary'] = array( 'store_score' => (int) $summary['store_score'], 'grade' => (string) $summary['grade'], ); } } wp_send_json_success( $payload ); } /** * AJAX: clear stored scan results. * * @return void */ public function ajax_clear_scan() { $this->require_capability(); check_ajax_referer( self::NONCE_ACTION, 'nonce' ); $this->scanner->clear(); wp_send_json_success(); } /** * AJAX: preview the available fixes for a product (read-only). * * @return void */ public function ajax_fix_preview() { $this->require_capability(); check_ajax_referer( self::FIX_NONCE_ACTION, 'nonce' ); $product_id = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0; $preview = $this->fixer->preview_product( $product_id ); if ( is_wp_error( $preview ) ) { wp_send_json_error( array( 'message' => $preview->get_error_message() ), 400 ); } wp_send_json_success( $preview ); } /** * AJAX: generate an AI proposal for review WITHOUT writing anything. * * Powers the "preview before saving" step for the AI fixes: the model runs * here, the proposed text is returned for the merchant to review/edit, and only * a subsequent apply (with the reviewed value) persists it. * * @return void */ public function ajax_fix_generate() { $this->require_capability(); check_ajax_referer( self::FIX_NONCE_ACTION, 'nonce' ); $fix_id = isset( $_POST['fix_id'] ) ? sanitize_key( wp_unslash( $_POST['fix_id'] ) ) : ''; $product_id = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0; $result = $this->fixer->generate_fix( $fix_id, $product_id ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); } wp_send_json_success( $result ); } /** * AJAX: apply a single fix to a product (writes, snapshotting for undo). * * @return void */ public function ajax_fix_apply() { $this->require_capability(); check_ajax_referer( self::FIX_NONCE_ACTION, 'nonce' ); $fix_id = isset( $_POST['fix_id'] ) ? sanitize_key( wp_unslash( $_POST['fix_id'] ) ) : ''; $product_id = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0; // Merchant input (e.g. GTIN/SKU). Sanitized per value; fixes re-validate. $input = array(); if ( isset( $_POST['input'] ) && is_array( $_POST['input'] ) ) { $raw = wp_unslash( $_POST['input'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each scalar value sanitized below. foreach ( $raw as $key => $value ) { // Fixes only ever expect scalar field values; ignore anything else // so sanitize_text_field() never receives an array. if ( ! is_scalar( $value ) ) { continue; } $input[ sanitize_key( $key ) ] = sanitize_text_field( (string) $value ); } } $result = $this->fixer->apply_fix( $fix_id, $product_id, $input ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); } // Re-score the product so the Fix view can update its row live. $result['rescore'] = $this->rescore_payload( $product_id ); wp_send_json_success( $result ); } /** * AJAX: undo a previously applied fix (restore the snapshot). * * @return void */ public function ajax_fix_undo() { $this->require_capability(); check_ajax_referer( self::FIX_NONCE_ACTION, 'nonce' ); $fix_id = isset( $_POST['fix_id'] ) ? sanitize_key( wp_unslash( $_POST['fix_id'] ) ) : ''; $product_id = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0; $result = $this->fixer->undo_fix( $fix_id, $product_id ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); } // Re-score after the revert so the row reflects the restored value. $result['rescore'] = $this->rescore_payload( $product_id ); wp_send_json_success( $result ); } /** * AJAX: build a plain, structured "this is what an AI shopping agent sees" * view of a product — the fields agents actually consume — highlighting what's * present vs missing. Pure education; reads only, writes nothing. * * @return void */ public function ajax_agent_preview() { $this->require_capability(); check_ajax_referer( self::FIX_NONCE_ACTION, 'nonce' ); $product_id = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0; $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { wp_send_json_error( array( 'message' => __( 'Product not found.', 'agentictrack-agent-readiness-for-woocommerce' ) ), 400 ); } $missing = __( '— missing (agents can’t rely on this)', 'agentictrack-agent-readiness-for-woocommerce' ); $currency = html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8' ); // Identifiers. $sku = (string) $product->get_sku(); $gtin = is_callable( array( $product, 'get_global_unique_id' ) ) ? (string) $product->get_global_unique_id() : ''; if ( '' === $gtin ) { $gtin = (string) $product->get_meta( '_global_unique_id', true ); } // Categories. $cats = wp_get_post_terms( $product->get_id(), 'product_cat', array( 'fields' => 'names' ) ); $cats = is_array( $cats ) ? array_map( 'wp_strip_all_tags', $cats ) : array(); // Attributes (non-empty, custom + taxonomy). $attrs = array(); foreach ( $product->get_attributes() as $attribute ) { if ( ! $attribute instanceof WC_Product_Attribute ) { continue; } if ( $attribute->is_taxonomy() ) { $terms = wc_get_product_terms( $product->get_id(), $attribute->get_name(), array( 'fields' => 'names' ) ); $values = is_array( $terms ) ? $terms : array(); $label = wc_attribute_label( $attribute->get_name() ); } else { $values = (array) $attribute->get_options(); $label = $attribute->get_name(); } $values = array_filter( array_map( 'wp_strip_all_tags', $values ) ); if ( ! empty( $values ) ) { $attrs[] = $label . ': ' . implode( ', ', $values ); } } $price = $product->get_price(); $desc = trim( wp_strip_all_tags( (string) $product->get_description() ) ); $short = trim( wp_strip_all_tags( (string) $product->get_short_description() ) ); $rows = array( array( 'label' => __( 'Name', 'agentictrack-agent-readiness-for-woocommerce' ), 'value' => $product->get_name(), 'ok' => '' !== $product->get_name(), ), array( 'label' => __( 'Price', 'agentictrack-agent-readiness-for-woocommerce' ), 'value' => ( '' !== $price ) ? $currency . wc_format_decimal( $price, wc_get_price_decimals() ) : $missing, 'ok' => '' !== $price, ), array( 'label' => __( 'Availability', 'agentictrack-agent-readiness-for-woocommerce' ), 'value' => ( 'instock' === $product->get_stock_status() ) ? __( 'In stock', 'agentictrack-agent-readiness-for-woocommerce' ) : __( 'Out of stock', 'agentictrack-agent-readiness-for-woocommerce' ), 'ok' => true, ), array( 'label' => __( 'SKU', 'agentictrack-agent-readiness-for-woocommerce' ), 'value' => ( '' !== $sku ) ? $sku : $missing, 'ok' => '' !== $sku, ), array( 'label' => __( 'GTIN / barcode', 'agentictrack-agent-readiness-for-woocommerce' ), 'value' => ( '' !== $gtin ) ? $gtin : $missing, 'ok' => '' !== $gtin, ), array( 'label' => __( 'Categories', 'agentictrack-agent-readiness-for-woocommerce' ), 'value' => ! empty( $cats ) ? implode( ', ', $cats ) : $missing, 'ok' => ! empty( $cats ), ), array( 'label' => __( 'Image', 'agentictrack-agent-readiness-for-woocommerce' ), 'value' => ( $product->get_image_id() > 0 ) ? __( 'Present', 'agentictrack-agent-readiness-for-woocommerce' ) : $missing, 'ok' => $product->get_image_id() > 0, ), array( 'label' => __( 'Attributes', 'agentictrack-agent-readiness-for-woocommerce' ), 'value' => ! empty( $attrs ) ? implode( ' · ', $attrs ) : $missing, 'ok' => ! empty( $attrs ), ), ); wp_send_json_success( array( 'name' => $product->get_name(), 'rows' => $rows, 'description' => ( '' !== $desc ) ? $desc : ( ( '' !== $short ) ? $short : '' ), 'empty_desc' => ( '' === $desc && '' === $short ), ) ); } /** * AJAX: set a product's featured image from the inline media picker on the Fix * page, then re-score its row. Writes via WooCommerce CRUD (HPOS-safe). * * @return void */ public function ajax_set_image() { $this->require_capability(); check_ajax_referer( self::FIX_NONCE_ACTION, 'nonce' ); $product_id = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0; $image_id = isset( $_POST['image_id'] ) ? absint( wp_unslash( $_POST['image_id'] ) ) : 0; $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { wp_send_json_error( array( 'message' => __( 'Product not found.', 'agentictrack-agent-readiness-for-woocommerce' ) ), 400 ); } if ( $image_id <= 0 || 'attachment' !== get_post_type( $image_id ) || 0 !== strpos( (string) get_post_mime_type( $image_id ), 'image/' ) ) { wp_send_json_error( array( 'message' => __( 'Please choose an image from your media library.', 'agentictrack-agent-readiness-for-woocommerce' ) ), 400 ); } $product->set_image_id( $image_id ); $product->save(); wp_send_json_success( array( 'thumb' => wp_get_attachment_image_url( $image_id, 'thumbnail' ), 'rescore' => $this->rescore_payload( $product_id ), ) ); } /** * AJAX: apply the safe (free, automatic) fixes to a batch of products, for the * store-wide "fix all products" runner. Processes one page per call so the * browser can show progress and the request stays fast; the JS loops pages. * * @return void */ public function ajax_bulk_fix_all() { $this->require_capability(); check_ajax_referer( self::FIX_NONCE_ACTION, 'nonce' ); $page = isset( $_POST['page'] ) ? max( 1, absint( wp_unslash( $_POST['page'] ) ) ) : 1; /** * Products processed per store-wide bulk-fix batch. Smaller than the scan * batch because each product may write several times. Clamped 1–50. * * @param int $size Batch size. */ $per_page = (int) apply_filters( 'arwc_bulk_fix_batch_size', 5 ); $per_page = max( 1, min( 50, $per_page ) ); // Free tier: the store-wide runner is capped to the first N products (per- // product fixing stays unlimited). Pro removes the cap. Filterable. $is_pro = $this->license->is_pro(); $free_cap = (int) apply_filters( 'arwc_bulk_fix_free_cap', 5 ); $free_cap = max( 1, $free_cap ); $query = wc_get_products( array( 'status' => 'publish', 'type' => array_keys( wc_get_product_types() ), 'limit' => $per_page, 'page' => $page, 'paginate' => true, 'orderby' => 'ID', 'order' => 'ASC', 'return' => 'ids', ) ); $ids = isset( $query->products ) ? (array) $query->products : array(); $total = isset( $query->total ) ? (int) $query->total : 0; // The number of products this run will actually touch (capped on Free). $effective_total = $is_pro ? $total : min( $total, $free_cap ); $start_index = ( $page - 1 ) * $per_page; $batch_products = 0; $batch_changed = 0; $batch_fixes = 0; foreach ( $ids as $offset => $product_id ) { // Stop at the Free cap (global index across pages). if ( ! $is_pro && ( $start_index + (int) $offset ) >= $free_cap ) { break; } $batch_products++; $result = $this->fixer->apply_safe_fixes( (int) $product_id ); if ( is_wp_error( $result ) ) { continue; } $applied = isset( $result['applied'] ) ? (int) $result['applied'] : 0; if ( $applied > 0 ) { $batch_changed++; $batch_fixes += $applied; // Keep the stored scan in sync so scores reflect the fixes live. $this->scanner->update_product( (int) $product_id ); } } $processed = $start_index + $batch_products; $done = ( 0 === $batch_products ) || ( $processed >= $effective_total ); $payload = array( 'processed' => (int) $processed, 'total' => (int) $effective_total, 'done' => (bool) $done, 'capped' => ( ! $is_pro && $total > $free_cap ), 'cap' => (int) $free_cap, 'store_total' => (int) $total, 'batch_changed' => (int) $batch_changed, 'batch_fixes' => (int) $batch_fixes, ); // On the final batch, hand back the fresh store score so the header chip can // update live (fixes reflect immediately without a manual re-audit). if ( $done ) { $summary = $this->scanner->get_summary(); if ( is_array( $summary ) ) { $payload['summary'] = array( 'store_score' => (int) $summary['store_score'], 'grade' => (string) $summary['grade'], ); } } wp_send_json_success( $payload ); } /** * Re-score a product and shape the result for the Fix view's live row update. * * @param int $product_id Product ID. * @return array|null Fresh score/grade/issue-count (+ store totals), or null. */ protected function rescore_payload( $product_id ) { $rescore = $this->scanner->update_product( $product_id ); if ( ! is_array( $rescore ) || empty( $rescore['row'] ) ) { return null; } $row = $rescore['row']; $summary = isset( $rescore['summary'] ) && is_array( $rescore['summary'] ) ? $rescore['summary'] : array(); return array( 'product_id' => (int) $row['id'], 'score' => (int) $row['score'], 'grade' => (string) $row['grade'], 'grade_class' => 'arwc-grade-' . (string) $row['grade'], 'issues_count' => count( (array) $row['issues'] ), 'store_score' => isset( $summary['store_score'] ) ? (int) $summary['store_score'] : null, 'store_grade' => isset( $summary['grade'] ) ? (string) $summary['grade'] : null, ); } /** * AJAX: return the live OpenRouter model list for the Settings picker. * * @return void */ public function ajax_ai_models() { $this->require_capability(); check_ajax_referer( self::MODELS_NONCE_ACTION, 'nonce' ); // Reuse the fixer's client when Pro; otherwise a bare client is fine (the // models catalogue is public and needs no license). $client = $this->fixer->ai(); if ( ! $client instanceof ARWC_OpenRouter ) { $client = new ARWC_OpenRouter(); } $force = isset( $_POST['refresh'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['refresh'] ) ); $models = $client->list_models( $force ); if ( is_wp_error( $models ) ) { wp_send_json_error( array( 'message' => $models->get_error_message() ), 400 ); } wp_send_json_success( array( 'models' => $models ) ); } /** * AJAX: test the OpenRouter API key (no tokens spent). * * @return void */ public function ajax_ai_test() { $this->require_capability(); check_ajax_referer( self::MODELS_NONCE_ACTION, 'nonce' ); // Test the value in the field (may be unsaved); empty falls back to saved. $key = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : ''; $client = new ARWC_OpenRouter(); $result = $client->test_key( $key ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); } // Build a friendly confirmation, including credit usage when reported. $message = __( 'API key works.', 'agentictrack-agent-readiness-for-woocommerce' ); if ( ! empty( $result['label'] ) ) { $message = sprintf( /* translators: %s: OpenRouter key label. */ __( 'API key works — %s.', 'agentictrack-agent-readiness-for-woocommerce' ), $result['label'] ); } if ( null !== $result['limit'] ) { $remaining = max( 0, (float) $result['limit'] - (float) $result['usage'] ); $message .= ' ' . sprintf( /* translators: %s: remaining credit amount in USD. */ __( 'Remaining credit: $%s.', 'agentictrack-agent-readiness-for-woocommerce' ), number_format_i18n( $remaining, 2 ) ); } wp_send_json_success( array( 'message' => $message ) ); } /** * Add the agent-readiness score badge to the admin toolbar. * * Mirrors the at-a-glance score chip merchants expect in the toolbar: a * coloured dot graded good/fair/poor plus the store score, linking to the * audit. Shown only to users who can see the audit, and only after a scan has * produced a score. * * @param WP_Admin_Bar $wp_admin_bar Toolbar instance. * @return void */ public function admin_bar_node( $wp_admin_bar ) { if ( ! current_user_can( self::CAPABILITY ) || ! is_object( $wp_admin_bar ) ) { return; } $colors = array( 'good' => '#46b450', 'fair' => '#dba617', 'poor' => '#dc3232', ); // On a product edit screen, show THAT product's score (what the merchant is // actually looking at) rather than the store average — otherwise the badge // looks unchanged after they just improved the open product. $product_ctx = $this->admin_bar_product_context(); if ( null !== $product_ctx ) { $color = isset( $colors[ $product_ctx['grade'] ] ) ? $colors[ $product_ctx['grade'] ] : '#a7aaad'; $label = sprintf( /* translators: %d: this product's agent-readiness score 0-100. */ __( 'This product · %d', 'agentictrack-agent-readiness-for-woocommerce' ), (int) $product_ctx['score'] ); $dot = ''; $wp_admin_bar->add_node( array( 'id' => 'arwc-score', 'title' => $dot . '' . esc_html( $label ) . '', 'href' => add_query_arg( array( 'page' => self::FIX_PAGE_SLUG, 'arwc_focus' => (int) $product_ctx['id'], ), admin_url( 'admin.php' ) ), 'meta' => array( 'title' => __( 'This product\'s AI agent-readiness score — open its fixes', 'agentictrack-agent-readiness-for-woocommerce' ), ), ) ); return; } $summary = $this->scanner->get_summary(); $scanned = is_array( $summary ) && ! empty( $summary['processed'] ); $grade = $scanned ? (string) $summary['grade'] : ''; $color = isset( $colors[ $grade ] ) ? $colors[ $grade ] : '#a7aaad'; if ( $scanned ) { $label = sprintf( /* translators: %d: store agent-readiness score 0-100. */ __( 'Agent Readiness · %d', 'agentictrack-agent-readiness-for-woocommerce' ), (int) $summary['store_score'] ); } else { $label = __( 'Agent Readiness', 'agentictrack-agent-readiness-for-woocommerce' ); } $dot = ''; $wp_admin_bar->add_node( array( 'id' => 'arwc-score', 'title' => $dot . '' . esc_html( $label ) . '', 'href' => admin_url( 'admin.php?page=' . self::PAGE_SLUG ), 'meta' => array( 'title' => __( 'Your store\'s AI agent-readiness score', 'agentictrack-agent-readiness-for-woocommerce' ), ), ) ); } /** * If the current screen is a single product editor, score that product for the * toolbar badge. Returns null on any other screen (front-end, list tables, other * post types), so the badge falls back to the store average. * * @return array{id:int,score:int,grade:string}|null */ protected function admin_bar_product_context() { if ( ! is_admin() || ! function_exists( 'get_current_screen' ) ) { return null; } $screen = get_current_screen(); if ( ! $screen || 'post' !== $screen->base || 'product' !== $screen->post_type ) { return null; } $post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen context; core-provided edit param. if ( ! $post_id ) { return null; } $product = wc_get_product( $post_id ); if ( ! $product instanceof WC_Product ) { return null; } $scored = $this->scorer->score_product( $product ); $score = (int) $scored['score']; return array( 'id' => $post_id, 'score' => $score, 'grade' => ARWC_Scorer::grade( $score ), ); } /** * Shared AJAX guard: capability check. * * Nonce verification is performed inline in each handler (with * check_ajax_referer) so it sits in the same scope as the request data it * protects. * * @return void */ protected function require_capability() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'agentictrack-agent-readiness-for-woocommerce' ) ), 403 ); } } /** * Render just the results section (used to refresh the page after a scan). * * @return string */ protected function get_summary_html() { $summary = $this->scanner->get_summary(); $license = $this->license; ob_start(); require ARWC_PLUGIN_DIR . 'admin/views/results.php'; return (string) ob_get_clean(); } /** * Add a quick link to the audit page from the Plugins list. * * @param array $links Existing action links. * @return array */ public function plugin_action_links( $links ) { $fix_url = admin_url( 'admin.php?page=' . self::FIX_PAGE_SLUG ); $audit_url = admin_url( 'admin.php?page=' . self::PAGE_SLUG ); array_unshift( $links, '' . esc_html__( 'Fix', 'agentictrack-agent-readiness-for-woocommerce' ) . '' ); array_unshift( $links, '' . esc_html__( 'Run Audit', 'agentictrack-agent-readiness-for-woocommerce' ) . '' ); return $links; } }