| 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 |
18 | declare(strict_types=1);
19 |
20 | namespace SwapAds\Client\Admin;
21 |
22 | use SwapAds\Client\Admin\OperatorDashboardHubPage;
23 | use SwapAds\Client\Admin\ClientErrorRenderer;
24 | use SwapAds\Client\Api\RestClient;
25 | use SwapAds\Client\License\LicenseManager;
26 |
27 | /**
28 | * Class OnboardingWizardPage.
29 | *
30 | * @since 1.1.0
31 | */
32 | final 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 | }