';
+ }
+
+ /**
+ * URL of the page shown to the visitor whose order was blocked.
+ *
+ * @return string
+ * @psalm-suppress UndefinedFunction
+ */
+ private function getBlockedOrderRedirectUrl()
+ {
+ $url = wc_get_endpoint_url('order-received', '', wc_get_checkout_url());
+
+ // The key makes the page look like the usual one and points at the stored details
+ return $this->blocked_order_key === ''
+ ? $url
+ : add_query_arg('key', $this->blocked_order_key, $url);
+ }
+
+ /**
+ * Response for the Store API checkout route imitating a passed checkout.
+ *
+ * @param \WC_Order $order
+ *
+ * @return array
+ * @psalm-suppress UndefinedClass, UndefinedFunction
+ */
+ private function getStoreApiPassedResponse($order)
+ {
+ return array(
+ 'order_id' => $order->get_id(),
+ 'status' => $order->get_status(),
+ 'order_key' => $order->get_order_key(),
+ 'customer_note' => $order->get_customer_note(),
+ 'customer_id' => $order->get_customer_id(),
+ 'billing_address' => $order->get_address('billing'),
+ 'shipping_address' => $order->get_address('shipping'),
+ 'payment_method' => $order->get_payment_method(),
+ 'payment_result' => array(
+ 'payment_status' => 'success',
+ 'payment_details' => array(),
+ 'redirect_url' => $this->getBlockedOrderRedirectUrl(),
+ ),
+ );
+ }
+
/**
* @return void
* @psalm-suppress UndefinedFunction
@@ -630,6 +901,247 @@ public function addOrdersSpamStatusHideFromList($query)
}
}
+ /**
+ * Enable the always visible status links on the HPOS orders list
+ *
+ * @param \WP_Screen $current_screen
+ *
+ * @return void
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function addOrdersListStatusViews($current_screen)
+ {
+ // Keep in sync with the capability required by the row actions and AJAX handlers
+ // (WcSpamOrdersListTable::row_actions_handler(), AJAXService::checkNonceRestrictingNonAdmins()),
+ // otherwise a user could see the 'Spam' view but get a 403 trying to use it.
+ if ( ! current_user_can('manage_options') || ! isset($current_screen->id) || ! function_exists('wc_get_page_screen_id') ) {
+ return;
+ }
+
+ $orders_screen_id = wc_get_page_screen_id('shop_order');
+
+ // The legacy storage renders the orders on the posts list screen, the links there lead to another URL
+ if ( empty($orders_screen_id) || $orders_screen_id === 'shop_order' || $current_screen->id !== $orders_screen_id ) {
+ return;
+ }
+
+ add_filter('views_' . $orders_screen_id, [$this, 'addOrdersListStatusLinks']);
+
+ // WooCommerce replaces the whole list with a notice while the store has no orders of its own.
+ // The status links go away with it, so the 'Spam' view becomes unreachable - keep the list on screen.
+ add_filter(
+ 'woocommerce_shop_order_list_table_should_render_blank_state',
+ [$this, 'keepOrdersListWhenSpamOrdersExist']
+ );
+
+ // The blocked orders are stored apart from the WooCommerce ones, so the 'Spam' view is rendered by the plugin
+ if ( Get::getString('status') === 'wc-spamorder' ) {
+ $this->replaceOrdersListRenderer($orders_screen_id);
+ }
+ }
+
+ /**
+ * Keep the orders list on screen when the store has no orders of its own but spam ones exist.
+ *
+ * A brand new store has nothing in the WooCommerce tables, so the list is replaced with the
+ * "When you receive a new order, it will appear here." notice. The blocked orders live in a table
+ * of the plugin and are not counted there, so the shop owner loses the only way to reach them.
+ *
+ * @param bool|null $should_render_blank_state Null keeps the WooCommerce own decision
+ *
+ * @return bool|null
+ * @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedReturnValue
+ */
+ public function keepOrdersListWhenSpamOrdersExist($should_render_blank_state)
+ {
+ if ( WcSpamOrdersFunctions::getSpamOrdersCount() > 0 ) {
+ return false;
+ }
+
+ return $should_render_blank_state;
+ }
+
+ /**
+ * Hand the orders page over to the spam orders table.
+ *
+ * The page content is printed by the WooCommerce page controller hooked to the page hook,
+ * so that callback is taken off and replaced. If it can not be found (the WooCommerce
+ * internals have changed), nothing is replaced to avoid rendering two tables at once.
+ *
+ * @param string $page_hook
+ *
+ * @return void
+ */
+ private function replaceOrdersListRenderer($page_hook)
+ {
+ global $wp_filter;
+
+ if ( ! isset($wp_filter[$page_hook]) || ! isset($wp_filter[$page_hook]->callbacks) ) {
+ return;
+ }
+
+ $removed = false;
+
+ foreach ( $wp_filter[$page_hook]->callbacks as $priority => $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ if ( ! is_array($callback['function']) || ! isset($callback['function'][0]) || ! is_object($callback['function'][0]) ) {
+ continue;
+ }
+
+ if ( strpos(get_class($callback['function'][0]), 'Admin\\Orders\\PageController') === false ) {
+ continue;
+ }
+
+ $removed = remove_action($page_hook, $callback['function'], $priority) || $removed;
+ }
+ }
+
+ if ( $removed ) {
+ add_action($page_hook, [$this, 'renderSpamOrdersPage']);
+ }
+ }
+
+ /**
+ * The spam orders table shown in place of the WooCommerce orders list,
+ * keeping the page markup and the status links of the original page.
+ *
+ * @return void
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function renderSpamOrdersPage()
+ {
+ // This is also the callback for the legacy 'apbct_wc_spam_orders' fallback page (addLegacySpamOrdersMenuPage()).
+ // getOrdersListViews() builds HPOS-style links (page=wc-orders), so it's only valid to embed on the HPOS screen -
+ // on legacy installations pass null so the list table builds its own standalone views.
+ $embedded_views = function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order'
+ ? $this->getOrdersListViews()
+ : null;
+
+ $list_table = new \Cleantalk\ApbctWP\WcSpamOrdersListTable($embedded_views);
+ ?>
+
+
+
+ renderPageNotices(); ?>
+
+
+ $label ) {
+ $count = wc_orders_count($status, 'shop_order');
+
+ $status_object = get_post_status_object($status);
+ if ( $status_object && ! empty($status_object->show_in_admin_all_list) ) {
+ $all_count += $count;
+ }
+
+ if ( $count > 0 ) {
+ $views[$status] = $this->getOrdersListStatusLink($status, $label, false);
+ }
+ }
+
+ return array_merge(
+ array('all' => $this->getOrdersListStatusLink('', __('All', 'cleantalk-spam-protect'), false, $all_count)),
+ $views
+ );
+ }
+
+ /**
+ * The orders list shows the statuses having orders only, so the spam workflow statuses
+ * are unreachable until an order gets marked as spam. Both of them are added back:
+ * 'Spam' itself and 'On hold' the unmarked orders are moved to.
+ *
+ * @param array $views
+ *
+ * @return array
+ * @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedReturnValue
+ */
+ public function addOrdersListStatusLinks($views)
+ {
+ if ( ! is_array($views) || ! function_exists('wc_get_order_statuses') ) {
+ return $views;
+ }
+
+ $order_statuses = wc_get_order_statuses();
+ $current_status = Get::getString('status');
+
+ foreach ( array('wc-on-hold', 'wc-spamorder') as $status ) {
+ if ( isset($views[$status]) || ! isset($order_statuses[$status]) ) {
+ continue;
+ }
+
+ $views[$status] = $this->getOrdersListStatusLink(
+ $status,
+ $order_statuses[$status],
+ $current_status === $status
+ );
+ }
+
+ return $views;
+ }
+
+ /**
+ * @param string $status Empty for the 'All' link
+ * @param string $label
+ * @param bool $is_current
+ * @param int|null $count Known count, counted by the status when not given
+ *
+ * @return string
+ */
+ private function getOrdersListStatusLink($status, $label, $is_current, $count = null)
+ {
+ if ( is_null($count) ) {
+ $count = $this->getOrdersListStatusCount($status);
+ }
+
+ $url = admin_url('admin.php?page=wc-orders');
+ if ( ! empty($status) ) {
+ $url = add_query_arg('status', $status, $url);
+ }
+
+ return sprintf(
+ '%s (%s)',
+ esc_url($url),
+ $is_current ? ' class="current"' : '',
+ esc_html($label),
+ number_format_i18n($count)
+ );
+ }
+
+ /**
+ * @param string $status
+ *
+ * @return int
+ */
+ private function getOrdersListStatusCount($status)
+ {
+ // The blocked orders never become WooCommerce ones, they are counted in the plugin table
+ if ( $status === 'wc-spamorder' ) {
+ return WcSpamOrdersFunctions::getSpamOrdersCount();
+ }
+
+ return function_exists('wc_orders_count') ? wc_orders_count($status, 'shop_order') : 0;
+ }
+
/**
* Add bulk actions: 'Mark as spam' and 'Unmark as spam'
*/
diff --git a/lib/Cleantalk/ApbctWP/State.php b/lib/Cleantalk/ApbctWP/State.php
index 92b1cbd0d..9d0cf5005 100644
--- a/lib/Cleantalk/ApbctWP/State.php
+++ b/lib/Cleantalk/ApbctWP/State.php
@@ -95,7 +95,8 @@ class State extends \Cleantalk\Common\State
'data__email_decoder_encode_phone_numbers' => 0,
'data__email_decoder_encode_email_addresses' => 1,
'data__email_decoder_excluded_strings' => '',
- 'data__wc_store_blocked_orders' => 0,
+ 'data__wc_store_blocked_orders' => 1,
+ 'forms__wc_show_rejection_message' => 0,
// Exclusions
// Send to the cloud some excepted requests
diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php
index 327f28559..b75eabc35 100644
--- a/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php
+++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php
@@ -6,6 +6,22 @@
class WcSpamOrdersFunctions
{
+ /**
+ * Count of the stored blocked orders, used for the 'Spam' status counter of the orders list.
+ *
+ * @return int
+ */
+ public static function getSpamOrdersCount()
+ {
+ global $wpdb;
+
+ if ( ! defined('APBCT_TBL_WC_SPAM_ORDERS') ) {
+ return 0;
+ }
+
+ return (int)$wpdb->get_var('SELECT COUNT(*) FROM ' . APBCT_TBL_WC_SPAM_ORDERS . ';');
+ }
+
public static function restoreOrderAction()
{
AJAXService::checkNonceRestrictingNonAdmins();
diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php
index b5afb11d8..25ace553a 100644
--- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php
+++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php
@@ -8,19 +8,51 @@
class WcSpamOrdersListTable extends CleantalkListTable
{
+ /**
+ * Names of the request parameters carrying the actions of the table.
+ *
+ * The table is embedded into the WooCommerce orders screen on HPOS installations, where the
+ * HPOS page controller inspects $_REQUEST['action'] on the 'load-{page}' hook and runs its own
+ * 'bulk-orders' nonce check, aborting the whole request with "The link you followed has expired"
+ * long before the table is built. Dedicated parameter names keep our actions invisible to it.
+ */
+ const BULK_ACTION_PARAM = 'apbct_bulk_action';
+ const ROW_ACTION_PARAM = 'apbct_row_action';
+
+ /**
+ * Name of the nonce of the row actions.
+ *
+ * The HPOS page controller redirects to an URL stripped of '_wpnonce' and '_wp_http_referer'
+ * (see PageController::strip_http_referer()), which would drop the nonce of our delete link
+ * before it is ever verified. A name of our own survives that redirect.
+ */
+ const ROW_NONCE_PARAM = 'apbct_row_nonce';
+
protected $apbct;
protected $wc_active = false;
- protected $page_title = '';
protected $wc_spam_orders_count = 0;
- public function __construct()
+ /**
+ * Status links of the hosting page when the table is embedded into it.
+ * Null means the table is rendered on its own page and builds the links itself.
+ *
+ * @var array|null
+ */
+ protected $embedded_views = null;
+
+ /**
+ * @param array|null $embedded_views Status links of the hosting page, see $embedded_views
+ */
+ public function __construct($embedded_views = null)
{
parent::__construct(array(
'singular' => 'wc_spam_orders',
'plural' => 'wc_spam_orders'
));
+ $this->embedded_views = is_array($embedded_views) ? $embedded_views : null;
+
$this->bulk_actions_handler();
$this->row_actions_handler();
@@ -32,10 +64,7 @@ public function __construct()
$this->prepare_items();
global $apbct;
- $this->apbct = $apbct;
- $this->page_title = 'WooCommerce spam orders';
-
- $this->generatePageHeader();
+ $this->apbct = $apbct;
}
/**
@@ -62,8 +91,13 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam
$wc_spam_orders = $this->getWcSpamOrders();
$this->wc_spam_orders_count = count($wc_spam_orders);
+ // Blocked orders are never put on hold, so the view is always empty
+ if ( $this->getCurrentStatus() === 'on-hold' ) {
+ $wc_spam_orders = array();
+ }
+
$this->set_pagination_args(array(
- 'total_items' => $this->wc_spam_orders_count,
+ 'total_items' => count($wc_spam_orders),
'per_page' => $per_page,
));
@@ -83,29 +117,40 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam
continue;
}
- $delete_url = wp_nonce_url(
- admin_url('admin.php?page=' . Get::getString('page') . '&action=delete&spam=' . $wc_spam_order->id),
- 'apbct_wc_spam_orders_row',
- '_wpnonce'
+ // The status has to be kept, the hosting page is chosen by it
+ $current_status = Get::getString('status');
+ $delete_url = admin_url('admin.php?page=' . Get::getString('page'));
+ $delete_url = add_query_arg(
+ array_filter(array(
+ 'status' => $current_status,
+ self::ROW_ACTION_PARAM => 'delete',
+ 'spam' => $wc_spam_order->id,
+ )),
+ $delete_url
);
+ $delete_url = wp_nonce_url($delete_url, 'apbct_wc_spam_orders_row', self::ROW_NONCE_PARAM);
$actions = array(
'restore' => '' . esc_html__('Restore', 'cleantalk-spam-protect') . '',
'delete' => 'Delete',
'details' => '' . esc_html__('See details', 'cleantalk-spam-protect') . '',
);
- $order_id_column = sprintf('%1$s %2$s', $wc_spam_order->id, $this->row_actions($actions));
+ $order_column = sprintf(
+ '%1$s %2$s',
+ $this->renderOrderColumn($wc_spam_order->id, $wc_spam_order->customer_details),
+ $this->row_actions($actions)
+ );
- $order_details_column = $this->renderOrderDetailsColumn($wc_spam_order->order_details);
- $customer_details_column = $this->renderCustomerDetailsColumn($wc_spam_order->customer_details);
- $order_date_column = $this->renderOrderDateColumn($wc_spam_order->order_date);
+ $order_date_column = $this->renderOrderDateColumn($wc_spam_order->order_date);
+ $status_column = $this->renderStatusColumn();
+ $total_column = $this->renderTotalColumn($wc_spam_order->order_details);
$this->items[] = array(
- 'cb' => $wc_spam_order->id,
- 'ct_order_id' => $order_id_column,
- 'ct_order_details' => $order_details_column,
- 'ct_customer_details' => $customer_details_column,
- 'ct_order_date' => $order_date_column,
+ 'cb' => $wc_spam_order->id,
+ 'ct_order' => $order_column,
+ 'ct_order_date' => $order_date_column,
+ 'ct_status' => $status_column,
+ 'ct_total' => $total_column,
);
}
}
@@ -113,11 +158,13 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam
public function get_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
{
$columns = array(
- 'cb' => '',
- 'ct_order_id' => esc_html__('ID', 'cleantalk-spam-protect'),
- 'ct_order_details' => esc_html__('Order details', 'cleantalk-spam-protect'),
- 'ct_customer_details' => esc_html__('Customer details', 'cleantalk-spam-protect'),
- 'ct_order_date' => esc_html__('Order date', 'cleantalk-spam-protect'),
+ 'cb' => '',
+ 'ct_order' => esc_html__('Order', 'cleantalk-spam-protect'),
+ 'ct_order_date' => esc_html__('Date', 'cleantalk-spam-protect'),
+ 'ct_status' => esc_html__('Status', 'cleantalk-spam-protect'),
+ // Sums up only the stored cart line items - shipping, fees and other checkout
+ // charges are not persisted for a blocked order, so this is an items subtotal.
+ 'ct_total' => esc_html__('Items total', 'cleantalk-spam-protect'),
);
return $columns;
@@ -126,10 +173,81 @@ public function get_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.N
protected function get_sortable_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
{
return array(
+ 'ct_order' => array('id', false),
'ct_order_date' => array('order_date', false),
+ 'ct_total' => array('total', false),
);
}
+ /**
+ * Statuses row above the table, the same one as the WooCommerce orders list has.
+ * Every stored order is a blocked spam one, so the "On hold" view is always empty.
+ *
+ * @return array
+ */
+ protected function get_views() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ {
+ if ( ! is_null($this->embedded_views) ) {
+ return $this->embedded_views;
+ }
+
+ $current_status = $this->getCurrentStatus();
+
+ $statuses = array(
+ 'all' => array(esc_html__('All', 'cleantalk-spam-protect'), $this->wc_spam_orders_count),
+ 'on-hold' => array(esc_html__('On hold', 'cleantalk-spam-protect'), 0),
+ 'spam' => array(esc_html__('Spam', 'cleantalk-spam-protect'), $this->wc_spam_orders_count),
+ );
+
+ $views = array();
+
+ foreach ( $statuses as $status => $status_data ) {
+ list($title, $count) = $status_data;
+
+ $url = admin_url('admin.php?page=' . Get::getString('page'));
+ if ( $status !== 'all' ) {
+ $url = add_query_arg('status', $status, $url);
+ }
+
+ $views[$status] = sprintf(
+ '%3$s (%4$d)',
+ esc_url($url),
+ $status === $current_status ? ' class="current" aria-current="page"' : '',
+ $title,
+ $count
+ );
+ }
+
+ return $views;
+ }
+
+ /**
+ * Currently selected status view.
+ *
+ * @return string all|on-hold|spam
+ */
+ private function getCurrentStatus()
+ {
+ $status = Get::getString('status');
+
+ return in_array($status, array('on-hold', 'spam'), true) ? $status : 'all';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function display() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ {
+ $this->views();
+
+ parent::display();
+ }
+
+ public function no_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ {
+ esc_html_e('No orders found.', 'cleantalk-spam-protect');
+ }
+
public function get_bulk_actions() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
{
return array(
@@ -137,6 +255,58 @@ public function get_bulk_actions() // phpcs:ignore PSR1.Methods.CamelCapsMethodN
);
}
+ /**
+ * The bulk actions select rendered under a name of our own, see BULK_ACTION_PARAM.
+ *
+ * Mirrors the markup of the parent, the flat list of actions of this table needs no optgroups.
+ *
+ * @param string $which 'top' or 'bottom'
+ *
+ * @return void
+ */
+ protected function bulk_actions($which = '') // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ {
+ $actions = $this->get_bulk_actions();
+
+ if ( empty($actions) ) {
+ return;
+ }
+
+ $two = $which === 'bottom' ? '2' : '';
+ $name = self::BULK_ACTION_PARAM . $two;
+
+ echo '';
+ echo '';
+
+ submit_button(__('Apply'), 'action', '', false, array('id' => 'doaction' . $two));
+ }
+
+ /**
+ * Action chosen in the bulk actions select, read from our own parameter, see BULK_ACTION_PARAM.
+ *
+ * @return string|false
+ */
+ public function current_action() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
+ {
+ foreach ( array(self::BULK_ACTION_PARAM, self::BULK_ACTION_PARAM . '2') as $param ) {
+ $action = Post::getString($param);
+
+ if ( $action !== '' && $action !== '-1' ) {
+ return $action;
+ }
+ }
+
+ return false;
+ }
+
public function bulk_actions_handler() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
{
if ( empty(Post::get('spamorderids')) || empty(Post::get('_wpnonce')) ) {
@@ -178,19 +348,22 @@ public function column_default($item, $column_name) // phpcs:ignore PSR1.Methods
public function row_actions_handler() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
{
- if ( empty(Get::get('action')) ) {
+ if ( empty(Get::get(self::ROW_ACTION_PARAM)) ) {
return;
}
- if ( ! wp_verify_nonce(Get::getString('_wpnonce'), 'apbct_wc_spam_orders_row') ) {
+ if ( ! wp_verify_nonce(Get::getString(self::ROW_NONCE_PARAM), 'apbct_wc_spam_orders_row') ) {
wp_die(esc_html__('Security check failed. Please try again.', 'cleantalk-spam-protect'), 403);
}
- if ( ! current_user_can('activate_plugins') ) {
+ // Kept in sync with the capability required by the restore/details AJAX handlers
+ // (AJAXService::checkNonceRestrictingNonAdmins()) and the view registration
+ // (Woocommerce::addOrdersListStatusViews()).
+ if ( ! current_user_can('manage_options') ) {
wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'cleantalk-spam-protect'), 403);
}
- if ( Get::get('action') === 'delete' ) {
+ if ( Get::get(self::ROW_ACTION_PARAM) === 'delete' ) {
$id = filter_input(INPUT_GET, 'spam', FILTER_SANITIZE_ENCODED, FILTER_FLAG_STRIP_HIGH);
$this->removeSpam(array($id));
}
@@ -256,6 +429,46 @@ private function renderCustomerDetailsColumn($customer_details)
return $result;
}
+ /**
+ * Order number and the customer name, the same way as WooCommerce orders list does it.
+ *
+ * @param int|string $spam_order_id
+ * @param string $customer_details
+ *
+ * @return string
+ */
+ private function renderOrderColumn($spam_order_id, $customer_details)
+ {
+ $customer_details = json_decode($customer_details, true);
+
+ $customer_name = '';
+
+ if ( is_array($customer_details) ) {
+ $customer_name = trim(
+ TT::getArrayValueAsString($customer_details, 'billing_first_name')
+ . ' '
+ . TT::getArrayValueAsString($customer_details, 'billing_last_name'),
+ " \n\r\t\v\x00"
+ );
+
+ if ( $customer_name === '' ) {
+ $customer_name = TT::getArrayValueAsString($customer_details, 'billing_email');
+ }
+ }
+
+ if ( $customer_name === '' ) {
+ $customer_name = esc_html__('Guest', 'cleantalk-spam-protect');
+ } else {
+ $customer_name = esc_html($customer_name);
+ }
+
+ return sprintf(
+ '#%1$s %2$s',
+ esc_attr(TT::toString($spam_order_id)),
+ $customer_name
+ );
+ }
+
private function renderOrderDateColumn($order_date)
{
if ( ! $order_date ) {
@@ -268,14 +481,96 @@ private function renderOrderDateColumn($order_date)
return '-';
}
+ // Fresh orders are shown as "5 minutes ago", the older ones as a date. Same as WooCommerce does.
+ $diff = time() - $timestamp;
+ if ( $diff >= 0 && $diff < DAY_IN_SECONDS ) {
+ /* translators: %s: human-readable time difference */
+ $show_date = sprintf(__('%s ago', 'cleantalk-spam-protect'), human_time_diff($timestamp, time()));
+ } else {
+ $show_date = date_i18n('M j, Y', $timestamp); // Feb 15, 2023
+ }
+
return sprintf(
'',
esc_attr(date_i18n('c', $timestamp)), // 2023-02-15T20:25:06+00:00
- esc_html(date_i18n('d.m.Y H:i', $timestamp)), // 15.02.2023 20:25
- esc_html(date_i18n('M d, Y', $timestamp)) // Feb 15, 2023
+ esc_attr(date_i18n('d.m.Y H:i', $timestamp)), // 15.02.2023 20:25
+ esc_html($show_date)
);
}
+ /**
+ * Every stored order is a blocked spam one, so the status is always the same.
+ *
+ * @return string
+ */
+ private function renderStatusColumn()
+ {
+ return ''
+ . esc_html__('Spam', 'cleantalk-spam-protect')
+ . '';
+ }
+
+ /**
+ * @param string $order_details
+ *
+ * @return string Formatted order total or a dash if it can not be calculated.
+ */
+ private function renderTotalColumn($order_details)
+ {
+ $total = $this->calcOrderTotal($order_details);
+
+ if ( is_null($total) ) {
+ return '-';
+ }
+
+ /** @psalm-suppress UndefinedFunction */
+ return function_exists('wc_price')
+ ? wc_price($total)
+ : esc_html(number_format_i18n($total, 2));
+ }
+
+ /**
+ * Sums up the stored cart items. Cart data keeps the calculated line totals,
+ * the product price is used as a fallback only.
+ *
+ * @param string $order_details
+ *
+ * @return float|null Null if the order details can not be decoded.
+ *
+ * @psalm-suppress UndefinedFunction
+ */
+ private function calcOrderTotal($order_details)
+ {
+ $order_details = json_decode($order_details, true);
+
+ if ( ! is_array($order_details) ) {
+ return null;
+ }
+
+ $total = 0;
+
+ foreach ( $order_details as $order_detail ) {
+ if ( ! is_array($order_detail) ) {
+ continue;
+ }
+
+ if ( isset($order_detail['line_total']) ) {
+ $total += (float) $order_detail['line_total'] + (float) ($order_detail['line_tax'] ?? 0);
+ continue;
+ }
+
+ if ( isset($order_detail['product_id']) && function_exists('wc_get_product') && class_exists('\WC_Product') ) {
+ $wc_product = wc_get_product($order_detail['product_id']);
+ $wc_product_class = '\WC_Product';
+ if ( $wc_product instanceof $wc_product_class ) {
+ $total += (float) $wc_product->get_price() * (float) ($order_detail['quantity'] ?? 1);
+ }
+ }
+ }
+
+ return (float) $total;
+ }
+
/**
* @return array
*/
@@ -288,20 +583,69 @@ private function getWcSpamOrders()
$sql = 'SELECT * FROM ' . APBCT_TBL_WC_SPAM_ORDERS;
- if ($orderby) {
- $sql .= ' ORDER BY ' . $orderby . ' ' . $order;
- }
+ // The newest spam orders are shown first by default, the same way as WooCommerce orders list does it.
+ $sql .= ' ORDER BY ' . ($orderby ? $orderby : 'order_date') . ' ' . $order;
$result = $wpdb->get_results($sql, OBJECT);
- return is_array($result) ? $result : array();
+ $result = is_array($result) ? $result : array();
+
+ if ( Get::getString('orderby') === 'total' ) {
+ $result = $this->sortByTotal($result, $order);
+ }
+
+ return $result;
+ }
+
+ /**
+ * The order total is not stored as a column, so it has to be sorted after the fetch.
+ *
+ * @param array $wc_spam_orders
+ * @param string $order ASC|DESC
+ *
+ * @return array
+ */
+ private function sortByTotal($wc_spam_orders, $order)
+ {
+ $totals = array();
+
+ foreach ( $wc_spam_orders as $key => $wc_spam_order ) {
+ $calculated_total = is_string($wc_spam_order->order_details)
+ ? $this->calcOrderTotal($wc_spam_order->order_details)
+ : null;
+
+ if ( is_null($calculated_total) ) {
+ // Keep undecodable totals consistently at the end for both ASC and DESC.
+ $calculated_total = $order === 'DESC' ? -PHP_FLOAT_MAX : PHP_FLOAT_MAX;
+ }
+
+ $totals[$key] = (float) $calculated_total;
+ }
+
+ uasort($totals, static function ($a, $b) {
+ if ( $a === $b ) {
+ return 0;
+ }
+ return $a < $b ? -1 : 1;
+ });
+
+ if ( $order === 'DESC' ) {
+ $totals = array_reverse($totals, true);
+ }
+
+ $sorted = array();
+ foreach ( array_keys($totals) as $key ) {
+ $sorted[] = $wc_spam_orders[$key];
+ }
+
+ return $sorted;
}
private function getSqlOrderBy()
{
$order_by = Get::getString('orderby');
- $allowed_order_by = array_keys($this->get_sortable_columns());
- return in_array('ct_' . $order_by, $allowed_order_by) ? $order_by : '';
+ $allowed_order_by = array('id', 'order_date');
+ return in_array($order_by, $allowed_order_by, true) ? $order_by : '';
}
private function removeSpam($ids)
@@ -325,10 +669,16 @@ private function removeSpam($ids)
);
}
- private function generatePageHeader()
+ /**
+ * Notices shown above the table: the stored orders count and the warnings about the data.
+ *
+ * @return void
+ * @psalm-suppress PossiblyUnusedMethod
+ */
+ public function renderPageNotices()
{
if ( ! apbct_api_key__is_correct() ) {
- if ( 1 == $this->spam_checker->getApbct()->moderate_ip ) {
+ if ( 1 == $this->apbct->moderate_ip ) {
echo '