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 */
21declare(strict_types=1);
22
23namespace SwapAds\Client\Admin;
24
25use SwapAds\Client\Api\RestClient;
26use SwapAds\Client\Audience\SiteAudience;
27use SwapAds\Client\License\LicenseManager;
28
29final 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}