| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 11.28% covered (danger) | 11.28% | 22 / 195 | 30.00% covered (danger) | 30.00% | 3 / 10 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
OperatorDashboardHubPage | 11.28% covered (danger) | 11.28% | 22 / 195 | 30.00% covered (danger) | 30.00% | 3 / 10 | 1867.25 | 0.00% covered (danger) | 0.00% | 0 / 1
currentTab | 100.00% covered (success) | 100.00% | 2 / 2 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
register | 0.00% covered (danger) | 0.00% | 0 / 3 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
addMenu | 100.00% covered (success) | 100.00% | 9 / 9 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
render | 0.00% covered (danger) | 0.00% | 0 / 10 | 0.00% covered (danger) | 0.00% | 0 / 1 | 12 |
renderTabs | 100.00% covered (success) | 100.00% | 11 / 11 | 100.00% covered (success) | 100.00% | 1 / 1 | 5 |
renderSetupTab | 0.00% covered (danger) | 0.00% | 0 / 60 | 0.00% covered (danger) | 0.00% | 0 / 1 | 56 |
renderNicheDropdown | 0.00% covered (danger) | 0.00% | 0 / 55 | 0.00% covered (danger) | 0.00% | 0 / 1 | 90 |
renderAudienceCheckboxes | 0.00% covered (danger) | 0.00% | 0 / 16 | 0.00% covered (danger) | 0.00% | 0 / 1 | 20 |
fetchAudienceDefinitions | 0.00% covered (danger) | 0.00% | 0 / 17 | 0.00% covered (danger) | 0.00% | 0 / 1 | 30 |
handleSaveAudience | 0.00% covered (danger) | 0.00% | 0 / 12 | 0.00% covered (danger) | 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> &mdash; '
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 | }