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
34declare(strict_types=1);
35
36namespace SwapAds\Client\Admin;
37
38use SwapAds\Client\Api\RestClient;
39use SwapAds\Client\License\LicenseManager;
40
41/**
42 * Class ApprovalsHubPage.
43 *
44 * @since 1.5.4
45 */
46final 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}