*/
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 ) ) );
}
}