Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.23% covered (danger)
30.23%
91 / 301
18.75% covered (danger)
18.75%
3 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
BacklinkApprovalPage
30.23% covered (danger)
30.23%
91 / 301
18.75% covered (danger)
18.75%
3 / 16
1282.53
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 addMenu
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 fetchSuggestions
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 render
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
3.05
 renderSuggestionsCard
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 1
110
 renderPagination
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 renderRow
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
7
 handleApproveBulk
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
90
 handleReject
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 handleRejectBulk
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
72
 handleApprove
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 approveOne
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 redirectSuccess
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 redirectRejected
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 redirectError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 hideSubmenuFromSidebar
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Backlink Approval Queue (CF15) — Operator B's view.
4 *
5 * Lists backlink suggestions pulled from server
6 * (GET /swapads-server/v1/backlinks/match) and lets the operator approve
7 * or reject each one inline.
8 *
9 * Per _UX-SPEC.md §2.5:
10 *   - Sortable columns: source, anchor, match, trust, reciprocal risk
11 *   - Bulk actions: approve + reject
12 *   - Reciprocal warning when the requester already links to the offerer
13 *   - One-click approve -> POST /v1/backlinks/approve
14 *
15 * Per "build-for-change" rule (locked 2026-07-28): every render path
16 * goes through Admin\Renderer static helpers.
17 *
18 * @package SwapAds\Client\Admin
19 * @since   1.0.0
20 */
21
22declare(strict_types=1);
23
24namespace SwapAds\Client\Admin;
25
26use SwapAds\Client\Api\RestClient;
27use SwapAds\Client\License\LicenseManager;
28
29/**
30 * Class BacklinkApprovalPage.
31 *
32 * @since 1.0.0
33 */
34final class BacklinkApprovalPage
35{
36    public const MENU_SLUG    = 'swapads-client-approval';
37    public const NONCE_ACTION = 'swapads_client_approve_backlink';
38    public const NONCE_ACTION_BULK = 'swapads_client_approve_bulk';
39    public const NONCE_ACTION_REJECT      = 'swapads_client_reject_backlink';
40    public const NONCE_ACTION_REJECT_BULK = 'swapads_client_reject_bulk';
41    public const RECIPROCAL_WARN_THRESHOLD = 0.2;
42    public const DEFAULT_LIMIT = 25;
43    public const BULK_MAX = 50;
44    public const ERROR_CONTEXT  = 'swapads_client_backlink_approve';
45
46    /**
47     * Register WP hooks.
48     */
49    public static function register(): void
50    {
51        add_action('admin_menu', [self::class, 'addMenu']);
52        // F2XX-fix 2026-08-01: hide the sidebar link via CSS instead of
53        // remove_submenu_page() (which broke the cap check in WP 7.0).
54        add_action('admin_head', [self::class, 'hideSubmenuFromSidebar']);
55        add_action('admin_post_' . self::NONCE_ACTION, [self::class, 'handleApprove']);
56        add_action('admin_post_' . self::NONCE_ACTION_BULK, [self::class, 'handleApproveBulk']);
57        add_action('admin_post_' . self::NONCE_ACTION_REJECT, [self::class, 'handleReject']);
58        add_action('admin_post_' . self::NONCE_ACTION_REJECT_BULK, [self::class, 'handleRejectBulk']);
59    }
60
61    /**
62     * Register as sub-menu of swapads-client.
63     *
64     * DEPRECATED — BacklinksHubPage owns the menu now.
65     */
66    public static function addMenu(): void
67    {
68        // F2XX (2026-08-01): re-register the legacy swapads-client-approval
69        // slug as a HIDDEN submenu. Renders the suggestions queue (see render)
70        // for the 'Review Suggestions' button + old bookmarks. Hub owns the
71        // visible 'Backlinks' submenu.
72        add_submenu_page(
73            OperatorDashboardHubPage::MENU_SLUG,
74            'Review Suggestions',
75            'Review Suggestions',
76            'manage_options',
77            self::MENU_SLUG,
78            [self::class, 'render']
79        );
80        // F2XX-fix 2026-08-01: do NOT call remove_submenu_page() — it breaks
81        // admin-post.php routing in WP 7.0. Sidebar link is hidden via
82        // admin_head CSS (see hideSubmenuFromSidebar).
83    }
84
85    /**
86     * Fetch suggestions from server.
87     *
88     * F202 (2026-07-30): accept optional offset for pagination. Operator
89     * clicks Next → URL gets ?offset=N → server returns the next page.
90     *
91     * @return array{count: int, items: array<int, array<string, mixed>>, error?: string, limit?: int, offset?: int}
92     */
93    public static function fetchSuggestions(int $limit = self::DEFAULT_LIMIT, int $offset = 0): array
94    {
95        // F198: RestClient::request() no longer throws for REST/transport
96        // failures; we read lastError() to surface inline.
97        $client = RestClient::fromOption();
98        $response = $client->get('/v1/backlinks/match', ['limit' => $limit, 'offset' => $offset]);
99        if (!is_array($response) || !isset($response['items']) || !is_array($response['items'])) {
100            if (($err = $client->lastError()) !== null) {
101                set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
102                return ['count' => 0, 'items' => [], 'error' => (string) ($err['error_code'] ?? 'UNKNOWN')];
103            }
104            return ['count' => 0, 'items' => []];
105        }
106        return [
107            'count'  => (int) ($response['count'] ?? count($response['items'])),
108            'items'  => $response['items'],
109            'limit'  => (int) ($response['limit'] ?? $limit),
110            'offset' => (int) ($response['offset'] ?? $offset),
111        ];
112    }
113
114    /**
115     * Render the full approval page (back-compat entry point).
116     *
117     * @deprecated F200: BacklinksHubPage owns the menu now. Kept for
118     *             callers that may still dispatch to MENU_SLUG directly.
119     */
120    public static function render(): void
121    {
122        if (!current_user_can('manage_options')) {
123            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
124        }
125
126        echo '<div class="wrap swapads-page">';
127        echo '<h1>' . esc_html__('Backlink Suggestions', 'swapads-client') . '</h1>';
128        echo '<p class="description">'
129            . esc_html__('Approve or reject backlink offers from partner operators.', 'swapads-client')
130            . '</p>';
131
132        if (!LicenseManager::isLicensed()) {
133            echo Renderer::card(
134                'License required',
135                '<p>Activate your license before viewing backlink suggestions.</p>',
136                'swapads-card-warning'
137            );
138            echo '</div>';
139            return;
140        }
141
142        self::renderSuggestionsCard();
143        echo '</div>';
144    }
145
146    /**
147     * Render the Place Backlinks tab body (suggestion queue).
148     *
149     * Called by BacklinksHubPage (F200 tabbed UI). Outputs:
150     * - success notice if ?approved=ID was passed
151     * - persisted error from previous failed load
152     * - suggestion stats bar + reciprocal-risk warning
153     * - the suggestion table (per-row approve forms) + bulk form
154     *
155     * @since 1.5.2
156     */
157    public static function renderSuggestionsCard(): void
158    {
159        if (isset($_GET['approved'])) {
160            $id = (int) ($_GET['id'] ?? 0);
161            echo Renderer::notice('Backlink #' . ($id > 0 ? $id : '') . ' approved.', 'success');
162        }
163        // F198: surface persisted error from previous submit/load attempt.
164        $persisted = get_transient(self::ERROR_CONTEXT);
165        if (is_array($persisted)) {
166            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'load backlink suggestions');
167            delete_transient(self::ERROR_CONTEXT);
168        }
169
170        // F202: read ?offset= from query for pagination
171        $offset = (int) ($_GET['offset'] ?? 0);
172        $offset = max(0, min($offset, 1000));
173        $data = self::fetchSuggestions(self::DEFAULT_LIMIT, $offset);
174        $limit = (int) ($data['limit'] ?? self::DEFAULT_LIMIT);
175        if (isset($data['error'])) {
176            echo Renderer::card(
177                'Server unreachable',
178                '<p>Could not fetch suggestions: <code>' . esc_html($data['error']) . '</code></p>',
179                'swapads-card-warning'
180            );
181            return;
182        }
183
184        $count  = $data['count'];
185        $items  = $data['items'];
186        $reciprocalCount = 0;
187        foreach ($items as $it) {
188            if ((float) ($it['reciprocal_risk'] ?? 0) >= self::RECIPROCAL_WARN_THRESHOLD) {
189                $reciprocalCount++;
190            }
191        }
192
193        // Top stats bar
194        $stats  = '<p>';
195        $stats .= '<span class="swapads-stat"><strong>' . (int) $count . '</strong> suggestions</span> &nbsp;|&nbsp; ';
196        $stats .= '<span class="swapads-stat"><strong>' . $reciprocalCount . '</strong> with reciprocal warning</span>';
197        $stats .= '</p>';
198
199        if ($reciprocalCount > 0) {
200            $stats .= Renderer::notice(
201                'Some suggestions create direct A↔B reciprocal links. Search engines may discount reciprocal link networks — review these carefully.',
202                'warning'
203            );
204        }
205        echo Renderer::card('Queue', $stats);
206
207        if ($count === 0) {
208            echo Renderer::card(
209                '',
210                '<p>No backlink suggestions right now. Check back later, or refine your audience profile to see more partners.</p>'
211            );
212            return;
213        }
214
215        // F202 (2026-07-30): the bulk form must WRAP the checkboxes, not
216        // sit outside the table. Old code had:
217        //   <table>...<input type="checkbox" name="backlink_ids[]">...</table>
218        //   <form>...</form>
219        // which meant no checkbox was ever inside the form (the per-row
220        // approve forms were nested inside <td>, also bad).
221        //
222        // New structure:
223        //   <form>
224        //     <table>
225        //       <input type="hidden" name="action" value="bulk_approve">
226        //       <input type="hidden" name="_wpnonce" value="...">
227        //       <thead>... checkbox (select-all) ...</thead>
228        //       <tbody>
229        //         <tr>
230        //           <td><input type="checkbox" name="backlink_ids[]"></td>
231        //           <td><form>...per-row approve...</form></td>
232        //         </tr>
233        //       </tbody>
234        //     </table>
235        //     <p>Bulk: <input name="custom_message"> [Approve selected]</p>
236        //   </form>
237        //
238        // Note: HTML forbids nested <form> tags, so we use the per-row
239        // approve buttons via an admin-post URL + GET param (not nested
240        // form). The bulk form covers the checkboxes.
241
242        // Open bulk form first, then table
243        $bulkOpen = '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" id="swapads-bulk-form">';
244        $bulkOpen .= wp_nonce_field('swapads_client_approve_bulk', '_wpnonce', true, false);
245        $bulkOpen .= '<input type="hidden" name="action" value="swapads_client_approve_bulk">';
246
247        // Build the table
248        $body  = $bulkOpen;
249        $body .= '<table class="widefat striped">';
250        $body .= '<thead><tr>';
251        $body .= '<th class="check-column"><input type="checkbox" id="swapads-select-all"></th>';
252        $body .= '<th>Source</th>';
253        $body .= '<th>Anchor</th>';
254        $body .= '<th>Match</th>';
255        $body .= '<th>Rel</th>';
256        $body .= '<th>Risk</th>';
257        $body .= '<th>Hint</th>';
258        $body .= '<th>Actions</th>';
259        $body .= '</tr></thead>';
260        $body .= '<tbody>';
261        foreach ($items as $item) {
262            $body .= self::renderRow($item);
263        }
264        $body .= '</tbody>';
265        $body .= '</table>';
266
267        // Bulk submit area
268        $body .= '<div class="swapads-bulk-actions" style="margin-top:12px; display:flex; gap:12px; align-items:center; flex-wrap:wrap;">';
269        $body .= '<button type="submit" class="button button-primary">Approve selected</button>';
270        // F223: bulk reject (separate button, separate nonce + action).
271        // We can't put two <submit> buttons in one form easily, so this
272        // is a separate form pointing at admin-post.php?action=reject-bulk.
273        $body .= '</div>';
274        $body .= '</form>';
275
276        // F223: separate bulk-reject form (different action, different nonce)
277        $rejectBulkOpen  = '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" id="swapads-reject-bulk-form">';
278        $rejectBulkOpen .= wp_nonce_field(self::NONCE_ACTION_REJECT_BULK, '_wpnonce', true, false);
279        $rejectBulkOpen .= '<input type="hidden" name="action" value="' . esc_attr(self::NONCE_ACTION_REJECT_BULK) . '">';
280        $body .= $rejectBulkOpen;
281        $body .= '<div class="swapads-bulk-reject" style="margin-top:8px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">';
282        $body .= '<label for="swapads-reject-reason" style="font-size:12px;">Rejection reason (optional):</label>';
283        $body .= '<input type="text" id="swapads-reject-reason" name="reason" maxlength="500" '
284              . 'placeholder="e.g. Wrong niche, anchor mismatch" '
285              . 'style="flex:1; min-width:200px;">';
286        $body .= '<button type="submit" class="button" '
287              . 'onclick="return confirm(\'Reject all selected backlinks? This will mark them as not-approvable-from-you.\');">'
288              . 'Reject selected</button>';
289        $body .= '</div>';
290        $body .= '</form>';
291
292        // Pagination (F202): Next/Prev via ?offset=N&limit=L query params
293        $body .= self::renderPagination($count, $limit, $offset);
294
295        echo Renderer::card('', $body);
296    }
297
298    /**
299     * Render Next/Prev pagination links for the suggestion queue.
300     *
301     * @since 1.5.2
302     */
303    private static function renderPagination(int $count, int $limit, int $offset): string
304    {
305        $hubUrl = admin_url('admin.php?page=swapads-client-backlinks-hub&tab=place');
306        $out  = '<div class="swapads-pagination" style="margin-top:12px; display:flex; gap:12px;">';
307        if ($offset > 0) {
308            $prevOffset = max(0, $offset - $limit);
309            $out .= '<a href="' . esc_url(add_query_arg(['offset' => $prevOffset], $hubUrl)) . '" class="button">&laquo; Previous</a>';
310        }
311        $out .= '<span class="description">Showing ' . ($offset + 1) . '&ndash;' . ($offset + $count) . '</span>';
312        if ($count >= $limit) {
313            $nextOffset = $offset + $limit;
314            $out .= '<a href="' . esc_url(add_query_arg(['offset' => $nextOffset], $hubUrl)) . '" class="button">Next &raquo;</a>';
315        }
316        $out .= '</div>';
317        return $out;
318    }
319
320    /**
321     * Render one suggestion row.
322     *
323     * @param array<string, mixed> $item
324     */
325    public static function renderRow(array $item): string
326    {
327        $id          = (int) ($item['backlink_id'] ?? 0);
328        $sourceUrl   = (string) ($item['source_url'] ?? '');
329        $anchor      = (string) ($item['anchor_text'] ?? '');
330        $rel         = (string) ($item['rel_attribute'] ?? 'dofollow');
331        $matchScore  = (float) ($item['score'] ?? 0);
332        $reciprocal  = (float) ($item['reciprocal_risk'] ?? 0);
333        $hint        = (string) ($item['hint'] ?? '');
334        // F119 (2026-07-30): per-row direct reciprocity detection.
335        // The server already returns reciprocal_factors.direct (0-1) per
336        // BACKLINK-MVP-ARCHITECTURE §"Reciprocal Warning (Soft)". When
337        // direct >= 0.6 (the weighted score threshold from F42), surface a
338        // specific warning: "Domain X already has your backlink. SEO value
339        // is higher if you select an alternative."
340        $factors        = (array) ($item['reciprocal_factors'] ?? []);
341        $directFactor   = (float) ($factors['direct'] ?? 0);
342        $isDirectReciprocal = $directFactor >= 0.6;
343        $riskWarn       = $reciprocal >= self::RECIPROCAL_WARN_THRESHOLD || $isDirectReciprocal;
344
345        $matchPct    = number_format($matchScore * 100, 0);
346        $riskPct     = number_format($reciprocal * 100, 0);
347        $relUpper    = strtoupper($rel);
348        $host        = wp_parse_url($sourceUrl, PHP_URL_HOST) ?: $sourceUrl;
349
350        $row  = '<tr' . ($riskWarn ? ' class="swapads-row-warning"' : '') . '>';
351        $row .= '<th class="check-column" scope="row"><input type="checkbox" name="backlink_ids[]" value="' . esc_attr((string) $id) . '"></th>';
352        $row .= '<td>';
353        $row .= '<strong>' . esc_html($host) . '</strong><br>';
354        $row .= '<code class="swapads-url">' . esc_html($sourceUrl) . '</code>';
355        // F119: per-row warning with the specific domain name
356        if ($isDirectReciprocal) {
357            $row .= '<div class="swapads-reciprocal-warning" '
358                . 'role="status" aria-live="polite" '
359                . 'style="margin-top:6px; padding:6px 10px; border-left:3px solid #d63638; '
360                . 'background:#fef7f7; color:#3a1a1a; font-size:12px; line-height:1.4;">';
361            $row .= '<strong>&#9888; Direct reciprocity detected:</strong> '
362                . '<em>' . esc_html($host) . '</em> already has your backlink. '
363                . 'Some search engines discount reciprocal link networks. '
364                . 'SEO value is higher if you select an alternative link.';
365            $row .= '</div>';
366        }
367        $row .= '</td>';
368        $row .= '<td>' . esc_html($anchor) . '</td>';
369        $row .= '<td><span class="swapads-score">' . esc_html($matchPct) . '%</span></td>';
370        $row .= '<td><code>' . esc_html($relUpper) . '</code></td>';
371        $row .= '<td>';
372        if ($riskWarn) {
373            $row .= '<span class="swapads-risk swapads-risk-warn">' . esc_html($riskPct) . '% reciprocal</span>';
374        } else {
375            $row .= '<span class="swapads-risk">' . esc_html($riskPct) . '%</span>';
376        }
377        $row .= '</td>';
378        $row .= '<td>' . ($hint !== '' ? esc_html($hint) : '<span class="swapads-muted">&mdash;</span>') . '</td>';
379        $row .= '<td>';
380        // F202 (2026-07-30): the bulk form wraps the table. We CAN'T use a
381        // nested <form> for per-row approve (HTML spec). Instead, emit a
382        // link to admin-post.php with the backlink_id as a query arg; the
383        // admin-post handler reads from $_REQUEST (GET or POST).
384        $approveUrl = add_query_arg(
385            [
386                'action'      => self::NONCE_ACTION,
387                'backlink_id' => $id,
388                '_wpnonce'    => wp_create_nonce(self::NONCE_ACTION),
389            ],
390            admin_url('admin-post.php')
391        );
392        $row .= '<a href="' . esc_url($approveUrl) . '" class="button button-primary button-small">Approve</a> ';
393        // F223: per-row Reject button (GET link, same pattern as approve).
394        $rejectUrl = add_query_arg(
395            [
396                'action'      => self::NONCE_ACTION_REJECT,
397                'backlink_id' => $id,
398                '_wpnonce'    => wp_create_nonce(self::NONCE_ACTION_REJECT),
399            ],
400            admin_url('admin-post.php')
401        );
402        $row .= '<a href="' . esc_url($rejectUrl) . '" class="button button-small" '
403              . 'onclick="return confirm(\'Reject backlink #' . $id . '? The offering operator will be notified that you don\'t want this one.\');">Reject</a>';
404        $row .= '</td>';
405        $row .= '</tr>';
406        return $row;
407    }
408
409    /**
410     * Handle bulk approve submission.
411     *
412     * F202 (2026-07-30): accepts a list of backlink_ids and posts them
413     * in a single round-trip to the server's bulk endpoint. Per-row
414     * failures are surfaced in the success/error notice via the redirect
415     * query arg `bulk_result`.
416     */
417    public static function handleApproveBulk(): void
418    {
419        if (!current_user_can('manage_options')) {
420            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
421        }
422        check_admin_referer(self::NONCE_ACTION_BULK);
423
424        $ids = $_POST['backlink_ids'] ?? null;
425        if (!is_array($ids) || empty($ids)) {
426            self::redirectError('No backlinks selected');
427            return;
428        }
429        // Normalize: ints, drop non-positive, dedup
430        $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $i): bool => $i > 0)));
431        if (count($ids) > self::BULK_MAX) {
432            self::redirectError('Bulk limit is ' . self::BULK_MAX);
433            return;
434        }
435        if (empty($ids)) {
436            self::redirectError('No valid backlink ids');
437            return;
438        }
439        $client = RestClient::fromOption();
440        $resp = $client->post('/v1/backlinks/approve-bulk', ['backlink_ids' => $ids]);
441        $err = $client->lastError();
442        if ($err !== null) {
443            $msg = (string) ($err['error_code'] ?? 'UNKNOWN');
444            self::redirectError($msg);
445            return;
446        }
447        $approvedCount = (int) ($resp['count'] ?? 0);
448        $failedCount   = (int) (isset($resp['failed']) && is_array($resp['failed']) ? count($resp['failed']) : 0);
449        wp_safe_redirect(add_query_arg(
450            [
451                'page'        => BacklinksHubPage::MENU_SLUG,
452                'tab'         => BacklinksHubPage::TAB_PLACE,
453                'bulk_ok'     => $approvedCount,
454                'bulk_failed' => $failedCount,
455            ],
456            admin_url('admin.php')
457        ));
458    }
459
460    /**
461     * Handle single reject submission.
462     *
463     * F223 (2026-07-30): GET link from the per-row Reject button
464     * (uses $_REQUEST, not $_POST, same as handleApprove).
465     *
466     * @since 1.5.2
467     */
468    public static function handleReject(): void
469    {
470        if (!current_user_can('manage_options')) {
471            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
472        }
473        check_admin_referer(self::NONCE_ACTION_REJECT);
474
475        $backlinkId = (int) ($_REQUEST['backlink_id'] ?? 0);
476        if ($backlinkId <= 0) {
477            self::redirectError('backlink_id is required');
478            return;
479        }
480        $reason = trim((string) ($_REQUEST['reason'] ?? ''));
481        $client = RestClient::fromOption();
482        $resp = $client->rejectBacklink($backlinkId, $reason);
483        if ($client->lastError() !== null) {
484            self::redirectError('reject_failed');
485            return;
486        }
487        self::redirectRejected($backlinkId);
488    }
489
490    /**
491     * Handle bulk reject submission.
492     *
493     * F223 (2026-07-30): same pattern as handleApproveBulk — single
494     * round-trip POST to /v1/backlinks/reject-bulk, then redirect
495     * back to Place tab with bulk_rejected=count query arg.
496     */
497    public static function handleRejectBulk(): void
498    {
499        if (!current_user_can('manage_options')) {
500            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
501        }
502        check_admin_referer(self::NONCE_ACTION_REJECT_BULK);
503
504        $ids = $_POST['backlink_ids'] ?? null;
505        if (!is_array($ids) || empty($ids)) {
506            self::redirectError('No backlinks selected');
507            return;
508        }
509        $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn (int $i): bool => $i > 0)));
510        if (empty($ids)) {
511            self::redirectError('No valid backlink ids');
512            return;
513        }
514        $reason = trim((string) ($_POST['reason'] ?? ''));
515        $client = RestClient::fromOption();
516        $resp = $client->rejectBacklinkBulk($ids, $reason);
517        if ($client->lastError() !== null) {
518            self::redirectError('reject_failed');
519            return;
520        }
521        $rejectedCount = (int) ($resp['count'] ?? 0);
522        $failedCount   = (int) (isset($resp['failed']) && is_array($resp['failed']) ? count($resp['failed']) : 0);
523        wp_safe_redirect(add_query_arg(
524            [
525                'page'             => BacklinksHubPage::MENU_SLUG,
526                'tab'              => BacklinksHubPage::TAB_PLACE,
527                'bulk_rejected'    => $rejectedCount,
528                'bulk_reject_fail' => $failedCount,
529            ],
530            admin_url('admin.php')
531        ));
532        // No exit; — see SettingsPage::render() for rationale.
533    }
534
535
536    /**
537     * Handle approve submission (single row or bulk).
538     */
539    public static function handleApprove(): void
540    {
541        if (!current_user_can('manage_options')) {
542            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
543        }
544        check_admin_referer(self::NONCE_ACTION);
545
546        // F202 (2026-07-30): bulk now has a dedicated handler
547        // (handleApproveBulk). handleApprove is single-row only.
548        // F202 also changed the per-row link to use GET (so we read from
549        // $_REQUEST — works for both GET and POST).
550        $backlinkId = (int) ($_REQUEST['backlink_id'] ?? 0);
551        if ($backlinkId <= 0) {
552            self::redirectError('backlink_id is required');
553            return;
554        }
555        self::approveOne($backlinkId);
556        self::redirectSuccess($backlinkId);
557    }
558
559    /**
560     * POST to server to approve a single backlink offer.
561     */
562    private static function approveOne(int $backlinkId): void
563    {
564        try {
565            RestClient::fromOption()->post('/v1/backlinks/approve', ['backlink_id' => $backlinkId]);
566        } catch (\Throwable $e) {
567            // Swallow — we'll redirect with the last error if any
568        }
569    }
570
571    private static function redirectSuccess(int $id): void
572    {
573        wp_safe_redirect(add_query_arg(
574            ['page' => self::MENU_SLUG, 'approved' => '1', 'id' => $id],
575            admin_url('admin.php')
576        ));
577        // No exit; — see SettingsPage::render() for rationale.
578    }
579
580    /**
581     * F223 (2026-07-30): redirect back to Place tab after a per-row reject.
582     *
583     * @since 1.5.2
584     */
585    private static function redirectRejected(int $id): void
586    {
587        wp_safe_redirect(add_query_arg(
588            [
589                'page'   => self::MENU_SLUG,
590                'tab'    => BacklinksHubPage::TAB_PLACE,
591                'rejected' => '1',
592                'id'     => $id,
593            ],
594            admin_url('admin.php')
595        ));
596        // No exit; — see SettingsPage::render() for rationale.
597    }
598
599    private static function redirectError(string $message): void
600    {
601        wp_safe_redirect(add_query_arg(
602            ['page' => self::MENU_SLUG, 'error' => rawurlencode($message)],
603            admin_url('admin.php')
604        ));
605        // No exit; — see SettingsPage::render() for rationale.
606    }
607
608
609    /**
610     * Hide the sidebar link via CSS instead of remove_submenu_page().
611     *
612     * F2XX-fix 2026-08-01: WP 7.0's user_can_access_admin_page() iterates
613     * $submenu[$parent] looking for the slug; if remove_submenu_page() removed
614     * the entry, the cap check returned false and the operator saw
615     * 'Sorry, you are not allowed to access this page' on form postbacks.
616     *
617     * Keeping the entry in $submenu (so the cap check passes) and hiding the
618     * visual link via CSS is the correct WP 7.0 pattern.
619     */
620    public static function hideSubmenuFromSidebar(): void
621    {
622        echo '<style>#adminmenu a[href*="page=swapads-client-approval"]{display:none!important}</style>';
623    }
624}