settings_key = $settings_key; $this->prefix = $prefix; $defaults = array( 'steps' => array(), 'translation_strings' => array(), 'admin_menu_position' => 999, 'page_slug' => "{$prefix}_wizard", 'hide_when_completed' => true, 'show_in_menu' => true, 'menu_args' => array( 'parent' => '', // Empty for dashboard, or parent slug for submenu. 'capability' => 'manage_options', ), ); $args = wp_parse_args( $args, $defaults ); $this->args = $args; $this->page_slug = $args['page_slug']; $this->menu_args = $args['menu_args']; $this->set_translation_strings( $args['translation_strings'] ); $this->set_steps( $args['steps'] ); // Initialize settings form. $this->settings_form = new Settings_Form( array( 'settings_key' => $this->settings_key, 'prefix' => $this->prefix, 'translation_strings' => $this->translation_strings, ) ); $this->settings_sanitize = new Settings_Sanitize( array( 'settings_key' => $this->settings_key, 'prefix' => $this->prefix, ) ); $this->hooks(); } /** * Adds the functions to the appropriate WordPress hooks. */ public function hooks() { add_action( 'admin_menu', array( $this, 'admin_menu' ), $this->args['admin_menu_position'] ); add_action( 'admin_init', array( $this, 'process_step' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); } /** * Sets translation strings. * * @param array $strings Translation strings. */ public function set_translation_strings( $strings ) { $defaults = array( 'page_title' => 'Setup Wizard', 'menu_title' => 'Setup Wizard', 'wizard_title' => 'Setup Wizard', 'next_step' => 'Next Step', 'previous_step' => 'Previous Step', 'finish_setup' => 'Finish Setup', 'skip_wizard' => 'Skip Wizard', 'step_of' => 'Step %1$d of %2$d', 'steps_nav_aria_label' => 'Setup Wizard Steps', 'wizard_complete' => 'Wizard Complete!', 'setup_complete' => 'Setup has been completed successfully.', 'go_to_settings' => 'Go to Settings', 'tom_select_no_results' => 'No results found for "%s"', ); $this->translation_strings = wp_parse_args( $strings, $defaults ); } /** * Set wizard steps. * * @param array $steps Array of wizard steps. * @return object Class object. */ public function set_steps( $steps ) { $this->steps = $steps; $this->total_steps = count( $steps ); return $this; } /** * Add admin menu for the wizard. */ public function admin_menu() { $capability = ! empty( $this->menu_args['capability'] ) ? $this->menu_args['capability'] : 'manage_options'; $parent = ! empty( $this->menu_args['parent'] ) ? $this->menu_args['parent'] : 'index.php'; $this->page_id = add_submenu_page( $parent, (string) $this->translation_strings['page_title'], (string) $this->translation_strings['menu_title'], $capability, $this->page_slug, array( $this, 'render_wizard_page' ) ); $hide_submenu = ( isset( $this->args['show_in_menu'] ) && ! $this->args['show_in_menu'] ) || ( ( $this->args['hide_when_completed'] ?? true ) && $this->is_wizard_completed() ); if ( $hide_submenu ) { add_action( 'admin_enqueue_scripts', array( $this, 'hide_completed_wizard_submenu' ) ); } } /** * Hide wizard submenu item when the wizard is completed. * * @return void */ public function hide_completed_wizard_submenu() { $slug = sanitize_key( $this->page_slug ); $css = '#adminmenu a[href$="page=' . $slug . '"], #adminmenu a[href*="page=' . $slug . '&"] { display: none; }'; wp_add_inline_style( 'wp-admin', $css ); } /** * Enqueue scripts and styles for the wizard. * * @param string $hook Current admin page hook. */ public function enqueue_scripts( $hook ) { if ( false === strpos( $hook, $this->page_slug ) ) { return; } $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min'; // Wizard styles. wp_enqueue_style( "{$this->prefix}-wizard-css", plugins_url( 'css/wizard' . $minimize . '.css', __FILE__ ), array( 'wp-color-picker' ), $this->get_version(), 'all' ); // Use Settings_API to enqueue common scripts and styles. Settings_API::enqueue_scripts_styles( $this->prefix ); // Tom Select assets for taxonomy fields. wp_register_style( 'wz-' . $this->prefix . '-tom-select', plugins_url( 'css/tom-select.min.css', __FILE__ ), array(), $this->get_version() ); wp_register_script( 'wz-' . $this->prefix . '-tom-select', plugins_url( 'js/tom-select.complete.min.js', __FILE__ ), array( 'jquery' ), $this->get_version(), true ); wp_register_script( 'wz-' . $this->prefix . '-tom-select-init', plugin_dir_url( __FILE__ ) . 'js/tom-select-init' . $minimize . '.js', array( 'jquery', 'wz-' . $this->prefix . '-tom-select' ), $this->get_version(), true ); wp_enqueue_style( 'wz-' . $this->prefix . '-tom-select' ); wp_enqueue_script( 'wz-' . $this->prefix . '-tom-select' ); wp_enqueue_script( 'wz-' . $this->prefix . '-tom-select-init' ); // Localize Tom Select settings for wizard. wp_localize_script( 'wz-' . $this->prefix . '-tom-select-init', "{$this->prefix}TomSelectSettings", array( 'action' => $this->prefix . '_taxonomy_search_tom_select', 'nonce' => wp_create_nonce( $this->prefix . '_taxonomy_search_tom_select' ), 'endpoint' => 'category', 'strings' => array( 'no_results' => esc_html( $this->translation_strings['tom_select_no_results'] ), ), ) ); } /** * Process wizard step submission. */ public function process_step() { if ( empty( $_POST['wizard_action'] ) ) { // Don't run on every admin_init, only on our form submission. return; } $nonce_value = isset( $_POST[ $this->prefix . '_wizard_nonce' ] ) ? sanitize_text_field( wp_unslash( $_POST[ $this->prefix . '_wizard_nonce' ] ) ) : ''; if ( empty( $nonce_value ) || ! wp_verify_nonce( $nonce_value, $this->prefix . '_wizard_nonce' ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } // Initialise the current step based on the URL or stored option before processing the action. $this->current_step = $this->get_current_step(); $action = sanitize_text_field( wp_unslash( $_POST['wizard_action'] ) ); switch ( $action ) { case 'next_step': $this->process_current_step(); $this->next_step(); $this->redirect_to_step( $this->current_step ); break; case 'previous_step': $this->previous_step(); $this->redirect_to_step( $this->current_step ); break; case 'finish_setup': $this->process_current_step(); $this->mark_wizard_completed(); $this->redirect_to_step( $this->total_steps + 1 ); break; case 'skip_wizard': $this->mark_wizard_completed(); $this->redirect_to_admin(); break; default: break; } } /** * Process the current step's form data. */ protected function process_current_step() { $current_step_config = $this->get_current_step_config(); if ( empty( $current_step_config['settings'] ) ) { return; } $settings = array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( isset( $_POST[ $this->settings_key ] ) && is_array( $_POST[ $this->settings_key ] ) ) { foreach ( $current_step_config['settings'] as $setting_id => $setting_config ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( isset( $_POST[ $this->settings_key ][ $setting_id ] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing $value = $this->sanitize_setting_value( wp_unslash( $_POST[ $this->settings_key ][ $setting_id ] ), $setting_config ); $settings[ $setting_id ] = $value; } } } // Save settings for this step. $this->save_step_settings( $settings ); /** * Action fired after processing a wizard step. * * @param int $step Current step number. * @param array $settings Settings data for this step. */ do_action( $this->prefix . '_wizard_step_processed', $this->current_step, $settings ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound } /** * Sanitize setting value based on its type. * * @param mixed $value Setting value. * @param array $setting_config Setting configuration. * @return mixed Sanitized value. */ protected function sanitize_setting_value( $value, $setting_config ) { $type = $setting_config['type'] ?? 'text'; // Use the Settings_Sanitize class for proper sanitization. $settings_sanitize = $this->settings_sanitize; // Check if we have a specific sanitizer for this type. if ( is_callable( array( $settings_sanitize, "sanitize_{$type}_field" ) ) ) { return call_user_func( array( $settings_sanitize, "sanitize_{$type}_field" ), $value ); } // Fallback to basic sanitization. if ( is_array( $value ) ) { return array_map( 'sanitize_text_field', $value ); } return sanitize_text_field( $value ); } /** * Save settings for the current step. * * @param array $settings Settings to save. */ protected function save_step_settings( $settings ) { $existing_settings = get_option( $this->settings_key, array() ); $updated_settings = array_merge( $existing_settings, $settings ); update_option( $this->settings_key, $updated_settings ); } /** * Move to the next step. */ protected function next_step() { if ( $this->current_step < $this->total_steps ) { ++$this->current_step; $this->update_current_step(); } } /** * Move to the previous step. */ protected function previous_step() { if ( $this->current_step > 1 ) { --$this->current_step; $this->update_current_step(); } } /** * Redirect to a specific wizard step. * * @param int $step Step number to redirect to. */ protected function redirect_to_step( $step ) { $url = add_query_arg( array( 'page' => $this->page_slug, 'step' => $step, ), admin_url( 'admin.php' ) ); wp_safe_redirect( $url ); exit; } /** * Redirect to the admin page after wizard completion. */ protected function redirect_to_admin() { $url = $this->get_completion_redirect_url(); wp_safe_redirect( $url ); exit; } /** * Mark the wizard as completed without redirecting. */ protected function mark_wizard_completed() { update_option( "{$this->prefix}_wizard_completed", true ); update_option( "{$this->prefix}_wizard_completed_date", current_time( 'mysql' ) ); // Clean up the transient and option that triggered the wizard. delete_transient( "{$this->prefix}_show_wizard_activation_redirect" ); delete_option( "{$this->prefix}_show_wizard" ); /** * Action fired when the wizard is completed. * * @param string $prefix Plugin prefix. */ do_action( "{$this->prefix}_wizard_completed", $this->prefix ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound } /** * Complete the wizard. */ protected function complete_wizard() { $this->mark_wizard_completed(); // Redirect to completion page or main settings. wp_safe_redirect( $this->get_completion_redirect_url() ); exit; } /** * Get the current step number. * * @return int Current step number. */ public function get_current_step() { // Check if we have a step parameter in the URL first. // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( isset( $_GET['step'] ) ) { $step = absint( $_GET['step'] ); if ( $step > 0 && $step <= $this->total_steps + 1 ) { $this->current_step = $step; $this->update_current_step(); return $this->current_step; } } // phpcs:enable WordPress.Security.NonceVerification.Recommended // Fall back to the database value. if ( ! $this->current_step ) { $this->current_step = get_option( "{$this->prefix}_wizard_current_step", 1 ); } return $this->current_step; } /** * Update the current step in the database. */ protected function update_current_step() { update_option( "{$this->prefix}_wizard_current_step", $this->current_step ); } /** * Get the current step configuration. * * @return array Current step configuration. */ public function get_current_step_config() { $keys = array_keys( $this->steps ); $index = $this->get_current_step() - 1; // Return empty array if steps is empty or index is out of bounds. if ( empty( $keys ) || ! isset( $keys[ $index ] ) ) { return array(); } return $this->steps[ $keys[ $index ] ] ?? array(); } /** * Check if the wizard has been completed. * * @return bool True if wizard is completed. */ public function is_wizard_completed() { return (bool) get_option( "{$this->prefix}_wizard_completed", false ); } /** * Render the wizard page. */ public function render_wizard_page() { $this->current_step = $this->get_current_step(); $step_config = $this->get_current_step_config(); if ( empty( $step_config ) ) { $this->render_completion_page(); return; } ?>

translation_strings['wizard_title'] ); ?>

render_wizard_steps_navigation(); ?>

translation_strings['step_of']; printf( esc_html( $step_pattern ), esc_html( $current_step_name ), esc_html( (string) $this->current_step ), esc_html( (string) $this->total_steps ) ); ?>

prefix}_wizard_nonce", "{$this->prefix}_wizard_nonce" ); ?>
$field ) { $args = Settings_API::parse_field_args( $field ); // Get all settings from the main settings array. $all_settings = get_option( $this->settings_key, array() ); // Check if this setting exists in the saved settings. $value = $all_settings[ $setting_id ] ?? null; // Use saved value if it exists, otherwise use default. $args['value'] = ( null !== $value ) ? $value : ( $args['default'] ?? '' ); $type = $args['type'] ?? 'text'; $callback = method_exists( $this->settings_form, "callback_{$type}" ) ? array( $this->settings_form, "callback_{$type}" ) : array( $this->settings_form, 'callback_missing' ); echo ''; echo ''; echo ''; echo ''; } ?>
'; if ( ! empty( $args['name'] ) ) { echo ''; } echo ''; \call_user_func( $callback, $args ); echo '
prefix}_wizard_before_actions", $this->current_step, $this->total_steps ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound ?>
render_wizard_buttons(); ?>
settings_key, array() ); return $settings[ $setting_id ] ?? ''; } /** * Get the skip wizard link URL. * * @return string Skip wizard link URL. */ protected function get_skip_link_url() { return $this->get_completion_redirect_url(); } /** * Render wizard navigation buttons. */ protected function render_wizard_buttons() { ?>
current_step > 1 ) : ?> current_step < $this->total_steps ) : ?>
prefix}_wizard_completion_before" ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound ?>

translation_strings['wizard_complete'] ); ?>

translation_strings['setup_complete'] ); ?>

prefix}_wizard_completion_message" ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound ?>

get_completion_buttons(); foreach ( $buttons as $button ) : $class = isset( $button['primary'] ) && $button['primary'] ? 'button-primary' : 'button-secondary'; ?>

prefix}_wizard_completion_after" ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound } /** * Get the URL to redirect to after wizard completion. * * @return string Redirect URL. */ protected function get_completion_redirect_url() { /** * Filter the URL to redirect to after wizard completion. * * @param string $url The URL to redirect to. * @param string $prefix Plugin prefix. */ return apply_filters( // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound "{$this->prefix}_wizard_completion_url", // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound admin_url( "admin.php?page={$this->prefix}_settings" ), $this->prefix ); } /** * Get the completion page buttons. * * @return array Array of button configurations. */ protected function get_completion_buttons() { $buttons = array( array( 'url' => $this->get_completion_redirect_url(), 'text' => $this->translation_strings['go_to_settings'], 'primary' => true, ), ); /** * Filter the completion page buttons. * * @param array $buttons Array of button configurations. * @param string $prefix Plugin prefix. */ return apply_filters( "{$this->prefix}_wizard_completion_buttons", $buttons, $this->prefix ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound } /** * Get the version for cache busting. * * @return string Version number. */ protected function get_version() { /** * Filter the version number used for cache busting. * * @param string $version Version number. * @param string $prefix Plugin prefix. */ return apply_filters( "{$this->prefix}_wizard_version", self::VERSION, $this->prefix ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound } /** * Render the wizard steps navigation. */ protected function render_wizard_steps_navigation() { $step_keys = array_keys( $this->steps ); ?>
    $step_key ) : $step_number = $index + 1; $step_config = $this->steps[ $step_key ]; $is_current = $step_number === $this->current_step; $is_completed = $step_number < $this->current_step; $class_parts = array(); if ( $is_current ) { $class_parts[] = 'active'; } elseif ( $is_completed ) { $class_parts[] = 'done'; } $class = implode( ' ', $class_parts ); ?>
  1. >
$this->page_slug, 'step' => $step, ), admin_url( 'admin.php' ) ); } /** * Reset the wizard to allow it to run again. */ public function reset_wizard() { delete_option( "{$this->prefix}_wizard_completed" ); delete_option( "{$this->prefix}_wizard_completed_date" ); delete_option( "{$this->prefix}_wizard_current_step" ); } /** * Check if the wizard should be shown (e.g., on first activation). * * @return bool True if wizard should be shown. */ public function should_show_wizard() { // Show wizard if it hasn't been completed and it's been triggered. return ! $this->is_wizard_completed() && get_option( "{$this->prefix}_show_wizard", false ); } /** * Trigger the wizard to be shown. */ public function trigger_wizard() { update_option( "{$this->prefix}_show_wizard", true ); } }