PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.12.5
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.12.5
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.12.5, at includes/core/Tracking/class-wpfnl-telemetry.php

876 lines 38.0 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 * Dispatch a step-view or step-completion event from the REST layer.
177 *
178 * @param array $data Keys: event_type (viewed|completed), step_name, step_index, goal, time_on_step.
179 */
180 public function track_onboarding_step( array $data ): void {
181 do_action( 'wpfunnels_onboarding_step_tracked', $data );
182 }
183
184 /**
185 * Dispatch the unified onboarding outcome event from the REST layer.
186 *
187 * @param array $data Keys: outcome, last_step_name, last_step_index, total_steps, goal, funnel_id.
188 */
189 public function track_onboarding_progress( array $data ): void {
190 do_action( 'wpfunnels_onboarding_progress', $data );
191 }
192
193 // -------------------------------------------------------------------------
194 // Payload callbacks
195 // -------------------------------------------------------------------------
196
197 /**
198 * Onboarding completion payload — sent once when the wizard finishes.
199 *
200 * @param int $funnel_id Funnel ID created during setup.
201 * @param string $action Button clicked at the Complete step (e.g. goToDashboard).
202 * @param string $goal Goal the user chose (order-value | improve-checkout | sales).
203 *
204 * @return array<string,mixed>
205 */
206 public function setup_payload( int $_funnel_id = 0, string $action = '', string $goal = '' ): array {
207 $start_time = (int) get_option( 'wpfunnels_onboarding_start_time', 0 );
208 $elapsed = $start_time ? ( time() - $start_time ) : 0;
209
210 return [
211 'action' => sanitize_text_field( $action ),
212 'goal' => sanitize_text_field( $goal ),
213 'time_to_complete_seconds' => $elapsed,
214 '__timestamp' => $this->current_timestamp(),
215 ];
216 }
217
218 /**
219 * Feature usage payload for order bumps and upsells.
220 *
221 * @param int|string $funnel_id Funnel ID.
222 * @param int|string $step_id Step ID (0 when not applicable).
223 * @param mixed ...$_extra Additional hook args silently ignored.
224 *
225 * @return array<string,mixed>
226 */
227 public function feature_usage_payload( $funnel_id = 0, $step_id = 0, ...$_extra ): array {
228 return [
229 'funnel_id' => absint( $funnel_id ),
230 '__timestamp' => $this->current_timestamp(),
231 ];
232 }
233
234 /**
235 * Feature usage payload for template imports — includes template context.
236 *
237 * @param int|string $funnel_id Funnel ID.
238 * @param int|string $template_id Template ID.
239 * @param mixed ...$_extra Additional hook args silently ignored.
240 *
241 * @return array<string,mixed>
242 */
243 public function template_imported_payload( $funnel_id = 0, $template_id = 0, ...$_extra ): array {
244 return [
245 'funnel_id' => absint( $funnel_id ),
246 'template_id' => absint( $template_id ),
247 '__timestamp' => $this->current_timestamp(),
248 ];
249 }
250
251 // -------------------------------------------------------------------------
252 // Funnel lifecycle handlers
253 // -------------------------------------------------------------------------
254
255 /**
256 * Track funnel creation in PostHog.
257 *
258 * Hooked on wpfunnels_after_funnel_created (same hook used by EventTracker
259 * for the legacy first_strike event).
260 *
261 * @param int|string $funnel_id Funnel ID.
262 * @param string $funnel_type Funnel type identifier.
263 */
264 public function handle_funnel_created( $funnel_id, $funnel_type ): void {
265 if ( ! $this->client ) {
266 return;
267 }
268 try {
269 $this->client->track( 'activation/funnel_created', [
270 'funnel_id' => absint( $funnel_id ),
271 'funnel_type' => sanitize_text_field( (string) $funnel_type ),
272 '__timestamp' => $this->current_timestamp(),
273 ] );
274 } catch ( \Exception $e ) {
275 // Failure-safe.
276 }
277 }
278
279 /**
280 * Track funnel publish transition and fire the wpfunnels_funnel_published hook.
281 *
282 * Listens on transition_post_status for all post types — returns early for
283 * anything that is not a wpfunnels post moving into publish status.
284 *
285 * @param string $new_status Post status after transition.
286 * @param string $old_status Post status before transition.
287 * @param \WP_Post $post The post being transitioned.
288 */
289 public function handle_funnel_published( string $new_status, string $old_status, \WP_Post $post ): void {
290 if ( 'publish' !== $new_status || 'publish' === $old_status ) {
291 return;
292 }
293 if ( 'wpfunnels' !== $post->post_type ) {
294 return;
295 }
296 if ( ! $this->client ) {
297 return;
298 }
299
300 $created_ts = strtotime( $post->post_date_gmt );
301 $days_created = $created_ts ? round( ( time() - $created_ts ) / DAY_IN_SECONDS, 1 ) : 0;
302
303 try {
304 $this->client->track( 'activation/funnel_published', [
305 'funnel_id' => $post->ID,
306 'funnel_type' => get_post_meta( $post->ID, '_wpfnl_funnel_type', true ) ?: 'unknown',
307 'days_since_created' => $days_created,
308 '__timestamp' => $this->current_timestamp(),
309 ] );
310 } catch ( \Exception $e ) {
311 // Failure-safe.
312 }
313 }
314
315 // -------------------------------------------------------------------------
316 // Onboarding step handlers
317 // -------------------------------------------------------------------------
318
319 /**
320 * Send a step_viewed or step_completed PostHog event.
321 *
322 * Fires when wpfunnels_onboarding_step_tracked is dispatched from the REST
323 * track-step endpoint.
324 *
325 * @param array $data {
326 * @type string $event_type 'viewed' or 'completed'
327 * @type string $step_name Step slug (e.g. welcome, choose_goal)
328 * @type int $step_index 1-based step number
329 * @type string $goal Selected goal or empty string
330 * @type int $time_on_step Seconds spent on the step before proceeding
331 * }
332 */
333 public function handle_onboarding_step( array $data ): void {
334 if ( ! $this->client ) {
335 return;
336 }
337
338 $event_type = sanitize_key( $data['event_type'] ?? 'viewed' );
339 $event_map = [
340 'viewed' => 'onboarding/step_viewed',
341 'completed' => 'onboarding/step_completed',
342 ];
343
344 $event = $event_map[ $event_type ] ?? null;
345 if ( ! $event ) {
346 return;
347 }
348
349 try {
350 $this->client->track( $event, [
351 'step_name' => sanitize_key( $data['step_name'] ?? '' ),
352 'step_index' => absint( $data['step_index'] ?? 0 ),
353 'goal' => sanitize_text_field( $data['goal'] ?? '' ),
354 'time_on_step' => absint( $data['time_on_step'] ?? 0 ),
355 '__timestamp' => $this->current_timestamp(),
356 ] );
357 } catch ( \Exception $e ) {
358 // Failure-safe.
359 }
360
361 // Stamp the wizard start time on first step view so setup_payload can
362 // compute time_to_complete_seconds.
363 if ( 1 === absint( $data['step_index'] ?? 0 ) && 'viewed' === $event_type ) {
364 if ( ! get_option( 'wpfunnels_onboarding_start_time' ) ) {
365 update_option( 'wpfunnels_onboarding_start_time', time(), false );
366 }
367 }
368 }
369
370 /**
371 * Send the unified onboarding_progress PostHog event.
372 *
373 * Single-event model that captures the full onboarding outcome in one event.
374 * Fires for all three terminal outcomes: completed, skipped, and exited.
375 * Consent is enforced by the SDK's opt-in gate on client->track().
376 *
377 * Fires once per wizard session — guarded by a wp_options flag to prevent
378 * duplicate events from redirects or re-renders.
379 *
380 * @param array $data {
381 * @type string $outcome Terminal outcome: 'completed', 'skipped', or 'exited'
382 * @type string $last_step_name Slug of the last step reached (e.g. 'choose_goal').
383 * Aliases: 'step_name' (from the abandoned path).
384 * @type int $last_step_index 1-based index of the last step.
385 * Aliases: 'step_index' (from the abandoned path).
386 * @type int $total_steps Total steps in the wizard for this goal path
387 * @type string $goal Selected goal slug, or '' if not yet chosen
388 * @type int $funnel_id ID of funnel created (0 if none created yet)
389 * }
390 */
391 public function handle_onboarding_progress( array $data ): void {
392 if ( ! $this->client ) {
393 return;
394 }
395
396 $outcome = sanitize_key( $data['outcome'] ?? '' );
397 if ( ! in_array( $outcome, [ 'completed', 'skipped', 'exited' ], true ) ) {
398 return;
399 }
400
401 // Prevent duplicate fires within the same wizard session.
402 if ( get_option( 'wpfunnels_onboarding_progress_tracked' ) ) {
403 return;
404 }
405
406 // Support both field name conventions: abandoned path uses step_name/step_index,
407 // completion path uses last_step_name/last_step_index.
408 $last_step_name = sanitize_key( $data['last_step_name'] ?? $data['step_name'] ?? '' );
409 $last_step_index = absint( $data['last_step_index'] ?? $data['step_index'] ?? 0 );
410
411 $start_time = (int) get_option( 'wpfunnels_onboarding_start_time', 0 );
412
413 try {
414 $this->client->track( 'onboarding_progress', [
415 'outcome' => $outcome,
416 'last_step_name' => $last_step_name,
417 'last_step_index' => $last_step_index,
418 'total_steps' => absint( $data['total_steps'] ?? 0 ),
419 'goal' => sanitize_text_field( $data['goal'] ?? '' ),
420 'time_to_exit_seconds' => $start_time ? ( time() - $start_time ) : 0,
421 'funnel_id' => absint( $data['funnel_id'] ?? 0 ),
422 'plugin_version' => WPFNL_VERSION,
423 '__timestamp' => $this->current_timestamp(),
424 ] );
425
426 // Mark as fired so re-renders / redirects cannot double-fire.
427 update_option( 'wpfunnels_onboarding_progress_tracked', '1', false );
428 } catch ( \Exception $e ) {
429 // Failure-safe.
430 }
431 }
432
433 /**
434 * Queue an NPS prompt after the setup wizard completes.
435 *
436 * Overrides the default NPS copy with setup-specific messaging so the
437 * question is contextually relevant to the just-finished onboarding.
438 * Runs at priority 20 on wpfunnels_setup_wizard_complete (after telemetry
439 * tracking at priority 10).
440 *
441 * @param int $funnel_id Funnel ID.
442 * @param string $action Completion action.
443 * @param string $goal Selected goal.
444 */
445 public function maybe_trigger_onboarding_nps( int $funnel_id = 0, string $action = '', string $goal = '' ): void {
446 if ( get_option( 'wpfunnels_wizard_nps_triggered' ) ) {
447 return;
448 }
449 $funnel_tyep = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true ) ?: 'woo';
450 $nps_question = 'How easy was it to set up your first funnel with WPFunnels?';
451 $feedback_msg = "We're sorry setup was difficult. What got in your way?";
452 if ( 'store_checkout' === $funnel_tyep ) {
453 $nps_question = 'How likely are you to recommend WPFunnels to your friends or colleagues based on your checkout setup experience?';
454 }
455
456 $review_prompt = $this->get_review_prompt_instance();
457 if ( ! $review_prompt ) {
458 return;
459 }
460 update_option( 'wpfunnels_wizard_nps_triggered', '1' );
461 $review_prompt->trigger_prompt( 'onboarding_complete', [
462 'modal_title' => 'How was your setup experience?',
463 'nps_question' => $nps_question,
464 'feedback_msg' => $feedback_msg,
465 ] );
466 }
467
468 /**
469 * Queue an NPS prompt after the user creates their first funnel (wizard or non-wizard).
470 *
471 * Fires once — guarded by a wp_options flag. For wizard users the wizard-complete
472 * NPS (maybe_trigger_onboarding_nps) will overwrite this trigger context on the
473 * same page-load cycle, so wizard users see the more contextual wizard copy.
474 * Non-wizard users see the first-funnel copy.
475 *
476 * @param int|string $funnel_id Newly created funnel ID.
477 * @param string $funnel_type Funnel type identifier.
478 */
479 public function maybe_trigger_first_funnel_nps( $funnel_id, $funnel_type = '' ): void {
480 if ( get_option( 'wpfunnels_first_funnel_nps_triggered' ) ) {
481 return;
482 }
483 $review_prompt = $this->get_review_prompt_instance();
484 if ( ! $review_prompt ) {
485 return;
486 }
487 $funnel_tyep = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true ) ?: 'woo';
488 $nps_question = 'How easy was it to create your first funnel with WPFunnels?';
489 $feedback_msg = "We're sorry to hear that. What got in your way?";
490 if ( 'store_checkout' === $funnel_tyep ) {
491 $nps_question = 'How likely are you to recommend WPFunnels to your friends or colleagues based on your checkout setup experience?';
492 }
493
494 update_option( 'wpfunnels_first_funnel_nps_triggered', '1' );
495 $review_prompt->trigger_prompt( 'first_funnel_created', [
496 'modal_title' => 'You created your first funnel!',
497 'nps_question' => $nps_question,
498 'feedback_msg' => $feedback_msg,
499 ] );
500 }
501
502 // -------------------------------------------------------------------------
503 // Helpers
504 // -------------------------------------------------------------------------
505
506 /**
507 * Return the ReviewPrompt instance safely, guarding against stale PHP opcache
508 * that may not yet have the get_review_prompt() method available.
509 *
510 * @return \LinnoSDK\Telemetry\ReviewPrompt|null
511 */
512 private function get_review_prompt_instance(): ?\LinnoSDK\Telemetry\ReviewPrompt {
513 if ( ! $this->client ) {
514 return null;
515 }
516 try {
517 return $this->client->get_review_prompt();
518 } catch ( \Throwable $e) {
519 return null;
520 }
521 }
522
523 // -------------------------------------------------------------------------
524 // Deactivation feedback
525 // -------------------------------------------------------------------------
526
527 /**
528 * Find and remove the SDK's admin_footer deactivation modal for WPFunnels.
529 *
530 * Runs at priority 0 so it fires before the SDK's default priority-10 callback.
531 * We cannot use get_deactivation() on Client because another plugin may have
532 * loaded its own Client.php first (PHP only keeps the first class definition).
533 * Instead we identify WPFunnels' Deactivation instance by the plugin_action_links
534 * hook it uniquely registers for WPFNL_FILE, then remove its admin_footer callback.
535 */
536 public function remove_sdk_deactivation_modal() {
537 global $wp_filter;
538
539 if ( ! isset( $wp_filter['admin_footer'] ) ) {
540 return;
541 }
542
543 $links_hook = 'plugin_action_links_' . plugin_basename( WPFNL_FILE );
544
545 foreach ( $wp_filter['admin_footer']->callbacks as $priority => $callbacks ) {
546 foreach ( $callbacks as $cb ) {
547 $fn = $cb['function'];
548 if ( ! is_array( $fn ) || ! ( $fn[0] instanceof \LinnoSDK\Telemetry\Deactivation ) ) {
549 continue;
550 }
551 if ( has_filter( $links_hook, [ $fn[0], 'filter_plugin_action_links' ] ) ) {
552 remove_action( 'admin_footer', [ $fn[0], 'add_deactivation_feedback_modal' ], $priority );
553 return;
554 }
555 }
556 }
557 }
558
559 /**
560 * Render WPFunnels' own deactivation modal on the plugins.php page.
561 *
562 * This replaces the SDK's generic modal (unhooked via remove_sdk_deactivation_modal()) so
563 * WPFunnels uses isolated wpfnl-dr-/wpfnl-de- CSS class names and one-click
564 * UX without conflicting with mail-mint or wpvr modals on the same page.
565 * The SDK's plugin_action_links filter and AJAX handler remain active.
566 */
567 public function render_deactivation_modal() {
568 global $pagenow;
569 if ( 'plugins.php' !== $pagenow ) {
570 return;
571 }
572
573 $nonce = wp_create_nonce( 'wpfunnels-deactivation-nonce' );
574 $reasons = apply_filters( 'wpfunnels_telemetry_deactivation_reasons', [] );
575
576 if ( empty( $reasons ) ) {
577 return;
578 }
579 ?>
580 <style type="text/css">
581 .wpfnl-dr-modal {
582 position: fixed;
583 z-index: 99999;
584 top: 0; right: 0; bottom: 0; left: 0;
585 background: rgba(0,0,0,0.5);
586 display: none;
587 box-sizing: border-box;
588 overflow: scroll;
589 }
590 .wpfnl-dr-modal * { box-sizing: border-box; }
591 .wpfnl-dr-modal.modal-active { display: block; }
592 .wpfnl-dr-modal-wrap {
593 max-width: 780px;
594 width: 100%;
595 position: relative;
596 margin: 8% auto;
597 background: #fff;
598 border-radius: 8px;
599 box-shadow: 0 8px 32px rgba(0,0,0,0.18);
600 }
601 .wpfnl-dr-modal-header {
602 border-bottom: 1px solid #E8E8E8;
603 padding: 24px 24px 16px 24px;
604 }
605 .wpfnl-dr-modal-header h3 {
606 line-height: 1.5;
607 margin: 0 0 4px 0;
608 color: #1a1a2e;
609 font-size: 16px;
610 font-weight: 600;
611 }
612 .wpfnl-dr-modal-subheading { margin: 0; color: #718096; font-size: 13px; }
613 .wpfnl-dr-modal-body { padding: 20px 24px 8px 24px; }
614 .wpfnl-dr-modal-footer {
615 border-top: 1px solid #E8E8E8;
616 padding: 16px 24px;
617 display: flex;
618 align-items: center;
619 justify-content: flex-end;
620 }
621 ul.wpfnl-de-reasons {
622 display: flex;
623 flex-wrap: wrap;
624 margin: 0 -6px 12px -6px;
625 padding: 0;
626 list-style: none;
627 }
628 ul.wpfnl-de-reasons li { padding: 0 6px; margin: 0 0 12px 0; width: 25%; }
629 .wpfnl-de-reason-btn {
630 width: 100%;
631 border: 1.5px solid #E8E8E8;
632 border-radius: 6px;
633 background: #fff;
634 cursor: pointer;
635 text-align: center;
636 padding: 14px 8px 12px 8px;
637 transition: border-color 0.15s, background 0.15s, transform 0.1s;
638 display: flex;
639 flex-direction: column;
640 align-items: center;
641 gap: 8px;
642 height: 100%;
643 }
644 .wpfnl-de-reason-btn:hover {
645 border-color: #7B68EE;
646 background: #f5f3ff;
647 transform: translateY(-1px);
648 }
649 .wpfnl-de-reason-btn:active { transform: translateY(0); }
650 .wpfnl-de-reason-btn:disabled { cursor: default; opacity: 0.6; }
651 .wpfnl-de-reason-text { color: #4A5568; font-size: 12px; line-height: 1.4; }
652 .wpfnl-de-reason-icon { display: flex; align-items: center; justify-content: center; }
653 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-btn {
654 background: #7B68EE;
655 border-color: #7B68EE;
656 }
657 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-icon svg,
658 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-icon svg g { fill: #fff; }
659 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-text { color: #fff; }
660 .wpfnl-dr-btn-cancel,
661 .wpfnl-dr-btn-cancel:hover {
662 border: 1px solid #EBEBEB;
663 border-radius: 4px;
664 font-size: 13px;
665 line-height: 1.5;
666 color: #718096;
667 padding: 6px 14px;
668 cursor: pointer;
669 background-color: transparent;
670 text-decoration: none;
671 }
672 </style>
673
674 <div class="wpfnl-dr-modal" id="wpfunnels-dr-modal">
675 <div class="wpfnl-dr-modal-wrap">
676 <div class="wpfnl-dr-modal-header">
677 <h3><?php esc_html_e( "Quick question before you go — what's the main reason?", 'wpfnl' ); ?></h3>
678 <p class="wpfnl-dr-modal-subheading"><?php esc_html_e( 'One click is all it takes. Your feedback helps us improve.', 'wpfnl' ); ?></p>
679 </div>
680 <div class="wpfnl-dr-modal-body">
681 <ul class="wpfnl-de-reasons">
682 <?php foreach ( $reasons as $reason ) : ?>
683 <li data-reason-id="<?php echo esc_attr( $reason['id'] ); ?>">
684 <button type="button" class="wpfnl-de-reason-btn">
685 <div class="wpfnl-de-reason-icon"><?php echo $reason['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
686 <div class="wpfnl-de-reason-text"><?php echo esc_html( $reason['text'] ); ?></div>
687 </button>
688 </li>
689 <?php endforeach; ?>
690 </ul>
691 </div>
692 <div class="wpfnl-dr-modal-footer">
693 <button type="button" class="wpfnl-dr-btn-cancel"><?php esc_html_e( 'Cancel', 'wpfnl' ); ?></button>
694 </div>
695 </div>
696 </div>
697
698 <script type="text/javascript">
699 (function($) {
700 $(function() {
701 var modal = $('#wpfunnels-dr-modal');
702 var deactivateLink = '';
703 var submitting = false;
704
705 $('#the-list').on('click', 'a.wpfunnels-deactivation-link', function(e) {
706 e.preventDefault();
707 deactivateLink = $(this).attr('href');
708 submitting = false;
709 modal.find('.wpfnl-de-reason-btn').prop('disabled', false);
710 modal.find('li').removeClass('wpfnl-de-reason-selected');
711 modal.addClass('modal-active');
712 });
713
714 modal.on('click', 'button.wpfnl-dr-btn-cancel', function(e) {
715 e.preventDefault();
716 modal.removeClass('modal-active');
717 });
718
719 modal.on('click', '.wpfnl-de-reason-btn', function() {
720 if (submitting || !deactivateLink) return;
721 submitting = true;
722
723 var reasonId = $(this).closest('li').data('reason-id');
724 $(this).closest('li').addClass('wpfnl-de-reason-selected');
725 modal.find('.wpfnl-de-reason-btn').prop('disabled', true);
726
727 $.ajax({
728 url: ajaxurl,
729 type: 'POST',
730 data: {
731 nonce: '<?php echo esc_js( $nonce ); ?>',
732 action: 'wpfunnels_submit_deactivation_reason',
733 reason_id: reasonId,
734 reason_info: ''
735 },
736 complete: function() {
737 window.location.href = deactivateLink;
738 }
739 });
740 });
741 });
742 }(jQuery));
743 </script>
744 <?php
745 }
746
747 /**
748 * Replace the generic SDK deactivation reasons with WPFunnels-specific ones.
749 *
750 * Hooked on wpfunnels_telemetry_deactivation_reasons. Called by render_deactivation_modal()
751 * via apply_filters to populate WPFunnels' own modal.
752 *
753 * @param array $reasons Default reasons from the SDK.
754 * @return array
755 */
756 public function filter_deactivation_reasons( array $reasons ): array {
757 return [
758
759 [
760 'id' => 'plugin-conflict',
761 'text' => __( 'Plugin conflict', 'wpfnl' ),
762 '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>',
763 ],
764 [
765 'id' => 'payment-not-working',
766 'text' => __( 'Payment not working', 'wpfnl' ),
767 '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>',
768 ],
769 [
770 'id' => 'funnel-builder-hard-to-use',
771 'text' => __( 'Funnel builder too complex', 'wpfnl' ),
772 '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>',
773 ],
774 [
775 'id' => 'checkout-not-working',
776 'text' => __( 'Checkout not working', 'wpfnl' ),
777 '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>',
778 ],
779 [
780 'id' => 'upsell-downsell-not-working',
781 'text' => __( 'Upsell / downsell issues', 'wpfnl' ),
782 '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>',
783 ],
784 [
785 'id' => 'missing-feature',
786 'text' => __( 'Missing a feature I need', 'wpfnl' ),
787 '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>',
788 ],
789 [
790 'id' => 'template-not-good-enough',
791 'text' => __( 'Templates are not good enough', 'wpfnl' ),
792 '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>',
793 ],
794 [
795 'id' => 'lacking-payment-gateway',
796 'text' => __( 'Lack of payment gateway options', 'wpfnl' ),
797 '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>',
798 ],
799 [
800 'id' => 'found-better-plugin',
801 'text' => __( 'Switching to another plugin', 'wpfnl' ),
802 '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>',
803 ],
804 [
805 'id' => 'temporary',
806 'text' => __( 'Temporarily deactivating', '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 6v6l4 2" stroke="#3B86FF" stroke-width="2" stroke-linecap="round"/></svg>',
808 ],
809 ];
810 }
811
812 // -------------------------------------------------------------------------
813 // Aha milestone (per-funnel first live sale)
814 // -------------------------------------------------------------------------
815
816 /**
817 * Fire the Aha milestone event exactly once per funnel on the first order.
818 *
819 * @param int|string $order_id Order ID.
820 * @param int|string $funnel_id Funnel ID.
821 * @param int|string $checkout_id Checkout step ID.
822 */
823 public function handle_aha_milestone( $order_id, $funnel_id, $checkout_id ): void {
824 $funnel_id = absint( $funnel_id );
825 if ( ! $funnel_id ) {
826 return;
827 }
828 if ( $this->has_aha_completed( $funnel_id ) ) {
829 return;
830 }
831 try {
832 if ( $this->client ) {
833 $this->client->track_kui( 'first_live_sale', $this->aha_payload( $order_id, $funnel_id ) );
834 }
835 } catch ( \Exception $e ) {
836 // Failure-safe: telemetry errors must never block order processing.
837 }
838 $this->mark_aha_completed( $funnel_id, $order_id );
839 }
840
841 /**
842 * @param int $funnel_id Funnel post ID.
843 * @return bool
844 */
845 public function has_aha_completed( int $funnel_id ): bool {
846 return (bool) get_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', true );
847 }
848
849 /**
850 * @param int $funnel_id Funnel post ID.
851 * @param int|string $order_id Order that triggered the milestone.
852 */
853 public function mark_aha_completed( int $funnel_id, $order_id ): void {
854 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', '1' );
855 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_at', $this->current_timestamp() );
856 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_order', absint( $order_id ) );
857 }
858
859 /**
860 * @param int|string $order_id Order ID.
861 * @param int $funnel_id Funnel ID.
862 * @return array<string,mixed>
863 */
864 public function aha_payload( $order_id, int $funnel_id ): array {
865 return [
866 'funnel_id' => $funnel_id,
867 '__timestamp' => $this->current_timestamp(),
868 ];
869 }
870
871 /** @return string ISO-8601 UTC timestamp. */
872 private function current_timestamp(): string {
873 return gmdate( 'c' );
874 }
875 }
876