title. * * @return array */ public static function pages() { return array( 'statbird' => __( 'Overview', 'statbird' ), 'statbird-customers' => __( 'Customers', 'statbird' ), 'statbird-insights' => __( 'Insights', 'statbird' ), 'statbird-settings' => __( 'Settings', 'statbird' ), 'statbird-help' => __( 'Help', 'statbird' ), ); } /** * Menu. */ public static function menu() { add_menu_page( 'Statbird', 'Statbird', self::CAP, 'statbird', array( __CLASS__, 'render' ), self::menu_icon(), 56 ); foreach ( self::pages() as $slug => $title ) { add_submenu_page( 'statbird', $title . ' - Statbird', $title, self::CAP, $slug, array( __CLASS__, 'render' ) ); } } /** * Settings link on the Plugins screen. * * @param array $links Links. * @return array */ public static function action_links( $links ) { array_unshift( $links, '' . esc_html__( 'Dashboard', 'statbird' ) . '' ); return $links; } /** * The logo mark: a bird perched at the top of a rising trend line. * * @param string $eye Eye colour (the background it sits on). * @return string SVG. */ public static function logo_svg( $eye = '#fffdf9' ) { return ''; } /** * Menu icon as a data URI (WordPress recolours it to match the admin scheme). * * @return string */ private static function menu_icon() { $svg = ''; return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- standard way to pass a menu icon. } /** * Whether the current screen is a Statbird page. * * @return string Page slug or ''. */ public static function current_page() { $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only. return isset( self::pages()[ $page ] ) ? $page : ''; } /** * Styles and scripts on Statbird pages (and the order screen card). * * @param string $hook Screen hook. */ public static function assets( $hook ) { $page = self::current_page(); $order = in_array( $hook, array( 'post.php', 'woocommerce_page_wc-orders' ), true ); if ( ! $page && ! $order ) { return; } wp_enqueue_style( 'statbird-admin', STATBIRD_URL . 'assets/admin.css', array(), STATBIRD_VERSION ); if ( ! $page ) { return; } wp_enqueue_script( 'statbird-admin', STATBIRD_URL . 'assets/admin.js', array(), STATBIRD_VERSION, true ); wp_localize_script( 'statbird-admin', 'statbird', array( 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'statbird' ), 'i18n' => array( 'working' => __( 'Working...', 'statbird' ), 'thinking' => __( 'Thinking...', 'statbird' ), 'error' => __( 'Something went wrong. Please try again.', 'statbird' ), 'copied' => __( 'Copied', 'statbird' ), 'copy' => __( 'Copy email', 'statbird' ), /* translators: %d: number of customers read so far. */ 'reading' => __( 'Reading orders... %d customers so far', 'statbird' ), 'scoring' => __( 'Scoring customers...', 'statbird' ), 'reports' => __( 'Based on:', 'statbird' ), ), ) ); } /** * Renders a Statbird page. */ public static function render() { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to view this page.', 'statbird' ) ); } $page = self::current_page(); $view = array( 'statbird' => 'overview', 'statbird-customers' => 'customers', 'statbird-insights' => 'insights', 'statbird-settings' => 'settings', 'statbird-help' => 'help', )[ $page ] ?? 'overview'; echo '
'; include STATBIRD_DIR . 'admin/views/header.php'; include STATBIRD_DIR . 'admin/views/' . $view . '.php'; echo '
'; } /** * Whether there is scored data to show. * * @return bool */ public static function has_data() { $snap = Insights::get(); return ! empty( $snap['kpis']['customers'] ); } /** * Money, in the store's currency, without trailing zeros for whole amounts. * * @param float $amount Amount. * @param int $decimals Decimals. * @return string Plain text (no HTML). */ public static function money( $amount, $decimals = 0 ) { $sym = html_entity_decode( get_woocommerce_currency_symbol(), ENT_QUOTES, 'UTF-8' ); return $sym . number_format_i18n( (float) $amount, $decimals ); } /** * Link to a customer's orders (their history in WooCommerce). * * @param array $c Customer row. * @return string */ public static function customer_url( array $c ) { $email = (string) ( $c['email'] ?? '' ); if ( Orders::uses_hpos() ) { return admin_url( 'admin.php?page=wc-orders&s=' . rawurlencode( $email ) ); } return admin_url( 'edit.php?post_type=shop_order&s=' . rawurlencode( $email ) ); } /** * Human time since a GMT datetime. * * @param string $gmt MySQL datetime (GMT). * @return string */ public static function ago( $gmt ) { if ( ! $gmt ) { return ''; } /* translators: %s: human time difference, like "3 days". */ return sprintf( __( '%s ago', 'statbird' ), human_time_diff( strtotime( $gmt . ' UTC' ), time() ) ); } /** * AJAX: rebuild and scoring progress. */ public static function ajax_status() { check_ajax_referer( 'statbird', 'nonce' ); if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( null, 403 ); } $s = Rebuild::state(); wp_send_json_success( array( 'status' => $s['status'], 'done' => (int) $s['done'], 'ready' => self::has_data() && 'running' !== $s['status'], ) ); } /** * AJAX: start a rebuild. */ public static function ajax_start() { check_ajax_referer( 'statbird', 'nonce' ); if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( null, 403 ); } Rebuild::start(); wp_send_json_success( Rebuild::state() ); } /** * AJAX: AI features. */ public static function ajax_ai() { check_ajax_referer( 'statbird', 'nonce' ); if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( null, 403 ); } $task = isset( $_POST['task'] ) ? sanitize_key( wp_unslash( $_POST['task'] ) ) : ''; $fresh = ! empty( $_POST['fresh'] ); switch ( $task ) { case 'summary': $r = AI::summary( $fresh ); break; case 'next_action': $r = AI::next_action( isset( $_POST['group'] ) ? sanitize_key( wp_unslash( $_POST['group'] ) ) : '', $fresh ); break; case 'explain': $r = AI::explain( isset( $_POST['index'] ) ? absint( $_POST['index'] ) : 0, $fresh ); break; case 'patterns': $r = AI::pattern_ideas( $fresh ); break; case 'ask': $r = AI::ask( isset( $_POST['question'] ) ? sanitize_textarea_field( wp_unslash( $_POST['question'] ) ) : '' ); break; default: $r = new \WP_Error( 'statbird_task', __( 'Unknown task.', 'statbird' ) ); } if ( is_wp_error( $r ) ) { wp_send_json_error( array( 'message' => $r->get_error_message() ) ); } if ( 'summary' === $task && is_string( $r ) ) { update_option( 'statbird_last_summary', array( 'text' => $r, 'at' => gmdate( 'Y-m-d H:i:s' ), ), false ); } wp_send_json_success( is_array( $r ) ? $r : array( 'text' => $r ) ); } /** * Saves settings. */ public static function save_settings() { check_admin_referer( 'statbird_settings' ); if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'statbird' ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised key by key in Settings::save(). $input = isset( $_POST['statbird'] ) && is_array( $_POST['statbird'] ) ? wp_unslash( $_POST['statbird'] ) : array(); Settings::save( $input ); if ( ! empty( $_POST['rebuild'] ) ) { Rebuild::start(); } wp_safe_redirect( admin_url( 'admin.php?page=statbird-settings&saved=1' ) ); exit; } /** * Customers matching the Customers screen filters. * * @param array $args group, s, orderby, order, limit, offset. * @return array{rows:array[],total:int} */ public static function query_customers( array $args ) { global $wpdb; $t = Store::table(); $where = array( '1=1' ); $params = array(); if ( ! empty( $args['group'] ) && isset( Scoring::groups()[ $args['group'] ] ) ) { $where[] = 'segment = %s'; $params[] = $args['group']; } if ( ! empty( $args['s'] ) ) { $like = '%' . $wpdb->esc_like( $args['s'] ) . '%'; $where[] = '(name LIKE %s OR email LIKE %s)'; $params[] = $like; $params[] = $like; } $cols = array( 'name' => 'name', 'orders' => 'orders', 'spent' => 'spent', 'last_order' => 'last_order', 'churn_risk' => 'churn_risk', 'value_12m' => 'value_12m', 'segment' => 'segment', ); $orderby = $cols[ $args['orderby'] ?? '' ] ?? 'spent'; $order = 'asc' === strtolower( $args['order'] ?? '' ) ? 'ASC' : 'DESC'; $sql = implode( ' AND ', $where ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- table name, whitelisted column and direction; values use placeholders. $count_sql = "SELECT COUNT(*) FROM $t WHERE $sql"; $total = (int) ( $params ? $wpdb->get_var( $wpdb->prepare( $count_sql, $params ) ) : $wpdb->get_var( $count_sql ) ); $list_sql = "SELECT * FROM $t WHERE $sql ORDER BY $orderby $order, id ASC LIMIT %d OFFSET %d"; $rows = (array) $wpdb->get_results( $wpdb->prepare( $list_sql, array_merge( $params, array( (int) ( $args['limit'] ?? 25 ), (int) ( $args['offset'] ?? 0 ) ) ) ), ARRAY_A ); // phpcs:enable return array( 'rows' => $rows, 'total' => $total, ); } /** * Streams a CSV of the filtered customers. */ public static function export() { check_admin_referer( 'statbird_export' ); if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'statbird' ) ); } $group = isset( $_GET['group'] ) ? sanitize_key( wp_unslash( $_GET['group'] ) ) : ''; $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $groups = Scoring::groups(); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="statbird-' . ( $group ? $group : 'customers' ) . '-' . gmdate( 'Y-m-d' ) . '.csv"' ); $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming a download. fputcsv( $out, array( 'Name', 'Email', 'Group', 'Orders', 'Spent', 'First order', 'Last order', 'Chance of ordering again (90 days)', 'Churn risk', 'Next 12 months', 'Recency', 'Frequency', 'Monetary' ), ',', '"', '' ); $offset = 0; do { $batch = self::query_customers( array( 'group' => $group, 's' => $search, 'orderby' => 'spent', 'limit' => 1000, 'offset' => $offset, ) )['rows']; foreach ( $batch as $c ) { fputcsv( $out, array_map( array( __CLASS__, 'csv_safe' ), array( $c['name'], $c['email'], $groups[ $c['segment'] ]['label'] ?? $c['segment'], $c['orders'], $c['spent'], $c['first_order'], $c['last_order'], ( 100 - (int) $c['churn_risk'] ) . '%', $c['churn_risk'] . '%', $c['value_12m'], $c['r'], $c['f'], $c['m'] ) ), ',', '"', '' ); } $offset += 1000; $got = count( $batch ); } while ( 1000 === $got ); fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- streaming a download. exit; } /** * Stops spreadsheet apps from running cell contents as formulas. * * @param mixed $v Cell. * @return string */ public static function csv_safe( $v ) { $v = (string) $v; return ( '' !== $v && in_array( $v[0], array( '=', '+', '-', '@', "\t", "\r" ), true ) && ! is_numeric( $v ) ) ? "'" . $v : $v; } /** * Adds the customer card to the order screen (both order storage modes). */ public static function order_box() { $screen = function_exists( 'wc_get_page_screen_id' ) ? wc_get_page_screen_id( 'shop-order' ) : 'shop_order'; add_meta_box( 'statbird-customer', __( 'Customer insight', 'statbird' ), array( __CLASS__, 'render_order_box' ), $screen, 'side', 'high' ); } /** * Renders the order screen card. * * @param \WP_Post|\WC_Order $order_or_post Order or its post. */ public static function render_order_box( $order_or_post ) { global $wpdb; $order = $order_or_post instanceof \WC_Order ? $order_or_post : wc_get_order( $order_or_post->ID ); if ( ! $order ) { return; } $key = Orders::key_for_order( $order ); $t = Store::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name only. $c = $key ? $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $t WHERE customer_key = %s", md5( $key ) ), ARRAY_A ) : null; include STATBIRD_DIR . 'admin/views/order-box.php'; } }