logger = $logger; } // ------------------------------------------------------------------------- // Settings helpers // ------------------------------------------------------------------------- /** * Get the stored GSC settings (client_id, client_secret, site_url). * @return array{client_id: string, client_secret: string, site_url: string} */ public function getSettings(): array { $raw = get_option(self::OPTION_KEY, array()); if (!is_array($raw)) { $raw = array(); } return array( 'client_id' => isset($raw['client_id']) && is_string($raw['client_id']) ? $raw['client_id'] : '', 'client_secret' => isset($raw['client_secret']) && is_string($raw['client_secret']) ? $raw['client_secret'] : '', 'site_url' => isset($raw['site_url']) && is_string($raw['site_url']) ? $raw['site_url'] : home_url('/'), ); } /** * Save GSC settings. Returns an error message string or '' on success. * @param array $postData * @return string */ public function saveSettings(array $postData): string { $clientId = isset($postData['gsc_client_id']) ? sanitize_text_field((string)(is_scalar($postData['gsc_client_id']) ? $postData['gsc_client_id'] : '')) : ''; $clientSecret = isset($postData['gsc_client_secret']) ? sanitize_text_field((string)(is_scalar($postData['gsc_client_secret']) ? $postData['gsc_client_secret'] : '')) : ''; $siteUrl = isset($postData['gsc_site_url']) ? esc_url_raw((string)(is_scalar($postData['gsc_site_url']) ? $postData['gsc_site_url'] : '')) : home_url('/'); update_option(self::OPTION_KEY, array( 'client_id' => $clientId, 'client_secret' => $clientSecret, 'site_url' => $siteUrl, ), false); // Saving new credentials starts fresh — clear any previous OAuth error. $this->clearLastOAuthError(); return ''; } /** * Whether centralized OAuth mode is active (no per-user Google Cloud credentials needed). * * When true, the plugin uses a published OAuth app proxied through a Cloudflare Worker * at CENTRALIZED_AUTH_URL instead of requiring each site owner to create their own * Google Cloud project. * * @return bool */ public function isCentralizedMode(): bool { $s = $this->getSettings(); // Centralized mode is ON unless the user has entered their own credentials. // The explicit flag allows toggling back from custom → centralized. if ($s['client_id'] !== '' && $s['client_secret'] !== '') { return false; } return true; } /** * Is the integration configured (credentials entered or centralized mode)? * @return bool */ public function isConfigured(): bool { if ($this->isCentralizedMode()) { return true; } $s = $this->getSettings(); return $s['client_id'] !== '' && $s['client_secret'] !== ''; } /** * Is an access token available (OAuth has been authorized)? * @return bool */ public function isAuthorized(): bool { $token = get_option(self::TOKEN_OPTION_KEY, false); if (!is_array($token) || empty($token['access_token'])) { return false; } // Treat token as valid if it doesn't have an expiry or expiry is in the future. if (!empty($token['expires_at']) && (int)$token['expires_at'] < time()) { return $this->refreshToken(); } return true; } // ------------------------------------------------------------------------- // OAuth flow // ------------------------------------------------------------------------- /** * Build the Google OAuth 2.0 authorization URL. * * In centralized mode the user is sent to the Cloudflare Worker's /authorize * endpoint which in turn redirects to Google. In custom-credentials mode the * user is sent directly to Google. * * @return string */ public function buildAuthUrl(): string { if ($this->isCentralizedMode()) { $params = array( 'site_callback_url' => $this->getCallbackUrl(), 'nonce' => wp_create_nonce('abj404_gsc_oauth'), 'scope' => self::SCOPE, ); return self::CENTRALIZED_AUTH_URL . '/authorize?' . http_build_query($params); } $s = $this->getSettings(); $params = array( 'client_id' => $s['client_id'], 'redirect_uri' => $this->getCallbackUrl(), 'response_type' => 'code', 'scope' => self::SCOPE, 'access_type' => 'offline', 'prompt' => 'consent', 'state' => wp_create_nonce('abj404_gsc_oauth'), ); return self::OAUTH_AUTH_URL . '?' . http_build_query($params); } /** * The OAuth callback URL that must be registered in Google Cloud. * @return string */ public function getCallbackUrl(): string { return admin_url('admin-ajax.php?action=abj404_gsc_oauth_callback'); } /** * Store tokens received directly from the centralized OAuth callback. * * In centralized mode, the Worker exchanges the auth code for tokens and * passes them back as URL parameters — so there is no code-for-token * exchange on the plugin side. This method stores the pre-exchanged tokens. * * @param string $accessToken * @param string $refreshToken * @param int $expiresIn Seconds until the access token expires. * @return void */ public function storeCentralizedTokens(string $accessToken, string $refreshToken, int $expiresIn): void { $token = array( 'access_token' => $accessToken, 'token_type' => 'Bearer', 'expires_at' => $expiresIn > 0 ? (time() + $expiresIn - 60) : 0, 'refresh_token' => $refreshToken, ); update_option(self::TOKEN_OPTION_KEY, $token, false); $this->clearLastOAuthError(); } /** * Exchange an authorization code for tokens. Returns '' on success, error on failure. * * In centralized mode this should not be called — tokens arrive directly from * the Worker callback via storeCentralizedTokens(). An early return guards against * accidental invocation. * * @param string $code * @return string */ public function exchangeCodeForToken(string $code): string { if ($this->isCentralizedMode()) { return 'Code exchange is not used in centralized mode.'; } $s = $this->getSettings(); $response = wp_remote_post(self::OAUTH_TOKEN_URL, array( 'body' => array( 'code' => $code, 'client_id' => $s['client_id'], 'client_secret' => $s['client_secret'], 'redirect_uri' => $this->getCallbackUrl(), 'grant_type' => 'authorization_code', ), 'timeout' => 15, )); if (is_wp_error($response)) { return $response->get_error_message(); } $body = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($body) || empty($body['access_token'])) { $error = (is_array($body) && isset($body['error_description'])) ? $body['error_description'] : __('OAuth token exchange failed.', '404-solution'); return is_string($error) ? $error : __('OAuth token exchange failed.', '404-solution'); } $token = array( 'access_token' => $body['access_token'], 'token_type' => isset($body['token_type']) ? $body['token_type'] : 'Bearer', 'expires_at' => isset($body['expires_in']) ? (time() + (int)$body['expires_in'] - 60) : 0, 'refresh_token' => isset($body['refresh_token']) ? $body['refresh_token'] : '', ); update_option(self::TOKEN_OPTION_KEY, $token, false); $this->clearLastOAuthError(); // authorization succeeded — clear any previous error return ''; } /** * Refresh the access token using the stored refresh token. * * In centralized mode the refresh request is sent to the Worker's /refresh * endpoint (which holds the client_secret). In custom mode the request goes * directly to Google's token endpoint. * * @return bool true if token refreshed successfully. */ private function refreshToken(): bool { $token = get_option(self::TOKEN_OPTION_KEY, false); if (!is_array($token) || empty($token['refresh_token'])) { return false; } if ($this->isCentralizedMode()) { return $this->refreshTokenViaCentralized($token); } $s = $this->getSettings(); $response = wp_remote_post(self::OAUTH_TOKEN_URL, array( 'body' => array( 'refresh_token' => $token['refresh_token'], 'client_id' => $s['client_id'], 'client_secret' => $s['client_secret'], 'grant_type' => 'refresh_token', ), 'timeout' => 15, )); if (is_wp_error($response)) { return false; } $body = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($body) || empty($body['access_token'])) { return false; } $token['access_token'] = $body['access_token']; $token['expires_at'] = isset($body['expires_in']) ? (time() + (int)$body['expires_in'] - 60) : 0; update_option(self::TOKEN_OPTION_KEY, $token, false); return true; } /** * Refresh the access token via the centralized Worker's /refresh endpoint. * * @param array $token Current stored token array. * @return bool true if token refreshed successfully. */ private function refreshTokenViaCentralized(array $token): bool { $response = wp_remote_post(self::CENTRALIZED_AUTH_URL . '/refresh', array( 'headers' => array('Content-Type' => 'application/json'), 'body' => (string)wp_json_encode(array( 'refresh_token' => $token['refresh_token'], )), 'timeout' => 15, )); if (is_wp_error($response)) { return false; } $body = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($body) || empty($body['access_token'])) { return false; } $token['access_token'] = $body['access_token']; $token['expires_at'] = isset($body['expires_in']) ? (time() + (int)$body['expires_in'] - 60) : 0; update_option(self::TOKEN_OPTION_KEY, $token, false); return true; } /** * Revoke authorization and delete stored tokens. * @return void */ public function revokeAuthorization(): void { delete_option(self::TOKEN_OPTION_KEY); delete_option(self::OPTION_KEY); delete_transient(self::TRANSIENT_KEY); $this->clearLastOAuthError(); } // ------------------------------------------------------------------------- // API queries // ------------------------------------------------------------------------- /** * Fetch search analytics data for a list of URLs. * Returns cached data if available, otherwise fetches from the API. * * In the nightly-cron architecture this method is primarily used as * a fallback; the main fetch path is fetchAndCacheGscData(). * * @param string[] $urls Relative or absolute URLs to query * @param int $days Number of days (max 16 months back) * @return array> */ public function getSearchAnalyticsForUrls(array $urls, int $days = 90): array { if (!$this->isAuthorized() || empty($urls)) { return array(); } $cached = get_transient(self::TRANSIENT_KEY); if (is_array($cached)) { return $cached; } $allRows = $this->doFetchFromApi($urls, $days); set_transient(self::TRANSIENT_KEY, $allRows, self::TRANSIENT_TTL); update_option(self::LAST_FETCH_OPTION_KEY, time(), false); return $allRows; } /** * Query the GSC Search Analytics API for each URL individually. * * GSC API does not support OR-filtering in dimensionFilterGroups, * so each URL must be queried one at a time. * * @param string[] $urls Relative or absolute URLs to query * @param int $days Number of days to look back * @return array> */ private function doFetchFromApi(array $urls, int $days = 90): array { $s = $this->getSettings(); $token = get_option(self::TOKEN_OPTION_KEY, false); if (!is_array($token) || empty($token['access_token'])) { return array(); } $siteUrl = $s['site_url']; $endDate = date('Y-m-d'); $startTimestamp = strtotime("-{$days} days"); $startDate = date('Y-m-d', $startTimestamp !== false ? $startTimestamp : 0); $urls = array_slice($urls, 0, 500); $allRows = array(); foreach ($urls as $url) { $absoluteUrl = (strpos($url, 'http') === 0) ? $url : rtrim(home_url('/'), '/') . '/' . ltrim($url, '/'); $body = array( 'startDate' => $startDate, 'endDate' => $endDate, 'dimensions' => array('page'), 'dimensionFilterGroups' => array( array( 'filters' => array( array( 'dimension' => 'page', 'operator' => 'equals', 'expression' => $absoluteUrl, ), ), ), ), 'rowLimit' => 1000, ); $encodedSiteUrl = urlencode($siteUrl); $response = wp_remote_post( self::API_BASE_URL . "/sites/{$encodedSiteUrl}/searchAnalytics/query", array( 'headers' => array( 'Authorization' => 'Bearer ' . $token['access_token'], 'Content-Type' => 'application/json', ), 'body' => (string)wp_json_encode($body), 'timeout' => 20, ) ); if (is_wp_error($response)) { $this->logger->warn('GSC API transport error: ' . $response->get_error_message()); break; } $httpCode = (int) wp_remote_retrieve_response_code($response); if ($httpCode !== 200) { $this->logger->warn('GSC API returned HTTP ' . $httpCode . ': ' . wp_remote_retrieve_body($response)); break; } $data = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($data) || empty($data['rows'])) { continue; } foreach ($data['rows'] as $row) { if (!is_array($row)) { continue; } $allRows[] = array( 'url' => isset($row['keys'][0]) ? $row['keys'][0] : '', 'clicks' => isset($row['clicks']) ? (int)$row['clicks'] : 0, 'impressions' => isset($row['impressions']) ? (int)$row['impressions'] : 0, 'position' => isset($row['position']) ? round((float)$row['position'], 1) : 0.0, ); } } // Sort by clicks descending usort($allRows, function ($a, $b) { return $b['clicks'] - $a['clicks']; }); return $allRows; } /** * Fetch GSC data and cache it. Called by the nightly cron and background refresh. * * Uses a lock transient to prevent overlapping fetches. * * @return void */ public function fetchAndCacheGscData(): void { if (!$this->isAuthorized()) { return; } // Prevent overlapping fetches if (get_transient(self::LOCK_TRANSIENT_KEY)) { return; } set_transient(self::LOCK_TRANSIENT_KEY, '1', self::LOCK_TTL); try { $urls = $this->getUrlsToQuery(); $allRows = $this->doFetchFromApi($urls); set_transient(self::TRANSIENT_KEY, $allRows, self::TRANSIENT_TTL); update_option(self::LAST_FETCH_OPTION_KEY, time(), false); } finally { delete_transient(self::LOCK_TRANSIENT_KEY); } } /** * Get the list of 404 URLs to query from the logs table. * * Extracted as a protected method so tests can override without needing * a full DataAccess/database stack. * * @return string[] */ protected function getUrlsToQuery(): array { $dao = abj_service('data_access'); return $dao->getDistinctLoggedUrls(); } /** * Return cached GSC data, or false if the cache is empty. * * @return array>|false */ public function getCachedData() { $cached = get_transient(self::TRANSIENT_KEY); return is_array($cached) ? $cached : false; } /** * Whether a background refresh should be triggered. * * True when the last fetch was more than STALE_THRESHOLD seconds ago * or no fetch has ever run. * * @return bool */ public function isRefreshNeeded(): bool { $lastFetch = get_option(self::LAST_FETCH_OPTION_KEY, 0); $lastFetchTime = is_numeric($lastFetch) ? (int)$lastFetch : 0; return (time() - $lastFetchTime) > self::STALE_THRESHOLD; } /** * Schedule an immediate single-event background refresh via WP-Cron. * * Guards against scheduling when a fetch is already locked or already scheduled. * * @return void */ public function scheduleBackgroundRefresh(): void { if (get_transient(self::LOCK_TRANSIENT_KEY)) { return; } if (wp_next_scheduled(self::BACKGROUND_REFRESH_HOOK)) { return; } wp_schedule_single_event(time(), self::BACKGROUND_REFRESH_HOOK); } /** * Fetch top N 404 URLs that also have GSC search traffic. * Correlates captured 404s with GSC data. * * @param array $capturedUrls Array of captured 404 URL strings * @param int $days Number of days for GSC data * @return array> Rows with url, clicks, impressions, position */ public function getTrafficDataForCaptured404s(array $capturedUrls, int $days = 90): array { if (empty($capturedUrls)) { return array(); } $data = $this->getSearchAnalyticsForUrls($capturedUrls, $days); return array_filter($data, function ($row) { return is_array($row) && isset($row['clicks']) && $row['clicks'] > 0; }); } // ------------------------------------------------------------------------- // OAuth error persistence (survives the post-OAuth redirect) // ------------------------------------------------------------------------- /** * Persist an OAuth error so it is visible after the page redirect. * @param string $message * @return void */ public function setLastOAuthError(string $message): void { update_option(self::ERROR_OPTION_KEY, $message, false); } /** * Retrieve the last stored OAuth error, or '' if none. * @return string */ public function getLastOAuthError(): string { $v = get_option(self::ERROR_OPTION_KEY, ''); return is_string($v) ? $v : ''; } /** * Clear any stored OAuth error (called on successful authorization and on revoke). * @return void */ public function clearLastOAuthError(): void { delete_option(self::ERROR_OPTION_KEY); } // ------------------------------------------------------------------------- // State machine // ------------------------------------------------------------------------- /** * Determine the current UI state of the GSC integration. * * In centralized mode the 'not_configured' state is skipped because * no per-user credentials are needed — the state starts at * 'configured_not_connected' until the user authorizes. * * @return string 'not_configured'|'configured_not_connected'|'error'|'connected' */ public function getState(): string { if (!$this->isConfigured()) { return 'not_configured'; } if ($this->isAuthorized()) { return 'connected'; } if ($this->getLastOAuthError() !== '') { return 'error'; } return 'configured_not_connected'; } // ------------------------------------------------------------------------- // Admin UI // ------------------------------------------------------------------------- /** * Render the inner content for the GSC settings/status card. * Callers wrap this via echoOptionsSection() for card + collapse support. * * Data is fetched by the nightly cron — this method only reads cached data. * * @param string[] $capturedUrls Deprecated — no longer used. Kept for backward compatibility. * @return string HTML */ public function renderAdminSection(array $capturedUrls = []): string { switch ($this->getState()) { case 'not_configured': return $this->renderNotConfiguredState(); case 'configured_not_connected': return $this->renderConfiguredNotConnectedState(); case 'error': return $this->renderErrorState(); default: // 'connected' return $this->renderConnectedState(); } } /** * State: no custom credentials entered and centralized mode not yet authorized. * * In centralized mode (default) this state is actually skipped because * isConfigured() returns true. This render method now only appears when * the user is in custom-credentials mode with empty credentials — which * shouldn't normally happen since centralized is the default. Kept for * the "use your own credentials" advanced flow. * * Shows a simple "Connect" button for centralized mode and an expandable * advanced section for custom credentials. * * @return string */ private function renderNotConfiguredState(): string { $callbackUrl = $this->getCallbackUrl(); $s = $this->getSettings(); $copiedLabel = esc_js(__('Copied!', '404-solution')); $html = '

' . esc_html__('Connect to Google Search Console to see which broken URLs were getting real search traffic.', '404-solution') . '

'; // Advanced: custom credentials toggle + wizard $html .= '
'; $html .= '' . esc_html__('Advanced: use your own Google Cloud credentials', '404-solution') . ''; $html .= '
'; $html .= '

' . esc_html__('Setup steps:', '404-solution') . '

'; $html .= '
    '; $html .= '
  1. ' . sprintf(esc_html__('Create a project in %s.', '404-solution'), 'Google Cloud Console') . '
  2. '; $html .= '
  3. ' . esc_html__('Enable the "Google Search Console API".', '404-solution') . '
  4. '; $html .= '
  5. ' . esc_html__('Create OAuth 2.0 credentials (Web application type).', '404-solution') . '
  6. '; $html .= '
  7. ' . esc_html__('Add this Authorized Redirect URI to your OAuth client:', '404-solution'); $html .= '
    '; $html .= '' . esc_html($callbackUrl) . ''; $html .= ''; $html .= '
    '; $html .= '
  8. '; $html .= '
  9. ' . esc_html__('Enter your Client ID and Client Secret below.', '404-solution') . '
  10. '; $html .= '
'; // Tiny inline script: copy button handler (admin-only page, no CSP concerns) $html .= ''; $nonceField = wp_nonce_field('abj404_gsc_save', '_wpnonce_gsc', true, false); $html .= '
'; $html .= $nonceField; $html .= ''; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '

' . esc_html__('The site URL as registered in Search Console (e.g. https://example.com/).', '404-solution') . '

'; $html .= '
'; $html .= ''; $html .= '
'; $html .= '
'; // end details inner div $html .= '
'; return $html; } /** * State: configured but OAuth not yet completed. * * In centralized mode: shows a friendly "Connect to Google Search Console" button * plus an advanced link to use custom credentials. * In custom mode: shows the existing "Credentials saved — authorization required" UI. * * @return string */ private function renderConfiguredNotConnectedState(): string { $authUrl = $this->buildAuthUrl(); $revokeUrl = wp_nonce_url(admin_url('admin-ajax.php?action=abj404_gsc_revoke'), 'abj404_gsc_revoke'); if ($this->isCentralizedMode()) { $html = '

' . esc_html__('Connect to Google Search Console to see which broken URLs were getting real search traffic.', '404-solution') . '

'; $html .= '' . esc_html__('Connect to Google Search Console', '404-solution') . ''; // Advanced: use your own credentials $html .= '
'; $html .= '' . esc_html__('Advanced: use your own Google Cloud credentials', '404-solution') . ''; $html .= '
'; $html .= $this->renderCustomCredentialsForm(); $html .= '
'; $html .= '
'; return $html; } $html = '
'; $html .= esc_html__('Credentials saved — authorization required', '404-solution'); $html .= '
'; $html .= '

' . esc_html__('Click the button below to authorize access to your Search Console data.', '404-solution') . '

'; $html .= '' . esc_html__('Authorize with Google', '404-solution') . ''; $html .= ' ' . esc_html__('Remove Credentials', '404-solution') . ''; return $html; } /** * Render the custom credentials form used in the "Advanced" expandable section. * Extracted to avoid duplication between renderNotConfiguredState and * renderConfiguredNotConnectedState. * * @return string HTML */ private function renderCustomCredentialsForm(): string { $callbackUrl = $this->getCallbackUrl(); $s = $this->getSettings(); $copiedLabel = esc_js(__('Copied!', '404-solution')); $html = '

' . esc_html__('Setup steps:', '404-solution') . '

'; $html .= '
    '; $html .= '
  1. ' . sprintf(esc_html__('Create a project in %s.', '404-solution'), 'Google Cloud Console') . '
  2. '; $html .= '
  3. ' . esc_html__('Enable the "Google Search Console API".', '404-solution') . '
  4. '; $html .= '
  5. ' . esc_html__('Create OAuth 2.0 credentials (Web application type).', '404-solution') . '
  6. '; $html .= '
  7. ' . esc_html__('Add this Authorized Redirect URI to your OAuth client:', '404-solution'); $html .= '
    '; $html .= '' . esc_html($callbackUrl) . ''; $html .= ''; $html .= '
    '; $html .= '
  8. '; $html .= '
  9. ' . esc_html__('Enter your Client ID and Client Secret below.', '404-solution') . '
  10. '; $html .= '
'; // Tiny inline script: copy button handler (admin-only page, no CSP concerns) $html .= ''; $nonceField = wp_nonce_field('abj404_gsc_save', '_wpnonce_gsc', true, false); $html .= '
'; $html .= $nonceField; $html .= ''; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '

' . esc_html__('The site URL as registered in Search Console (e.g. https://example.com/).', '404-solution') . '

'; $html .= '
'; $html .= ''; $html .= '
'; return $html; } /** * State: fully connected. Shows a green status pill + traffic data table. * * Reads only from the transient cache — never calls the GSC API directly. * The cache is populated by the nightly cron or a background refresh. * * @return string */ private function renderConnectedState(): string { $revokeUrl = wp_nonce_url(admin_url('admin-ajax.php?action=abj404_gsc_revoke'), 'abj404_gsc_revoke'); $html = '
'; $html .= esc_html__('Connected to Google Search Console', '404-solution'); $html .= '
'; $html .= '

' . esc_html__('Search traffic data for your captured 404 URLs is shown below. Data is refreshed nightly.', '404-solution') . '

'; $html .= '' . esc_html__('Disconnect', '404-solution') . ''; $cached = $this->getCachedData(); if (is_array($cached) && !empty($cached)) { $html .= '

' . esc_html__('404 URLs with Search Traffic (last 90 days)', '404-solution') . '

'; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; foreach (array_slice($cached, 0, 25) as $row) { if (!is_array($row)) { continue; } $html .= ''; $rowUrl = isset($row['url']) && is_scalar($row['url']) ? (string)$row['url'] : ''; $rowClicks = isset($row['clicks']) && is_scalar($row['clicks']) ? (string)$row['clicks'] : '0'; $rowImpressions = isset($row['impressions']) && is_scalar($row['impressions']) ? (string)$row['impressions'] : '0'; $rowPosition = isset($row['position']) && is_scalar($row['position']) ? (string)$row['position'] : '—'; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; } $html .= '
' . esc_html__('URL', '404-solution') . '' . esc_html__('Clicks', '404-solution') . '' . esc_html__('Impressions', '404-solution') . '' . esc_html__('Avg. Position', '404-solution') . '
' . esc_html($rowUrl) . '' . esc_html($rowClicks) . '' . esc_html($rowImpressions) . '' . esc_html($rowPosition) . '
'; } elseif ($this->isRefreshNeeded()) { $html .= '

' . esc_html__('GSC data is being fetched in the background. Reload this page in a few minutes.', '404-solution') . '

'; } else { $html .= '

' . esc_html__('No search traffic data found for your captured 404 URLs in the last 90 days.', '404-solution') . '

'; } return $html; } /** * State: authorization was attempted but failed. * Shows a danger box with the error message + Try Again / Remove Credentials buttons. * @return string */ private function renderErrorState(): string { $error = $this->getLastOAuthError(); $authUrl = $this->buildAuthUrl(); $revokeUrl = wp_nonce_url(admin_url('admin-ajax.php?action=abj404_gsc_revoke'), 'abj404_gsc_revoke'); $html = '
'; $html .= '' . esc_html__('Authorization failed', '404-solution') . ''; if ($error !== '') { $html .= '

' . esc_html($error) . '

'; } $html .= '
'; $html .= '

' . esc_html__("Click 'Try Again' to retry authorization, or 'Remove Credentials' to start over.", '404-solution') . '

'; $html .= '' . esc_html__('Try Again', '404-solution') . ''; $html .= ' ' . esc_html__('Remove Credentials', '404-solution') . ''; return $html; } }