| 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 |
22 | declare(strict_types=1);
23 |
24 | namespace SwapAds\Client\Admin;
25 |
26 | use SwapAds\Client\Api\RestClient;
27 | use SwapAds\Client\License\LicenseManager;
28 |
29 | /**
30 | * Class BacklinkApprovalPage.
31 | *
32 | * @since 1.0.0
33 | */
34 | final 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 | }