'pending'|'done'|'skipped', 'step' => 1..3, 'updated' => timestamp ).
* A missing option means "not part of the wizard" (upgrades), treated as skipped.
*
* @package AccessIQ
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class AccessIQ_Onboarding {
const OPTION = 'accessiq_onboarding';
const PAGE = 'accessiq-setup';
const NONCE = 'accessiq_wizard';
const STEP_COUNT = 3;
/** @var string Hook suffix of the wizard page. */
private static $hook = '';
public static function init() {
add_action( 'admin_menu', array( __CLASS__, 'register_page' ), 20 );
// Late, after every other AccessIQ submenu is registered.
add_action( 'admin_menu', array( __CLASS__, 'hide_page' ), 999 );
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue' ) );
add_action( 'admin_notices', array( __CLASS__, 'maybe_notice' ) );
add_action( 'wp_ajax_accessiq_wizard_scan', array( __CLASS__, 'ajax_scan' ) );
add_action( 'wp_ajax_accessiq_wizard_widget', array( __CLASS__, 'ajax_widget' ) );
add_action( 'wp_ajax_accessiq_wizard_progress', array( __CLASS__, 'ajax_progress' ) );
add_action( 'admin_post_accessiq_setup_skip', array( __CLASS__, 'handle_skip' ) );
add_action( 'admin_post_accessiq_setup_finish', array( __CLASS__, 'handle_finish' ) );
}
// ── State ───────────────────────────────────────────────────────────
/**
* Current state. A missing option reads as 'skipped'.
*
* @return array{status:string,step:int,updated:int}
*/
public static function get_state() {
$raw = get_option( self::OPTION, null );
$state = array(
'status' => 'skipped',
'step' => 1,
'updated' => 0,
);
if ( is_array( $raw ) ) {
if ( isset( $raw['status'] ) && in_array( $raw['status'], array( 'pending', 'done', 'skipped' ), true ) ) {
$state['status'] = $raw['status'];
}
if ( isset( $raw['step'] ) ) {
$state['step'] = min( self::STEP_COUNT, max( 1, (int) $raw['step'] ) );
}
if ( isset( $raw['updated'] ) ) {
$state['updated'] = (int) $raw['updated'];
}
}
return $state;
}
public static function is_pending() {
$state = self::get_state();
return 'pending' === $state['status'];
}
/**
* Save state (autoload off).
*
* @param string $status pending|done|skipped.
* @param int|null $step 1..3, or null to keep the current step.
*/
public static function set_state( $status, $step = null ) {
$current = self::get_state();
$status = in_array( $status, array( 'pending', 'done', 'skipped' ), true ) ? $status : 'skipped';
$step = null === $step ? $current['step'] : min( self::STEP_COUNT, max( 1, (int) $step ) );
$value = array(
'status' => $status,
'step' => $step,
'updated' => time(),
);
if ( false === get_option( self::OPTION, false ) ) {
add_option( self::OPTION, $value, '', false );
} else {
update_option( self::OPTION, $value, false );
}
}
/**
* Fresh install = no onboarding option, no scans and no saved AccessIQ settings.
* Must run before activate() creates the default settings.
*/
public static function is_fresh_install() {
if ( false !== get_option( self::OPTION, false ) ) {
return false;
}
if ( false !== get_option( 'accessiq_settings', false ) ) {
return false;
}
$scans = get_posts(
array(
'post_type' => ACCESSIQ_SCAN_CPT,
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
)
);
return empty( $scans );
}
/**
* Called from activate(): records the starting state once, when the option does not exist yet.
*
* @param bool $fresh Result of is_fresh_install(), taken before default settings were written.
*/
public static function init_on_activate( $fresh ) {
if ( false === get_option( self::OPTION, false ) ) {
self::set_state( $fresh ? 'pending' : 'skipped', 1 );
}
}
public static function url() {
return admin_url( 'admin.php?page=' . self::PAGE );
}
// ── Page registration ───────────────────────────────────────────────
public static function register_page() {
self::$hook = (string) add_submenu_page(
'accessiq',
__( 'AccessIQ Setup', 'accessiq' ),
__( 'Setup', 'accessiq' ),
'manage_options',
self::PAGE,
array( __CLASS__, 'render' )
);
if ( self::$hook ) {
// Keep the admin
set even though the menu entry is removed afterwards.
add_action( 'load-' . self::$hook, array( __CLASS__, 'set_title' ) );
}
}
public static function set_title() {
global $title;
$title = __( 'AccessIQ Setup', 'accessiq' );
}
/**
* Hide the page from the submenu. WordPress refuses a page that is missing from $submenu
* ("not allowed to access this page"), so the entry is kept while the wizard itself is open.
*/
public static function hide_page() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only check of the requested screen.
if ( isset( $_GET['page'] ) && self::PAGE === sanitize_key( wp_unslash( $_GET['page'] ) ) ) {
return;
}
remove_submenu_page( 'accessiq', self::PAGE );
}
public static function render() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to view this page.', 'accessiq' ) );
}
include ACCESSIQ_DIR . 'admin/views/setup.php';
}
public static function enqueue( $hook ) {
if ( '' === self::$hook || $hook !== self::$hook ) {
return;
}
// The page stays registered while open (see hide_page), so keep its menu entry out of sight.
wp_register_style( 'accessiq-setup-menu', false, array(), ACCESSIQ_VERSION );
wp_enqueue_style( 'accessiq-setup-menu' );
wp_add_inline_style( 'accessiq-setup-menu', '#adminmenu .wp-submenu li:has(> a[href*="page=accessiq-setup"]){display:none}' );
wp_enqueue_script( 'accessiq-setup', ACCESSIQ_URL . 'admin/js/setup.js', array(), ACCESSIQ_VERSION, true );
wp_localize_script(
'accessiq-setup',
'accessiqSetup',
array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE ),
'step' => self::get_state()['step'],
'steps' => self::STEP_COUNT,
'home' => home_url( '/' ),
'i18n' => array(
'scanning' => __( 'Scanning your page. This can take up to 30 seconds…', 'accessiq' ),
/* translators: 1: score out of 100, 2: standard such as "WCAG 2.2 AA", 3: number of errors, 4: number of warnings */
'scanResult' => __( 'Score %1$d out of 100 against %2$s: %3$d errors and %4$d warnings.', 'accessiq' ),
'scanFailed' => __( 'The scan could not be completed.', 'accessiq' ),
'errNetwork' => __( 'Network error. Check your connection and try again.', 'accessiq' ),
'saving' => __( 'Saving…', 'accessiq' ),
'widgetOn' => __( 'The accessibility toolbar is now on.', 'accessiq' ),
'widgetOff' => __( 'The accessibility toolbar is now off.', 'accessiq' ),
'saveFailed' => __( 'Could not save. Please try again.', 'accessiq' ),
'enterUrl' => __( 'Please enter a URL.', 'accessiq' ),
'stateDone' => __( '(done)', 'accessiq' ),
'stateCurrent' => __( '(current)', 'accessiq' ),
'stateTodo' => __( '(not started)', 'accessiq' ),
'openScanner' => __( 'Open the Scanner page', 'accessiq' ),
'viewReport' => __( 'View full report', 'accessiq' ),
'next' => __( 'Next step', 'accessiq' ),
'quickNote' => __( 'This quick scan runs a smaller set of checks than the Scanner page, so the score can be higher there. Run the full scan on the Scanner page for complete results.', 'accessiq' ),
/* translators: %d: number of automated checks that ran */
'checksRan' => __( '%d automated checks ran.', 'accessiq' ),
'fullScan' => __( 'Run the full scan', 'accessiq' ),
),
)
);
}
// ── AJAX ────────────────────────────────────────────────────────────
private static function guard() {
check_ajax_referer( self::NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'accessiq' ) ), 403 );
}
}
/** Friendly text for a failed server-side scan. */
private static function scan_error_message( $err ) {
$code = is_wp_error( $err ) ? $err->get_error_code() : '';
switch ( $code ) {
case 'http_error':
return __( 'The page could not be loaded (it returned an error status). Check the URL and try again.', 'accessiq' );
case 'empty_body':
return __( 'The page returned no content to scan.', 'accessiq' );
case 'invalid_url':
return __( 'Enter a valid URL on this site.', 'accessiq' );
default:
return __( 'Your site could not load this page from the server. Many hosts block this kind of request. You can use the normal Scanner page instead, which checks the page in your browser.', 'accessiq' );
}
}
public static function ajax_scan() {
self::guard();
$url = esc_url_raw( wp_unslash( isset( $_POST['url'] ) ? $_POST['url'] : '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce checked in guard().
if ( ! $url ) {
wp_send_json_error( array( 'message' => __( 'Enter a valid URL on this site.', 'accessiq' ), 'code' => 'invalid_url' ) );
}
if ( ! AccessIQ_Scanner::is_same_site_url( $url ) ) {
wp_send_json_error( array(
'message' => __( 'The free scanner checks pages on your own site only. Enter a URL on this site.', 'accessiq' ),
'code' => 'external_url',
) );
}
$level = AccessIQ_Scanner::clean_level( isset( $_POST['level'] ) ? $_POST['level'] : '' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$version = AccessIQ_Scanner::clean_version( isset( $_POST['version'] ) ? $_POST['version'] : '' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$result = AccessIQ_Scanner::run_scan_for_schedule( $url, $level, $version );
if ( is_wp_error( $result ) ) {
wp_send_json_error( array(
'message' => self::scan_error_message( $result ),
'code' => $result->get_error_code(),
'scanner_url' => admin_url( 'admin.php?page=accessiq-scanner' ),
) );
}
$scan_id = (int) $result['scan_id'];
$errors = (int) get_post_meta( $scan_id, '_accessiq_errors', true );
$warnings = (int) get_post_meta( $scan_id, '_accessiq_warnings', true );
$checks = (int) get_post_meta( $scan_id, '_accessiq_checks', true );
self::set_state( 'pending', 2 );
wp_send_json_success( array(
'scan_id' => $scan_id,
'score' => (int) $result['score'],
'errors' => $errors,
'warnings' => $warnings,
'checks' => $checks,
'standard' => AccessIQ_Scanner::standard_label( $scan_id ),
'explanation' => AccessIQ_Scanner::score_explanation(),
// add_query_arg, not wp_nonce_url: the latter returns & which breaks a href set from JSON.
'report_url' => esc_url_raw( add_query_arg(
'_wpnonce',
wp_create_nonce( 'accessiq_view_report' ),
admin_url( 'admin.php?page=accessiq-reports&scan_id=' . $scan_id )
) ),
) );
}
public static function ajax_widget() {
self::guard();
// Same rule as AccessIQ_Settings::ajax_save() for boolean fields: only the string "true" turns it on.
$on = isset( $_POST['enabled'] ) && 'true' === sanitize_text_field( wp_unslash( $_POST['enabled'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked in guard().
$s = get_option( 'accessiq_settings', AccessIQ::default_settings() );
if ( ! is_array( $s ) ) {
$s = AccessIQ::default_settings();
}
$s['widget_enabled'] = $on;
update_option( 'accessiq_settings', $s );
do_action( 'accessiq_settings_saved' );
self::set_state( 'pending', 3 );
wp_send_json_success( array( 'enabled' => $on ) );
}
public static function ajax_progress() {
self::guard();
$op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : 'step'; // phpcs:ignore WordPress.Security.NonceVerification.Missing
if ( 'skip' === $op ) {
self::set_state( 'skipped' );
} else {
$step = isset( $_POST['step'] ) ? absint( $_POST['step'] ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Missing
self::set_state( 'pending', $step );
}
wp_send_json_success();
}
// ── Skip / Finish links (work without JavaScript) ───────────────────
public static function skip_url() {
return wp_nonce_url( admin_url( 'admin-post.php?action=accessiq_setup_skip' ), self::NONCE );
}
public static function finish_url() {
return wp_nonce_url( admin_url( 'admin-post.php?action=accessiq_setup_finish' ), self::NONCE );
}
private static function guard_post() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'accessiq' ), '', array( 'response' => 403 ) );
}
check_admin_referer( self::NONCE );
}
public static function handle_skip() {
self::guard_post();
self::set_state( 'skipped' );
wp_safe_redirect( admin_url( 'admin.php?page=accessiq' ) );
exit;
}
public static function handle_finish() {
self::guard_post();
self::set_state( 'done', self::STEP_COUNT );
wp_safe_redirect( admin_url( 'admin.php?page=accessiq&accessiq_setup=done' ) );
exit;
}
// ── Notices (AccessIQ screens only) ─────────────────────────────────
private static function on_accessiq_screen() {
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
return $screen && false !== strpos( (string) $screen->id, 'accessiq' );
}
public static function maybe_notice() {
if ( ! current_user_can( 'manage_options' ) || ! self::on_accessiq_screen() ) {
return;
}
$screen = get_current_screen();
if ( self::$hook && $screen->id === self::$hook ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only.
if ( isset( $_GET['accessiq_setup'] ) && 'done' === sanitize_key( wp_unslash( $_GET['accessiq_setup'] ) ) && 'done' === self::get_state()['status'] ) {
echo '' . esc_html__( 'Setup finished. You can reopen the setup guide from the About page at any time.', 'accessiq' ) . '
';
return;
}
if ( self::is_pending() ) {
$state = self::get_state();
?>