Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
11.28% |
22 / 195 |
|
30.00% |
3 / 10 |
CRAP | |
0.00% |
0 / 1 |
| OperatorDashboardHubPage | |
11.28% |
22 / 195 |
|
30.00% |
3 / 10 |
1867.25 | |
0.00% |
0 / 1 |
| currentTab | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| register | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| addMenu | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| render | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
| renderTabs | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
| renderSetupTab | |
0.00% |
0 / 60 |
|
0.00% |
0 / 1 |
56 | |||
| renderNicheDropdown | |
0.00% |
0 / 55 |
|
0.00% |
0 / 1 |
90 | |||
| renderAudienceCheckboxes | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
20 | |||
| fetchAudienceDefinitions | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
30 | |||
| handleSaveAudience | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
210 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * OperatorDashboardHubPage — F201 first submenu with 2 tabs. |
| 4 | * |
| 5 | * Replaces the two separate menu items: |
| 6 | * - "SwapAds" (parent) — kept for back-compat, page redirects to hub |
| 7 | * - "Dashboard" submenu — promoted to FIRST submenu via menu_position |
| 8 | * - "Backlinks" submenu — added by BacklinksHubPage (F200) |
| 9 | * |
| 10 | * Tabs: |
| 11 | * - ?tab=balance (default): existing OperatorDashboardPage metric tiles |
| 12 | * - ?tab=setup: audience config + license + auto-inject toggle |
| 13 | * |
| 14 | * The Setup tab fetches /audience/definitions from the server to render |
| 15 | * sub-niches / geo_buckets / traffic_ranges as checkbox groups. Operators |
| 16 | * can enable/disable individual items rather than typing comma-separated |
| 17 | * values. |
| 18 | * |
| 19 | * @since 1.5.2 |
| 20 | */ |
| 21 | declare(strict_types=1); |
| 22 | |
| 23 | namespace SwapAds\Client\Admin; |
| 24 | |
| 25 | use SwapAds\Client\Api\RestClient; |
| 26 | use SwapAds\Client\Audience\SiteAudience; |
| 27 | use SwapAds\Client\License\LicenseManager; |
| 28 | |
| 29 | final class OperatorDashboardHubPage |
| 30 | { |
| 31 | public const MENU_SLUG = 'swapads-client-operator-hub'; |
| 32 | |
| 33 | public const TAB_BALANCE = 'balance'; |
| 34 | public const TAB_SETUP = 'setup'; |
| 35 | |
| 36 | /** |
| 37 | * Active tab (sanitized). Defaults to TAB_BALANCE. |
| 38 | */ |
| 39 | public static function currentTab(): string |
| 40 | { |
| 41 | $t = (string) ($_GET['tab'] ?? self::TAB_BALANCE); |
| 42 | return in_array($t, [self::TAB_BALANCE, self::TAB_SETUP], true) ? $t : self::TAB_BALANCE; |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Register WP hooks. |
| 47 | * |
| 48 | * @since 1.5.2 |
| 49 | */ |
| 50 | public static function register(): void |
| 51 | { |
| 52 | add_action('admin_menu', [self::class, 'addMenu']); |
| 53 | // Back-compat: SettingsPage's old admin-post handler still works. |
| 54 | add_action('admin_post_swapads_client_save', [SettingsPage::class, 'handleSave']); |
| 55 | add_action('admin_post_swapads_client_save_audience', [self::class, 'handleSaveAudience']); |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * Register the dashboard menu — FIRST submenu under SwapAds. |
| 60 | * |
| 61 | * Uses menu_position=0 (or 1) so it appears immediately after the |
| 62 | * top-level "SwapAds" entry. Old SettingsPage / Dashboard submenus |
| 63 | * are removed by the Plugin.php registration order. |
| 64 | */ |
| 65 | public static function addMenu(): void |
| 66 | { |
| 67 | add_menu_page( |
| 68 | 'SwapAds Dashboard', |
| 69 | 'SwapAds', |
| 70 | 'manage_options', |
| 71 | self::MENU_SLUG, |
| 72 | [self::class, 'render'], |
| 73 | 'dashicons-randomize', |
| 74 | 81 |
| 75 | ); |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * Render the full hub page. |
| 80 | * |
| 81 | * @since 1.5.2 |
| 82 | */ |
| 83 | public static function render(): void |
| 84 | { |
| 85 | if (!current_user_can('manage_options')) { |
| 86 | wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]); |
| 87 | } |
| 88 | |
| 89 | $tab = self::currentTab(); |
| 90 | echo '<div class="wrap swapads-page">'; |
| 91 | echo '<h1>' . esc_html__('SwapAds Dashboard', 'swapads-client') . '</h1>'; |
| 92 | echo self::renderTabs($tab); |
| 93 | |
| 94 | if ($tab === self::TAB_SETUP) { |
| 95 | self::renderSetupTab(); |
| 96 | } else { |
| 97 | OperatorDashboardPage::render(); |
| 98 | } |
| 99 | |
| 100 | echo '</div>'; // .wrap |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Render the tab nav (same shape as BacklinksHubPage so a11y attrs match). |
| 105 | */ |
| 106 | public static function renderTabs(string $active): string |
| 107 | { |
| 108 | $balance = esc_url(add_query_arg('tab', self::TAB_BALANCE)); |
| 109 | $setup = esc_url(add_query_arg('tab', self::TAB_SETUP)); |
| 110 | $h = '<nav class="swapads-tab-nav" role="tablist" aria-label="Dashboard tabs">'; |
| 111 | $h .= '<a href="' . $balance . '" role="tab" aria-selected="' . ($active === self::TAB_BALANCE ? 'true' : 'false') . '"' |
| 112 | . ' class="swapads-tab' . ($active === self::TAB_BALANCE ? ' swapads-tab--active' : '') . '">' |
| 113 | . esc_html__('Balance', 'swapads-client') . '</a>'; |
| 114 | $h .= '<a href="' . $setup . '" role="tab" aria-selected="' . ($active === self::TAB_SETUP ? 'true' : 'false') . '"' |
| 115 | . ' class="swapads-tab' . ($active === self::TAB_SETUP ? ' swapads-tab--active' : '') . '">' |
| 116 | . esc_html__('Setup', 'swapads-client') . '</a>'; |
| 117 | $h .= '</nav>'; |
| 118 | return $h; |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Render the Setup tab: license panel + WP domain (read-only) + |
| 123 | * auto-inject toggle + audience checkbox groups (sub-niches / |
| 124 | * geo_buckets / traffic_ranges) fetched from /audience/definitions. |
| 125 | * |
| 126 | * Replaces the old SettingsPage form (which had a Server URL field |
| 127 | * + default placement slug field — both removed 2026-07-30). |
| 128 | * |
| 129 | * @since 1.5.2 |
| 130 | */ |
| 131 | public static function renderSetupTab(): void |
| 132 | { |
| 133 | echo '<h2 style="margin-top:16px">' . esc_html__('Setup', 'swapads-client') . '</h2>'; |
| 134 | echo '<p class="description">' |
| 135 | . esc_html__('Configure your license, auto-inject, and audience. Audience fields are fetched from the server — check the boxes that match your site.', 'swapads-client') |
| 136 | . '</p>'; |
| 137 | |
| 138 | // License status panel |
| 139 | echo '<div class="swapads-card" style="margin-top:16px">'; |
| 140 | echo '<h3>' . esc_html__('License', 'swapads-client') . '</h3>'; |
| 141 | if (LicenseManager::isLicensed()) { |
| 142 | echo '<p><strong>Status:</strong> <span class="swapads-status swapads-status--active">' |
| 143 | . esc_html(LicenseManager::status()) . '</span></p>'; |
| 144 | echo '<p><strong>License key:</strong> <code>' . esc_html(LicenseManager::key()) . '</code></p>'; |
| 145 | } else { |
| 146 | echo '<p>No active license. Activate via ' |
| 147 | . '<a href="' . esc_url(admin_url('admin.php?page=' . SettingsPage::MENU_SLUG)) . '">SwapAds Settings</a>.</p>'; |
| 148 | } |
| 149 | // Plg-001 (2026-08-01): server-activation status + manual "Activate now" button. |
| 150 | // Freemius opt-in is asynchronous; this button lets the operator force |
| 151 | // a re-attempt without waiting for the next 12h cron tick. |
| 152 | $fsStatus = \SwapAds\Client\License\FreemiusAutoActivator::getStatus(); |
| 153 | echo '<p><strong>Server activation:</strong> <span class="swapads-status swapads-status--' . esc_attr($fsStatus) . '">' |
| 154 | . esc_html(ucfirst($fsStatus)) . '</span></p>'; |
| 155 | $last = \SwapAds\Client\License\FreemiusAutoActivator::getLastResult(); |
| 156 | if (is_array($last) && isset($last['error_code']) && $fsStatus !== 'active') { |
| 157 | echo '<p class="description">Last error: <code>' . esc_html((string) $last['error_code']) . '</code> — ' |
| 158 | . esc_html((string) ($last['message'] ?? '')) . '</p>'; |
| 159 | } |
| 160 | if ($fsStatus !== 'active') { |
| 161 | $retryUrl = wp_nonce_url( |
| 162 | add_query_arg(['action' => 'swapads_client_retry_activation'], admin_url('admin-post.php')), |
| 163 | 'swapads_client_retry_activation' |
| 164 | ); |
| 165 | echo '<p><a class="button button-primary" href="' . esc_url($retryUrl) . '">' |
| 166 | . esc_html__('Activate license now', 'swapads-client') . '</a></p>'; |
| 167 | } |
| 168 | echo '<p><strong>Site domain:</strong> <code>' . esc_html(home_url()) . '</code></p>'; |
| 169 | echo '</div>'; |
| 170 | |
| 171 | // Auto-inject toggle (banner injection into footer) |
| 172 | $autoInject = (bool) get_option('swapads_client_auto_inject', false); |
| 173 | echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">'; |
| 174 | echo '<input type="hidden" name="action" value="swapads_client_save">'; |
| 175 | wp_nonce_field(SettingsPage::NONCE_ACTION); |
| 176 | echo '<div class="swapads-card" style="margin-top:16px">'; |
| 177 | echo '<h3>' . esc_html__('Banner injection', 'swapads-client') . '</h3>'; |
| 178 | echo '<p><label><input type="checkbox" name="auto_inject" value="1" ' . checked($autoInject, true, false) . '> ' |
| 179 | . esc_html__('Auto-inject banner ads into site footer (requires shortcode or widget for placement).', 'swapads-client') |
| 180 | . '</label></p>'; |
| 181 | echo '<p>' . get_submit_button('Save', 'primary', 'submit', false) . '</p>'; |
| 182 | echo '</div>'; |
| 183 | echo '</form>'; |
| 184 | |
| 185 | // F235 (2026-07-31): Audience is now a TWO-STEP selector. |
| 186 | // Step 1: pick a niche from server-defined dropdown (adult/igaming/cannabis). |
| 187 | // Step 2: pick sub-niches scoped to that niche (filtered server-side). |
| 188 | echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">'; |
| 189 | echo '<input type="hidden" name="action" value="swapads_client_save_audience">'; |
| 190 | wp_nonce_field('swapads_client_save_audience'); |
| 191 | $current = SiteAudience::get(); |
| 192 | $definitions = self::fetchAudienceDefinitions(); |
| 193 | echo '<div class="swapads-card" style="margin-top:16px">'; |
| 194 | echo '<h3>' . esc_html__('Audience', 'swapads-client') . '</h3>'; |
| 195 | |
| 196 | if ($definitions === null) { |
| 197 | echo '<p><em>Could not fetch audience definitions from the server. Save the rest of your settings and try again once the server is reachable.</em></p>'; |
| 198 | } else { |
| 199 | // Step 1: Niche dropdown (server-defined) |
| 200 | self::renderNicheDropdown( |
| 201 | (array) ($definitions['niches'] ?? []), |
| 202 | (array) ($definitions['sub_niches_by_niche'] ?? []), |
| 203 | (string) ($current['niche_code'] ?? ''), |
| 204 | (array) ($current['sub_niches'] ?? []) |
| 205 | ); |
| 206 | // Step 2 + 3: other dimensions (unchanged) |
| 207 | self::renderAudienceCheckboxes('Geo buckets', 'geo_buckets', (array) ($definitions['geo_buckets'] ?? []), (array) ($current['geo_buckets'] ?? [])); |
| 208 | self::renderAudienceCheckboxes('Traffic ranges', 'traffic_ranges', (array) ($definitions['traffic_ranges'] ?? []), (array) ($current['traffic_ranges'] ?? [])); |
| 209 | } |
| 210 | echo '<p>' . get_submit_button('Save audience', 'primary', 'submit', false) . '</p>'; |
| 211 | echo '</div>'; |
| 212 | echo '</form>'; |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * F235 (2026-07-31): Render the niche dropdown + sub-niche checkboxes. |
| 217 | * |
| 218 | * Architecture: |
| 219 | * - Niche dropdown is single-select (Y1: operator picks 1 niche). |
| 220 | * Multiple niches deferred to Y1.5. |
| 221 | * - Sub-niche checkboxes are scoped to the picked niche via JS: |
| 222 | * when operator changes niche, we show ONLY sub-niches for that niche. |
| 223 | * - Sub-niche options are rendered server-side as <optgroup data-niche="X"> |
| 224 | * OR <div data-niche="X"> so JS can show/hide without re-fetching. |
| 225 | * |
| 226 | * @param array<int, array{code: string, label: string}> $niches |
| 227 | * @param array<string, array<int, array{code: string, label: string}>> $subNichesByNiche |
| 228 | * @param string $currentNicheCode |
| 229 | * @param array<int, string> $currentSubNiches |
| 230 | */ |
| 231 | private static function renderNicheDropdown(array $niches, array $subNichesByNiche, string $currentNicheCode, array $currentSubNiches): void |
| 232 | { |
| 233 | echo '<fieldset style="margin: 12px 0; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px;">'; |
| 234 | echo '<legend style="font-weight: 600; padding: 0 6px;">' . esc_html__('Step 1: Pick your site niche', 'swapads-client') . '</legend>'; |
| 235 | echo '<p class="description">' . esc_html__('Your site is about ONE of these topics. Sub-niches in step 2 are scoped to your choice.', 'swapads-client') . '</p>'; |
| 236 | |
| 237 | // Single-select dropdown |
| 238 | echo '<select name="niche_code" id="swapads-niche-dropdown" style="min-width: 280px; padding: 6px 8px; font-size: 14px;">'; |
| 239 | echo '<option value="">— ' . esc_html__('Select a niche', 'swapads-client') . ' —</option>'; |
| 240 | foreach ($niches as $n) { |
| 241 | $code = (string) $n['code']; |
| 242 | $label = (string) $n['label']; |
| 243 | $selected = selected($code, $currentNicheCode, false); |
| 244 | echo '<option value="' . esc_attr($code) . '"' . $selected . '>' |
| 245 | . esc_html($label) . ' (' . esc_html($code) . ')</option>'; |
| 246 | } |
| 247 | echo '</select>'; |
| 248 | echo '</fieldset>'; |
| 249 | |
| 250 | // Step 2: Sub-niche checkboxes scoped per niche (all rendered, JS shows the matching group) |
| 251 | echo '<fieldset style="margin: 12px 0; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px;">'; |
| 252 | echo '<legend style="font-weight: 600; padding: 0 6px;">' . esc_html__('Step 2: Sub-niches that apply to your site', 'swapads-client') . '</legend>'; |
| 253 | echo '<p class="description">' . esc_html__('Check every category that fits. Multiple selections allowed within your niche.', 'swapads-client') . '</p>'; |
| 254 | echo '<div id="swapads-subniche-groups">'; |
| 255 | if (empty($subNichesByNiche)) { |
| 256 | echo '<p><em>' . esc_html__('No sub-niche definitions available for any niche yet.', 'swapads-client') . '</em></p>'; |
| 257 | } |
| 258 | foreach ($niches as $n) { |
| 259 | $nicheCode = (string) $n['code']; |
| 260 | $opts = (array) ($subNichesByNiche[$nicheCode] ?? []); |
| 261 | if (empty($opts)) { |
| 262 | continue; |
| 263 | } |
| 264 | $sel = array_flip($currentSubNiches); |
| 265 | $isVisible = ($currentNicheCode !== '' && $currentNicheCode === $nicheCode); |
| 266 | $displayStyle = $isVisible ? 'block' : 'none'; |
| 267 | echo '<div class="swapads-subniche-group" data-niche="' . esc_attr($nicheCode) . '" style="display: ' . $displayStyle . ';">'; |
| 268 | echo '<p><strong>' . esc_html((string) $n['label']) . '</strong></p>'; |
| 269 | echo '<div style="display: flex; flex-wrap: wrap; gap: 12px; max-width: 800px;">'; |
| 270 | foreach ($opts as $opt) { |
| 271 | $code = (string) $opt['code']; |
| 272 | $label = (string) $opt['label']; |
| 273 | $checked = isset($sel[$code]); |
| 274 | echo '<label style="display:inline-flex; align-items:center; gap:4px;">' |
| 275 | . '<input type="checkbox" name="sub_niches[]" value="' . esc_attr($code) . '"' |
| 276 | . ($checked ? ' checked' : '') . '>' |
| 277 | . esc_html($label) . ' <code style="font-size: 11px; color: #666;">' . esc_html($code) . '</code>' |
| 278 | . '</label>'; |
| 279 | } |
| 280 | echo '</div>'; |
| 281 | echo '</div>'; |
| 282 | } |
| 283 | echo '</div>'; // #swapads-subniche-groups |
| 284 | echo '</fieldset>'; |
| 285 | |
| 286 | // Inline JS for showing/hiding sub-niche groups based on niche selection |
| 287 | echo '<script>(function(){' |
| 288 | . 'var dropdown = document.getElementById("swapads-niche-dropdown");' |
| 289 | . 'var groups = document.querySelectorAll(".swapads-subniche-group");' |
| 290 | . 'function sync(){' |
| 291 | . 'var v = dropdown.value;' |
| 292 | . 'groups.forEach(function(g){' |
| 293 | . 'g.style.display = (g.dataset.niche === v) ? "block" : "none";' |
| 294 | . '});' |
| 295 | . '}' |
| 296 | . 'if (dropdown) dropdown.addEventListener("change", sync);' |
| 297 | . 'sync();' |
| 298 | . '})();</script>'; |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * Render a single checkbox group for one audience dimension. |
| 303 | * |
| 304 | * @param string $title Heading shown to the operator. |
| 305 | * @param string $name Form field name (also storage key). |
| 306 | * @param array<int, string> $options Server-defined options (e.g. ['live','recorded']). |
| 307 | * @param array<int, string> $selected Currently selected option codes. |
| 308 | */ |
| 309 | private static function renderAudienceCheckboxes(string $title, string $name, array $options, array $selected): void |
| 310 | { |
| 311 | if (empty($options)) { |
| 312 | return; |
| 313 | } |
| 314 | $sel = array_flip($selected); |
| 315 | echo '<fieldset style="margin: 12px 0; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px;">'; |
| 316 | echo '<legend style="font-weight: 600; padding: 0 6px;">' . esc_html__($title, 'swapads-client') . '</legend>'; |
| 317 | echo '<div style="display: flex; flex-wrap: wrap; gap: 12px; max-width: 800px;">'; |
| 318 | foreach ($options as $opt) { |
| 319 | $optStr = (string) $opt; |
| 320 | $checked = isset($sel[$optStr]); |
| 321 | echo '<label style="display:inline-flex; align-items:center; gap:4px;">' |
| 322 | . '<input type="checkbox" name="' . esc_attr($name) . '[]" value="' . esc_attr($optStr) . '"' |
| 323 | . ($checked ? ' checked' : '') . '>' |
| 324 | . '<code>' . esc_html($optStr) . '</code>' |
| 325 | . '</label>'; |
| 326 | } |
| 327 | echo '</div>'; |
| 328 | echo '</fieldset>'; |
| 329 | } |
| 330 | |
| 331 | /** |
| 332 | * Fetch audience definitions from server via RestClient. |
| 333 | * |
| 334 | * @return array<string, mixed>|null Null on error or unlicensed. |
| 335 | */ |
| 336 | public static function fetchAudienceDefinitions(): ?array |
| 337 | { |
| 338 | if (!LicenseManager::isLicensed()) { |
| 339 | return null; |
| 340 | } |
| 341 | $client = RestClient::fromOption(); |
| 342 | $resp = $client->get('/v1/audience/definitions'); |
| 343 | if (!is_array($resp) || !isset($resp['data']) || !is_array($resp['data'])) { |
| 344 | return null; |
| 345 | } |
| 346 | $data = $resp['data']; |
| 347 | return [ |
| 348 | 'niches' => array_values((array) ($data['niches'] ?? [])), // F235 |
| 349 | 'sub_niches_by_niche' => array_map( |
| 350 | fn ($arr) => array_values((array) $arr), |
| 351 | (array) ($data['sub_niches_by_niche'] ?? []) |
| 352 | ), // F235 |
| 353 | 'sub_niches' => array_values((array) ($data['sub_niches'] ?? [])), |
| 354 | 'geo_buckets' => array_values((array) ($data['geo_buckets'] ?? [])), |
| 355 | 'traffic_ranges' => array_values((array) ($data['traffic_ranges'] ?? [])), |
| 356 | ]; |
| 357 | } |
| 358 | |
| 359 | /** |
| 360 | * Handle the Save Audience POST. |
| 361 | * |
| 362 | * Reads checkbox groups from $_POST, calls SiteAudience::save(), and |
| 363 | * redirects back to the hub Setup tab with a success/error flag. |
| 364 | * |
| 365 | * @since 1.5.2 |
| 366 | */ |
| 367 | public static function handleSaveAudience(): void |
| 368 | { |
| 369 | if (!current_user_can('manage_options')) { |
| 370 | wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]); |
| 371 | } |
| 372 | check_admin_referer('swapads_client_save_audience'); |
| 373 | // F235 (2026-07-31): niche_code is now single-select dropdown. |
| 374 | // Empty string = no niche chosen (allowed; sub_niches cleared). |
| 375 | $nicheCode = isset($_POST['niche_code']) && is_string($_POST['niche_code']) ? trim((string) $_POST['niche_code']) : ''; |
| 376 | $subNiches = isset($_POST['sub_niches']) && is_array($_POST['sub_niches']) ? array_map('strval', $_POST['sub_niches']) : []; |
| 377 | $geoBuckets = isset($_POST['geo_buckets']) && is_array($_POST['geo_buckets']) ? array_map('strval', $_POST['geo_buckets']) : []; |
| 378 | $trafficRanges = isset($_POST['traffic_ranges']) && is_array($_POST['traffic_ranges']) ? array_map('strval', $_POST['traffic_ranges']) : []; |
| 379 | $result = SiteAudience::save($nicheCode, $subNiches, $geoBuckets, $trafficRanges); |
| 380 | if (is_array($result) && ($result['success'] ?? false)) { |
| 381 | wp_safe_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => self::TAB_SETUP, 'saved' => 1], admin_url('admin.php'))); |
| 382 | } else { |
| 383 | $err = is_array($result) && isset($result['error_code']) ? $result['error_code'] : 'UNKNOWN'; |
| 384 | wp_safe_redirect(add_query_arg(['page' => self::MENU_SLUG, 'tab' => self::TAB_SETUP, 'error' => $err], admin_url('admin.php'))); |
| 385 | } |
| 386 | // No exit; — see SettingsPage::render() for rationale. |
| 387 | } |
| 388 | } |