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/inc/cleantalk-admin.php b/inc/cleantalk-admin.php index f52e3e67d..c245d2b31 100644 --- a/inc/cleantalk-admin.php +++ b/inc/cleantalk-admin.php @@ -1198,7 +1198,7 @@ function apbct_admin__admin_bar__add_child_nodes($wp_admin_bar) array( 'parent' => 'apbct__parent_node', 'id' => 'ct_settings_bulk_orders', - 'title' => '' + 'title' => '' . __('WooCommerce spam orders', 'cleantalk-spam-protect') . '', ) ); diff --git a/inc/cleantalk-ajax.php b/inc/cleantalk-ajax.php index 48d12a792..e8b9be86a 100644 --- a/inc/cleantalk-ajax.php +++ b/inc/cleantalk-ajax.php @@ -894,7 +894,6 @@ function ct_ajax_hook($message_obj = null) 'message' => $ct_result->comment ) ); - die(); } // Plugin Name: eForm - WordPress Form Builder; ajax action ipt_fsqm_save_form diff --git a/inc/cleantalk-public-integrations.php b/inc/cleantalk-public-integrations.php index 5fd280d9c..b22c6df36 100644 --- a/inc/cleantalk-public-integrations.php +++ b/inc/cleantalk-public-integrations.php @@ -2448,9 +2448,6 @@ function apbct_form__the7_contact_form() ); wp_send_json($response); - - // IMPORTANT: don't forget to "exit" @todo AG: Why? Exit does not terminate connection, but I can't see how it is applicable - exit; } } } diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index cea88f860..9b7b70e8c 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -364,9 +364,24 @@ function apbct_settings__set_fields() array('val' => 0, 'label' => __('Off')), ), ), + 'forms__wc_show_rejection_message' => array( + 'title' => __('Show rejection message to customers', 'cleantalk-spam-protect'), + 'description' => __( + 'This message tells the customer why their order was filtered, allowing them to fix the issue that caused it. However, this may result in multiple orders from the same customer because all rejected orders are saved in the Spam folder. By default, this option is OFF.', + 'cleantalk-spam-protect' + ), + 'class' => 'apbct_settings-field_wrapper--sub', + 'options' => array( + array('val' => 1, 'label' => __('On')), + array('val' => 0, 'label' => __('Off')), + ), + ), 'data__wc_store_blocked_orders' => array( 'title' => __('Store blocked orders', 'cleantalk-spam-protect'), - 'description' => __('The orders which was blocked by the Anti-Spam will be stored and could be restored manually later if its needed.', 'cleantalk-spam-protect'), + 'description' => __( + 'Orders blocked by Anti-Spam will be stored and can be restored manually later if needed. If the option is OFF, the customer must fix the issue on their end to pass the anti-spam check and place an order. By default, this option is ON.', + 'cleantalk-spam-protect' + ), 'class' => 'apbct_settings-field_wrapper--sub', 'options' => array( array('val' => 1, 'label' => __('On')), @@ -2179,7 +2194,7 @@ function apbct_settings__field__action_buttons() if ( apbct_is_plugin_active('woocommerce/woocommerce.php') ) { add_filter('apbct_settings_action_buttons', function ($buttons_array) { $buttons_array[] = - '' + '' . __('WooCommerce spam orders', 'cleantalk-spam-protect') . ''; return $buttons_array; diff --git a/inc/cleantalk-updater.php b/inc/cleantalk-updater.php index d4c865378..943e5447d 100644 --- a/inc/cleantalk-updater.php +++ b/inc/cleantalk-updater.php @@ -1378,6 +1378,14 @@ function apbct_update_to_6_60_0() } } +function apbct_update_to_6_88_0() +{ + global $apbct; + + $apbct->settings['forms__wc_show_rejection_message'] = 1; + $apbct->save('settings'); +} + function apbct_update_to_6_76_0() { global $apbct; diff --git a/lib/Cleantalk/Antispam/Integrations/ChatyContactForm.php b/lib/Cleantalk/Antispam/Integrations/ChatyContactForm.php index 7836148e3..12599885b 100644 --- a/lib/Cleantalk/Antispam/Integrations/ChatyContactForm.php +++ b/lib/Cleantalk/Antispam/Integrations/ChatyContactForm.php @@ -41,6 +41,5 @@ public function doBlock($message) 'message' => $message, ]; wp_send_json($response); - exit; } } diff --git a/lib/Cleantalk/Antispam/Integrations/ElementorUltimateAddonsRegister.php b/lib/Cleantalk/Antispam/Integrations/ElementorUltimateAddonsRegister.php index feaa89b16..f5a839cc0 100644 --- a/lib/Cleantalk/Antispam/Integrations/ElementorUltimateAddonsRegister.php +++ b/lib/Cleantalk/Antispam/Integrations/ElementorUltimateAddonsRegister.php @@ -24,7 +24,6 @@ public function doBlock($message) ) ) ); - return false; } public function allow() diff --git a/lib/Cleantalk/Antispam/Integrations/HivePress.php b/lib/Cleantalk/Antispam/Integrations/HivePress.php index 50a1eda3c..c5f227594 100644 --- a/lib/Cleantalk/Antispam/Integrations/HivePress.php +++ b/lib/Cleantalk/Antispam/Integrations/HivePress.php @@ -39,6 +39,5 @@ public function doBlock($message) 'error' => ['message' => $message], ] ); - die(); } } diff --git a/lib/Cleantalk/Antispam/Integrations/HivePressRegistration.php b/lib/Cleantalk/Antispam/Integrations/HivePressRegistration.php index fc5044faa..0279910a2 100644 --- a/lib/Cleantalk/Antispam/Integrations/HivePressRegistration.php +++ b/lib/Cleantalk/Antispam/Integrations/HivePressRegistration.php @@ -35,6 +35,5 @@ public function doBlock($message) 'error' => ['message' => $message], ] ); - die(); } } diff --git a/lib/Cleantalk/Antispam/Integrations/MailPoet.php b/lib/Cleantalk/Antispam/Integrations/MailPoet.php index 5343bf60b..df1bb14e0 100644 --- a/lib/Cleantalk/Antispam/Integrations/MailPoet.php +++ b/lib/Cleantalk/Antispam/Integrations/MailPoet.php @@ -58,6 +58,5 @@ public function doBlock($message) ), 403 ); - die(); } } diff --git a/lib/Cleantalk/Antispam/Integrations/SureForms.php b/lib/Cleantalk/Antispam/Integrations/SureForms.php index 8e9a83d5f..eeb997515 100644 --- a/lib/Cleantalk/Antispam/Integrations/SureForms.php +++ b/lib/Cleantalk/Antispam/Integrations/SureForms.php @@ -29,6 +29,5 @@ public function doBlock($message) 'message' => $message ) ); - die(); } } diff --git a/lib/Cleantalk/Antispam/Integrations/UserRegistrationPro.php b/lib/Cleantalk/Antispam/Integrations/UserRegistrationPro.php index d6011c211..d4719fecd 100644 --- a/lib/Cleantalk/Antispam/Integrations/UserRegistrationPro.php +++ b/lib/Cleantalk/Antispam/Integrations/UserRegistrationPro.php @@ -38,8 +38,6 @@ public function getDataForChecking($argument) /** * @param $message - * - * @psalm-suppress UnusedVariable */ public function doBlock($message) { @@ -48,6 +46,5 @@ public function doBlock($message) 'message' => $message ) ); - die(); } } diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index bafa1e415..fe9ab1b81 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -34,8 +34,19 @@ */ class Woocommerce extends IntegrationByClassBase { + /** + * Prefix of the transient keeping the details of a blocked order. + */ + const BLOCKED_ORDER_TRANSIENT = 'apbct_blocked_order_'; + private $event_token = null; + /** + * Key of the transient holding the details of the order blocked in this request. + * @var string + */ + private $blocked_order_key = ''; + /** * @return void * @psalm-suppress PossiblyUnusedMethod @@ -47,6 +58,12 @@ public function doPublicWork() // honeypot add_filter('woocommerce_checkout_fields', [$this, 'addHoneypotField']); + // The blocked visitor gets a thank you page with no order behind it - fill in the details. + // Only the redirect of a blocked order carries the key, so the rest of the pages are left alone. + if ( Get::getString('key') !== '' ) { + add_action('woocommerce_after_template_part', [$this, 'renderBlockedOrderOverview'], 10, 4); + } + // add to cart hooks if cart works with non-ajax requests $this->addCartActions(); @@ -94,27 +111,7 @@ public function doAjaxWork() public function doAdminWork() { - add_action('admin_menu', function () { - add_submenu_page( - 'woocommerce', - __("WooCommerce spam orders", 'cleantalk-spam-protect'), - __("WooCommerce spam orders", 'cleantalk-spam-protect'), - 'activate_plugins', - 'apbct_wc_spam_orders', - function () { - ?> -
-
- display(); - ?> -
-
- 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 +232,24 @@ 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(); + if ( $apbct->settings['forms__wc_show_rejection_message'] ) { + // The customer is told why the order was rejected and is left on the checkout + if ( $apbct->settings['data__wc_store_blocked_orders'] ) { + $this->storeBlockedOrder(); + } + + wp_send_json(array( + 'result' => 'failure', + 'messages' => '', + 'refresh' => 'false', + 'reload' => 'false' + )); } + + $this->handleBlockedOrder(); wp_send_json(array( - 'result' => 'failure', - 'messages' => "", - 'refresh' => 'false', - 'reload' => 'false' + 'result' => 'success', + 'redirect' => $this->getBlockedOrderRedirectUrl(), )); } } @@ -252,6 +264,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 +303,31 @@ public function checkoutCheckFromRest($order) ct_hash($ct_result->id); if ( $ct_result->allow == 0 ) { - if ( $apbct->settings['data__wc_store_blocked_orders'] ) { - $this->storeBlockedOrder(); + $show_rejection = (bool)$apbct->settings['forms__wc_show_rejection_message']; + + if ( $show_rejection ) { + // The customer is told why the order was rejected and is left on the checkout + if ( $apbct->settings['data__wc_store_blocked_orders'] ) { + $this->storeBlockedOrder(); + } + + $response = array( + 'code' => 'woocommerce_store_api_checkout_order_processed', + 'message' => $ct_result->comment, + 'data' => array('status' => 403), + ); + } else { + // The details must be stored before the response carrying their key is built + $this->handleBlockedOrder($order); + + $response = $this->getStoreApiPassedResponse($order); } 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 +335,199 @@ 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); + if ( $show_rejection ) { + 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($order = null) + { + $this->storeBlockedOrder(); + + $this->rememberBlockedOrderOverview($order); + + if ( function_exists('wc') && ! is_null(wc()->cart) ) { + wc()->cart->empty_cart(); + } + } + + /** + * Store the details of the blocked order to show them on the thank you page. + * + * The blocked order never becomes a WooCommerce one, so the thank you page has nothing to + * render and gives the spammer a hint that the order went wrong. The details are kept in a + * transient addressed by the key of the redirect URL and printed by renderBlockedOrderOverview(). + * + * @param \WC_Order|null $order Order created by the Store API checkout, absent for the classic one + * + * @return void + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function rememberBlockedOrderOverview($order = null) + { + $overview = array( + 'date' => date_i18n(get_option('date_format')), + 'total' => $this->getBlockedOrderTotal($order), + 'payment_method' => $this->getBlockedOrderPaymentMethod($order), + ); + + $this->blocked_order_key = 'wc_order_' . wp_generate_password(13, false); + + set_transient(self::BLOCKED_ORDER_TRANSIENT . $this->blocked_order_key, $overview, HOUR_IN_SECONDS); + } + + /** + * @param \WC_Order|null $order + * + * @return string Formatted total, the cart one when there is no order + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function getBlockedOrderTotal($order = null) + { + if ( $order instanceof \WC_Order ) { + return $order->get_formatted_order_total(); + } + + return ( function_exists('wc') && ! is_null(wc()->cart) ) ? wc()->cart->get_total() : ''; + } + + /** + * @param \WC_Order|null $order + * + * @return string Title of the chosen gateway, empty when it can not be resolved + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function getBlockedOrderPaymentMethod($order = null) + { + if ( $order instanceof \WC_Order ) { + return $order->get_payment_method_title(); + } + + $chosen_method = Post::getString('payment_method'); + + if ( $chosen_method === '' || ! function_exists('WC') ) { + return ''; + } + + $gateways = WC()->payment_gateways() ? WC()->payment_gateways()->payment_gateways() : array(); + + return isset($gateways[$chosen_method]) ? $gateways[$chosen_method]->get_title() : ''; + } + + /** + * Print the order details on the thank you page shown to the blocked visitor. + * + * WooCommerce renders the details itself when an order stands behind the page. There is none + * for a blocked order, so the same markup is printed right after the template that says + * the order has been received. + * + * @param string $template_name + * @param string $template_path + * @param string $located + * @param array $args + * + * @return void + * @psalm-suppress PossiblyUnusedMethod, UndefinedFunction + */ + public function renderBlockedOrderOverview($template_name, $template_path, $located, $args) + { + if ( $template_name !== 'checkout/order-received.php' || ! empty($args['order']) ) { + return; + } + + $blocked_order_key = Get::getString('key'); + + if ( $blocked_order_key === '' ) { + return; + } + + $overview = get_transient(self::BLOCKED_ORDER_TRANSIENT . $blocked_order_key); + + if ( ! is_array($overview) || empty($overview['date']) ) { + return; + } + + $rows = array( + 'date' => array(__('Date:', 'cleantalk-spam-protect'), $overview['date']), + 'total' => array(__('Total:', 'cleantalk-spam-protect'), $overview['total']), + ); + + if ( ! empty($overview['payment_method']) ) { + $rows['method'] = array( + __('Payment method:', 'cleantalk-spam-protect'), + $overview['payment_method'] + ); + } + + echo ''; + } + + /** + * 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 @@ -596,6 +810,237 @@ 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) + { + if ( ! 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 + */ + 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 ( ! 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() + { + $list_table = new \Cleantalk\ApbctWP\WcSpamOrdersListTable($this->getOrdersListViews()); + ?> +
+

+
+ renderPageNotices(); ?> +
+ display(); ?> +
+
+ $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 e0d637726..ff2220536 100644 --- a/lib/Cleantalk/ApbctWP/State.php +++ b/lib/Cleantalk/ApbctWP/State.php @@ -58,6 +58,7 @@ class State extends \Cleantalk\Common\State 'forms__wc_checkout_test' => 1, // WooCommerce checkout default test 'forms__wc_register_from_order' => 1, // Woocommerce registration during checkout 'forms__wc_add_to_cart' => 0, // Woocommerce add to cart + 'forms__wc_show_rejection_message' => 0, // Tell the customer why the order was rejected 'forms__search_test' => 1, // Test default WordPress form 'forms__check_external' => 0, 'forms__check_external__capture_buffer' => 0, @@ -94,7 +95,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 diff --git a/lib/Cleantalk/ApbctWP/Variables/AltSessions.php b/lib/Cleantalk/ApbctWP/Variables/AltSessions.php index 82e41d3ea..3694d7e62 100644 --- a/lib/Cleantalk/ApbctWP/Variables/AltSessions.php +++ b/lib/Cleantalk/ApbctWP/Variables/AltSessions.php @@ -192,7 +192,6 @@ public static function setFromRemote($request = null) 'error' => 'AltSessions: No cookies data provided.' ) ); - die(); // Need to prevent psalm further processing checking } if ( ! wp_verify_nonce($nonce, $action) ) { @@ -211,7 +210,6 @@ public static function setFromRemote($request = null) try { $cookies_array = json_decode($cookies_to_set, true); } catch ( \Exception $e ) { - $cookies_array = array(); unset($e); wp_send_json(array( 'success' => false, @@ -234,7 +232,6 @@ public static function setFromRemote($request = null) //other versions json errors if json_decode returns null if ( is_null($cookies_array) ) { - $cookies_array = array(); wp_send_json(array( 'success' => false, 'error' => 'AltSessions: Internal JSON error: $cookies_array is null.')); 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..ca7a4b8b2 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -11,16 +11,28 @@ class WcSpamOrdersListTable extends CleantalkListTable 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 +44,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 +71,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 +97,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, + 'action' => 'delete', + 'spam' => $wc_spam_order->id, + )), + $delete_url ); + $delete_url = wp_nonce_url($delete_url, 'apbct_wc_spam_orders_row', '_wpnonce'); $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 +138,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 +151,79 @@ 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 @@ -256,6 +352,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 +403,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 +505,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 +591,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 '

' . sprintf( __( @@ -346,39 +618,35 @@ private function generatePageHeader() } ?> -
-

- apbct->data["wl_mode_enabled"]) { - echo $this->apbct->data["wl_brandname"]; - } else { - echo 'CleanTalk logo' . $this->apbct->plugin_name; - } - ?> -

- -
-

page_title; ?>

-

Total count of spam orders: wc_spam_orders_count ?>

-

Please do backup of WordPress database before delete any orders!

-

Results are based on the decision of our spam checking system and do not give a complete guarantee that - these orders are spam.

- apbct->settings['data__wc_store_blocked_orders'] != 1) { - echo '

' - . __( - 'To store WooCommerce spam orders, enable the "Store blocked WooCommerce orders" option in CleanTalk settings.', - 'cleantalk-spam-protect' - ) - . '

'; - } - ?> -
+

%2$s', + esc_url(TT::toString($this->apbct->settings_link)), + esc_html__('Anti-Spam by CleanTalk', 'cleantalk-spam-protect') + ); ?>

apbct->settings['data__wc_store_blocked_orders']) ) { + echo '

' + . sprintf( + esc_html__( + 'To store Spam orders, enable the "Store blocked WooCommerce orders" option in %1$sCleanTalk settings%2$s.', + 'cleantalk-spam-protect' + ), + '', + '' + ) + . '

'; + } } private function deleteFromDb($spam_ids)