'customer', 'plural' => 'customers', 'ajax' => false, ) ); } /** * The list's columns. * * @return array Column key => label. */ public function get_columns() { $columns = array(); $columns['customer_id'] = __( 'ID', 'advanced-customer-reports-woo' ); $columns['customer_login'] = __( 'Username', 'advanced-customer-reports-woo' ); $columns['customer_name'] = __( 'Name', 'advanced-customer-reports-woo' ); $columns['customer_email'] = __( 'Email', 'advanced-customer-reports-woo' ); $columns['customer_orders'] = __( 'Orders', 'advanced-customer-reports-woo' ); $columns['customer_total_spent'] = __( 'Total Spent', 'advanced-customer-reports-woo' ); $columns['view_report'] = __( 'View Report', 'advanced-customer-reports-woo' ); return $columns; } /** * The sortable columns, and the orderby value each one sorts on. * * @return array Column key => array( orderby value, starts descending ). */ public function get_sortable_columns() { $columns = array( 'customer_id' => array('customer_id', false), 'customer_login' => array('customer_login', false), 'customer_name' => array('customer_name', false), 'customer_email' => array('customer_email', false), 'customer_orders' => array('customer_orders', true), 'customer_total_spent' => array('customer_total_spent', true), ); return $columns; } /** * The bulk actions offered above the list. * * @return array Action key => label. */ protected function get_bulk_actions() { $actions = array(); return $actions; } /** * Load one page of customers for display. * * @return void */ public function prepare_items() { $columns = $this->get_columns(); $hidden = array(); $sortable = $this->get_sortable_columns(); $this->_column_headers = array($columns, $hidden, $sortable); $per_page = $this->get_items_per_page( 'customers_per_page', 20 ); $current_page = $this->get_pagenum(); $this->set_request_filters(); $total_items = $this->get_customers_count(); $this->set_pagination_args( array( 'total_items' => $total_items, 'per_page' => $per_page, 'total_pages' => ceil( $total_items / $per_page ), ) ); $this->items = $this->get_customers( $per_page, $current_page ); } /** * Count the customers matching a search term. * * @param string $search Search term. * @return int */ public function get_total_customers( $search = '' ) { $this->search = sanitize_text_field( $search ); return $this->get_customers_count(); } /** * Read the search, filter and sort settings from the request. * * @return void */ private function set_request_filters() { $allowed_orderby = array_values( wp_list_pluck( $this->get_sortable_columns(), 0 ) ); $default_orderby = acreports_get_option( 'sort_users_by', 'customer_total_spent' ); if ( !in_array( $default_orderby, $allowed_orderby, true ) ) { $default_orderby = 'customer_total_spent'; } // The filters narrow down a read-only list and travel in the URL, so the // pagination and sorting links keep them. // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only list filters, validated below. $this->search = ( isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '' ); $this->min_orders = $this->get_int_filter( 'min_orders' ); $this->max_orders = $this->get_int_filter( 'max_orders' ); $this->min_spent = $this->get_decimal_filter( 'min_spent' ); $this->max_spent = $this->get_decimal_filter( 'max_spent' ); $requested_orderby = ( isset( $_REQUEST['orderby'] ) ? sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ) : $default_orderby ); $this->orderby = ( in_array( $requested_orderby, $allowed_orderby, true ) ? $requested_orderby : $default_orderby ); $requested_order = ( isset( $_REQUEST['order'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ) : 'DESC' ); $this->order = ( 'ASC' === $requested_order ? 'ASC' : 'DESC' ); // phpcs:enable WordPress.Security.NonceVerification.Recommended } /** * Read a whole-number filter from the request. * * @param string $key Request key. * @return int|null The value, or null when it is not set. */ private function get_int_filter( $key ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only list filter. if ( !isset( $_REQUEST[$key] ) || '' === $_REQUEST[$key] ) { return null; } return max( 0, absint( wp_unslash( $_REQUEST[$key] ) ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended } /** * Read a decimal filter from the request. * * @param string $key Request key. * @return float|null The value, or null when it is not set. */ private function get_decimal_filter( $key ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only list filter. if ( !isset( $_REQUEST[$key] ) || '' === $_REQUEST[$key] ) { return null; } $value = sanitize_text_field( wp_unslash( $_REQUEST[$key] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended return max( 0, (float) wc_format_decimal( $value ) ); } /** * Whether the WooCommerce lookup tables the list is built from exist. * * @return bool */ private function lookup_table_exists() { static $exists = null; if ( null !== $exists ) { return $exists; } global $wpdb; $lookup_table = $wpdb->prefix . 'wc_customer_lookup'; $order_stats_table = $wpdb->prefix . 'wc_order_stats'; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A table-exists check, memoised for the request. $exists = $lookup_table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $lookup_table ) ) && $order_stats_table === $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $order_stats_table ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $exists; } /** * The paid order statuses the Orders and Total Spent columns count, with and without the wc- prefix. * * @return string[] */ private function get_paid_order_statuses() { $statuses = ( function_exists( 'wc_get_is_paid_statuses' ) ? wc_get_is_paid_statuses() : array('processing', 'completed') ); $statuses_with_prefixes = array(); foreach ( $statuses as $status ) { $status = (string) $status; $statuses_with_prefixes[] = $status; $statuses_with_prefixes[] = ( 0 === strpos( $status, 'wc-' ) ? substr( $status, 3 ) : 'wc-' . $status ); } $statuses_with_prefixes = array_values( array_unique( array_filter( $statuses_with_prefixes ) ) ); return ( !empty( $statuses_with_prefixes ) ? $statuses_with_prefixes : array( 'processing', 'completed', 'wc-processing', 'wc-completed' ) ); } /** * The column to sort by, from an allow-list. * * @return string */ private function get_orderby_sql() { $orderby_map = array( 'customer_id' => 'customer_id', 'customer_login' => 'customer_login', 'customer_name' => 'customer_name', 'customer_email' => 'customer_email', 'customer_orders' => 'customer_orders', 'customer_total_spent' => 'customer_total_spent', 'customer_health' => 'customer_health', ); return ( isset( $orderby_map[$this->orderby] ) ? $orderby_map[$this->orderby] : $orderby_map['customer_total_spent'] ); } /** * The order totals subquery, shared by the registered and guest branches. * * Guest orders are attributed to their own lookup row by WooCommerce, so the * same join works for both. * * @param array $params Query parameters, appended to. * @return string SQL. */ private function get_order_stats_join( &$params ) { global $wpdb; $order_stats_table = $wpdb->prefix . 'wc_order_stats'; $paid_statuses = $this->get_paid_order_statuses(); $status_placeholders = implode( ', ', array_fill( 0, count( $paid_statuses ), '%s' ) ); $params = array_merge( $params, $paid_statuses ); // parent_id > 0 rows are refunds, not orders. Counting them would add // phantom orders and subtract their negative totals from the spend. return "\n\t\t\tLEFT JOIN (\n\t\t\t\tSELECT customer_id, COUNT(order_id) AS orders_count, SUM(total_sales) AS total_spent\n\t\t\t\tFROM {$order_stats_table}\n\t\t\t\tWHERE status IN ({$status_placeholders})\n\t\t\t\t\tAND parent_id = 0\n\t\t\t\tGROUP BY customer_id\n\t\t\t) os ON os.customer_id = cl.customer_id\n\t\t"; } /** * Order count and spend filters. * * The expressions differ between the two branches because the registered * branch sums across a customer's lookup rows, so the caller passes in how * orders and spend are expressed. * * @param string $orders_expression SQL for the order count. * @param string $spent_expression SQL for the total spent. * @param array $params Query parameters, appended to. * @return string[] Conditions. */ private function get_totals_conditions( $orders_expression, $spent_expression, &$params ) { $conditions = array(); if ( null !== $this->min_orders ) { $conditions[] = "{$orders_expression} >= %d"; $params[] = $this->min_orders; } if ( null !== $this->max_orders ) { $conditions[] = "{$orders_expression} <= %d"; $params[] = $this->max_orders; } if ( null !== $this->min_spent ) { $conditions[] = "{$spent_expression} >= %f"; $params[] = $this->min_spent; } if ( null !== $this->max_spent ) { $conditions[] = "{$spent_expression} <= %f"; $params[] = $this->max_spent; } return $conditions; } /** * Registered customers: every WordPress user, with their order totals. * * When guest orders are merged, a customer's totals are summed across both * their account lookup row and any guest lookup row using the same email, * so the list agrees with their report and their segment. * * @param array $params Query parameters, appended to. * @return string SQL. */ private function get_registered_customers_query( &$params ) { global $wpdb; $lookup_table = $wpdb->prefix . 'wc_customer_lookup'; $name_expression = "COALESCE(NULLIF(TRIM(CONCAT_WS(' ', MAX(NULLIF(cl.first_name, '')), MAX(NULLIF(cl.last_name, '')))), ''), NULLIF(u.display_name, ''), u.user_login)"; $orders_expression = 'COALESCE(SUM(os.orders_count), 0)'; $spent_expression = 'COALESCE(SUM(os.total_spent), 0)'; $where = array('1=1'); $lookup_join = 'cl.user_id = u.ID'; if ( acreports_should_merge_guest_orders() ) { $lookup_join .= " OR ( cl.user_id IS NULL AND COALESCE(cl.email, '') <> '' AND cl.email = u.user_email )"; } $columns = array( 'u.ID AS customer_id', 'u.user_login AS customer_login', "{$name_expression} AS customer_name", "COALESCE(MAX(NULLIF(cl.first_name, '')), '') AS customer_first_name", "COALESCE(MAX(NULLIF(cl.last_name, '')), '') AS customer_last_name", 'u.user_email AS customer_email', "{$orders_expression} AS customer_orders", "{$spent_expression} AS customer_total_spent", '0 AS is_guest' ); $joins = $this->get_order_stats_join( $params ); $select = 'SELECT ' . implode( ', ', $columns ) . "\n\t\t\tFROM {$wpdb->users} u\n\t\t\tLEFT JOIN {$lookup_table} cl ON ( {$lookup_join} )\n\t\t\t{$joins}\n\t\t"; if ( '' !== $this->search ) { $like = '%' . $wpdb->esc_like( $this->search ) . '%'; $search_where = array( 'u.user_login LIKE %s', 'u.user_email LIKE %s', 'u.display_name LIKE %s', 'u.user_nicename LIKE %s', 'cl.first_name LIKE %s', 'cl.last_name LIKE %s', "CONCAT_WS(' ', cl.first_name, cl.last_name) LIKE %s", 'cl.email LIKE %s' ); $params = array_merge( $params, array_fill( 0, count( $search_where ), $like ) ); if ( is_numeric( $this->search ) ) { $search_where[] = 'u.ID = %d'; $params[] = absint( $this->search ); } $where[] = '(' . implode( ' OR ', $search_where ) . ')'; } $sql = $select . ' WHERE ' . implode( ' AND ', $where ); $sql .= ' GROUP BY u.ID, u.user_login, u.user_email, u.display_name'; // Order and spend filters compare against the summed totals. $having = $this->get_totals_conditions( $orders_expression, $spent_expression, $params ); if ( !empty( $having ) ) { $sql .= ' HAVING ' . implode( ' AND ', $having ); } return $sql; } /** * Guest customers: the lookup rows with no user_id. * * That is how WooCommerce records someone who checked out without creating * an account. * * @param array $params Query parameters, appended to. * @return string SQL. */ private function get_guest_customers_query( &$params ) { global $wpdb; $lookup_table = $wpdb->prefix . 'wc_customer_lookup'; $name_expression = "COALESCE(NULLIF(TRIM(CONCAT_WS(' ', NULLIF(cl.first_name, ''), NULLIF(cl.last_name, ''))), ''), cl.email)"; $where = array('cl.user_id IS NULL', "COALESCE(cl.email, '') <> ''"); $columns = array( '0 AS customer_id', "'' AS customer_login", "{$name_expression} AS customer_name", "COALESCE(cl.first_name, '') AS customer_first_name", "COALESCE(cl.last_name, '') AS customer_last_name", 'cl.email AS customer_email', 'COALESCE(os.orders_count, 0) AS customer_orders', 'COALESCE(os.total_spent, 0) AS customer_total_spent', '1 AS is_guest' ); $joins = $this->get_order_stats_join( $params ); $select = 'SELECT ' . implode( ', ', $columns ) . "\n\t\t\tFROM {$lookup_table} cl\n\t\t\t{$joins}\n\t\t"; // When guest orders are folded into the matching registered customer, // listing them again here would double up the same person. if ( acreports_should_merge_guest_orders() ) { $where[] = "NOT EXISTS ( SELECT 1 FROM {$wpdb->users} u2 WHERE u2.user_email = cl.email )"; } if ( '' !== $this->search ) { $like = '%' . $wpdb->esc_like( $this->search ) . '%'; $search_where = array( 'cl.email LIKE %s', 'cl.first_name LIKE %s', 'cl.last_name LIKE %s', "CONCAT_WS(' ', cl.first_name, cl.last_name) LIKE %s" ); $params = array_merge( $params, array_fill( 0, count( $search_where ), $like ) ); $where[] = '(' . implode( ' OR ', $search_where ) . ')'; } $where = array_merge( $where, $this->get_totals_conditions( 'COALESCE(os.orders_count, 0)', 'COALESCE(os.total_spent, 0)', $params ) ); return $select . ' WHERE ' . implode( ' AND ', $where ); } /** * The FROM and WHERE clauses shared by the count and the page queries. * * @return array {from, where, params} */ private function get_query_parts() { $params = array(); $registered_sql = $this->get_registered_customers_query( $params ); $guest_sql = $this->get_guest_customers_query( $params ); $union_sql = "{$registered_sql} UNION ALL {$guest_sql}"; $from = "FROM ( {$union_sql} ) customers"; $where = array(); return array( 'from' => $from, 'where' => ( empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where ) ), 'params' => $params, ); } /** * Bind parameters into SQL. * * @param string $sql SQL with placeholders. * @param array $params Values, in placeholder order. * @return string */ private function prepare_sql( $sql, $params = array() ) { global $wpdb; if ( empty( $params ) ) { return $sql; } array_unshift( $params, $sql ); return call_user_func_array( array($wpdb, 'prepare'), $params ); } /** * Count the customers matching the filters. * * @return int */ private function get_customers_count() { if ( !$this->lookup_table_exists() ) { return count( $this->get_customers_fallback( -1, 1 ) ); } global $wpdb; $query_parts = $this->get_query_parts(); $sql = "SELECT COUNT(*) {$query_parts['from']} {$query_parts['where']}"; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Analytics tables have no API. The query is built from literals and placeholders only; every value is bound by prepare_sql(). $count = $wpdb->get_var( $this->prepare_sql( $sql, $query_parts['params'] ) ); if ( !empty( $wpdb->last_error ) ) { return count( $this->get_customers_fallback( -1, 1 ) ); } return (int) $count; } /** * One page of customers matching the filters. * * @param int $per_page Customers per page. * @param int $page_number Page, from 1. * @return array[] Customers. */ public function get_customers( $per_page = 20, $page_number = 1 ) { if ( !$this->lookup_table_exists() ) { return $this->get_customers_fallback( $per_page, $page_number ); } global $wpdb; $query_parts = $this->get_query_parts(); $offset = max( 0, ($page_number - 1) * $per_page ); $orderby_sql = $this->get_orderby_sql(); $sql = "\n\t\t\tSELECT *\n\t\t\t{$query_parts['from']}\n\t\t\t{$query_parts['where']}\n\t\t\tORDER BY {$orderby_sql} {$this->order}, customer_id DESC, customer_email ASC\n\t\t\tLIMIT %d OFFSET %d\n\t\t"; $params = array_merge( $query_parts['params'], array($per_page, $offset) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Analytics tables have no API. The query is built from literals and placeholders only; every value is bound by prepare_sql(). $rows = $wpdb->get_results( $this->prepare_sql( $sql, $params ), ARRAY_A ); if ( !empty( $wpdb->last_error ) || !is_array( $rows ) ) { return $this->get_customers_fallback( $per_page, $page_number ); } $customers = array(); foreach ( $rows as $row ) { $customers[] = array( 'customer_id' => absint( $row['customer_id'] ), 'customer_login' => $row['customer_login'], 'customer_name' => $row['customer_name'], 'customer_first_name' => (string) $row['customer_first_name'], 'customer_last_name' => (string) $row['customer_last_name'], 'customer_email' => $row['customer_email'], 'customer_orders' => absint( $row['customer_orders'] ), 'customer_total_spent' => (float) $row['customer_total_spent'], 'is_guest' => !empty( $row['is_guest'] ), 'customer_segment' => ( isset( $row['customer_segment'] ) ? (string) $row['customer_segment'] : '' ), 'customer_health' => ( isset( $row['customer_health'] ) ? absint( $row['customer_health'] ) : 0 ), 'first_order_gmt' => ( isset( $row['rfm_first'] ) ? (string) $row['rfm_first'] : '' ), 'last_order_gmt' => ( isset( $row['rfm_last'] ) ? (string) $row['rfm_last'] : '' ), ); } return $customers; } /** * Registered customers only, read user by user, for stores without the lookup tables. * * @param int $per_page Customers per page, or -1 for all. * @param int $page_number Page, from 1. * @return array[] Customers. */ private function get_customers_fallback( $per_page = 20, $page_number = 1 ) { $args = array( 'search' => '*' . esc_attr( $this->search ) . '*', 'search_columns' => array( 'user_login', 'user_email', 'display_name', 'user_nicename', 'ID' ), 'orderby' => 'ID', 'order' => 'DESC', ); if ( -1 !== $per_page ) { $args['number'] = $per_page; $args['offset'] = ($page_number - 1) * $per_page; } $all_users = get_users( $args ); $customers = array(); foreach ( $all_users as $user ) { $customer_orders = wc_get_customer_order_count( $user->ID ); $customer_total_spent = wc_get_customer_total_spent( $user->ID ); if ( null !== $this->min_orders && $customer_orders < $this->min_orders ) { continue; } if ( null !== $this->max_orders && $customer_orders > $this->max_orders ) { continue; } if ( null !== $this->min_spent && $customer_total_spent < $this->min_spent ) { continue; } if ( null !== $this->max_spent && $customer_total_spent > $this->max_spent ) { continue; } $customers[] = array( 'customer_id' => $user->ID, 'customer_login' => $user->user_login, 'customer_name' => $user->display_name, 'customer_first_name' => $user->first_name, 'customer_last_name' => $user->last_name, 'customer_email' => $user->user_email, 'customer_orders' => $customer_orders, 'customer_total_spent' => $customer_total_spent, 'is_guest' => false, 'customer_segment' => '', 'customer_health' => 0, 'first_order_gmt' => '', 'last_order_gmt' => '', ); } return $customers; } /** * The checkbox a customer is selected for a bulk action with. * * Guests have no user ID, so they are keyed by their billing email. * * @param array $item Customer. * @return string HTML. */ protected function column_cb( $item ) { $key = ( !empty( $item['is_guest'] ) ? 'guest:' . $item['customer_email'] : 'user:' . $item['customer_id'] ); $id = 'acreports-customer-' . md5( $key ); return sprintf( '', esc_attr( $id ), /* translators: %s: customer name. */ esc_html( sprintf( __( 'Select %s', 'advanced-customer-reports-woo' ), $item['customer_name'] ) ), esc_attr( $key ) ); } /** * Render a cell. * * WP_List_Table echoes whatever this returns without escaping it. Names and * emails come from checkout, so every value is escaped here. * * @param array $item Customer. * @param string $column_name Column key. * @return string HTML. */ public function column_default( $item, $column_name ) { $is_guest = !empty( $item['is_guest'] ); switch ( $column_name ) { case 'customer_id': return ( $is_guest ? '—' : esc_html( $item[$column_name] ) ); case 'customer_login': return ( $is_guest ? '' . esc_html__( 'Guest', 'advanced-customer-reports-woo' ) . '' : esc_html( $item[$column_name] ) ); case 'customer_name': case 'customer_email': case 'customer_orders': return esc_html( $item[$column_name] ); case 'customer_total_spent': return wp_kses_post( wc_price( $item[$column_name] ) ); case 'view_report': // type="button", because the list sits inside the bulk actions form. return ''; default: return ''; } } } /** * Render the customers list, with its filters, on the report screen. * * @return void */ function acreports_customers_table() { if ( !acreports_current_user_can_view_reports() ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only list filters, kept in the URL and validated below. $page = ( isset( $_REQUEST['page'] ) ? sanitize_key( wp_unslash( $_REQUEST['page'] ) ) : 'advanced-customer-reports' ); $search = ( isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '' ); $min_orders = ( isset( $_REQUEST['min_orders'] ) && '' !== $_REQUEST['min_orders'] ? absint( wp_unslash( $_REQUEST['min_orders'] ) ) : '' ); $max_orders = ( isset( $_REQUEST['max_orders'] ) && '' !== $_REQUEST['max_orders'] ? absint( wp_unslash( $_REQUEST['max_orders'] ) ) : '' ); $min_spent = ( isset( $_REQUEST['min_spent'] ) && '' !== $_REQUEST['min_spent'] ? wc_format_decimal( sanitize_text_field( wp_unslash( $_REQUEST['min_spent'] ) ) ) : '' ); $max_spent = ( isset( $_REQUEST['max_spent'] ) && '' !== $_REQUEST['max_spent'] ? wc_format_decimal( sanitize_text_field( wp_unslash( $_REQUEST['max_spent'] ) ) ) : '' ); $segment = ( isset( $_REQUEST['segment'] ) ? sanitize_key( wp_unslash( $_REQUEST['segment'] ) ) : '' ); $orderby = ( isset( $_REQUEST['orderby'] ) ? sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ) : acreports_get_option( 'sort_users_by', 'customer_total_spent' ) ); $order = ( isset( $_REQUEST['order'] ) && 'asc' === strtolower( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ) ? 'asc' : 'desc' ); $paged = ( isset( $_REQUEST['paged'] ) ? absint( wp_unslash( $_REQUEST['paged'] ) ) : 0 ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $is_premium = acreports_fs()->can_use_premium_code(); $segment_labels = acreports_get_rfm_segment_labels(); if ( !$is_premium || !isset( $segment_labels[$segment] ) ) { $segment = ''; } // The filters in effect, so the export and the bulk actions form act on the // same customers as the list on screen. $filter_args = array_filter( array( 's' => $search, 'min_orders' => $min_orders, 'max_orders' => $max_orders, 'min_spent' => $min_spent, 'max_spent' => $max_spent, 'segment' => $segment, 'orderby' => $orderby, 'order' => $order, ), function ( $value ) { return '' !== (string) $value; } ); $list_url = add_query_arg( array_merge( array( 'page' => $page, ), $filter_args, ( $paged ? array( 'paged' => $paged, ) : array() ) ), admin_url( 'admin.php' ) ); $sort_options = array( 'customer_total_spent' => __( 'Total spent', 'advanced-customer-reports-woo' ), 'customer_orders' => __( 'Total orders', 'advanced-customer-reports-woo' ), 'customer_health' => __( 'Health score', 'advanced-customer-reports-woo' ), 'customer_name' => __( 'Name', 'advanced-customer-reports-woo' ), 'customer_email' => __( 'Email', 'advanced-customer-reports-woo' ), 'customer_login' => __( 'Username', 'advanced-customer-reports-woo' ), 'customer_id' => __( 'Customer ID', 'advanced-customer-reports-woo' ), ); echo '