add_filter('pricing_url', [$this, 'change_freemius_pricing_url'], 10); resolver_fs()->add_filter('show_deactivation_feedback_form', function () { return false; }); // AJAX handlers. Nonce and capability checks are applied by // RSVL_Ajax::register() from the manifests — never inside the // handlers themselves. RSVL_Ajax::register(RSVL_Ajax::admin_routes([ 'resolver_save_settings' => 'ajax_save_settings', 'resolver_dismiss_onboarding' => 'ajax_dismiss_onboarding', 'resolver_chat_preview_status' => 'ajax_chat_preview_status', 'resolver_claim_confetti' => 'ajax_claim_confetti', ]), $this); RSVL_Model_Admin::register(); RSVL_Training_Admin::register(); RSVL_Visibility_Admin::register(); RSVL_Chats_Admin::register(); RSVL_Privacy_Admin::register(); } /** * Add the admin menu page. */ public function add_menu_page() { $slug = self::PAGE; $callback = [$this, 'render_page']; add_menu_page( __('Resolver', 'resolver-ai-chatbot'), __('Resolver', 'resolver-ai-chatbot'), 'manage_options', $slug, $callback, $this->get_menu_icon(), 90 ); // The Dashboard is the page itself, so its menu link has no tab. foreach (self::tabs() as $tab => $label) { $menu_slug = 'dashboard' === $tab ? $slug : $slug . '&tab=' . $tab; add_submenu_page($slug, $label, $label, 'manage_options', $menu_slug, $callback); } if (! resolver_is_pro()) { global $submenu; $submenu[ $slug ][] = [ __('Get Pro', 'resolver-ai-chatbot') . ' →', 'manage_options', 'https://resolverchatbot.com/pricing/', ]; } } /** * Color the Get Pro menu link, on every admin screen, since the menu is * on all of them. Added to WordPress's own menu stylesheet, which they * all load. */ public function admin_menu_css() { if (resolver_is_pro()) { return; } wp_add_inline_style('admin-menu', '#adminmenu a[href="https://resolverchatbot.com/pricing/"]{color:#f18500!important}'); } public function highlight_submenu($submenu_file) { $screen = get_current_screen(); if (! $screen || 'toplevel_page_' . self::PAGE !== $screen->id) { return $submenu_file; } $tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : ''; if ('' === $tab || 'dashboard' === $tab) { return self::PAGE; } return self::PAGE . '&tab=' . $tab; } // The menu link is removed in the SDK setup, but this makes it completely inaccessible public function remove_freemius_pricing_menu() { remove_submenu_page(self::PAGE, self::PAGE . '-pricing'); } // Changes the URL for the "Upgrade" tab in the Account menu and the Upgrade link in the plugin settings public function change_freemius_pricing_url() { return 'https://resolverchatbot.com/pricing/'; } /** * Enqueue admin assets. */ public function enqueue_assets(string $hook) { if (! self::is_plugin_screen($hook)) { return; } add_filter('admin_footer_text', '__return_empty_string', 11); add_filter('update_footer', '__return_empty_string', 11); wp_enqueue_style( 'rsvl-coloris', RSVL_PLUGIN_URL . 'admin/vendor/coloris/coloris.min.css', [], RSVL_VERSION ); wp_enqueue_script( 'rsvl-coloris', RSVL_PLUGIN_URL . 'admin/vendor/coloris/coloris.min.js', [], RSVL_VERSION, true ); wp_enqueue_style( 'rsvl-admin', RSVL_PLUGIN_URL . 'admin/css/admin.css', [], (string) filemtime(RSVL_PLUGIN_DIR . 'admin/css/admin.css') ); $admin_deps = ['jquery', 'wp-i18n', 'rsvl-coloris', 'rsvl-markdown', 'rsvl-chat-widget']; // Mirror RSVL_Settings_Page::$active_tab's own default — the Dashboard's // menu links (top-level item and submenu item) omit the tab param // entirely, unlike the plugin's own in-page nav tab for Dashboard, which // sets tab=dashboard explicitly. Missing the param here (Chart.js never // loads) is why charts silently failed to render when landing on the // Dashboard from outside the plugin. $current_tab = isset($_GET['tab']) ? sanitize_text_field(wp_unslash($_GET['tab'])) : 'dashboard'; // Registered on every screen so Pro can enqueue it for its own charts. wp_register_script('rsvl-chartjs', RSVL_PLUGIN_URL . 'admin/vendor/chartjs/chart.umd.js', [], '4.5.1', true); if (in_array($current_tab, ['dashboard', 'token-usage'], true)) { wp_enqueue_script('rsvl-chartjs'); $admin_deps[] = 'rsvl-chartjs'; } wp_enqueue_script( 'rsvl-admin', RSVL_PLUGIN_URL . 'admin/js/admin.js', $admin_deps, (string) filemtime(RSVL_PLUGIN_DIR . 'admin/js/admin.js'), true ); RSVL_Script_Strings::attach('rsvl-admin', 'admin'); // Screens with scripts of their own, which build on admin.js. $screen_scripts = ['model' => 'admin-model', 'chats' => 'admin-chats']; if (isset($screen_scripts[ $current_tab ])) { $file = 'admin/js/' . $screen_scripts[ $current_tab ] . '.js'; wp_enqueue_script( 'rsvl-' . $screen_scripts[ $current_tab ], RSVL_PLUGIN_URL . $file, ['jquery', 'rsvl-admin'], (string) filemtime(RSVL_PLUGIN_DIR . $file), true ); } // The token usage chart, on the dashboard and on Token Usage. if (in_array($current_tab, ['dashboard', 'token-usage'], true)) { wp_enqueue_script( 'rsvl-admin-charts', RSVL_PLUGIN_URL . 'admin/js/admin-charts.js', ['jquery', 'rsvl-chartjs', 'rsvl-admin'], (string) filemtime(RSVL_PLUGIN_DIR . 'admin/js/admin-charts.js'), true ); RSVL_Script_Strings::attach('rsvl-admin-charts', 'charts'); } $provider_models = []; foreach (RSVL_Provider::get_all_providers() as $id => $p) { $provider_models[ $id ] = $p->get_available_models(); } $connection_statuses = array_map(fn ($provider) => $provider->is_connected(), RSVL_Provider::get_all_providers()); $embedding_provider = resolver_get_setting('embedding_provider'); wp_localize_script('rsvl-admin', 'rsvlAdmin', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'adminUrl' => admin_url('admin.php'), 'nonce' => wp_create_nonce('resolver_admin_nonce'), 'providerModels' => $provider_models, 'connectionStatuses' => $connection_statuses, 'apiKeyUrl' => admin_url('options-connectors.php'), 'savedEmbeddingProvider' => $embedding_provider, // Switching training platforms waits for a training run to end. 'trainingRunning' => RSVL_Indexing_Status::is_running(), // Training and retraining both re-embed, so every control that // starts one stays disabled until an embedding provider is connected. 'embeddingConnected' => ! empty($connection_statuses[ $embedding_provider ]), // For numbers formatted in the browser, e.g. "de-DE". 'locale' => str_replace('_', '-', determine_locale()), // Loaded only after a live reveal wins the site's one-time claim. 'confettiUrl' => get_option('resolver_has_seen_confetti') ? '' : RSVL_PLUGIN_URL . 'admin/vendor/confetti/confetti.min.js?ver=2.2.0', ]); // Initialize the preview even when it starts hidden: training can // make it ready while this page remains open. RSVL_Widget_Renderer::enqueue_scripts(true); wp_localize_script('rsvl-chat-widget', 'rsvlChat', RSVL_Widget_Renderer::script_data(true)); } /** * Render the chat widget in the admin footer on our settings page. */ public function render_chat_preview() { $screen = get_current_screen(); if (! $screen || ! self::is_plugin_screen($screen->id)) { return; } RSVL_Widget_Renderer::render_html(true); } /** * Render the main admin page. */ public function render_page() { if (! current_user_can('manage_options')) { return; } (new RSVL_Settings_Page())->render(); } /** * Save the settings form without leaving the page (admin.js), so what the * save changes shows at once: the chat preview, the checklist, the token * alert, and the open tab's own state. * * Saving through update_option() takes the same path as options.php: * RSVL_Settings::sanitize() as the option's sanitize callback, then the * update_option_resolver_settings listeners, including the retraining a * switch of training platform starts (RSVL_Indexer::handle_settings_update()) * and the purge of pages made untrainable (RSVL_Training_Admin::maybe_purge_private_posts()). * * The response carries what the form can't know from its own fields: * values the sanitizer rewrote, and page state the save changed. Each * open tab gets its own entry, keyed by its _sections[] marker. */ public function ajax_save_settings() { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by RSVL_Settings::sanitize(), the option's registered sanitize callback. $input = isset($_POST['resolver_settings']) && is_array($_POST['resolver_settings']) ? wp_unslash($_POST['resolver_settings']) : []; $sections = isset($input['_sections']) && is_array($input['_sections']) ? array_map('sanitize_key', $input['_sections']) : []; $before = RSVL_Settings::all(); $trained_before = RSVL_Index::get_post_ids(); update_option(RSVL_Settings::OPTION, $input); $after = RSVL_Settings::all(); $data = [ // The checklist re-rendered for the saved settings; '' once dismissed. 'onboarding' => resolver_get_template('onboarding-checklist', array_merge((new RSVL_Settings_Page())->get_onboarding_data(), ['is_pro' => resolver_is_pro()])), // The header's token alert, which a new limit can show or clear. 'token_usage_percent' => RSVL_Token_Budget::usage_percent(), 'token_usage_url' => self::tab_url('token-usage'), ]; if (in_array('model', $sections, true)) { $data['model'] = (new RSVL_Model_Admin())->saved_response($before, $after); if ($data['model']['retraining']) { // Follow the retraining on the Training menu, which plays it // from the start (see admin.js). $data['redirect'] = self::tab_url('training', ['retraining' => '1']); } } if (in_array('appearance', $sections, true)) { // What the preview widget can't show live: see applyAppearanceSave() in admin.js. $data['appearance'] = array_merge(['position' => $after['position']], RSVL_Widget_Renderer::get_options()); } if (in_array('training', $sections, true)) { $training = new RSVL_Training_Admin(); $data['training'] = [ // Pages the save made untrainable, gone from the Pages table. 'removed_post_ids' => array_values(array_diff($trained_before, RSVL_Index::get_post_ids())), 'count_label' => $training->get_pages_count_label(), ]; } if (in_array('visibility', $sections, true)) { $visibility = (new RSVL_Visibility_Admin())->get_tab_data(true); $data['visibility'] = [ 'display_mode' => $after['display_mode'], // Same rule as templates/tab-visibility.php: a chatbot that // isn't displayed can't be turned on until it could show. 'display_disabled' => 'none' === $after['display_mode'] && (! $visibility['model_configured'] || ! $visibility['has_training_data']), 'display_singular_types' => $after['display_singular_types'], 'display_archives' => $after['display_archives'], 'display_special' => $after['display_special'], ]; } if (in_array('token_usage', $sections, true)) { $limit = (int) $after['monthly_token_limit']; $data['token_usage'] = [ 'limit' => $limit, /* translators: %s: number of tokens. */ 'limit_label' => $limit > 0 ? sprintf(__('%s tokens/month', 'resolver-ai-chatbot'), number_format_i18n($limit)) : '', ]; } wp_send_json_success($data); } public function ajax_dismiss_onboarding() { update_option('resolver_onboarding_dismissed', true, false); wp_send_json_success(); } public function ajax_chat_preview_status() { wp_send_json_success(['ready' => RSVL_Widget_Renderer::is_preview_ready()]); } public function ajax_claim_confetti() { // add_option's unique option name lets only one tab/admin claim it. // Polling readiness and server-rendered visibility never consume it. $claimed = RSVL_Widget_Renderer::is_preview_ready() && add_option('resolver_has_seen_confetti', true, '', false); wp_send_json_success(['claimed' => $claimed]); } /** * The menu icon: public/img/plugin-icon.svg, base64-encoded. It's drawn * with filled shapes and clip paths only: WordPress repaints a menu icon * by setting every fill to the admin color scheme's color, which would * turn a mask's black and white into one color, and doesn't touch strokes. */ private function get_menu_icon() { return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGNsaXBQYXRoIGlkPSJnYXAiPjxwYXRoIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAsMEg2NFY2NEgwWk0zMywyMC4yNUg0OUExMy43NSAxMy43NSAwIDAgMSA2Mi43NSwzNFY0MEExMy43NSAxMy43NSAwIDAgMSA0OSw1My43NUgzM0ExMy43NSAxMy43NSAwIDAgMSAxOS4yNSw0MFYzNEExMy43NSAxMy43NSAwIDAgMSAzMywyMC4yNVoiLz48L2NsaXBQYXRoPjxjbGlwUGF0aCBpZD0iZXllcyI+PHBhdGggY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCwwSDY0VjY0SDBaTTM1LjUsMzJIMzUuNUEyLjUgMi41IDAgMCAxIDM4LDM0LjVWMzkuNUEyLjUgMi41IDAgMCAxIDM1LjUsNDJIMzUuNUEyLjUgMi41IDAgMCAxIDMzLDM5LjVWMzQuNUEyLjUgMi41IDAgMCAxIDM1LjUsMzJaTTQ2LjUsMzJINDYuNUEyLjUgMi41IDAgMCAxIDQ5LDM0LjVWMzkuNUEyLjUgMi41IDAgMCAxIDQ2LjUsNDJINDYuNUEyLjUgMi41IDAgMCAxIDQ0LDM5LjVWMzQuNUEyLjUgMi41IDAgMCAxIDQ2LjUsMzJaIi8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtMC41KSIgZmlsbD0iI2E3YWFhZCI+PGcgY2xpcC1wYXRoPSJ1cmwoI2dhcCkiPjxwYXRoIGQ9Ik0xNSw1SDMxQTEyIDEyIDAgMCAxIDQzLDE3VjIzQTEyIDEyIDAgMCAxIDMxLDM1SDE1QTEyIDEyIDAgMCAxIDMsMjNWMTdBMTIgMTIgMCAwIDEgMTUsNVoiLz48cGF0aCBkPSJNOSwzMUw2LDQyTDE5LjIsMzVaIi8+PC9nPjxnIGNsaXAtcGF0aD0idXJsKCNleWVzKSI+PHBhdGggZD0iTTMzLDIySDQ5QTEyIDEyIDAgMCAxIDYxLDM0VjQwQTEyIDEyIDAgMCAxIDQ5LDUySDMzQTEyIDEyIDAgMCAxIDIxLDQwVjM0QTEyIDEyIDAgMCAxIDMzLDIyWiIvPjxwYXRoIGQ9Ik01NSw0OEw1OCw2MEw0NCw1MVoiLz48L2c+PHBhdGggZD0iTTUyLDEzLjVINTJBMS41IDEuNSAwIDAgMSA1My41LDE1VjI2QTEuNSAxLjUgMCAwIDEgNTIsMjcuNUg1MkExLjUgMS41IDAgMCAxIDUwLjUsMjZWMTVBMS41IDEuNSAwIDAgMSA1MiwxMy41WiIvPjxjaXJjbGUgY3g9IjUyIiBjeT0iMTIuNSIgcj0iMyIvPjwvZz48L3N2Zz4='; } /** * The settings page's tabs, slug => label, in menu order. */ public static function tabs(): array { return [ 'dashboard' => __('Dashboard', 'resolver-ai-chatbot'), 'model' => __('Model', 'resolver-ai-chatbot'), 'training' => __('Training', 'resolver-ai-chatbot'), 'appearance' => __('Appearance', 'resolver-ai-chatbot'), 'visibility' => __('Visibility', 'resolver-ai-chatbot'), 'chats' => __('Chats', 'resolver-ai-chatbot'), 'analytics' => __('Analytics', 'resolver-ai-chatbot'), 'token-usage' => __('Token Usage', 'resolver-ai-chatbot'), 'help' => __('Help', 'resolver-ai-chatbot'), ]; } /** * The URL of a settings page tab, and any more query arguments, such as * a panel. */ public static function tab_url(string $tab, array $args = []): string { return add_query_arg(array_merge(['page' => self::PAGE, 'tab' => $tab], $args), admin_url('admin.php')); } /** * Whether an admin screen (its hook suffix or screen ID) is one of the * plugin's: the settings page, or a page Freemius adds under it, such as * resolver-account. Matched exactly, because "resolver" alone could be * part of another plugin's page. */ public static function is_plugin_screen(string $id): bool { $freemius_pages = 'account|contact|pricing|affiliation|addons|wp-support-forum'; return 1 === preg_match('/_page_' . preg_quote(self::PAGE, '/') . "(-({$freemius_pages}))?$/", $id); } }