PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.2
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.2
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
wpfunnels / includes / core / Tracking / class-wpfnl-telemetry.php

class-wpfnl-telemetry.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.2, at includes/core/Tracking/class-wpfnl-telemetry.php

916 lines 39.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPFunnels\Tracking;
4
5 use LinnoSDK\Telemetry\Client;
6
7 class Telemetry {
8
9 private const POSTHOG_API_KEY = 'phc_lpaTTy4DiOGImfWtE6NK8puO5dZ9y3lum35BxUledi4';
10
11 /** @var Telemetry|null */
12 private static $instance;
13
14 /** @var Client|null */
15 private $client;
16
17 /**
18 * Initialize or return the singleton.
19 *
20 * @return Telemetry|null
21 */
22 public static function init() {
23 if ( ! self::$instance ) {
24 self::$instance = new self();
25 }
26 return self::$instance;
27 }
28
29 /**
30 * Return the already-initialized singleton without creating one.
31 *
32 * @return Telemetry|null
33 */
34 public static function instance(): ?self {
35 return self::$instance;
36 }
37
38 private function __construct() {
39 if ( ! class_exists( '\\LinnoSDK\\Telemetry\\Client' ) ) {
40 return;
41 }
42
43 try {
44 $this->client = new Client( [
45 'pluginFile' => WPFNL_FILE,
46 'slug' => 'wpfunnels',
47 'pluginName' => 'WPFunnels',
48 'version' => WPFNL_VERSION,
49 'driver' => 'posthog',
50 'driver_config' => [
51 'api_key' => self::POSTHOG_API_KEY,
52 'host' => 'https://eu.i.posthog.com',
53 ],
54 'review_prompt' => [
55 'webhook' => 'https://getwpfunnels.com/wp-json/mrm/v1/form/44/webhook/receive?token=b553ed8f2d6f9ff1612b4fe79c19ff9ee8a30c170eb547f1aba9376eb7ac8039',
56 'min_feedback_length' => 50,
57 // Progressive snooze: first dismiss → 30 days, second → 90, third+ → 180.
58 'snooze_schedule' => [ 30, 90, 180 ],
59 'nps_question' => 'How likely are you to recommend WPFunnels to your friends or colleagues?',
60 'low_score_threshold' => 7,
61 'review_url' => 'https://wordpress.org/support/plugin/wpfunnels/reviews/#new-post',
62 'support_url' => 'https://getwpfunnels.com/support/',
63 'privacy_url' => 'https://getwpfunnels.com/privacy-policy/',
64 'installed_option_key' => 'wpfunnels_installed_time',
65 'position' => 'bottom-right',
66 'allowed_screens' => [
67 WPFNL_MAIN_PAGE_SLUG,
68 WPFNL_FUNNEL_PAGE_SLUG,
69 WPFNL_TEMPLATE_PAGE_SLUG,
70 WPFNL_SETTINGS_SLUG,
71 WPFNL_GLOBAL_SETTINGS_SLUG,
72 WPFNL_EDIT_FUNNEL_SLUG,
73 WPFNL_INTEGRATIONS_MAIN_PAGE_SLUG,
74 ],
75 ],
76 ] );
77 } catch ( \Exception $e ) {
78 return;
79 }
80
81 $this->register_triggers();
82 }
83
84 /**
85 * Wire up all WordPress hooks for event tracking.
86 */
87 private function register_triggers(): void {
88 if ( ! $this->client ) {
89 return;
90 }
91
92 try {
93 $this->client->define_triggers( [
94 'onboarding' => [
95 'hook' => 'wpfunnels_setup_wizard_complete',
96 'callback' => [ $this, 'setup_payload' ],
97 ],
98 'feature_used' => [
99 'template_library' => [
100 'hook' => 'wpfunnels_template_imported',
101 'callback' => [ $this, 'template_imported_payload' ],
102 ],
103 'order_bumps' => [
104 'hook' => 'wpfunnels_order_bump_added',
105 'callback' => [ $this, 'feature_usage_payload' ],
106 ],
107 'upsells_downsells' => [
108 'hook' => 'wpfunnels_upsell_configured',
109 'callback' => [ $this, 'feature_usage_payload' ],
110 ],
111 'vertical_canvas_mode' => [
112 'hook' => 'wpfunnels_vertical_mode_used',
113 'callback' => [ $this, 'feature_usage_payload' ],
114 ],
115 ],
116 ] );
117 } catch ( \Exception $e ) {
118 // Failure-safe: trigger registration errors must not surface to callers.
119 }
120
121 // Funnel lifecycle → PostHog.
122 add_action( 'wpfunnels_after_funnel_created', [ $this, 'handle_funnel_created' ], 10, 2 );
123 add_action( 'transition_post_status', [ $this, 'handle_funnel_published' ], 10, 3 );
124
125 // Onboarding step events dispatched from the REST track-step endpoint.
126 // add_action( 'wpfunnels_onboarding_step_tracked', [ $this, 'handle_onboarding_step' ], 10, 1 );
127
128 // Unified onboarding outcome event (single-event model).
129 add_action( 'wpfunnels_onboarding_progress', [ $this, 'handle_onboarding_progress' ], 10, 1 );
130
131 // NPS contextual triggers.
132 add_action( 'wpfunnels_setup_wizard_complete', [ $this, 'maybe_trigger_onboarding_nps' ], 20, 3 );
133 add_action( 'wpfunnels_after_funnel_created', [ $this, 'maybe_trigger_first_funnel_nps' ], 20, 2 );
134 add_action( 'wpfunnels_template_imported', [ $this, 'maybe_trigger_first_funnel_nps' ], 20, 1 );
135
136 // Per-funnel Aha milestone — wired directly because SDK KUI thresholding
137 // is site-scoped and cannot enforce the funnel-level single-fire guarantee.
138 add_action( 'wpfunnels/funnel_order_placed', [ $this, 'handle_aha_milestone' ], 10, 3 );
139
140 // Replace generic SDK deactivation reasons with WPFunnels-specific ones.
141 add_filter( 'wpfunnels_telemetry_deactivation_reasons', [ $this, 'filter_deactivation_reasons' ] );
142
143 // Swap the SDK's shared deactivation modal for WPFunnels' own isolated implementation.
144 // The SDK's modal uses generic CSS class names (wd-dr-*, wd-de-*) that conflict with
145 // mail-mint and wpvr when all plugins are active. WPFunnels' modal uses wpfnl-dr-/wpfnl-de-
146 // prefixed classes and one-click behavior, keeping it fully independent.
147 //
148 // We cannot call $this->client->get_deactivation() because another plugin (mail-mint,
149 // wpvr) may load its own Client.php first — only one class definition wins in PHP.
150 // Instead we scan $wp_filter at priority 0 and identify WPFunnels' Deactivation
151 // instance by the plugin_action_links hook it registers for WPFNL_FILE.
152 add_action( 'admin_footer', [ $this, 'remove_sdk_deactivation_modal' ], 0 );
153 add_action( 'admin_footer', [ $this, 'render_deactivation_modal' ] );
154 }
155
156 // -------------------------------------------------------------------------
157 // Public API — called from REST controllers
158 // -------------------------------------------------------------------------
159
160 /**
161 * Persist the user's consent decision and sync the telemetry client.
162 *
163 * Must be called as early as possible (Welcome step proceed) so subsequent
164 * step-tracking calls pass the opt-in gate inside the SDK.
165 *
166 * @param bool $consented True when the user checked the data-sharing box.
167 */
168 public function save_consent( bool $consented ): void {
169 if ( ! $this->client ) {
170 return;
171 }
172 $this->client->set_optin_state( $consented ? 'yes' : 'no' );
173 }
174
175 /**
176 * Read the stored consent decision.
177 *
178 * Delegates to the SDK, which also migrates the pre-Linno Appsero keys
179 * (`wpfunnels_allow_tracking` and friends) onto the shared Linno option — so
180 * a site that consented through an older setup wizard still reports 'yes'
181 * here. When the SDK is unavailable we read those same keys directly rather
182 * than reporting "no decision" and silently losing an existing opt-in.
183 *
184 * @return string|null 'yes', 'no', or null when the user has never decided.
185 */
186 public function get_consent_state(): ?string {
187 if ( $this->client ) {
188 return $this->client->get_optin_state();
189 }
190
191 foreach ( [ 'linno_telemetry_allow_tracking', WPFNL_SLUG . '_allow_tracking' ] as $option_key ) {
192 $value = get_option( $option_key, null );
193
194 if ( is_string( $value ) ) {
195 $value = strtolower( trim( $value ) );
196
197 if ( 'yes' === $value || 'no' === $value ) {
198 return $value;
199 }
200 }
201 }
202
203 return null;
204 }
205
206 /**
207 * Whether the user has actively opted in to usage tracking.
208 *
209 * @return bool
210 */
211 public function has_consent(): bool {
212 return 'yes' === $this->get_consent_state();
213 }
214
215 /**
216 * Dispatch a step-view or step-completion event from the REST layer.
217 *
218 * @param array $data Keys: event_type (viewed|completed), step_name, step_index, goal, time_on_step.
219 */
220 public function track_onboarding_step( array $data ): void {
221 do_action( 'wpfunnels_onboarding_step_tracked', $data );
222 }
223
224 /**
225 * Dispatch the unified onboarding outcome event from the REST layer.
226 *
227 * @param array $data Keys: outcome, last_step_name, last_step_index, total_steps, goal, funnel_id.
228 */
229 public function track_onboarding_progress( array $data ): void {
230 do_action( 'wpfunnels_onboarding_progress', $data );
231 }
232
233 // -------------------------------------------------------------------------
234 // Payload callbacks
235 // -------------------------------------------------------------------------
236
237 /**
238 * Onboarding completion payload — sent once when the wizard finishes.
239 *
240 * @param int $funnel_id Funnel ID created during setup.
241 * @param string $action Button clicked at the Complete step (e.g. goToDashboard).
242 * @param string $goal Goal the user chose (order-value | improve-checkout | sales).
243 *
244 * @return array<string,mixed>
245 */
246 public function setup_payload( int $_funnel_id = 0, string $action = '', string $goal = '' ): array {
247 $start_time = (int) get_option( 'wpfunnels_onboarding_start_time', 0 );
248 $elapsed = $start_time ? ( time() - $start_time ) : 0;
249
250 return [
251 'action' => sanitize_text_field( $action ),
252 'goal' => sanitize_text_field( $goal ),
253 'time_to_complete_seconds' => $elapsed,
254 '__timestamp' => $this->current_timestamp(),
255 ];
256 }
257
258 /**
259 * Feature usage payload for order bumps and upsells.
260 *
261 * @param int|string $funnel_id Funnel ID.
262 * @param int|string $step_id Step ID (0 when not applicable).
263 * @param mixed ...$_extra Additional hook args silently ignored.
264 *
265 * @return array<string,mixed>
266 */
267 public function feature_usage_payload( $funnel_id = 0, $step_id = 0, ...$_extra ): array {
268 return [
269 'funnel_id' => absint( $funnel_id ),
270 '__timestamp' => $this->current_timestamp(),
271 ];
272 }
273
274 /**
275 * Feature usage payload for template imports — includes template context.
276 *
277 * @param int|string $funnel_id Funnel ID.
278 * @param int|string $template_id Template ID.
279 * @param mixed ...$_extra Additional hook args silently ignored.
280 *
281 * @return array<string,mixed>
282 */
283 public function template_imported_payload( $funnel_id = 0, $template_id = 0, ...$_extra ): array {
284 return [
285 'funnel_id' => absint( $funnel_id ),
286 'template_id' => absint( $template_id ),
287 '__timestamp' => $this->current_timestamp(),
288 ];
289 }
290
291 // -------------------------------------------------------------------------
292 // Funnel lifecycle handlers
293 // -------------------------------------------------------------------------
294
295 /**
296 * Track funnel creation in PostHog.
297 *
298 * Hooked on wpfunnels_after_funnel_created (same hook used by EventTracker
299 * for the legacy first_strike event).
300 *
301 * @param int|string $funnel_id Funnel ID.
302 * @param string $funnel_type Funnel type identifier.
303 */
304 public function handle_funnel_created( $funnel_id, $funnel_type ): void {
305 if ( ! $this->client ) {
306 return;
307 }
308 try {
309 $this->client->track( 'activation/funnel_created', [
310 'funnel_id' => absint( $funnel_id ),
311 'funnel_type' => sanitize_text_field( (string) $funnel_type ),
312 '__timestamp' => $this->current_timestamp(),
313 ] );
314 } catch ( \Exception $e ) {
315 // Failure-safe.
316 }
317 }
318
319 /**
320 * Track funnel publish transition and fire the wpfunnels_funnel_published hook.
321 *
322 * Listens on transition_post_status for all post types — returns early for
323 * anything that is not a wpfunnels post moving into publish status.
324 *
325 * @param string $new_status Post status after transition.
326 * @param string $old_status Post status before transition.
327 * @param \WP_Post $post The post being transitioned.
328 */
329 public function handle_funnel_published( string $new_status, string $old_status, \WP_Post $post ): void {
330 if ( 'publish' !== $new_status || 'publish' === $old_status ) {
331 return;
332 }
333 if ( 'wpfunnels' !== $post->post_type ) {
334 return;
335 }
336 if ( ! $this->client ) {
337 return;
338 }
339
340 $created_ts = strtotime( $post->post_date_gmt );
341 $days_created = $created_ts ? round( ( time() - $created_ts ) / DAY_IN_SECONDS, 1 ) : 0;
342
343 try {
344 $this->client->track( 'activation/funnel_published', [
345 'funnel_id' => $post->ID,
346 'funnel_type' => get_post_meta( $post->ID, '_wpfnl_funnel_type', true ) ?: 'unknown',
347 'days_since_created' => $days_created,
348 '__timestamp' => $this->current_timestamp(),
349 ] );
350 } catch ( \Exception $e ) {
351 // Failure-safe.
352 }
353 }
354
355 // -------------------------------------------------------------------------
356 // Onboarding step handlers
357 // -------------------------------------------------------------------------
358
359 /**
360 * Send a step_viewed or step_completed PostHog event.
361 *
362 * Fires when wpfunnels_onboarding_step_tracked is dispatched from the REST
363 * track-step endpoint.
364 *
365 * @param array $data {
366 * @type string $event_type 'viewed' or 'completed'
367 * @type string $step_name Step slug (e.g. welcome, choose_goal)
368 * @type int $step_index 1-based step number
369 * @type string $goal Selected goal or empty string
370 * @type int $time_on_step Seconds spent on the step before proceeding
371 * }
372 */
373 public function handle_onboarding_step( array $data ): void {
374 if ( ! $this->client ) {
375 return;
376 }
377
378 $event_type = sanitize_key( $data['event_type'] ?? 'viewed' );
379 $event_map = [
380 'viewed' => 'onboarding/step_viewed',
381 'completed' => 'onboarding/step_completed',
382 ];
383
384 $event = $event_map[ $event_type ] ?? null;
385 if ( ! $event ) {
386 return;
387 }
388
389 try {
390 $this->client->track( $event, [
391 'step_name' => sanitize_key( $data['step_name'] ?? '' ),
392 'step_index' => absint( $data['step_index'] ?? 0 ),
393 'goal' => sanitize_text_field( $data['goal'] ?? '' ),
394 'time_on_step' => absint( $data['time_on_step'] ?? 0 ),
395 '__timestamp' => $this->current_timestamp(),
396 ] );
397 } catch ( \Exception $e ) {
398 // Failure-safe.
399 }
400
401 // Stamp the wizard start time on first step view so setup_payload can
402 // compute time_to_complete_seconds.
403 if ( 1 === absint( $data['step_index'] ?? 0 ) && 'viewed' === $event_type ) {
404 if ( ! get_option( 'wpfunnels_onboarding_start_time' ) ) {
405 update_option( 'wpfunnels_onboarding_start_time', time(), false );
406 }
407 }
408 }
409
410 /**
411 * Send the unified onboarding_progress PostHog event.
412 *
413 * Single-event model that captures the full onboarding outcome in one event.
414 * Fires for all three terminal outcomes: completed, skipped, and exited.
415 * Consent is enforced by the SDK's opt-in gate on client->track().
416 *
417 * Fires once per wizard session — guarded by a wp_options flag to prevent
418 * duplicate events from redirects or re-renders.
419 *
420 * @param array $data {
421 * @type string $outcome Terminal outcome: 'completed', 'skipped', or 'exited'
422 * @type string $last_step_name Slug of the last step reached (e.g. 'choose_goal').
423 * Aliases: 'step_name' (from the abandoned path).
424 * @type int $last_step_index 1-based index of the last step.
425 * Aliases: 'step_index' (from the abandoned path).
426 * @type int $total_steps Total steps in the wizard for this goal path
427 * @type string $goal Selected goal slug, or '' if not yet chosen
428 * @type int $funnel_id ID of funnel created (0 if none created yet)
429 * }
430 */
431 public function handle_onboarding_progress( array $data ): void {
432 if ( ! $this->client ) {
433 return;
434 }
435
436 $outcome = sanitize_key( $data['outcome'] ?? '' );
437 if ( ! in_array( $outcome, [ 'completed', 'skipped', 'exited' ], true ) ) {
438 return;
439 }
440
441 // Prevent duplicate fires within the same wizard session.
442 if ( get_option( 'wpfunnels_onboarding_progress_tracked' ) ) {
443 return;
444 }
445
446 // Support both field name conventions: abandoned path uses step_name/step_index,
447 // completion path uses last_step_name/last_step_index.
448 $last_step_name = sanitize_key( $data['last_step_name'] ?? $data['step_name'] ?? '' );
449 $last_step_index = absint( $data['last_step_index'] ?? $data['step_index'] ?? 0 );
450
451 $start_time = (int) get_option( 'wpfunnels_onboarding_start_time', 0 );
452
453 try {
454 $this->client->track( 'onboarding_progress', [
455 'outcome' => $outcome,
456 'last_step_name' => $last_step_name,
457 'last_step_index' => $last_step_index,
458 'total_steps' => absint( $data['total_steps'] ?? 0 ),
459 'goal' => sanitize_text_field( $data['goal'] ?? '' ),
460 'time_to_exit_seconds' => $start_time ? ( time() - $start_time ) : 0,
461 'funnel_id' => absint( $data['funnel_id'] ?? 0 ),
462 'plugin_version' => WPFNL_VERSION,
463 '__timestamp' => $this->current_timestamp(),
464 ] );
465
466 // Mark as fired so re-renders / redirects cannot double-fire.
467 update_option( 'wpfunnels_onboarding_progress_tracked', '1', false );
468 } catch ( \Exception $e ) {
469 // Failure-safe.
470 }
471 }
472
473 /**
474 * Queue an NPS prompt after the setup wizard completes.
475 *
476 * Overrides the default NPS copy with setup-specific messaging so the
477 * question is contextually relevant to the just-finished onboarding.
478 * Runs at priority 20 on wpfunnels_setup_wizard_complete (after telemetry
479 * tracking at priority 10).
480 *
481 * @param int $funnel_id Funnel ID.
482 * @param string $action Completion action.
483 * @param string $goal Selected goal.
484 */
485 public function maybe_trigger_onboarding_nps( int $funnel_id = 0, string $action = '', string $goal = '' ): void {
486 if ( get_option( 'wpfunnels_wizard_nps_triggered' ) ) {
487 return;
488 }
489 $funnel_tyep = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true ) ?: 'woo';
490 $nps_question = 'How easy was it to set up your first funnel with WPFunnels?';
491 $feedback_msg = "We're sorry setup was difficult. What got in your way?";
492 if ( 'store_checkout' === $funnel_tyep ) {
493 $nps_question = 'How likely are you to recommend WPFunnels to your friends or colleagues based on your checkout setup experience?';
494 }
495
496 $review_prompt = $this->get_review_prompt_instance();
497 if ( ! $review_prompt ) {
498 return;
499 }
500 update_option( 'wpfunnels_wizard_nps_triggered', '1' );
501 $review_prompt->trigger_prompt( 'onboarding_complete', [
502 'modal_title' => 'How was your setup experience?',
503 'nps_question' => $nps_question,
504 'feedback_msg' => $feedback_msg,
505 ] );
506 }
507
508 /**
509 * Queue an NPS prompt after the user creates their first funnel (wizard or non-wizard).
510 *
511 * Fires once — guarded by a wp_options flag. For wizard users the wizard-complete
512 * NPS (maybe_trigger_onboarding_nps) will overwrite this trigger context on the
513 * same page-load cycle, so wizard users see the more contextual wizard copy.
514 * Non-wizard users see the first-funnel copy.
515 *
516 * @param int|string $funnel_id Newly created funnel ID.
517 * @param string $funnel_type Funnel type identifier.
518 */
519 public function maybe_trigger_first_funnel_nps( $funnel_id, $funnel_type = '' ): void {
520 if ( get_option( 'wpfunnels_first_funnel_nps_triggered' ) ) {
521 return;
522 }
523 $review_prompt = $this->get_review_prompt_instance();
524 if ( ! $review_prompt ) {
525 return;
526 }
527 $funnel_tyep = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true ) ?: 'woo';
528 $nps_question = 'How easy was it to create your first funnel with WPFunnels?';
529 $feedback_msg = "We're sorry to hear that. What got in your way?";
530 if ( 'store_checkout' === $funnel_tyep ) {
531 $nps_question = 'How likely are you to recommend WPFunnels to your friends or colleagues based on your checkout setup experience?';
532 }
533
534 update_option( 'wpfunnels_first_funnel_nps_triggered', '1' );
535 $review_prompt->trigger_prompt( 'first_funnel_created', [
536 'modal_title' => 'You created your first funnel!',
537 'nps_question' => $nps_question,
538 'feedback_msg' => $feedback_msg,
539 ] );
540 }
541
542 // -------------------------------------------------------------------------
543 // Helpers
544 // -------------------------------------------------------------------------
545
546 /**
547 * Return the ReviewPrompt instance safely, guarding against stale PHP opcache
548 * that may not yet have the get_review_prompt() method available.
549 *
550 * @return \LinnoSDK\Telemetry\ReviewPrompt|null
551 */
552 private function get_review_prompt_instance(): ?\LinnoSDK\Telemetry\ReviewPrompt {
553 if ( ! $this->client ) {
554 return null;
555 }
556 try {
557 return $this->client->get_review_prompt();
558 } catch ( \Throwable $e) {
559 return null;
560 }
561 }
562
563 // -------------------------------------------------------------------------
564 // Deactivation feedback
565 // -------------------------------------------------------------------------
566
567 /**
568 * Find and remove the SDK's admin_footer deactivation modal for WPFunnels.
569 *
570 * Runs at priority 0 so it fires before the SDK's default priority-10 callback.
571 * We cannot use get_deactivation() on Client because another plugin may have
572 * loaded its own Client.php first (PHP only keeps the first class definition).
573 * Instead we identify WPFunnels' Deactivation instance by the plugin_action_links
574 * hook it uniquely registers for WPFNL_FILE, then remove its admin_footer callback.
575 */
576 public function remove_sdk_deactivation_modal() {
577 global $wp_filter;
578
579 if ( ! isset( $wp_filter['admin_footer'] ) ) {
580 return;
581 }
582
583 $links_hook = 'plugin_action_links_' . plugin_basename( WPFNL_FILE );
584
585 foreach ( $wp_filter['admin_footer']->callbacks as $priority => $callbacks ) {
586 foreach ( $callbacks as $cb ) {
587 $fn = $cb['function'];
588 if ( ! is_array( $fn ) || ! ( $fn[0] instanceof \LinnoSDK\Telemetry\Deactivation ) ) {
589 continue;
590 }
591 if ( has_filter( $links_hook, [ $fn[0], 'filter_plugin_action_links' ] ) ) {
592 remove_action( 'admin_footer', [ $fn[0], 'add_deactivation_feedback_modal' ], $priority );
593 return;
594 }
595 }
596 }
597 }
598
599 /**
600 * Render WPFunnels' own deactivation modal on the plugins.php page.
601 *
602 * This replaces the SDK's generic modal (unhooked via remove_sdk_deactivation_modal()) so
603 * WPFunnels uses isolated wpfnl-dr-/wpfnl-de- CSS class names and one-click
604 * UX without conflicting with mail-mint or wpvr modals on the same page.
605 * The SDK's plugin_action_links filter and AJAX handler remain active.
606 */
607 public function render_deactivation_modal() {
608 global $pagenow;
609 if ( 'plugins.php' !== $pagenow ) {
610 return;
611 }
612
613 $nonce = wp_create_nonce( 'wpfunnels-deactivation-nonce' );
614 $reasons = apply_filters( 'wpfunnels_telemetry_deactivation_reasons', [] );
615
616 if ( empty( $reasons ) ) {
617 return;
618 }
619 ?>
620 <style type="text/css">
621 .wpfnl-dr-modal {
622 position: fixed;
623 z-index: 99999;
624 top: 0; right: 0; bottom: 0; left: 0;
625 background: rgba(0,0,0,0.5);
626 display: none;
627 box-sizing: border-box;
628 overflow: scroll;
629 }
630 .wpfnl-dr-modal * { box-sizing: border-box; }
631 .wpfnl-dr-modal.modal-active { display: block; }
632 .wpfnl-dr-modal-wrap {
633 max-width: 780px;
634 width: 100%;
635 position: relative;
636 margin: 8% auto;
637 background: #fff;
638 border-radius: 8px;
639 box-shadow: 0 8px 32px rgba(0,0,0,0.18);
640 }
641 .wpfnl-dr-modal-header {
642 border-bottom: 1px solid #E8E8E8;
643 padding: 24px 24px 16px 24px;
644 }
645 .wpfnl-dr-modal-header h3 {
646 line-height: 1.5;
647 margin: 0 0 4px 0;
648 color: #1a1a2e;
649 font-size: 16px;
650 font-weight: 600;
651 }
652 .wpfnl-dr-modal-subheading { margin: 0; color: #718096; font-size: 13px; }
653 .wpfnl-dr-modal-body { padding: 20px 24px 8px 24px; }
654 .wpfnl-dr-modal-footer {
655 border-top: 1px solid #E8E8E8;
656 padding: 16px 24px;
657 display: flex;
658 align-items: center;
659 justify-content: flex-end;
660 }
661 ul.wpfnl-de-reasons {
662 display: flex;
663 flex-wrap: wrap;
664 margin: 0 -6px 12px -6px;
665 padding: 0;
666 list-style: none;
667 }
668 ul.wpfnl-de-reasons li { padding: 0 6px; margin: 0 0 12px 0; width: 25%; }
669 .wpfnl-de-reason-btn {
670 width: 100%;
671 border: 1.5px solid #E8E8E8;
672 border-radius: 6px;
673 background: #fff;
674 cursor: pointer;
675 text-align: center;
676 padding: 14px 8px 12px 8px;
677 transition: border-color 0.15s, background 0.15s, transform 0.1s;
678 display: flex;
679 flex-direction: column;
680 align-items: center;
681 gap: 8px;
682 height: 100%;
683 }
684 .wpfnl-de-reason-btn:hover {
685 border-color: #7B68EE;
686 background: #f5f3ff;
687 transform: translateY(-1px);
688 }
689 .wpfnl-de-reason-btn:active { transform: translateY(0); }
690 .wpfnl-de-reason-btn:disabled { cursor: default; opacity: 0.6; }
691 .wpfnl-de-reason-text { color: #4A5568; font-size: 12px; line-height: 1.4; }
692 .wpfnl-de-reason-icon { display: flex; align-items: center; justify-content: center; }
693 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-btn {
694 background: #7B68EE;
695 border-color: #7B68EE;
696 }
697 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-icon svg,
698 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-icon svg g { fill: #fff; }
699 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-text { color: #fff; }
700 .wpfnl-dr-btn-cancel,
701 .wpfnl-dr-btn-cancel:hover {
702 border: 1px solid #EBEBEB;
703 border-radius: 4px;
704 font-size: 13px;
705 line-height: 1.5;
706 color: #718096;
707 padding: 6px 14px;
708 cursor: pointer;
709 background-color: transparent;
710 text-decoration: none;
711 }
712 </style>
713
714 <div class="wpfnl-dr-modal" id="wpfunnels-dr-modal">
715 <div class="wpfnl-dr-modal-wrap">
716 <div class="wpfnl-dr-modal-header">
717 <h3><?php esc_html_e( "Quick question before you go — what's the main reason?", 'wpfnl' ); ?></h3>
718 <p class="wpfnl-dr-modal-subheading"><?php esc_html_e( 'One click is all it takes. Your feedback helps us improve.', 'wpfnl' ); ?></p>
719 </div>
720 <div class="wpfnl-dr-modal-body">
721 <ul class="wpfnl-de-reasons">
722 <?php foreach ( $reasons as $reason ) : ?>
723 <li data-reason-id="<?php echo esc_attr( $reason['id'] ); ?>">
724 <button type="button" class="wpfnl-de-reason-btn">
725 <div class="wpfnl-de-reason-icon"><?php echo $reason['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
726 <div class="wpfnl-de-reason-text"><?php echo esc_html( $reason['text'] ); ?></div>
727 </button>
728 </li>
729 <?php endforeach; ?>
730 </ul>
731 </div>
732 <div class="wpfnl-dr-modal-footer">
733 <button type="button" class="wpfnl-dr-btn-cancel"><?php esc_html_e( 'Cancel', 'wpfnl' ); ?></button>
734 </div>
735 </div>
736 </div>
737
738 <script type="text/javascript">
739 (function($) {
740 $(function() {
741 var modal = $('#wpfunnels-dr-modal');
742 var deactivateLink = '';
743 var submitting = false;
744
745 $('#the-list').on('click', 'a.wpfunnels-deactivation-link', function(e) {
746 e.preventDefault();
747 deactivateLink = $(this).attr('href');
748 submitting = false;
749 modal.find('.wpfnl-de-reason-btn').prop('disabled', false);
750 modal.find('li').removeClass('wpfnl-de-reason-selected');
751 modal.addClass('modal-active');
752 });
753
754 modal.on('click', 'button.wpfnl-dr-btn-cancel', function(e) {
755 e.preventDefault();
756 modal.removeClass('modal-active');
757 });
758
759 modal.on('click', '.wpfnl-de-reason-btn', function() {
760 if (submitting || !deactivateLink) return;
761 submitting = true;
762
763 var reasonId = $(this).closest('li').data('reason-id');
764 $(this).closest('li').addClass('wpfnl-de-reason-selected');
765 modal.find('.wpfnl-de-reason-btn').prop('disabled', true);
766
767 $.ajax({
768 url: ajaxurl,
769 type: 'POST',
770 data: {
771 nonce: '<?php echo esc_js( $nonce ); ?>',
772 action: 'wpfunnels_submit_deactivation_reason',
773 reason_id: reasonId,
774 reason_info: ''
775 },
776 complete: function() {
777 window.location.href = deactivateLink;
778 }
779 });
780 });
781 });
782 }(jQuery));
783 </script>
784 <?php
785 }
786
787 /**
788 * Replace the generic SDK deactivation reasons with WPFunnels-specific ones.
789 *
790 * Hooked on wpfunnels_telemetry_deactivation_reasons. Called by render_deactivation_modal()
791 * via apply_filters to populate WPFunnels' own modal.
792 *
793 * @param array $reasons Default reasons from the SDK.
794 * @return array
795 */
796 public function filter_deactivation_reasons( array $reasons ): array {
797 return [
798
799 [
800 'id' => 'plugin-conflict',
801 'text' => __( 'Plugin conflict', 'wpfnl' ),
802 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke="#3B86FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
803 ],
804 [
805 'id' => 'payment-not-working',
806 'text' => __( 'Payment not working', 'wpfnl' ),
807 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="#3B86FF" stroke-width="2"/><path d="M12 7v5l3 3" stroke="#3B86FF" stroke-width="2" stroke-linecap="round"/></svg>',
808 ],
809 [
810 'id' => 'funnel-builder-hard-to-use',
811 'text' => __( 'Funnel builder too complex', 'wpfnl' ),
812 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="3" stroke="#3B86FF" stroke-width="2"/><path d="M9 9h6M9 12h4" stroke="#3B86FF" stroke-width="2" stroke-linecap="round"/></svg>',
813 ],
814 [
815 'id' => 'checkout-not-working',
816 'text' => __( 'Checkout not working', 'wpfnl' ),
817 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="#3B86FF" stroke-width="2"/><path d="M12 7v5l3 3" stroke="#3B86FF" stroke-width="2" stroke-linecap="round"/></svg>',
818 ],
819 [
820 'id' => 'upsell-downsell-not-working',
821 'text' => __( 'Upsell / downsell issues', 'wpfnl' ),
822 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M3 17l4-4 4 4 4-8 4 4" stroke="#3B86FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
823 ],
824 [
825 'id' => 'missing-feature',
826 'text' => __( 'Missing a feature I need', 'wpfnl' ),
827 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="#3B86FF" stroke-width="2"/><path d="M12 8v4M12 16h.01" stroke="#3B86FF" stroke-width="2" stroke-linecap="round"/></svg>',
828 ],
829 [
830 'id' => 'template-not-good-enough',
831 'text' => __( 'Templates are not good enough', 'wpfnl' ),
832 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M12 5l7 7-7 7" stroke="#3B86FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
833 ],
834 [
835 'id' => 'lacking-payment-gateway',
836 'text' => __( 'Lack of payment gateway options', 'wpfnl' ),
837 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="#3B86FF" stroke-width="2"/><path d="M12 8v4M12 16h.01" stroke="#3B86FF" stroke-width="2" stroke-linecap="round"/></svg>',
838 ],
839 [
840 'id' => 'found-better-plugin',
841 'text' => __( 'Switching to another plugin', 'wpfnl' ),
842 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M5 12h14M12 5l7 7-7 7" stroke="#3B86FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
843 ],
844 [
845 'id' => 'temporary',
846 'text' => __( 'Temporarily deactivating', 'wpfnl' ),
847 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="#3B86FF" stroke-width="2"/><path d="M12 6v6l4 2" stroke="#3B86FF" stroke-width="2" stroke-linecap="round"/></svg>',
848 ],
849 ];
850 }
851
852 // -------------------------------------------------------------------------
853 // Aha milestone (per-funnel first live sale)
854 // -------------------------------------------------------------------------
855
856 /**
857 * Fire the Aha milestone event exactly once per funnel on the first order.
858 *
859 * @param int|string $order_id Order ID.
860 * @param int|string $funnel_id Funnel ID.
861 * @param int|string $checkout_id Checkout step ID.
862 */
863 public function handle_aha_milestone( $order_id, $funnel_id, $checkout_id ): void {
864 $funnel_id = absint( $funnel_id );
865 if ( ! $funnel_id ) {
866 return;
867 }
868 if ( $this->has_aha_completed( $funnel_id ) ) {
869 return;
870 }
871 try {
872 if ( $this->client ) {
873 $this->client->track_kui( 'first_live_sale', $this->aha_payload( $order_id, $funnel_id ) );
874 }
875 } catch ( \Exception $e ) {
876 // Failure-safe: telemetry errors must never block order processing.
877 }
878 $this->mark_aha_completed( $funnel_id, $order_id );
879 }
880
881 /**
882 * @param int $funnel_id Funnel post ID.
883 * @return bool
884 */
885 public function has_aha_completed( int $funnel_id ): bool {
886 return (bool) get_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', true );
887 }
888
889 /**
890 * @param int $funnel_id Funnel post ID.
891 * @param int|string $order_id Order that triggered the milestone.
892 */
893 public function mark_aha_completed( int $funnel_id, $order_id ): void {
894 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', '1' );
895 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_at', $this->current_timestamp() );
896 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_order', absint( $order_id ) );
897 }
898
899 /**
900 * @param int|string $order_id Order ID.
901 * @param int $funnel_id Funnel ID.
902 * @return array<string,mixed>
903 */
904 public function aha_payload( $order_id, int $funnel_id ): array {
905 return [
906 'funnel_id' => $funnel_id,
907 '__timestamp' => $this->current_timestamp(),
908 ];
909 }
910
911 /** @return string ISO-8601 UTC timestamp. */
912 private function current_timestamp(): string {
913 return gmdate( 'c' );
914 }
915 }
916