diff --git a/includes/class-webdecoy-review-ask.php b/includes/class-webdecoy-review-ask.php new file mode 100644 index 0000000..c305ed9 --- /dev/null +++ b/includes/class-webdecoy-review-ask.php @@ -0,0 +1,199 @@ +' (snoozed until then). + * + * @package WebDecoy + */ + +// Prevent direct access. +if (!defined('ABSPATH')) { + exit; +} + +/** + * WebDecoy review ask controller. + */ +class WebDecoy_Review_Ask +{ + public const FIRST_SEEN_OPTION = 'webdecoy_review_first_seen'; + public const STATE_OPTION = 'webdecoy_review_ask'; + + /** Wait before the first ask: 7 days (literal to keep the class loadable sans WordPress). */ + public const WAIT = 604800; + + /** "Maybe later" snoozes for 30 days. */ + public const SNOOZE = 2592000; + + public const REVIEW_URL = 'https://wordpress.org/support/plugin/webdecoy/reviews/#new-post'; + + private const ACTION = 'webdecoy_review_ask'; + + public function register(): void + { + if (!is_admin() || WebDecoy_Runtime_Config::hide_admin_ui()) { + return; + } + add_action('admin_notices', [$this, 'render']); + add_action('admin_post_' . self::ACTION, [$this, 'handle']); + } + + /** + * Render the ask on the WordPress dashboard and WebDecoy's own pages, when due. + */ + public function render(): void + { + if (!current_user_can('manage_options') || !self::on_relevant_screen()) { + return; + } + + $now = time(); + $first_seen = (int) get_option(self::FIRST_SEEN_OPTION, 0); + if ($first_seen <= 0) { + update_option(self::FIRST_SEEN_OPTION, $now, false); + return; + } + + $visits = class_exists('WebDecoy_Crawler_Visits') ? WebDecoy_Crawler_Visits::summary(168) : ['total' => 0, 'groups' => ['ai' => 0]]; + $total = (int) ($visits['total'] ?? 0); + $ai = (int) ($visits['groups']['ai'] ?? 0); + + if (!self::is_due($first_seen, $now, (string) get_option(self::STATE_OPTION, ''), $total)) { + return; + } + + $link = static function (string $choice): string { + return wp_nonce_url( + admin_url('admin-post.php?action=' . self::ACTION . '&choice=' . $choice), + self::ACTION + ); + }; + ?> +
+ id === 'dashboard' || strpos((string) $screen->id, 'webdecoy') !== false; + } + + // --------------------------------------------------------------------- + // Pure helpers (no WordPress calls) — unit-tested in tests/ReviewAskTest.php. + // --------------------------------------------------------------------- + + /** + * Whether the ask is due: a week since first seen, bots counted this week, + * and the admin has neither answered nor snoozed past now. + */ + public static function is_due(int $first_seen, int $now, string $state, int $bot_visits): bool + { + if ($first_seen <= 0 || ($now - $first_seen) < self::WAIT || $bot_visits <= 0) { + return false; + } + if ($state === '') { + return true; + } + if (strpos($state, 'later:') === 0) { + return $now >= (int) substr($state, 6); + } + return false; + } + + /** + * The stored state for a choice. Anything unrecognised snoozes rather + * than silencing the ask for good. + */ + public static function state_for(string $choice, int $now): string + { + switch ($choice) { + case 'done': + return 'done'; + case 'never': + return 'never'; + default: + return 'later:' . ($now + self::SNOOZE); + } + } + + /** + * The ask, built on what the plugin counted this week. + */ + public static function message(int $total, int $ai): string + { + if ($ai > 0) { + return sprintf( + /* translators: 1: bot visits counted in 7 days, 2: of which AI crawlers and agents */ + __('In the last 7 days WebDecoy counted %1$s bot visits on this site, %2$s of them from AI crawlers and agents. If that was useful to see, a short review on WordPress.org helps other site owners find it.', 'webdecoy'), + number_format($total), + number_format($ai) + ); + } + return sprintf( + /* translators: %s: bot visits counted in 7 days */ + __('In the last 7 days WebDecoy counted %s bot visits on this site. If that was useful to see, a short review on WordPress.org helps other site owners find it.', 'webdecoy'), + number_format($total) + ); + } +} diff --git a/tests/ReviewAskTest.php b/tests/ReviewAskTest.php new file mode 100644 index 0000000..e1fd613 --- /dev/null +++ b/tests/ReviewAskTest.php @@ -0,0 +1,61 @@ + not due'); + $true(WebDecoy_Review_Ask::is_due($now - $week + 1, $now, '', 50) === false, 'just under a week -> not due'); + $true(WebDecoy_Review_Ask::is_due($now - $week, $now, '', 50) === true, 'a week with bots counted -> due'); +}); + +$t('never on a site that counted nothing this week', function () use ($true, $now, $week) { + $true(WebDecoy_Review_Ask::is_due($now - 2 * $week, $now, '', 0) === false, 'zero visits -> not due'); +}); + +$t('an answer silences it; a snooze holds until it ends', function () use ($true, $now, $week) { + $first = $now - 2 * $week; + $true(WebDecoy_Review_Ask::is_due($first, $now, 'done', 50) === false, 'reviewed -> never again'); + $true(WebDecoy_Review_Ask::is_due($first, $now, 'never', 50) === false, "don't ask again -> never again"); + $true(WebDecoy_Review_Ask::is_due($first, $now, 'later:' . ($now + 1), 50) === false, 'inside the snooze -> not due'); + $true(WebDecoy_Review_Ask::is_due($first, $now, 'later:' . $now, 50) === true, 'snooze ended -> due'); +}); + +echo "\nReview ask: choices\n"; + +$t('each choice stores its state, and anything else snoozes', function () use ($same, $now) { + $same('done', WebDecoy_Review_Ask::state_for('done', $now)); + $same('never', WebDecoy_Review_Ask::state_for('never', $now)); + $same('later:' . ($now + WebDecoy_Review_Ask::SNOOZE), WebDecoy_Review_Ask::state_for('later', $now)); + $same('later:' . ($now + WebDecoy_Review_Ask::SNOOZE), WebDecoy_Review_Ask::state_for('bogus', $now)); +}); + +echo "\nReview ask: copy\n"; + +$t('names the week\'s count, and the AI share when there is one', function () use ($true) { + $with = WebDecoy_Review_Ask::message(1234, 56); + $true(strpos($with, '1,234 bot visits') !== false && strpos($with, '56 of them from AI') !== false, $with); + $without = WebDecoy_Review_Ask::message(12, 0); + $true(strpos($without, '12 bot visits') !== false && strpos($without, 'AI') === false, $without); + $true(strpos($with . $without, '—') === false, 'no em-dashes in customer copy'); +}); diff --git a/webdecoy.php b/webdecoy.php index 07c987b..7cfc882 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -1087,6 +1087,7 @@ public function load_includes(): void } require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-actor-feed.php'; require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-critical-moment.php'; + require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-review-ask.php'; // One-click Cloud connect + entitlements sync. Makes no external request // until the admin explicitly clicks "Connect". @@ -1108,6 +1109,9 @@ public function load_includes(): void $this->critical_moment = new WebDecoy_Critical_Moment(); $this->critical_moment->register(); + // One wordpress.org review ask, after a week with bots counted. Admin only. + (new WebDecoy_Review_Ask())->register(); + if (class_exists('WooCommerce')) { require_once WEBDECOY_PLUGIN_DIR . 'includes/class-webdecoy-woocommerce.php'; }