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.4 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 All 262 releases
← All changes | includes/core/Tracking/class-wpfnl-telemetry.php +979 -100 3.10.6 → 3.13.3 View file →
@@ -5,42 +5,18 @@
5 5 use LinnoSDK\Telemetry\Client;
6 6
7 7 class Telemetry {
8 8
9 - /**
10 - * PostHog API key.
11 - *
12 - * @var string
13 - */
14 9 private const POSTHOG_API_KEY = 'phc_lpaTTy4DiOGImfWtE6NK8puO5dZ9y3lum35BxUledi4';
15 10
16 - /**
17 - * Approved feature identifiers for usage tracking.
18 - *
19 - * @var string[]
20 - */
21 - private const APPROVED_FEATURES = [
22 - 'template_library',
23 - 'order_bumps',
24 - 'upsells_downsells',
25 - ];
26 -
27 - /**
28 - * Singleton instance.
29 - *
30 - * @var Telemetry|null
31 - */
11 + /** @var Telemetry|null */
32 12 private static $instance;
33 13
34 - /**
35 - * Linno telemetry client.
36 - *
37 - * @var Client|null
38 - */
14 + /** @var Client|null */
39 15 private $client;
40 16
41 17 /**
42 - * Initialize telemetry singleton.
18 + * Initialize or return the singleton.
43 19 *
44 20 * @return Telemetry|null
45 21 */
46 22 public static function init() {
@@ -46,18 +22,20 @@
46 22 public static function init() {
47 23 if ( ! self::$instance ) {
48 24 self::$instance = new self();
49 25 }
50 -
51 26 return self::$instance;
52 27 }
53 28
54 29 /**
55 - * Boot the PostHog-backed telemetry client and register triggers.
30 + * Return the already-initialized singleton without creating one.
56 31 *
57 - * Failure-safe: any SDK exception is caught so telemetry never interrupts
58 - * plugin activation or other core flows.
32 + * @return Telemetry|null
59 33 */
34 + public static function instance(): ?self {
35 + return self::$instance;
36 + }
37 +
60 38 private function __construct() {
61 39 if ( ! class_exists( '\\LinnoSDK\\Telemetry\\Client' ) ) {
62 40 return;
63 41 }
@@ -70,14 +48,15 @@
70 48 'version' => WPFNL_VERSION,
71 49 'driver' => 'posthog',
72 50 'driver_config' => [
73 51 'api_key' => self::POSTHOG_API_KEY,
74 - 'host' => 'https://eu.i.posthog.com',
52 + 'host' => 'https://eu.i.posthog.com',
75 53 ],
76 54 'review_prompt' => [
77 55 'webhook' => 'https://getwpfunnels.com/wp-json/mrm/v1/form/44/webhook/receive?token=b553ed8f2d6f9ff1612b4fe79c19ff9ee8a30c170eb547f1aba9376eb7ac8039',
78 56 'min_feedback_length' => 50,
79 - 'snooze_schedule' => [ 7, 30, 90 ],
57 + // Progressive snooze: first dismiss → 30 days, second → 90, third+ → 180.
58 + 'snooze_schedule' => [ 30, 90, 180 ],
80 59 'nps_question' => 'How likely are you to recommend WPFunnels to your friends or colleagues?',
81 60 'low_score_threshold' => 7,
82 61 'review_url' => 'https://wordpress.org/support/plugin/wpfunnels/reviews/#new-post',
83 62 'support_url' => 'https://getwpfunnels.com/support/',
@@ -99,20 +78,19 @@
99 78 return;
100 79 }
101 80
102 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 );
103 89 }
104 90
105 91 /**
106 - * Register all telemetry triggers: onboarding lifecycle, feature usage, and
107 - * per-funnel Aha milestone.
108 - *
109 - * Activation and deactivation lifecycle events are handled automatically by
110 - * the Linno SDK through register_activation_hook / register_deactivation_hook.
111 - * Onboarding completion is single-fire; the SDK guards repeat emissions via
112 - * has_sent_event( 'onboarding_completed' ).
113 - *
114 - * @return void
92 + * Wire up all WordPress hooks for event tracking.
115 93 */
116 94 private function register_triggers(): void {
117 95 if ( ! $this->client ) {
118 96 return;
@@ -126,9 +104,9 @@
126 104 ],
127 105 'feature_used' => [
128 106 'template_library' => [
129 107 'hook' => 'wpfunnels_template_imported',
130 - 'callback' => [ $this, 'feature_usage_payload' ],
108 + 'callback' => [ $this, 'template_imported_payload' ],
131 109 ],
132 110 'order_bumps' => [
133 111 'hook' => 'wpfunnels_order_bump_added',
134 112 'callback' => [ $this, 'feature_usage_payload' ],
@@ -136,8 +114,51 @@
136 114 'upsells_downsells' => [
137 115 'hook' => 'wpfunnels_upsell_configured',
138 116 'callback' => [ $this, 'feature_usage_payload' ],
139 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 + ],
140 161 ],
141 162 ] );
142 163 } catch ( \Exception $e ) {
143 164 // Failure-safe: trigger registration errors must not surface to callers.
@@ -142,79 +163,962 @@
142 163 } catch ( \Exception $e ) {
143 164 // Failure-safe: trigger registration errors must not surface to callers.
144 165 }
145 166
146 - // Per-funnel Aha milestone wired directly; SDK KUI thresholding is
147 - // site-scoped and cannot enforce the funnel-level single-fire guarantee.
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.
148 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' ] );
149 200 }
150 201
151 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 + // -------------------------------------------------------------------------
152 484 // Payload callbacks
153 485 // -------------------------------------------------------------------------
154 486
155 487 /**
156 - * Onboarding completion payload.
488 + * Onboarding completion payload — sent once when the wizard finishes.
157 489 *
158 - * @param int $funnel_id Funnel id passed by the setup wizard hook.
159 - * @param string $action Setup action label.
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).
160 493 *
161 494 * @return array<string,mixed>
162 495 */
163 - public function setup_payload( int $funnel_id = 0, string $action = '' ): array {
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 +
164 500 return [
165 - 'action' => sanitize_text_field( $action ),
166 - '__timestamp' => $this->current_timestamp(),
501 + 'action' => sanitize_text_field( $action ),
502 + 'goal' => sanitize_text_field( $goal ),
503 + 'time_to_complete_seconds' => $elapsed,
504 + '__timestamp' => $this->current_timestamp(),
167 505 ];
168 506 }
169 507
170 508 /**
171 - * Feature usage payload.
509 + * Feature usage payload for order bumps and upsells.
172 510 *
173 - * Accepts the arguments supplied by whichever product-action hook fired.
174 - * Only approved feature identifiers are passed (validated by the trigger map
175 - * itself), and no customer PII is included.
176 - *
177 - * @param int|string $funnel_id Funnel id.
178 - * @param int|string $step_id Step id (0 when not applicable).
511 + * @param int|string $funnel_id Funnel ID.
512 + * @param int|string $step_id Step ID (0 when not applicable).
179 513 * @param mixed ...$_extra Additional hook args silently ignored.
180 514 *
181 515 * @return array<string,mixed>
182 516 */
183 517 public function feature_usage_payload( $funnel_id = 0, $step_id = 0, ...$_extra ): array {
184 - $payload = [
518 + return [
519 + 'funnel_id' => absint( $funnel_id ),
185 520 '__timestamp' => $this->current_timestamp(),
186 521 ];
187 - return $payload;
188 522 }
189 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 +
190 541 // -------------------------------------------------------------------------
191 - // Aha milestone (per-funnel first live sale)
542 + // Funnel lifecycle handlers
192 543 // -------------------------------------------------------------------------
193 544
194 545 /**
195 - * Handle the funnel order placed hook for the per-funnel Aha milestone.
546 + * Track funnel creation in PostHog.
196 547 *
197 - * Fires the Aha event exactly once per funnel. Duplicate, retried, or
198 - * replayed order events for the same funnel are silently skipped.
548 + * Hooked on wpfunnels_after_funnel_created (same hook used by EventTracker
549 + * for the legacy first_strike event).
199 550 *
200 - * @param int|string $order_id Order id.
201 - * @param int|string $funnel_id Funnel id.
202 - * @param int|string $checkout_id Checkout step id.
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.
203 571 *
204 - * @return void
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.
205 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 + */
206 1113 public function handle_aha_milestone( $order_id, $funnel_id, $checkout_id ): void {
207 1114 $funnel_id = absint( $funnel_id );
208 -
209 1115 if ( ! $funnel_id ) {
210 1116 return;
211 1117 }
212 -
213 1118 if ( $this->has_aha_completed( $funnel_id ) ) {
214 1119 return;
215 1120 }
216 -
217 1121 try {
218 1122 if ( $this->client ) {
219 1123 $this->client->track_kui( 'first_live_sale', $this->aha_payload( $order_id, $funnel_id ) );
220 1124 }
@@ -220,17 +1124,13 @@
220 1124 }
221 1125 } catch ( \Exception $e ) {
222 1126 // Failure-safe: telemetry errors must never block order processing.
223 1127 }
224 -
225 1128 $this->mark_aha_completed( $funnel_id, $order_id );
226 1129 }
227 1130
228 1131 /**
229 - * Check whether the first-live-sale Aha milestone has been recorded for a funnel.
230 - *
231 1132 * @param int $funnel_id Funnel post ID.
232 - *
233 1133 * @return bool
234 1134 */
235 1135 public function has_aha_completed( int $funnel_id ): bool {
236 1136 return (bool) get_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', true );
@@ -236,19 +1136,10 @@
236 1136 return (bool) get_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', true );
237 1137 }
238 1138
239 1139 /**
240 - * Persist the Aha milestone state for a funnel.
241 - *
242 - * Stores three meta keys so the milestone is traceable:
243 - * - _wpfnl_first_live_sale_completed (boolean flag)
244 - * - _wpfnl_first_live_sale_at (UTC ISO-8601 timestamp)
245 - * - _wpfnl_first_live_sale_order (order ID)
246 - *
247 1140 * @param int $funnel_id Funnel post ID.
248 1141 * @param int|string $order_id Order that triggered the milestone.
249 - *
250 - * @return void
251 1142 */
252 1143 public function mark_aha_completed( int $funnel_id, $order_id ): void {
253 1144 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_completed', '1' );
254 1145 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_at', $this->current_timestamp() );
@@ -255,32 +1146,20 @@
255 1146 update_post_meta( $funnel_id, '_wpfnl_first_live_sale_order', absint( $order_id ) );
256 1147 }
257 1148
258 1149 /**
259 - * Build the Aha milestone payload.
260 - *
261 - * Excludes all customer PII. Only funnel context and the order ID are included.
262 - *
263 - * @param int|string $order_id Order id.
264 - * @param int $funnel_id Funnel id.
265 - *
1150 + * @param int|string $order_id Order ID.
1151 + * @param int $funnel_id Funnel ID.
266 1152 * @return array<string,mixed>
267 1153 */
268 1154 public function aha_payload( $order_id, int $funnel_id ): array {
269 1155 return [
1156 + 'funnel_id' => $funnel_id,
270 1157 '__timestamp' => $this->current_timestamp(),
271 1158 ];
272 1159 }
273 1160
274 - // -------------------------------------------------------------------------
275 - // Helpers
276 - // -------------------------------------------------------------------------
277 -
278 - /**
279 - * Return current UTC timestamp for analytics ingestion.
280 - *
281 - * @return string ISO-8601 UTC timestamp.
282 - */
1161 + /** @return string ISO-8601 UTC timestamp. */
283 1162 private function current_timestamp(): string {
284 1163 return gmdate( 'c' );
285 1164 }
286 1165 }