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

1,166 lines 50.1 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 $this->register_retention_events();
83
84 /*
85 * Product activity trail: every screen and REST action, sent daily for
86 * opted-in sites and at deactivation for every site.
87 */
88 ActivityTrail::init( $this->client, self::POSTHOG_API_KEY );
89 }
90
91 /**
92 * Wire up all WordPress hooks for event tracking.
93 */
94 private function register_triggers(): void {
95 if ( ! $this->client ) {
96 return;
97 }
98
99 try {
100 $this->client->define_triggers( [
101 'onboarding' => [
102 'hook' => 'wpfunnels_setup_wizard_complete',
103 'callback' => [ $this, 'setup_payload' ],
104 ],
105 'feature_used' => [
106 'template_library' => [
107 'hook' => 'wpfunnels_template_imported',
108 'callback' => [ $this, 'template_imported_payload' ],
109 ],
110 'order_bumps' => [
111 'hook' => 'wpfunnels_order_bump_added',
112 'callback' => [ $this, 'feature_usage_payload' ],
113 ],
114 'upsells_downsells' => [
115 'hook' => 'wpfunnels_upsell_configured',
116 'callback' => [ $this, 'feature_usage_payload' ],
117 ],
118 'vertical_canvas_mode' => [
119 'hook' => 'wpfunnels_vertical_mode_used',
120 'callback' => [ $this, 'feature_usage_payload' ],
121 ],
122 // Retention signals — low-frequency admin or AI-driven actions, sent on every use.
123 // template_funnel_imported — a funnel was built from the template library.
124 'template_funnel_imported' => [
125 'hook' => 'wpfunnels_after_funnel_imported',
126 'callback' => function ( $funnel_id = 0, $type = '', $builder = '' ) {
127 return [
128 'template_type' => sanitize_key( (string) $type ),
129 'builder' => sanitize_key( (string) $builder ),
130 ];
131 },
132 ],
133 // ai_chat_started — the AI assistant was opened for real work.
134 'ai_chat_started' => [
135 'hook' => 'wpfunnels_ai_chat_started',
136 'callback' => function ( $conversation_id = 0, $user_id = 0, $context_type = '' ) {
137 return [ 'context_type' => sanitize_key( (string) $context_type ) ];
138 },
139 ],
140 // ai_funnel_published — a funnel built by the AI assistant went live.
141 'ai_funnel_published' => [
142 'hook' => 'wpfunnels_ai_funnel_published',
143 'callback' => function () {
144 return [];
145 },
146 ],
147 // mail_mint_automation_saved — email automation attached to a funnel.
148 'mail_mint_automation_saved' => [
149 'hook' => 'wpfunnels/save_mint_automation',
150 'callback' => function () {
151 return [];
152 },
153 ],
154 // step_duplicated — a step was duplicated on the canvas.
155 'step_duplicated' => [
156 'hook' => 'wpfunnels/after_step_duplicate',
157 'callback' => function () {
158 return [];
159 },
160 ],
161 ],
162 ] );
163 } catch ( \Exception $e ) {
164 // Failure-safe: trigger registration errors must not surface to callers.
165 }
166
167 // Funnel lifecycle → PostHog.
168 add_action( 'wpfunnels_after_funnel_created', [ $this, 'handle_funnel_created' ], 10, 2 );
169 add_action( 'transition_post_status', [ $this, 'handle_funnel_published' ], 10, 3 );
170
171 // Onboarding step events dispatched from the REST track-step endpoint.
172 // add_action( 'wpfunnels_onboarding_step_tracked', [ $this, 'handle_onboarding_step' ], 10, 1 );
173
174 // Unified onboarding outcome event (single-event model).
175 add_action( 'wpfunnels_onboarding_progress', [ $this, 'handle_onboarding_progress' ], 10, 1 );
176
177 // NPS contextual triggers.
178 add_action( 'wpfunnels_setup_wizard_complete', [ $this, 'maybe_trigger_onboarding_nps' ], 20, 3 );
179 add_action( 'wpfunnels_after_funnel_created', [ $this, 'maybe_trigger_first_funnel_nps' ], 20, 2 );
180 add_action( 'wpfunnels_template_imported', [ $this, 'maybe_trigger_first_funnel_nps' ], 20, 1 );
181
182 // Per-funnel Aha milestone — wired directly because SDK KUI thresholding
183 // is site-scoped and cannot enforce the funnel-level single-fire guarantee.
184 add_action( 'wpfunnels/funnel_order_placed', [ $this, 'handle_aha_milestone' ], 10, 3 );
185
186 // Replace generic SDK deactivation reasons with WPFunnels-specific ones.
187 add_filter( 'wpfunnels_telemetry_deactivation_reasons', [ $this, 'filter_deactivation_reasons' ] );
188
189 // Swap the SDK's shared deactivation modal for WPFunnels' own isolated implementation.
190 // The SDK's modal uses generic CSS class names (wd-dr-*, wd-de-*) that conflict with
191 // mail-mint and wpvr when all plugins are active. WPFunnels' modal uses wpfnl-dr-/wpfnl-de-
192 // prefixed classes and one-click behavior, keeping it fully independent.
193 //
194 // We cannot call $this->client->get_deactivation() because another plugin (mail-mint,
195 // wpvr) may load its own Client.php first — only one class definition wins in PHP.
196 // Instead we scan $wp_filter at priority 0 and identify WPFunnels' Deactivation
197 // instance by the plugin_action_links hook it registers for WPFNL_FILE.
198 add_action( 'admin_footer', [ $this, 'remove_sdk_deactivation_modal' ], 0 );
199 add_action( 'admin_footer', [ $this, 'render_deactivation_modal' ] );
200 }
201
202 // -------------------------------------------------------------------------
203 // Retention: weekly "value delivered" signals
204 // -------------------------------------------------------------------------
205
206 /**
207 * Is WPFunnels producing results on this site this week?
208 *
209 * Every hook below fires on visitor or cron traffic (checkouts, offers,
210 * opt-ins), so each feature is sent at most once a week per site through
211 * the weekly gate. Properties are non-personal: step or offer type and a
212 * value bucket — never order, customer or funnel ids.
213 *
214 * Hooks were chosen by their call sites, not their names. Left out on
215 * purpose: `wpfunnels/update_ab_testing_winner` (fires on every Pro step
216 * visit, not when a winner is chosen), `wpfunnels/ab_testing_auto_end`
217 * (fires when the auto-end setting is saved) and `wpfunnels/trigger_automation`
218 * (fires at the end of every journey whether or not an automation exists).
219 */
220 private function register_retention_events(): void {
221 // funnel_order_placed — a WooCommerce order was placed through a funnel checkout.
222 add_action(
223 'wpfunnels/funnel_order_placed',
224 function ( $order_id = 0 ) {
225 $this->send_weekly_feature_used(
226 'funnel_order_placed',
227 function () use ( $order_id ) {
228 $order = function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : null;
229 return [ 'value_bucket' => $order ? self::value_bucket( $order->get_total() ) : 'unknown' ];
230 }
231 );
232 },
233 10,
234 1
235 );
236
237 // order_bump_accepted — a shopper ticked an order bump at checkout (before the order).
238 add_action(
239 'wpfunnels/order_bump_accepted',
240 function () {
241 $this->send_weekly_feature_used( 'order_bump_accepted' );
242 }
243 );
244
245 // offer_accepted / offer_rejected — an upsell or downsell was answered.
246 foreach ( [ 'wpfunnels/offer_accepted' => 'offer_accepted', 'wpfunnels/offer_rejected' => 'offer_rejected' ] as $hook => $feature ) {
247 add_action(
248 $hook,
249 function ( $order = null, $offer_product = [] ) use ( $feature ) {
250 $this->send_weekly_feature_used(
251 $feature,
252 function () use ( $offer_product ) {
253 $step_id = is_array( $offer_product ) && isset( $offer_product['step_id'] ) ? absint( $offer_product['step_id'] ) : 0;
254 $properties = [ 'offer_type' => $step_id ? sanitize_key( (string) get_post_meta( $step_id, '_step_type', true ) ) : 'unknown' ];
255 if ( is_array( $offer_product ) && isset( $offer_product['total'] ) && is_numeric( $offer_product['total'] ) ) {
256 $properties['value_bucket'] = self::value_bucket( $offer_product['total'] );
257 }
258 return $properties;
259 }
260 );
261 },
262 10,
263 2
264 );
265 }
266
267 // optin_submitted — a visitor submitted a funnel opt-in form.
268 add_action(
269 'wpfunnels/after_optin_submit',
270 function ( $step_id = 0, $post_action = '' ) {
271 $this->send_weekly_feature_used( 'optin_submitted', [ 'post_action' => sanitize_key( (string) $post_action ) ] );
272 },
273 10,
274 2
275 );
276
277 // funnel_visited / funnel_completed — a visitor entered a funnel's first
278 // step or reached its last one. Admins previewing their own funnels are skipped.
279 foreach ( [ 'wpfunnels/funnel_journey_starts' => 'funnel_visited', 'wpfunnels/funnel_journey_end' => 'funnel_completed' ] as $hook => $feature ) {
280 add_action(
281 $hook,
282 function () use ( $feature ) {
283 if ( current_user_can( 'wpf_manage_funnels' ) ) {
284 return;
285 }
286 $this->send_weekly_feature_used( $feature );
287 }
288 );
289 }
290
291 // checkout_visit_recorded — a unique visitor reached a funnel or store checkout.
292 add_action(
293 'wpfunnels/checkout_visit_recorded',
294 function ( $type = '' ) {
295 $this->send_weekly_feature_used( 'checkout_visit_recorded', [ 'checkout_type' => sanitize_key( (string) $type ) ] );
296 }
297 );
298
299 // offer_subscription_created — an offer created a WooCommerce subscription (Pro).
300 add_action(
301 'wpfunnels/subscription_created',
302 function () {
303 $this->send_weekly_feature_used( 'offer_subscription_created' );
304 }
305 );
306 }
307
308 /**
309 * Send a `feature_used` event at most once a week per site.
310 *
311 * These hooks sit on hot visitor paths, so the two cheap checks come first:
312 * not opted in (track_feature_used() would drop the event anyway) and
313 * already sent this week. Only then does claim_weekly_slot() take the
314 * atomic slot. Properties may be a callable, evaluated only when the event
315 * is actually sent.
316 *
317 * @param string $feature Feature name.
318 * @param array|callable $properties Event properties, or a callable returning them.
319 * @return void
320 */
321 public function send_weekly_feature_used( string $feature, $properties = [] ): void {
322 try {
323 if ( ! $this->client || 'yes' !== $this->client->get_optin_state() ) {
324 return;
325 }
326
327 if ( ! self::claim_weekly_slot( 'wpfunnels_telemetry_' . $feature . '_last_sent' ) ) {
328 return;
329 }
330
331 $properties = is_callable( $properties ) ? call_user_func( $properties ) : $properties;
332 $this->client->track_feature_used( $feature, is_array( $properties ) ? $properties : [] );
333 } catch ( \Throwable $e ) {
334 // Telemetry must never break a checkout or an offer.
335 }
336 }
337
338 /**
339 * Claim this week's send slot for a gate option.
340 *
341 * Fast path: already sent this week — skips the write for almost every
342 * call; a stale read just falls through to the atomic claim. The claim is a
343 * conditional UPDATE, so only one of several concurrent requests can flip
344 * the value; the rest affect 0 rows.
345 *
346 * @param string $option_key Gate option storing the last-sent time.
347 * @param int|null $now Current time (for tests).
348 * @return bool True when this call won the slot.
349 */
350 public static function claim_weekly_slot( string $option_key, ?int $now = null ): bool {
351 global $wpdb;
352
353 $now = null === $now ? time() : $now;
354 $threshold = $now - WEEK_IN_SECONDS;
355
356 if ( (int) get_option( $option_key, 0 ) >= $threshold ) {
357 return false;
358 }
359
360 // Ensure the row exists so the conditional UPDATE has something to claim.
361 // add_option is a no-op if it already exists, preserving the last send time.
362 add_option( $option_key, '0', '', false );
363
364 $claimed = $wpdb->query(
365 $wpdb->prepare(
366 "UPDATE {$wpdb->options} SET option_value = %d WHERE option_name = %s AND option_value < %d",
367 $now,
368 $option_key,
369 $threshold
370 )
371 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- atomic claim; the options API check-then-set is racy under concurrency.
372
373 if ( empty( $claimed ) ) {
374 return false;
375 }
376
377 // Keep the options cache in sync with the value just written directly.
378 wp_cache_delete( $option_key, 'options' );
379
380 return true;
381 }
382
383 /**
384 * Bucket a money amount so no exact order value is sent. Store currency
385 * is not converted; the buckets only tell small from large orders.
386 *
387 * @param mixed $amount Amount.
388 * @return string
389 */
390 public static function value_bucket( $amount ): string {
391 $amount = (float) $amount;
392
393 if ( $amount <= 0 ) {
394 return '0';
395 }
396
397 foreach ( [ 50 => '1-49', 100 => '50-99', 250 => '100-249', 500 => '250-499', 1000 => '500-999' ] as $limit => $bucket ) {
398 if ( $amount < $limit ) {
399 return $bucket;
400 }
401 }
402
403 return '1000+';
404 }
405
406 // -------------------------------------------------------------------------
407 // Public API — called from REST controllers
408 // -------------------------------------------------------------------------
409
410 /**
411 * Persist the user's consent decision and sync the telemetry client.
412 *
413 * Must be called as early as possible (Welcome step proceed) so subsequent
414 * step-tracking calls pass the opt-in gate inside the SDK.
415 *
416 * @param bool $consented True when the user checked the data-sharing box.
417 */
418 public function save_consent( bool $consented ): void {
419 if ( ! $this->client ) {
420 return;
421 }
422 $this->client->set_optin_state( $consented ? 'yes' : 'no' );
423 }
424
425 /**
426 * Read the stored consent decision.
427 *
428 * Delegates to the SDK, which also migrates the pre-Linno Appsero keys
429 * (`wpfunnels_allow_tracking` and friends) onto the shared Linno option — so
430 * a site that consented through an older setup wizard still reports 'yes'
431 * here. When the SDK is unavailable we read those same keys directly rather
432 * than reporting "no decision" and silently losing an existing opt-in.
433 *
434 * @return string|null 'yes', 'no', or null when the user has never decided.
435 */
436 public function get_consent_state(): ?string {
437 if ( $this->client ) {
438 return $this->client->get_optin_state();
439 }
440
441 foreach ( [ 'linno_telemetry_allow_tracking', WPFNL_SLUG . '_allow_tracking' ] as $option_key ) {
442 $value = get_option( $option_key, null );
443
444 if ( is_string( $value ) ) {
445 $value = strtolower( trim( $value ) );
446
447 if ( 'yes' === $value || 'no' === $value ) {
448 return $value;
449 }
450 }
451 }
452
453 return null;
454 }
455
456 /**
457 * Whether the user has actively opted in to usage tracking.
458 *
459 * @return bool
460 */
461 public function has_consent(): bool {
462 return 'yes' === $this->get_consent_state();
463 }
464
465 /**
466 * Dispatch a step-view or step-completion event from the REST layer.
467 *
468 * @param array $data Keys: event_type (viewed|completed), step_name, step_index, goal, time_on_step.
469 */
470 public function track_onboarding_step( array $data ): void {
471 do_action( 'wpfunnels_onboarding_step_tracked', $data );
472 }
473
474 /**
475 * Dispatch the unified onboarding outcome event from the REST layer.
476 *
477 * @param array $data Keys: outcome, last_step_name, last_step_index, total_steps, goal, funnel_id.
478 */
479 public function track_onboarding_progress( array $data ): void {
480 do_action( 'wpfunnels_onboarding_progress', $data );
481 }
482
483 // -------------------------------------------------------------------------
484 // Payload callbacks
485 // -------------------------------------------------------------------------
486
487 /**
488 * Onboarding completion payload — sent once when the wizard finishes.
489 *
490 * @param int $funnel_id Funnel ID created during setup.
491 * @param string $action Button clicked at the Complete step (e.g. goToDashboard).
492 * @param string $goal Goal the user chose (order-value | improve-checkout | sales).
493 *
494 * @return array<string,mixed>
495 */
496 public function setup_payload( int $_funnel_id = 0, string $action = '', string $goal = '' ): array {
497 $start_time = (int) get_option( 'wpfunnels_onboarding_start_time', 0 );
498 $elapsed = $start_time ? ( time() - $start_time ) : 0;
499
500 return [
501 'action' => sanitize_text_field( $action ),
502 'goal' => sanitize_text_field( $goal ),
503 'time_to_complete_seconds' => $elapsed,
504 '__timestamp' => $this->current_timestamp(),
505 ];
506 }
507
508 /**
509 * Feature usage payload for order bumps and upsells.
510 *
511 * @param int|string $funnel_id Funnel ID.
512 * @param int|string $step_id Step ID (0 when not applicable).
513 * @param mixed ...$_extra Additional hook args silently ignored.
514 *
515 * @return array<string,mixed>
516 */
517 public function feature_usage_payload( $funnel_id = 0, $step_id = 0, ...$_extra ): array {
518 return [
519 'funnel_id' => absint( $funnel_id ),
520 '__timestamp' => $this->current_timestamp(),
521 ];
522 }
523
524 /**
525 * Feature usage payload for template imports — includes template context.
526 *
527 * @param int|string $funnel_id Funnel ID.
528 * @param int|string $template_id Template ID.
529 * @param mixed ...$_extra Additional hook args silently ignored.
530 *
531 * @return array<string,mixed>
532 */
533 public function template_imported_payload( $funnel_id = 0, $template_id = 0, ...$_extra ): array {
534 return [
535 'funnel_id' => absint( $funnel_id ),
536 'template_id' => absint( $template_id ),
537 '__timestamp' => $this->current_timestamp(),
538 ];
539 }
540
541 // -------------------------------------------------------------------------
542 // Funnel lifecycle handlers
543 // -------------------------------------------------------------------------
544
545 /**
546 * Track funnel creation in PostHog.
547 *
548 * Hooked on wpfunnels_after_funnel_created (same hook used by EventTracker
549 * for the legacy first_strike event).
550 *
551 * @param int|string $funnel_id Funnel ID.
552 * @param string $funnel_type Funnel type identifier.
553 */
554 public function handle_funnel_created( $funnel_id, $funnel_type ): void {
555 if ( ! $this->client ) {
556 return;
557 }
558 try {
559 $this->client->track( 'activation/funnel_created', [
560 'funnel_id' => absint( $funnel_id ),
561 'funnel_type' => sanitize_text_field( (string) $funnel_type ),
562 '__timestamp' => $this->current_timestamp(),
563 ] );
564 } catch ( \Exception $e ) {
565 // Failure-safe.
566 }
567 }
568
569 /**
570 * Track funnel publish transition and fire the wpfunnels_funnel_published hook.
571 *
572 * Listens on transition_post_status for all post types — returns early for
573 * anything that is not a wpfunnels post moving into publish status.
574 *
575 * @param string $new_status Post status after transition.
576 * @param string $old_status Post status before transition.
577 * @param \WP_Post $post The post being transitioned.
578 */
579 public function handle_funnel_published( string $new_status, string $old_status, \WP_Post $post ): void {
580 if ( 'publish' !== $new_status || 'publish' === $old_status ) {
581 return;
582 }
583 if ( 'wpfunnels' !== $post->post_type ) {
584 return;
585 }
586 if ( ! $this->client ) {
587 return;
588 }
589
590 $created_ts = strtotime( $post->post_date_gmt );
591 $days_created = $created_ts ? round( ( time() - $created_ts ) / DAY_IN_SECONDS, 1 ) : 0;
592
593 try {
594 $this->client->track( 'activation/funnel_published', [
595 'funnel_id' => $post->ID,
596 'funnel_type' => get_post_meta( $post->ID, '_wpfnl_funnel_type', true ) ?: 'unknown',
597 'days_since_created' => $days_created,
598 '__timestamp' => $this->current_timestamp(),
599 ] );
600 } catch ( \Exception $e ) {
601 // Failure-safe.
602 }
603 }
604
605 // -------------------------------------------------------------------------
606 // Onboarding step handlers
607 // -------------------------------------------------------------------------
608
609 /**
610 * Send a step_viewed or step_completed PostHog event.
611 *
612 * Fires when wpfunnels_onboarding_step_tracked is dispatched from the REST
613 * track-step endpoint.
614 *
615 * @param array $data {
616 * @type string $event_type 'viewed' or 'completed'
617 * @type string $step_name Step slug (e.g. welcome, choose_goal)
618 * @type int $step_index 1-based step number
619 * @type string $goal Selected goal or empty string
620 * @type int $time_on_step Seconds spent on the step before proceeding
621 * }
622 */
623 public function handle_onboarding_step( array $data ): void {
624 if ( ! $this->client ) {
625 return;
626 }
627
628 $event_type = sanitize_key( $data['event_type'] ?? 'viewed' );
629 $event_map = [
630 'viewed' => 'onboarding/step_viewed',
631 'completed' => 'onboarding/step_completed',
632 ];
633
634 $event = $event_map[ $event_type ] ?? null;
635 if ( ! $event ) {
636 return;
637 }
638
639 try {
640 $this->client->track( $event, [
641 'step_name' => sanitize_key( $data['step_name'] ?? '' ),
642 'step_index' => absint( $data['step_index'] ?? 0 ),
643 'goal' => sanitize_text_field( $data['goal'] ?? '' ),
644 'time_on_step' => absint( $data['time_on_step'] ?? 0 ),
645 '__timestamp' => $this->current_timestamp(),
646 ] );
647 } catch ( \Exception $e ) {
648 // Failure-safe.
649 }
650
651 // Stamp the wizard start time on first step view so setup_payload can
652 // compute time_to_complete_seconds.
653 if ( 1 === absint( $data['step_index'] ?? 0 ) && 'viewed' === $event_type ) {
654 if ( ! get_option( 'wpfunnels_onboarding_start_time' ) ) {
655 update_option( 'wpfunnels_onboarding_start_time', time(), false );
656 }
657 }
658 }
659
660 /**
661 * Send the unified onboarding_progress PostHog event.
662 *
663 * Single-event model that captures the full onboarding outcome in one event.
664 * Fires for all three terminal outcomes: completed, skipped, and exited.
665 * Consent is enforced by the SDK's opt-in gate on client->track().
666 *
667 * Fires once per wizard session — guarded by a wp_options flag to prevent
668 * duplicate events from redirects or re-renders.
669 *
670 * @param array $data {
671 * @type string $outcome Terminal outcome: 'completed', 'skipped', or 'exited'
672 * @type string $last_step_name Slug of the last step reached (e.g. 'choose_goal').
673 * Aliases: 'step_name' (from the abandoned path).
674 * @type int $last_step_index 1-based index of the last step.
675 * Aliases: 'step_index' (from the abandoned path).
676 * @type int $total_steps Total steps in the wizard for this goal path
677 * @type string $goal Selected goal slug, or '' if not yet chosen
678 * @type int $funnel_id ID of funnel created (0 if none created yet)
679 * }
680 */
681 public function handle_onboarding_progress( array $data ): void {
682 if ( ! $this->client ) {
683 return;
684 }
685
686 $outcome = sanitize_key( $data['outcome'] ?? '' );
687 if ( ! in_array( $outcome, [ 'completed', 'skipped', 'exited' ], true ) ) {
688 return;
689 }
690
691 // Prevent duplicate fires within the same wizard session.
692 if ( get_option( 'wpfunnels_onboarding_progress_tracked' ) ) {
693 return;
694 }
695
696 // Support both field name conventions: abandoned path uses step_name/step_index,
697 // completion path uses last_step_name/last_step_index.
698 $last_step_name = sanitize_key( $data['last_step_name'] ?? $data['step_name'] ?? '' );
699 $last_step_index = absint( $data['last_step_index'] ?? $data['step_index'] ?? 0 );
700
701 $start_time = (int) get_option( 'wpfunnels_onboarding_start_time', 0 );
702
703 try {
704 $this->client->track( 'onboarding_progress', [
705 'outcome' => $outcome,
706 'last_step_name' => $last_step_name,
707 'last_step_index' => $last_step_index,
708 'total_steps' => absint( $data['total_steps'] ?? 0 ),
709 'goal' => sanitize_text_field( $data['goal'] ?? '' ),
710 'time_to_exit_seconds' => $start_time ? ( time() - $start_time ) : 0,
711 'funnel_id' => absint( $data['funnel_id'] ?? 0 ),
712 'plugin_version' => WPFNL_VERSION,
713 '__timestamp' => $this->current_timestamp(),
714 ] );
715
716 // Mark as fired so re-renders / redirects cannot double-fire.
717 update_option( 'wpfunnels_onboarding_progress_tracked', '1', false );
718 } catch ( \Exception $e ) {
719 // Failure-safe.
720 }
721 }
722
723 /**
724 * Queue an NPS prompt after the setup wizard completes.
725 *
726 * Overrides the default NPS copy with setup-specific messaging so the
727 * question is contextually relevant to the just-finished onboarding.
728 * Runs at priority 20 on wpfunnels_setup_wizard_complete (after telemetry
729 * tracking at priority 10).
730 *
731 * @param int $funnel_id Funnel ID.
732 * @param string $action Completion action.
733 * @param string $goal Selected goal.
734 */
735 public function maybe_trigger_onboarding_nps( int $funnel_id = 0, string $action = '', string $goal = '' ): void {
736 if ( get_option( 'wpfunnels_wizard_nps_triggered' ) ) {
737 return;
738 }
739 $funnel_tyep = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true ) ?: 'woo';
740 $nps_question = 'How easy was it to set up your first funnel with WPFunnels?';
741 $feedback_msg = "We're sorry setup was difficult. What got in your way?";
742 if ( 'store_checkout' === $funnel_tyep ) {
743 $nps_question = 'How likely are you to recommend WPFunnels to your friends or colleagues based on your checkout setup experience?';
744 }
745
746 $review_prompt = $this->get_review_prompt_instance();
747 if ( ! $review_prompt ) {
748 return;
749 }
750 update_option( 'wpfunnels_wizard_nps_triggered', '1' );
751 $review_prompt->trigger_prompt( 'onboarding_complete', [
752 'modal_title' => 'How was your setup experience?',
753 'nps_question' => $nps_question,
754 'feedback_msg' => $feedback_msg,
755 ] );
756 }
757
758 /**
759 * Queue an NPS prompt after the user creates their first funnel (wizard or non-wizard).
760 *
761 * Fires once — guarded by a wp_options flag. For wizard users the wizard-complete
762 * NPS (maybe_trigger_onboarding_nps) will overwrite this trigger context on the
763 * same page-load cycle, so wizard users see the more contextual wizard copy.
764 * Non-wizard users see the first-funnel copy.
765 *
766 * @param int|string $funnel_id Newly created funnel ID.
767 * @param string $funnel_type Funnel type identifier.
768 */
769 public function maybe_trigger_first_funnel_nps( $funnel_id, $funnel_type = '' ): void {
770 if ( get_option( 'wpfunnels_first_funnel_nps_triggered' ) ) {
771 return;
772 }
773 $review_prompt = $this->get_review_prompt_instance();
774 if ( ! $review_prompt ) {
775 return;
776 }
777 $funnel_tyep = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true ) ?: 'woo';
778 $nps_question = 'How easy was it to create your first funnel with WPFunnels?';
779 $feedback_msg = "We're sorry to hear that. What got in your way?";
780 if ( 'store_checkout' === $funnel_tyep ) {
781 $nps_question = 'How likely are you to recommend WPFunnels to your friends or colleagues based on your checkout setup experience?';
782 }
783
784 update_option( 'wpfunnels_first_funnel_nps_triggered', '1' );
785 $review_prompt->trigger_prompt( 'first_funnel_created', [
786 'modal_title' => 'You created your first funnel!',
787 'nps_question' => $nps_question,
788 'feedback_msg' => $feedback_msg,
789 ] );
790 }
791
792 // -------------------------------------------------------------------------
793 // Helpers
794 // -------------------------------------------------------------------------
795
796 /**
797 * Return the ReviewPrompt instance safely, guarding against stale PHP opcache
798 * that may not yet have the get_review_prompt() method available.
799 *
800 * @return \LinnoSDK\Telemetry\ReviewPrompt|null
801 */
802 private function get_review_prompt_instance(): ?\LinnoSDK\Telemetry\ReviewPrompt {
803 if ( ! $this->client ) {
804 return null;
805 }
806 try {
807 return $this->client->get_review_prompt();
808 } catch ( \Throwable $e) {
809 return null;
810 }
811 }
812
813 // -------------------------------------------------------------------------
814 // Deactivation feedback
815 // -------------------------------------------------------------------------
816
817 /**
818 * Find and remove the SDK's admin_footer deactivation modal for WPFunnels.
819 *
820 * Runs at priority 0 so it fires before the SDK's default priority-10 callback.
821 * We cannot use get_deactivation() on Client because another plugin may have
822 * loaded its own Client.php first (PHP only keeps the first class definition).
823 * Instead we identify WPFunnels' Deactivation instance by the plugin_action_links
824 * hook it uniquely registers for WPFNL_FILE, then remove its admin_footer callback.
825 */
826 public function remove_sdk_deactivation_modal() {
827 global $wp_filter;
828
829 if ( ! isset( $wp_filter['admin_footer'] ) ) {
830 return;
831 }
832
833 $links_hook = 'plugin_action_links_' . plugin_basename( WPFNL_FILE );
834
835 foreach ( $wp_filter['admin_footer']->callbacks as $priority => $callbacks ) {
836 foreach ( $callbacks as $cb ) {
837 $fn = $cb['function'];
838 if ( ! is_array( $fn ) || ! ( $fn[0] instanceof \LinnoSDK\Telemetry\Deactivation ) ) {
839 continue;
840 }
841 if ( has_filter( $links_hook, [ $fn[0], 'filter_plugin_action_links' ] ) ) {
842 remove_action( 'admin_footer', [ $fn[0], 'add_deactivation_feedback_modal' ], $priority );
843 return;
844 }
845 }
846 }
847 }
848
849 /**
850 * Render WPFunnels' own deactivation modal on the plugins.php page.
851 *
852 * This replaces the SDK's generic modal (unhooked via remove_sdk_deactivation_modal()) so
853 * WPFunnels uses isolated wpfnl-dr-/wpfnl-de- CSS class names and one-click
854 * UX without conflicting with mail-mint or wpvr modals on the same page.
855 * The SDK's plugin_action_links filter and AJAX handler remain active.
856 */
857 public function render_deactivation_modal() {
858 global $pagenow;
859 if ( 'plugins.php' !== $pagenow ) {
860 return;
861 }
862
863 $nonce = wp_create_nonce( 'wpfunnels-deactivation-nonce' );
864 $reasons = apply_filters( 'wpfunnels_telemetry_deactivation_reasons', [] );
865
866 if ( empty( $reasons ) ) {
867 return;
868 }
869 ?>
870 <style type="text/css">
871 .wpfnl-dr-modal {
872 position: fixed;
873 z-index: 99999;
874 top: 0; right: 0; bottom: 0; left: 0;
875 background: rgba(0,0,0,0.5);
876 display: none;
877 box-sizing: border-box;
878 overflow: scroll;
879 }
880 .wpfnl-dr-modal * { box-sizing: border-box; }
881 .wpfnl-dr-modal.modal-active { display: block; }
882 .wpfnl-dr-modal-wrap {
883 max-width: 780px;
884 width: 100%;
885 position: relative;
886 margin: 8% auto;
887 background: #fff;
888 border-radius: 8px;
889 box-shadow: 0 8px 32px rgba(0,0,0,0.18);
890 }
891 .wpfnl-dr-modal-header {
892 border-bottom: 1px solid #E8E8E8;
893 padding: 24px 24px 16px 24px;
894 }
895 .wpfnl-dr-modal-header h3 {
896 line-height: 1.5;
897 margin: 0 0 4px 0;
898 color: #1a1a2e;
899 font-size: 16px;
900 font-weight: 600;
901 }
902 .wpfnl-dr-modal-subheading { margin: 0; color: #718096; font-size: 13px; }
903 .wpfnl-dr-modal-body { padding: 20px 24px 8px 24px; }
904 .wpfnl-dr-modal-footer {
905 border-top: 1px solid #E8E8E8;
906 padding: 16px 24px;
907 display: flex;
908 align-items: center;
909 justify-content: flex-end;
910 }
911 ul.wpfnl-de-reasons {
912 display: flex;
913 flex-wrap: wrap;
914 margin: 0 -6px 12px -6px;
915 padding: 0;
916 list-style: none;
917 }
918 ul.wpfnl-de-reasons li { padding: 0 6px; margin: 0 0 12px 0; width: 25%; }
919 .wpfnl-de-reason-btn {
920 width: 100%;
921 border: 1.5px solid #E8E8E8;
922 border-radius: 6px;
923 background: #fff;
924 cursor: pointer;
925 text-align: center;
926 padding: 14px 8px 12px 8px;
927 transition: border-color 0.15s, background 0.15s, transform 0.1s;
928 display: flex;
929 flex-direction: column;
930 align-items: center;
931 gap: 8px;
932 height: 100%;
933 }
934 .wpfnl-de-reason-btn:hover {
935 border-color: #7B68EE;
936 background: #f5f3ff;
937 transform: translateY(-1px);
938 }
939 .wpfnl-de-reason-btn:active { transform: translateY(0); }
940 .wpfnl-de-reason-btn:disabled { cursor: default; opacity: 0.6; }
941 .wpfnl-de-reason-text { color: #4A5568; font-size: 12px; line-height: 1.4; }
942 .wpfnl-de-reason-icon { display: flex; align-items: center; justify-content: center; }
943 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-btn {
944 background: #7B68EE;
945 border-color: #7B68EE;
946 }
947 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-icon svg,
948 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-icon svg g { fill: #fff; }
949 ul.wpfnl-de-reasons li.wpfnl-de-reason-selected .wpfnl-de-reason-text { color: #fff; }
950 .wpfnl-dr-btn-cancel,
951 .wpfnl-dr-btn-cancel:hover {
952 border: 1px solid #EBEBEB;
953 border-radius: 4px;
954 font-size: 13px;
955 line-height: 1.5;
956 color: #718096;
957 padding: 6px 14px;
958 cursor: pointer;
959 background-color: transparent;
960 text-decoration: none;
961 }
962 </style>
963
964 <div class="wpfnl-dr-modal" id="wpfunnels-dr-modal">
965 <div class="wpfnl-dr-modal-wrap">
966 <div class="wpfnl-dr-modal-header">
967 <h3><?php esc_html_e( "Quick question before you go — what's the main reason?", 'wpfnl' ); ?></h3>
968 <p class="wpfnl-dr-modal-subheading"><?php esc_html_e( 'One click is all it takes. Your feedback helps us improve.', 'wpfnl' ); ?></p>
969 </div>
970 <div class="wpfnl-dr-modal-body">
971 <ul class="wpfnl-de-reasons">
972 <?php foreach ( $reasons as $reason ) : ?>
973 <li data-reason-id="<?php echo esc_attr( $reason['id'] ); ?>">
974 <button type="button" class="wpfnl-de-reason-btn">
975 <div class="wpfnl-de-reason-icon"><?php echo $reason['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
976 <div class="wpfnl-de-reason-text"><?php echo esc_html( $reason['text'] ); ?></div>
977 </button>
978 </li>
979 <?php endforeach; ?>
980 </ul>
981 </div>
982 <div class="wpfnl-dr-modal-footer">
983 <button type="button" class="wpfnl-dr-btn-cancel"><?php esc_html_e( 'Cancel', 'wpfnl' ); ?></button>
984 </div>
985 </div>
986 </div>
987
988 <script type="text/javascript">
989 (function($) {
990 $(function() {
991 var modal = $('#wpfunnels-dr-modal');
992 var deactivateLink = '';
993 var submitting = false;
994
995 $('#the-list').on('click', 'a.wpfunnels-deactivation-link', function(e) {
996 e.preventDefault();
997 deactivateLink = $(this).attr('href');
998 submitting = false;
999 modal.find('.wpfnl-de-reason-btn').prop('disabled', false);
1000 modal.find('li').removeClass('wpfnl-de-reason-selected');
1001 modal.addClass('modal-active');
1002 });
1003
1004 modal.on('click', 'button.wpfnl-dr-btn-cancel', function(e) {
1005 e.preventDefault();
1006 modal.removeClass('modal-active');
1007 });
1008
1009 modal.on('click', '.wpfnl-de-reason-btn', function() {
1010 if (submitting || !deactivateLink) return;
1011 submitting = true;
1012
1013 var reasonId = $(this).closest('li').data('reason-id');
1014 $(this).closest('li').addClass('wpfnl-de-reason-selected');
1015 modal.find('.wpfnl-de-reason-btn').prop('disabled', true);
1016
1017 $.ajax({
1018 url: ajaxurl,
1019 type: 'POST',
1020 data: {
1021 nonce: '<?php echo esc_js( $nonce ); ?>',
1022 action: 'wpfunnels_submit_deactivation_reason',
1023 reason_id: reasonId,
1024 reason_info: ''
1025 },
1026 complete: function() {
1027 window.location.href = deactivateLink;
1028 }
1029 });
1030 });
1031 });
1032 }(jQuery));
1033 </script>
1034 <?php
1035 }
1036
1037 /**
1038 * Replace the generic SDK deactivation reasons with WPFunnels-specific ones.
1039 *
1040 * Hooked on wpfunnels_telemetry_deactivation_reasons. Called by render_deactivation_modal()
1041 * via apply_filters to populate WPFunnels' own modal.
1042 *
1043 * @param array $reasons Default reasons from the SDK.
1044 * @return array
1045 */
1046 public function filter_deactivation_reasons( array $reasons ): array {
1047 return [
1048
1049 [
1050 'id' => 'plugin-conflict',
1051 'text' => __( 'Plugin conflict', 'wpfnl' ),
1052 '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>',
1053 ],
1054 [
1055 'id' => 'payment-not-working',
1056 'text' => __( 'Payment not working', 'wpfnl' ),
1057 '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>',
1058 ],
1059 [
1060 'id' => 'funnel-builder-hard-to-use',
1061 'text' => __( 'Funnel builder too complex', 'wpfnl' ),
1062 '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>',
1063 ],
1064 [
1065 'id' => 'checkout-not-working',
1066 'text' => __( 'Checkout not working', 'wpfnl' ),
1067 '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>',
1068 ],
1069 [
1070 'id' => 'upsell-downsell-not-working',
1071 'text' => __( 'Upsell / downsell issues', 'wpfnl' ),
1072 '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>',
1073 ],
1074 [
1075 'id' => 'missing-feature',
1076 'text' => __( 'Missing a feature I need', 'wpfnl' ),
1077 '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>',
1078 ],
1079 [
1080 'id' => 'template-not-good-enough',
1081 'text' => __( 'Templates are not good enough', 'wpfnl' ),
1082 '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>',
1083 ],
1084 [
1085 'id' => 'lacking-payment-gateway',
1086 'text' => __( 'Lack of payment gateway options', 'wpfnl' ),
1087 '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>',
1088 ],
1089 [
1090 'id' => 'found-better-plugin',
1091 'text' => __( 'Switching to another plugin', 'wpfnl' ),
1092 '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>',
1093 ],
1094 [
1095 'id' => 'temporary',
1096 'text' => __( 'Temporarily deactivating', 'wpfnl' ),
1097 '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>',
1098 ],
1099 ];
1100 }
1101
1102 // -------------------------------------------------------------------------
1103 // Aha milestone (per-funnel first live sale)
1104 // -------------------------------------------------------------------------
1105
1106 /**
1107 * Fire the Aha milestone event exactly once per funnel on the first order.
1108 *
1109 * @param int|string $order_id Order ID.
1110 * @param int|string $funnel_id Funnel ID.
1111 * @param int|string $checkout_id Checkout step ID.
1112 */
1113 public function handle_aha_milestone( $order_id, $funnel_id, $checkout_id ): void {
1114 $funnel_id = absint( $funnel_id );
1115 if ( ! $funnel_id ) {
1116 return;
1117 }
1118 if ( $this->has_aha_completed( $funnel_id ) ) {
1119 return;
1120 }
1121 try {
1122 if ( $this->client ) {
1123 $this->client->track_kui( 'first_live_sale', $this->aha_payload( $order_id, $funnel_id ) );
1124 }
1125 } catch ( \Exception $e ) {
1126 // Failure-safe: telemetry errors must never block order processing.
1127 }
1128 $this->mark_aha_completed( $funnel_id, $order_id );
1129 }
1130
1131 /**
1132 * @param int $funnel_id Funnel post ID.
1133 * @return bool
1134 */
1135 public function has_aha_completed( int $funnel_id ): bool {
1136 return (bool) get_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', true );
1137 }
1138
1139 /**
1140 * @param int $funnel_id Funnel post ID.
1141 * @param int|string $order_id Order that triggered the milestone.
1142 */
1143 public function mark_aha_completed( int $funnel_id, $order_id ): void {
1144 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', '1' );
1145 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_at', $this->current_timestamp() );
1146 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_order', absint( $order_id ) );
1147 }
1148
1149 /**
1150 * @param int|string $order_id Order ID.
1151 * @param int $funnel_id Funnel ID.
1152 * @return array<string,mixed>
1153 */
1154 public function aha_payload( $order_id, int $funnel_id ): array {
1155 return [
1156 'funnel_id' => $funnel_id,
1157 '__timestamp' => $this->current_timestamp(),
1158 ];
1159 }
1160
1161 /** @return string ISO-8601 UTC timestamp. */
1162 private function current_timestamp(): string {
1163 return gmdate( 'c' );
1164 }
1165 }
1166