PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-deactivation-feedback.php

class-deactivation-feedback.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.1, at includes/class-deactivation-feedback.php

296 lines 12.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Deactivation feedback modal.
4 *
5 * Shows a short, polite "why are you leaving?" survey to every admin who clicks
6 * Deactivate on the Plugins screen, so we learn what to fix.
7 *
8 * FLOW — this class is the UI + capture layer only; it never sends anything
9 * itself. On "Submit & Deactivate" it STORES the reason in the canonical WP
10 * Insights options (`wpins_deactivation_reason_<slug>` /
11 * `wpins_deactivation_details_<slug>`) and returns. WordPress then fires the
12 * real deactivation, and Usage_Tracker::deactivate_this_plugin() reads those
13 * options and transmits them to WPInsight (send.wpinsight.com) using the
14 * platform's `deactivation_reason` / `deactivation_details` contract — the same
15 * two-phase pattern every WPDeveloper plugin uses. This keeps a single send
16 * path (correlated to the registered site when usage analytics is on; a
17 * minimal reason-only payload as per-action consent when it's off).
18 *
19 * "Skip & Deactivate", the close button, the overlay, and Esc store NOTHING
20 * and deactivate/dismiss with no side effects.
21 *
22 * @package XSpeed
23 */
24
25 namespace XSpeed;
26
27 defined( 'ABSPATH' ) || exit;
28
29 class Deactivation_Feedback {
30
31 /** admin-ajax action + nonce name for the survey submission. */
32 const AJAX_ACTION = 'xspeed_deactivation_feedback';
33 const NONCE = 'xspeed_deactivation_feedback';
34
35 /**
36 * Canonical WP Insights option-name prefixes. Usage_Tracker reads these at
37 * deactivation time keyed on the same plugin slug (basename of XSPEED_FILE
38 * without .php), so the two must agree. These names are the WPInsight
39 * platform contract — do not rename them.
40 */
41 const REASON_OPTION_PREFIX = 'wpins_deactivation_reason_';
42 const DETAILS_OPTION_PREFIX = 'wpins_deactivation_details_';
43
44 public function __construct() {
45 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
46 add_action( 'admin_footer-plugins.php', array( $this, 'render_modal' ) );
47 add_action( 'wp_ajax_' . self::AJAX_ACTION, array( $this, 'handle_submit' ) );
48 }
49
50 /**
51 * The survey reasons. Defined in PHP (not JS) so every label + prompt is
52 * translatable via the `xspeed` textdomain. Each reason may declare:
53 * - 'prompt' : placeholder for an optional follow-up textarea.
54 * - 'support' : true to surface a "contact support" callout (used for the
55 * reasons where we'd rather help than lose the user).
56 *
57 * @return array<int,array<string,mixed>>
58 */
59 private function reasons() {
60 return array(
61 array(
62 'id' => 'no_longer_needed',
63 'label' => __( 'I no longer need the plugin', 'xspeed' ),
64 ),
65 array(
66 'id' => 'switching_plugin',
67 'label' => __( "I'm switching to another plugin", 'xspeed' ),
68 'prompt' => __( 'Which plugin are you switching to?', 'xspeed' ),
69 ),
70 array(
71 'id' => 'difficult_to_use',
72 'label' => __( 'The plugin is difficult to use', 'xspeed' ),
73 'prompt' => __( 'What did you find confusing? We\'d love to improve it.', 'xspeed' ),
74 'support' => true,
75 ),
76 array(
77 'id' => 'couldnt_get_working',
78 'label' => __( "I couldn't get the plugin to work", 'xspeed' ),
79 'prompt' => __( 'What issue did you run into? We\'re glad to help.', 'xspeed' ),
80 'support' => true,
81 ),
82 array(
83 'id' => 'performance',
84 'label' => __( "The plugin affects my site's performance", 'xspeed' ),
85 'prompt' => __( 'What did you notice? Any detail helps us pin it down.', 'xspeed' ),
86 'support' => true,
87 ),
88 array(
89 'id' => 'missing_feature',
90 'label' => __( "It's missing a specific feature I need", 'xspeed' ),
91 'prompt' => __( 'Which feature were you looking for?', 'xspeed' ),
92 ),
93 array(
94 'id' => 'temporary',
95 'label' => __( "It's a temporary deactivation", 'xspeed' ),
96 ),
97 array(
98 'id' => 'other',
99 'label' => __( 'Other', 'xspeed' ),
100 'prompt' => __( 'Please tell us a little more.', 'xspeed' ),
101 ),
102 );
103 }
104
105 /**
106 * Enqueue the modal assets — only on the Plugins screen, only for users who
107 * can actually deactivate plugins.
108 *
109 * These two files are SOURCED from `public/`, not `assets/`, even though
110 * they load from `assets/` at runtime. `assets/` is Vite's `outDir` with
111 * `emptyOutDir: true`, so every `npm run build` DELETES anything there it
112 * didn't generate — a hand-written file placed in `assets/` silently
113 * disappears from the release zip (`npm run dist-archive` builds first).
114 * `public/` is Vite's `publicDir`: its contents are copied into `assets/`
115 * verbatim on each build, which is how `icon.svg` and `menu-icon.css`
116 * already ship. Keep these two there.
117 *
118 * @param string $hook Current admin page hook suffix.
119 */
120 public function enqueue( $hook ) {
121 if ( 'plugins.php' !== $hook || ! current_user_can( 'activate_plugins' ) ) {
122 return;
123 }
124
125 $css = XSPEED_DIR . 'assets/deactivate.css';
126 $js = XSPEED_DIR . 'assets/deactivate.js';
127
128 if ( file_exists( $css ) ) {
129 wp_enqueue_style(
130 'xspeed-deactivate',
131 XSPEED_URL . 'assets/deactivate.css',
132 array(),
133 XSPEED_VERSION . '.' . filemtime( $css )
134 );
135 }
136
137 if ( file_exists( $js ) ) {
138 wp_enqueue_script(
139 'xspeed-deactivate',
140 XSPEED_URL . 'assets/deactivate.js',
141 array(),
142 XSPEED_VERSION . '.' . filemtime( $js ),
143 true
144 );
145 wp_localize_script(
146 'xspeed-deactivate',
147 'XSpeedDeactivate',
148 array(
149 // The plugin's row identity on plugins.php (data-plugin attr).
150 'plugin' => plugin_basename( XSPEED_FILE ),
151 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
152 'action' => self::AJAX_ACTION,
153 'nonce' => wp_create_nonce( self::NONCE ),
154 )
155 );
156 }
157 }
158
159 /**
160 * Render the (hidden) modal markup into the Plugins-screen footer. All
161 * copy lives here so it's translatable; the JS only toggles visibility and
162 * posts the result.
163 *
164 * THEME — the modal follows xSpeed's OWN light/dark preference, not the
165 * OS's. `Admin::user_theme()` reads the `xspeed_theme` cookie that
166 * `useTheme` mirrors on every toggle, so the dashboard and this modal
167 * share one source of truth and agree by construction. We stamp the class
168 * on our own container rather than on `<body>`: `Admin::admin_body_class()`
169 * deliberately bails on screens that aren't ours, and `plugins.php` is a
170 * core screen we don't own — so `body.xspeed-dark` is never present here
171 * and keying the CSS off it would pin the modal to light mode forever.
172 * A `prefers-color-scheme` media query is equally wrong: it tracks the OS
173 * and ignores the in-app toggle, which is the bug this replaces (a user on
174 * dark-theme xSpeed with a light OS got a full-screen white dialog).
175 */
176 public function render_modal() {
177 if ( ! current_user_can( 'activate_plugins' ) ) {
178 return;
179 }
180 $support_url = 'https://xspeedcache.com/support/';
181 $theme_class = 'dark' === Admin::user_theme() ? ' xspeed-deactivate--dark' : '';
182 ?>
183 <div id="xspeed-deactivate-modal" class="xspeed-deactivate<?php echo esc_attr( $theme_class ); ?>" aria-hidden="true">
184 <div class="xspeed-deactivate__overlay" data-xspeed-close></div>
185 <div class="xspeed-deactivate__dialog" role="dialog" aria-modal="true" aria-labelledby="xspeed-deactivate-title">
186 <div class="xspeed-deactivate__header">
187 <span class="xspeed-deactivate__brand">
188 <img class="xspeed-deactivate__logo" src="<?php echo esc_url( XSPEED_URL . 'assets/icon.svg' ); ?>" alt="" width="24" height="24" />
189 <span><?php esc_html_e( 'xSpeed Cache', 'xspeed' ); ?></span>
190 </span>
191 <button type="button" class="xspeed-deactivate__close" data-xspeed-close aria-label="<?php esc_attr_e( 'Close', 'xspeed' ); ?>">&times;</button>
192 </div>
193
194 <div class="xspeed-deactivate__body">
195 <h2 id="xspeed-deactivate-title" class="xspeed-deactivate__title"><?php esc_html_e( 'Sorry to see you go', 'xspeed' ); ?></h2>
196 <p class="xspeed-deactivate__sub">
197 <?php esc_html_e( "If you have a moment, we'd love to know why you're deactivating xSpeed Cache — pick all that apply. It takes less than a minute and helps us make it better for everyone, but it's completely optional.", 'xspeed' ); ?>
198 </p>
199
200 <form id="xspeed-deactivate-form" class="xspeed-deactivate__reasons">
201 <?php foreach ( $this->reasons() as $reason ) : ?>
202 <div class="xspeed-deactivate__reason">
203 <label class="xspeed-deactivate__option">
204 <input type="checkbox" name="xspeed-deactivate-reason" value="<?php echo esc_attr( $reason['id'] ); ?>" />
205 <span><?php echo esc_html( $reason['label'] ); ?></span>
206 </label>
207 <?php if ( ! empty( $reason['prompt'] ) ) : ?>
208 <textarea
209 class="xspeed-deactivate__detail"
210 data-for="<?php echo esc_attr( $reason['id'] ); ?>"
211 rows="2"
212 placeholder="<?php echo esc_attr( $reason['prompt'] ); ?>"
213 hidden></textarea>
214 <?php endif; ?>
215 <?php if ( ! empty( $reason['support'] ) ) : ?>
216 <div class="xspeed-deactivate__help" data-for="<?php echo esc_attr( $reason['id'] ); ?>" hidden>
217 <span class="xspeed-deactivate__help-title"><?php esc_html_e( 'đź’ˇ Need a hand before you go?', 'xspeed' ); ?></span>
218 <a href="<?php echo esc_url( $support_url ); ?>" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Contact our support team', 'xspeed' ); ?></a>
219 </div>
220 <?php endif; ?>
221 </div>
222 <?php endforeach; ?>
223 </form>
224
225 <p class="xspeed-deactivate__privacy">
226 <?php esc_html_e( 'Your response is sent to WPDeveloper along with your site URL and plugin version. Choose “Skip & Deactivate” to leave without sharing anything.', 'xspeed' ); ?>
227 </p>
228 </div>
229
230 <div class="xspeed-deactivate__footer">
231 <a href="#" class="xspeed-deactivate__skip" data-xspeed-skip><?php esc_html_e( 'Skip & Deactivate', 'xspeed' ); ?></a>
232 <button type="button" class="xspeed-deactivate__submit button button-primary" data-xspeed-submit>
233 <?php esc_html_e( 'Submit & Deactivate', 'xspeed' ); ?>
234 </button>
235 </div>
236 </div>
237 </div>
238 <?php
239 }
240
241 /**
242 * admin-ajax handler for the survey. Verifies nonce + capability, then
243 * STORES the selected reason(s) in the canonical WP Insights options. The
244 * actual transmission to WPInsight happens later in
245 * Usage_Tracker::deactivate_this_plugin(), which WordPress fires when the
246 * browser follows the real deactivate URL after this resolves. This method
247 * never sends anything and never deactivates anything itself.
248 *
249 * Reasons are multi-select. Each selected id is resolved to its label
250 * SERVER-SIDE (never trust a client-sent display string) and the labels are
251 * joined into the single `deactivation_reason` string WPInsight files —
252 * keeping the platform's scalar contract. Per-reason follow-up text arrives
253 * as `detail_<id>` and is combined into `deactivation_details`.
254 */
255 public function handle_submit() {
256 check_ajax_referer( self::NONCE, 'nonce' );
257
258 if ( ! current_user_can( 'activate_plugins' ) ) {
259 wp_send_json_error( array( 'message' => 'forbidden' ), 403 );
260 }
261
262 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Unslashed here and sanitized with sanitize_key() on the very next line; PHPCS cannot follow the two-statement form. Values are then matched against the hardcoded reasons() allowlist below, so anything unrecognised is dropped.
263 $submitted = isset( $_POST['reason'] ) ? (array) wp_unslash( $_POST['reason'] ) : array();
264 $submitted = array_map( 'sanitize_key', $submitted );
265
266 $labels = array();
267 $details = array();
268 foreach ( $this->reasons() as $reason ) {
269 if ( ! in_array( $reason['id'], $submitted, true ) ) {
270 continue;
271 }
272 $labels[] = $reason['label'];
273
274 $detail_key = 'detail_' . $reason['id'];
275 if ( isset( $_POST[ $detail_key ] ) ) {
276 $text = sanitize_textarea_field( wp_unslash( $_POST[ $detail_key ] ) );
277 if ( '' !== $text ) {
278 // Prefix with the label so multi-reason notes stay legible.
279 $details[] = $reason['label'] . ': ' . $text;
280 }
281 }
282 }
283
284 $slug = basename( XSPEED_FILE, '.php' );
285
286 if ( ! empty( $labels ) ) {
287 update_option( self::REASON_OPTION_PREFIX . $slug, implode( ', ', $labels ), false );
288 }
289 if ( ! empty( $details ) ) {
290 update_option( self::DETAILS_OPTION_PREFIX . $slug, implode( ' | ', $details ), false );
291 }
292
293 wp_send_json_success();
294 }
295 }
296