namespace, '/onboarding', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_state' ], 'permission_callback' => [ $this, 'permission_callback' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'update_state' ], 'permission_callback' => [ $this, 'permission_callback' ], 'args' => [ 'step' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function ( $value ) { return is_string( $value ) && in_array( $value, self::STEPS, true ); }, ], 'markCompleted' => [ 'sanitize_callback' => function ( $value ) { if ( is_array( $value ) ) { return array_values( array_filter( array_map( 'sanitize_text_field', $value ) ) ); } if ( is_string( $value ) ) { return sanitize_text_field( $value ); } return $value; }, 'validate_callback' => function ( $value ) { if ( is_array( $value ) ) { foreach ( $value as $v ) { if ( ! is_string( $v ) || ! in_array( $v, self::STEPS, true ) ) { return false; } } return true; } return is_string( $value ) && in_array( $value, self::STEPS, true ); }, ], 'auditId' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ], 'skipped' => [ 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', ], ], ], ] ); register_rest_route( $this->namespace, '/onboarding/reset', [ 'methods' => 'POST', 'callback' => [ $this, 'reset_state' ], 'permission_callback' => [ $this, 'permission_callback' ], ] ); } public function get_state( WP_REST_Request $request ): WP_REST_Response { return new WP_REST_Response( $this->resolve_state(), 200 ); } public function update_state( WP_REST_Request $request ): WP_REST_Response { $state = $this->read_state(); $step = $request->get_param( 'step' ); if ( is_string( $step ) && in_array( $step, self::STEPS, true ) ) { $state['step'] = $step; if ( ! in_array( $step, $state['completedSteps'], true ) && 'welcome' !== $step ) { // no-op; advance() marks completion separately } } $completed = $request->get_param( 'markCompleted' ); if ( is_array( $completed ) ) { foreach ( $completed as $c ) { if ( is_string( $c ) && in_array( $c, self::STEPS, true ) && ! in_array( $c, $state['completedSteps'], true ) ) { $state['completedSteps'][] = $c; } } } elseif ( is_string( $completed ) && in_array( $completed, self::STEPS, true ) ) { if ( ! in_array( $completed, $state['completedSteps'], true ) ) { $state['completedSteps'][] = $completed; } } if ( $request->has_param( 'auditId' ) ) { $state['auditId'] = sanitize_text_field( (string) $request->get_param( 'auditId' ) ); } if ( $request->has_param( 'skipped' ) ) { $state['skipped'] = (bool) $request->get_param( 'skipped' ); } update_option( self::OPTION, $state, false ); return new WP_REST_Response( $this->resolve_state(), 200 ); } public function reset_state( WP_REST_Request $request ): WP_REST_Response { delete_option( self::OPTION ); // A reset means "start the wizard over", so hold off the background // entitlement reconcile — adopting the backend's paid state would jump // the user straight back to the apply step. The flag is persisted rather // than applied to this one response because the wizard refreshes its // state immediately after resetting, and that GET would otherwise sync. // resolve_state() lifts it again once the user reaches the pay step. Entitlement::set_sync_suppressed( true ); return new WP_REST_Response( $this->resolve_state(), 200 ); } private function read_state(): array { return Entitlement::read_state(); } /** * Merge stored state with live status so the resolved step always reflects reality. */ /** * Same notion of “Google connected” as Status_Controller / Dashboard (backend auth status). */ private const CONNECTED_CACHE = 'adbot_google_connected_cache'; private const CONNECTED_LAST = 'adbot_google_connected_last'; private function is_google_connected(): bool { if ( ! Consent::has_consent() ) { return false; } if ( '' === Token_Store::get_site_token() ) { delete_transient( self::CONNECTED_CACHE ); update_option( self::CONNECTED_LAST, false, false ); return false; } // Short-lived cache to avoid a backend round-trip on every onboarding // read (resolve_state runs on each GET/POST /onboarding). $cached = get_transient( self::CONNECTED_CACHE ); if ( '1' === $cached || '0' === $cached ) { return '1' === $cached; } try { $result = ( new Client() )->get( '/auth/status' ); $connected = (bool) ( $result['connected'] ?? false ); set_transient( self::CONNECTED_CACHE, $connected ? '1' : '0', 30 ); update_option( self::CONNECTED_LAST, $connected, false ); return $connected; } catch ( Backend_Exception $e ) { // A slow or failed /auth/status call (e.g. a Vercel cold start while // the backend refreshes the Google token) must NOT flap the user back // to "disconnected". Doing so bounced the wizard step backward and // looped against the connect screen, which re-advanced on every tick. // Trust the last known result instead. return (bool) get_option( self::CONNECTED_LAST, false ); } } private function resolve_state(): array { $state = $this->read_state(); $order = array_flip( self::STEPS ); // A reset suppresses the reconcile below so it cannot undo the reset. // Lift it once the user has walked back to the pay step: from there on // "you have already paid" is the answer they want, and leaving the flag // set would paywall a paying customer for good. if ( ! empty( $state['syncSuppressed'] ) && ( $order[ $state['step'] ] ?? 0 ) >= $order['pay'] ) { Entitlement::set_sync_suppressed( false ); $state['syncSuppressed'] = false; } // An EFT verified by the Adbot admin is settled entirely server-side — // nothing pushes it to this site. Reconcile here, not just on the pay // step, so a paid customer never meets the paywall wherever they land. // No-ops once paid or while suppressed, and rate-limited otherwise. if ( Entitlement::sync( (string) ( $state['auditId'] ?? '' ) ) ) { $state = $this->read_state(); } $connected = $this->is_google_connected(); $snippet_active = (bool) get_option( 'adbot_snippet_active' ); $container_id = (string) get_option( 'adbot_snippet_container_id', '' ); // Reconcile completedSteps with live state in both directions. // Add: if the infrastructure for a step is in place, mark it done. // Remove: if the prerequisite no longer holds (e.g. user disconnected), // drop any downstream "completed" flags so the UI shows the real status. $completed = array_values( array_unique( (array) $state['completedSteps'] ) ); $prune = []; if ( ! $connected ) { $prune = [ 'connect', 'property', 'ga4', 'audit', 'report', 'pay', 'apply' ]; } elseif ( ! $snippet_active ) { $prune = [ 'property', 'ga4', 'audit', 'report', 'pay', 'apply' ]; } if ( $prune && ! empty( $state['paid'] ) ) { // Payment is not a prerequisite of anything upstream — a disconnect // does not un-buy it, and showing "Unlock fixes" to someone who has // paid is exactly the bug this guards against. $prune = array_values( array_diff( $prune, [ 'pay' ] ) ); } if ( $prune ) { $completed = array_values( array_diff( $completed, $prune ) ); } if ( $connected && ! in_array( 'connect', $completed, true ) ) { $completed[] = 'connect'; } if ( $snippet_active && ! in_array( 'property', $completed, true ) ) { $completed[] = 'property'; } $state['completedSteps'] = $completed; // Auto-correct only for the "not yet started" case — don't clobber user's // explicit navigation (including Back). If the user hasn't advanced past // welcome but live state has progressed, bump them forward so they don't // re-do earlier steps. $min = $order[ $state['step'] ] ?? 0; $is_init = in_array( $state['step'], [ 'welcome' ], true ); // If the user disconnected mid-flow, drop them back to connect. if ( ! $connected && $min > $order['connect'] ) { $state['step'] = 'connect'; } elseif ( ! $snippet_active && $state['step'] !== 'done' && $min > $order['property'] ) { $state['step'] = 'property'; } elseif ( $is_init && $connected && ! $snippet_active ) { $state['step'] = 'property'; } elseif ( $is_init && $connected && $snippet_active ) { // Route through the GA4 picker unless a measurement id was already chosen. $state['step'] = '' === (string) get_option( 'adbot_ga4_measurement_id', '' ) ? 'ga4' : 'audit'; } $state['connected'] = $connected; $state['snippetActive'] = $snippet_active; $state['containerId'] = $container_id; $state['steps'] = self::STEPS; // Persist any auto-adjusted completions/step pointer. This is an explicit // key list, so every key the rest of the plugin relies on has to appear: // pendingRef (set by the payments controller) lets the pay step resume an // in-flight checkout after a reload, and syncSuppressed is what keeps a // wizard reset from being undone by the next state read. update_option( self::OPTION, [ 'step' => $state['step'], 'completedSteps' => array_values( array_unique( $state['completedSteps'] ) ), 'paid' => (bool) $state['paid'], 'auditId' => (string) $state['auditId'], 'entitlementRef' => (string) $state['entitlementRef'], 'skipped' => (bool) $state['skipped'], 'pendingRef' => (string) $state['pendingRef'], 'syncSuppressed' => ! empty( $state['syncSuppressed'] ), ], false ); return $state; } }