PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / ReviewFeedback.php

ReviewFeedback.php in 404 Solution 4.2.0, at includes/ReviewFeedback.php

349 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Review request and user feedback flow.
9 *
10 * Manages the multi-step review solicitation: qualification question,
11 * review link for satisfied users, feedback form for unsatisfied users,
12 * and permanent dismissal after any terminal action.
13 */
14 class ABJ_404_Solution_ReviewFeedback {
15
16 private const INITIAL_DELAY_DAYS = 30;
17 private const ASK_LATER_DELAY_DAYS = 7;
18 private const CLOSE_X_SNOOZE_DAYS = 14;
19
20 /** Set to true by handleResponseRedirects() when feedback POST is processed. */
21 private static bool $feedbackSubmitted = false;
22
23 /** Reset static state between tests. */
24 public static function resetForTests(): void {
25 self::$feedbackSubmitted = false;
26 }
27
28 /**
29 * Display an admin dashboard notification (captured-404 count + review request).
30 *
31 * @return void
32 */
33 static function echoDashboardNotification() {
34 $connector = abj_service('wordpress_connector');
35
36 if (!is_admin() || !$connector->getPluginLogic()->userIsPluginAdmin()) {
37 $connector->getLogger()->logUserCapabilities("echoDashboardNotification");
38 return;
39 }
40
41 ABJ_404_Solution_WordPress_Connector::echoAdminRuntimeErrorNotice();
42
43 global $pagenow;
44 global $abj404view;
45
46 $isPluginPage = array_key_exists('page', $_GET) && $_GET['page'] == ABJ404_PP;
47 $isDashboard = $pagenow == 'index.php' && !isset($_GET['page']);
48
49 if ($isPluginPage) {
50 $dbNotice = get_transient('abj404_plugin_db_notice');
51 if (is_array($dbNotice) && isset($dbNotice['message']) && is_string($dbNotice['message'])) {
52 $type = isset($dbNotice['type']) && is_string($dbNotice['type']) ? $dbNotice['type'] : 'warning';
53 // Per owner directive: collation issues must NEVER surface as user notices.
54 if ($type === 'collation') {
55 // intentionally do not render
56 } else {
57 $warningTypes = array(
58 'stale_permalink_cache',
59 'warning',
60 'read_only',
61 'disk_full',
62 'query_quota',
63 );
64 $cssClass = in_array($type, $warningTypes, true) ? 'notice-warning' : 'notice-error';
65 echo '<div class="notice ' . esc_attr($cssClass) . '"><p>' .
66 esc_html($dbNotice['message']) . '</p></div>';
67 }
68 }
69 }
70
71 if ($isPluginPage || $isDashboard) {
72 $captured404Count = $connector->getCapturedCountForNotification();
73 if ($connector->getPluginLogic()->shouldNotifyAboutCaptured404s($captured404Count)) {
74 $msg = $abj404view->getDashboardNotificationCaptured($captured404Count);
75 echo $msg;
76 }
77
78 self::maybeShowReviewRequest();
79 }
80 }
81
82 /**
83 * Handle review GET redirects and feedback POST submission on admin_init.
84 *
85 * @return void
86 */
87 static function handleResponseRedirects() {
88 if (!is_admin()) {
89 return;
90 }
91 if (!isset($_GET['page']) || $_GET['page'] !== ABJ404_PP) {
92 return;
93 }
94
95 if (isset($_GET['abj404_review_response'])) {
96 $rawResponseNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
97 $responseNonce = sanitize_text_field(ABJ_404_Solution_WordPress_Connector::normalizeRequestScalar($rawResponseNonce));
98 if ($responseNonce === '' || !wp_verify_nonce($responseNonce, 'abj404_review_response')) {
99 return;
100 }
101
102 $response = sanitize_text_field(ABJ_404_Solution_WordPress_Connector::normalizeRequestScalar($_GET['abj404_review_response']));
103 $allowedResponses = array('yes', 'not_yet', 'ask_later', 'close_x', 'never');
104 if (!in_array($response, $allowedResponses, true)) {
105 return;
106 }
107
108 if ($response === 'yes') {
109 update_user_meta(get_current_user_id(), 'abj404_review_step', 'show_review_link');
110 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
111 } elseif ($response === 'not_yet') {
112 update_user_meta(get_current_user_id(), 'abj404_review_step', 'show_feedback');
113 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
114 } elseif ($response === 'ask_later') {
115 update_user_meta(get_current_user_id(), 'abj404_review_remind_later', time() + (self::ASK_LATER_DELAY_DAYS * 86400));
116 delete_user_meta(get_current_user_id(), 'abj404_review_step');
117 } elseif ($response === 'close_x') {
118 update_user_meta(get_current_user_id(), 'abj404_review_remind_later', time() + (self::CLOSE_X_SNOOZE_DAYS * 86400));
119 delete_user_meta(get_current_user_id(), 'abj404_review_step');
120 } elseif ($response === 'never') {
121 update_user_meta(get_current_user_id(), 'abj404_review_dismissed', 'permanent');
122 delete_user_meta(get_current_user_id(), 'abj404_review_step');
123 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
124 }
125
126 wp_safe_redirect(remove_query_arg(array('abj404_review_response', '_wpnonce')));
127 exit;
128 }
129
130 if (isset($_GET['abj404_leaving_review'])) {
131 $rawLeavingReviewNonce = isset($_GET['_wpnonce']) ? $_GET['_wpnonce'] : '';
132 $leavingReviewNonce = sanitize_text_field(ABJ_404_Solution_WordPress_Connector::normalizeRequestScalar($rawLeavingReviewNonce));
133 if ($leavingReviewNonce !== '' && wp_verify_nonce($leavingReviewNonce, 'abj404_leaving_review')) {
134 update_user_meta(get_current_user_id(), 'abj404_review_dismissed', 'permanent');
135 delete_user_meta(get_current_user_id(), 'abj404_review_step');
136 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
137
138 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/reviewRedirectScript.html");
139 $f = abj_service('functions');
140 $html = $f->str_replace('{review_url}', esc_js('https://wordpress.org/support/plugin/404-solution/reviews/#new-post'), $html);
141 echo $html;
142 wp_safe_redirect(remove_query_arg(array('abj404_leaving_review', '_wpnonce')));
143 exit;
144 }
145 }
146
147 $rawFeedbackNonce = isset($_POST['abj404_feedback_nonce']) ? $_POST['abj404_feedback_nonce'] : '';
148 $feedbackNonce = sanitize_text_field(ABJ_404_Solution_WordPress_Connector::normalizeRequestScalar($rawFeedbackNonce));
149 if (isset($_POST['abj404_submit_feedback']) &&
150 $feedbackNonce !== '' &&
151 wp_verify_nonce($feedbackNonce, 'abj404_submit_feedback')) {
152
153 $issuesRaw = isset($_POST['feedback_issues']) ? $_POST['feedback_issues'] : array();
154 $issues = ABJ_404_Solution_WordPress_Connector::sanitizeFeedbackIssues($issuesRaw);
155
156 $feedbackDetailsRaw = isset($_POST['feedback_details']) ? $_POST['feedback_details'] : '';
157 $feedback_details = sanitize_textarea_field(ABJ_404_Solution_WordPress_Connector::normalizeRequestScalar($feedbackDetailsRaw));
158
159 $feedback_data = array(
160 'timestamp' => current_time('mysql'),
161 'user_id' => get_current_user_id(),
162 'site_url' => get_site_url(),
163 'issues' => $issues,
164 'details' => $feedback_details,
165 'wp_version' => get_bloginfo('version'),
166 'plugin_version' => ABJ404_VERSION,
167 'php_version' => PHP_VERSION
168 );
169
170 $all_feedback_raw = get_option('abj404_user_feedback', array());
171 $all_feedback = is_array($all_feedback_raw) ? $all_feedback_raw : array();
172 $all_feedback[] = $feedback_data;
173 update_option('abj404_user_feedback', $all_feedback);
174
175 self::emailFeedback($feedback_data);
176
177 update_user_meta(get_current_user_id(), 'abj404_review_dismissed', 'permanent');
178 delete_user_meta(get_current_user_id(), 'abj404_review_step');
179 delete_user_meta(get_current_user_id(), 'abj404_review_remind_later');
180
181 self::$feedbackSubmitted = true;
182 }
183 }
184
185 /**
186 * Display a review request notification after sustained plugin use.
187 *
188 * @return void
189 */
190 private static function maybeShowReviewRequest() {
191 if (!isset($_GET['page']) || $_GET['page'] !== ABJ404_PP) {
192 return;
193 }
194
195 if (self::$feedbackSubmitted) {
196 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/feedbackSuccessNotice.html");
197 echo $html;
198 return;
199 }
200
201 $dismissed = get_user_meta(get_current_user_id(), 'abj404_review_dismissed', true);
202 if ($dismissed === 'permanent') {
203 return;
204 }
205
206 $remind_later = get_user_meta(get_current_user_id(), 'abj404_review_remind_later', true);
207 if ($remind_later && time() < $remind_later) {
208 return;
209 }
210
211 $installed_time = get_option('abj404_installed_time');
212 if (!$installed_time) {
213 $installed_time = time();
214 update_option('abj404_installed_time', $installed_time);
215 return;
216 }
217
218 $days_installed = (time() - $installed_time) / 86400;
219 if ($days_installed < self::INITIAL_DELAY_DAYS) {
220 return;
221 }
222
223 $review_step = get_user_meta(get_current_user_id(), 'abj404_review_step', true);
224
225 if ($review_step === 'show_review_link') {
226 self::showReviewLinkNotice();
227 } elseif ($review_step === 'show_feedback') {
228 self::showFeedbackFormNotice();
229 } else {
230 self::showQualificationQuestion();
231 }
232 }
233
234 /**
235 * @param array<string, mixed> $feedback_data
236 * @return void
237 */
238 private static function emailFeedback($feedback_data) {
239 $to = '404solution@ajexperience.com';
240 $subject = '404 Solution Feedback from ' . get_bloginfo('name');
241
242 $message = "New feedback received from 404 Solution plugin\n\n";
243 $message .= "Site: " . $feedback_data['site_url'] . "\n";
244 $message .= "Date: " . $feedback_data['timestamp'] . "\n";
245 $message .= "WordPress Version: " . $feedback_data['wp_version'] . "\n";
246 $message .= "Plugin Version: " . $feedback_data['plugin_version'] . "\n";
247 $message .= "PHP Version: " . $feedback_data['php_version'] . "\n\n";
248
249 $message .= "Issues Selected:\n";
250 $feedbackIssues = isset($feedback_data['issues']) && is_array($feedback_data['issues']) ? $feedback_data['issues'] : array();
251 if (!empty($feedbackIssues)) {
252 foreach ($feedbackIssues as $issue) {
253 $issueStr = is_string($issue) ? $issue : (string)$issue;
254 $message .= " - " . ucfirst(str_replace('_', ' ', $issueStr)) . "\n";
255 }
256 } else {
257 $message .= " None selected\n";
258 }
259
260 $message .= "\nAdditional Details:\n";
261 $message .= $feedback_data['details'] ? $feedback_data['details'] : "(No additional details provided)\n";
262
263 $headers = array('Content-Type: text/plain; charset=UTF-8');
264
265 wp_mail($to, $subject, $message, $headers);
266 }
267
268 /** @return void */
269 private static function showQualificationQuestion() {
270 $yes_url = wp_nonce_url(
271 add_query_arg('abj404_review_response', 'yes'),
272 'abj404_review_response'
273 );
274 $not_yet_url = wp_nonce_url(
275 add_query_arg('abj404_review_response', 'not_yet'),
276 'abj404_review_response'
277 );
278 $ask_later_url = wp_nonce_url(
279 add_query_arg('abj404_review_response', 'ask_later'),
280 'abj404_review_response'
281 );
282 $never_url = wp_nonce_url(
283 add_query_arg('abj404_review_response', 'never'),
284 'abj404_review_response'
285 );
286 $close_url = wp_nonce_url(
287 add_query_arg('abj404_review_response', 'close_x'),
288 'abj404_review_response'
289 );
290
291 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/reviewQualificationQuestion.html");
292 $f = abj_service('functions');
293 $html = $f->str_replace('{yes_url}', esc_attr($yes_url), $html);
294 $html = $f->str_replace('{not_yet_url}', esc_attr($not_yet_url), $html);
295 $html = $f->str_replace('{ask_later_url}', esc_attr($ask_later_url), $html);
296 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
297 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
298 echo $html;
299 }
300
301 /** @return void */
302 private static function showReviewLinkNotice() {
303 $review_link_url = wp_nonce_url(
304 add_query_arg('abj404_leaving_review', '1'),
305 'abj404_leaving_review'
306 );
307
308 $never_url = wp_nonce_url(
309 add_query_arg('abj404_review_response', 'never'),
310 'abj404_review_response'
311 );
312 $close_url = wp_nonce_url(
313 add_query_arg('abj404_review_response', 'close_x'),
314 'abj404_review_response'
315 );
316
317 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/reviewLinkNotice.html");
318 $f = abj_service('functions');
319 $html = $f->str_replace('{review_link_url}', esc_attr($review_link_url), $html);
320 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
321 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
322 echo $html;
323 }
324
325 /** @return void */
326 private static function showFeedbackFormNotice() {
327 $never_url = wp_nonce_url(
328 add_query_arg('abj404_review_response', 'never'),
329 'abj404_review_response'
330 );
331 $close_url = wp_nonce_url(
332 add_query_arg('abj404_review_response', 'close_x'),
333 'abj404_review_response'
334 );
335
336 ob_start();
337 wp_nonce_field('abj404_submit_feedback', 'abj404_feedback_nonce');
338 $nonce_field = ob_get_clean();
339 if ($nonce_field === false) { $nonce_field = ''; }
340
341 $html = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/html/feedbackFormNotice.html");
342 $f = abj_service('functions');
343 $html = $f->str_replace('{nonce_field}', $nonce_field, $html);
344 $html = $f->str_replace('{never_url}', esc_attr($never_url), $html);
345 $html = $f->str_replace('{close_url}', esc_attr($close_url), $html);
346 echo $html;
347 }
348 }
349