array( 'href' => array(), 'target' => array(), 'rel' => array(), ), ); /** * Status for the current request (fetched once per admin page load). * * @var array|null|false */ private static $status = false; /** * Admin menu icon: the Clientager logo in one colour, which WordPress tints to match the admin colour scheme. */ const MENU_ICON = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMiAzMiI+PGcgZmlsbD0iYmxhY2siPjxwYXRoIGQ9Ik01IDE0LjVBMTEgOSAwIDAgMSAyNyAxNC41SDI1QTkgNyAwIDAgMCA3IDE0LjVaIi8+PHJlY3QgeD0iNC41IiB5PSIxMy41IiB3aWR0aD0iMy4yIiBoZWlnaHQ9IjYuNSIgcng9IjEuNCIvPjxyZWN0IHg9IjI0LjMiIHk9IjEzLjUiIHdpZHRoPSIzLjIiIGhlaWdodD0iNi41IiByeD0iMS40Ii8+PHJlY3QgeD0iMTUuMiIgeT0iNy41IiB3aWR0aD0iMS42IiBoZWlnaHQ9IjMuNSIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTEyLjMgMTAuNWg3LjRhMy44IDMuOCAwIDAgMSAzLjggMy44djUuNGEzLjggMy44IDAgMCAxLTMuOCAzLjhoLTcuNGEzLjggMy44IDAgMCAxLTMuOC0zLjh2LTUuNGEzLjggMy44IDAgMCAxIDMuOC0zLjhaTTEyIDEyLjJhMS44IDEuOCAwIDAgMC0xLjggMS44djEuNmExLjggMS44IDAgMCAwIDEuOCAxLjhoOGExLjggMS44IDAgMCAwIDEuOC0xLjhWMTRhMS44IDEuOCAwIDAgMC0xLjgtMS44WiIvPjxjaXJjbGUgY3g9IjEzIiBjeT0iMTQuOCIgcj0iMS4xNSIvPjxjaXJjbGUgY3g9IjE5IiBjeT0iMTQuOCIgcj0iMS4xNSIvPjxwYXRoIGQ9Ik02LjEgMTkuNWgxLjhjMCAyLjkgMi45IDQuOCA2LjYgNC44djEuOGMtNC42IDAtOC40LTIuNi04LjQtNi42WiIvPjxjaXJjbGUgY3g9IjE1IiBjeT0iMjUuMiIgcj0iMS41Ii8+PC9nPjwvc3ZnPg=='; /** * Largest distance of the chat button from the side (x) and the bottom (y), in px. Same as the server. */ /** * Chat button icons, the same as on the website (src/lib/widget-icons.ts on the server). 'custom' is the site's own image. */ const ICONS = array( 'chat' => '', 'dots' => '', 'headset' => '', 'robot' => '', 'help' => '', 'sparkle' => '', ); const OFFSET_MAX = array( 'x' => 200, 'y' => 300, ); /** * Free trial length, for labels only (the server decides the real end date). */ const FREE_TRIAL_DAYS = 7; /** * Hooks. */ public static function init() { add_action( 'admin_menu', array( __CLASS__, 'menu' ) ); add_action( 'admin_enqueue_scripts', array( __CLASS__, 'assets' ) ); add_action( 'admin_notices', array( __CLASS__, 'activation_notice' ) ); add_action( 'in_admin_header', array( __CLASS__, 'hide_notices' ), 1000 ); add_action( 'admin_post_clientager_leads_csv', array( __CLASS__, 'leads_csv' ) ); foreach ( array( 'connect_start', 'connect_verify', 'learn', 'preview', 'live', 'widget_save', 'notify_save', 'origins_save', 'convo_reply', 'disconnect', 'trial', 'request_update', 'form_save', 'email_look', 'chat_rules', 'review', 'lead_status', 'help', 'whatsapp' ) as $action ) { add_action( 'wp_ajax_clientager_' . $action, array( __CLASS__, 'ajax_' . $action ) ); } } /** * Pages: slug => title. * * @return array */ private static function pages() { return array( 'clientager' => __( 'Get started', 'clientager' ), 'clientager-preview' => __( 'Preview', 'clientager' ), 'clientager-widget' => __( 'Chat widget', 'clientager' ), 'clientager-form' => __( 'Contact form', 'clientager' ), 'clientager-inbox' => __( 'Inbox', 'clientager' ), 'clientager-requests' => __( 'Requests', 'clientager' ), 'clientager-leads' => __( 'Leads', 'clientager' ), 'clientager-settings' => __( 'Settings', 'clientager' ), 'clientager-help' => __( 'Help', 'clientager' ), ); } /** * Admin menu. */ public static function menu() { add_menu_page( __( 'Clientager', 'clientager' ), __( 'Clientager', 'clientager' ), self::CAP, 'clientager', array( __CLASS__, 'render' ), self::MENU_ICON, 58 ); foreach ( self::pages() as $slug => $title ) { add_submenu_page( 'clientager', $title . ' ‹ ' . __( 'Clientager', 'clientager' ), $title, self::CAP, $slug, array( __CLASS__, 'render' ) ); } } /** * Current page slug (one of ours) or ''. * * @return string */ private static function current_page() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only picks which screen to draw. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; return array_key_exists( $page, self::pages() ) ? $page : ''; } /** * CSS and JS on our screens only. * * @param string $hook Screen hook. */ public static function assets( $hook ) { if ( ! self::current_page() ) { return; } // The file's change time is part of the version, so a site or CDN cache never serves an older stylesheet. $ver = static function ( $file ) { $time = @filemtime( CLIENTAGER_DIR . $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged return CLIENTAGER_VERSION . ( $time ? '.' . $time : '' ); }; wp_enqueue_style( 'clientager-admin', CLIENTAGER_URL . 'assets/admin.css', array(), $ver( 'assets/admin.css' ) ); // On every Clientager screen: screens change without a page load (admin.js), so what one screen needs must be there. wp_enqueue_media(); // "Choose image" for the chat button icon. // form.js from the Clientager service draws the contact form preview exactly as visitors will see the form. $status = Clientager_Api::status( true ); $src = is_array( $status ) && ! empty( $status['contact_form']['src'] ) ? esc_url_raw( (string) $status['contact_form']['src'] ) : ''; if ( $src && wp_parse_url( $src, PHP_URL_HOST ) === wp_parse_url( Clientager_Api::base(), PHP_URL_HOST ) ) { wp_enqueue_script( 'clientager-form', $src, array(), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- versioned by the service. } wp_enqueue_script( 'clientager-admin', CLIENTAGER_URL . 'assets/admin.js', array(), $ver( 'assets/admin.js' ), true ); wp_localize_script( 'clientager-admin', 'ClientagerAdmin', array( 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE ), 'urls' => array( 'start' => admin_url( 'admin.php?page=clientager' ), 'preview' => admin_url( 'admin.php?page=clientager-preview' ), ), 'i18n' => array( 'sending' => __( 'Sending…', 'clientager' ), 'verifying' => __( 'Checking…', 'clientager' ), 'learning' => __( 'Your AI is reading your website. This takes up to a minute…', 'clientager' ), /* translators: 1: number of pages, 2: number of products. */ 'learned' => __( 'Done! Your AI learned %1$s pages and %2$s products.', 'clientager' ), 'thinking' => __( 'Typing…', 'clientager' ), /* translators: 1: messages left, 2: total free messages. */ 'left' => __( '%1$s of %2$s free test messages left', 'clientager' ), 'confirmOff' => __( 'Disconnect this site from Clientager? The chat is removed from your website.', 'clientager' ), 'error' => __( 'Something went wrong. Please try again.', 'clientager' ), 'tryPreview' => __( 'Try your AI now', 'clientager' ), 'you' => __( 'You', 'clientager' ), 'saving' => __( 'Saving…', 'clientager' ), 'writeNote' => __( 'Write a note first.', 'clientager' ), 'logoBad' => __( 'Upload a PNG or JPG image of up to 2 MB.', 'clientager' ), 'saved' => __( 'Saved. Your chat uses the new settings now.', 'clientager' ), 'savedLimits' => __( 'Saved. New messages follow these limits.', 'clientager' ), 'waOpening' => __( 'Opening the secure WhatsApp window…', 'clientager' ), 'waWaiting' => __( 'Finish the steps in the WhatsApp window. This page updates by itself when you are done.', 'clientager' ), 'waBlocked' => __( 'Your browser blocked the window. Allow pop-ups for this page and try again.', 'clientager' ), 'waShown' => __( 'Saved. The WhatsApp button shows in your website chat.', 'clientager' ), 'waHidden' => __( 'Saved. The WhatsApp button is hidden from your website chat.', 'clientager' ), 'waOff' => __( 'Disconnect WhatsApp? Your AI stops answering it. The number stays yours.', 'clientager' ), 'helpSent' => __( 'Thanks, we got your message. We will reply to your email, usually within one business day.', 'clientager' ), 'badColor' => __( 'Pick a colour like #2563eb.', 'clientager' ), 'resetDone' => __( 'Back to the defaults. Click Save changes to use them on your website.', 'clientager' ), 'resetAsk' => __( 'Put every chat setting back to its default? Nothing changes on your website until you save.', 'clientager' ), 'greeting' => __( 'Hi! How can we help you today?', 'clientager' ), 'autoHi' => __( 'Hi there! Have a question? We are here to help.', 'clientager' ), 'product_text' => isset( $w['auto_hi']['product_text'] ) ? sanitize_text_field( (string) $w['auto_hi']['product_text'] ) : '', 'exit' => ! empty( $w['auto_hi']['exit'] ), 'view' => __( 'View', 'clientager' ), 'addToCart' => __( 'Add to cart', 'clientager' ), 'pickIcon' => __( 'Chat button icon', 'clientager' ), 'useIcon' => __( 'Use this image', 'clientager' ), 'noGreeting' => __( 'Write a greeting first.', 'clientager' ), 'openChat' => __( 'Open chat', 'clientager' ), 'closeChat' => __( 'Close chat', 'clientager' ), ), ) ); } /** * One dismissible notice on the Plugins screen right after activation. */ public static function activation_notice() { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || 'plugins' !== $screen->id || ! current_user_can( self::CAP ) || ! get_transient( 'clientager_activation_notice' ) ) { return; } delete_transient( 'clientager_activation_notice' ); if ( Clientager_Api::token() ) { return; } printf( '

%1$s %3$s

', esc_html__( 'Clientager is installed. Connect it to let your AI learn your website and try it for free.', 'clientager' ), esc_url( admin_url( 'admin.php?page=clientager' ) ), esc_html__( 'Get started', 'clientager' ) ); } /** * Status for this request. * * @return array|null */ private static function status() { if ( false === self::$status ) { self::$status = Clientager_Api::status( false ); } return self::$status; } /** * Draws the current screen. */ public static function render() { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'clientager' ) ); } $page = self::current_page(); $status = self::status(); echo '
'; self::header( $page, $status ); echo '
'; if ( is_array( $status ) && ! empty( $status['blocked'] ) ) { self::notice( 'error', __( 'This website has been blocked from Clientager. Please contact support@clientager.com.', 'clientager' ) ); } elseif ( is_array( $status ) && ! empty( $status['unreachable'] ) ) { self::notice( 'warning', __( 'Clientager could not be reached just now. Please reload the page in a moment.', 'clientager' ) ); } elseif ( ! is_array( $status ) ) { self::screen_connect(); } else { switch ( $page ) { case 'clientager-preview': self::screen_preview( $status ); break; case 'clientager-widget': self::screen_widget( $status ); break; case 'clientager-form': self::screen_form( $status ); break; case 'clientager-inbox': self::screen_inbox(); break; case 'clientager-requests': self::screen_requests(); break; case 'clientager-leads': self::screen_leads(); break; case 'clientager-settings': self::screen_settings( $status ); break; case 'clientager-help': self::screen_help(); break; default: self::screen_overview( $status ); } } echo '
'; } /** * App bar (logo, business, status) and tabs. * * @param string $page Current page. * @param array|null $status Status. */ private static function header( $page, $status ) { $connected = is_array( $status ) && empty( $status['unreachable'] ) && empty( $status['blocked'] ); if ( ! $connected ) { $state = array( 'off', __( 'Not connected', 'clientager' ) ); } elseif ( ! empty( $status['live'] ) ) { $state = array( 'live', ! empty( $status['trial']['days_left'] ) ? sprintf( /* translators: %d: days left in the free trial. */ _n( 'Live · free trial, %d day left', 'Live · free trial, %d days left', (int) $status['trial']['days_left'], 'clientager' ), (int) $status['trial']['days_left'] ) : __( 'Live on your website', 'clientager' ) ); } elseif ( ! empty( $status['paid'] ) ) { $state = array( 'ready', ! empty( $status['trial']['days_left'] ) ? sprintf( /* translators: %d: days left in the free trial. */ _n( 'Free trial · %d day left', 'Free trial · %d days left', (int) $status['trial']['days_left'], 'clientager' ), (int) $status['trial']['days_left'] ) : __( 'Ready to go live', 'clientager' ) ); } elseif ( self::trial_ended( $status ) ) { $state = array( 'preview', __( 'Free trial ended', 'clientager' ) ); } else { $state = array( 'preview', __( 'Free preview', 'clientager' ) ); } echo '
'; self::icon( 'logo', 'cg-logo' ); echo '

' . esc_html__( 'Clientager', 'clientager' ) . '

'; echo '' . esc_html( $connected && ! empty( $status['business'] ) ? (string) $status['business'] : __( 'AI chatbot & receptionist', 'clientager' ) ) . ''; echo '
'; printf( '%2$s', esc_attr( $state[0] ), esc_html( $state[1] ) ); echo '
'; if ( ! $connected ) { return; } $icons = array( 'clientager' => 'rocket', 'clientager-preview' => 'chat', 'clientager-widget' => 'brush', 'clientager-form' => 'form', 'clientager-inbox' => 'inbox', 'clientager-requests' => 'ticket', 'clientager-leads' => 'users', 'clientager-settings' => 'settings', 'clientager-help' => 'help', ); echo ''; } /** * An inline alert inside our screens (never a WordPress admin notice). * * @param string $type success|warning|error|info. * @param string $message Text. */ private static function notice( $type, $message ) { printf( '
%2$s
', esc_attr( $type ), esc_html( $message ) ); } /** * Card heading with optional icon and subtitle. * * @param string $title Title. * @param string $sub Subtitle. * @param string $icon Icon name. */ private static function card_head( $title, $sub = '', $icon = '' ) { echo '
'; if ( $icon ) { echo ''; self::icon( $icon ); echo ''; } echo '

' . esc_html( $title ) . '

'; if ( $sub ) { echo '

' . esc_html( $sub ) . '

'; } echo '
'; } /** * Friendly empty state. * * @param string $icon Icon. * @param string $title Title. * @param string $text Text. */ private static function empty_state( $icon, $title, $text ) { echo '
'; self::icon( $icon ); echo '

' . esc_html( $title ) . '

' . esc_html( $text ) . '

'; } /* ------------------------------------------------------------------ screens */ /** * Not connected: email + code. */ private static function screen_connect() { $user = wp_get_current_user(); $email = $user && $user->user_email ? $user->user_email : get_option( 'admin_email' ); $steps = array( array( __( 'Connect with your email', 'clientager' ), __( 'No password. We send you a 6-digit code.', 'clientager' ) ), array( __( 'Your AI reads your website', 'clientager' ), __( 'Services, prices, opening hours and products.', 'clientager' ) ), array( __( 'Test it privately', 'clientager' ), __( '25 free messages, right here in WordPress.', 'clientager' ) ), array( __( 'Go live when you are happy', 'clientager' ), __( 'Start a 7-day free trial (new accounts) and put it on your website.', 'clientager' ) ), ); ?>

    $st ) : ?>

0 || $paid, __( 'Test it privately', 'clientager' ), __( '25 free messages', 'clientager' ) ), array( $paid, self::trial_days( $s ) || ! empty( $s['trial']['days_left'] ) ? __( 'Start your free trial', 'clientager' ) : __( 'Choose a plan', 'clientager' ), ! empty( $s['trial']['days_left'] ) ? sprintf( /* translators: %d: days left in the free trial. */ _n( 'Free trial, %d day left', 'Free trial, %d days left', (int) $s['trial']['days_left'], 'clientager' ), (int) $s['trial']['days_left'] ) : self::plan_price_line( $s ) ), array( $live, __( 'Live on your website', 'clientager' ), __( 'One click', 'clientager' ) ), ); $current = 0; foreach ( $steps as $i => $st ) { if ( ! $st[0] ) { $current = $i; break; } $current = $i + 1; } if ( ! $learned ) { $next = array( __( 'Let your AI learn your website', 'clientager' ), __( 'It reads your published pages and products, so it can answer questions about your business. Nothing is changed on your site.', 'clientager' ), 'sparkles' ); } elseif ( self::trial_ended( $s ) ) { $next = array( __( 'Your free trial has ended', 'clientager' ), __( 'The chat is off on your website. Choose a plan to switch it back on. Your conversations, leads and settings are kept.', 'clientager' ), 'lock' ); } elseif ( ! $paid ) { $next = array( __( 'Your AI is ready. Try it now', 'clientager' ), self::trial_days( $s ) ? sprintf( /* translators: %d: number of free trial days. */ __( 'Ask the questions your customers ask: prices, opening hours, services. Only you can see the preview. Happy with it? Put it on your website with a %d-day free trial.', 'clientager' ), self::trial_days( $s ) ) : __( 'Ask the questions your customers ask: prices, opening hours, services. Only you can see the preview.', 'clientager' ), 'chat' ); } elseif ( ! $live ) { $next = array( __( 'Put your AI on your website', 'clientager' ), __( 'One click adds the chat to every page. You can switch it off any time in Settings.', 'clientager' ), 'rocket' ); } else { $next = array( __( 'Your AI is live', 'clientager' ), __( 'It answers your visitors 24/7. New chats and leads appear in Inbox and Leads.', 'clientager' ), 'check' ); } ?>
    $st ) : ?>
8 ) ); if ( ! $res['ok'] ) { return; } $d = $res['data']; $money = static function ( $n, $c ) { return function_exists( 'wc_price' ) ? wp_strip_all_tags( wc_price( (float) $n, array( 'currency' => (string) $c ) ) ) : $n . ' ' . $c; }; $totals = array(); foreach ( isset( $d['totals'] ) && is_array( $d['totals'] ) ? $d['totals'] : array() as $t ) { $totals[] = $money( isset( $t['amount'] ) ? $t['amount'] : 0, isset( $t['currency'] ) ? $t['currency'] : '' ); } ?>

'; $trial = ! empty( $s['trial']['ends_at'] ) ? $s['trial'] : null; self::stat_row( __( 'Plan', 'clientager' ), $paid && ! empty( $s['plan'] ) ? ucfirst( (string) $s['plan'] ) . ( $trial ? ' · ' . __( 'free trial', 'clientager' ) : '' ) : ( self::trial_ended( $s ) ? __( 'Free trial ended', 'clientager' ) : __( 'Free preview', 'clientager' ) ) ); if ( $paid && $trial ) { /* translators: 1: date the trial ends, 2: days left. */ self::stat_row( __( 'Trial ends', 'clientager' ), sprintf( _n( '%1$s (%2$d day left)', '%1$s (%2$d days left)', (int) $trial['days_left'], 'clientager' ), wp_date( get_option( 'date_format' ), strtotime( (string) $trial['ends_at'] ) ), (int) $trial['days_left'] ) ); } self::stat_row( __( 'On your website', 'clientager' ), ! empty( $s['live'] ) ? __( 'Live', 'clientager' ) : ( self::trial_ended( $s ) ? __( 'Off', 'clientager' ) : __( 'Not live yet', 'clientager' ) ) ); if ( ! empty( $s['email'] ) ) { self::stat_row( __( 'Account', 'clientager' ), (string) $s['email'] ); } echo ''; $free = self::free_trial( $s ); if ( self::trial_ended( $s ) ) { /* translators: %s: date the free trial ended. */ echo '

' . esc_html( sprintf( __( 'Your free trial ended on %s and the chat is off on your website. Choose a plan to switch it back on. Your conversations, leads and settings are kept.', 'clientager' ), wp_date( get_option( 'date_format' ), strtotime( (string) $free['ends_at'] ) ) ) ) . '

'; echo '
'; self::checkout_button( $s, __( 'Choose a plan', 'clientager' ) ); echo '
'; return; } if ( $paid && $free && ! empty( $free['active'] ) && empty( $s['subscribed'] ) ) { self::usage_bar( __( 'Trial AI conversations', 'clientager' ), (int) $free['used'], (int) $free['limit'] ); if ( ! empty( $free['emails_limit'] ) ) { self::usage_bar( __( 'Trial AI emails', 'clientager' ), (int) $free['emails_used'], (int) $free['emails_limit'] ); } /* translators: %s: date the free trial ends. */ echo '

' . esc_html( sprintf( __( 'Free until %s, no card needed. Then the chat switches off on your website until you choose a plan.', 'clientager' ), wp_date( get_option( 'date_format' ), strtotime( (string) $free['ends_at'] ) ) ) ) . '

'; echo '
'; self::checkout_button( $s, __( 'Choose a plan', 'clientager' ), 'cg-btn cg-btn-secondary' ); echo '
'; return; } if ( ! $paid ) { self::usage_bar( __( 'Free test messages', 'clientager' ), $used, $limit ); if ( ! empty( $s['can_free_trial'] ) ) { /* translators: 1: number of free trial days, 2: monthly price of the cheapest plan, e.g. "$25". */ echo '

' . esc_html( sprintf( __( 'Try it on your website free for %1$d days. No card needed. Then from %2$s/month if you want to keep it.', 'clientager' ), self::FREE_TRIAL_DAYS, self::plans_from( $s ) ) ) . '

'; } elseif ( self::trial_days( $s ) ) { /* translators: 1: number of free trial days, 2: monthly price of the cheapest plan, e.g. "$25". */ echo '

' . esc_html( sprintf( __( '%1$d-day free trial on any plan, $0 today. Then from %2$s/month. Cancel before it ends and you pay nothing.', 'clientager' ), self::trial_days( $s ), self::plans_from( $s ) ) ) . '

'; } else { /* translators: %s: monthly price of the cheapest plan, e.g. "$25". */ echo '

' . esc_html( sprintf( __( 'Plans from %s/month · Cancel anytime', 'clientager' ), self::plans_from( $s ) ) ) . '

'; } echo '
'; self::trial_button( $s, __( 'See plans', 'clientager' ), self::trial_days( $s ) ? 'cg-btn cg-btn-primary' : 'cg-btn cg-btn-secondary' ); echo '
'; return; } $usage = isset( $s['usage'] ) && is_array( $s['usage'] ) ? $s['usage'] : array(); $bars = array( 'conversations' => __( 'AI conversations this month', 'clientager' ), 'emails' => __( 'AI emails this month', 'clientager' ), 'products' => __( 'Products & services', 'clientager' ), 'minutes' => __( 'Call minutes this month', 'clientager' ), ); $near = false; foreach ( $bars as $key => $label ) { if ( empty( $usage[ $key ]['limit'] ) ) { continue; } $near = self::usage_bar( $label, (int) ( isset( $usage[ $key ]['used'] ) ? $usage[ $key ]['used'] : 0 ), (int) $usage[ $key ]['limit'] ) || $near; } if ( $trial ) { /* translators: %s: date the trial ends. */ echo '

' . esc_html( sprintf( __( 'Your free trial ends on %s. Your first payment is taken then; cancel before that in Clientager billing and you pay nothing.', 'clientager' ), wp_date( get_option( 'date_format' ), strtotime( (string) $trial['ends_at'] ) ) ) ) . '

'; } $billing = ! empty( $s['dashboard_url'] ) ? untrailingslashit( (string) $s['dashboard_url'] ) . '/billing' : ''; if ( $near ) { echo '

' . esc_html__( 'You are close to your plan’s limit. Upgrade to keep your AI answering without a pause.', 'clientager' ) . '

'; } if ( $billing ) { echo '
'; printf( '%3$s', esc_attr( $near ? 'cg-btn cg-btn-primary' : 'cg-btn cg-btn-secondary' ), esc_url( $billing ), esc_html( $near ? __( 'Upgrade plan', 'clientager' ) : __( 'Billing in Clientager', 'clientager' ) ) ); echo '
'; } } /** * One usage bar. Amber from 80%, red when full. * * @param string $label Label. * @param int $used Used. * @param int $limit Limit. * @return bool True when 80% or more is used. */ private static function usage_bar( $label, $used, $limit ) { $pct = $limit > 0 ? min( 100, (int) round( $used / $limit * 100 ) ) : 0; $level = $pct >= 100 ? ' is-full' : ( $pct >= 80 ? ' is-near' : '' ); echo '
' . esc_html( $label ) . ''; /* translators: 1: used, 2: limit. */ printf( esc_html__( '%1$s / %2$s', 'clientager' ), esc_html( number_format_i18n( $used ) ), esc_html( number_format_i18n( $limit ) ) ); echo '
'; return $pct >= 80; } /** * Cheapest plan's monthly price in the account's country (from the server), e.g. "$25" or "₹1,999". * * @param array $s Status. * @return string */ private static function plans_from( $s ) { $from = isset( $s['plans_from'] ) ? sanitize_text_field( (string) $s['plans_from'] ) : ''; return '' !== $from ? $from : '$25'; } /** * Free trial days the checkout gives this account (0 when it already had one, or is paid). * * @param array $s Status. * @return int */ private static function trial_days( $s ) { if ( ! empty( $s['can_free_trial'] ) ) { return self::FREE_TRIAL_DAYS; } return empty( $s['paid'] ) && ! empty( $s['trial_days'] ) ? (int) $s['trial_days'] : 0; } /** * The no-checkout free trial from the server (active, ends_at, days_left, used, limit), or null. * * @param array $s Status. * @return array|null */ private static function free_trial( $s ) { return ! empty( $s['free_trial'] ) && is_array( $s['free_trial'] ) ? $s['free_trial'] : null; } /** * Free trial over and no plan chosen: the server has switched the chat off. * * @param array $s Status. * @return bool */ private static function trial_ended( $s ) { $t = self::free_trial( $s ); return $t && empty( $t['active'] ) && empty( $s['paid'] ); } /** * "Start 7-day free trial" (one click, no checkout) when the server offers it, else the checkout link. * The server decides who may start a trial; this button only asks. * * @param array $s Status. * @param string $label Checkout label. * @param string $class Classes. */ private static function trial_button( $s, $label, $class = 'cg-btn cg-btn-primary' ) { if ( ! empty( $s['can_free_trial'] ) ) { /* translators: %d: number of free trial days. */ printf( '', esc_attr( $class ), esc_html( sprintf( __( 'Start %d-day free trial', 'clientager' ), self::FREE_TRIAL_DAYS ) ) ); return; } self::checkout_button( $s, self::trial_ended( $s ) ? __( 'Choose a plan', 'clientager' ) : self::plan_cta( $s, $label ), $class ); } /** * Label for the checkout button: "Start 7-day free trial" when a trial is on offer, else the given label. * * @param array $s Status. * @param string $label Label without a trial. * @return string */ private static function plan_cta( $s, $label ) { $days = self::trial_days( $s ); /* translators: %d: number of free trial days. */ return $days ? sprintf( __( 'Start %d-day free trial', 'clientager' ), $days ) : $label; } /** * "7 days free, then from $25/month" or "From $25/month". * * @param array $s Status. * @return string */ private static function plan_price_line( $s ) { $days = self::trial_days( $s ); /* translators: 1: number of free trial days, 2: monthly price of the cheapest plan, e.g. "$25". */ return $days ? sprintf( _n( '%1$d day free, then from %2$s/month', '%1$d days free, then from %2$s/month', $days, 'clientager' ), $days, self::plans_from( $s ) ) : sprintf( /* translators: %s: monthly price of the cheapest plan, e.g. "$25". */ __( 'From %s/month', 'clientager' ), self::plans_from( $s ) ); } /** * One label/value row. * * @param string $label Label. * @param string $value Value. */ private static function stat_row( $label, $value ) { echo '
' . esc_html( $label ) . '' . esc_html( $value ) . '
'; } /** * Link to the checkout (new tab). * * @param array $s Status. * @param string $label Button text. * @param string $class CSS classes. */ private static function checkout_button( $s, $label, $class = 'cg-btn cg-btn-primary' ) { if ( empty( $s['checkout_url'] ) ) { return; } printf( '%3$s', esc_attr( $class ), esc_url( (string) $s['checkout_url'] ), esc_html( $label ) ); } /** * Add-ons for paid accounts (extra AI conversations, call minutes, done-for-you setup), bought in Clientager's Billing * with PayPal or Razorpay. Prices come from the server, priced for the account's country. * * @param array $s Status. */ private static function addons_card( $s ) { $items = isset( $s['addons'] ) && is_array( $s['addons'] ) ? $s['addons'] : array(); if ( empty( $s['paid'] ) || ! $items ) { return; } ?>

0 ? min( 100, (int) round( max( 0, $limit - $used ) / $limit * 100 ) ) : 0; ?>
= $limit ) ? '' : 'hidden'; ?>>
= $limit ? 'hidden' : ''; ?>>
' . $inner . ''; echo wp_kses( $svg, self::svg_tags() ); } /** * An https link to an image for the chat button, else ''. Same rule as the server. * * @param mixed $v Value. * @return string */ private static function icon_url( $v ) { $v = trim( (string) $v ); return strlen( $v ) <= 500 && preg_match( '#^https://[^\s"\'<>()\\\\]+$#i', $v ) ? $v : ''; } /** * A distance of the chat button from the screen's edge, in px: 0 to $max, 20 when unset. * * @param mixed $v Value. * @param int $max Largest allowed. * @return int */ private static function px( $v, $max ) { return is_numeric( $v ) ? max( 0, min( $max, (int) round( (float) $v ) ) ) : 20; } /** * The chat's settings as shown and edited in the plugin, with safe defaults. * * @param array $s Status. * @return array */ private static function widget_look( $s ) { $w = isset( $s['widget'] ) && is_array( $s['widget'] ) ? $s['widget'] : array(); $needs = array( 'off', 'optional', 'required' ); $form = isset( $w['form'] ) && is_array( $w['form'] ) ? $w['form'] : array(); $out = array( 'color' => isset( $w['color'] ) && preg_match( '/^#[0-9a-f]{6}$/i', (string) $w['color'] ) ? strtolower( (string) $w['color'] ) : '#0a0a0a', 'position' => isset( $w['position'] ) && 'left' === $w['position'] ? 'left' : 'right', 'greeting' => isset( $w['greeting'] ) && '' !== trim( (string) $w['greeting'] ) ? sanitize_textarea_field( (string) $w['greeting'] ) : __( 'Hi! How can we help you today?', 'clientager' ), 'ask_contact' => ! empty( $w['ask_contact'] ), 'form' => array(), 'quick_questions' => array(), 'pages' => array( 'mode' => isset( $w['pages']['mode'] ) && in_array( $w['pages']['mode'], array( 'only', 'except' ), true ) ? (string) $w['pages']['mode'] : 'all', 'rules' => isset( $w['pages']['rules'] ) && is_array( $w['pages']['rules'] ) ? array_slice( array_map( 'sanitize_text_field', array_map( 'strval', $w['pages']['rules'] ) ), 0, 20 ) : array(), ), 'offset' => array(), 'icon' => isset( $w['icon'] ) && ( 'custom' === $w['icon'] || isset( self::ICONS[ $w['icon'] ] ) ) ? (string) $w['icon'] : 'chat', 'icon_url' => self::icon_url( isset( $w['icon_url'] ) ? $w['icon_url'] : '' ), 'auto_hi' => array( 'enabled' => ! empty( $w['auto_hi']['enabled'] ), 'delay' => isset( $w['auto_hi']['delay'] ) ? max( 1, min( 120, (int) $w['auto_hi']['delay'] ) ) : 5, 'text' => isset( $w['auto_hi']['text'] ) && '' !== trim( (string) $w['auto_hi']['text'] ) ? sanitize_text_field( (string) $w['auto_hi']['text'] ) : __( 'Hi there! Have a question? We are here to help.', 'clientager' ), ), ); if ( 'custom' === $out['icon'] && '' === $out['icon_url'] ) { $out['icon'] = 'chat'; } $off = isset( $w['offset'] ) && is_array( $w['offset'] ) ? $w['offset'] : array(); foreach ( array( 'desktop', 'mobile' ) as $device ) { $o = isset( $off[ $device ] ) && is_array( $off[ $device ] ) ? $off[ $device ] : array(); $out['offset'][ $device ] = array( 'x' => self::px( isset( $o['x'] ) ? $o['x'] : null, self::OFFSET_MAX['x'] ), 'y' => self::px( isset( $o['y'] ) ? $o['y'] : null, self::OFFSET_MAX['y'] ), ); } foreach ( array( 'name' => 'required', 'email' => 'required', 'phone' => 'optional', ) as $k => $default ) { $out['form'][ $k ] = isset( $form[ $k ] ) && in_array( $form[ $k ], $needs, true ) ? $form[ $k ] : $default; } foreach ( isset( $w['quick_questions'] ) && is_array( $w['quick_questions'] ) ? array_slice( $w['quick_questions'], 0, 4 ) : array() as $q ) { $q = sanitize_text_field( (string) $q ); if ( '' !== $q ) { $out['quick_questions'][] = $q; } } return $out; } /** * The chat window drawn like the website chat (src/lib/widget-script.ts on the server). * "inline": the private test chat on the Preview tab, always open. * "float": the live chat bubble on the Chat widget tab, fixed to the bottom corner of the screen like on the website. * Both send through the private preview (ajax_preview) with its limits. * * @param array $s Status. * @param string $mode inline|float. * @param bool $locked Sending is switched off (preview used, or the website not learned yet). */ private static function chat_window( $s, $mode, $locked = false ) { $w = self::widget_look( $s ); $business = ! empty( $s['business'] ) ? (string) $s['business'] : wp_strip_all_tags( get_bloginfo( 'name' ) ); $float = 'float' === $mode; $labels = array( 'name' => __( 'Your name', 'clientager' ), 'email' => __( 'Your email', 'clientager' ), 'phone' => __( 'Your phone', 'clientager' ), ); $why = ''; if ( $locked ) { $why = empty( $s['learned'] ) ? __( 'Your AI has not learned your website yet. Go to Get started and press "Learn my website", then try it here.', 'clientager' ) : __( 'Your free preview is used. Choose a plan to keep testing and put the chat on your website.', 'clientager' ); } ?>
>
>
>

$label ) : ?>
>
>
> />
>
__( 'Required', 'clientager' ), 'optional' => __( 'Optional', 'clientager' ), 'off' => __( 'Don’t ask', 'clientager' ), ); $fields = array( 'name' => __( 'Name', 'clientager' ), 'email' => __( 'Email', 'clientager' ), 'phone' => __( 'Phone', 'clientager' ), ); $ideas = array( __( 'What are your opening hours?', 'clientager' ), __( 'How much does it cost?', 'clientager' ), __( 'Can I book an appointment?', 'clientager' ), __( 'Where are you located?', 'clientager' ) ); ?>

__( 'Bottom left', 'clientager' ), 'right' => __( 'Bottom right', 'clientager' ), ) as $val => $label ) : ?>
__( 'Computer', 'clientager' ), 'mobile' => __( 'Phone', 'clientager' ), ) as $device => $label ) : ?>
> __( 'From the side', 'clientager' ), 'y' => __( 'From the bottom', 'clientager' ), ) as $axis => $label ) : ?>

__( 'Chat', 'clientager' ), 'dots' => __( 'Typing', 'clientager' ), 'headset' => __( 'Headset', 'clientager' ), 'robot' => __( 'AI robot', 'clientager' ), 'help' => __( 'Question', 'clientager' ), 'sparkle' => __( 'AI sparkle', 'clientager' ), ); foreach ( $icon_labels as $key => $label ) : ?>
>

$label ) : ?>

>

array( __( 'New leads', 'clientager' ), __( 'Someone filled in a form or the chat.', 'clientager' ), true ), 'new_booking' => array( __( 'New bookings', 'clientager' ), __( 'Your AI or your team booked someone in.', 'clientager' ), true ), 'callback' => array( __( 'Callback requests', 'clientager' ), __( 'A visitor asked for a person to get back to them.', 'clientager' ), true ), 'daily_summary' => array( __( 'Daily summary', 'clientager' ), __( 'One email around 8:00 in the morning with yesterday’s leads and bookings.', 'clientager' ), false ), ); ?>

$k ) : ?>
= $limit ); } /** * Conversations; website chats can be answered from here. */ private static function screen_inbox() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view selection. $id = isset( $_GET['conversation'] ) ? sanitize_text_field( wp_unslash( $_GET['conversation'] ) ) : ''; if ( $id && preg_match( '/^[0-9a-f-]{36}$/i', $id ) ) { self::screen_conversation( $id ); return; } $res = Clientager_Api::request( 'GET', '/conversations?limit=30' ); echo '
'; echo '
'; self::card_head( __( 'Inbox', 'clientager' ), __( 'Chats with your website visitors and form messages. Open one to reply or take over from the AI.', 'clientager' ), 'inbox' ); echo '
'; if ( ! $res['ok'] ) { echo '
'; self::notice( 'error', $res['error'] ); echo '
'; return; } $items = isset( $res['data']['items'] ) && is_array( $res['data']['items'] ) ? $res['data']['items'] : array(); if ( ! $items ) { self::empty_state( 'inbox', __( 'No conversations yet', 'clientager' ), __( 'When visitors chat with your AI or send a form, the conversation shows up here.', 'clientager' ) ); echo ''; return; } echo '
    '; foreach ( $items as $c ) { $cid = isset( $c['id'] ) ? (string) $c['id'] : ''; $name = ! empty( $c['customer'] ) ? (string) $c['customer'] : __( 'Website visitor', 'clientager' ); $channel = isset( $c['channel'] ) ? (string) $c['channel'] : ''; printf( '
  • %2$s%3$s%4$s%5$s
  • ', esc_url( admin_url( 'admin.php?page=clientager-inbox&conversation=' . rawurlencode( $cid ) ) ), esc_html( self::initials( $name ) ), esc_html( $name ), self::channel_badge( $channel ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built with esc_attr/esc_html in channel_badge(). esc_html( isset( $c['last_message'] ) ? (string) $c['last_message'] : '' ), esc_html( self::ago( isset( $c['last_at'] ) ? (string) $c['last_at'] : '' ) ) ); } echo '
'; } /** * One conversation. * * @param string $id Conversation id. */ private static function screen_conversation( $id ) { $res = Clientager_Api::request( 'GET', '/conversations/' . rawurlencode( $id ) ); $back = admin_url( 'admin.php?page=clientager-inbox' ); echo '← ' . esc_html__( 'All conversations', 'clientager' ) . ''; echo '
'; if ( ! $res['ok'] ) { self::notice( 'error', $res['error'] ); echo '
'; return; } $d = $res['data']; $name = ! empty( $d['customer'] ) ? (string) $d['customer'] : __( 'Website visitor', 'clientager' ); echo '
' . esc_html( self::initials( $name ) ) . '

' . esc_html( $name ) . '

'; echo self::channel_badge( isset( $d['channel'] ) ? (string) $d['channel'] : '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built with esc_attr/esc_html in channel_badge(). echo '
'; $can_reply = ! empty( $d['can_reply'] ); $human = ! empty( $d['human'] ); if ( $can_reply ) { printf( '', $human ? '1' : '0', $human ? esc_html__( 'Give back to AI', 'clientager' ) : esc_html__( 'Take over', 'clientager' ) ); } elseif ( ! empty( $d['open_url'] ) ) { printf( '%2$s', esc_url( (string) $d['open_url'] ), esc_html__( 'Reply in Clientager', 'clientager' ) ); } echo '
'; if ( $can_reply && $human ) { echo '
' . esc_html__( 'You are answering this chat. The AI stays quiet until you give it back.', 'clientager' ) . '
'; } echo '
'; foreach ( isset( $d['messages'] ) && is_array( $d['messages'] ) ? $d['messages'] : array() as $m ) { $from = isset( $m['from'] ) && in_array( $m['from'], array( 'customer', 'agent', 'staff' ), true ) ? $m['from'] : 'agent'; printf( '
%3$s
', esc_attr( $from ), esc_html( 'customer' === $from ? self::initials( $name ) : ( 'staff' === $from ? __( 'You', 'clientager' ) : 'AI' ) ), nl2br( esc_html( isset( $m['text'] ) ? (string) $m['text'] : '' ) ), esc_html( self::ago( isset( $m['at'] ) ? (string) $m['at'] : '' ) ) ); } echo '
'; if ( $can_reply ) { printf( '

', esc_attr( $id ), esc_attr__( 'Write a reply… the visitor sees it in the chat on your website', 'clientager' ), esc_html__( 'Send', 'clientager' ) ); } echo ''; } /** * Contact form: fields, texts and look of the form shown by the "Clientager contact form" block or [clientager_form], * with a live preview drawn by form.js. * * @param array $s Status. */ private static function screen_form( $s ) { $res = Clientager_Api::request( 'GET', '/contact-form' ); if ( ! $res['ok'] ) { self::notice( 'error', $res['error'] ); return; } $f = $res['data']; $names = array( 'name' => __( 'Name', 'clientager' ), 'email' => __( 'Email', 'clientager' ), 'phone' => __( 'Phone', 'clientager' ), 'order' => __( 'Order number', 'clientager' ), 'message' => __( 'Message', 'clientager' ), ); $needs = array( 'required' => __( 'Required', 'clientager' ), 'optional' => __( 'Optional', 'clientager' ), 'off' => __( 'Don’t ask', 'clientager' ), ); $st = isset( $f['style'] ) && is_array( $f['style'] ) ? $f['style'] : array(); $styles = array( 'corners' => array( __( 'Corners', 'clientager' ), array( 'square' => __( 'Square', 'clientager' ), 'rounded' => __( 'Rounded', 'clientager' ), 'pill' => __( 'Round', 'clientager' ), ), ), 'layout' => array( __( 'Layout', 'clientager' ), array( 'two' => __( 'Two columns', 'clientager' ), 'one' => __( 'One column', 'clientager' ), ), ), 'fields' => array( __( 'Field style', 'clientager' ), array( 'outline' => __( 'Outlined', 'clientager' ), 'filled' => __( 'Filled', 'clientager' ), ), ), 'width' => array( __( 'Width', 'clientager' ), array( 'narrow' => __( 'Narrow, centred', 'clientager' ), 'full' => __( 'Full width', 'clientager' ), ), ), ); $val = static function ( $k ) use ( $f ) { return isset( $f[ $k ] ) ? (string) $f[ $k ] : ''; }; ?>

[clientager_form]

$name ) : ?>

$def ) : ?>
$t ) : ?>
'; self::card_head( __( 'Requests', 'clientager' ), __( 'Complaints, booking requests and callbacks your AI took from chats, forms and emails. Mark them done when they are handled.', 'clientager' ), 'ticket' ); echo '
'; foreach ( array( 'open' => __( 'Open', 'clientager' ), 'all' => __( 'All', 'clientager' ), ) as $k => $label ) { $on = '' === $q && ( 'all' === $k ) === $all; printf( '%4$s', esc_url( admin_url( 'admin.php?page=clientager-requests' . ( 'all' === $k ? '&show=all' : '' ) ) ), $on ? 'is-on' : '', $on ? ' aria-current="page"' : '', esc_html( $label ) ); } echo '
'; self::search_form( 'clientager-requests', $q, __( 'Search by name, email, phone, order number or words', 'clientager' ) ); if ( ! $res['ok'] ) { echo '
'; self::notice( 'error', $res['error'] ); echo '
'; return; } $items = isset( $res['data']['items'] ) && is_array( $res['data']['items'] ) ? $res['data']['items'] : array(); if ( ! $items ) { if ( '' !== $q ) { self::empty_state( 'ticket', __( 'No matching requests', 'clientager' ), __( 'Try a name, an email, a phone number or an order number like #1042.', 'clientager' ) ); } else { self::empty_state( 'ticket', $all ? __( 'No requests yet', 'clientager' ) : __( 'Nothing open', 'clientager' ), __( 'When a customer complains, asks to book or wants a call back, your AI files it here and emails you.', 'clientager' ) ); } echo ''; return; } $kinds = array( 'complaint' => __( 'Complaint', 'clientager' ), 'booking' => __( 'Booking', 'clientager' ), 'callback' => __( 'Callback', 'clientager' ), 'other' => __( 'Request', 'clientager' ), ); $statuses = array( 'open' => __( 'Open', 'clientager' ), 'in_progress' => __( 'In progress', 'clientager' ), 'resolved' => __( 'Resolved', 'clientager' ), 'closed' => __( 'Closed', 'clientager' ), ); echo '
    '; foreach ( $items as $t ) { $id = isset( $t['id'] ) ? (string) $t['id'] : ''; $kind = isset( $t['kind'], $kinds[ $t['kind'] ] ) ? (string) $t['kind'] : 'other'; $status = isset( $t['status'], $statuses[ $t['status'] ] ) ? (string) $t['status'] : 'open'; $cu = isset( $t['customer'] ) && is_array( $t['customer'] ) ? $t['customer'] : array(); $email = ! empty( $cu['email'] ) ? sanitize_email( (string) $cu['email'] ) : ''; $name = ! empty( $cu['name'] ) ? (string) $cu['name'] : ( $email ? $email : __( 'Customer', 'clientager' ) ); $done = in_array( $status, array( 'resolved', 'closed' ), true ); echo '
  • '; // One line: type, who, what (cut short), order, status, when. Click to see everything and act on it. echo '' . esc_html( $kinds[ $kind ] ) . ''; if ( ! empty( $t['urgent'] ) && ! $done ) { echo ''; } echo '' . esc_html( $name ) . ''; echo '' . esc_html( isset( $t['summary'] ) ? (string) $t['summary'] : '' ) . ''; if ( ! empty( $t['order'] ) ) { echo '' . esc_html( (string) $t['order'] ) . ''; } echo '' . esc_html( $statuses[ $status ] ) . ''; echo '
    '; if ( ! empty( $t['summary'] ) ) { echo '

    ' . esc_html( (string) $t['summary'] ) . '

    '; } $meta = array(); if ( $email ) { $meta[] = '' . esc_html( $email ) . ''; } if ( ! empty( $cu['phone'] ) ) { $meta[] = '' . esc_html( (string) $cu['phone'] ) . ''; } if ( ! empty( $t['order'] ) ) { /* translators: %s: order number, e.g. #1042. */ $meta[] = esc_html( sprintf( __( 'Order %s', 'clientager' ), (string) $t['order'] ) ); } if ( ! empty( $t['conversation_id'] ) ) { $meta[] = '' . esc_html__( 'Open conversation', 'clientager' ) . ''; } if ( $meta ) { echo '
    ' . implode( '', $meta ) . '
    '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each part escaped above. } $notes = isset( $t['notes'] ) && is_array( $t['notes'] ) ? array_slice( $t['notes'], -3 ) : array(); if ( $notes ) { echo '
      '; foreach ( $notes as $n ) { $bits = array(); if ( ! empty( $n['status'] ) && isset( $statuses[ $n['status'] ] ) ) { /* translators: %s: new status, e.g. Resolved. */ $bits[] = sprintf( __( 'Marked %s', 'clientager' ), strtolower( $statuses[ $n['status'] ] ) ); } if ( ! empty( $n['emailed'] ) ) { $bits[] = __( 'emailed to the customer', 'clientager' ); } echo '
    • ' . esc_html( self::ago( isset( $n['at'] ) ? (string) $n['at'] : '' ) . ( $bits ? ' · ' . implode( ', ', $bits ) : '' ) ) . '' . ( ! empty( $n['text'] ) ? esc_html( (string) $n['text'] ) : '' ) . '
    • '; } echo '
    '; } echo '
    '; if ( $email ) { /* translators: %s: customer email address. */ echo ''; } echo '
    '; if ( 'open' === $status ) { echo ''; } if ( ! $done ) { echo ''; } else { echo ''; } echo ''; echo '

    '; echo '
  • '; } echo '
'; } /** * Search box above a list (a plain GET form, so the search stays in the address bar). * * @param string $page Admin page slug. * @param string $q Current search. * @param string $placeholder Placeholder. */ private static function search_form( $page, $q, $placeholder ) { printf( '', esc_url( admin_url( 'admin.php' ) ), esc_attr( $page ), esc_attr( $q ), esc_attr( $placeholder ), esc_html__( 'Search', 'clientager' ), '' !== $q ? '' . esc_html__( 'Clear', 'clientager' ) . '' : '' ); } /** * Leads table. */ private static function screen_leads() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only search. $q = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : ''; $res = Clientager_Api::request( 'GET', '/leads?limit=50' . ( '' !== $q ? '&q=' . rawurlencode( $q ) : '' ) ); if ( '' === $q ) { Clientager_Api::clear_status(); // The "new leads" count on the tab is cleared by opening the list. } echo '
'; echo '
'; self::card_head( __( 'Leads', 'clientager' ), __( 'People who left their details in the chat or in one of your forms. Set a status as you follow up.', 'clientager' ), 'users' ); printf( '%2$s', esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=clientager_leads_csv' . ( '' !== $q ? '&q=' . rawurlencode( $q ) : '' ) ), 'clientager_leads_csv' ) ), esc_html__( 'Export CSV', 'clientager' ) ); echo '
'; self::search_form( 'clientager-leads', $q, __( 'Search by name, email, phone or request', 'clientager' ) ); if ( ! $res['ok'] ) { echo '
'; self::notice( 'error', $res['error'] ); echo '
'; return; } $items = isset( $res['data']['items'] ) && is_array( $res['data']['items'] ) ? $res['data']['items'] : array(); if ( ! $items ) { if ( '' !== $q ) { self::empty_state( 'users', __( 'No matching leads', 'clientager' ), __( 'Try a name, an email or a phone number.', 'clientager' ) ); } else { self::empty_state( 'users', __( 'No leads yet', 'clientager' ), __( 'Once your AI is live, everyone who leaves a name, phone or email appears here.', 'clientager' ) ); } echo ''; return; } echo '
'; foreach ( $items as $l ) { $email = isset( $l['email'] ) ? sanitize_email( (string) $l['email'] ) : ''; $name = ! empty( $l['name'] ) ? (string) $l['name'] : ( $email ? $email : __( 'Visitor', 'clientager' ) ); $status = isset( $l['status'] ) ? strtolower( (string) $l['status'] ) : ''; echo ''; echo ''; echo ''; echo ''; $lid = isset( $l['id'] ) ? (string) $l['id'] : ''; $opts = self::lead_statuses(); // A status set in the Clientager dashboard (its own pipeline) stays selectable as it is. if ( $status && ! isset( $opts[ $status ] ) ) { $opts = array( $status => ucfirst( str_replace( '_', ' ', $status ) ) ) + $opts; } if ( $lid ) { echo ''; } else { echo ''; } echo ''; } echo '
' . esc_html__( 'Contact', 'clientager' ) . '' . esc_html__( 'Phone', 'clientager' ) . '' . esc_html__( 'Request', 'clientager' ) . '' . esc_html__( 'Source', 'clientager' ) . '' . esc_html__( 'Status', 'clientager' ) . '' . esc_html__( 'Date', 'clientager' ) . '
' . esc_html( self::initials( $name ) ) . '
' . esc_html( $name ) . ''; if ( $email ) { echo '' . esc_html( $email ) . ''; } echo '
' . esc_html( ! empty( $l['phone'] ) ? (string) $l['phone'] : '–' ) . '' . esc_html( isset( $l['title'] ) ? (string) $l['title'] : '' ) . '' . esc_html( isset( $l['source'] ) ? ucfirst( str_replace( '_', ' ', (string) $l['source'] ) ) : '' ) . '' . esc_html( $status ? ucfirst( $status ) : '–' ) . '' . esc_html( self::ago( isset( $l['created_at'] ) ? (string) $l['created_at'] : '' ) ) . '
'; } /** * Lead statuses the plugin can set. * * @return array */ private static function lead_statuses() { return array( 'new' => __( 'New', 'clientager' ), 'contacted' => __( 'Contacted', 'clientager' ), 'qualified' => __( 'Qualified', 'clientager' ), 'won' => __( 'Won', 'clientager' ), 'lost' => __( 'Lost', 'clientager' ), ); } /** * Downloads the leads (up to 1,000, or the ones matching the search) as a CSV file. */ public static function leads_csv() { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'clientager' ), 403 ); } check_admin_referer( 'clientager_leads_csv' ); $q = isset( $_GET['q'] ) ? sanitize_text_field( wp_unslash( $_GET['q'] ) ) : ''; $res = Clientager_Api::request( 'GET', '/leads?export=1&limit=1000' . ( '' !== $q ? '&q=' . rawurlencode( $q ) : '' ), null, array( 'timeout' => 30 ) ); if ( ! $res['ok'] ) { wp_die( esc_html( $res['error'] ), 502 ); } $items = isset( $res['data']['items'] ) && is_array( $res['data']['items'] ) ? $res['data']['items'] : array(); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="clientager-leads-' . gmdate( 'Y-m-d' ) . '.csv"' ); // A cell starting with = + - @ would run as a formula in spreadsheet apps. $cell = static function ( $v ) { $v = (string) $v; return preg_match( '/^[=+\-@\t\r]/', $v ) && ! preg_match( '/^\+[\d\s().-]+$/', $v ) ? "'" . $v : $v; }; $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming the download. fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- UTF-8 mark so spreadsheet apps read accents. fputcsv( $out, array( 'Name', 'Email', 'Phone', 'Request', 'Source', 'Status', 'Date' ) ); foreach ( $items as $l ) { fputcsv( $out, array_map( $cell, array( $l['name'] ?? '', $l['email'] ?? '', $l['phone'] ?? '', $l['title'] ?? '', $l['source'] ?? '', $l['status'] ?? '', $l['created_at'] ?? '' ) ) ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- streaming the download. exit; } /** * Settings. * * @param array $s Status. */ private static function screen_settings( $s ) { $paid = ! empty( $s['paid'] ); $live = ! empty( $s['live'] ); $forms = Clientager_Forms::detected(); $used = isset( $s['preview']['used'] ) ? (int) $s['preview']['used'] : 0; $limit = isset( $s['preview']['limit'] ) ? (int) $s['preview']['limit'] : 25; $supported = Clientager_Forms::supported(); $sections = array( 'general' => array( __( 'Chat & websites', 'clientager' ), 'globe' ), 'alerts' => array( __( 'Alerts', 'clientager' ), 'bell' ), 'knowledge' => array( __( 'Website knowledge', 'clientager' ), 'book' ), 'forms' => array( __( 'Forms & emails', 'clientager' ), 'mail' ), 'plan' => array( __( 'Plan & billing', 'clientager' ), 'card' ), 'site' => array( __( 'Disconnect', 'clientager' ), 'power' ), ); ?>

__( 'A question', 'clientager' ), 'problem' => __( 'Something is not working', 'clientager' ), 'feature' => __( 'A feature request', 'clientager' ), ); ?>

' . esc_html__( 'Your messages', 'clientager' ) . '
    '; foreach ( $messages as $m ) { $type = isset( $m['type'] ) && isset( $types[ $m['type'] ] ) ? (string) $m['type'] : 'question'; $done = isset( $m['status'] ) && 'done' === $m['status']; printf( '
  • %1$s%2$s%4$s
  • ', esc_html( $types[ $type ] ), esc_html( isset( $m['subject'] ) ? (string) $m['subject'] : '' ), esc_attr( $done ? 'won' : 'open' ), $done ? esc_html__( 'Answered', 'clientager' ) : esc_html__( 'Open', 'clientager' ), esc_html( self::ago( isset( $m['created_at'] ) ? (string) $m['created_at'] : '' ) ) ); } echo '
'; } ?>
'', 'ticket' => '', 'bell' => '', 'reset' => '', 'rocket' => '', 'chat' => '', 'inbox' => '', 'users' => '', 'help' => '', 'settings' => '', 'send' => '', 'check' => '', 'lock' => '', 'phone' => '', 'whatsapp' => '', 'mail' => '', 'globe' => '', 'sparkles' => '', 'refresh' => '', 'power' => '', 'card' => '', 'book' => '', 'form' => '', 'brush' => '', 'eye' => '', 'clock' => '', ); } /** * Prints one inline icon (hidden from screen readers). * * @param string $name Icon name. * @param string $class Extra class. */ private static function icon( $name, $class = '' ) { $icons = self::icons(); if ( ! isset( $icons[ $name ] ) ) { return; } $svg = str_replace( '' . esc_html( self::channel_label( $channel ) ) . ''; } /** * ISO date -> "5 min ago" (or the date when older than a week). * * @param string $iso Date. * @return string */ private static function ago( $iso ) { $ts = $iso ? strtotime( $iso ) : false; if ( ! $ts ) { return ''; } $diff = time() - $ts; if ( $diff >= 0 && $diff < WEEK_IN_SECONDS ) { /* translators: %s: time span, e.g. "5 mins". */ return sprintf( __( '%s ago', 'clientager' ), human_time_diff( $ts, time() ) ); } return wp_date( get_option( 'date_format' ), $ts ); } /** * On our screens, other plugins' admin notices are hidden: our feedback is shown inside the cards. */ public static function hide_notices() { if ( ! self::current_page() ) { return; } foreach ( array( 'admin_notices', 'all_admin_notices', 'network_admin_notices', 'user_admin_notices' ) as $hook ) { remove_all_actions( $hook ); } } /** * Channel key -> label. * * @param string $channel Channel. * @return string */ private static function channel_label( $channel ) { $labels = array( 'website' => __( 'Website', 'clientager' ), 'whatsapp' => __( 'WhatsApp', 'clientager' ), 'email' => __( 'Email', 'clientager' ), 'sms' => __( 'SMS', 'clientager' ), 'phone' => __( 'Phone', 'clientager' ), 'voice' => __( 'Phone', 'clientager' ), ); return isset( $labels[ $channel ] ) ? $labels[ $channel ] : ucfirst( $channel ); } /** * Readable message for an API error code. * * @param array $res API result. * @return string */ private static function friendly( $res ) { $map = array( 'rate' => __( 'Please wait a moment and try again.', 'clientager' ), 'bad_code' => __( 'That code is not right. Check the email and try again.', 'clientager' ), 'expired' => __( 'That code has expired. Send a new one.', 'clientager' ), 'blocked' => __( 'This website has been blocked. Please contact support@clientager.com.', 'clientager' ), 'preview_used' => __( 'Your free preview is used. Choose a plan to put your AI on your website.', 'clientager' ), 'not_learned' => __( 'Your AI has not learned your website yet. Press "Learn my website" first.', 'clientager' ), 'busy' => __( 'Previews are busy right now. Please try again later.', 'clientager' ), 'learn_locked' => __( 'Your website was already learned. Re-learning is included in paid plans.', 'clientager' ), 'not_paid' => __( 'Choose a plan to put your AI on your website.', 'clientager' ), 'bad_token' => __( 'This site is no longer connected. Please connect again.', 'clientager' ), 'not_connected' => __( 'Connect your Clientager account first.', 'clientager' ), 'network' => __( 'Could not reach Clientager. Please try again in a moment.', 'clientager' ), ); return isset( $map[ $res['code'] ] ) ? $map[ $res['code'] ] : ( $res['error'] ? $res['error'] : __( 'Something went wrong. Please try again.', 'clientager' ) ); } /** * Capability check for AJAX (each handler checks its nonce first, with check_ajax_referer). */ private static function guard() { if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'clientager' ) ), 403 ); } } /** * Sends an API result to the browser (never the token). * * @param array $res API result. * @param array $extra Extra success data. */ private static function reply( $res, $extra = array() ) { if ( $res['ok'] ) { wp_send_json_success( $extra ); } $data = array( 'message' => self::friendly( $res ), 'code' => $res['code'], ); if ( ! empty( $res['data']['checkout_url'] ) ) { $data['checkout_url'] = esc_url_raw( (string) $res['data']['checkout_url'] ); } wp_send_json_error( $data, $res['status'] ? $res['status'] : 500 ); } /* ------------------------------------------------------------------ AJAX */ /** * Step 1: email a code. */ public static function ajax_connect_start() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; if ( ! is_email( $email ) ) { wp_send_json_error( array( 'message' => __( 'Please enter a valid email address.', 'clientager' ) ), 400 ); } $res = Clientager_Api::request( 'POST', '/connect/start', array( 'email' => $email, 'site_url' => home_url( '/' ), 'site_name' => wp_strip_all_tags( get_bloginfo( 'name' ) ), 'wp_site_id' => Clientager_Api::site_id(), ), array( 'auth' => false ) ); self::reply( $res, array( 'message' => __( 'Code sent. Check your email (and the spam folder).', 'clientager' ) ) ); } /** * Step 2: check the code and save the token. */ public static function ajax_connect_verify() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; $code = isset( $_POST['code'] ) ? preg_replace( '/\D/', '', sanitize_text_field( wp_unslash( $_POST['code'] ) ) ) : ''; if ( ! is_email( $email ) || 6 !== strlen( $code ) ) { wp_send_json_error( array( 'message' => __( 'Enter the 6-digit code from your email.', 'clientager' ) ), 400 ); } $res = Clientager_Api::request( 'POST', '/connect/verify', array( 'email' => $email, 'code' => $code, 'site_url' => home_url( '/' ), 'site_name' => wp_strip_all_tags( get_bloginfo( 'name' ) ), 'wp_site_id' => Clientager_Api::site_id(), 'plugin_version' => CLIENTAGER_VERSION, 'timezone' => wp_timezone_string(), 'language' => get_locale(), ), array( 'auth' => false ) ); if ( $res['ok'] && ! empty( $res['data']['token'] ) ) { Clientager_Api::save_token( (string) $res['data']['token'] ); delete_transient( 'clientager_activation_notice' ); wp_send_json_success( array( 'redirect' => admin_url( 'admin.php?page=clientager&connected=1' ) ) ); } if ( $res['ok'] ) { $res = array( 'ok' => false, 'status' => 500, 'data' => array(), 'error' => '', 'code' => '', ); } self::reply( $res ); } /** * Learn (or re-learn) the website. */ public static function ajax_learn() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $res = Clientager_Learn::run(); if ( $res['ok'] ) { Clientager_Api::clear_status(); } self::reply( $res, array( 'pages' => isset( $res['data']['pages'] ) ? (int) $res['data']['pages'] : 0, 'products' => isset( $res['data']['products'] ) ? (int) $res['data']['products'] : 0, ) ); } /** * Product cards from a preview reply, cleaned for the screen (at most 3). * * @param mixed $list Products from the service. * @return array */ private static function preview_products( $list ) { $out = array(); foreach ( is_array( $list ) ? array_slice( $list, 0, 3 ) : array() as $p ) { $url = is_array( $p ) && isset( $p['url'] ) ? esc_url_raw( (string) $p['url'], array( 'http', 'https' ) ) : ''; if ( ! $url ) { continue; } $image = isset( $p['image'] ) ? esc_url_raw( (string) $p['image'], array( 'https' ) ) : ''; $out[] = array( 'name' => isset( $p['name'] ) ? sanitize_text_field( (string) $p['name'] ) : '', 'url' => $url, 'image' => $image ? $image : null, 'price' => isset( $p['price'] ) && is_numeric( $p['price'] ) ? (float) $p['price'] : null, 'currency' => isset( $p['currency'] ) && preg_match( '/^[A-Za-z]{3}$/', (string) $p['currency'] ) ? strtoupper( (string) $p['currency'] ) : '', ); } return $out; } /** * One preview message. */ public static function ajax_preview() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $message = isset( $_POST['message'] ) ? sanitize_text_field( wp_unslash( $_POST['message'] ) ) : ''; $message = function_exists( 'mb_substr' ) ? mb_substr( $message, 0, 500 ) : substr( $message, 0, 500 ); if ( '' === $message ) { wp_send_json_error( array( 'message' => __( 'Type a message first.', 'clientager' ) ), 400 ); } $user_id = get_current_user_id(); $session = (string) get_user_meta( $user_id, 'clientager_preview_session', true ); if ( ! $session ) { $session = wp_generate_uuid4(); update_user_meta( $user_id, 'clientager_preview_session', $session ); } $res = Clientager_Api::request( 'POST', '/preview', array( 'message' => $message, 'session_id' => $session, ), array( 'timeout' => 45 ) ); self::reply( $res, array( 'reply' => isset( $res['data']['reply'] ) ? sanitize_textarea_field( (string) $res['data']['reply'] ) : '', 'products' => self::preview_products( isset( $res['data']['products'] ) ? $res['data']['products'] : array() ), 'preview' => array( 'used' => isset( $res['data']['preview']['used'] ) ? (int) $res['data']['preview']['used'] : null, 'limit' => isset( $res['data']['preview']['limit'] ) ? (int) $res['data']['preview']['limit'] : null, ), ) ); } /** * Sets a lead's status (new, contacted, qualified, won, lost). The server checks it again. */ public static function ajax_lead_status() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in guard(). $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! isset( self::lead_statuses()[ $status ] ) ) { wp_send_json_error( array( 'message' => __( 'Pick a status.', 'clientager' ) ), 400 ); } $res = Clientager_Api::request( 'POST', '/leads', array( 'id' => $id, 'status' => $status, ) ); self::reply( $res ); } /** * The answer to "Enjoying Clientager?": yes, no (with optional feedback for our team) or later. */ public static function ajax_review() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in guard(). $answer = isset( $_POST['answer'] ) ? sanitize_key( wp_unslash( $_POST['answer'] ) ) : ''; $feedback = isset( $_POST['feedback'] ) ? sanitize_textarea_field( wp_unslash( $_POST['feedback'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! in_array( $answer, array( 'yes', 'no', 'later' ), true ) ) { wp_send_json_error( array( 'message' => __( 'Pick an answer.', 'clientager' ) ), 400 ); } $res = Clientager_Api::request( 'POST', '/review', array( 'answer' => $answer, 'feedback' => $feedback, ) ); Clientager_Api::clear_status(); self::reply( $res ); } /** * Saves the email logo (sent as base64) or sign-off. The server checks and shrinks the image. */ public static function ajax_email_look() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in guard(). $body = array(); if ( isset( $_POST['signoff'] ) ) { $body['signoff'] = sanitize_textarea_field( wp_unslash( $_POST['signoff'] ) ); } if ( ! empty( $_POST['remove_logo'] ) ) { $body['remove_logo'] = true; } if ( isset( $_POST['per_day'] ) || isset( $_POST['per_thread'] ) ) { $body['rules'] = array( 'per_day' => isset( $_POST['per_day'] ) ? absint( wp_unslash( $_POST['per_day'] ) ) : 0, 'per_thread' => isset( $_POST['per_thread'] ) ? absint( wp_unslash( $_POST['per_thread'] ) ) : 0, 'skip_short' => ! empty( $_POST['skip_short'] ), 'handoff_note' => ! empty( $_POST['handoff_note'] ), ); } if ( isset( $_POST['logo'] ) ) { $data = preg_replace( '/[^A-Za-z0-9+\/=]/', '', (string) wp_unslash( $_POST['logo'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- base64 only, checked here. $type = isset( $_POST['type'] ) ? sanitize_text_field( wp_unslash( $_POST['type'] ) ) : ''; if ( '' === $data || strlen( $data ) > 2900000 || ! in_array( $type, array( 'image/png', 'image/jpeg' ), true ) ) { wp_send_json_error( array( 'message' => __( 'Upload a PNG or JPG image of up to 2 MB.', 'clientager' ) ), 400 ); } $body['logo'] = array( 'data' => $data, 'type' => $type, ); } // phpcs:enable WordPress.Security.NonceVerification.Missing $res = Clientager_Api::request( 'POST', '/email-look', $body, array( 'timeout' => 30 ) ); Clientager_Api::clear_status(); self::reply( $res, array( 'reload' => true ) ); } /** * Saves the AI chat limits (website chat and WhatsApp). The server keeps them within its bounds. */ public static function ajax_chat_rules() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $body = array( 'per_chat' => isset( $_POST['per_chat'] ) ? absint( wp_unslash( $_POST['per_chat'] ) ) : 0, 'per_day' => isset( $_POST['per_day'] ) ? absint( wp_unslash( $_POST['per_day'] ) ) : 0, 'handoff_text' => isset( $_POST['handoff_text'] ) ? sanitize_textarea_field( wp_unslash( $_POST['handoff_text'] ) ) : '', ); $res = Clientager_Api::request( 'POST', '/chat-rules', $body ); Clientager_Api::clear_status(); self::reply( $res, array() ); } /** * Sends a Help message to the Clientager team. */ public static function ajax_help() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $type = isset( $_POST['type'] ) ? sanitize_key( wp_unslash( $_POST['type'] ) ) : ''; $body = array( 'type' => in_array( $type, array( 'question', 'problem', 'feature' ), true ) ? $type : 'question', 'message' => isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '', 'reply_to' => isset( $_POST['reply_to'] ) ? sanitize_email( wp_unslash( $_POST['reply_to'] ) ) : '', 'version' => CLIENTAGER_VERSION, ); $res = Clientager_Api::request( 'POST', '/help', $body ); self::reply( $res, array() ); } /** * WhatsApp: op=connect returns the sign-in address (on clientager.com, valid 30 minutes), op=status says whether a * number is connected, op=button saves the chat's WhatsApp button, op=disconnect stops it. */ public static function ajax_whatsapp() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : ''; if ( 'disconnect' === $op ) { self::reply( Clientager_Api::request( 'POST', '/whatsapp', array( 'action' => 'disconnect' ) ) ); } if ( 'button' === $op ) { $body = array( 'action' => 'button', 'enabled' => ! empty( $_POST['enabled'] ), 'label' => isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '', 'text' => isset( $_POST['text'] ) ? sanitize_text_field( wp_unslash( $_POST['text'] ) ) : '', ); self::reply( Clientager_Api::request( 'POST', '/whatsapp', $body ) ); } $res = Clientager_Api::request( 'GET', '/whatsapp' ); if ( 'status' === $op ) { self::reply( $res, array( 'connected' => ! empty( $res['data']['connected'] ) ) ); } $url = $res['ok'] && ! empty( $res['data']['connect_url'] ) ? esc_url_raw( (string) $res['data']['connect_url'] ) : ''; if ( $res['ok'] && ! $url ) { $res = array( 'ok' => false, 'status' => 409, 'code' => 'wa_unavailable', 'error' => ! empty( $res['data']['connected'] ) ? __( 'WhatsApp is already connected. Reload this page.', 'clientager' ) : __( 'WhatsApp cannot be connected on this plan yet.', 'clientager' ), 'data' => array() ); } self::reply( $res, array( 'url' => $url, 'connected' => ! empty( $res['data']['connected'] ) ) ); } /** * Saves the contact form's fields, texts and look. The server checks them again and is the only place they are stored. */ public static function ajax_form_save() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $text = static function ( $key, $max ) { $v = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked in guard(). return function_exists( 'mb_substr' ) ? mb_substr( $v, 0, $max ) : substr( $v, 0, $max ); }; $fields = array(); foreach ( array( 'name', 'email', 'phone', 'order', 'message' ) as $k ) { $need = $text( 'need_' . $k, 10 ); $fields[ $k ] = array( 'need' => in_array( $need, array( 'off', 'optional', 'required' ), true ) ? $need : 'off', 'label' => $text( 'label_' . $k, 60 ), ); } $color = strtolower( $text( 'color', 7 ) ); if ( ! preg_match( '/^#[0-9a-f]{6}$/', $color ) ) { wp_send_json_error( array( 'message' => __( 'Pick a colour like #2563eb.', 'clientager' ) ), 400 ); } $pick = static function ( $key, $ok ) use ( $text ) { $v = $text( $key, 10 ); return in_array( $v, $ok, true ) ? $v : $ok[0]; }; $res = Clientager_Api::request( 'POST', '/contact-form', array( 'fields' => $fields, 'title' => $text( 'title', 120 ), 'intro' => $text( 'intro', 400 ), 'button' => $text( 'button', 40 ), 'success' => $text( 'success', 300 ), 'consent' => $text( 'consent', 200 ), 'style' => array( 'color' => $color, 'corners' => $pick( 'corners', array( 'rounded', 'square', 'pill' ) ), 'layout' => $pick( 'layout', array( 'two', 'one' ) ), 'fields' => $pick( 'fields', array( 'outline', 'filled' ) ), 'width' => $pick( 'width', array( 'narrow', 'full' ) ), ), ) ); self::reply( $res, array( 'message' => __( 'Saved. The form on your website uses the new settings now.', 'clientager' ) ) ); } /** * Changes a request's status or adds a note (the server checks both again). */ public static function ajax_request_update() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : ''; $note = isset( $_POST['note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['note'] ) ) : ''; $body = array( 'id' => $id, 'note' => $note, 'email_customer' => isset( $_POST['email'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['email'] ) ), ); if ( in_array( $status, array( 'open', 'in_progress', 'resolved', 'closed' ), true ) ) { $body['status'] = $status; } $res = Clientager_Api::request( 'POST', '/requests', $body ); Clientager_Api::clear_status(); self::reply( $res, array( 'reload' => true ) ); } /** * Starts the free trial. The server decides whether this site may have one. */ public static function ajax_trial() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $res = Clientager_Api::request( 'POST', '/trial', array() ); Clientager_Api::clear_status(); if ( $res['ok'] ) { Clientager_Api::status( false ); } self::reply( $res, array( 'reload' => true ) ); } /** * Switches the chat on or off on the website. The server checks the plan. */ public static function ajax_live() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $enabled = isset( $_POST['enabled'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['enabled'] ) ); $res = Clientager_Api::request( 'POST', '/live', array( 'enabled' => $enabled ) ); Clientager_Api::clear_status(); if ( $res['ok'] ) { Clientager_Api::status( false ); } self::reply( $res, array( 'reload' => true ) ); } /** * Saves the chat widget settings. The server checks them again and is the only place they are stored. */ public static function ajax_widget_save() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $text = static function ( $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked at the top of ajax_widget_save(). return isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : ''; }; $len = static function ( $v ) { return function_exists( 'mb_strlen' ) ? mb_strlen( $v ) : strlen( $v ); }; $color = strtolower( $text( 'color' ) ); if ( ! preg_match( '/^#[0-9a-f]{6}$/', $color ) ) { wp_send_json_error( array( 'message' => __( 'Pick a colour like #2563eb.', 'clientager' ) ), 400 ); } $greeting = isset( $_POST['greeting'] ) ? trim( sanitize_textarea_field( wp_unslash( $_POST['greeting'] ) ) ) : ''; if ( '' === $greeting || $len( $greeting ) > 300 ) { wp_send_json_error( array( 'message' => __( 'Write a hello message of up to 300 characters.', 'clientager' ) ), 400 ); } $needs = array( 'off', 'optional', 'required' ); $form = array(); foreach ( array( 'name', 'email', 'phone' ) as $k ) { $v = $text( 'f_' . $k ); $form[ $k ] = in_array( $v, $needs, true ) ? $v : 'optional'; } $quick = array(); $raw = isset( $_POST['quick'] ) && is_array( $_POST['quick'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['quick'] ) ) : array(); foreach ( $raw as $q ) { $q = trim( (string) $q ); if ( '' === $q ) { continue; } if ( $len( $q ) > 60 || count( $quick ) >= 4 ) { wp_send_json_error( array( 'message' => __( 'Up to 4 quick questions of 60 characters each.', 'clientager' ) ), 400 ); } $quick[] = $q; } $icon = $text( 'icon' ); $icon = 'custom' === $icon || isset( self::ICONS[ $icon ] ) ? $icon : 'chat'; $url = isset( $_POST['icon_url'] ) ? trim( esc_url_raw( wp_unslash( $_POST['icon_url'] ) ) ) : ''; if ( '' !== $url && '' === self::icon_url( $url ) ) { wp_send_json_error( array( 'message' => __( 'Use an https link to an image for your own icon.', 'clientager' ) ), 400 ); } if ( 'custom' === $icon && '' === $url ) { wp_send_json_error( array( 'message' => __( 'Choose an image for your own icon, or pick one of ours.', 'clientager' ) ), 400 ); } $hi_on = '1' === $text( 'auto_hi' ); $hi_text = $text( 'auto_hi_text' ); if ( $hi_on && ( '' === $hi_text || $len( $hi_text ) > 200 ) ) { wp_send_json_error( array( 'message' => __( 'Write a greeting bubble of up to 200 characters.', 'clientager' ) ), 400 ); } $hi_product = $text( 'auto_hi_product' ); if ( $len( $hi_product ) > 200 ) { wp_send_json_error( array( 'message' => __( 'Write a product page greeting of up to 200 characters.', 'clientager' ) ), 400 ); } $auto_hi = array( 'enabled' => $hi_on, 'delay' => max( 1, min( 120, (int) $text( 'auto_hi_delay' ) ) ), 'text' => $hi_text, 'product_text' => $hi_product, 'exit' => '1' === $text( 'auto_hi_exit' ), ); $offset = array(); foreach ( array( 'desktop', 'mobile' ) as $device ) { foreach ( self::OFFSET_MAX as $axis => $max ) { $offset[ $device ][ $axis ] = self::px( $text( 'off_' . $device . '_' . $axis ), $max ); } } $res = Clientager_Api::request( 'POST', '/widget', array( 'position' => 'left' === $text( 'position' ) ? 'left' : 'right', 'offset' => $offset, 'icon' => $icon, 'icon_url' => $url, 'auto_hi' => $auto_hi, 'color' => $color, 'greeting' => $greeting, 'ask_contact' => '1' === $text( 'ask_contact' ), 'form' => $form, 'quick_questions' => $quick, 'pages' => array( 'mode' => in_array( $text( 'pages_mode' ), array( 'only', 'except' ), true ) ? $text( 'pages_mode' ) : 'all', 'rules' => array_values( array_filter( array_map( 'trim', explode( "\n", isset( $_POST['pages_rules'] ) ? sanitize_textarea_field( wp_unslash( $_POST['pages_rules'] ) ) : '' ) ) ) ), ), ), array( 'timeout' => 30 ) ); if ( $res['ok'] ) { Clientager_Api::clear_status(); } self::reply( $res, array( 'message' => __( 'Saved. Your chat uses the new settings now.', 'clientager' ) ) ); } /** * Add or remove an allowed website (Settings tab). */ public static function ajax_origins_save() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $add = isset( $_POST['add'] ) ? sanitize_text_field( wp_unslash( $_POST['add'] ) ) : ''; $remove = isset( $_POST['remove'] ) ? esc_url_raw( wp_unslash( $_POST['remove'] ) ) : ''; $res = Clientager_Api::request( 'POST', '/origins', $remove ? array( 'remove' => $remove ) : array( 'add' => $add ) ); if ( $res['ok'] ) { Clientager_Api::clear_status(); } self::reply( $res, array( 'message' => __( 'Saved.', 'clientager' ) ) ); } /** * Answer a website chat from the Inbox, or take it over / give it back to the AI. */ public static function ajax_convo_reply() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; if ( ! preg_match( '/^[0-9a-f-]{36}$/i', $id ) ) { wp_send_json_error( array( 'message' => __( 'Conversation not found.', 'clientager' ) ), 404 ); } if ( isset( $_POST['ai'] ) ) { $body = array( 'ai' => '1' === sanitize_text_field( wp_unslash( $_POST['ai'] ) ) ); } else { $body = array( 'text' => isset( $_POST['text'] ) ? sanitize_textarea_field( wp_unslash( $_POST['text'] ) ) : '' ); } $res = Clientager_Api::request( 'POST', '/conversations/' . rawurlencode( $id ), $body ); self::reply( $res, array( 'message' => __( 'Sent.', 'clientager' ) ) ); } /** * Save the alert emails and switches (Settings tab). */ public static function ajax_notify_save() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $raw = isset( $_POST['emails'] ) ? sanitize_text_field( wp_unslash( $_POST['emails'] ) ) : ''; $emails = array(); foreach ( preg_split( '/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY ) as $e ) { if ( ! is_email( $e ) ) { /* translators: %s: what was typed. */ wp_send_json_error( array( 'message' => sprintf( __( '"%s" is not a valid email address.', 'clientager' ), $e ) ), 400 ); } $emails[] = strtolower( $e ); } $emails = array_values( array_unique( $emails ) ); if ( ! $emails ) { wp_send_json_error( array( 'message' => __( 'Add at least one email address.', 'clientager' ) ), 400 ); } if ( count( $emails ) > 5 ) { wp_send_json_error( array( 'message' => __( 'Use up to 5 email addresses.', 'clientager' ) ), 400 ); } $body = array( 'emails' => $emails ); foreach ( array( 'new_lead', 'new_booking', 'callback', 'daily_summary' ) as $k ) { $body[ $k ] = isset( $_POST[ $k ] ) && '1' === $_POST[ $k ]; } $res = Clientager_Api::request( 'POST', '/notify', $body ); if ( $res['ok'] ) { Clientager_Api::clear_status(); } self::reply( $res, array( 'message' => __( 'Alerts saved.', 'clientager' ) ) ); } /** * Disconnect this site. */ public static function ajax_disconnect() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); Clientager_Api::request( 'POST', '/disconnect', array(), array( 'timeout' => 8 ) ); Clientager_Api::forget(); wp_send_json_success( array( 'redirect' => admin_url( 'admin.php?page=clientager' ) ) ); } }