get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- single-row existence probe on admin screens only. $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s LIMIT 1", ADDON_BOSS_PRODUCT_OPTIONS_META ) ); } /** How long a variation count is trusted. A catalogue does not change by the minute. */ const ADDON_BOSS_VARIATION_LOAD_TTL = DAY_IN_SECONDS; /** Where the cached count lives, so the uninstall sweep takes it with the rest. */ const ADDON_BOSS_VARIATION_LOAD_KEY = 'addon_boss_variation_load'; /** * What this shop is carrying in variations right now: how many products are * variable, and how many variations they add up to. * * This is the merchant's own number, and it is the only honest way to explain * what option sets are for. "Instead of a hundred variations, one product" is * an argument; "you have 47 products carrying 1,340 variations" is their * Tuesday. A shop with none sees nothing, which is the correct behaviour: the * pitch is not for them. * * One indexed COUNT on post_type, cached for a day. Variations are their own * post type, so this never touches postmeta and never instantiates a product. * * @param bool $fresh Skip the cache. For the moment a conversion changes the answer. * @return array{products:int,variations:int} */ function addon_boss_variation_load( bool $fresh = false ): array { if ( ! $fresh ) { $cached = get_transient( ADDON_BOSS_VARIATION_LOAD_KEY ); if ( is_array( $cached ) && isset( $cached['products'], $cached['variations'] ) ) { return array( 'products' => (int) $cached['products'], 'variations' => (int) $cached['variations'], ); } } global $wpdb; $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one indexed count, cached below for a day. "SELECT COUNT(*) AS variations, COUNT(DISTINCT post_parent) AS products FROM {$wpdb->posts} WHERE post_type = 'product_variation' AND post_status IN ( 'publish', 'private' )", ARRAY_A ); $load = array( 'products' => isset( $row['products'] ) ? (int) $row['products'] : 0, 'variations' => isset( $row['variations'] ) ? (int) $row['variations'] : 0, ); set_transient( ADDON_BOSS_VARIATION_LOAD_KEY, $load, ADDON_BOSS_VARIATION_LOAD_TTL ); return $load; } /** * That count as the sentence a merchant reads, or '' when there is nothing to * say. Kept beside the counter so the wording and the numbers can never drift * apart, and so the converter can print the same line. */ function addon_boss_variation_load_line(): string { $load = addon_boss_variation_load(); if ( $load['products'] < 1 || $load['variations'] < 1 ) { return ''; } return sprintf( /* translators: 1: number of variable products, 2: number of variations they hold between them */ _n( 'This shop has %1$s variable product with %2$s variations. Option sets do the same job on one product.', 'This shop has %1$s variable products with %2$s variations. Option sets do the same job on one product.', $load['products'], 'addon-boss' ), number_format_i18n( $load['products'] ), number_format_i18n( $load['variations'] ) ); } /** * The four steps with their live state. Each carries its one next action as * plain words + a URL; an action that cannot be resolved yet (step 3 before * anything is attached) is simply absent until it can. * * @return array */ function addon_boss_onboarding_steps(): array { $sets_done = (bool) addon_boss_option_sets(); $pid = addon_boss_onboarding_attached_product_id(); $attach_done = $pid > 0; $seen_done = (bool) get_option( ADDON_BOSS_ONBOARDING_SEEN ); $order_done = (bool) get_option( ADDON_BOSS_ONBOARDING_ORDERED ); // The storefront link both later steps point at: the attached product's own // page, once it has one shoppers can reach. $view_url = ''; if ( $attach_done && 'publish' === get_post_status( $pid ) ) { $view_url = (string) get_permalink( $pid ); } return array( array( 'id' => 'sets', 'done' => $sets_done, 'title' => __( 'Build your first option set', 'addon-boss' ), 'sub' => __( 'Ready-made shapes are waiting, or build your own.', 'addon-boss' ), 'act' => __( 'Start your first set', 'addon-boss' ), 'url' => admin_url( 'admin.php?page=addon-boss-option-sets' ), ), array( 'id' => 'attach', 'done' => $attach_done, 'title' => __( 'Attach it to a product', 'addon-boss' ), 'sub' => __( 'Open a product and look for its Product options tab.', 'addon-boss' ), 'act' => __( 'Open your products', 'addon-boss' ), 'url' => admin_url( 'edit.php?post_type=product' ), ), array( 'id' => 'seen', 'done' => $seen_done, 'title' => __( 'See it on the product page', 'addon-boss' ), 'sub' => '' !== $view_url || $seen_done ? '' : __( 'Once a published product carries a set, its page link appears here.', 'addon-boss' ), 'act' => __( 'View the product page', 'addon-boss' ), 'url' => $view_url, ), array( 'id' => 'order', 'done' => $order_done, 'title' => __( 'Place a test order', 'addon-boss' ), 'sub' => __( 'Order it like a shopper would; any test payment will do.', 'addon-boss' ), 'act' => __( 'Order it yourself', 'addon-boss' ), 'url' => $view_url, ), ); } /** * The checklist card on the AddOnBoss landing screen. House card language: * the bb-editor frame, ticks that fill as the store really progresses, one * quiet dismiss that never comes back. */ function addon_boss_onboarding_card(): void { if ( ! addon_boss_onboarding_active() || ! current_user_can( 'manage_woocommerce' ) ) { return; } $steps = addon_boss_onboarding_steps(); $done = count( array_filter( array_column( $steps, 'done' ) ) ); $all_done = count( $steps ) === $done; $dismiss = wp_nonce_url( admin_url( 'admin-post.php?action=addon_boss_dismiss_onboarding' ), 'addon_boss_dismiss_onboarding' ); echo '
'; echo '
' . esc_html__( 'Your first product with options', 'addon-boss' ) . ''; echo '' . esc_html( sprintf( /* translators: 1: steps done, 2: steps in total */ __( '%1$d of %2$d done', 'addon-boss' ), $done, count( $steps ) ) ) . ''; echo '✕'; echo '
'; echo '
'; // Their own number, before the steps, when they have one. A shop with no // variable products is not carrying this problem and is never told it is. $bb_load = addon_boss_variation_load_line(); if ( '' !== $bb_load ) { // The sentence is the doorway: it leads to the variable-filtered product // list, where each row carries the rebuild action. A shop with no // variations printed nothing above, so nobody is sent to an empty list. echo '

' . esc_html( $bb_load ) . ' ' . esc_html__( 'See your variable products', 'addon-boss' ) . '

'; } echo '
    '; $bb_lead = true; // The FIRST open step's action is the card's primary and wears the house coral. foreach ( $steps as $bb_step ) { echo '
  1. '; echo ''; echo '' . esc_html( $bb_step['title'] ) . ''; if ( ! $bb_step['done'] && '' !== $bb_step['sub'] ) { echo '' . esc_html( $bb_step['sub'] ) . ''; } echo ''; if ( ! $bb_step['done'] && '' !== $bb_step['url'] ) { echo '' . esc_html( $bb_step['act'] ) . ''; } if ( ! $bb_step['done'] ) { $bb_lead = false; } echo '
  2. '; } echo '
'; if ( $all_done ) { echo '

' . esc_html__( 'All four done: your options are live and selling. Dismiss this guide whenever you like.', 'addon-boss' ) . '

'; } echo '
'; } /** * The compact echo on the Option Sets library while it is empty: the same four * steps as one quiet trail line, so the empty state says where this first set * is taking the merchant. No actions here; the page itself is step one. */ function addon_boss_onboarding_trail(): void { if ( ! addon_boss_onboarding_active() ) { return; } echo '

'; $bb_i = 0; foreach ( addon_boss_onboarding_steps() as $bb_step ) { ++$bb_i; echo ''; echo ''; echo esc_html( $bb_step['title'] ); echo ''; } echo '

'; } add_action( 'addon_boss_option_sets_empty_state', 'addon_boss_onboarding_trail' ); /** Dismiss for good: one option write, swept by the uninstall cleanup. */ function addon_boss_handle_dismiss_onboarding(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } check_admin_referer( 'addon_boss_dismiss_onboarding' ); update_option( ADDON_BOSS_ONBOARDING_DISMISSED, 'dismissed', false ); $back = wp_get_referer(); wp_safe_redirect( $back ? $back : admin_url( 'admin.php?page=addon-boss' ) ); exit; } add_action( 'admin_post_addon_boss_dismiss_onboarding', 'addon_boss_handle_dismiss_onboarding' ); // ------------------------------------------------------------------ // The activation pointer: the two cheap, conventional bridges from // "Plugin activated" to the landing screen. Without them the merchant // is stranded on the Plugins screen hunting a menu named BossMage. // ------------------------------------------------------------------ /** * A "Get started" action link on the plugin's own row of the Plugins screen, * standing before core's Deactivate. The one place every merchant certainly * looks right after activating is that row, so that is where the door goes. * * @param array $links The row's action links. * @return array The same links with Get started in front. */ function addon_boss_plugin_action_links( $links ): array { // A named key, not array_unshift: core prints each link inside a span // classed by its key, and a numeric key would class it "0". return array( 'bb-get-started' => '' . esc_html__( 'Get started', 'addon-boss' ) . '', ) + (array) $links; } add_filter( 'plugin_action_links_' . plugin_basename( ADDON_BOSS_FILE ), 'addon_boss_plugin_action_links' ); /** * The one-time doorway notice after activation: what is ready, and where to * begin. It shows while the activation marker reads '1' and the checklist is * both alive and completely untouched; the first real tick settles the marker * to 'done' (a merchant mid-checklist needs no doorway), dismissal writes * 'dismissed', and neither state ever shows it again, reactivation included. */ function addon_boss_activation_pointer_notice(): void { if ( '1' !== get_option( ADDON_BOSS_ACTIVATION_NOTICE ) || ! current_user_can( 'manage_woocommerce' ) ) { return; } if ( ! addon_boss_onboarding_active() ) { return; // The checklist was dismissed: the merchant asked for no guides. } foreach ( addon_boss_onboarding_steps() as $bb_step ) { if ( $bb_step['done'] ) { // Progress exists, so the doorway has been found. Settle the marker // for good; later admin loads skip the state probe entirely. update_option( ADDON_BOSS_ACTIVATION_NOTICE, 'done', false ); return; } } $dismiss = wp_nonce_url( admin_url( 'admin-post.php?action=addon_boss_dismiss_activation_notice' ), 'addon_boss_dismiss_activation_notice' ); echo '

'; echo esc_html__( 'AddOnBoss is ready.', 'addon-boss' ); echo ' ' . esc_html__( 'Set up your first product options', 'addon-boss' ) . ''; echo '

'; // Core's dismiss X only hides the box for the one page load; this makes the // X mean it. One capturing listener, planted with the notice itself; the // nonced URL rides the notice's own markup. addon_boss_inline_js( 'document.addEventListener( "click", function ( e ) {' . 'if ( ! e.target || ! e.target.closest || ! e.target.closest( "[data-bb-aob-hello] .notice-dismiss" ) ) { return; }' . 'var n = e.target.closest( "[data-bb-aob-hello]" );' . 'if ( n && n.getAttribute( "data-dismiss-url" ) ) { fetch( n.getAttribute( "data-dismiss-url" ), { credentials: "same-origin" } ); }' . '} );' ); } add_action( 'admin_notices', 'addon_boss_activation_pointer_notice' ); /** Dismissed is forever: the marker flips and the notice never returns. */ function addon_boss_handle_dismiss_activation_notice(): void { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to do that.', 'addon-boss' ) ); } check_admin_referer( 'addon_boss_dismiss_activation_notice' ); update_option( ADDON_BOSS_ACTIVATION_NOTICE, 'dismissed', false ); $back = wp_get_referer(); wp_safe_redirect( $back ? $back : admin_url( 'plugins.php' ) ); exit; } add_action( 'admin_post_addon_boss_dismiss_activation_notice', 'addon_boss_handle_dismiss_activation_notice' ); /** * Step 3's flag: the first storefront render of a product that offers options. * Runs just after the engine's renderer on the same hook, mirrors its "is * there anything to show" test, and writes exactly once ever. */ function addon_boss_onboarding_note_render(): void { if ( get_option( ADDON_BOSS_ONBOARDING_SEEN ) ) { return; } global $product; if ( ! $product instanceof WC_Product || ! $product->is_purchasable() ) { return; } if ( ! addon_boss_standalone_option_sets( $product ) && ! addon_boss_standalone_product_fields( $product ) ) { return; } update_option( ADDON_BOSS_ONBOARDING_SEEN, $product->get_id(), false ); } add_action( 'woocommerce_before_add_to_cart_button', 'addon_boss_onboarding_note_render', 12 ); /** * Step 4's flag: a PLACED order with a line carrying options or fields. Not the * line-item hook: block checkout builds a draft order (and its lines) the moment * the checkout page loads, so ticking there marked "Place a test order" done for * a shop that only opened checkout. These two hooks fire once the order is * really placed, classic and block checkout respectively. * * @param int|WC_Order $order_or_id The order, or its id (classic checkout passes the id first). */ function addon_boss_onboarding_note_order( $order_or_id ): void { if ( get_option( ADDON_BOSS_ONBOARDING_ORDERED ) ) { return; } $order = $order_or_id instanceof WC_Order ? $order_or_id : wc_get_order( $order_or_id ); if ( ! $order instanceof WC_Order ) { return; } foreach ( $order->get_items() as $item ) { // The engine writes these on every line carrying options or fields // (standalone.php's order-line seam), so their presence is the signal. if ( $item->meta_exists( '_bundle_boss_standalone_options' ) || $item->meta_exists( ADDON_BOSS_PRODUCT_FIELDS_META ) ) { update_option( ADDON_BOSS_ONBOARDING_ORDERED, '1', false ); return; } } } add_action( 'woocommerce_checkout_order_processed', 'addon_boss_onboarding_note_order', 20, 1 ); add_action( 'woocommerce_store_api_checkout_order_processed', 'addon_boss_onboarding_note_order', 20, 1 ); /** * The next action a notice should carry at a given moment, or an empty array * when the checklist is dismissed or the moment's step is already done. * * set_saved a set was just saved or imported and nothing is attached yet: * the answer is "Now attach it to a product". * attached options just landed on a product and no page has been seen: * the answer is "View it on the product page". * * @param string $moment Which moment just happened. * @param int $product_id The product involved, where the moment has one. * @return array{l:string,u:string}|array */ function addon_boss_onboarding_next_action( string $moment, int $product_id = 0 ): array { if ( ! addon_boss_onboarding_active() ) { return array(); } if ( 'set_saved' === $moment && 0 === addon_boss_onboarding_attached_product_id() ) { return array( 'l' => __( 'Now attach it to a product', 'addon-boss' ), 'u' => admin_url( 'edit.php?post_type=product' ), ); } if ( 'attached' === $moment && ! get_option( ADDON_BOSS_ONBOARDING_SEEN ) && $product_id > 0 && 'publish' === get_post_status( $product_id ) ) { $url = (string) get_permalink( $product_id ); if ( '' !== $url ) { return array( 'l' => __( 'View it on the product page', 'addon-boss' ), 'u' => $url, ); } } return array(); } /** * After options land on a product from its own edit screen, say what comes * next. Runs after the engine's save on the same hook; the notice itself shows * on the reload the save redirects to, then never again for that save. * * @param WC_Product $product The product just saved. */ function addon_boss_onboarding_note_attach( $product ): void { if ( ! $product instanceof WC_Product || ! addon_boss_onboarding_active() || get_option( ADDON_BOSS_ONBOARDING_SEEN ) ) { return; } if ( ! addon_boss_product_option_config( $product ) ) { return; } set_transient( 'addon_boss_first_attach_notice_' . get_current_user_id(), $product->get_id(), MINUTE_IN_SECONDS ); } add_action( 'woocommerce_admin_process_product_object', 'addon_boss_onboarding_note_attach', 20 ); /** Print (once) the just-attached notice with its next action. */ function addon_boss_onboarding_attach_notice(): void { $pid = (int) get_transient( 'addon_boss_first_attach_notice_' . get_current_user_id() ); if ( $pid <= 0 ) { return; } delete_transient( 'addon_boss_first_attach_notice_' . get_current_user_id() ); $action = addon_boss_onboarding_next_action( 'attached', $pid ); if ( ! $action ) { return; } echo '

'; echo esc_html__( 'Your options are on this product.', 'addon-boss' ); echo ' ' . esc_html( $action['l'] ) . ''; echo '

'; } add_action( 'admin_notices', 'addon_boss_onboarding_attach_notice' );