false, 'completed' => false, ) ); } private static function counts() { if ( null === self::$counts ) { self::$counts = YMMHERO_DB::count_vehicles_by_assignment(); } return self::$counts; } public static function is_dismissed() { $option = self::option(); return ! empty( $option['dismissed'] ); } /** * Whether every step is done *right now*, derived fresh. Answers "is there * anything left to do", which is not the same question as whether the card * should show — see is_visible(). */ public static function is_complete() { foreach ( self::steps() as $step ) { if ( ! $step['done'] ) { return false; } } return true; } /** * True once the site has had all three steps done at the same time, whenever * that was. Set on the render that first sees it and never cleared, so the * card does not return when a working store later deletes a vehicle. */ public static function is_finished() { $option = self::option(); if ( ! empty( $option['completed'] ) ) { return true; } if ( ! self::is_complete() ) { return false; } self::update( array( 'completed' => true ) ); return true; } public static function is_visible() { return ! self::is_dismissed() && ! self::is_finished(); } public static function steps() { $counts = self::counts(); return array( array( 'key' => 'vehicles', 'title' => __( 'Add your vehicles', 'ymm-hero' ), 'text' => __( 'The years, makes and models your customers drive.', 'ymm-hero' ), 'done' => $counts['all'] > 0, 'action' => __( 'Add vehicles', 'ymm-hero' ), 'url' => admin_url( 'admin.php?page=ymmhero-vehicles&action=add' ), ), array( 'key' => 'assignments', 'title' => __( 'Assign products to them', 'ymm-hero' ), 'text' => __( 'A vehicle with no products filters the shop down to nothing.', 'ymm-hero' ), 'done' => $counts['assigned'] > 0, 'action' => __( 'Assign products', 'ymm-hero' ), 'url' => admin_url( 'admin.php?page=ymmhero-assignments&action=add' ), ), array( 'key' => 'widget', 'title' => __( 'Put the search on your shop', 'ymm-hero' ), 'text' => __( 'Until the widget is placed, customers never see the vehicle picker. Use the Elementor or block editor widget, or paste the shortcode anywhere.', 'ymm-hero' ), 'done' => self::widget_is_placed(), 'action' => __( 'Open display settings', 'ymm-hero' ), 'url' => admin_url( 'admin.php?page=ymmhero-settings&tab=display' ), ), ); } /* ---------- widget placement ---------- */ /** * Auto-inject answers this without a query. Failing that the widget can still * have been placed by hand, so post content is scanned for the shortcode and * the block, and post meta for the Elementor widget. That scan is two * unindexed LIKEs, so its answer is cached — invalidated on save_post and on * a settings save rather than left to expire. */ public static function widget_is_placed() { if ( YMMHERO_Settings::get_display( 'search_widget_auto_inject' ) ) { return true; } $cached = get_transient( self::PLACED_TRANSIENT ); if ( false !== $cached ) { return '1' === $cached; } $placed = self::scan_for_widget(); set_transient( self::PLACED_TRANSIENT, $placed ? '1' : '0', HOUR_IN_SECONDS ); return $placed; } /** * save_post fires on every post on the site, so this bails before touching the * database in the cases where the cached answer is not being read anyway: the * card is gone, or auto-inject already settles the question. The stored * 'completed' flag is read directly rather than through is_finished(), which * would write an option from a front-end save. */ public static function flush_placed() { $option = self::option(); if ( ! empty( $option['dismissed'] ) || ! empty( $option['completed'] ) ) { return; } if ( YMMHERO_Settings::get_display( 'search_widget_auto_inject' ) ) { return; } delete_transient( self::PLACED_TRANSIENT ); } private static function scan_for_widget() { global $wpdb; $shortcode = '%' . $wpdb->esc_like( '[ymmhero_search' ) . '%'; $block = '%' . $wpdb->esc_like( 'wp:ymmhero-search/search-widget' ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin owns the ymmhero_vehicles/ymmhero_vehicle_products tables; there is no core API for them; results are cached in the YMMHERO_DB object-cache layer and invalidated by YMMHERO_DB::flush_cache(). $found = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND ( post_content LIKE %s OR post_content LIKE %s ) LIMIT 1", $shortcode, $block ) ); if ( $found ) { return true; } $elementor = '%' . $wpdb->esc_like( '"widgetType":"ymmhero_search"' ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin owns the ymmhero_vehicles/ymmhero_vehicle_products tables; there is no core API for them; results are cached in the YMMHERO_DB object-cache layer and invalidated by YMMHERO_DB::flush_cache(). return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_elementor_data' AND meta_value LIKE %s LIMIT 1", $elementor ) ); } /* ---------- urls ---------- */ public static function load_sample_url() { return wp_nonce_url( admin_url( 'admin-post.php?action=ymmhero_load_sample_vehicles' ), 'ymmhero_load_sample_vehicles' ); } public static function dismiss_url() { return wp_nonce_url( admin_url( 'admin-post.php?action=ymmhero_dismiss_setup' ), 'ymmhero_dismiss_setup' ); } /* ---------- handlers ---------- */ public function handle_load_sample() { self::guard( 'ymmhero_load_sample_vehicles' ); // Sample rows are ordinary vehicles once inserted — nothing tracks them and // nothing takes them back out. They are edited or deleted from the Vehicle // DB screen like any other row. foreach ( self::sample_rows() as $row ) { list( $year, $make, $model ) = $row; YMMHERO_DB::get_or_create_vehicle( $year, $make, $model ); } self::redirect_back( 'sample_loaded' ); } public function handle_dismiss() { self::guard( 'ymmhero_dismiss_setup' ); self::update( array( 'dismissed' => true ) ); self::redirect_back( '' ); } private static function guard( $nonce_action ) { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Permission denied.', 'ymm-hero' ) ); } check_admin_referer( $nonce_action ); } private static function update( $changes ) { update_option( self::OPTION, array_merge( self::option(), $changes ), false ); } /** * The checklist lives in the rail of several screens, so these actions return * to whichever one launched them rather than to a fixed page. */ private static function redirect_back( $message ) { $referer = wp_get_referer(); $target = $referer ? $referer : admin_url( 'admin.php?page=' . YMMHERO_Admin::menu_slug() ); $target = remove_query_arg( array( 'message', 'count', 'imported' ), $target ); if ( $message ) { $target = add_query_arg( 'message', $message, $target ); } wp_safe_redirect( $target ); exit; } }