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(
'
',
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 '';
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 '';
foreach ( self::pages() as $slug => $title ) {
printf(
'',
esc_url( admin_url( 'admin.php?page=' . $slug ) ),
$slug === $page ? ' is-active' : '',
$slug === $page ? ' aria-current="page"' : ''
);
self::icon( $icons[ $slug ] );
echo '' . esc_html( $title ) . ' ';
$count = array(
'clientager-requests' => isset( $status['requests_open'] ) ? (int) $status['requests_open'] : 0,
'clientager-leads' => isset( $status['leads_new'] ) && $slug !== $page ? (int) $status['leads_new'] : 0,
);
if ( ! empty( $count[ $slug ] ) ) {
echo '' . esc_html( number_format_i18n( $count[ $slug ] ) ) . ' ';
}
echo ' ';
}
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' ) ),
);
?>
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' );
}
?>
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( '%2$s ', 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'; ?>>
' . $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' );
}
?>
>
>×
__( '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' ) );
?>
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 ),
);
?>
= $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 %6$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(
'
%2$s ',
$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(
'
',
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(
'%3$s
',
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 ] : '';
};
?>
';
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 ] ) . ' ' . esc_html( self::ago( isset( $t['created_at'] ) ? (string) $t['created_at'] : '' ) ) . ' ';
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 '
' . esc_html( sprintf( __( 'Email this note to %s', 'clientager' ), $email ) ) . '';
}
echo '
';
if ( 'open' === $status ) {
echo '' . esc_html__( 'Start working on it', 'clientager' ) . ' ';
}
if ( ! $done ) {
echo '' . esc_html__( 'Mark resolved', 'clientager' ) . ' ';
} else {
echo '' . esc_html__( 'Reopen', 'clientager' ) . ' ';
}
echo '' . esc_html__( 'Save note', 'clientager' ) . ' ';
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(
'%5$s %6$s ',
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 '' . esc_html__( 'Contact', 'clientager' ) . ' ' . esc_html__( 'Phone', 'clientager' ) . ' ' . esc_html__( 'Request', 'clientager' ) . ' ' . esc_html__( 'Source', 'clientager' ) . ' ' . esc_html__( 'Status', 'clientager' ) . ' ' . esc_html__( 'Date', 'clientager' ) . ' ';
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 '' . esc_html( ! empty( $l['phone'] ) ? (string) $l['phone'] : '–' ) . ' ';
echo '' . esc_html( isset( $l['title'] ) ? (string) $l['title'] : '' ) . ' ';
echo '' . esc_html( isset( $l['source'] ) ? ucfirst( str_replace( '_', ' ', (string) $l['source'] ) ) : '' ) . ' ';
$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 '';
foreach ( $opts as $k => $label ) {
echo '' . esc_html( $label ) . ' ';
}
echo ' ';
} else {
echo '' . esc_html( $status ? ucfirst( $status ) : '–' ) . ' ';
}
echo '' . esc_html( self::ago( isset( $l['created_at'] ) ? (string) $l['created_at'] : '' ) ) . ' ';
}
echo '
';
}
/**
* 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 %5$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( ' true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'cx' => true,
'cy' => true,
'r' => true,
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'rx' => true,
);
return array(
'svg' => array_merge(
$shape,
array(
'class' => true,
'viewbox' => true,
'aria-hidden' => true,
'focusable' => true,
'xmlns' => true,
)
),
'path' => $shape,
'circle' => $shape,
'rect' => $shape,
);
}
/**
* Initials for an avatar circle.
*
* @param string $name Name or email.
* @return string
*/
private static function initials( $name ) {
$name = trim( preg_replace( '/@.*$/', '', (string) $name ) );
$parts = preg_split( '/[\s._-]+/', $name, -1, PREG_SPLIT_NO_EMPTY );
if ( ! $parts ) {
return '?';
}
$first = function_exists( 'mb_substr' ) ? mb_substr( $parts[0], 0, 1 ) : substr( $parts[0], 0, 1 );
$last = count( $parts ) > 1 ? ( function_exists( 'mb_substr' ) ? mb_substr( end( $parts ), 0, 1 ) : substr( end( $parts ), 0, 1 ) ) : '';
return function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $first . $last ) : strtoupper( $first . $last );
}
/**
* Coloured channel badge (escaped HTML).
*
* @param string $channel Channel key.
* @return string
*/
private static function channel_badge( $channel ) {
$key = 'voice' === $channel ? 'phone' : sanitize_html_class( $channel );
return '' . 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' ) ) );
}
}