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

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