| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 82.19% covered (warning) | 82.19% | 120 / 146 | 28.57% covered (danger) | 28.57% | 2 / 7 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
ApprovalsHubPage | 82.19% covered (warning) | 82.19% | 120 / 146 | 28.57% covered (danger) | 28.57% | 2 / 7 | 32.43 | 0.00% covered (danger) | 0.00% | 0 / 1
register | 100.00% covered (success) | 100.00% | 2 / 2 | 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 |
render | 0.00% covered (danger) | 0.00% | 0 / 9 | 0.00% covered (danger) | 0.00% | 0 / 1 | 6 |
renderSection | 88.00% covered (warning) | 88.00% | 66 / 75 | 0.00% covered (danger) | 0.00% | 0 / 1 | 10.17 |
renderRow | 86.11% covered (warning) | 86.11% | 31 / 36 | 0.00% covered (danger) | 0.00% | 0 / 1 | 8.17 |
fetchApprovals | 86.67% covered (warning) | 86.67% | 13 / 15 | 0.00% covered (danger) | 0.00% | 0 / 1 | 5.06 |
hideSubmenuFromSidebar | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |

1 | <?php
2 | /**
3 | * SwapAds Client — Approvals Hub Page (F120, 2026-08-01).
4 | *
5 | * Two-way transparency view: shows the operator (A) which OTHER
6 | * operators (B) approved which of A's backlink offers.
7 | *
8 | * Server-side GET /swapads-server/v1/backlinks/approvals returns:
9 | * {
10 | * success: true,
11 | * items: [
12 | * { approval_id, backlink_id, status, approved_at, rejected_at,
13 | * rejection_reason, approver_site_id, approver_site_url,
14 | * approver_site_domain, source_url, anchor_text,
15 | * target_audience, created_at },
16 | * ...
17 | * ],
18 | * count: N,
19 | * filter: { backlink_id, status }
20 | * }
21 | *
22 | * Visible in the Backlinks Hub as the 3rd tab "Approvals" (after
23 | * "Your Backlinks" + "Place Backlinks").
24 | *
25 | * Per "build-for-change" rule (locked 2026-07-28): every render path
26 | * goes through SwapAds\Client\Admin\Renderer static helpers.
27 | *
28 | * Permission model: requires `manage_options` (standard operator-only
29 | * capability; matches the rest of the Backlinks hub).
30 | *
31 | * @since 1.5.4
32 | */
33 |
34 | declare(strict_types=1);
35 |
36 | namespace SwapAds\Client\Admin;
37 |
38 | use SwapAds\Client\Api\RestClient;
39 | use SwapAds\Client\License\LicenseManager;
40 |
41 | /**
42 | * Class ApprovalsHubPage.
43 | *
44 | * @since 1.5.4
45 | */
46 | final class ApprovalsHubPage
47 | {
48 | /** Menu slug for the legacy/routeable placeholder. */
49 | public const MENU_SLUG = 'swapads-client-approvals';
50 |
51 | /** Transient key for surfacing server errors to operators. */
52 | public const ERROR_CONTEXT = 'swapads_client_approvals';
53 |
54 | /** Default rows per page (matches BacklinkApprovalPage::DEFAULT_LIMIT). */
55 | public const DEFAULT_LIMIT = 25;
56 |
57 | /** Max rows server allows in a single page (matches RestClient guard). */
58 | public const MAX_LIMIT = 500;
59 |
60 | /** Allowed status filter values — mirrors BacklinkRepository. */
61 | public const ALLOWED_STATUSES = ['pending', 'approved', 'placed', 'rejected'];
62 |
63 | /**
64 | * Register WP hooks.
65 | *
66 | * F2XX (2026-08-01): register() MUST wire `admin_menu` action. Missing
67 | * it (the regression) left addMenu() never invoked.
68 | *
69 | * @since 1.5.4
70 | */
71 | public static function register(): void
72 | {
73 | add_action('admin_menu', [self::class, 'addMenu']);
74 | // F2XX-fix 2026-08-01: hide the sidebar link via CSS instead of
75 | // remove_submenu_page() (which broke the cap check in WP 7.0).
76 | add_action('admin_head', [self::class, 'hideSubmenuFromSidebar']);
77 | }
78 |
79 | /**
80 | * Register as a HIDDEN submenu of the operator dashboard hub.
81 | *
82 | * Hidden (visible-through-direct-URL-only) because the operator-facing
83 | * entry point is the 3rd tab on the Backlinks Hub
84 | * (swapads-client-backlinks-hub?tab=approvals). The legacy slug is
85 | * preserved so existing bookmarks keep working.
86 | *
87 | * @since 1.5.4
88 | */
89 | public static function addMenu(): void
90 | {
91 | add_submenu_page(
92 | OperatorDashboardHubPage::MENU_SLUG,
93 | 'Backlink Approvals',
94 | 'Backlink Approvals',
95 | 'manage_options',
96 | self::MENU_SLUG,
97 | [self::class, 'render']
98 | );
99 | // F2XX (2026-08-01): keep the sidebar count locked to 4 visible
100 | // submenus. This page is reachable via direct URL + the Backlinks
101 | // Hub's "Approvals" tab.
102 | // F2XX-fix 2026-08-01: do NOT call remove_submenu_page() — it breaks
103 | // admin-post.php routing in WP 7.0. Sidebar link is hidden via
104 | // admin_head CSS (see hideSubmenuFromSidebar).
105 | }
106 |
107 | /**
108 | * Render the full page (used when accessed via the legacy URL).
109 | *
110 | * For normal navigation, BacklinksHubPage delegates the actual
111 | * rendering here via renderSection().
112 | *
113 | * @since 1.5.4
114 | */
115 | public static function render(): void
116 | {
117 | if (!current_user_can('manage_options')) {
118 | wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
119 | }
120 |
121 | echo '<div class="wrap swapads-page">';
122 | echo '<h1>' . esc_html__('Backlink Approvals', 'swapads-client') . '</h1>';
123 | echo '<p class="description">'
124 | . esc_html__('Two-way transparency: see which other operators approved your backlink offers.', 'swapads-client')
125 | . '</p>';
126 |
127 | self::renderSection();
128 |
129 | echo '</div>'; // .wrap
130 | }
131 |
132 | /**
133 | * Render just the approvals card + table, suitable for embedding
134 | * inside the BacklinksHubPage's "Approvals" tab.
135 | *
136 | * @since 1.5.4
137 | */
138 | public static function renderSection(): void
139 | {
140 | if (!LicenseManager::isLicensed()) {
141 | echo Renderer::card(
142 | 'License required',
143 | '<p>You need an active license before viewing backlink approvals.</p>'
144 | . '<p><a href="' . esc_url(admin_url('admin.php?page=' . SettingsPage::MENU_SLUG)) . '" class="button">Activate license</a></p>',
145 | 'swapads-card-warning'
146 | );
147 | return;
148 | }
149 |
150 | // F2XX (2026-08-01): surface persisted error envelopes + clear them.
151 | $persisted = get_transient(self::ERROR_CONTEXT);
152 | if (is_array($persisted)) {
153 | // Don't render as a red error if this transient carries a
154 | // success summary — keep the success/error separation invariant.
155 | if (($persisted['error_code'] ?? '') !== 'APPROVALS_SUMMARY') {
156 | echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'load backlink approvals');
157 | }
158 | delete_transient(self::ERROR_CONTEXT);
159 | }
160 |
161 | // Read filters from query string (with sanitization).
162 | $statusFilter = (string) ($_GET['status'] ?? '');
163 | if (!in_array($statusFilter, self::ALLOWED_STATUSES, true)) {
164 | $statusFilter = '';
165 | }
166 | $limit = (int) ($_GET['limit'] ?? self::DEFAULT_LIMIT);
167 | $limit = max(1, min(self::MAX_LIMIT, $limit));
168 |
169 | $data = self::fetchApprovals(0, $statusFilter, $limit);
170 |
171 | if (isset($data['error'])) {
172 | echo Renderer::card(
173 | 'Server unreachable',
174 | '<p>Could not fetch approvals: <code>' . esc_html((string) $data['error']) . '</code></p>',
175 | 'swapads-card-warning'
176 | );
177 | return;
178 | }
179 |
180 | $items = $data['items'];
181 | $count = (int) $data['count'];
182 |
183 | // Top stats bar + filter form
184 | $statsHtml = '<p>';
185 | $statsHtml .= '<span class="swapads-stat"><strong>' . (int) $count . '</strong> approvals</span>';
186 | $statsHtml .= '</p>';
187 |
188 | $filterUrl = add_query_arg([], remove_query_arg(['status', 'limit']));
189 | $filterHtml = '<form method="get" class="swapads-inline-form">';
190 | $filterHtml .= '<input type="hidden" name="page" value="' . esc_attr(BacklinksHubPage::MENU_SLUG) . '">';
191 | $filterHtml .= '<input type="hidden" name="tab" value="approvals">';
192 | $filterHtml .= '<label>Status:&nbsp;<select name="status">';
193 | $filterHtml .= '<option value="">All statuses</option>';
194 | foreach (self::ALLOWED_STATUSES as $st) {
195 | $sel = $statusFilter === $st ? ' selected' : '';
196 | $filterHtml .= '<option value="' . esc_attr($st) . '"' . $sel . '>' . esc_html(ucfirst($st)) . '</option>';
197 | }
198 | $filterHtml .= '</select></label> &nbsp;';
199 | $filterHtml .= '<label>Limit:&nbsp;<input type="number" name="limit" value="' . esc_attr((string) $limit) . '" min="1" max="' . esc_attr((string) self::MAX_LIMIT) . '" style="width:80px"></label>';
200 | $filterHtml .= '&nbsp;<button type="submit" class="button">Apply</button>';
201 | $filterHtml .= '</form>';
202 |
203 | echo Renderer::card('Approvals', $statsHtml . $filterHtml);
204 |
205 | if ($count === 0) {
206 | echo Renderer::card(
207 | '',
208 | '<p>No approvals yet. When other operators approve your backlink offers, they will appear here.</p>'
209 | );
210 | return;
211 | }
212 |
213 | // Table of approvals
214 | $table = '<table class="widefat swapads-table">';
215 | $table .= '<thead>';
216 | $table .= '<tr>';
217 | $table .= '<th>Partner</th>';
218 | $table .= '<th>Your backlink</th>';
219 | $table .= '<th>Status</th>';
220 | $table .= '<th>When</th>';
221 | $table .= '<th>Note</th>';
222 | $table .= '</tr>';
223 | $table .= '</thead>';
224 | $table .= '<tbody>';
225 | foreach ($items as $it) {
226 | $table .= self::renderRow($it);
227 | }
228 | $table .= '</tbody>';
229 | $table .= '</table>';
230 |
231 | echo $table;
232 |
233 | // Legend / help text below the table
234 | $legend = '<p class="description">';
235 | $legend .= '<strong>Status meanings:</strong> '
236 | . '<code>pending</code> = partner has not yet decided; '
237 | . '<code>approved</code> = partner said yes (your backlink should appear on their page); '
238 | . '<code>placed</code> = partner confirmed the link is now live on their site (source-check verified); '
239 | . '<code>rejected</code> = partner declined (with optional reason).';
240 | $legend .= '</p>';
241 | echo Renderer::card('', $legend);
242 | }
243 |
244 | /**
245 | * Render a single row of the approvals table.
246 | *
247 | * @param array<string, mixed> $it
248 | *
249 | * @since 1.5.4
250 | */
251 | private static function renderRow(array $it): string
252 | {
253 | $partner = (string) ($it['approver_site_domain'] ?? '');
254 | if ($partner === '') {
255 | $partner = (string) ($it['approver_site_url'] ?? '');
256 | }
257 | $anchor = (string) ($it['anchor_text'] ?? '');
258 | $source = (string) ($it['source_url'] ?? '');
259 | $status = (string) ($it['status'] ?? 'pending');
260 | $when = (string) ($it['approved_at'] ?? '');
261 | if ($when === '' || $when === '0000-00-00 00:00:00') {
262 | $when = (string) ($it['created_at'] ?? '');
263 | }
264 | $reason = (string) ($it['rejection_reason'] ?? '');
265 | $audience = (string) ($it['target_audience'] ?? '');
266 | $bid = (int) ($it['backlink_id'] ?? 0);
267 | $approvalId = (int) ($it['approval_id'] ?? 0);
268 |
269 | // Status pill colour
270 | $pillClass = match ($status) {
271 | 'approved', 'placed' => 'swapads-pill swapads-pill-success',
272 | 'rejected' => 'swapads-pill swapads-pill-error',
273 | default => 'swapads-pill swapads-pill-neutral',
274 | };
275 |
276 | $row = '<tr>';
277 | $row .= '<td>';
278 | if ($partner !== '') {
279 | $row .= '<strong>' . esc_html($partner) . '</strong>';
280 | } else {
281 | $row .= '<em>unknown partner</em>';
282 | }
283 | if ($audience !== '') {
284 | $row .= '<br><small class="description">audience: ' . esc_html($audience) . '</small>';
285 | }
286 | $row .= '</td>';
287 | $row .= '<td>';
288 | $row .= '<div>' . esc_html($anchor) . '</div>';
289 | if ($source !== '') {
290 | $row .= '<small class="description">' . esc_html($source) . '</small>';
291 | }
292 | $row .= '</td>';
293 | $row .= '<td><span class="' . esc_attr($pillClass) . '">' . esc_html(ucfirst($status)) . '</span></td>';
294 | $row .= '<td><small>' . esc_html($when) . '</small></td>';
295 | $row .= '<td><small>' . ($reason !== '' ? esc_html($reason) : '—') . '</small></td>';
296 | $row .= '</tr>';
297 |
298 | return $row;
299 | }
300 |
301 | /**
302 | * Fetch approvals from the server.
303 | *
304 | * Returns a normalized array even on transport / auth failure so
305 | * the caller can always read `items` / `count` without null checks.
306 | * When the server is unreachable, `error` carries the message.
307 | *
308 | * @return array{items: array<int, array<string, mixed>>, count: int, limit: int, error?: string}
309 | *
310 | * @since 1.5.4
311 | */
312 | public static function fetchApprovals(int $backlinkId = 0, string $status = '', int $limit = self::DEFAULT_LIMIT): array
313 | {
314 | $rest = RestClient::fromOption();
315 | $resp = $rest->listBacklinkApprovals($backlinkId, $status, $limit);
316 |
317 | if (!is_array($resp)) {
318 | return ['items' => [], 'count' => 0, 'limit' => $limit, 'error' => 'no response from server'];
319 | }
320 |
321 | // Surface server-side error envelopes (e.g. LICENSE_NOT_FOUND).
322 | if (isset($resp['success']) && $resp['success'] === false) {
323 | $errMsg = (string) ($resp['message'] ?? 'server returned an error envelope');
324 | return ['items' => [], 'count' => 0, 'limit' => $limit, 'error' => $errMsg];
325 | }
326 |
327 | $items = $resp['items'] ?? [];
328 | if (!is_array($items)) {
329 | $items = [];
330 | }
331 |
332 | return [
333 | 'items' => $items,
334 | 'count' => count($items),
335 | 'limit' => $limit,
336 | ];
337 | }
338 |
339 |
340 | /**
341 | * Hide the sidebar link via CSS instead of remove_submenu_page().
342 | *
343 | * F2XX-fix 2026-08-01: WP 7.0's user_can_access_admin_page() iterates
344 | * $submenu[$parent] looking for the slug; if remove_submenu_page() removed
345 | * the entry, the cap check returned false and the operator saw
346 | * 'Sorry, you are not allowed to access this page' on form postbacks.
347 | *
348 | * Keeping the entry in $submenu (so the cap check passes) and hiding the
349 | * visual link via CSS is the correct WP 7.0 pattern.
350 | */
351 | public static function hideSubmenuFromSidebar(): void
352 | {
353 | echo '<style>#adminmenu a[href*="page=swapads-client-approvals-hub"]{display:none!important}</style>';
354 | }
355 | }