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

649 lines 28.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
144 // -------------------------------------------------------------------------
145 // Public API — called from REST controllers
146 // -------------------------------------------------------------------------
147
148 /**
149 * Persist the user's consent decision and sync the telemetry client.
150 *
151 * Must be called as early as possible (Welcome step proceed) so subsequent
152 * step-tracking calls pass the opt-in gate inside the SDK.
153 *
154 * @param bool $consented True when the user checked the data-sharing box.
155 */
156 public function save_consent( bool $consented ): void {
157 if ( ! $this->client ) {
158 return;
159 }
160 $this->client->set_optin_state( $consented ? 'yes' : 'no' );
161 }
162
163 /**
164 * Dispatch a step-view or step-completion event from the REST layer.
165 *
166 * @param array $data Keys: event_type (viewed|completed), step_name, step_index, goal, time_on_step.
167 */
168 public function track_onboarding_step( array $data ): void {
169 do_action( 'wpfunnels_onboarding_step_tracked', $data );
170 }
171
172 /**
173 * Dispatch the unified onboarding outcome event from the REST layer.
174 *
175 * @param array $data Keys: outcome, last_step_name, last_step_index, total_steps, goal, funnel_id.
176 */
177 public function track_onboarding_progress( array $data ): void {
178 do_action( 'wpfunnels_onboarding_progress', $data );
179 }
180
181 // -------------------------------------------------------------------------
182 // Payload callbacks
183 // -------------------------------------------------------------------------
184
185 /**
186 * Onboarding completion payload — sent once when the wizard finishes.
187 *
188 * @param int $funnel_id Funnel ID created during setup.
189 * @param string $action Button clicked at the Complete step (e.g. goToDashboard).
190 * @param string $goal Goal the user chose (order-value | improve-checkout | sales).
191 *
192 * @return array<string,mixed>
193 */
194 public function setup_payload( int $_funnel_id = 0, string $action = '', string $goal = '' ): array {
195 $start_time = (int) get_option( 'wpfunnels_onboarding_start_time', 0 );
196 $elapsed = $start_time ? ( time() - $start_time ) : 0;
197
198 return [
199 'action' => sanitize_text_field( $action ),
200 'goal' => sanitize_text_field( $goal ),
201 'time_to_complete_seconds' => $elapsed,
202 '__timestamp' => $this->current_timestamp(),
203 ];
204 }
205
206 /**
207 * Feature usage payload for order bumps and upsells.
208 *
209 * @param int|string $funnel_id Funnel ID.
210 * @param int|string $step_id Step ID (0 when not applicable).
211 * @param mixed ...$_extra Additional hook args silently ignored.
212 *
213 * @return array<string,mixed>
214 */
215 public function feature_usage_payload( $funnel_id = 0, $step_id = 0, ...$_extra ): array {
216 return [
217 'funnel_id' => absint( $funnel_id ),
218 '__timestamp' => $this->current_timestamp(),
219 ];
220 }
221
222 /**
223 * Feature usage payload for template imports — includes template context.
224 *
225 * @param int|string $funnel_id Funnel ID.
226 * @param int|string $template_id Template ID.
227 * @param mixed ...$_extra Additional hook args silently ignored.
228 *
229 * @return array<string,mixed>
230 */
231 public function template_imported_payload( $funnel_id = 0, $template_id = 0, ...$_extra ): array {
232 return [
233 'funnel_id' => absint( $funnel_id ),
234 'template_id' => absint( $template_id ),
235 '__timestamp' => $this->current_timestamp(),
236 ];
237 }
238
239 // -------------------------------------------------------------------------
240 // Funnel lifecycle handlers
241 // -------------------------------------------------------------------------
242
243 /**
244 * Track funnel creation in PostHog.
245 *
246 * Hooked on wpfunnels_after_funnel_created (same hook used by EventTracker
247 * for the legacy first_strike event).
248 *
249 * @param int|string $funnel_id Funnel ID.
250 * @param string $funnel_type Funnel type identifier.
251 */
252 public function handle_funnel_created( $funnel_id, $funnel_type ): void {
253 if ( ! $this->client ) {
254 return;
255 }
256 try {
257 $this->client->track( 'activation/funnel_created', [
258 'funnel_id' => absint( $funnel_id ),
259 'funnel_type' => sanitize_text_field( (string) $funnel_type ),
260 '__timestamp' => $this->current_timestamp(),
261 ] );
262 } catch ( \Exception $e ) {
263 // Failure-safe.
264 }
265 }
266
267 /**
268 * Track funnel publish transition and fire the wpfunnels_funnel_published hook.
269 *
270 * Listens on transition_post_status for all post types — returns early for
271 * anything that is not a wpfunnels post moving into publish status.
272 *
273 * @param string $new_status Post status after transition.
274 * @param string $old_status Post status before transition.
275 * @param \WP_Post $post The post being transitioned.
276 */
277 public function handle_funnel_published( string $new_status, string $old_status, \WP_Post $post ): void {
278 if ( 'publish' !== $new_status || 'publish' === $old_status ) {
279 return;
280 }
281 if ( 'wpfunnels' !== $post->post_type ) {
282 return;
283 }
284 if ( ! $this->client ) {
285 return;
286 }
287
288 $created_ts = strtotime( $post->post_date_gmt );
289 $days_created = $created_ts ? round( ( time() - $created_ts ) / DAY_IN_SECONDS, 1 ) : 0;
290
291 try {
292 $this->client->track( 'activation/funnel_published', [
293 'funnel_id' => $post->ID,
294 'funnel_type' => get_post_meta( $post->ID, '_wpfnl_funnel_type', true ) ?: 'unknown',
295 'days_since_created' => $days_created,
296 '__timestamp' => $this->current_timestamp(),
297 ] );
298 } catch ( \Exception $e ) {
299 // Failure-safe.
300 }
301 }
302
303 // -------------------------------------------------------------------------
304 // Onboarding step handlers
305 // -------------------------------------------------------------------------
306
307 /**
308 * Send a step_viewed or step_completed PostHog event.
309 *
310 * Fires when wpfunnels_onboarding_step_tracked is dispatched from the REST
311 * track-step endpoint.
312 *
313 * @param array $data {
314 * @type string $event_type 'viewed' or 'completed'
315 * @type string $step_name Step slug (e.g. welcome, choose_goal)
316 * @type int $step_index 1-based step number
317 * @type string $goal Selected goal or empty string
318 * @type int $time_on_step Seconds spent on the step before proceeding
319 * }
320 */
321 public function handle_onboarding_step( array $data ): void {
322 if ( ! $this->client ) {
323 return;
324 }
325
326 $event_type = sanitize_key( $data['event_type'] ?? 'viewed' );
327 $event_map = [
328 'viewed' => 'onboarding/step_viewed',
329 'completed' => 'onboarding/step_completed',
330 ];
331
332 $event = $event_map[ $event_type ] ?? null;
333 if ( ! $event ) {
334 return;
335 }
336
337 try {
338 $this->client->track( $event, [
339 'step_name' => sanitize_key( $data['step_name'] ?? '' ),
340 'step_index' => absint( $data['step_index'] ?? 0 ),
341 'goal' => sanitize_text_field( $data['goal'] ?? '' ),
342 'time_on_step' => absint( $data['time_on_step'] ?? 0 ),
343 '__timestamp' => $this->current_timestamp(),
344 ] );
345 } catch ( \Exception $e ) {
346 // Failure-safe.
347 }
348
349 // Stamp the wizard start time on first step view so setup_payload can
350 // compute time_to_complete_seconds.
351 if ( 1 === absint( $data['step_index'] ?? 0 ) && 'viewed' === $event_type ) {
352 if ( ! get_option( 'wpfunnels_onboarding_start_time' ) ) {
353 update_option( 'wpfunnels_onboarding_start_time', time(), false );
354 }
355 }
356 }
357
358 /**
359 * Send the unified onboarding_progress PostHog event.
360 *
361 * Single-event model that captures the full onboarding outcome in one event.
362 * Fires for all three terminal outcomes: completed, skipped, and exited.
363 * Consent is enforced by the SDK's opt-in gate on client->track().
364 *
365 * Fires once per wizard session — guarded by a wp_options flag to prevent
366 * duplicate events from redirects or re-renders.
367 *
368 * @param array $data {
369 * @type string $outcome Terminal outcome: 'completed', 'skipped', or 'exited'
370 * @type string $last_step_name Slug of the last step reached (e.g. 'choose_goal').
371 * Aliases: 'step_name' (from the abandoned path).
372 * @type int $last_step_index 1-based index of the last step.
373 * Aliases: 'step_index' (from the abandoned path).
374 * @type int $total_steps Total steps in the wizard for this goal path
375 * @type string $goal Selected goal slug, or '' if not yet chosen
376 * @type int $funnel_id ID of funnel created (0 if none created yet)
377 * }
378 */
379 public function handle_onboarding_progress( array $data ): void {
380 if ( ! $this->client ) {
381 return;
382 }
383
384 $outcome = sanitize_key( $data['outcome'] ?? '' );
385 if ( ! in_array( $outcome, [ 'completed', 'skipped', 'exited' ], true ) ) {
386 return;
387 }
388
389 // Prevent duplicate fires within the same wizard session.
390 if ( get_option( 'wpfunnels_onboarding_progress_tracked' ) ) {
391 return;
392 }
393
394 // Support both field name conventions: abandoned path uses step_name/step_index,
395 // completion path uses last_step_name/last_step_index.
396 $last_step_name = sanitize_key( $data['last_step_name'] ?? $data['step_name'] ?? '' );
397 $last_step_index = absint( $data['last_step_index'] ?? $data['step_index'] ?? 0 );
398
399 $start_time = (int) get_option( 'wpfunnels_onboarding_start_time', 0 );
400
401 try {
402 $this->client->track( 'onboarding_progress', [
403 'outcome' => $outcome,
404 'last_step_name' => $last_step_name,
405 'last_step_index' => $last_step_index,
406 'total_steps' => absint( $data['total_steps'] ?? 0 ),
407 'goal' => sanitize_text_field( $data['goal'] ?? '' ),
408 'time_to_exit_seconds' => $start_time ? ( time() - $start_time ) : 0,
409 'funnel_id' => absint( $data['funnel_id'] ?? 0 ),
410 'plugin_version' => WPFNL_VERSION,
411 '__timestamp' => $this->current_timestamp(),
412 ] );
413
414 // Mark as fired so re-renders / redirects cannot double-fire.
415 update_option( 'wpfunnels_onboarding_progress_tracked', '1', false );
416 } catch ( \Exception $e ) {
417 // Failure-safe.
418 }
419 }
420
421 /**
422 * Queue an NPS prompt after the setup wizard completes.
423 *
424 * Overrides the default NPS copy with setup-specific messaging so the
425 * question is contextually relevant to the just-finished onboarding.
426 * Runs at priority 20 on wpfunnels_setup_wizard_complete (after telemetry
427 * tracking at priority 10).
428 *
429 * @param int $funnel_id Funnel ID.
430 * @param string $action Completion action.
431 * @param string $goal Selected goal.
432 */
433 public function maybe_trigger_onboarding_nps( int $funnel_id = 0, string $action = '', string $goal = '' ): void {
434 if ( get_option( 'wpfunnels_wizard_nps_triggered' ) ) {
435 return;
436 }
437 $funnel_tyep = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true ) ?: 'woo';
438 $nps_question = 'How easy was it to set up your first funnel with WPFunnels?';
439 $feedback_msg = "We're sorry setup was difficult. What got in your way?";
440 if ( 'store_checkout' === $funnel_tyep ) {
441 $nps_question = 'How likely are you to recommend WPFunnels to your friends or colleagues based on your checkout setup experience?';
442 }
443
444 $review_prompt = $this->get_review_prompt_instance();
445 if ( ! $review_prompt ) {
446 return;
447 }
448 update_option( 'wpfunnels_wizard_nps_triggered', '1' );
449 $review_prompt->trigger_prompt( 'onboarding_complete', [
450 'modal_title' => 'How was your setup experience?',
451 'nps_question' => $nps_question,
452 'feedback_msg' => $feedback_msg,
453 ] );
454 }
455
456 /**
457 * Queue an NPS prompt after the user creates their first funnel (wizard or non-wizard).
458 *
459 * Fires once — guarded by a wp_options flag. For wizard users the wizard-complete
460 * NPS (maybe_trigger_onboarding_nps) will overwrite this trigger context on the
461 * same page-load cycle, so wizard users see the more contextual wizard copy.
462 * Non-wizard users see the first-funnel copy.
463 *
464 * @param int|string $funnel_id Newly created funnel ID.
465 * @param string $funnel_type Funnel type identifier.
466 */
467 public function maybe_trigger_first_funnel_nps( $funnel_id, $funnel_type = '' ): void {
468 if ( get_option( 'wpfunnels_first_funnel_nps_triggered' ) ) {
469 return;
470 }
471 $review_prompt = $this->get_review_prompt_instance();
472 if ( ! $review_prompt ) {
473 return;
474 }
475 $funnel_tyep = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true ) ?: 'woo';
476 $nps_question = 'How easy was it to create your first funnel with WPFunnels?';
477 $feedback_msg = "We're sorry to hear that. What got in your way?";
478 if ( 'store_checkout' === $funnel_tyep ) {
479 $nps_question = 'How likely are you to recommend WPFunnels to your friends or colleagues based on your checkout setup experience?';
480 }
481
482 update_option( 'wpfunnels_first_funnel_nps_triggered', '1' );
483 $review_prompt->trigger_prompt( 'first_funnel_created', [
484 'modal_title' => 'You created your first funnel!',
485 'nps_question' => $nps_question,
486 'feedback_msg' => $feedback_msg,
487 ] );
488 }
489
490 // -------------------------------------------------------------------------
491 // Helpers
492 // -------------------------------------------------------------------------
493
494 /**
495 * Return the ReviewPrompt instance safely, guarding against stale PHP opcache
496 * that may not yet have the get_review_prompt() method available.
497 *
498 * @return \LinnoSDK\Telemetry\ReviewPrompt|null
499 */
500 private function get_review_prompt_instance(): ?\LinnoSDK\Telemetry\ReviewPrompt {
501 if ( ! $this->client ) {
502 return null;
503 }
504 try {
505 return $this->client->get_review_prompt();
506 } catch ( \Throwable $e) {
507 return null;
508 }
509 }
510
511 // -------------------------------------------------------------------------
512 // Deactivation feedback
513 // -------------------------------------------------------------------------
514
515 /**
516 * Replace the generic SDK deactivation reasons with WPFunnels-specific ones.
517 *
518 * Hooked on wpfunnels_telemetry_deactivation_reasons (fired by the SDK's
519 * Deactivation class). Each reason requires only 'id', 'text', and 'icon'.
520 * One-click on a card immediately submits and deactivates — no textarea needed.
521 *
522 * @param array $reasons Default reasons from the SDK.
523 * @return array
524 */
525 public function filter_deactivation_reasons( array $reasons ): array {
526 return [
527 [
528 'id' => 'funnel-builder-hard-to-use',
529 'text' => __( 'Funnel builder too complex', 'wpfnl' ),
530 '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>',
531 ],
532 [
533 'id' => 'checkout-not-working',
534 'text' => __( 'Checkout not working', 'wpfnl' ),
535 '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>',
536 ],
537 [
538 'id' => 'upsell-downsell-not-working',
539 'text' => __( 'Upsell / downsell issues', 'wpfnl' ),
540 '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>',
541 ],
542 [
543 'id' => 'plugin-conflict',
544 'text' => __( 'Plugin conflict', 'wpfnl' ),
545 '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>',
546 ],
547 [
548 'id' => 'payment-not-working',
549 'text' => __( 'Payment not working', 'wpfnl' ),
550 '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>',
551 ],
552 [
553 'id' => 'missing-feature',
554 'text' => __( 'Missing a feature I need', 'wpfnl' ),
555 '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>',
556 ],
557 [
558 'id' => 'template-not-good-enough',
559 'text' => __( 'Templates are not good enough', 'wpfnl' ),
560 '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>',
561 ],
562 [
563 'id' => 'missing-integration',
564 'text' => __( 'Missing integration I need', 'wpfnl' ),
565 'icon' => '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="#3B86FF" stroke-width="2" stroke-linecap="round"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="#3B86FF" stroke-width="2" stroke-linecap="round"/></svg>',
566 ],
567 [
568 'id' => 'found-better-plugin',
569 'text' => __( 'Switching to another plugin', 'wpfnl' ),
570 '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>',
571 ],
572 [
573 'id' => 'lacking-reporting',
574 'text' => __( 'Lack of reporting', 'wpfnl' ),
575 '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>',
576 ],
577 [
578 'id' => 'temporary',
579 'text' => __( 'Temporarily deactivating', 'wpfnl' ),
580 '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>',
581 ],
582 ];
583 }
584
585 // -------------------------------------------------------------------------
586 // Aha milestone (per-funnel first live sale)
587 // -------------------------------------------------------------------------
588
589 /**
590 * Fire the Aha milestone event exactly once per funnel on the first order.
591 *
592 * @param int|string $order_id Order ID.
593 * @param int|string $funnel_id Funnel ID.
594 * @param int|string $checkout_id Checkout step ID.
595 */
596 public function handle_aha_milestone( $order_id, $funnel_id, $checkout_id ): void {
597 $funnel_id = absint( $funnel_id );
598 if ( ! $funnel_id ) {
599 return;
600 }
601 if ( $this->has_aha_completed( $funnel_id ) ) {
602 return;
603 }
604 try {
605 if ( $this->client ) {
606 $this->client->track_kui( 'first_live_sale', $this->aha_payload( $order_id, $funnel_id ) );
607 }
608 } catch ( \Exception $e ) {
609 // Failure-safe: telemetry errors must never block order processing.
610 }
611 $this->mark_aha_completed( $funnel_id, $order_id );
612 }
613
614 /**
615 * @param int $funnel_id Funnel post ID.
616 * @return bool
617 */
618 public function has_aha_completed( int $funnel_id ): bool {
619 return (bool) get_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', true );
620 }
621
622 /**
623 * @param int $funnel_id Funnel post ID.
624 * @param int|string $order_id Order that triggered the milestone.
625 */
626 public function mark_aha_completed( int $funnel_id, $order_id ): void {
627 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', '1' );
628 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_at', $this->current_timestamp() );
629 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_order', absint( $order_id ) );
630 }
631
632 /**
633 * @param int|string $order_id Order ID.
634 * @param int $funnel_id Funnel ID.
635 * @return array<string,mixed>
636 */
637 public function aha_payload( $order_id, int $funnel_id ): array {
638 return [
639 'funnel_id' => $funnel_id,
640 '__timestamp' => $this->current_timestamp(),
641 ];
642 }
643
644 /** @return string ISO-8601 UTC timestamp. */
645 private function current_timestamp(): string {
646 return gmdate( 'c' );
647 }
648 }
649