From 5b78a8dcac9cb9a269659271032cdb14b4bcec06 Mon Sep 17 00:00:00 2001 From: datorik Date: Thu, 13 Aug 2026 18:23:50 +0300 Subject: [PATCH 1/2] Udp.Code.WooCommerce integration --- .../IntegrationsByClass/Woocommerce.php | 93 +++++++++++++++---- lib/Cleantalk/ApbctWP/State.php | 2 +- 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index bafa1e415..ce9c2b06c 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -177,6 +177,11 @@ public function checkoutCheck($_data, $errors) { global $apbct, $cleantalk_executed; + if ( ! $apbct->settings['data__wc_store_blocked_orders'] ) { + // The checkout is left to WooCommerce as is: no check, no blocked order to store. + return; + } + if ( count($errors->errors) ) { return; } @@ -230,14 +235,10 @@ public function checkoutCheck($_data, $errors) ct_hash($ct_result->id); if ( $ct_result->allow == 0 ) { - if ( $apbct->settings['data__wc_store_blocked_orders'] ) { - $this->storeBlockedOrder(); - } + $this->handleBlockedOrder(); wp_send_json(array( - 'result' => 'failure', - 'messages' => "", - 'refresh' => 'false', - 'reload' => 'false' + 'result' => 'success', + 'redirect' => $this->getBlockedOrderRedirectUrl(), )); } } @@ -252,6 +253,11 @@ public function checkoutCheckFromRest($order) { global $apbct, $cleantalk_executed; + if ( ! $apbct->settings['data__wc_store_blocked_orders'] ) { + // The checkout is left to WooCommerce as is: no check, no blocked order to store. + return; + } + if ( is_null($order) || ! ($order instanceof \WC_Order) ) { return; } @@ -286,11 +292,15 @@ public function checkoutCheckFromRest($order) ct_hash($ct_result->id); if ( $ct_result->allow == 0 ) { - if ( $apbct->settings['data__wc_store_blocked_orders'] ) { - $this->storeBlockedOrder(); - } + $response = $this->getStoreApiPassedResponse($order); + + $this->handleBlockedOrder(); if ( $order->get_status() === 'pending' || $order->get_status() === 'checkout-draft' ) { + if ( function_exists('wc_release_stock_for_order') ) { + wc_release_stock_for_order($order); + } + try { $order->delete(true); } catch (\Exception $e) { @@ -298,22 +308,67 @@ public function checkoutCheckFromRest($order) } } - $response = [ - 'code' => 'woocommerce_store_api_checkout_order_processed', - 'message' => $ct_result->comment, - 'data' => [ - 'status' => 403 - ] - ]; - if ( ! headers_sent() ) { - http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); } die(json_encode($response)); } } } + /** + * Common actions for an order blocked as spam. + * + * @return void + * @psalm-suppress UndefinedFunction + */ + private function handleBlockedOrder() + { + $this->storeBlockedOrder(); + + if ( function_exists('wc') && ! is_null(wc()->cart) ) { + wc()->cart->empty_cart(); + } + } + + /** + * URL of the page shown to the visitor whose order was blocked. + * + * @return string + * @psalm-suppress UndefinedFunction + */ + private function getBlockedOrderRedirectUrl() + { + return wc_get_endpoint_url('order-received', '', wc_get_checkout_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 diff --git a/lib/Cleantalk/ApbctWP/State.php b/lib/Cleantalk/ApbctWP/State.php index e0d637726..9e8ed2041 100644 --- a/lib/Cleantalk/ApbctWP/State.php +++ b/lib/Cleantalk/ApbctWP/State.php @@ -94,7 +94,7 @@ class State extends \Cleantalk\Common\State 'data__email_decoder_obfuscation_custom_text' => '', 'data__email_decoder_encode_phone_numbers' => 0, 'data__email_decoder_encode_email_addresses' => 1, - 'data__wc_store_blocked_orders' => 0, + 'data__wc_store_blocked_orders' => 1, // Exclusions // Send to the cloud some excepted requests From bccf9f8ac6a58504f0334c243515e141fb66c980 Mon Sep 17 00:00:00 2001 From: datorik Date: Fri, 14 Aug 2026 17:22:35 +0300 Subject: [PATCH 2/2] Udp.Code.Security WooCommerce UI --- css/cleantalk-admin.min.css | 2 +- css/src/cleantalk-admin.css | 45 +++ .../ApbctWP/WcSpamOrdersListTable.php | 285 ++++++++++++++++-- 3 files changed, 308 insertions(+), 24 deletions(-) diff --git a/css/cleantalk-admin.min.css b/css/cleantalk-admin.min.css index daf049497..c2c7a6d27 100644 --- a/css/cleantalk-admin.min.css +++ b/css/cleantalk-admin.min.css @@ -1 +1 @@ -:disabled{cursor:not-allowed!important}.apbct_color--gray{color:gray}.apbct_display--none{display:none}.apbct_bottom_links--left{margin-right:2pc}.apbct_bottom_links--other{margin-right:2pc;margin-left:2pc}.ct_translate_links{color:#969614}.ct_support_links{color:#961414}.ct_faq_links{color:#149614}.ct_setting_links{color:#141496}.ct_translate_links:hover{color:#d2d214!important}.ct_support_links:hover{color:#fa1414!important}.ct_faq_links:hover{color:#14fa14!important}.ct_setting_links:hover{color:#1414fa!important}.ct_link_new_tab img{float:none!important;margin:0 2px;border:0}#negative_reports_table tr td{padding:7px 5px!important}#wp-admin-bar-cleantalk_admin_bar__parent_node{margin-right:5px}#wp-admin-bar-cleantalk_admin_bar__parent_node span{display:inline-block}#wp-admin-bar-cleantalk_admin_bar__parent_node .apbct-icon-attention-alt{background:#d63638;color:#fff;border-radius:50%;font-size:12px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__spbc_icon{width:14px;height:17px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__apbct_icon{width:18px;height:18px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node div.cleantalk_admin_bar__sum_counter{color:#999;display:inline;padding:2px 5px!important}.cleantalk_admin_bar__blocked div{cursor:not-allowed!important}.cleantalk_admin_bar__blocked div a{color:#777!important}.cleantalk_admin_bar__title{vertical-align:top}.cleantalk_admin_bar__separator{height:0!important}.cleantalk-admin_bar--list_wrapper .ab-sub-wrapper ul:last-child{margin-bottom:5px!important}.apbct-plugin-errors{margin-left:0;margin-bottom:20px}#cleantalk_notice_review .caption{margin:0 0 15px;color:gray}#cleantalk_notice_review .button{margin-bottom:20px}.apbct-details-spam-order-button,.apbct-restore-spam-order-button{cursor:pointer}.apbct-details-spam-order-button{color:gray}.ct-modal-buttons{display:flex;align-items:center;padding:20px 0;justify-content:space-between}.ct-modal-message{font-weight:700;font-size:16px;line-height:2rem}.apbct-popup-fade:before{content:'';background:#000;position:fixed;left:0;top:0;width:100%;height:100%;opacity:.7;z-index:9999}.apbct-popup{position:fixed;top:20%;left:50%;padding:20px;width:360px;margin-left:-200px;background:#fff;border:1px solid;border-radius:4px;z-index:99999;opacity:1}.apbct-table-actions-wrapper{background:#fcfcfc;background:#fcfcfc;border-radius:2px;padding:5px!important;border:1px solid #d3d3d3;margin:0 5px 5px 0!important}.apbct_wc_details__table-container_header{text-align:center}.apbct_wc_details__wrapper{display:flex;flex-direction:column;scroll-behavior:smooth;overflow-y:auto;max-height:400px;min-width:50pc;position:relative}.apbct_wc_details__table-wrapper-inner{margin-bottom:25px;background:#f9f9f9;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,.08);position:relative;padding:0 15px}.apbct_wc_details__table-wrapper-inner h4{margin:0 0 12px 0;color:#333;font-size:16px;font-weight:600;padding:10px 0 8px 0;border-bottom:2px solid #e0e0e0;position:sticky;top:0;background:#f9f9f9;z-index:10}.apbct_wc_details__table{width:100%;border-collapse:collapse;background:#fff;border-radius:6px;overflow:hidden}.apbct_wc_details__table-key-cell{border:1px solid #e8e8e8;padding:10px 12px;font-weight:600;background:#f5f7fa;color:#2c3e50;width:35%;font-size:13px}.apbct_wc_details__table-value-cell{border:1px solid #e8e8e8;padding:10px 12px;color:#34495e;font-size:13px;word-break:break-word}.apbct_wc_details__table tbody tr:hover{background:#fafbfc}.apbct_wc_details__table tbody tr:last-child td{border-bottom:none}.apbct_wc_details__table-value-cell--json{font-family:monospace;font-size:12px;white-space:pre-wrap}@supports (position:sticky){.apbct_wc_details__table th,.apbct_wc_details__table-wrapper-inner h4{position:sticky}}@media screen and (max-width:1120px){.apbct-tablenav{display:flex;flex-direction:column;flex-wrap:nowrap;height:100%;max-width:50%}}.apbct-notice.apbct-banner-success{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#00bb5d!important}.apbct-banner-content{display:flex;flex-wrap:wrap;justify-content:space-between;row-gap:16px}.apbct-banner-content-wrapper{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between}.apbct-banner-text-wrapper{margin-top:16px;margin-left:32px}.apbct-banner-button{display:block;padding:17px 53px;color:#fff!important;border-radius:8px;font-size:16px;font-weight:500;text-decoration:none!important;text-align:center;align-content:center}.apbct-banner-button-wrapper{display:grid;gap:12px;align-content:center;text-align:center;margin-right:24px;margin-left:32px}.apbct-banner-title{font-size:24px;line-height:29px;font-weight:500;color:#000}.apbct-banner-subtitle{font-size:14px;line-height:22px;font-weight:400;color:#444;margin-top:8px}.apbct-banner-big-subtitle{font-size:16px}.apbct-banner-link{font-size:16px;line-height:24px;font-weight:400;color:#026e88!important}.apbct-banner-dismiss-link{font-weight:400;font-size:14px;line-height:22px;color:#646464!important}.apbct-notice.apbct-banner-error{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#bb3323!important}.apbct-notice.apbct-banner-error .notice-dismiss::before{color:#646464}.apbct-banner-button-green{background-color:#00bb5d!important}.apbct-banner-button-green:hover{background-color:#00a34f!important}.apbct-banner-button-red{background-color:#bb3323!important}.apbct-banner-button-red:hover{background-color:#a82e20!important} \ No newline at end of file +:disabled{cursor:not-allowed!important}.apbct_color--gray{color:gray}.apbct_display--none{display:none}.apbct_bottom_links--left{margin-right:2pc}.apbct_bottom_links--other{margin-right:2pc;margin-left:2pc}.ct_translate_links{color:#969614}.ct_support_links{color:#961414}.ct_faq_links{color:#149614}.ct_setting_links{color:#141496}.ct_translate_links:hover{color:#d2d214!important}.ct_support_links:hover{color:#fa1414!important}.ct_faq_links:hover{color:#14fa14!important}.ct_setting_links:hover{color:#1414fa!important}.ct_link_new_tab img{float:none!important;margin:0 2px;border:0}#negative_reports_table tr td{padding:7px 5px!important}#wp-admin-bar-cleantalk_admin_bar__parent_node{margin-right:5px}#wp-admin-bar-cleantalk_admin_bar__parent_node span{display:inline-block}#wp-admin-bar-cleantalk_admin_bar__parent_node .apbct-icon-attention-alt{background:#d63638;color:#fff;border-radius:50%;font-size:12px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__spbc_icon{width:14px;height:17px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__apbct_icon{width:18px;height:18px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node div.cleantalk_admin_bar__sum_counter{color:#999;display:inline;padding:2px 5px!important}.cleantalk_admin_bar__blocked div{cursor:not-allowed!important}.cleantalk_admin_bar__blocked div a{color:#777!important}.cleantalk_admin_bar__title{vertical-align:top}.cleantalk_admin_bar__separator{height:0!important}.cleantalk-admin_bar--list_wrapper .ab-sub-wrapper ul:last-child{margin-bottom:5px!important}.apbct-plugin-errors{margin-left:0;margin-bottom:20px}#cleantalk_notice_review .caption{margin:0 0 15px;color:gray}#cleantalk_notice_review .button{margin-bottom:20px}.apbct-details-spam-order-button,.apbct-restore-spam-order-button{cursor:pointer}.apbct-details-spam-order-button{color:gray}.wp-list-table.wc_spam_orders .column-ct_order_date,.wp-list-table.wc_spam_orders .column-ct_status,.wp-list-table.wc_spam_orders .column-ct_total{width:15%}.wp-list-table.wc_spam_orders td{vertical-align:middle}.wp-list-table.wc_spam_orders .apbct-order-view{color:#2271b1;text-decoration:none}.wp-list-table.wc_spam_orders .apbct-order-view:hover{color:#135e96}.apbct-order-status{display:inline-flex;line-height:2.5em;color:#454545;background:#e5e5e5;border-radius:4px;border-bottom:1px solid rgba(0,0,0,.05);margin:-.25em 0;cursor:inherit!important;white-space:nowrap;max-width:100%}.apbct-order-status>span{margin:0 1em;overflow:hidden;text-overflow:ellipsis}.apbct-order-status--spam{background:#eba3a3;color:#570000}.ct-modal-buttons{display:flex;align-items:center;padding:20px 0;justify-content:space-between}.ct-modal-message{font-weight:700;font-size:16px;line-height:2rem}.apbct-popup-fade:before{content:'';background:#000;position:fixed;left:0;top:0;width:100%;height:100%;opacity:.7;z-index:9999}.apbct-popup{position:fixed;top:20%;left:50%;padding:20px;width:360px;margin-left:-200px;background:#fff;border:1px solid;border-radius:4px;z-index:99999;opacity:1}.apbct-table-actions-wrapper{background:#fcfcfc;background:#fcfcfc;border-radius:2px;padding:5px!important;border:1px solid #d3d3d3;margin:0 5px 5px 0!important}.apbct_wc_details__table-container_header{text-align:center}.apbct_wc_details__wrapper{display:flex;flex-direction:column;scroll-behavior:smooth;overflow-y:auto;max-height:400px;min-width:50pc;position:relative}.apbct_wc_details__table-wrapper-inner{margin-bottom:25px;background:#f9f9f9;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,.08);position:relative;padding:0 15px}.apbct_wc_details__table-wrapper-inner h4{margin:0 0 12px 0;color:#333;font-size:16px;font-weight:600;padding:10px 0 8px 0;border-bottom:2px solid #e0e0e0;position:sticky;top:0;background:#f9f9f9;z-index:10}.apbct_wc_details__table{width:100%;border-collapse:collapse;background:#fff;border-radius:6px;overflow:hidden}.apbct_wc_details__table-key-cell{border:1px solid #e8e8e8;padding:10px 12px;font-weight:600;background:#f5f7fa;color:#2c3e50;width:35%;font-size:13px}.apbct_wc_details__table-value-cell{border:1px solid #e8e8e8;padding:10px 12px;color:#34495e;font-size:13px;word-break:break-word}.apbct_wc_details__table tbody tr:hover{background:#fafbfc}.apbct_wc_details__table tbody tr:last-child td{border-bottom:none}.apbct_wc_details__table-value-cell--json{font-family:monospace;font-size:12px;white-space:pre-wrap}@supports (position:sticky){.apbct_wc_details__table th,.apbct_wc_details__table-wrapper-inner h4{position:sticky}}@media screen and (max-width:1120px){.apbct-tablenav{display:flex;flex-direction:column;flex-wrap:nowrap;height:100%;max-width:50%}}.apbct-notice.apbct-banner-success{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#00bb5d!important}.apbct-banner-content{display:flex;flex-wrap:wrap;justify-content:space-between;row-gap:16px}.apbct-banner-content-wrapper{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between}.apbct-banner-text-wrapper{margin-top:16px;margin-left:32px}.apbct-banner-button{display:block;padding:17px 53px;color:#fff!important;border-radius:8px;font-size:16px;font-weight:500;text-decoration:none!important;text-align:center;align-content:center}.apbct-banner-button-wrapper{display:grid;gap:12px;align-content:center;text-align:center;margin-right:24px;margin-left:32px}.apbct-banner-title{font-size:24px;line-height:29px;font-weight:500;color:#000}.apbct-banner-subtitle{font-size:14px;line-height:22px;font-weight:400;color:#444;margin-top:8px}.apbct-banner-big-subtitle{font-size:16px}.apbct-banner-link{font-size:16px;line-height:24px;font-weight:400;color:#026e88!important}.apbct-banner-dismiss-link{font-weight:400;font-size:14px;line-height:22px;color:#646464!important}.apbct-notice.apbct-banner-error{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#bb3323!important}.apbct-notice.apbct-banner-error .notice-dismiss::before{color:#646464}.apbct-banner-button-green{background-color:#00bb5d!important}.apbct-banner-button-green:hover{background-color:#00a34f!important}.apbct-banner-button-red{background-color:#bb3323!important}.apbct-banner-button-red:hover{background-color:#a82e20!important} \ No newline at end of file diff --git a/css/src/cleantalk-admin.css b/css/src/cleantalk-admin.css index 815b371aa..09ff7c189 100644 --- a/css/src/cleantalk-admin.css +++ b/css/src/cleantalk-admin.css @@ -105,6 +105,51 @@ .apbct-details-spam-order-button { color: gray; } + +/* WooCommerce orders list alike view of the spam orders table. + The order column has no width, so it takes all the space left. */ +.wp-list-table.wc_spam_orders .column-ct_order_date, +.wp-list-table.wc_spam_orders .column-ct_status, +.wp-list-table.wc_spam_orders .column-ct_total { + width: 15%; +} + +.wp-list-table.wc_spam_orders td { + vertical-align: middle; +} + +.wp-list-table.wc_spam_orders .apbct-order-view { + color: #2271b1; + text-decoration: none; +} + +.wp-list-table.wc_spam_orders .apbct-order-view:hover { + color: #135e96; +} + +.apbct-order-status { + display: inline-flex; + line-height: 2.5em; + color: #454545; + background: #e5e5e5; + border-radius: 4px; + border-bottom: 1px solid rgba(0, 0, 0, .05); + margin: -.25em 0; + cursor: inherit !important; + white-space: nowrap; + max-width: 100%; +} + +.apbct-order-status > span { + margin: 0 1em; + overflow: hidden; + text-overflow: ellipsis; +} + +.apbct-order-status--spam { + background: #eba3a3; + color: #570000; +} .ct-modal-buttons { display: flex; align-items: center; diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index b5afb11d8..3a25aa965 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -62,8 +62,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, )); @@ -94,18 +99,22 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam '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 +122,11 @@ 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'), + 'ct_total' => esc_html__('Total', 'cleantalk-spam-protect'), ); return $columns; @@ -126,8 +135,75 @@ 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 + { + $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 @@ -256,6 +332,45 @@ 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') + ); + + 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 +383,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 +485,62 @@ 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 ) { + $totals[$key] = is_string($wc_spam_order->order_details) + ? (float) $this->calcOrderTotal($wc_spam_order->order_details) + : 0; + } + + 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)