PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / includes / posimyth-sdk / class-posimyth-deactivation-survey.php

class-posimyth-deactivation-survey.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/posimyth-sdk/class-posimyth-deactivation-survey.php

804 lines 37.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * POSIMYTH deactivation survey — asks "why are you leaving?" before deactivation and
4 * sends the reason to the analytics hub (non-blocking). Config-driven: instantiate once
5 * per plugin with that plugin's slug + a unique ajax action. Never blocks deactivation.
6 *
7 * Usage (in the plugin's main file):
8 * new Posimyth_Deactivation_Survey( array(
9 * 'plugin_name' => 'The Plus Addons for Elementor',
10 * 'plugin_slug' => 'the-plus-addons-for-elementor-page-builder',
11 * 'ajax_action' => 'posimyth_tpae_deact',
12 * ) );
13 *
14 * @package POSIMYTH\Analytics\SDK
15 */
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 if ( ! class_exists( 'Posimyth_Deactivation_Survey' ) ) {
22
23 /**
24 * Renders the pre-deactivation dialog and forwards the chosen reason to the analytics hub.
25 */
26 class Posimyth_Deactivation_Survey {
27
28 /**
29 * Resolved configuration for this product.
30 *
31 * @var array
32 */
33 private array $config;
34
35 /**
36 * The dialog stylesheet is identical for every product, so it is emitted once per page even
37 * when several plugins each render their own (namespaced) dialog.
38 *
39 * @var bool
40 */
41 private static bool $styles_printed = false;
42
43 /**
44 * Hooks the dialog into the plugins screen and registers its AJAX handler.
45 *
46 * @param array $config Product configuration; see the wp_parse_args() defaults below.
47 */
48 public function __construct( array $config ) {
49 $this->config = wp_parse_args(
50 $config,
51 array(
52 'plugin_name' => '',
53 'plugin_slug' => '',
54 // This product's own mark for the dialog heading, as ready-made markup — normally an
55 // <img> whose src the caller has already run through esc_url(). Leave empty to keep
56 // the built-in Nexter mark, which is correct for Extension and Blocks and wrong for
57 // every other product, so anything outside the Nexter brand must set it.
58 'logo_html' => '',
59 // The plugin's file path relative to the plugins dir ('slug/main-file.php'). Used to
60 // recognise this plugin's own Deactivate link by its href — the row's data-slug is
61 // NOT reliable: core falls back to sanitize_title of the (translated) plugin name,
62 // so on translated locales the old data-slug selector matched nothing and the
63 // dialog never opened (E3). Defaults to 'slug/slug.php'.
64 'plugin_file' => '',
65 'ajax_action' => '',
66 // Deprecated for the submit gate: submitting the form is itself the consent, so a
67 // full submit always sends. Retained only so callers passing it don't break.
68 'opt_in_option' => '',
69 // Callable used for a FULL submit to send "reason + non-sensitive setup info" via the
70 // tracker's do_request( $event, $extra ) — e.g. array( 'Posimyth_Tracker_NE', 'do_request' ).
71 // When absent, a full submit falls back to a minimal reason payload built here.
72 'tracker_cb' => null,
73 // This product's own reason set: slug => label, or slug => array( 'label', 'icon' ).
74 // Empty keeps the built-in seven. See reason_list(); passing icons is what switches the
75 // dialog to the card layout.
76 'reasons' => array(),
77 /*
78 * Accent for the top border, the selected card, the submit button and the focus ring.
79 *
80 * This has to be per-product, and the mechanism has to isolate products from each
81 * other. One copy of this class serves every active POSIMYTH plugin and the
82 * stylesheet below is printed once for the whole page, so a literal colour in it
83 * paints EVERY product's dialog — a brief spell with a hardcoded #6660ef put The
84 * Plus Addons' purple on Sticky Header Effects' dialog, on its own plugin row.
85 *
86 * The value is set as a --posi-accent custom property on each dialog element, so it
87 * is scoped by the element itself: a declaration inside one product's dialog cannot
88 * reach another's, no matter which plugin's copy of this file won the loader.
89 *
90 * The default is the historical one, so a product that passes nothing renders
91 * exactly as it did before this key existed.
92 */
93 'accent' => '#1717cc',
94 )
95 );
96
97 if ( empty( $this->config['plugin_slug'] ) || empty( $this->config['ajax_action'] ) ) {
98 return;
99 }
100
101 add_action( 'admin_footer-plugins.php', array( $this, 'render_modal' ) );
102 add_action( 'wp_ajax_' . $this->config['ajax_action'], array( $this, 'handle_ajax' ) );
103 }
104
105 /**
106 * The dialog's free-text limit. Matches the hub column (other_text varchar(500)) so nothing a
107 * user can type is ever silently cut down elsewhere — the boundary is visible in the UI.
108 */
109 const OTHER_TEXT_MAX = 500;
110
111 /**
112 * The reasons this product offers — single source for BOTH the rendered choices and the AJAX
113 * handler's allowlist, so the two can never drift apart. That invariant is why a product cannot
114 * simply render its own list: anything not in here collapses to 'no-reason' on submit.
115 *
116 * A product overrides the set through the `reasons` config key, as slug => label or
117 * slug => array( 'label' => …, 'icon' => '<svg…>' ). Supplying icons is what switches the dialog
118 * to the card layout; without them it stays the plain list. Slugs are what reach the hub, and the
119 * hub groups them per plugin_slug, so one product having its own set does not disturb another's.
120 *
121 * @return array<string,array{label:string,icon:string}>
122 */
123 private function reason_list(): array {
124 $configured = $this->config['reasons'] ?? array();
125
126 /*
127 * A callable is the supported way to pass a translated set. The survey is constructed at
128 * `plugins_loaded`, so labels written straight into the config array are translated there
129 * too — before WordPress is ready for it, which is the "translation loading was triggered
130 * too early" notice in WP 6.7+. Deferring the array to a closure moves every __() to the
131 * moment this method actually runs: rendering the dialog in admin_footer-plugins.php, or
132 * handling its AJAX submit. Both are long after `init`.
133 *
134 * This must never be dropped in a re-sync. Nexter Extension and Nexter Blocks both pass a
135 * closure, and a copy that only accepts arrays does not error on one — is_array() is simply
136 * false, so the config collapses to the built-in seven and both products silently lose their
137 * own reason set and card icons. That is exactly what shipped while a copy without this
138 * block held the highest version.
139 */
140 if ( is_callable( $configured ) ) {
141 $configured = call_user_func( $configured );
142 }
143
144 if ( ! is_array( $configured ) ) {
145 $configured = array();
146 }
147
148 if ( empty( $configured ) ) {
149 return self::default_reasons();
150 }
151
152 $out = array();
153 foreach ( $configured as $slug => $entry ) {
154 $slug = sanitize_key( (string) $slug );
155 if ( '' === $slug ) {
156 continue;
157 }
158
159 $label = is_array( $entry ) ? (string) ( $entry['label'] ?? '' ) : (string) $entry;
160 $icon = is_array( $entry ) ? (string) ( $entry['icon'] ?? '' ) : '';
161 if ( '' === $label ) {
162 continue;
163 }
164
165 $out[ $slug ] = array(
166 'label' => $label,
167 'icon' => $icon,
168 );
169 }
170
171 // A config that produced nothing usable would otherwise render an empty dialog with a
172 // permanently disabled Submit, so fall back rather than ship a dead form.
173 return empty( $out ) ? self::default_reasons() : $out;
174 }
175
176 /**
177 * The built-in set, used by every product that does not override it.
178 *
179 * @return array<string,array{label:string,icon:string}>
180 */
181 private static function default_reasons(): array {
182 $labels = array(
183 'no-longer-needed' => __( 'I no longer need the plugin', 'wdesignkit' ),
184 'found-better' => __( 'I found a better plugin', 'wdesignkit' ),
185 'not-working' => __( "It's not working", 'wdesignkit' ),
186 'temporary' => __( "It's a temporary deactivation", 'wdesignkit' ),
187 'plugin-conflict' => __( 'Plugin conflict', 'wdesignkit' ),
188 'site-speed' => __( 'It slowed down my site', 'wdesignkit' ),
189 'other' => __( 'Other', 'wdesignkit' ),
190 );
191
192 $out = array();
193 foreach ( $labels as $slug => $label ) {
194 $out[ $slug ] = array(
195 'label' => $label,
196 'icon' => '',
197 );
198 }
199 return $out;
200 }
201
202 /**
203 * The kses allowlist for a reason icon. Product-supplied markup, but it crosses an SDK boundary
204 * into an admin screen, so it goes through an allowlist like every other injected fragment.
205 *
206 * @return array
207 */
208 private static function icon_tags(): array {
209 $shape = array(
210 'fill' => true,
211 'd' => true,
212 'fill-rule' => true,
213 'clip-rule' => true,
214 'stroke' => true,
215 'stroke-width' => true,
216 'stroke-linecap' => true,
217 'stroke-linejoin' => true,
218 'clip-path' => true,
219 );
220
221 return array(
222 'svg' => array(
223 'xmlns' => true,
224 'fill' => true,
225 'viewbox' => true,
226 'width' => true,
227 'height' => true,
228 'class' => true,
229 ),
230 'g' => $shape,
231 'path' => $shape,
232 'rect' => array_merge( $shape, array( 'width' => true, 'height' => true, 'x' => true, 'y' => true, 'rx' => true ) ),
233 'circle' => array_merge( $shape, array( 'cx' => true, 'cy' => true, 'r' => true ) ),
234 'defs' => array(),
235 'clippath' => array( 'id' => true ),
236 );
237 }
238
239 /**
240 * Prints this product's dialog markup, plus the shared stylesheet and the scoped behaviour.
241 */
242 public function render_modal(): void {
243 // Not in the network admin (A6): network deactivation is a super admin's bulk operation
244 // across the whole install — intercepting it with a per-site "why are you leaving?" dialog
245 // is wrong there, and the tracker's own deactivated_plugin listener still records the
246 // network deactivation as a single event.
247 if ( is_multisite() && is_network_admin() ) {
248 return;
249 }
250
251 $slug = $this->config['plugin_slug'];
252 $action = $this->config['ajax_action'];
253 $name = empty( $this->config['plugin_name'] ) ? $slug : $this->config['plugin_name'];
254 $nonce = wp_create_nonce( $action );
255
256 // See the config note: recognise our Deactivate link by the plugin file in its href, not
257 // by the row's locale-dependent data-slug.
258 $plugin_file = ! empty( $this->config['plugin_file'] ) ? $this->config['plugin_file'] : $slug . '/' . $slug . '.php';
259
260 // Every participating product renders its OWN dialog on this screen (the reason has to be
261 // attributed to the right plugin_slug, and each has a different deactivation URL), so the
262 // markup must be namespaced per plugin. Sharing one id/class set meant two dialogs with
263 // duplicate ids in the DOM, and selectors matching both — which is why jQuery's .text()
264 // returned the label twice ("Submit & DeactivateSubmit & Deactivate").
265 $uid = 'posi-deact-' . sanitize_html_class( $slug );
266
267 $reasons = $this->reason_list();
268
269 // Validated as a hex colour: it is interpolated into a style attribute, so a product's
270 // config must not be able to smuggle further declarations in through it.
271 $accent = (string) ( $this->config['accent'] ?? '' );
272 $accent = preg_match( '/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $accent ) ? $accent : '#1717cc';
273 ?>
274 <?php
275 /*
276 * UX notes on this layout:
277 * - ONE primary action ("Submit & Deactivate"). The previous version offered three
278 * competing buttons of equal weight, which made the user choose between actions
279 * instead of just answering the question.
280 * - "I agree to be contacted" is a CHECKBOX, not a button: it modifies what the
281 * submission carries, it is not a separate destination.
282 * - "Skip & Deactivate" is a low-emphasis text button, so opting out is always available
283 * but never competes with the primary action.
284 * - Submit stays disabled until a reason is picked, so it cannot be pressed with nothing
285 * to send.
286 */
287 ?>
288 <?php
289 /*
290 * Two scoping hooks on the root, both per-product:
291 *
292 * - `posi-deact-p-{slug}` is a parent class a product can target from its OWN stylesheet
293 * to restyle only its own dialog. The shared stylesheet below never uses it, so anything
294 * written against it cannot bleed into a sibling's dialog.
295 * - `--posi-accent` carries this product's colour. Set on the element, so it is scoped by
296 * the element: the shared rules read var(--posi-accent) and each dialog resolves it to
297 * its own value.
298 */
299 ?>
300 <div id="<?php echo esc_attr( $uid ); ?>" class="posi-deact-modal posi-deact-p-<?php echo esc_attr( sanitize_html_class( $slug ) ); ?>" style="--posi-accent:<?php echo esc_attr( $accent ); ?>">
301 <div class="posi-deact-dialog" role="dialog" aria-modal="true" aria-labelledby="<?php echo esc_attr( $uid ); ?>-title" tabindex="-1">
302 <h3 id="<?php echo esc_attr( $uid ); ?>-title">
303 <?php
304 /*
305 * The product mark, from the caller's `logo_html`. This file ships inside several
306 * plugins, and until now it rendered a hardcoded Nexter mark regardless — so a user
307 * deactivating The Plus Addons for Elementor, or Sticky Header Effects, was shown
308 * another product's logo on their own dialog. Both of those already passed
309 * `logo_html`; nothing here ever read it.
310 *
311 * Run through wp_kses rather than echoed raw. The value is developer-supplied
312 * config, not user input, but this is markup crossing an SDK boundary into an admin
313 * screen, and an allowlist costs nothing.
314 *
315 * The inline fallback below is the Nexter mark, kept so Extension and Blocks look
316 * exactly as before when they pass nothing. Deliberately without the
317 * nxt-normal-theme-logo class it carries elsewhere: this file must not depend on
318 * any host stylesheet, so the sizing lives in .posi-deact-icon instead.
319 */
320 $posi_logo = isset( $this->config['logo_html'] ) ? trim( (string) $this->config['logo_html'] ) : '';
321 ?>
322 <span class="posi-deact-icon" aria-hidden="true">
323 <?php
324 if ( '' !== $posi_logo ) {
325 echo wp_kses(
326 $posi_logo,
327 array(
328 'img' => array(
329 'src' => true,
330 'width' => true,
331 'height' => true,
332 'alt' => true,
333 'class' => true,
334 'style' => true,
335 'decoding' => true,
336 'loading' => true,
337 ),
338 'svg' => array(
339 'xmlns' => true,
340 'width' => true,
341 'height' => true,
342 'viewbox' => true,
343 'fill' => true,
344 'class' => true,
345 ),
346 'g' => array( 'fill' => true ),
347 'path' => array(
348 'd' => true,
349 'fill' => true,
350 'fill-rule' => true,
351 'clip-rule' => true,
352 ),
353 'rect' => array(
354 'width' => true,
355 'height' => true,
356 'x' => true,
357 'y' => true,
358 'rx' => true,
359 'fill' => true,
360 ),
361 )
362 );
363 } else {
364 ?>
365 <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 28 28"><rect width="28" height="28" fill="#1717cc" rx="5.833"></rect><path fill="#fff" d="M12.834 5.32h2.917v11.958a.875.875 0 0 1-.875.875h-2.042zM12.834 19.903h2.042c.483 0 .875.391.875.875v2.041h-2.917z"></path></svg>
366 <?php
367 }
368 ?>
369 </span>
370 <?php esc_html_e( 'Quick Feedback', 'wdesignkit' ); ?>
371 </h3>
372 <p class="posi-deact-sub">
373 <?php
374 /* translators: %s: product name */
375 echo esc_html( sprintf( __( 'Help us improve %s. Why are you deactivating?', 'wdesignkit' ), $name ) );
376 ?>
377 </p>
378 <form id="<?php echo esc_attr( $uid ); ?>-form">
379 <?php
380 /*
381 * Two layouts from one list. A product that supplies icons gets the card grid it
382 * had before this SDK existed; one that does not keeps the plain rows. Same input
383 * name, same slugs, same validation either way — only the presentation differs, so
384 * there is no second code path for the submit to fall out of sync with.
385 */
386 $has_icons = false;
387 foreach ( $reasons as $entry ) {
388 if ( '' !== $entry['icon'] ) {
389 $has_icons = true;
390 break;
391 }
392 }
393 ?>
394 <ul class="posi-deact-reasons<?php echo $has_icons ? ' posi-deact-cards' : ''; ?>">
395 <?php foreach ( $reasons as $val => $entry ) : ?>
396 <li>
397 <label>
398 <input type="radio" name="posi_reason" value="<?php echo esc_attr( $val ); ?>">
399 <?php if ( '' !== $entry['icon'] ) : ?>
400 <span class="posi-deact-ricon" aria-hidden="true"><?php echo wp_kses( $entry['icon'], self::icon_tags() ); ?></span>
401 <?php endif; ?>
402 <span><?php echo esc_html( $entry['label'] ); ?></span>
403 </label>
404 </li>
405 <?php endforeach; ?>
406 </ul>
407
408 <?php // maxlength mirrors the hub's column width, so long feedback is trimmed visibly here rather than lost later. ?>
409 <textarea name="posi_other_text" rows="3" maxlength="<?php echo (int) self::OTHER_TEXT_MAX; ?>" placeholder="<?php esc_attr_e( 'Please share more details…', 'wdesignkit' ); ?>"></textarea>
410
411 <div class="posi-deact-opts">
412 <?php
413 /*
414 * There is deliberately NO "Submit anonymously" option here.
415 *
416 * It used to exist and it could not keep its promise. WordPress fires
417 * `deactivated_plugin` on every deactivation, and the tracker's listener is gated
418 * on the persistent sharing consent alone — it has no idea which button was
419 * pressed in this dialog. So whenever sharing was already ON, an "anonymous"
420 * submission was immediately followed by a full environment ping carrying the real
421 * site URL and the same reason: the anonymous row was deanonymised seconds after it
422 * was written. Offering a privacy option that the surrounding system overrides is
423 * worse than not offering one. Do not reinstate it without first making the
424 * deactivation ping aware of the user's choice.
425 */
426 ?>
427
428 <?php
429 /*
430 * Opt-in to being contacted. This is the ONLY thing in this dialog that shares a
431 * personal detail (the site's admin email), so it is off by default. What it shares
432 * is disclosed by the consent line below, which JS rewrites when this is ticked so
433 * the "no personal data" wording is never shown while an email is actually being
434 * sent.
435 */
436 ?>
437 <label class="posi-deact-opt">
438 <input type="checkbox" class="posi-deact-contact" name="posi_contact_ok" value="1">
439 <span><?php esc_html_e( 'I agree to be contacted via email for support with this plugin.', 'wdesignkit' ); ?></span>
440 </label>
441 </div>
442
443 <div class="posi-deact-footer">
444 <?php // Plain '&' — esc_html_e() does the encoding. Passing '&amp;' here would be escaped again and render literally as "&amp;". ?>
445 <button type="button" class="posi-deact-skip"><?php esc_html_e( 'Skip & Deactivate', 'wdesignkit' ); ?></button>
446 <button type="submit" class="posi-deact-submit"><?php esc_html_e( 'Submit & Deactivate', 'wdesignkit' ); ?></button>
447 </div>
448
449 <?php
450 /* translators: %s: product name */
451 $consent_default = sprintf( __( 'Submitting shares non-sensitive info so we can improve %s. No personal data.', 'wdesignkit' ), $name );
452 /* translators: %s: product name */
453 $consent_contact = sprintf( __( 'Submitting shares non-sensitive info so we can improve %s, plus your admin email so we can reply.', 'wdesignkit' ), $name );
454 ?>
455 <p class="posi-deact-consent"
456 data-default="<?php echo esc_attr( $consent_default ); ?>"
457 data-contact="<?php echo esc_attr( $consent_contact ); ?>">
458 <?php
459 // Starts on the default wording; JS swaps in data-contact when the contact box is ticked.
460 echo esc_html( $consent_default );
461 ?>
462 </p>
463 </form>
464 </div>
465 </div>
466
467 <?php
468 // The stylesheet is shared by every product's dialog, so print it only once per page even
469 // when several plugins each render their own modal.
470 if ( ! self::$styles_printed ) :
471 self::$styles_printed = true;
472 ?>
473 <style>
474 /* Lighter wash plus a blur rather than a flat 50% black, so the plugin rows stay readable
475 behind the dialog. The rgba() line stands alone as the fallback: a browser without
476 backdrop-filter support keeps a usable scrim instead of a near-transparent overlay. */
477 .posi-deact-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.33);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:100000;align-items:center;justify-content:center;}
478 /*
479 * 575px matches the form this dialog replaced. At 520px the two-column card grid wrapped the
480 * longer labels ("Switched to Alternative") onto a second line while the shorter ones stayed
481 * single, so the cards came out different heights.
482 *
483 * The top border reads --posi-accent, which each dialog sets from its own `accent` config —
484 * see that key. Never a literal here: this stylesheet is printed once for the whole page and
485 * shared by every product's dialog, so a fixed colour would paint all of them.
486 */
487 .posi-deact-dialog{background:#fff;border-radius:5px;border-top:5px solid var(--posi-accent,#1717cc);padding:30px 32px;max-width:575px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,.18);max-height:90vh;overflow:auto;box-sizing:border-box;}
488 /* Focused programmatically for screen readers; no visible ring on the container itself. */
489 .posi-deact-dialog:focus{outline:none;}
490 .posi-deact-dialog h3{margin:0 0 6px;font-size:18px;line-height:1.3;color:#1c1c1c;display:flex;align-items:center;gap:8px;}
491 /* flex:none so the mark never squashes if the heading wraps. */
492 .posi-deact-icon{display:inline-flex;flex:none;}
493 .posi-deact-icon svg{display:block;}
494 .posi-deact-dialog .posi-deact-sub{color:#5e5e5e;margin:0 0 18px;font-size:13px;}
495 .posi-deact-reasons{margin:0;padding:0;list-style:none;}
496 .posi-deact-reasons li{margin:0 0 2px;}
497 /* Whole row is the hit target, not just the 16px radio. */
498 .posi-deact-reasons label{display:flex;align-items:center;gap:8px;cursor:pointer;padding:7px 8px;margin:0 -8px;border-radius:4px;font-size:13px;color:#1c1c1c;}
499 .posi-deact-reasons label:hover{background:#f5f7fe;background:color-mix(in srgb, var(--posi-accent,#1717cc) 8%, #fff);}
500 .posi-deact-reasons input[type=radio]{margin:0;flex:none;}
501 /* Card layout, used only when the product supplies reason icons. Ported from the design The
502 Plus Addons shipped before this SDK. Colours come from --posi-accent so one shared
503 stylesheet can serve products with different brand colours. */
504 .posi-deact-cards{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
505 .posi-deact-cards li{margin:0;}
506 .posi-deact-cards label{display:flex;align-items:center;gap:10px;padding:13px 14px;margin:0;border:1px solid #d9d9d9;border-radius:5px;background:#fff;font-size:13px;font-weight:500;line-height:1.3;transition:border-color .2s ease,background-color .2s ease;}
507 .posi-deact-cards label:hover{border-color:var(--posi-accent,#1717cc);background:#fff;}
508 .posi-deact-cards input[type=radio]{position:absolute;width:1px;height:1px;opacity:0;margin:0;pointer-events:none;}
509 .posi-deact-cards label:has(input:checked){border-color:var(--posi-accent,#1717cc);background:#f1f4ff;background:color-mix(in srgb, var(--posi-accent,#1717cc) 8%, #fff);}
510 .posi-deact-cards label:has(input:focus-visible){outline:2px solid var(--posi-accent,#1717cc);outline-offset:1px;}
511 .posi-deact-ricon{display:inline-flex;flex:none;width:20px;height:20px;}
512 .posi-deact-ricon svg{width:20px;height:20px;display:block;}
513 @media (max-width:600px){.posi-deact-cards{grid-template-columns:1fr;}}
514 .posi-deact-dialog textarea{display:none;width:100%;margin-top:10px;box-sizing:border-box;font-size:13px;}
515 .posi-deact-opts{margin:16px 0 0;padding-top:14px;border-top:1px solid #e5e5e5;display:flex;flex-direction:column;gap:8px;}
516 /* Each option is its own bordered box so it reads as a distinct, tappable choice. */
517 .posi-deact-opt{display:flex;align-items:center;gap:9px;cursor:pointer;font-size:13px;color:#1c1c1c;border:1px solid #dcdcde;border-radius:4px;padding:11px 13px;}
518 .posi-deact-opt:hover{border-color:var(--posi-accent,#1717cc);}
519 .posi-deact-opt input[type=checkbox]{margin:0;flex:none;}
520 /* A disabled option must look disabled, not just refuse the click. */
521 .posi-deact-opt.is-disabled{opacity:.5;cursor:not-allowed;}
522 .posi-deact-opt.is-disabled:hover{border-color:#dcdcde;}
523 /* Skip sits on the left, the primary action on the right. */
524 .posi-deact-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:18px;flex-wrap:wrap;}
525 .posi-deact-submit{background:var(--posi-accent,#1717cc);border:1px solid var(--posi-accent,#1717cc);color:#fff;padding:8px 18px;border-radius:4px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;line-height:1.4;}
526 .posi-deact-submit:disabled{background:#c7c7d6;border-color:#c7c7d6;cursor:not-allowed;}
527 /* Text button: available, but visually subordinate to the primary action. */
528 .posi-deact-skip{background:none;border:0;padding:4px 2px;color:#5e5e5e;font-size:13px;cursor:pointer;text-decoration:underline;font-family:inherit;}
529 .posi-deact-skip:hover{color:var(--posi-accent,#1717cc);}
530 .posi-deact-consent{color:#787878;font-size:12px;margin:14px 0 0;line-height:1.5;}
531 </style>
532 <?php endif; ?>
533
534 <script>
535 jQuery(function($){
536 // EVERYTHING below is scoped to this product's own dialog ($modal). Several products
537 // can render a dialog on this screen, so unscoped selectors previously matched all of
538 // them: clicking Deactivate on Nexter Blocks opened Nexter Extension's dialog (the
539 // first match), and .text() on the shared button class returned every label
540 // concatenated ("Submit & DeactivateSubmit & Deactivate").
541 var $modal = $('#<?php echo esc_js( $uid ); ?>');
542 if ( ! $modal.length ) { return; }
543
544 var $form = $modal.find('form');
545 var $submit = $modal.find('.posi-deact-submit');
546 var $other = $modal.find('textarea[name="posi_other_text"]');
547 var $contact = $modal.find('.posi-deact-contact');
548 var $consent = $modal.find('.posi-deact-consent');
549 var nonce = '<?php echo esc_js( $nonce ); ?>';
550
551 // The consent line must never claim "No personal data" while an email is about to be
552 // sent, so it is rewritten from the markup's data attributes when the box is ticked.
553 function syncOptions(){
554 $consent.text( $contact.is(':checked') ? $consent.data('contact') : $consent.data('default') );
555 }
556 $contact.on('change', syncOptions);
557 // Read the label from the markup rather than re-printing it: esc_js() HTML-encodes '&'
558 // to '&amp;' and jQuery .text() writes that literally.
559 var submitLabel = $submit.text();
560
561 function closeModal(){
562 $modal.hide();
563 }
564
565 /*
566 * Delegated + matched by href, not a direct bind on a data-slug row (E3):
567 * - direct handlers died when wp.updates re-rendered the row (after an update or a
568 * bulk action), so the next Deactivate click sailed straight past the dialog;
569 * - data-slug falls back to sanitize_title of the TRANSLATED plugin name, so on
570 * non-English locales the selector matched nothing and the dialog never opened.
571 * The plugin=<file> pair in the href is locale-proof and survives re-renders.
572 */
573 var pluginParam = 'plugin=' + encodeURIComponent('<?php echo esc_js( $plugin_file ); ?>');
574 $(document).on('click.<?php echo esc_js( $uid ); ?>', 'span.deactivate a', function(e){
575 var href = $(this).attr('href') || '';
576 if ( href.indexOf( pluginParam ) === -1 ) { return; }
577 e.preventDefault();
578 var deactUrl = href;
579
580 // Reset so reopening never shows a stale selection or a stuck button. Submit is
581 // re-ENABLED here rather than disabled: a previous submit leaves it disabled and
582 // relabelled "Submitting…", and reopening has to clear both or the button stays dead
583 // for the rest of the page load.
584 if ( $form.length && $form[0] ) { $form[0].reset(); }
585 $other.hide();
586 $submit.prop('disabled', false).text(submitLabel);
587 syncOptions(); // form.reset() clears the boxes; re-apply the derived UI state
588 $modal.css('display','flex');
589 // Focus the dialog, NOT the first radio — a focus ring on a radio reads as though that
590 // option were already selected.
591 $modal.find('.posi-deact-dialog').trigger('focus');
592
593 $modal.find('input[name="posi_reason"]').off('change.posi').on('change.posi', function(){
594 // Only the "Other" textarea is derived from the choice; Submit is always available,
595 // and an unanswered dialog submits as reason "no-reason", which the handler accepts.
596 $other.toggle( $(this).val() === 'other' );
597 });
598
599 function send(){
600 var reason = $modal.find('input[name="posi_reason"]:checked').val() || 'no-reason';
601 var other = $other.val() || '';
602 $submit.prop('disabled', true).text(<?php echo wp_json_encode( __( 'Submitting…', 'wdesignkit' ) ); ?>);
603 $.post(ajaxurl, {
604 action: '<?php echo esc_js( $action ); ?>',
605 nonce: nonce,
606 reason: reason,
607 other_text: other,
608 // Only ever 1 when the user ticked the contact box; the server reads the email
609 // from the site itself, so no address is posted from the browser.
610 contact_ok: $contact.is(':checked') ? 1 : 0
611 }).always(function(){ window.location.href = deactUrl; });
612 }
613
614 // One primary action. Submitting IS the consent: it sends the reason plus the
615 // non-sensitive setup payload, and the admin email only if the contact box is ticked.
616 $form.off('submit.posi').on('submit.posi', function(ev){
617 ev.preventDefault();
618 send();
619 });
620
621 // Skip → send nothing, just deactivate.
622 $modal.find('.posi-deact-skip').off('click.posi').on('click.posi', function(){
623 window.location.href = deactUrl;
624 });
625 });
626
627 // Backdrop click (only the overlay itself, not the dialog) and Esc simply back out:
628 // the dialog closes and the plugin stays ACTIVE. That is deliberately different from
629 // "Skip & Deactivate", which does deactivate. Neither sends anything.
630 $modal.on('click', function(e){
631 if ( e.target === this ) { closeModal(); }
632 });
633
634 $(document).on('keydown', function(e){
635 if ( ( e.key === 'Escape' || e.keyCode === 27 ) && $modal.is(':visible') ) {
636 closeModal();
637 }
638 });
639 });
640 </script>
641 <?php
642 }
643
644 /**
645 * Receives a submission from the dialog and forwards it. Skip / Esc never reach this handler.
646 */
647 public function handle_ajax(): void {
648 // Nonce AND capability. The dialog only renders on plugins.php, which WordPress already
649 // gates on activate_plugins, so the same capability is required here — a handler should not
650 // depend on its callers for authorisation. Checked before the nonce so an unprivileged
651 // caller gets a clear 403 rather than a nonce failure.
652 if ( ! current_user_can( 'activate_plugins' ) ) {
653 wp_send_json_error( array( 'message' => 'unauthorized' ), 403 );
654 }
655 check_ajax_referer( $this->config['ajax_action'], 'nonce' );
656
657 // Submitting is itself the consent: it sends the reason plus the non-sensitive setup
658 // payload. Skip / Esc / backdrop never reach this handler, so nothing is sent for them.
659 //
660 // There is no longer an 'anon' mode. It wrote a row under a placeholder site_url, but
661 // WordPress then fired `deactivated_plugin`, whose listener is gated on the persistent
662 // sharing consent and cannot see what was clicked here — so a full ping with the real site
663 // URL and the same reason followed moments later and undid it.
664 // Allowlisted against the same map the dialog renders (A7): the radio list is the only
665 // legitimate source, so anything else — a tampered request inventing its own "reason" —
666 // collapses to no-reason instead of flowing into the hub as free text.
667 $reason = sanitize_key( $_POST['reason'] ?? 'no-reason' );
668 if ( 'no-reason' !== $reason && ! array_key_exists( $reason, $this->reason_list() ) ) {
669 $reason = 'no-reason';
670 }
671
672 // Capped to the dialog's visible maxlength (A7): without this, a hand-crafted request
673 // could push a multi-megabyte body through to the hub.
674 $other_text = substr( sanitize_textarea_field( wp_unslash( $_POST['other_text'] ?? '' ) ), 0, self::OTHER_TEXT_MAX );
675
676 // The regular payload carries no personal data. The admin email is attached ONLY when the
677 // user ticked "I agree to be contacted" — and it is read server-side from the user
678 // submitting THIS request, never from the posted form, so the browser cannot inject an
679 // arbitrary address.
680 //
681 // This must be the consenting user's own address, not get_option( 'admin_email' ): that
682 // option is the site's single global address, which on a multi-admin or agency/reseller
683 // site can belong to someone else entirely (a former owner, a dev shop) — sending it here
684 // discloses an unrelated person's email based on THIS user's checkbox, which is not a valid
685 // consent basis for contacting them. check_ajax_referer() above has already run, so
686 // wp_get_current_user() is reliably the person who just ticked the box.
687 $contact_ok = ! empty( $_POST['contact_ok'] );
688 $extra = array();
689
690 if ( $contact_ok ) {
691 $email = sanitize_email( (string) wp_get_current_user()->user_email );
692 if ( is_email( $email ) ) {
693 $extra['contact_email'] = $email;
694 $extra['contact_ok'] = 1;
695 }
696 }
697
698 /*
699 * Tell the tracker this deactivation is already accounted for.
700 *
701 * WordPress fires `deactivated_plugin` moments after this, and that listener has no way of
702 * knowing the dialog just reported the same deactivation with a reason attached — so every
703 * opted-in deactivation was sent twice, once with a reason and once without, double-counting
704 * churn for exactly the opted-in cohort. The marker is short-lived and consumed by the
705 * listener.
706 */
707 if ( is_callable( $this->config['tracker_cb'] ) && is_array( $this->config['tracker_cb'] ) ) {
708 $tracker_class = $this->config['tracker_cb'][0];
709 if ( is_string( $tracker_class ) && method_exists( $tracker_class, 'mark_deactivation_reported' ) ) {
710 call_user_func( array( $tracker_class, 'mark_deactivation_reported' ) );
711 }
712 }
713
714 // The click itself is consent, so this sends regardless of the persistent
715 // Share-Non-Sensitive-Details toggle. Prefer the tracker's do_request(), which attaches the
716 // full non-sensitive environment payload plus the reason.
717 if ( is_callable( $this->config['tracker_cb'] ) ) {
718 call_user_func(
719 $this->config['tracker_cb'],
720 'deactivate',
721 array_merge(
722 array(
723 'reason' => $reason,
724 'other_text' => $other_text,
725 'consent_source' => 'deactivation_submit',
726 ),
727 $extra
728 ),
729 // The third argument is do_request()'s $bypass_consent. This is the ONE place that
730 // may pass true: the user pressing Submit in this dialog is the consent for this
731 // single submission, so it must send even when the persistent toggle is off.
732 true
733 );
734 wp_send_json_success();
735 }
736
737 // Fallback (no tracker wired): minimal reason payload with site context.
738 $this->send_raw(
739 array_merge(
740 array(
741 'plugin_slug' => $this->config['plugin_slug'],
742 'site_url' => home_url(),
743 'event' => 'deactivate',
744 'reason' => $reason,
745 'other_text' => $other_text,
746 ),
747 $extra
748 )
749 );
750
751 wp_send_json_success();
752 }
753
754 /**
755 * Fire-and-forget POST of a raw body to the analytics hub. Never blocks deactivation.
756 *
757 * @param array $body Payload to send.
758 */
759 private function send_raw( array $body ): void {
760 $endpoint = defined( 'POSIMYTH_ANALYTICS_ENDPOINT' )
761 ? POSIMYTH_ANALYTICS_ENDPOINT
762 : 'https://api.posimyth.com/wp-json/posimyth/v1/track';
763
764 $json = wp_json_encode( $body );
765
766 // No ingest key / signature — the hub endpoint is open. See the note in
767 // Posimyth_Tracker_Base::do_request() for why a baked shared key authenticated nothing.
768 $headers = array(
769 'Content-Type' => 'application/json',
770 );
771
772 // Same fix as Posimyth_Tracker_Base::do_request(): a 0.01s timeout aborted long before a
773 // real round trip to the hub could finish (~4s measured), so submitted feedback was
774 // silently lost. Send on shutdown — after wp_send_json_success() has already returned the
775 // response and the browser has been redirected to complete the deactivation — with a
776 // timeout that lets the request actually land. Deactivation is never delayed or blocked.
777 $timeout = (int) apply_filters( 'posimyth_analytics_request_timeout', 15 );
778
779 $dispatch = static function () use ( $endpoint, $headers, $json, $timeout ) {
780 if ( function_exists( 'fastcgi_finish_request' ) ) {
781 fastcgi_finish_request();
782 }
783 wp_remote_post(
784 $endpoint,
785 array(
786 'headers' => $headers,
787 'body' => $json,
788 'timeout' => $timeout,
789 'blocking' => true,
790 'data_format' => 'body',
791 'sslverify' => true,
792 )
793 );
794 };
795
796 if ( did_action( 'shutdown' ) ) {
797 $dispatch();
798 return;
799 }
800 add_action( 'shutdown', $dispatch, 99 );
801 }
802 }
803 }
804