Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.80% covered (danger)
47.80%
98 / 205
52.94% covered (warning)
52.94%
9 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
OnboardingWizardPage
47.80% covered (danger)
47.80%
98 / 205
52.94% covered (warning)
52.94%
9 / 17
809.15
0.00% covered (danger)
0.00%
0 / 1
 register
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addMenu
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 render
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
72
 currentStep
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 renderProgress
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 renderStepWelcome
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 renderStepAudience
16.00% covered (danger)
16.00%
4 / 25
0.00% covered (danger)
0.00%
0 / 1
45.93
 renderStepDone
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 renderAlreadyDone
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 renderFormOpen
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 renderFormClose
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 renderCheckboxGroup
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 fetchDefinitions
31.58% covered (danger)
31.58%
6 / 19
0.00% covered (danger)
0.00%
0 / 1
22.70
 fetchSavedAudience
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 handleStepSubmit
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
240
 saveAudience
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 isComplete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Onboarding Wizard (CF11 - client side).
4 *
5 * 3-step wizard that walks a first-time operator through:
6 *   Step 1: Welcome + license status check
7 *   Step 2: Pick your audience (sub_niches + geo_buckets + traffic_ranges)
8 *   Step 3: Done + next-steps guidance
9 *
10 * State persists between steps via the WP 'user_meta' option.
11 *
12 * All render paths go through Admin\Renderer (CF14 build-for-change rule).
13 *
14 * @package SwapAds\Client\Admin
15 * @since   1.1.0
16 */
17
18declare(strict_types=1);
19
20namespace SwapAds\Client\Admin;
21
22use SwapAds\Client\Admin\OperatorDashboardHubPage;
23use SwapAds\Client\Admin\ClientErrorRenderer;
24use SwapAds\Client\Api\RestClient;
25use SwapAds\Client\License\LicenseManager;
26
27/**
28 * Class OnboardingWizardPage.
29 *
30 * @since 1.1.0
31 */
32final class OnboardingWizardPage
33{
34    public const MENU_SLUG        = 'swapads-client-onboarding';
35    public const USER_META_DONE   = 'swapads_client_onboarding_complete';
36    public const ERROR_CONTEXT    = 'swapads_client_onboarding';
37    public const STEP_WELCOME     = 1;
38    public const STEP_AUDIENCE    = 2;
39    public const STEP_DONE        = 3;
40
41    /**
42     * Register WP hooks.
43     */
44    public static function register(): void
45    {
46        add_action('admin_menu', [self::class, 'addMenu']);
47        add_action('admin_post_swapads_client_onboarding_step', [self::class, 'handleStepSubmit']);
48    }
49
50    /**
51     * Register sub-menu page.
52     */
53    public static function addMenu(): void
54    {
55        // F233 (2026-07-31): hide Onboarding menu once the current user has
56        // completed onboarding. Operators who bookmarked ?page=...onboarding
57        // still land here (render() shows "already done") — only the menu
58        // entry disappears.
59        $userId = get_current_user_id();
60        $done   = (bool) get_user_meta($userId, self::USER_META_DONE, true);
61        if ($done) {
62            return;
63        }
64        add_submenu_page(
65            OperatorDashboardHubPage::MENU_SLUG,
66            'Onboarding',
67            'Onboarding',
68            'manage_options',
69            self::MENU_SLUG,
70            [self::class, 'render']
71        );
72    }
73
74    /**
75     * Render the wizard for the current step.
76     */
77    public static function render(): void
78    {
79        if (!current_user_can('manage_options')) {
80            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
81        }
82
83        $currentStep = self::currentStep();
84        $userId = get_current_user_id();
85        $done   = (bool) get_user_meta($userId, self::USER_META_DONE, true);
86
87        echo Renderer::pageHeader(
88            'Welcome to SwapAds',
89            'Get started in 3 quick steps. Set your audience preferences and we will match you with relevant backlink opportunities.'
90        );
91
92        // F198: surface any persisted error from a previous audience-save attempt.
93        $persisted = get_transient(self::ERROR_CONTEXT);
94        if (is_array($persisted)) {
95            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'save audience preferences');
96            delete_transient(self::ERROR_CONTEXT);
97        }
98
99        // Show progress bar (1/3, 2/3, 3/3)
100        echo self::renderProgress($currentStep, $done);
101
102        // Render the current step
103        if ($done) {
104            echo self::renderAlreadyDone();
105        } else {
106            switch ($currentStep) {
107                case self::STEP_WELCOME:
108                    echo self::renderStepWelcome();
109                    break;
110                case self::STEP_AUDIENCE:
111                    echo self::renderStepAudience();
112                    break;
113                case self::STEP_DONE:
114                    echo self::renderStepDone();
115                    break;
116                default:
117                    echo self::renderStepWelcome();
118                    break;
119            }
120        }
121
122        echo Renderer::pageFooter();
123    }
124
125    /**
126     * Determine which step the current user is on.
127     *
128     * Reads from request first (?step=N), then user meta (last seen step).
129     */
130    public static function currentStep(): int
131    {
132        // ?step=2 — explicit nav
133        if (isset($_GET['step'])) {
134            $step = (int) wp_unslash($_GET['step']);
135            if (in_array($step, [self::STEP_WELCOME, self::STEP_AUDIENCE, self::STEP_DONE], true)) {
136                return $step;
137            }
138        }
139        // User meta fallback
140        $userId = get_current_user_id();
141        $stored = (int) get_user_meta($userId, 'swapads_client_onboarding_step', true);
142        if ($stored >= self::STEP_WELCOME && $stored <= self::STEP_DONE) {
143            return $stored;
144        }
145        return self::STEP_WELCOME;
146    }
147
148    /**
149     * Render progress indicator.
150     */
151    public static function renderProgress(int $step, bool $done): string
152    {
153        $stages = [
154            self::STEP_WELCOME  => 'Welcome',
155            self::STEP_AUDIENCE => 'Audience',
156            self::STEP_DONE     => 'Done',
157        ];
158        $html = '<div class="swapads-wizard-progress">';
159        foreach ($stages as $n => $label) {
160            $state = $done ? 'done' : ($n < $step ? 'done' : ($n === $step ? 'current' : 'pending'));
161            $html .= '<span class="swapads-wizard-step swapads-wizard-step-' . esc_attr($state) . '">';
162            $html .= '<span class="swapads-wizard-step-num">' . esc_html((string) $n) . '</span> ';
163            $html .= '<span class="swapads-wizard-step-label">' . esc_html($label) . '</span>';
164            $html .= '</span>';
165        }
166        $html .= '</div>';
167        return $html;
168    }
169
170    /**
171     * Render step 1 (welcome).
172     */
173    public static function renderStepWelcome(): string
174    {
175        $licensed = LicenseManager::isLicensed();
176        $body  = '<p>SwapAds is a 1:1 barter exchange for backlinks and banner ads.</p>';
177        $body .= '<p>You will:</p>';
178        $body .= '<ol>';
179        $body .= '<li>Tell us about your audience (Step 2).</li>';
180        $body .= '<li>Receive backlink suggestions that match.</li>';
181        $body .= '<li>Approve them - and have your backlinks approved by others.</li>';
182        $body .= '</ol>';
183        if (!$licensed) {
184            $body .= '<p><strong>License inactive.</strong> Activate your license before continuing (see Settings).</p>';
185        }
186        $body .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
187        $body .= '<input type="hidden" name="action" value="swapads_client_onboarding_step">';
188        $body .= '<input type="hidden" name="step" value="' . esc_attr((string) self::STEP_AUDIENCE) . '">';
189        $body .= '<input type="hidden" name="swapads_nonce" value="' . esc_attr(wp_create_nonce('swapads_onboarding_step')) . '">';
190        $body .= '<p><button type="submit" class="button button-primary"' . ($licensed ? '' : ' disabled') . '>Continue to Step 2: Audience</button></p>';
191        $body .= '</form>';
192        return $body;
193    }
194
195    /**
196     * Render step 2 (audience selection).
197     */
198    public static function renderStepAudience(): string
199    {
200        $definitions = self::fetchDefinitions();
201        $saved       = self::fetchSavedAudience();
202        if (!is_array($definitions)) {
203            return '<p>Could not reach the server to load audience options. Please refresh in a moment.</p>';
204        }
205
206        $subNiches = is_array($definitions['sub_niches'] ?? null) ? $definitions['sub_niches'] : [];
207        $geos      = is_array($definitions['geo_buckets'] ?? null) ? $definitions['geo_buckets'] : [];
208        $traffic   = is_array($definitions['traffic_ranges'] ?? null) ? $definitions['traffic_ranges'] : [];
209
210        $savedSn = is_array($saved['sub_niches'] ?? null) ? $saved['sub_niches'] : [];
211        $savedG  = is_array($saved['geo_buckets'] ?? null) ? $saved['geo_buckets'] : [];
212        $savedT  = is_array($saved['traffic_ranges'] ?? null) ? $saved['traffic_ranges'] : [];
213
214        $body  = '<p>Select all that apply. You can change these later in Settings.</p>';
215        $body .= self::renderFormOpen();
216        $body .= '<h3>Sub-niches</h3>';
217        $body .= self::renderCheckboxGroup('sub_niches[]', $subNiches, $savedSn);
218        $body .= '<h3>Geo buckets</h3>';
219        $body .= self::renderCheckboxGroup('geo_buckets[]', $geos, $savedG);
220        $body .= '<h3>Traffic ranges</h3>';
221        $body .= self::renderCheckboxGroup('traffic_ranges[]', $traffic, $savedT);
222        $body .= '<input type="hidden" name="step" value="' . esc_attr((string) self::STEP_DONE) . '">';
223        $body .= '<p>';
224        $body .= '<button type="submit" class="button button-primary">Save and continue to Step 3</button> ';
225        $body .= '<a href="' . esc_url(add_query_arg('step', (string) self::STEP_WELCOME)) . '" class="button">Back</a>';
226        $body .= '</p>';
227        $body .= self::renderFormClose();
228        return $body;
229    }
230
231    /**
232     * Render step 3 (done + next steps).
233     */
234    public static function renderStepDone(): string
235    {
236        $body  = '<p><strong>All set!</strong> Your audience preferences are saved.</p>';
237        $body .= '<h3>Next steps</h3>';
238        $body .= '<ol>';
239        $body .= '<li><a href="' . esc_url(admin_url('admin.php?page=' . OperatorDashboardPage::MENU_SLUG)) . '">View your dashboard</a> to see credit balance and activity.</li>';
240        $body .= '<li><a href="' . esc_url(admin_url('admin.php?page=swapads-client-backlinks')) . '">Create a backlink offer</a> describing the page you want inbound links to.</li>';
241        $body .= '<li><a href="' . esc_url(admin_url('admin.php?page=swapads-client-approval')) . '">Review suggested backlinks</a> from operators in your niche.</li>';
242        $body .= '</ol>';
243        $body .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
244        $body .= '<input type="hidden" name="action" value="swapads_client_onboarding_step">';
245        $body .= '<input type="hidden" name="step" value="complete">';
246        $body .= '<input type="hidden" name="swapads_nonce" value="' . esc_attr(wp_create_nonce('swapads_onboarding_step')) . '">';
247        $body .= '<p><button type="submit" class="button button-primary">Finish onboarding</button></p>';
248        $body .= '</form>';
249        return $body;
250    }
251
252    /**
253     * Render a fallback message when the user is already onboarded.
254     */
255    public static function renderAlreadyDone(): string
256    {
257        $body  = '<p>You have already completed onboarding.</p>';
258        $body .= '<p><a class="button" href="' . esc_url(admin_url('admin.php?page=' . OperatorDashboardPage::MENU_SLUG)) . '">Go to Dashboard</a></p>';
259        return $body;
260    }
261
262    /**
263     * Render form-open tag with nonce.
264     */
265    public static function renderFormOpen(): string
266    {
267        return '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">'
268             . '<input type="hidden" name="action" value="swapads_client_onboarding_step">'
269             . '<input type="hidden" name="swapads_nonce" value="' . esc_attr(wp_create_nonce('swapads_onboarding_step')) . '">';
270    }
271
272    /**
273     * Render form-close tag.
274     */
275    public static function renderFormClose(): string
276    {
277        return '</form>';
278    }
279
280    /**
281     * Render a multi-checkbox group.
282     *
283     * @param string            $name   Form field name (must end with []).
284     * @param array<int,string> $values Available options.
285     * @param array<int,string> $saved  Selected values.
286     */
287    public static function renderCheckboxGroup(string $name, array $values, array $saved): string
288    {
289        if (count($values) === 0) {
290            return '<p class="swapads-muted">No options available.</p>';
291        }
292        $html = '<fieldset class="swapads-checkbox-group">';
293        foreach ($values as $v) {
294            $v = (string) $v;
295            $checked = in_array($v, $saved, true) ? 'checked="checked"' : '';
296            $html .= '<label><input type="checkbox" name="' . esc_attr($name) . '" value="' . esc_attr($v) . '" ' . $checked . '> ' . esc_html($v) . '</label>';
297        }
298        $html .= '</fieldset>';
299        return $html;
300    }
301
302    /**
303     * Fetch audience taxonomy from server.
304     *
305     * @return array<string, mixed>|null Null on error.
306     */
307    public static function fetchDefinitions(): ?array
308    {
309        // F198: RestClient::request() no longer throws for REST/transport
310        // failures — read lastError() and persist so render() can show it.
311        $client = RestClient::fromOption();
312        try {
313            $response = $client->get('/v1/audience/definitions');
314        } catch (\Throwable $e) {
315            set_transient(self::ERROR_CONTEXT, [
316                'error_code'  => 'FATAL',
317                'message'     => 'Unexpected fatal error: ' . $e->getMessage(),
318                'http_status' => 0,
319                'when'        => time(),
320            ], MINUTE_IN_SECONDS);
321            return null;
322        }
323        if (!is_array($response)) {
324            if (($err = $client->lastError()) !== null) {
325                set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
326            }
327            return null;
328        }
329        if (empty($response['success']) && ($err = $client->lastError()) !== null) {
330            set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
331            return null;
332        }
333        $data = $response['data'] ?? [];
334        return is_array($data) ? $data : null;
335    }
336
337    /**
338     * Fetch the current site's saved audience (best effort).
339     *
340     * @return array<string, mixed>
341     */
342    public static function fetchSavedAudience(): array
343    {
344        try {
345            $response = RestClient::fromOption()->get('/v1/audience/get');
346        } catch (\Throwable $e) {
347            return [];
348        }
349        if (!is_array($response)) {
350            return [];
351        }
352        $out = [];
353        foreach (['sub_niches', 'geo_buckets', 'traffic_ranges'] as $key) {
354            $out[$key] = is_array($response[$key] ?? null) ? array_map('strval', $response[$key]) : [];
355        }
356        return $out;
357    }
358
359    /**
360     * POST step form to admin-post.php handler.
361     */
362    public static function handleStepSubmit(): void
363    {
364        if (!current_user_can('manage_options')) {
365            wp_die('Forbidden', '', ['response' => 403]);
366        }
367        $nonce = isset($_POST['swapads_nonce']) ? (string) wp_unslash($_POST['swapads_nonce']) : '';
368        if (!wp_verify_nonce($nonce, 'swapads_onboarding_step')) {
369            wp_die('Invalid nonce', '', ['response' => 403]);
370        }
371
372        $step = isset($_POST['step']) ? (string) wp_unslash($_POST['step']) : '';
373        $userId = get_current_user_id();
374
375        if ($step === 'complete') {
376            update_user_meta($userId, self::USER_META_DONE, '1');
377            wp_safe_redirect(admin_url('admin.php?page=' . OperatorDashboardPage::MENU_SLUG));
378            if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
379        }
380
381        $target = (int) $step;
382        if ($target === self::STEP_AUDIENCE) {
383            // Forward from welcome -> audience (no save)
384            update_user_meta($userId, 'swapads_client_onboarding_step', (string) self::STEP_AUDIENCE);
385            wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG . '&step=' . self::STEP_AUDIENCE));
386            if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
387        }
388        if ($target === self::STEP_DONE) {
389            // Save audience + advance
390            $subNiches    = isset($_POST['sub_niches'])    ? (array) wp_unslash($_POST['sub_niches'])    : [];
391            $geoBuckets   = isset($_POST['geo_buckets'])   ? (array) wp_unslash($_POST['geo_buckets'])   : [];
392            $trafficRanges = isset($_POST['traffic_ranges']) ? (array) wp_unslash($_POST['traffic_ranges']) : [];
393            self::saveAudience(
394                array_values(array_filter($subNiches, 'is_string')),
395                array_values(array_filter($geoBuckets, 'is_string')),
396                array_values(array_filter($trafficRanges, 'is_string'))
397            );
398            update_user_meta($userId, 'swapads_client_onboarding_step', (string) self::STEP_DONE);
399            wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG . '&step=' . self::STEP_DONE));
400            if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
401        }
402
403        // Unknown step - bounce to welcome
404        wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG));
405        if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
406    }
407
408    /**
409     * Save the audience to the server (POST /v1/audience/set).
410     *
411     * @param array<int,string> $subNiches
412     * @param array<int,string> $geoBuckets
413     * @param array<int,string> $trafficRanges
414     */
415    public static function saveAudience(array $subNiches, array $geoBuckets, array $trafficRanges): bool
416    {
417        // F198: RestClient::request() never throws for REST/transport
418        // failures. On failure, persist the normalized error so render()
419        // can show the operator what went wrong.
420        $client = RestClient::fromOption();
421        $response = $client->post('/v1/audience/set', [
422            'sub_niches'     => array_values(array_unique(array_filter($subNiches, 'is_string'))),
423            'geo_buckets'    => array_values(array_unique(array_filter($geoBuckets, 'is_string'))),
424            'traffic_ranges' => array_values(array_unique(array_filter($trafficRanges, 'is_string'))),
425        ]);
426        if (!is_array($response) || empty($response['success'])) {
427            $err = $client->lastError() ?? $response;
428            if (is_array($err)) {
429                set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
430            }
431            return false;
432        }
433        return true;
434    }
435
436    /**
437     * Is the user finished with onboarding?
438     */
439    public static function isComplete(int $userId): bool
440    {
441        return (bool) get_user_meta($userId, self::USER_META_DONE, true);
442    }
443}