*/ public static function views() { return array( 'home' => array( 'slug' => self::SLUG_HOME, 'label' => __( 'Home', 'aivastark-widget' ), 'icon' => 'home', 'group' => 'main', 'type' => 'native', 'menu' => true, 'desc' => __( 'Your agent on this site, at a glance.', 'aivastark-widget' ), ), 'inbox' => array( 'slug' => 'aivastark-inbox', 'label' => __( 'Inbox', 'aivastark-widget' ), 'icon' => 'inbox', 'group' => 'conversations', 'type' => 'embed', 'embed' => 'inbox', 'path' => '/dashboard/inbox', 'menu' => true, 'desc' => __( 'Read every conversation and reply yourself when it matters.', 'aivastark-widget' ), ), 'quick-responses' => array( 'slug' => 'aivastark-quick-responses', 'label' => __( 'Quick responses', 'aivastark-widget' ), 'icon' => 'message', 'group' => 'conversations', 'type' => 'embed', 'embed' => 'quick-responses', 'path' => '/dashboard/quick-responses', 'menu' => false, 'desc' => __( 'Saved replies your team can send in one click.', 'aivastark-widget' ), ), 'editor' => array( 'slug' => self::SLUG_CUSTOMIZE, 'label' => __( 'Widget editor', 'aivastark-widget' ), 'icon' => 'palette', 'group' => 'agent', 'type' => 'embed', 'embed' => 'editor', 'path' => '/dashboard/playground', 'menu' => true, 'desc' => __( 'Colours, launcher, home screen and greeting, with a live preview.', 'aivastark-widget' ), ), 'knowledge' => array( 'slug' => 'aivastark-knowledge', 'label' => __( 'Knowledge base', 'aivastark-widget' ), 'icon' => 'book', 'group' => 'agent', 'type' => 'embed', 'embed' => 'knowledge', 'path' => '/dashboard/knowledge', 'menu' => true, 'desc' => __( 'The pages, documents and answers your agent learns from.', 'aivastark-widget' ), ), 'agent' => array( 'slug' => 'aivastark-agent', 'label' => __( 'AI agent', 'aivastark-widget' ), 'icon' => 'bot', 'group' => 'agent', 'type' => 'embed', 'embed' => 'agent', 'path' => '/dashboard/agent-settings', 'menu' => false, 'desc' => __( 'Persona, tone, language and what the agent is allowed to do.', 'aivastark-widget' ), ), 'chat-settings' => array( 'slug' => 'aivastark-chat-settings', 'label' => __( 'Chat settings', 'aivastark-widget' ), 'icon' => 'sliders', 'group' => 'agent', 'type' => 'embed', 'embed' => 'chat-settings', 'path' => '/dashboard/widgets', 'menu' => false, 'desc' => __( 'Handoff to your team, lead capture, follow-ups and answer gaps.', 'aivastark-widget' ), ), 'automations' => array( 'slug' => 'aivastark-automations', 'label' => __( 'Automations', 'aivastark-widget' ), 'icon' => 'workflow', 'group' => 'agent', 'type' => 'embed', 'embed' => 'automations', 'path' => '/dashboard/automations', 'menu' => false, 'desc' => __( 'Flows that run on their own when something happens.', 'aivastark-widget' ), ), 'leads' => array( 'slug' => 'aivastark-leads', 'label' => __( 'Leads', 'aivastark-widget' ), 'icon' => 'user-plus', 'group' => 'outcomes', 'type' => 'embed', 'embed' => 'leads', 'path' => '/dashboard/leads', 'menu' => true, 'desc' => __( 'People the agent captured, scored hot, warm or cold.', 'aivastark-widget' ), ), 'tickets' => array( 'slug' => 'aivastark-tickets', 'label' => __( 'Tickets', 'aivastark-widget' ), 'icon' => 'ticket', 'group' => 'outcomes', 'type' => 'embed', 'embed' => 'tickets', 'path' => '/dashboard/tickets', 'menu' => false, 'desc' => __( 'Issues handed to your team, each with an owner and a status.', 'aivastark-widget' ), ), 'analytics' => array( 'slug' => 'aivastark-analytics', 'label' => __( 'Analytics', 'aivastark-widget' ), 'icon' => 'chart', 'group' => 'insights', 'type' => 'embed', 'embed' => 'analytics', 'path' => '/dashboard', 'menu' => true, 'desc' => __( 'How your agent is doing: conversations, leads and trends.', 'aivastark-widget' ), ), 'visitors' => array( 'slug' => 'aivastark-visitors', 'label' => __( 'Visitors', 'aivastark-widget' ), 'icon' => 'eye', 'group' => 'insights', 'type' => 'embed', 'embed' => 'visitors', 'path' => '/dashboard/visitors', 'menu' => false, 'desc' => __( 'Who is on your site right now, and what they look at.', 'aivastark-widget' ), ), 'display' => array( 'slug' => self::SLUG_DISPLAY, 'label' => __( 'Display rules', 'aivastark-widget' ), 'icon' => 'layout', 'group' => 'site', 'type' => 'native', 'menu' => true, 'desc' => __( 'Where, for whom and when the widget loads on this site.', 'aivastark-widget' ), ), 'settings' => array( 'slug' => self::SLUG_SETTINGS, 'label' => __( 'Settings', 'aivastark-widget' ), 'icon' => 'settings', 'group' => 'site', 'type' => 'native', 'menu' => true, 'desc' => __( 'Widget Key, logged-in visitors, knowledge sync and developer hooks.', 'aivastark-widget' ), ), ); } /** Nav group headings, in order. 'main' has none. */ public static function groups() { return array( 'main' => '', 'conversations' => __( 'Conversations', 'aivastark-widget' ), 'agent' => __( 'Agent', 'aivastark-widget' ), 'outcomes' => __( 'Outcomes', 'aivastark-widget' ), 'insights' => __( 'Insights', 'aivastark-widget' ), 'site' => __( 'This WordPress site', 'aivastark-widget' ), ); } /** The view id for an admin page slug, or null. */ public static function view_for_slug( $slug ) { foreach ( self::views() as $id => $v ) { if ( $v['slug'] === $slug ) { return $id; } } return null; } /** The view being shown on this request, or null when not ours. */ public static function current_view() { $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return '' === $page ? null : self::view_for_slug( $page ); } /** wp-admin URL of a view. */ public static function view_url( $id ) { $views = self::views(); $slug = isset( $views[ $id ] ) ? $views[ $id ]['slug'] : self::SLUG_HOME; return admin_url( 'admin.php?page=' . $slug ); } /* ─── Menu + screen ──────────────────────────────────────────────── */ public static function register() { $cap = self::capability(); add_menu_page( __( 'Aivastark', 'aivastark-widget' ), __( 'Aivastark', 'aivastark-widget' ), $cap, self::SLUG_HOME, array( __CLASS__, 'render' ), self::menu_icon(), 58 ); // A view that is not listed in WordPress's submenu is registered with no // parent: WordPress serves its URL (and enforces the capability) but // lists it nowhere, and the app's own nav reaches it. Registering it under // Aivastark and then calling remove_submenu_page() is not the same thing: // WordPress then refuses the URL ("Sorry, you are not allowed to access // this page") on a reload, a new tab or a Home tile. foreach ( self::views() as $v ) { add_submenu_page( empty( $v['menu'] ) ? '' : self::SLUG_HOME, $v['label'], $v['label'], $cap, $v['slug'], array( __CLASS__, 'render' ) ); } } /** * The Aivastark mark in one colour, so WordPress's svg-painter tints it to * the admin colour scheme like a dashicon. */ private static function menu_icon() { $svg = '' . '' . ''; return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode } /** Our screens get the full-screen body classes. */ public static function body_class( $classes ) { if ( null === self::current_view() ) { return $classes; } $classes .= ' avs-screen'; if ( self::focus_mode() ) { $classes .= ' avs-focus'; } return $classes; } /** * Full-screen ("focus") mode hides WordPress's own sidebar while an * Aivastark screen is open. On by default; the top-bar menu button turns it * off and WordPress remembers the choice per user (wp-settings cookie). */ public static function focus_mode() { return 'off' !== get_user_setting( 'avsfocus', 'on' ); } /** " ‹ Aivastark ‹ Site" in the browser tab, hidden views included. */ public static function admin_title( $admin_title, $title ) { $id = self::current_view(); if ( null === $id ) { return $admin_title; } $views = self::views(); /* translators: 1: Aivastark screen, 2: site name */ return sprintf( __( '%1$s ‹ Aivastark ‹ %2$s', 'aivastark-widget' ), $views[ $id ]['label'], get_bloginfo( 'name' ) ); } /** Keep the Aivastark menu open on hidden views. */ public static function parent_file( $parent_file ) { return null === self::current_view() ? $parent_file : self::SLUG_HOME; } /** Highlight the matching WordPress submenu row (Home for hidden views). */ public static function submenu_file( $submenu_file ) { $id = self::current_view(); if ( null === $id ) { return $submenu_file; } $views = self::views(); return empty( $views[ $id ]['menu'] ) ? self::SLUG_HOME : $views[ $id ]['slug']; } /** * Drop every other plugin's admin notices on our screens. Our own outcomes * (saved, connected, …) show as toasts inside the app instead. */ public static function suppress_foreign_notices() { if ( null === self::current_view() ) { return; } remove_all_actions( 'admin_notices' ); remove_all_actions( 'all_admin_notices' ); remove_all_actions( 'network_admin_notices' ); remove_all_actions( 'user_admin_notices' ); } /** Our CSS/JS, on our screens only. */ public static function enqueue_assets() { $id = self::current_view(); if ( null === $id ) { return; } wp_enqueue_style( 'aivastark-admin', AIVASTARK_WIDGET_URL . 'admin/assets/admin.css', array(), AIVASTARK_WIDGET_VERSION ); wp_enqueue_script( 'aivastark-admin', AIVASTARK_WIDGET_URL . 'admin/assets/admin.js', array( 'utils' ), AIVASTARK_WIDGET_VERSION, true ); $views = array(); foreach ( self::views() as $vid => $v ) { $views[ $vid ] = array( 'slug' => $v['slug'], 'url' => self::view_url( $vid ), 'type' => $v['type'], 'label' => $v['label'], 'path' => isset( $v['path'] ) ? $v['path'] : '', ); } $dashboard = aivastark_widget_dashboard_url(); wp_localize_script( 'aivastark-admin', 'AivastarkAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::EMBED_NONCE ), 'view' => $id, 'views' => $views, 'connected' => Aivastark_Widget_Options::is_connected(), 'dashboardUrl' => $dashboard, 'dashboardOrigin' => self::origin_of( $dashboard ), 'i18n' => array( 'loadFailed' => __( 'Couldn\'t open this section. Check your connection and try again.', 'aivastark-widget' ), 'reconnect' => __( 'This site\'s connection to Aivastark has ended. Connect again to keep managing it here.', 'aivastark-widget' ), 'continueTab' => __( 'Your browser blocked a new tab.', 'aivastark-widget' ), 'continueLink' => __( 'Continue', 'aivastark-widget' ), 'copied' => __( 'Copied', 'aivastark-widget' ), 'unsaved' => __( 'You have unsaved changes.', 'aivastark-widget' ), 'confirmOff' => __( 'Disconnect this site from Aivastark? The widget keeps working with its Widget Key, but you won\'t be able to manage it from WordPress until you connect again.', 'aivastark-widget' ), 'confirmReset' => __( 'Put every display rule back to its default (on, every page, every visitor, every device, loaded right away)?', 'aivastark-widget' ), ), ) ); } /** scheme://host[:port] of a URL. */ private static function origin_of( $url ) { $p = wp_parse_url( $url ); if ( empty( $p['scheme'] ) || empty( $p['host'] ) ) { return ''; } return $p['scheme'] . '://' . $p['host'] . ( isset( $p['port'] ) ? ':' . $p['port'] : '' ); } /** One callback for every screen: the shell picks the page. */ public static function render() { if ( ! current_user_can( self::capability() ) ) { return; } $view = self::current_view(); if ( null === $view ) { $view = 'home'; } require AIVASTARK_WIDGET_PATH . 'admin/views/shell.php'; } /* ─── Notices (shown as toasts inside the app) ───────────────────── */ /** * The outcome carried on the URL after a redirect, if any. * * @return array|null { type: success|error, message } */ public static function notice_from_request() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- display only. if ( isset( $_GET['aivastark_error'] ) ) { $code = sanitize_key( wp_unslash( $_GET['aivastark_error'] ) ); $map = array( 'denied' => __( 'Connection cancelled.', 'aivastark-widget' ), 'missing' => __( 'The connection response was incomplete. Please try again.', 'aivastark-widget' ), 'state' => __( 'The connection link expired or was invalid. Please click Connect again.', 'aivastark-widget' ), 'exchange' => __( 'Could not complete the connection with Aivastark. Please try again.', 'aivastark-widget' ), ); return array( 'type' => 'error', 'message' => isset( $map[ $code ] ) ? $map[ $code ] : __( 'Something went wrong connecting to Aivastark.', 'aivastark-widget' ), ); } if ( isset( $_GET['aivastark_notice'] ) ) { $code = sanitize_key( wp_unslash( $_GET['aivastark_notice'] ) ); $map = array( 'connected' => __( 'Connected. Everything in the menu on the left now works right here.', 'aivastark-widget' ), 'disconnected' => __( 'Disconnected from Aivastark. The widget keeps running with its Widget Key.', 'aivastark-widget' ), 'personalize_on' => __( 'Personalization is on. The assistant now knows who is logged in.', 'aivastark-widget' ), 'personalize_off' => __( 'Personalization is off. Logged-in visitors are treated as anonymous again.', 'aivastark-widget' ), 'saved' => __( 'Saved.', 'aivastark-widget' ), 'display_reset' => __( 'Display rules are back to their defaults.', 'aivastark-widget' ), 'widget_on' => __( 'The widget is back on for this site.', 'aivastark-widget' ), 'widget_off' => __( 'The widget is off for this site. Visitors won\'t see it until you turn it back on.', 'aivastark-widget' ), 'sync_on' => __( 'Knowledge sync is on.', 'aivastark-widget' ), 'sync_off' => __( 'Knowledge sync is off.', 'aivastark-widget' ), ); if ( isset( $map[ $code ] ) ) { return array( 'type' => 'success', 'message' => $map[ $code ], ); } } // phpcs:enable return null; } /* ─── Form handlers (admin-post) ─────────────────────────────────── */ private static function guard( $nonce_action ) { if ( ! current_user_can( self::capability() ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'aivastark-widget' ), '', array( 'response' => 403 ) ); } check_admin_referer( $nonce_action ); } private static function back_to( $view_id, $notice ) { wp_safe_redirect( add_query_arg( 'aivastark_notice', $notice, self::view_url( $view_id ) ) ); exit; } /** * One-click enable/disable for logged-in-visitor personalization. Kept for * pages rendered by 2.4 that may still be open in a tab. */ public static function handle_personalize() { self::guard( 'aivastark_personalize' ); $enable = ! empty( $_POST['enable'] ); Aivastark_Widget_Options::update( array( 'personalize_users' => $enable ) ); self::back_to( 'home', $enable ? 'personalize_on' : 'personalize_off' ); } /** The one-click switches on Home: widget on/off, personalization, sync. */ public static function handle_toggle() { self::guard( 'aivastark_toggle' ); $key = isset( $_POST['key'] ) ? sanitize_key( wp_unslash( $_POST['key'] ) ) : ''; $enable = ! empty( $_POST['enable'] ); $notices = array( 'widget_enabled' => array( 'widget_on', 'widget_off' ), 'personalize_users' => array( 'personalize_on', 'personalize_off' ), 'change_ping' => array( 'sync_on', 'sync_off' ), ); if ( ! isset( $notices[ $key ] ) ) { wp_die( esc_html__( 'Unknown setting.', 'aivastark-widget' ), '', array( 'response' => 400 ) ); } Aivastark_Widget_Options::update( array( $key => $enable ) ); if ( 'change_ping' === $key && ! $enable ) { Aivastark_Widget_Change_Ping::clear_scheduled(); } $back = isset( $_POST['back'] ) ? sanitize_key( wp_unslash( $_POST['back'] ) ) : 'home'; self::back_to( isset( self::views()[ $back ] ) ? $back : 'home', $notices[ $key ][ $enable ? 0 : 1 ] ); } public static function handle_save_display() { self::guard( 'aivastark_save_display' ); $input = isset( $_POST['avs'] ) && is_array( $_POST['avs'] ) ? map_deep( wp_unslash( $_POST['avs'] ), 'sanitize_textarea_field' ) // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by map_deep, then validated field by field. : array(); $clean = Aivastark_Widget_Display_Rules::sanitize( $input, array_keys( wp_roles()->roles ) ); Aivastark_Widget_Options::update( $clean ); self::back_to( 'display', 'saved' ); } public static function handle_reset_display() { self::guard( 'aivastark_reset_display' ); Aivastark_Widget_Options::update( Aivastark_Widget_Display_Rules::defaults() ); self::back_to( 'display', 'display_reset' ); } public static function handle_save_settings() { self::guard( 'aivastark_save_settings' ); $values = array( 'widget_key' => isset( $_POST['widget_key'] ) ? sanitize_text_field( wp_unslash( $_POST['widget_key'] ) ) : '', // Unchecked checkboxes are absent from $_POST, so presence is the value. 'personalize_users' => isset( $_POST['personalize_users'] ), 'change_ping' => isset( $_POST['change_ping'] ), ); Aivastark_Widget_Options::update( $values ); if ( ! $values['change_ping'] ) { Aivastark_Widget_Change_Ping::clear_scheduled(); } // The signing secret lives in its own autoload-off option, never in the // settings array (see Aivastark_Widget_Options::SIGNING_SECRET). An empty // submission clears it (ids go back to unsigned), which is a valid choice. if ( isset( $_POST['signing_secret'] ) ) { Aivastark_Widget_Options::set_signing_secret( sanitize_text_field( wp_unslash( $_POST['signing_secret'] ) ) ); } self::back_to( 'settings', 'saved' ); } /* ─── Embedded sections ──────────────────────────────────────────── */ /** * Mint a one-time sign-in ticket and return the frame URL for a view. * Asked for by admin.js after the page paints, so the shell shows at once * and no ticket ever sits in cached HTML. */ public static function ajax_embed_ticket() { if ( ! current_user_can( self::capability() ) ) { wp_send_json_error( array( 'code' => 'forbidden' ), 403 ); } check_ajax_referer( self::EMBED_NONCE, 'nonce' ); $view = isset( $_POST['view'] ) ? sanitize_key( wp_unslash( $_POST['view'] ) ) : ''; $views = self::views(); if ( ! isset( $views[ $view ] ) || 'embed' !== $views[ $view ]['type'] ) { wp_send_json_error( array( 'code' => 'unknown_view' ), 400 ); } // Expected outcomes below answer 200 with success:false (admin.js reads // the code): an HTTP error status would also land in the console. if ( ! Aivastark_Widget_Options::is_connected() ) { wp_send_json_error( array( 'code' => 'not_connected' ) ); } $ticket = Aivastark_Widget_Api::sso_ticket(); if ( is_wp_error( $ticket ) || empty( $ticket['token_hash'] ) ) { $reason = is_wp_error( $ticket ) ? $ticket->get_error_message() : 'no_ticket'; // The install was revoked or deleted on the Aivastark side: only a // fresh Connect can fix that, so say so instead of "try again". $dead = in_array( $reason, array( 'revoked', 'invalid_token', 'invalid_refresh_token', 'refresh_expired' ), true ); wp_send_json_error( array( 'code' => $dead ? 'reconnect' : 'unavailable' ) ); } $project = isset( $ticket['project_id'] ) ? (string) $ticket['project_id'] : (string) Aivastark_Widget_Options::get( 'paired_project_id' ); $src = add_query_arg( array( 'view' => rawurlencode( $views[ $view ]['embed'] ), 'ticket' => rawurlencode( (string) $ticket['token_hash'] ), 'project' => rawurlencode( $project ), ), aivastark_widget_dashboard_url() . '/embed/wp/app' ); wp_send_json_success( array( 'src' => esc_url_raw( $src ) ) ); } }