| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Renders the Google Search Console admin settings/status section. |
| 9 |
* |
| 10 |
* HTML templates live in includes/html/gsc*.html with {placeholder} tokens; this |
| 11 |
* class loads them via FileSystemService::readFileContents() and substitutes |
| 12 |
* translated labels / per-request URLs / dynamic data via str_replace(). |
| 13 |
*/ |
| 14 |
class ABJ_404_Solution_GscAdminSectionRenderer { |
| 15 |
|
| 16 |
/** @var ABJ_404_Solution_GscOAuthTokenStore */ |
| 17 |
private $oauthStore; |
| 18 |
|
| 19 |
/** @var ABJ_404_Solution_GscSearchAnalyticsClient */ |
| 20 |
private $searchAnalytics; |
| 21 |
|
| 22 |
public function __construct( |
| 23 |
ABJ_404_Solution_GscOAuthTokenStore $oauthStore, |
| 24 |
ABJ_404_Solution_GscSearchAnalyticsClient $searchAnalytics |
| 25 |
) { |
| 26 |
$this->oauthStore = $oauthStore; |
| 27 |
$this->searchAnalytics = $searchAnalytics; |
| 28 |
} |
| 29 |
|
| 30 |
/** |
| 31 |
* Render the inner content for the GSC settings/status card. |
| 32 |
* |
| 33 |
* By default the UI state is derived from the OAuth/token store this |
| 34 |
* renderer already holds, so callers do not have to compute and pass it. |
| 35 |
* A caller may pass an explicit $state to render a specific branch (used to |
| 36 |
* exercise states that are unreachable through normal configuration, e.g. |
| 37 |
* 'not_configured' while centralized mode is active). |
| 38 |
* |
| 39 |
* @param string|null $state Force a specific UI state, or null to derive it. |
| 40 |
* @return string HTML. |
| 41 |
*/ |
| 42 |
public function renderAdminSection(?string $state = null): string { |
| 43 |
if ($state === null) { |
| 44 |
$state = $this->oauthStore->getState(); |
| 45 |
} |
| 46 |
switch ($state) { |
| 47 |
case 'not_configured': |
| 48 |
return $this->renderNotConfiguredState(); |
| 49 |
case 'configured_not_connected': |
| 50 |
return $this->renderConfiguredNotConnectedState(); |
| 51 |
case 'error': |
| 52 |
return $this->renderErrorState(); |
| 53 |
default: |
| 54 |
return $this->renderConnectedState(); |
| 55 |
} |
| 56 |
} |
| 57 |
|
| 58 |
/** |
| 59 |
* State: no custom credentials entered and centralized mode not yet authorized. |
| 60 |
* |
| 61 |
* @return string |
| 62 |
*/ |
| 63 |
private function renderNotConfiguredState(): string { |
| 64 |
$tpl = $this->loadTemplate('gscNotConfiguredState.html'); |
| 65 |
return strtr($tpl, [ |
| 66 |
'{intro_text}' => esc_html__('Connect to Google Search Console to see which broken URLs were getting real search traffic.', '404-solution'), |
| 67 |
'{advanced_label}' => esc_html__('Advanced: use your own Google Cloud credentials', '404-solution'), |
| 68 |
'{custom_credentials_form}' => $this->renderCustomCredentialsForm(), |
| 69 |
]); |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* State: configured but OAuth not yet completed. |
| 74 |
* |
| 75 |
* @return string |
| 76 |
*/ |
| 77 |
private function renderConfiguredNotConnectedState(): string { |
| 78 |
$authUrl = $this->oauthStore->buildAuthUrl(); |
| 79 |
$revokeUrl = wp_nonce_url(admin_url('admin-ajax.php?action=abj404_gsc_revoke'), 'abj404_gsc_revoke'); |
| 80 |
|
| 81 |
if ($this->oauthStore->isCentralizedMode()) { |
| 82 |
$tpl = $this->loadTemplate('gscConfiguredNotConnectedCentralized.html'); |
| 83 |
return strtr($tpl, [ |
| 84 |
'{intro_text}' => esc_html__('Connect to Google Search Console to see which broken URLs were getting real search traffic.', '404-solution'), |
| 85 |
'{auth_url}' => esc_url($authUrl), |
| 86 |
'{connect_label}' => esc_html__('Connect to Google Search Console', '404-solution'), |
| 87 |
'{advanced_label}' => esc_html__('Advanced: use your own Google Cloud credentials', '404-solution'), |
| 88 |
'{custom_credentials_form}' => $this->renderCustomCredentialsForm(), |
| 89 |
]); |
| 90 |
} |
| 91 |
|
| 92 |
$tpl = $this->loadTemplate('gscConfiguredNotConnectedCustom.html'); |
| 93 |
// allow-em-dash: preserving existing translated admin status label during renderer extraction. |
| 94 |
$statusLabel = esc_html__('Credentials saved — authorization required', '404-solution'); |
| 95 |
return strtr($tpl, [ |
| 96 |
'{status_label}' => $statusLabel, |
| 97 |
'{auth_prompt}' => esc_html__('Click the button below to authorize access to your Search Console data.', '404-solution'), |
| 98 |
'{auth_url}' => esc_url($authUrl), |
| 99 |
'{authorize_label}' => esc_html__('Authorize with Google', '404-solution'), |
| 100 |
'{revoke_url}' => esc_url($revokeUrl), |
| 101 |
'{remove_label}' => esc_html__('Remove Credentials', '404-solution'), |
| 102 |
]); |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Render the custom credentials form used in advanced sections. |
| 107 |
* |
| 108 |
* @return string HTML. |
| 109 |
*/ |
| 110 |
private function renderCustomCredentialsForm(): string { |
| 111 |
$callbackUrl = $this->oauthStore->getCallbackUrl(); |
| 112 |
$s = $this->oauthStore->getSettings(); |
| 113 |
$copiedLabel = esc_js(__('Copied!', '404-solution')); |
| 114 |
|
| 115 |
$stepCreateProject = sprintf( |
| 116 |
esc_html__('Create a project in %s.', '404-solution'), |
| 117 |
'<a href="https://console.cloud.google.com/" target="_blank" rel="noopener">Google Cloud Console</a>' |
| 118 |
); |
| 119 |
|
| 120 |
$tpl = $this->loadTemplate('gscCustomCredentialsForm.html'); |
| 121 |
return strtr($tpl, [ |
| 122 |
'{setup_steps_label}' => esc_html__('Setup steps:', '404-solution'), |
| 123 |
'{step_create_project}' => $stepCreateProject, |
| 124 |
'{step_enable_api}' => esc_html__('Enable the "Google Search Console API".', '404-solution'), |
| 125 |
'{step_create_credentials}' => esc_html__('Create OAuth 2.0 credentials (Web application type).', '404-solution'), |
| 126 |
'{step_add_redirect_uri}' => esc_html__('Add this Authorized Redirect URI to your OAuth client:', '404-solution'), |
| 127 |
'{callback_url}' => esc_html($callbackUrl), |
| 128 |
'{copy_label}' => esc_html__('Copy', '404-solution'), |
| 129 |
'{copied_label}' => (string)$copiedLabel, |
| 130 |
'{step_enter_credentials}' => esc_html__('Enter your Client ID and Client Secret below.', '404-solution'), |
| 131 |
'{nonce_field}' => wp_nonce_field('abj404_gsc_save', '_wpnonce_gsc', true, false), |
| 132 |
'{client_id_label}' => esc_html__('Client ID', '404-solution'), |
| 133 |
'{client_id_value}' => esc_attr($s['client_id']), |
| 134 |
'{client_secret_label}' => esc_html__('Client Secret', '404-solution'), |
| 135 |
'{client_secret_value}' => esc_attr($s['client_secret']), |
| 136 |
'{site_url_label}' => esc_html__('Search Console Site URL', '404-solution'), |
| 137 |
'{site_url_value}' => esc_attr($s['site_url']), |
| 138 |
'{site_url_help}' => esc_html__('The site URL as registered in Search Console (e.g. https://example.com/).', '404-solution'), |
| 139 |
'{save_label}' => esc_html__('Save Credentials', '404-solution'), |
| 140 |
]); |
| 141 |
} |
| 142 |
|
| 143 |
/** |
| 144 |
* State: fully connected. |
| 145 |
* |
| 146 |
* @return string |
| 147 |
*/ |
| 148 |
private function renderConnectedState(): string { |
| 149 |
$revokeUrl = wp_nonce_url(admin_url('admin-ajax.php?action=abj404_gsc_revoke'), 'abj404_gsc_revoke'); |
| 150 |
|
| 151 |
$headerTpl = $this->loadTemplate('gscConnectedHeader.html'); |
| 152 |
$html = strtr($headerTpl, [ |
| 153 |
'{status_label}' => esc_html__('Connected to Google Search Console', '404-solution'), |
| 154 |
'{description}' => esc_html__('Search traffic data for your captured 404 URLs is shown below. Data is refreshed nightly.', '404-solution'), |
| 155 |
'{revoke_url}' => esc_url($revokeUrl), |
| 156 |
'{disconnect_label}' => esc_html__('Disconnect', '404-solution'), |
| 157 |
]); |
| 158 |
|
| 159 |
$cached = $this->searchAnalytics->getCachedData(); |
| 160 |
|
| 161 |
if (is_array($cached) && !empty($cached)) { |
| 162 |
$rowTpl = $this->loadTemplate('gscConnectedTableRow.html'); |
| 163 |
$rows = ''; |
| 164 |
foreach (array_slice($cached, 0, 25) as $row) { |
| 165 |
if (!is_array($row)) { |
| 166 |
continue; |
| 167 |
} |
| 168 |
$rowUrl = isset($row['url']) && is_scalar($row['url']) ? (string)$row['url'] : ''; |
| 169 |
$rowClicks = isset($row['clicks']) && is_scalar($row['clicks']) ? (string)$row['clicks'] : '0'; |
| 170 |
$rowImpressions = isset($row['impressions']) && is_scalar($row['impressions']) ? (string)$row['impressions'] : '0'; |
| 171 |
$rowPosition = isset($row['position']) && is_scalar($row['position']) ? (string)$row['position'] : '-'; |
| 172 |
$rows .= strtr($rowTpl, [ |
| 173 |
'{url}' => esc_html($rowUrl), |
| 174 |
'{clicks}' => esc_html($rowClicks), |
| 175 |
'{impressions}' => esc_html($rowImpressions), |
| 176 |
'{position}' => esc_html($rowPosition), |
| 177 |
]); |
| 178 |
} |
| 179 |
$tableTpl = $this->loadTemplate('gscConnectedTable.html'); |
| 180 |
$html .= strtr($tableTpl, [ |
| 181 |
'{table_heading}' => esc_html__('404 URLs with Search Traffic (last 90 days)', '404-solution'), |
| 182 |
'{th_url}' => esc_html__('URL', '404-solution'), |
| 183 |
'{th_clicks}' => esc_html__('Clicks', '404-solution'), |
| 184 |
'{th_impressions}' => esc_html__('Impressions', '404-solution'), |
| 185 |
'{th_position}' => esc_html__('Avg. Position', '404-solution'), |
| 186 |
'{rows}' => $rows, |
| 187 |
]); |
| 188 |
} elseif ($this->searchAnalytics->isRefreshNeeded()) { |
| 189 |
$html .= $this->renderMutedNotice( |
| 190 |
esc_html__('GSC data is being fetched in the background. Reload this page in a few minutes.', '404-solution') |
| 191 |
); |
| 192 |
} else { |
| 193 |
$html .= $this->renderMutedNotice( |
| 194 |
esc_html__('No search traffic data found for your captured 404 URLs in the last 90 days.', '404-solution') |
| 195 |
); |
| 196 |
} |
| 197 |
|
| 198 |
return $html; |
| 199 |
} |
| 200 |
|
| 201 |
/** |
| 202 |
* State: authorization was attempted but failed. |
| 203 |
* |
| 204 |
* @return string |
| 205 |
*/ |
| 206 |
private function renderErrorState(): string { |
| 207 |
$error = $this->oauthStore->getLastOAuthError(); |
| 208 |
$authUrl = $this->oauthStore->buildAuthUrl(); |
| 209 |
$revokeUrl = wp_nonce_url(admin_url('admin-ajax.php?action=abj404_gsc_revoke'), 'abj404_gsc_revoke'); |
| 210 |
|
| 211 |
$errorBlock = ''; |
| 212 |
if ($error !== '') { |
| 213 |
$errorParaTpl = $this->loadTemplate('gscErrorMessageParagraph.html'); |
| 214 |
$errorBlock = strtr($errorParaTpl, ['{error}' => esc_html($error)]); |
| 215 |
} |
| 216 |
|
| 217 |
$tpl = $this->loadTemplate('gscErrorState.html'); |
| 218 |
return strtr($tpl, [ |
| 219 |
'{title}' => esc_html__('Authorization failed', '404-solution'), |
| 220 |
'{error_block}' => $errorBlock, |
| 221 |
'{instructions}' => esc_html__("Click 'Try Again' to retry authorization, or 'Remove Credentials' to start over.", '404-solution'), |
| 222 |
'{auth_url}' => esc_url($authUrl), |
| 223 |
'{try_again_label}' => esc_html__('Try Again', '404-solution'), |
| 224 |
'{revoke_url}' => esc_url($revokeUrl), |
| 225 |
'{remove_label}' => esc_html__('Remove Credentials', '404-solution'), |
| 226 |
]); |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* Render the small muted notice paragraph used when the connected table is |
| 231 |
* empty (refresh-in-progress / no-data cases). |
| 232 |
* |
| 233 |
* @param string $message Already-escaped translated message. |
| 234 |
* @return string HTML. |
| 235 |
*/ |
| 236 |
private function renderMutedNotice(string $message): string { |
| 237 |
$tpl = $this->loadTemplate('gscMutedNotice.html'); |
| 238 |
return strtr($tpl, ['{message}' => $message]); |
| 239 |
} |
| 240 |
|
| 241 |
/** |
| 242 |
* Read an HTML template from includes/html/. |
| 243 |
* |
| 244 |
* @param string $filename Template filename relative to includes/html/. |
| 245 |
* @return string Template contents. |
| 246 |
*/ |
| 247 |
private function loadTemplate(string $filename): string { |
| 248 |
return ABJ_404_Solution_FileSystemService::readFileContents( |
| 249 |
dirname(__DIR__) . '/html/' . $filename |
| 250 |
); |
| 251 |
} |
| 252 |
} |
| 253 |
|