| @@ -14,8 +14,22 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | class Cartflows_Admin_Notices { |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | + * Option key that stores whether the site has received at least one | |
| 19 | + * WooCommerce order through a CartFlows funnel. | |
| 20 | + * | |
| 21 | + * This is the success milestone used to gate the WordPress.org review | |
| 22 | + * notice — we only ask for a review after a user has seen real value | |
| 23 | + * from the plugin (revenue through a funnel), not after an arbitrary | |
| 24 | + * time-since-install delay. | |
| 25 | + * | |
| 26 | + * @since 2.2.5 | |
| 27 | + * @var string | |
| 28 | + */ | |
| 29 | + const FIRST_FUNNEL_ORDER_OPTION = 'cartflows_first_funnel_order_received'; | |
| 30 | + | |
| 31 | + /** | |
| 18 | 32 | * Instance |
| 19 | 33 | * |
| 20 | 34 | * @access private |
| 21 | 35 | * @var object Class object. |
| @@ -49,8 +63,20 @@ | ||
| 49 | 63 | $this->register_admin_notices(); |
| 50 | 64 | |
| 51 | 65 | // Group the ajax action callbacks. |
| 52 | 66 | $this->register_ajax_callbacks(); |
| 67 | + | |
| 68 | + // Track first successful CartFlows funnel order to unlock the review prompt. | |
| 69 | + // | |
| 70 | + // `woocommerce_new_order` fires *before* CartFlows saves `_wcf_flow_id` | |
| 71 | + // (the checkout module persists it on `woocommerce_checkout_update_order_meta`, | |
| 72 | + // which runs later in the same request). We therefore listen on hooks that | |
| 73 | + // run *after* the flow meta is on the order: | |
| 74 | + // | |
| 75 | + // - `woocommerce_checkout_order_processed` — classic shortcode checkout. | |
| 76 | + // - `woocommerce_store_api_checkout_order_processed` — Blocks-based checkout. | |
| 77 | + add_action( 'woocommerce_checkout_order_processed', array( $this, 'maybe_flag_first_funnel_order' ), 10, 3 ); | |
| 78 | + add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'maybe_flag_first_funnel_order' ), 10, 1 ); | |
| 53 | 79 | } |
| 54 | 80 | |
| 55 | 81 | /** |
| 56 | 82 | * Registers admin notices for CartFlows. |
| @@ -87,9 +113,9 @@ | ||
| 87 | 113 | |
| 88 | 114 | /** |
| 89 | 115 | * Persist the "Switch to New UI" dismissal for the current user. |
| 90 | 116 | * |
| 91 | - * Writes the same `notice-dismissed` user_meta key that Astra_Notices uses | |
| 117 | + * Writes the same `notice-dismissed` user_meta key that BSF_Admin_Notices uses | |
| 92 | 118 | * so the WP admin_notices banner and the React-shell banner stay in sync — |
| 93 | 119 | * dismissing either one hides both on the next load. |
| 94 | 120 | * |
| 95 | 121 | * @since 2.2.4 |
| @@ -277,9 +303,9 @@ | ||
| 277 | 303 | array( |
| 278 | 304 | 'id' => 'cartflows-5-start-notice', |
| 279 | 305 | 'type' => 'info', |
| 280 | 306 | 'class' => 'cartflows-5-star cartflows-admin-notice', |
| 281 | - 'show_if' => true, | |
| 307 | + 'show_if' => $this->should_show_review_notice(), | |
| 282 | 308 | /* translators: %1$s white label plugin name and %2$s deactivation link */ |
| 283 | 309 | 'message' => sprintf( |
| 284 | 310 | '<div class="notice-image" style="display: block;"> |
| 285 | 311 | <img src="%1$s" class="custom-logo" alt="CartFlows Icon" itemprop="logo" style="max-width: 90px; border-radius: 50px;"></div> |
| @@ -302,9 +328,9 @@ | ||
| 302 | 328 | </a> |
| 303 | 329 | |
| 304 | 330 | <a href="#" class="astra-notice-close astra-review-notice"> |
| 305 | 331 | <span class="dashicons dashicons-smiley"></span> |
| 306 | - <u>%8$s</u> | |
| 332 | + %8$s | |
| 307 | 333 | </a> |
| 308 | 334 | </div> |
| 309 | 335 | </div>', |
| 310 | 336 | $image_path, |
| @@ -309,9 +335,9 @@ | ||
| 309 | 335 | </div>', |
| 310 | 336 | $image_path, |
| 311 | 337 | __( 'Hi there! You recently used CartFlows to build a sales funnel — Thanks a ton!', 'cartflows' ), |
| 312 | 338 | __( 'It would be awesome if you could leave us a 5-star review-it helps us grow and guide others in choosing CartFlows!', 'cartflows' ), |
| 313 | - 'https://wordpress.org/support/plugin/cartflows/reviews/?filter=5#new-post', | |
| 339 | + 'https://wordpress.org/support/plugin/cartflows/reviews/#new-post', | |
| 314 | 340 | __( 'Ok, you deserve it', 'cartflows' ), |
| 315 | 341 | MONTH_IN_SECONDS, |
| 316 | 342 | __( 'Nope, maybe later', 'cartflows' ), |
| 317 | 343 | __( 'I already did', 'cartflows' ) |
| @@ -321,9 +347,9 @@ | ||
| 321 | 347 | ) |
| 322 | 348 | ); |
| 323 | 349 | |
| 324 | 350 | // CartFlows 3.0 — soft notice inviting legacy users to opt into the new admin UI. |
| 325 | - // Registered through Astra_Notices so it inherits the standard notice | |
| 351 | + // Registered through BSF_Admin_Notices so it inherits the standard notice | |
| 326 | 352 | // design, dismiss handling and per-user storage the rest of CartFlows uses. |
| 327 | 353 | if ( $this->is_new_ui_notice_visible() ) { |
| 328 | 354 | $this->enqueue_new_ui_notice_assets(); |
| 329 | 355 | |
| @@ -388,9 +414,9 @@ | ||
| 388 | 414 | 'nps_rating_message' => __( 'How likely are you to recommend #pluginname to your friends or colleagues?', 'cartflows' ), |
| 389 | 415 | |
| 390 | 416 | // Step 2A i.e. positive. |
| 391 | 417 | 'feedback_content' => __( 'Could you please do us a favor and give us a 5-star rating on WordPress? It would help others choose CartFlows with confidence. Thank you!', 'cartflows' ), |
| 392 | - 'plugin_rating_link' => esc_url( 'https://wordpress.org/support/plugin/cartflows/reviews/?filter=5#new-post' ), | |
| 418 | + 'plugin_rating_link' => esc_url( 'https://wordpress.org/support/plugin/cartflows/reviews/#new-post' ), | |
| 393 | 419 | |
| 394 | 420 | // Step 2B i.e. negative. |
| 395 | 421 | 'plugin_rating_title' => __( 'Thank you for your feedback', 'cartflows' ), |
| 396 | 422 | 'plugin_rating_content' => __( 'We value your input. How can we improve your experience?', 'cartflows' ), |
| @@ -527,8 +553,52 @@ | ||
| 527 | 553 | return false; |
| 528 | 554 | } |
| 529 | 555 | |
| 530 | 556 | /** |
| 557 | + * Determine whether the WordPress.org 5-star review notice should be shown. | |
| 558 | + * | |
| 559 | + * Gates the review ask on a real success milestone (first WooCommerce | |
| 560 | + * order processed through a CartFlows funnel) rather than an arbitrary | |
| 561 | + * time-since-install delay. This mirrors the usage-milestone pattern | |
| 562 | + * Elementor uses (>10 pages built) and sharpens the ask like FunnelKit's | |
| 563 | + * support-triggered outreach — both high-ROI strategies validated in the | |
| 564 | + * April 2026 WordPress.org review competitive analysis. | |
| 565 | + * | |
| 566 | + * The check is lightweight (one published-post count + one option read) | |
| 567 | + * and fires only on allowed admin screens, so the overhead is negligible. | |
| 568 | + * | |
| 569 | + * @since 2.2.5 | |
| 570 | + * @return bool True when the user has reached the success milestone. | |
| 571 | + */ | |
| 572 | + public function should_show_review_notice() { | |
| 573 | + | |
| 574 | + $total_funnels = $this->get_published_flow_count(); | |
| 575 | + $first_order_received = (bool) get_option( self::FIRST_FUNNEL_ORDER_OPTION, false ); | |
| 576 | + | |
| 577 | + $should_show = ( $total_funnels >= 1 ) && $first_order_received; | |
| 578 | + | |
| 579 | + /** | |
| 580 | + * Filters whether the WordPress.org 5-star review notice should be shown. | |
| 581 | + * | |
| 582 | + * Allows site owners, agencies, and white-label distributors to override | |
| 583 | + * the default gating logic. Return false to suppress the notice entirely, | |
| 584 | + * or true to force-show it (e.g. in staging for QA). | |
| 585 | + * | |
| 586 | + * @since 2.2.5 | |
| 587 | + * | |
| 588 | + * @param bool $should_show Whether the notice should be shown. | |
| 589 | + * @param int $total_funnels Number of published CartFlows flows. | |
| 590 | + * @param bool $first_order_received Whether the first funnel order flag is set. | |
| 591 | + */ | |
| 592 | + return (bool) apply_filters( | |
| 593 | + 'cartflows_show_review_notice', | |
| 594 | + $should_show, | |
| 595 | + $total_funnels, | |
| 596 | + $first_order_received | |
| 597 | + ); | |
| 598 | + } | |
| 599 | + | |
| 600 | + /** | |
| 531 | 601 | * Whether the "Switch to New UI" promo should be shown to the current user. |
| 532 | 602 | * |
| 533 | 603 | * Centralises the gating logic so the WP `admin_notices` render and the |
| 534 | 604 | * React-shell banner both honour the exact same conditions. |
| @@ -548,9 +618,9 @@ | ||
| 548 | 618 | if ( ! defined( 'CARTFLOWS_LEGACY_ADMIN' ) || ! CARTFLOWS_LEGACY_ADMIN ) { |
| 549 | 619 | return false; |
| 550 | 620 | } |
| 551 | 621 | |
| 552 | - // Astra_Notices' own astra-notice-close JS writes 'notice-dismissed' to | |
| 622 | + // BSF_Admin_Notices' own astra-notice-close JS writes 'notice-dismissed' to | |
| 553 | 623 | // this user_meta key when the user dismisses the notice. |
| 554 | 624 | if ( 'notice-dismissed' === get_user_meta( get_current_user_id(), 'cartflows-switch-to-new-ui-notice', true ) ) { |
| 555 | 625 | return false; |
| 556 | 626 | } |
| @@ -593,26 +663,101 @@ | ||
| 593 | 663 | ); |
| 594 | 664 | } |
| 595 | 665 | |
| 596 | 666 | /** |
| 597 | - * Check if the user has completed the onboarding, skipped the onboarding on ready step, and the store checkout is imported. | |
| 667 | + * Hook callback for the WooCommerce checkout-completion actions. | |
| 598 | 668 | * |
| 669 | + * When an order is successfully processed through a CartFlows funnel, | |
| 670 | + * persist a single option flag so the review notice can unlock. The | |
| 671 | + * option acts as an idempotent one-way switch — once set, it is never | |
| 672 | + * unset or re-evaluated on subsequent orders, keeping the hook cost at | |
| 673 | + * effectively zero after the first qualifying order. | |
| 674 | + * | |
| 675 | + * @since 2.2.5 | |
| 676 | + * | |
| 677 | + * @param int|WC_Order $order_or_id Order ID (classic checkout) or WC_Order | |
| 678 | + * (Blocks Store API passes the object). | |
| 679 | + * @param mixed $unused Unused — present so this method can also | |
| 680 | + * attach to `woocommerce_checkout_order_processed`, | |
| 681 | + * which passes `( $order_id, $posted_data, $order )`. | |
| 682 | + * @param WC_Order|null $order Order object (third arg from | |
| 683 | + * `woocommerce_checkout_order_processed`). | |
| 684 | + * @return void | |
| 685 | + */ | |
| 686 | + public function maybe_flag_first_funnel_order( $order_or_id, $unused = null, $order = null ) { | |
| 687 | + | |
| 688 | + // Cheapest possible short-circuit: once flagged, do no further work. | |
| 689 | + if ( (bool) get_option( self::FIRST_FUNNEL_ORDER_OPTION, false ) ) { | |
| 690 | + return; | |
| 691 | + } | |
| 692 | + | |
| 693 | + // Normalise the various hook signatures into a single WC_Order instance. | |
| 694 | + if ( $order_or_id instanceof WC_Order ) { | |
| 695 | + $order = $order_or_id; | |
| 696 | + } elseif ( ! $order instanceof WC_Order && function_exists( 'wc_get_order' ) ) { | |
| 697 | + $order = wc_get_order( $order_or_id ); | |
| 698 | + } | |
| 699 | + | |
| 700 | + if ( ! $order instanceof WC_Order ) { | |
| 701 | + return; | |
| 702 | + } | |
| 703 | + | |
| 704 | + $flow_meta = $order->get_meta( '_wcf_flow_id' ); | |
| 705 | + $flow_id = is_scalar( $flow_meta ) ? absint( $flow_meta ) : 0; | |
| 706 | + | |
| 707 | + if ( $flow_id > 0 ) { | |
| 708 | + update_option( self::FIRST_FUNNEL_ORDER_OPTION, 'yes', false ); | |
| 709 | + } | |
| 710 | + } | |
| 711 | + | |
| 712 | + /** | |
| 713 | + * Count of published CartFlows flows. | |
| 714 | + * | |
| 715 | + * Extracted into its own method so the gating/NPS checks share a single | |
| 716 | + * implementation that is easy to stub in unit tests. | |
| 717 | + * | |
| 718 | + * @since 2.2.5 | |
| 719 | + * @return int | |
| 720 | + */ | |
| 721 | + protected function get_published_flow_count() { | |
| 722 | + | |
| 723 | + if ( ! defined( 'CARTFLOWS_FLOW_POST_TYPE' ) ) { | |
| 724 | + return 0; | |
| 725 | + } | |
| 726 | + | |
| 727 | + $counts = wp_count_posts( CARTFLOWS_FLOW_POST_TYPE ); | |
| 728 | + | |
| 729 | + return isset( $counts->publish ) ? (int) $counts->publish : 0; | |
| 730 | + } | |
| 731 | + | |
| 732 | + /** | |
| 733 | + * Check if the user is eligible to see the NPS survey. | |
| 734 | + * | |
| 735 | + * Previous behaviour used `1 >= $total_funnels` which EXCLUDED engaged | |
| 736 | + * users with more than one published funnel — the exact audience we most | |
| 737 | + * want feedback from. This revision inverts that logic: once a user has | |
| 738 | + * reached post-onboarding engagement (store checkout set up OR at least | |
| 739 | + * one published funnel), they are eligible regardless of funnel count. | |
| 740 | + * | |
| 599 | 741 | * @since 2.1.6 |
| 742 | + * @since 2.2.5 Fixed audience logic to include power users (>1 funnel). | |
| 600 | 743 | * @return bool |
| 601 | 744 | */ |
| 602 | 745 | public function should_display_nps_survey_notice() { |
| 603 | 746 | |
| 604 | - $is_store_checkout_imported = (bool) get_option( '_cartflows_wizard_store_checkout_set', false ); // Must be true. | |
| 605 | - $onboarding_completed = (bool) get_option( 'wcf_setup_complete', false ); // Must be true. | |
| 606 | - $is_first_funnel_imported = (bool) get_option( 'wcf_first_flow_imported', false ); // Must be true. | |
| 607 | - $total_funnels = intval( wp_count_posts( CARTFLOWS_FLOW_POST_TYPE )->publish ); // Must be greater than or equal to 1. | |
| 747 | + $is_store_checkout_imported = (bool) get_option( '_cartflows_wizard_store_checkout_set', false ); | |
| 748 | + $onboarding_completed = (bool) get_option( 'wcf_setup_complete', false ); | |
| 749 | + $total_funnels = $this->get_published_flow_count(); | |
| 608 | 750 | |
| 609 | 751 | /** |
| 610 | - * Show the notice in two conditions. | |
| 611 | - * 1. If completed the onboarding steps/process of plugin and sets their first store checkout funnel successfully. | |
| 612 | - * 2. If sets up the first funnel manually and makes it live. | |
| 752 | + * Show the notice in either of these conditions: | |
| 753 | + * 1. User finished the onboarding wizard AND imported the store checkout funnel. | |
| 754 | + * 2. User has at least one published funnel — imported or built by hand. This has | |
| 755 | + * NO upper bound on funnel count, so power users still see the survey, and it | |
| 756 | + * subsumes the old "imported their first funnel" branch. | |
| 613 | 757 | */ |
| 614 | - return ( true === $is_store_checkout_imported && true === $onboarding_completed ) || ( true === $is_first_funnel_imported && ! empty( $total_funnels ) && 1 >= $total_funnels ) || ( ! empty( $total_funnels ) && 1 >= $total_funnels ); | |
| 758 | + return ( true === $is_store_checkout_imported && true === $onboarding_completed ) | |
| 759 | + || ! empty( $total_funnels ); | |
| 615 | 760 | } |
| 616 | 761 | } |
| 617 | 762 | |
| 618 | 763 | Cartflows_Admin_Notices::get_instance(); |