| 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 |
|