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;
}
}