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 +557 -21 3.10.7 → 3.13.3 View file →
@@ -73,13 +73,20 @@
73 73 WPFNL_INTEGRATIONS_MAIN_PAGE_SLUG,
74 74 ],
75 75 ],
76 76 ] );
77 - } catch ( \Exception ) {
77 + } catch ( \Exception $e ) {
78 78 return;
79 79 }
80 80
81 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 );
82 89 }
83 90
84 91 /**
85 92 * Wire up all WordPress hooks for event tracking.
@@ -107,11 +114,54 @@
107 114 'upsells_downsells' => [
108 115 'hook' => 'wpfunnels_upsell_configured',
109 116 'callback' => [ $this, 'feature_usage_payload' ],
110 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 + ],
111 161 ],
112 162 ] );
113 - } catch ( \Exception ) {
163 + } catch ( \Exception $e ) {
114 164 // Failure-safe: trigger registration errors must not surface to callers.
115 165 }
116 166
117 167 // Funnel lifecycle → PostHog.
@@ -134,11 +184,227 @@
134 184 add_action( 'wpfunnels/funnel_order_placed', [ $this, 'handle_aha_milestone' ], 10, 3 );
135 185
136 186 // Replace generic SDK deactivation reasons with WPFunnels-specific ones.
137 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' ] );
138 200 }
139 201
140 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 + // -------------------------------------------------------------------------
141 407 // Public API — called from REST controllers
142 408 // -------------------------------------------------------------------------
143 409
144 410 /**
@@ -156,8 +422,48 @@
156 422 $this->client->set_optin_state( $consented ? 'yes' : 'no' );
157 423 }
158 424
159 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 + /**
160 466 * Dispatch a step-view or step-completion event from the REST layer.
161 467 *
162 468 * @param array $data Keys: event_type (viewed|completed), step_name, step_index, goal, time_on_step.
163 469 */
@@ -254,9 +560,9 @@
254 560 'funnel_id' => absint( $funnel_id ),
255 561 'funnel_type' => sanitize_text_field( (string) $funnel_type ),
256 562 '__timestamp' => $this->current_timestamp(),
257 563 ] );
258 - } catch ( \Exception ) {
564 + } catch ( \Exception $e ) {
259 565 // Failure-safe.
260 566 }
261 567 }
262 568
@@ -290,9 +596,9 @@
290 596 'funnel_type' => get_post_meta( $post->ID, '_wpfnl_funnel_type', true ) ?: 'unknown',
291 597 'days_since_created' => $days_created,
292 598 '__timestamp' => $this->current_timestamp(),
293 599 ] );
294 - } catch ( \Exception ) {
600 + } catch ( \Exception $e ) {
295 601 // Failure-safe.
296 602 }
297 603 }
298 604
@@ -337,9 +643,9 @@
337 643 'goal' => sanitize_text_field( $data['goal'] ?? '' ),
338 644 'time_on_step' => absint( $data['time_on_step'] ?? 0 ),
339 645 '__timestamp' => $this->current_timestamp(),
340 646 ] );
341 - } catch ( \Exception ) {
647 + } catch ( \Exception $e ) {
342 648 // Failure-safe.
343 649 }
344 650
345 651 // Stamp the wizard start time on first step view so setup_payload can
@@ -408,9 +714,9 @@
408 714 ] );
409 715
410 716 // Mark as fired so re-renders / redirects cannot double-fire.
411 717 update_option( 'wpfunnels_onboarding_progress_tracked', '1', false );
412 - } catch ( \Exception ) {
718 + } catch ( \Exception $e ) {
413 719 // Failure-safe.
414 720 }
415 721 }
416 722
@@ -498,9 +804,9 @@
498 804 return null;
499 805 }
500 806 try {
501 807 return $this->client->get_review_prompt();
502 - } catch ( \Throwable ) {
808 + } catch ( \Throwable $e) {
503 809 return null;
504 810 }
505 811 }
506 812
@@ -508,13 +814,232 @@
508 814 // Deactivation feedback
509 815 // -------------------------------------------------------------------------
510 816
511 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 + /**
512 1038 * Replace the generic SDK deactivation reasons with WPFunnels-specific ones.
513 1039 *
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.
1040 + * Hooked on wpfunnels_telemetry_deactivation_reasons. Called by render_deactivation_modal()
1041 + * via apply_filters to populate WPFunnels' own modal.
517 1042 *
518 1043 * @param array $reasons Default reasons from the SDK.
519 1044 * @return array
520 1045 */
@@ -519,11 +1044,17 @@
519 1044 * @return array
520 1045 */
521 1046 public function filter_deactivation_reasons( array $reasons ): array {
522 1047 return [
1048 +
523 1049 [
524 - 'id' => 'checkout-not-working',
525 - 'text' => __( 'Checkout / payment issues', 'wpfnl' ),
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' ),
526 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>',
527 1058 ],
528 1059 [
529 1060 'id' => 'funnel-builder-hard-to-use',
@@ -530,11 +1061,11 @@
530 1061 'text' => __( 'Funnel builder too complex', 'wpfnl' ),
531 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>',
532 1063 ],
533 1064 [
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>',
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>',
537 1068 ],
538 1069 [
539 1070 'id' => 'upsell-downsell-not-working',
540 1071 'text' => __( 'Upsell / downsell issues', 'wpfnl' ),
@@ -540,15 +1071,20 @@
540 1071 'text' => __( 'Upsell / downsell issues', 'wpfnl' ),
541 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>',
542 1073 ],
543 1074 [
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>',
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>',
547 1078 ],
548 1079 [
549 - 'id' => 'missing-feature',
550 - 'text' => __( 'Missing a feature I need', 'wpfnl' ),
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' ),
551 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>',
552 1088 ],
553 1089 [
554 1090 'id' => 'found-better-plugin',
@@ -585,9 +1121,9 @@
585 1121 try {
586 1122 if ( $this->client ) {
587 1123 $this->client->track_kui( 'first_live_sale', $this->aha_payload( $order_id, $funnel_id ) );
588 1124 }
589 - } catch ( \Exception ) {
1125 + } catch ( \Exception $e ) {
590 1126 // Failure-safe: telemetry errors must never block order processing.
591 1127 }
592 1128 $this->mark_aha_completed( $funnel_id, $order_id );
593 1129 }