$key, 'camera_secret' => $camera_secret, 'endpoint' => $endpoint ? $endpoint : AIMENTION_VIS_DEFAULT_ENDPOINT, 'host' => wp_parse_url( home_url(), PHP_URL_HOST ), 'ip' => array( 'proxy' => in_array( $proxy, array( 'none', 'cloudflare', 'custom' ), true ) ? $proxy : 'none', 'header' => $header, 'trusted_ranges' => array_filter( array_map( 'trim', preg_split( '/[\s,]+/', $ranges ) ) ), ), ); } /* * Activation / deactivation. */ register_activation_hook( __FILE__, 'aimention_vis_activate' ); /** * Activation: create the local queue table; resume the schedule only if a key is already set. * * @return void */ function aimention_vis_activate() { // Local only: create the queue table. A fresh, unconnected install makes NO // external call and schedules nothing -- the plugin stays completely silent // until the user connects (opts in) by setting a collection key. If a key is // already present (e.g. reactivation of a connected site), resume the schedule. AIMention_Queue::create_table(); if ( '' !== trim( (string) get_option( 'aimention_vis_key', '' ) ) ) { aimention_vis_on_connect(); } } add_action( 'add_option_aimention_vis_key', 'aimention_vis_on_connect' ); add_action( 'update_option_aimention_vis_key', 'aimention_vis_on_connect' ); /** * Opt-in point: the moment a collection key is stored (via the in-plugin sign-up * or by pasting a key on Settings), spin up the background schedule and pull the * crawler directory. This is the plugin's FIRST external call -- nothing reaches * our servers before the user connects. * * @return void */ function aimention_vis_on_connect() { if ( '' === trim( (string) get_option( 'aimention_vis_key', '' ) ) ) { return; } if ( ! wp_next_scheduled( 'aimention_vis_process' ) ) { wp_schedule_event( time() + 60, 'aimention_vis_5min', 'aimention_vis_process' ); } if ( ! wp_next_scheduled( 'aimention_vis_refresh_directory' ) ) { wp_schedule_event( time() + 120, 'daily', 'aimention_vis_refresh_directory' ); } // Pull the directory now (off the current request) so detection can start. wp_schedule_single_event( time() + 5, 'aimention_vis_refresh_directory' ); } register_deactivation_hook( __FILE__, 'aimention_vis_deactivate' ); /** * Deactivation: clear the plugin's scheduled hooks. * * @return void */ function aimention_vis_deactivate() { wp_clear_scheduled_hook( 'aimention_vis_process' ); wp_clear_scheduled_hook( 'aimention_vis_refresh_directory' ); } add_filter( 'cron_schedules', 'aimention_vis_cron_schedules' ); // phpcs:ignore WordPress.WP.CronInterval -- a 5-minute drain is a deliberate product choice; the job is a lightweight bounded batch that keeps the raw-IP queue clearing well before its 24h cap. /** * Register the plugin's 5-minute cron schedule. * * @param array $schedules Existing cron schedules. * @return array Schedules with the plugin's interval added. */ function aimention_vis_cron_schedules( $schedules ) { $schedules['aimention_vis_5min'] = array( 'interval' => 300, 'display' => __( 'Every 5 minutes (AI Mention)', 'ai-mention-visibility' ), ); return $schedules; } /* * Request capture -- the ONLY thing that runs while a visitor waits. */ add_action( 'init', 'aimention_vis_capture', 1 ); /** * Detect a verified-candidate AI crawler on the current request and queue it. * * @return void */ function aimention_vis_capture() { if ( is_admin() || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( '' === $ua || ! aimention_vis_ua_hint( $ua ) ) { return; // human fast-path: one substring check, no DB, no work. } $snapshot = AIMention_Directory::get(); if ( ! $snapshot ) { return; // no directory pulled yet -> can't identify the agent. } $agent = AIMention_Directory::match_agent( $ua, $snapshot ); if ( ! $agent ) { return; // UA looked bot-ish but is not a known crawler. } $config = aimention_vis_config(); if ( '' === $config['key'] ) { return; // plugin not configured yet. } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- values are validated with FILTER_VALIDATE_IP in the resolver; unslashing/altering them would break IP matching. $ip = AIMention_IP_Resolver::resolve( $_SERVER, $config['ip'] ); if ( ! $ip ) { return; } AIMention_Queue::enqueue( array( 'event_id' => wp_generate_uuid4(), 'path' => aimention_vis_current_path(), 'agent_id' => (string) $agent['agent_id'], 'operator' => (string) $agent['operator'], 'agent_purpose' => (string) $agent['agent_purpose'], 'raw_ip' => $ip, 'created_at' => time(), ) ); } /** * Cheap pre-filter so the vast majority of (human) requests do zero work. * Covers every current agent UA: all but two contain "bot"; the two that don't * are ChatGPT-User and Perplexity-User. * * @param string $ua The request User-Agent string. * @return bool True if the UA is worth the fuller directory match. */ function aimention_vis_ua_hint( $ua ) { return false !== stripos( $ua, 'bot' ) || false !== stripos( $ua, 'ChatGPT-User' ) || false !== stripos( $ua, 'Perplexity-User' ); } /** * The current request path (no query string), capped at 512 chars. * * @return string The request path. */ function aimention_vis_current_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; $path = wp_parse_url( $uri, PHP_URL_PATH ); return $path ? substr( $path, 0, 512 ) : '/'; } /* * Cron: refresh the directory, process the queue. */ add_action( 'aimention_vis_refresh_directory', 'aimention_vis_do_refresh' ); /** * Cron: pull the latest crawler directory (only once connected). * * @return void */ function aimention_vis_do_refresh() { $config = aimention_vis_config(); if ( '' === $config['key'] ) { return; // never contact our servers before the user has connected (opted in). } AIMention_Directory::refresh( $config['endpoint'] ); } add_action( 'aimention_vis_process', 'aimention_vis_do_process' ); /** * Cron: drain the queue -- verify each pending event and send it, then drop the row. * * @return void */ function aimention_vis_do_process() { AIMention_Queue::purge_expired(); // enforce the 24h raw-IP cap first. $config = aimention_vis_config(); if ( '' === $config['key'] ) { return; } $snapshot = AIMention_Directory::get(); $version = ( $snapshot && isset( $snapshot['version'] ) ) ? (string) $snapshot['version'] : ''; // Batch of 200 (was 50) per 5-minute run = ~57,600 events/day of drain // headroom. Real crawler events must clear before the 24h raw-IP purge drops // them, so a UA flood on one site can't bury genuine hits until they age out. // A hard guarantee would need per-visitor enqueue rate-limiting, which the // single-insert request path deliberately avoids; drain-to-empty is unsafe // because a failed send is retained for retry and would loop while the API is // down, so a larger bounded batch is the safe headroom lever instead. foreach ( AIMention_Queue::take_batch( 200 ) as $row ) { $agent = $snapshot ? aimention_vis_find_agent( $snapshot, $row['agent_id'] ) : null; if ( ! $agent ) { AIMention_Queue::delete( $row['id'] ); // agent no longer in directory. continue; } $verdict = AIMention_Classifier::classify( $row['raw_ip'], $agent ); $payload = AIMention_Sender::build_payload( $row, $verdict, array( 'key' => $config['key'], 'camera_secret' => $config['camera_secret'], 'host' => $config['host'], 'ip_prefix_or_hash' => AIMention_IP_Resolver::hash( $row['raw_ip'], $config['key'] ), 'directory_version' => $version, ) ); if ( AIMention_Sender::send( $payload, $config['endpoint'] ) ) { AIMention_Queue::delete( $row['id'] ); // sent -> raw IP gone. } // On failure: leave the row; retried next run, purged at the 24h cap. } } /** * Look up a directory agent entry by its agent_id. * * @param array $snapshot The directory snapshot. * @param string $agent_id The agent id to find. * @return array|null The agent entry, or null if not present. */ function aimention_vis_find_agent( $snapshot, $agent_id ) { foreach ( $snapshot['agents'] as $agent ) { if ( isset( $agent['agent_id'] ) && $agent['agent_id'] === $agent_id ) { return $agent; } } return null; } /* * Settings screen. */ add_action( 'admin_menu', 'aimention_vis_menu' ); /** * Register the AI Mention admin menu (Dashboard + Settings). * * @return void */ function aimention_vis_menu() { add_menu_page( __( 'AI Mention Visibility', 'ai-mention-visibility' ), __( 'AI Mention', 'ai-mention-visibility' ), 'manage_options', 'aimention-visibility', 'aimention_vis_dashboard_page', plugins_url( 'assets/mark-cream-128.webp', __FILE__ ), 26.5 ); // Rename the auto-created first submenu (same slug as parent) to "Dashboard". add_submenu_page( 'aimention-visibility', __( 'AI Mention Visibility', 'ai-mention-visibility' ), __( 'Dashboard', 'ai-mention-visibility' ), 'manage_options', 'aimention-visibility', 'aimention_vis_dashboard_page' ); // Settings on its own tab. add_submenu_page( 'aimention-visibility', __( 'AI Mention Settings', 'ai-mention-visibility' ), __( 'Settings', 'ai-mention-visibility' ), 'manage_options', 'aimention-visibility-settings', 'aimention_vis_settings_page' ); } add_action( 'admin_enqueue_scripts', 'aimention_vis_menu_icon_css' ); /** * Size the admin-menu icon so the brand mark renders crisply. * * @return void */ function aimention_vis_menu_icon_css() { wp_register_style( 'aimention-vis-admin-menu', false, array(), AIMENTION_VIS_VERSION ); wp_enqueue_style( 'aimention-vis-admin-menu' ); wp_add_inline_style( 'aimention-vis-admin-menu', '#adminmenu #toplevel_page_aimention-visibility .wp-menu-image img{width:20px;height:20px;}' ); } add_action( 'admin_enqueue_scripts', 'aimention_vis_admin_assets' ); /** * Enqueue the plugin's admin styles/scripts on its own screens only. * * @param string $hook Current admin page hook suffix. * @return void */ function aimention_vis_admin_assets( $hook ) { // Matches both "toplevel_page_aimention-visibility" (Dashboard) and // "..._page_aimention-visibility-settings" (Settings). if ( false === strpos( (string) $hook, 'aimention-visibility' ) ) { return; } // Modal CSS + support script: both tabs. wp_enqueue_style( 'aimention-vis-dashboard', plugins_url( 'assets/aimvis-dashboard.css', __FILE__ ), array(), AIMENTION_VIS_VERSION ); wp_enqueue_script( 'aimention-vis-support', plugins_url( 'assets/aimvis-support.js', __FILE__ ), array(), AIMENTION_VIS_VERSION, true ); wp_localize_script( 'aimention-vis-support', 'aimentionVisSupport', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'aimention_vis_support' ), ) ); // Dashboard-only: charts + the connect (provision) modal. if ( 'toplevel_page_aimention-visibility' === $hook ) { wp_enqueue_style( 'aimention-vis-charts', plugins_url( 'assets/aimvis-charts.css', __FILE__ ), array(), AIMENTION_VIS_VERSION ); wp_enqueue_script( 'aimention-vis-charts', plugins_url( 'assets/aimvis-charts.js', __FILE__ ), array(), AIMENTION_VIS_VERSION, true ); wp_enqueue_script( 'aimention-vis-connect', plugins_url( 'assets/aimvis-connect.js', __FILE__ ), array(), AIMENTION_VIS_VERSION, true ); wp_localize_script( 'aimention-vis-connect', 'aimentionVisConnect', array( 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'aimention_vis_provision' ), ) ); } } /** * Collect a diagnostic snapshot of the site for a support request. * * @return array Diagnostic fields (versions, active theme/plugins, config, reachability). */ function aimention_vis_gather_diagnostics() { global $wpdb; if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $active = (array) get_option( 'active_plugins', array() ); $all = function_exists( 'get_plugins' ) ? get_plugins() : array(); $plugins = array(); foreach ( $active as $slug ) { $plugins[] = isset( $all[ $slug ] ) ? $all[ $slug ]['Name'] . ' ' . $all[ $slug ]['Version'] : $slug; } $theme = wp_get_theme(); $config = aimention_vis_config(); $snapshot = AIMention_Directory::get(); // Connectivity probe: can this server reach our API at all? This is invaluable for debugging. $probe = wp_remote_get( rtrim( $config['endpoint'], '/' ) . '/api/visibility/directory/', array( 'timeout' => 5 ) ); $reach = is_wp_error( $probe ) ? 'error: ' . $probe->get_error_message() : 'HTTP ' . (int) wp_remote_retrieve_response_code( $probe ); $key = $config['key']; return array( 'plugin_version' => AIMENTION_VIS_VERSION, 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'mysql_version' => $wpdb->db_version(), 'server_software' => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : '', 'is_multisite' => is_multisite() ? 'yes' : 'no', 'memory_limit' => ini_get( 'memory_limit' ), 'max_execution_time' => ini_get( 'max_execution_time' ), 'active_theme' => $theme ? ( $theme->get( 'Name' ) . ' ' . $theme->get( 'Version' ) ) : '', 'active_plugins' => $plugins, 'site_url' => get_site_url(), 'home_url' => home_url(), 'endpoint' => $config['endpoint'], 'key_set' => ( '' !== $key ) ? ( 'yes (ending ' . substr( $key, -4 ) . ')' ) : 'no', 'proxy_mode' => $config['ip']['proxy'], 'directory' => $snapshot ? ( 'version ' . ( isset( $snapshot['version'] ) ? $snapshot['version'] : '?' ) . ', ' . count( $snapshot['agents'] ) . ' agents' ) : 'not fetched', 'cron_scheduled' => wp_next_scheduled( 'aimention_vis_process' ) ? 'yes' : 'no', 'api_reachable' => $reach, ); } add_action( 'wp_ajax_aimention_vis_support', 'aimention_vis_ajax_support' ); /** * AJAX: forward a support request (with diagnostics) to AI Mention. * * @return void */ function aimention_vis_ajax_support() { check_ajax_referer( 'aimention_vis_support', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'error' => 'forbidden' ), 403 ); } $config = aimention_vis_config(); $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; $message = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : ''; $resp = wp_remote_post( rtrim( $config['endpoint'], '/' ) . '/api/visibility/support/', array( 'timeout' => 20, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'business_name' => get_bloginfo( 'name' ), 'email' => $email, 'website' => $config['host'], 'host' => $config['host'], 'message' => $message, 'diagnostics' => aimention_vis_gather_diagnostics(), ) ), ) ); if ( is_wp_error( $resp ) ) { wp_send_json_error( array( 'error' => 'unreachable' ), 502 ); } $code = (int) wp_remote_retrieve_response_code( $resp ); if ( $code >= 200 && $code < 300 ) { wp_send_json_success( array( 'sent' => true ) ); } wp_send_json_error( array( 'error' => 'http_' . $code ), ( $code >= 400 && $code < 600 ) ? $code : 502 ); } /** * Render the "Contact support" button and its modal. * * @return void */ function aimention_vis_support_ui() { $admin_mail = get_option( 'admin_email' ); echo '

' . esc_html__( "Stuck, or something not working? Send us your site details and we'll help.", 'ai-mention-visibility' ) . '

'; echo '
'; echo ''; echo '

' . esc_html__( 'Contact support', 'ai-mention-visibility' ) . '

'; echo '

' . esc_html__( "Tell us what's happening and we'll take a look. To help us debug, we'll include a snapshot of your site: WordPress & PHP versions, active plugins and theme, this plugin's settings, and a connectivity check. No visitor data or passwords are included.", 'ai-mention-visibility' ) . '

'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo '

'; echo '
'; echo '
'; } add_action( 'wp_ajax_aimention_vis_provision', 'aimention_vis_ajax_provision' ); /** * AJAX: create a free account and store the returned collection key. * * @return void */ function aimention_vis_ajax_provision() { check_ajax_referer( 'aimention_vis_provision', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'error' => 'forbidden' ), 403 ); } $config = aimention_vis_config(); $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; $business = isset( $_POST['business_name'] ) ? sanitize_text_field( wp_unslash( $_POST['business_name'] ) ) : ''; $website = isset( $_POST['website'] ) ? sanitize_text_field( wp_unslash( $_POST['website'] ) ) : ''; $resp = wp_remote_post( rtrim( $config['endpoint'], '/' ) . '/api/visibility/provision/', array( 'timeout' => 20, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'email' => $email, 'business_name' => $business, 'website' => $website, 'host' => $config['host'], 'platform' => 'WordPress', 'platform_version' => get_bloginfo( 'version' ), ) ), ) ); if ( is_wp_error( $resp ) ) { wp_send_json_error( array( 'error' => 'unreachable' ), 502 ); } $code = (int) wp_remote_retrieve_response_code( $resp ); $data = json_decode( wp_remote_retrieve_body( $resp ), true ); if ( 201 === $code && is_array( $data ) && ! empty( $data['key'] ) ) { update_option( 'aimention_vis_key', $data['key'] ); update_option( 'aimention_vis_camera_secret', isset( $data['camera_secret'] ) ? $data['camera_secret'] : '' ); wp_send_json_success( array( 'connected' => true ) ); } $err = ( is_array( $data ) && isset( $data['error'] ) ) ? $data['error'] : 'http_' . $code; wp_send_json_error( array( 'error' => $err ), ( $code >= 400 && $code < 600 ) ? $code : 400 ); } add_action( 'admin_init', 'aimention_vis_register_settings' ); /** * Register the plugin's settings (collection key and proxy options). * * @return void */ function aimention_vis_register_settings() { register_setting( 'aimention_vis', 'aimention_vis_key', array( 'sanitize_callback' => 'sanitize_text_field' ) ); register_setting( 'aimention_vis', 'aimention_vis_proxy', array( 'sanitize_callback' => 'sanitize_text_field' ) ); register_setting( 'aimention_vis', 'aimention_vis_proxy_header', array( 'sanitize_callback' => 'sanitize_text_field' ) ); register_setting( 'aimention_vis', 'aimention_vis_proxy_ranges', array( 'sanitize_callback' => 'sanitize_textarea_field' ) ); } /** * Fetch the read-only dashboard overview from AI Mention for this site. * Cached in a transient so a page load doesn't hit the API every time. * Returns the decoded array (with 'ok' => true) on success, an * array( 'ok' => false, 'error' => ... ) on failure, or null if the plugin * isn't configured with a collection key yet. * * @param bool $force Bypass the transient cache and refetch. * @return array|null The overview data, an error array, or null if unconfigured. */ function aimention_vis_fetch_overview( $force = false ) { $config = aimention_vis_config(); if ( '' === $config['key'] ) { return null; } $cache_key = 'aimention_vis_overview_' . md5( $config['host'] . '|' . $config['key'] ); if ( ! $force ) { $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } } $resp = wp_remote_post( rtrim( $config['endpoint'], '/' ) . '/api/visibility/overview/', array( 'timeout' => 10, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'key' => $config['key'], 'host' => $config['host'], ) ), ) ); if ( is_wp_error( $resp ) ) { $out = array( 'ok' => false, 'error' => 'unreachable', ); set_transient( $cache_key, $out, 120 ); return $out; } $code = (int) wp_remote_retrieve_response_code( $resp ); $data = json_decode( wp_remote_retrieve_body( $resp ), true ); if ( 200 !== $code || ! is_array( $data ) ) { $err = ( is_array( $data ) && isset( $data['error'] ) ) ? $data['error'] : 'http_' . $code; $out = array( 'ok' => false, 'error' => $err, ); set_transient( $cache_key, $out, 120 ); return $out; } $data['ok'] = true; set_transient( $cache_key, $data, 300 ); return $data; } /** * Render the dashboard overview (or the first-run connect screen). * * @param array|null $data Overview data from aimention_vis_fetch_overview(). * @return void */ function aimention_vis_render_overview( $data ) { if ( null === $data ) { $site_name = get_bloginfo( 'name' ); $admin_mail = get_option( 'admin_email' ); $site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $settings_url = admin_url( 'admin.php?page=aimention-visibility-settings' ); $logo = plugins_url( 'assets/lockup-horizontal-h256.webp', __FILE__ ); echo '
'; echo 'AI Mention'; echo '

' . esc_html__( 'See which AIs are reading your site.', 'ai-mention-visibility' ) . '

'; echo '

' . esc_html__( 'ChatGPT, Claude, Gemini and Perplexity crawl the web to decide what to recommend. This plugin shows you which ones read your site, and the real visitors they send you, verified and free forever.', 'ai-mention-visibility' ) . '

'; echo '

'; echo '

' . esc_html__( 'Already have an account?', 'ai-mention-visibility' ) . ' ' . esc_html__( 'Paste your key in Settings', 'ai-mention-visibility' ) . ' →

'; echo '

' . esc_html__( 'Only verified AI-crawler visits are reported. Ordinary human pageviews are never recorded, and raw IP addresses never leave your server.', 'ai-mention-visibility' ) . '

'; echo '
'; // Quick-connect modal (hidden until the button opens it). echo '
'; echo ''; echo '

' . esc_html__( 'Create your free account', 'ai-mention-visibility' ) . '

'; echo '

' . esc_html__( 'Connects this site instantly. We will email you a link to set a password for full web access. No card required.', 'ai-mention-visibility' ) . '

'; echo '
'; echo ''; echo ''; echo ''; echo ''; echo '

'; echo '
'; echo '
'; echo '
'; // .aimvis-dash return; } if ( empty( $data['ok'] ) ) { echo '

' . esc_html__( 'Could not load your dashboard right now. Check your collection key and endpoint below, then reload.', 'ai-mention-visibility' ) . '

'; return; } $ov = isset( $data['overview'] ) ? $data['overview'] : array(); $crawler = isset( $ov['crawler'] ) ? $ov['crawler'] : array(); $referral = isset( $ov['referral'] ) ? $ov['referral'] : array(); $canread = isset( $data['can_read'] ) ? $data['can_read'] : null; $report = isset( $data['dashboard_url'] ) ? $data['dashboard_url'] : ''; $host = isset( $data['site']['host'] ) ? $data['site']['host'] : ''; $verified = (int) ( $crawler['verified_total'] ?? 0 ); $awaiting = (int) ( $crawler['awaiting_verification'] ?? 0 ); $ref_total = (int) ( $referral['total'] ?? 0 ); $ops = array(); foreach ( ( $crawler['by_agent'] ?? array() ) as $r ) { if ( ! empty( $r['operator'] ) ) { $ops[ $r['operator'] ] = true; } } $assistants = count( $ops ); $ready_pass = 0; $ready_total = 0; if ( is_array( $canread ) && ! empty( $canread['signals'] ) ) { foreach ( $canread['signals'] as $s ) { ++$ready_total; if ( isset( $s['status'] ) && 'pass' === $s['status'] ) { ++$ready_pass; } } } $op_colors = array( 'openai' => '#10a37f', 'anthropic' => '#d97757', 'google' => '#4285f4', 'perplexity' => '#20b8cd', 'microsoft' => '#7e57c2', 'apple' => '#6b7280', 'you' => '#ec4899', ); $bar = function ( $label_html, $count, $max, $color ) { $w = $max > 0 ? (int) round( $count / $max * 100 ) : 0; echo '
' . wp_kses_post( $label_html ) . '
' . '
' . '
' . esc_html( (string) $count ) . '
'; }; echo '
'; // Branded header with the full AI Mention logo (white card; logo has dark text). echo '
'; echo 'AI Mention'; echo '
' . esc_html__( 'Verified AI-crawler activity', 'ai-mention-visibility' ); if ( '' !== $host ) { echo ' · ' . esc_html( $host ); } echo ' · ' . esc_html__( 'last 14 days', 'ai-mention-visibility' ) . '
'; if ( '' !== $report ) { // Outcome-led, not "full report" (which implies this view is a stub). It // opens a DIFFERENT question -- recommendations -- not more crawler data. echo '' . esc_html__( 'See if AI recommends you', 'ai-mention-visibility' ) . ' →'; } echo '
'; // One educational line: reading you is not the same as recommending you. if ( '' !== $report ) { echo '' . '' . esc_html__( 'AI systems are reading your website. But are they recommending your business?', 'ai-mention-visibility' ) . '' . '' . esc_html__( 'See what AI says about you', 'ai-mention-visibility' ) . ' →'; } // Hero stats. echo '
'; echo '
' . esc_html( (string) $verified ) . '
' . esc_html__( 'Verified AI-crawler visits', 'ai-mention-visibility' ); if ( $awaiting > 0 ) { echo ' ' . sprintf( /* translators: %d: number of AI-crawler visits awaiting verification */ esc_html__( '+%d awaiting', 'ai-mention-visibility' ), (int) $awaiting ) . ''; } echo '
'; echo '
' . esc_html( (string) $ref_total ) . '
' . esc_html__( 'Visits from AI assistants', 'ai-mention-visibility' ) . '
'; echo '
' . esc_html( (string) $assistants ) . '
' . esc_html__( 'AI assistants reading you', 'ai-mention-visibility' ) . '' . esc_html__( 'Being read is not the same as being recommended.', 'ai-mention-visibility' ) . '
'; if ( $ready_total > 0 ) { echo '
' . esc_html( (string) $ready_pass ) . '/' . esc_html( (string) $ready_total ) . '
' . esc_html__( 'AI-readiness checks', 'ai-mention-visibility' ) . '
'; } echo '
'; // Connected, but nothing has arrived yet. if ( empty( $ov['has_events'] ) ) { echo '
' . esc_html__( "You're connected and watching.", 'ai-mention-visibility' ) . ' ' . esc_html__( 'No AI-crawler visits recorded yet. It can take AI a while to discover a site, so check back in a few days. Your readiness check below works right now.', 'ai-mention-visibility' ) . '
'; } // Can AI read your site? if ( is_array( $canread ) && ! empty( $canread['signals'] ) ) { echo '
' . esc_html__( 'Can AI read your site?', 'ai-mention-visibility' ) . ''; if ( $ready_total > 0 ) { echo '' . sprintf( /* translators: 1: number of readiness checks passing, 2: total number of checks */ esc_html__( '%1$d of %2$d passing', 'ai-mention-visibility' ), (int) $ready_pass, (int) $ready_total ) . ''; } echo '
'; foreach ( $canread['signals'] as $sig ) { $pass = ( isset( $sig['status'] ) && 'pass' === $sig['status'] ); echo '
' . ( $pass ? '✓' : '✗' ) . '' . '
' . esc_html( $sig['label'] ?? '' ) . ''; if ( ! empty( $sig['explain'] ) ) { echo '' . esc_html( $sig['explain'] ) . ''; } echo '
'; if ( ! $pass && ! empty( $sig['learn_url'] ) ) { echo '' . esc_html__( 'How to fix', 'ai-mention-visibility' ) . ' →'; } echo '
'; } echo '
'; } elseif ( is_array( $canread ) ) { $state = isset( $canread['state'] ) ? $canread['state'] : ''; $msg = ''; if ( 'unreachable' === $state ) { $msg = __( 'We could not reach your site to run the readability check. We will try again shortly.', 'ai-mention-visibility' ); } elseif ( ! empty( $canread['headline'] ) ) { $msg = $canread['headline'] . ( ! empty( $canread['detail'] ) ? ' ' . $canread['detail'] : '' ); } if ( '' !== $msg ) { echo '
' . esc_html__( 'Can AI read your site?', 'ai-mention-visibility' ) . '
' . '

' . esc_html( $msg ) . '

'; } } // Pair A: which AI (bars, 30%) + crawler activity (chart, 70%). $has_crawl_bars = ! empty( $crawler['by_agent'] ); $has_crawl_chart = ( ( isset( $ov['timeline_active_days'] ) ? (int) $ov['timeline_active_days'] : 0 ) > 1 && ! empty( $ov['timeline_series']['series'] ) ); if ( $has_crawl_bars && $has_crawl_chart ) { echo '
'; } if ( $has_crawl_bars ) { $max = 1; foreach ( $crawler['by_agent'] as $r ) { $max = max( $max, (int) ( $r['count'] ?? 0 ) ); } echo '
' . esc_html__( 'Which AI is reading you', 'ai-mention-visibility' ) . '
'; foreach ( $crawler['by_agent'] as $r ) { $label = esc_html( $r['operator_label'] ?? '' ) . ' ' . esc_html( $r['purpose_label'] ?? '' ) . ''; $color = $op_colors[ $r['operator'] ?? '' ] ?? '#1a6bff'; $bar( $label, (int) ( $r['count'] ?? 0 ), $max, $color ); } echo '
'; } if ( $has_crawl_chart ) { echo '
' . esc_html__( 'AI crawler activity', 'ai-mention-visibility' ) . '' . esc_html__( 'last 14 days', 'ai-mention-visibility' ) . '
'; echo '
'; echo ''; echo '
'; } if ( $has_crawl_bars && $has_crawl_chart ) { echo '
'; } // Pair B: visits from AI (bars, 30%) + visitors chart (70%). $has_ref_bars = ! empty( $referral['by_operator'] ); $has_ref_chart = ( ( isset( $referral['timeline_active_days'] ) ? (int) $referral['timeline_active_days'] : 0 ) > 1 && ! empty( $referral['timeline_series']['series'] ) ); if ( $has_ref_bars && $has_ref_chart ) { echo '
'; } if ( $has_ref_bars ) { $max = 1; foreach ( $referral['by_operator'] as $r ) { $max = max( $max, (int) ( $r['count'] ?? 0 ) ); } echo '
' . esc_html__( 'Visits from AI assistants', 'ai-mention-visibility' ) . '
'; foreach ( $referral['by_operator'] as $r ) { $color = $op_colors[ $r['operator'] ?? '' ] ?? '#1a6bff'; $bar( esc_html( $r['operator_label'] ?? '' ), (int) ( $r['count'] ?? 0 ), $max, $color ); } echo '
'; } if ( $has_ref_chart ) { echo '
' . esc_html__( 'Visitors from AI', 'ai-mention-visibility' ) . '' . esc_html__( 'last 14 days', 'ai-mention-visibility' ) . '
'; echo '
'; echo ''; echo '
'; } if ( $has_ref_bars && $has_ref_chart ) { echo '
'; } // Pages AI reads most (bars). if ( ! empty( $crawler['top_pages'] ) ) { $max = 1; foreach ( $crawler['top_pages'] as $r ) { $max = max( $max, (int) ( $r['count'] ?? 0 ) ); } echo '
' . esc_html__( 'Pages AI reads most', 'ai-mention-visibility' ) . '
'; echo '

' . esc_html__( 'These are the pages AI systems are using to understand your business.', 'ai-mention-visibility' ) . '

'; foreach ( $crawler['top_pages'] as $r ) { $bar( '' . esc_html( $r['path'] ?? '' ) . '', (int) ( $r['count'] ?? 0 ), $max, '#1a6bff' ); } if ( '' !== $report ) { echo '' . esc_html__( 'Find out what they conclude from them', 'ai-mention-visibility' ) . ' →'; } echo '
'; } echo '
'; } /** * Render the Dashboard admin page. * * @return void */ function aimention_vis_dashboard_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } echo '
'; echo '

' . esc_html__( 'AI Mention Visibility', 'ai-mention-visibility' ) . '

'; aimention_vis_support_ui(); aimention_vis_render_overview( aimention_vis_fetch_overview() ); echo '
'; } /** * Render the Settings admin page. * * @return void */ function aimention_vis_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $proxy = get_option( 'aimention_vis_proxy', 'none' ); $snapshot = AIMention_Directory::get(); ?>