Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.36% covered (danger)
37.36%
167 / 447
30.77% covered (danger)
30.77%
4 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
BacklinkCreatePage
37.36% covered (danger)
37.36%
167 / 447
30.77% covered (danger)
30.77%
4 / 13
2218.78
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
 hideSubmenuFromSidebar
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addMenu
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 render
98.86% covered (success)
98.86%
87 / 88
0.00% covered (danger)
0.00%
0 / 1
9
 defaultValues
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 handleSubmit
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
420
 redirectWithError
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 renderYourBacklinksSection
0.00% covered (danger)
0.00%
0 / 141
0.00% covered (danger)
0.00%
0 / 1
812
 renderBacklinksTable
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
1 / 1
8
 handleDelete
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 handleSourceCheckNow
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 handleSourceCheckBulk
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 redirectWithMsg
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Backlink Create Admin Page (CF14).
4 *
5 * Operator-facing form for creating a new backlink offer via the
6 * server REST API (POST /swapads-server/v1/backlinks/offer).
7 *
8 * Per _UX-SPEC.md §2.4:
9 *   - Source URL (must be on operator's own site)
10 *   - Hint to partners (single line, max 250 chars)
11 *   - Anchor text (default: site name)
12 *   - Rel attribute (dofollow / nofollow / sponsored)
13 *   - Max external links on source page
14 *   - Target audience override (advanced, defaults to site audience)
15 *
16 * All render output goes through Admin\Renderer so the UI seam
17 * stays in one place — see Renderer.php.
18 *
19 * @package SwapAds\Client\Admin
20 * @since   1.0.0
21 */
22
23declare(strict_types=1);
24
25namespace SwapAds\Client\Admin;
26
27use SwapAds\Client\Admin\ClientErrorRenderer;
28use SwapAds\Client\Api\RestClient;
29use SwapAds\Client\Audience\SiteAudience;
30use SwapAds\Client\License\LicenseManager;
31
32/**
33 * Class BacklinkCreatePage.
34 *
35 * @since 1.0.0
36 */
37final class BacklinkCreatePage
38{
39    public const MENU_SLUG     = 'swapads-client-backlinks';
40    public const NONCE_ACTION  = 'swapads_client_create_backlink';
41    public const FORM_ACTION   = 'swapads_client_create_backlink';
42    public const ERROR_CONTEXT  = 'swapads_client_backlink_create';
43    public const MAX_HINT_LEN  = 250;
44    public const REL_OPTIONS   = ['dofollow', 'nofollow', 'sponsored'];
45
46    /**
47     * Register WP hooks.
48     *
49     * Menu registration moved to BacklinksHubPage (F200 tabbed UI).
50     * `add_submenu_page` here is a no-op kept for back-compat.
51     */
52    public static function register(): void
53    {
54        add_action('admin_menu', [self::class, 'addMenu']);
55        // F2XX-fix 2026-08-01: hide the sidebar link via CSS instead of
56        // remove_submenu_page() (which broke the cap check in WP 7.0 — admin-post.php
57        // routing rejected 'manage_options' users with 'Sorry, you are not allowed
58        // to access this page' because user_can_access_admin_page() iterates
59        // $submenu[$parent] and the removed entry caused a mismatch).
60        add_action('admin_head', [self::class, 'hideSubmenuFromSidebar']);
61        add_action('admin_post_' . self::FORM_ACTION, [self::class, 'handleSubmit']);
62        add_action('admin_post_swapads_delete_backlink', [self::class, 'handleDelete']);
63        // F121 (2026-07-31): manual source-check trigger (per-row + bulk)
64        add_action('admin_post_swapads_source_check_now', [self::class, 'handleSourceCheckNow']);
65        add_action('admin_post_swapads_source_check_bulk', [self::class, 'handleSourceCheckBulk']);
66    }
67
68    /**
69     * Hide the 'Create Backlink' submenu entry from the sidebar via CSS.
70     * The page itself stays routable (Hub owns the visible menu link).
71     *
72     * F2XX-fix 2026-08-01: replaces remove_submenu_page() which broke
73     * admin-post.php routing in WP 7.0. See register() docblock.
74     */
75    public static function hideSubmenuFromSidebar(): void
76    {
77        echo '<style>#adminmenu a[href*="page=swapads-client-backlinks"]{display:none!important}</style>';
78    }
79
80    /**
81     * Register the admin page (sub-menu of swapads-client).
82     *
83     * DEPRECATED — BacklinksHubPage owns the menu now. Kept as no-op
84     * for callers that may still call this directly.
85     */
86    public static function addMenu(): void
87    {
88        // F2XX (2026-08-01): re-register the legacy swapads-client-backlinks
89        // slug as a HIDDEN submenu. The page itself still renders the full
90        // create-backlink form (for direct URL access from old bookmarks +
91        // the 'Create Backlink' button). Hub owns the visible 'Backlinks'
92        // submenu now; this stays routable but hidden.
93        add_submenu_page(
94            OperatorDashboardHubPage::MENU_SLUG,
95            'Create Backlink',
96            'Create Backlink',
97            'manage_options',
98            self::MENU_SLUG,
99            [self::class, 'render']
100        );
101        // F2XX-fix 2026-08-01: do NOT call remove_submenu_page() — it breaks
102        // admin-post.php routing in WP 7.0. The sidebar link is hidden via
103        // admin_head CSS (see hideSubmenuFromSidebar).
104    }
105
106    /**
107     * Render the create-backlink form.
108     */
109    public static function render(): void
110    {
111        if (!current_user_can('manage_options')) {
112            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
113        }
114
115        $licensed = LicenseManager::isLicensed();
116        $siteUrl  = home_url();
117        $audience = SiteAudience::get();
118
119        echo Renderer::pageHeader(
120            'Create Backlink Offer',
121            'Submit a backlink offer. Partner operators will see it in their suggestion queue.'
122        );
123
124        if (isset($_GET['created'])) {
125            $id = (int) ($_GET['id'] ?? 0);
126            echo Renderer::notice('Backlink offer created' . ($id > 0 ? ' #' . $id : '') . '.', 'success');
127        }
128        // F121 (2026-07-31): surface flash messages from handleSourceCheckNow /
129        // handleSourceCheckBulk (or future handlers that use redirectWithMsg).
130        $msgCtx = (string) ($_GET['msg_ctx'] ?? '');
131        $msgSt  = (string) ($_GET['msg_st'] ?? '');
132        $msg    = (string) ($_GET['msg'] ?? '');
133        if ($msgCtx === 'source-check' && $msg !== '') {
134            $noticeType = $msgSt === 'ok' ? 'success' : 'warning';
135            $decoded = rawurldecode($msg);
136            echo '<div class="notice notice-' . esc_attr($noticeType) . ' is-dismissible" style="margin-top:12px;">'
137                . '<p><strong>Source check:</strong> ' . esc_html($decoded) . '</p>'
138                . '</div>';
139        }
140        // F198: surface any persisted error from the previous submit
141        // (POST flow → redirect → re-render with GET). Stored in transient
142        // by handleSubmit() when RestClient::request() returns a failure.
143        $persisted = get_transient(self::ERROR_CONTEXT);
144        if (is_array($persisted)) {
145            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'create backlink offer');
146            delete_transient(self::ERROR_CONTEXT);
147        }
148        if (!$licensed) {
149            echo Renderer::card(
150                'License required',
151                '<p>You need an active license before creating backlink offers.</p>'
152                    . '<p><a href="' . esc_url(admin_url('admin.php?page=' . SettingsPage::MENU_SLUG)) . '" class="button">Activate license</a></p>',
153                'swapads-card-warning'
154            );
155            echo Renderer::pageFooter();
156            return;
157        }
158
159        $defaults = self::defaultValues();
160        $body  = Renderer::formOpen(self::FORM_ACTION, self::NONCE_ACTION, 'Create Offer');
161        $body .= Renderer::formTableOpen();
162        $body .= Renderer::textField(
163            'source_url',
164            'Source URL *',
165            $defaults['source_url'],
166            'https://' . wp_parse_url($siteUrl, PHP_URL_HOST) . '/partners',
167            'Must be on your own site. Legal pages (Privacy, ToS) are excluded.'
168        );
169        $body .= Renderer::textField(
170            'hint',
171            'Hint to partners',
172            $defaults['hint'],
173            'e.g. Adult cam review site, mostly EU traffic',
174            'Single line, max ' . self::MAX_HINT_LEN . ' characters.'
175        );
176        $body .= Renderer::textField(
177            'anchor_text',
178            'Anchor text *',
179            $defaults['anchor_text'],
180            get_bloginfo('name'),
181            'Default: site name.'
182        );
183        $body .= Renderer::radioField(
184            'rel_attribute',
185            'Link type *',
186            array_map(static fn (string $v) => ['value' => $v, 'label' => ucfirst($v)], self::REL_OPTIONS),
187            $defaults['rel_attribute'],
188            ''
189        );
190        $body .= Renderer::textField(
191            'max_external_on_source',
192            'Max external links on source page',
193            (string) $defaults['max_external_on_source'],
194            '5',
195            'Other operators will see this number.'
196        );
197        // Target audience override — default to site audience
198        $body .= Renderer::textareaField(
199            'target_audience_override',
200            'Target audience (JSON, optional)',
201            '',
202            3,
203            'Override the site audience for this offer. Leave blank to use your site\'s audience: '
204                . esc_html(json_encode($audience))
205        );
206        $body .= Renderer::formTableClose();
207        $body .= '<p class="submit">';
208        $body .= '<button type="submit" class="button button-primary">Create Offer</button> ';
209        $body .= '<a href="' . esc_url(admin_url('admin.php?page=swapads-client')) . '" class="button">Cancel</a>';
210        $body .= '</p>';
211        $body .= '</form>';
212        echo Renderer::card('', $body);
213        echo Renderer::pageFooter();
214    }
215
216    /**
217     * Compute default form values from license/site state.
218     *
219     * @return array<string, mixed>
220     */
221    public static function defaultValues(): array
222    {
223        $host = wp_parse_url(home_url(), PHP_URL_HOST) ?: '';
224        return [
225            'source_url'           => '',
226            'hint'                 => '',
227            'anchor_text'          => get_bloginfo('name') ?: '',
228            'rel_attribute'        => 'dofollow',
229            'max_external_on_source' => 5,
230        ];
231    }
232
233    /**
234     * Validate + persist the form.
235     */
236    public static function handleSubmit(): void
237    {
238        if (!current_user_can('manage_options')) {
239            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
240        }
241        check_admin_referer(self::NONCE_ACTION);
242
243        $sourceUrl   = trim((string) ($_POST['source_url'] ?? ''));
244        $hint        = trim((string) ($_POST['hint'] ?? ''));
245        $anchorText  = trim((string) ($_POST['anchor_text'] ?? ''));
246        $rel         = (string) ($_POST['rel_attribute'] ?? 'dofollow');
247        $maxExternal = (int) ($_POST['max_external_on_source'] ?? 5);
248        $overrideRaw = trim((string) ($_POST['target_audience_override'] ?? ''));
249
250        // Validate
251        if ($sourceUrl === '') {
252            self::redirectWithError('source_url is required');
253            return;
254        }
255        if (!wp_http_validate_url($sourceUrl)) {
256            self::redirectWithError('source_url is not a valid URL');
257            return;
258        }
259        // Source URL must be on operator's own domain
260        $siteHost = wp_parse_url(home_url(), PHP_URL_HOST);
261        $srcHost  = wp_parse_url($sourceUrl, PHP_URL_HOST);
262        if (!$siteHost || !$srcHost || strcasecmp($siteHost, $srcHost) !== 0) {
263            self::redirectWithError('source_url must be on your own site (' . $siteHost . ')');
264            return;
265        }
266        if ($anchorText === '') {
267            self::redirectWithError('anchor_text is required');
268            return;
269        }
270        if (!in_array($rel, self::REL_OPTIONS, true)) {
271            self::redirectWithError('invalid rel_attribute');
272            return;
273        }
274        if (strlen($hint) > self::MAX_HINT_LEN) {
275            self::redirectWithError('hint exceeds ' . self::MAX_HINT_LEN . ' chars');
276            return;
277        }
278        if ($maxExternal < 1 || $maxExternal > 50) {
279            self::redirectWithError('max_external_on_source must be 1-50');
280            return;
281        }
282
283        // Build payload
284        $payload = [
285            'source_url'            => esc_url_raw($sourceUrl),
286            'anchor_text'           => $anchorText,
287            'rel_attribute'         => $rel,
288            'max_external_on_source' => $maxExternal,
289        ];
290        if ($hint !== '') {
291            $payload['hint'] = $hint;
292        }
293        if ($overrideRaw !== '') {
294            $decoded = json_decode($overrideRaw, true);
295            if (!is_array($decoded)) {
296                self::redirectWithError('target_audience_override is not valid JSON');
297                return;
298            }
299            $payload['target_audience_override'] = $decoded;
300        }
301
302        // POST to server
303        $response = RestClient::fromOption()->post('/v1/backlinks/offer', $payload);
304        if (!is_array($response)) {
305            self::redirectWithError('server returned no response');
306            return;
307        }
308        if (isset($response['error_code']) || isset($response['code'])) {
309            $msg = (string) ($response['error_code'] ?? $response['code'] ?? 'unknown');
310            $detail = (string) ($response['message'] ?? '');
311            self::redirectWithError($msg . ($detail !== '' ? ' — ' . $detail : ''));
312            return;
313        }
314        $id = (int) ($response['id'] ?? 0);
315        $redirect = add_query_arg(
316            ['page' => self::MENU_SLUG, 'created' => '1', 'id' => $id],
317            admin_url('admin.php')
318        );
319        wp_safe_redirect($redirect);
320        if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
321    }
322
323    private static function redirectWithError(string $message): void
324    {
325        $redirect = add_query_arg(
326            ['page' => self::MENU_SLUG, 'error' => rawurlencode($message)],
327            admin_url('admin.php')
328        );
329        wp_safe_redirect($redirect);
330        if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
331    }
332
333            
334    /**
335     * Render the "Your Backlinks" section (form + existing list).
336     *
337     * Called by BacklinksHubPage (F200 tabbed UI). Displays the create
338     * form at the top + a table of operator's existing backlinks below,
339     * each with edit/remove buttons.
340     *
341     * @since 1.5.2
342     */
343    public static function renderYourBacklinksSection(): void
344    {
345        $rest = RestClient::fromOption();
346        $listResp = $rest->listBacklinks();
347        /** @var array<int, array<string, mixed>> $backlinks */
348        $backlinks = [];
349        if (isset($listResp['backlinks']) && is_array($listResp['backlinks'])) {
350            $backlinks = $listResp['backlinks'];
351        }
352        $error = $rest->lastError();
353
354        // Surface any inline error from the most recent attempt
355        $persisted = get_transient(self::ERROR_CONTEXT);
356        if (is_array($persisted)) {
357            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'manage backlinks');
358            delete_transient(self::ERROR_CONTEXT);
359        }
360        if ($error !== null && !is_array($persisted)) {
361            $errStr = (string) wp_json_encode($error);
362            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, [
363                'code'    => 'BACKEND_UNREACHABLE',
364                'message' => $errStr,
365            ], 'load backlinks');
366        }
367
368        // ===== Form card (create + edit) =====
369        $editId = (int) ($_GET['edit'] ?? 0);
370        $editing = null;
371        if ($editId > 0) {
372            foreach ($backlinks as $b) {
373                if ((int) ($b['id'] ?? 0) === $editId) {
374                    $editing = $b;
375                    break;
376                }
377            }
378        }
379        $defaults = $editing
380            ? [
381                'source_url'             => (string) ($editing['source_url'] ?? ''),
382                'hint'                   => (string) ($editing['hint'] ?? ''),
383                'anchor_text'            => (string) ($editing['anchor_text'] ?? ''),
384                'rel_attribute'          => (string) ($editing['rel_attribute'] ?? 'dofollow'),
385                'max_external_on_source' => (int) ($editing['max_external_on_source'] ?? 5),
386            ]
387            : self::defaultValues();
388
389        echo '<div class="swapads-card" style="margin-top:16px">';
390        echo '<h2>' . esc_html($editId > 0 ? 'Edit backlink #' . $editId : 'Create new backlink offer') . '</h2>';
391        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
392        echo '<input type="hidden" name="action" value="' . esc_attr(self::FORM_ACTION) . '">';
393        if ($editId > 0) {
394            echo '<input type="hidden" name="edit_id" value="' . esc_attr((string) $editId) . '">';
395            echo '<input type="hidden" name="original_source_url" value="' . esc_attr((string) ($editing['source_url'] ?? '')) . '">';
396        }
397        wp_nonce_field(self::NONCE_ACTION);
398        $siteUrl = home_url();
399        echo '<table class="form-table" role="presentation"><tbody>';
400        $disabledHelp = $editId > 0 ? 'Source URL cannot be changed. Remove + recreate if needed.' : '';
401        $srcUrlValue  = $defaults['source_url'];
402        echo '<tr><th scope="row"><label for="source_url">Source URL *</label></th><td>';
403        if ($editId > 0) {
404            echo '<input type="url" id="source_url" name="source_url" value="' . esc_attr($srcUrlValue) . '" '
405                . 'class="regular-text" readonly aria-readonly="true" />';
406            if ($disabledHelp !== '') {
407                echo '<p class="description">' . esc_html($disabledHelp) . '</p>';
408            }
409        } else {
410            echo '<input type="url" id="source_url" name="source_url" value="' . esc_attr($srcUrlValue) . '" '
411                . 'class="regular-text" placeholder="https://' . esc_attr(wp_parse_url($siteUrl, PHP_URL_HOST) ?: 'example.com') . '/your-article" />';
412        }
413        echo '</td></tr>';
414        echo Renderer::textField(
415            'anchor_text',
416            'Anchor text *',
417            $defaults['anchor_text'],
418            'Your brand name'
419        );
420        echo Renderer::textareaField(
421            'hint',
422            'Hint for partner operators',
423            $defaults['hint'],
424            4,
425            'e.g. relevant to your niche, accepts guest posts, has comments open'
426        );
427        echo Renderer::selectField(
428            'rel_attribute',
429            'Rel attribute',
430            [
431                ['value' => 'dofollow',  'label' => 'dofollow'],
432                ['value' => 'nofollow',  'label' => 'nofollow'],
433                ['value' => 'sponsored', 'label' => 'sponsored'],
434            ],
435            $defaults['rel_attribute'],
436            ''
437        );
438        echo Renderer::textField(
439            'max_external_on_source',
440            'Max external links on source',
441            (string) $defaults['max_external_on_source'],
442            '5',
443            'Limits how many other backlink offers can be placed on this URL.'
444        );
445        echo '</tbody></table>';
446        echo '<p>';
447        if ($editId > 0) {
448            submit_button('Save changes', 'primary', 'submit', false);
449            echo ' <a href="' . esc_url(remove_query_arg('edit')) . '" class="button">Cancel</a>';
450        } else {
451            submit_button('Create backlink offer', 'primary', 'submit', false);
452        }
453        echo '</p>';
454        echo '</form>';
455        echo '</div>';
456
457        // ===== Existing list =====
458        echo '<h2 style="margin-top:24px">Your backlinks</h2>';
459        if (empty($backlinks)) {
460            // F220 (2026-07-30): Two-way transparency — show who approved MY backlinks.
461        // Operator A offers; Operator B approves. A wants to see who approved what.
462        $approvalsResp = $rest->listBacklinkApprovals(0, '', 25);
463        $approvals = (is_array($approvalsResp) && isset($approvalsResp['items']) && is_array($approvalsResp['items']))
464            ? $approvalsResp['items']
465            : [];
466        if (count($approvals) > 0) {
467            echo '<div class="swapads-card" style="margin-top:16px">';
468            echo '<h2>Recent approvals of your backlinks</h2>';
469            echo '<p class="description">These sites have approved your backlink offers. The backlink goes live as soon as one of them places it on a real page.</p>';
470            echo '<table class="widefat striped">';
471            echo '<thead><tr>';
472            echo '<th>Approver</th>';
473            echo '<th>Your backlink</th>';
474            echo '<th>Anchor</th>';
475            echo '<th>Status</th>';
476            echo '<th>When</th>';
477            echo '</tr></thead><tbody>';
478            foreach ($approvals as $ap) {
479                $approverDomain = (string) ($ap['approver_site_domain'] ?? '');
480                $approverUrl    = (string) ($ap['approver_site_url'] ?? '');
481                $sourceUrl      = (string) ($ap['source_url'] ?? '');
482                $anchorText     = (string) ($ap['anchor_text'] ?? '');
483                $apStatus       = (string) ($ap['status'] ?? 'pending');
484                $approvedAt     = (string) ($ap['approved_at'] ?? '');
485                $createdAt      = (string) ($ap['created_at'] ?? '');
486                $displayTime    = $approvedAt !== '' ? $approvedAt : $createdAt;
487
488                echo '<tr>';
489                echo '<td>';
490                if ($approverUrl !== '') {
491                    echo '<strong>' . esc_html($approverDomain !== '' ? $approverDomain : $approverUrl) . '</strong><br>';
492                    echo '<small><code>' . esc_html($approverUrl) . '</code></small>';
493                } else {
494                    echo '<span class="description">Site #' . (int) ($ap['approver_site_id'] ?? 0) . '</span>';
495                }
496                echo '</td>';
497                echo '<td><code>' . esc_html(wp_parse_url($sourceUrl, PHP_URL_HOST) ?: $sourceUrl) . '</code></td>';
498                echo '<td>' . esc_html($anchorText) . '</td>';
499                echo '<td><span class="swapads-status swapads-status--' . esc_attr($apStatus) . '">' . esc_html(ucfirst($apStatus)) . '</span></td>';
500                echo '<td>' . esc_html($displayTime !== '' ? mysql2date('Y-m-d H:i', $displayTime) : '—') . '</td>';
501                echo '</tr>';
502            }
503            echo '</tbody></table>';
504            echo '</div>';
505        }
506
507        echo Renderer::renderEmptyState(
508                'No backlinks yet',
509                'Create your first backlink offer above. Partner operators will see it in their suggestion queue.',
510                null,
511                null
512            );
513        } else {
514            echo self::renderBacklinksTable($backlinks);
515        }
516    }
517
518    /**
519     * Render the operator's backlink list as a WP admin table.
520     *
521     * @param array<int, array<string, mixed>> $backlinks Rows from BacklinkRepository::listWithStats.
522     * @return string HTML.
523     *
524     * @since 1.5.2
525     */
526    public static function renderBacklinksTable(array $backlinks): string
527    {
528        $h  = '<table class="widefat striped" role="table" aria-label="Your backlinks">';
529        $h .= '<thead><tr>';
530        $h .= '<th scope="col">ID</th>';
531        $h .= '<th scope="col">Source URL</th>';
532        $h .= '<th scope="col">Anchor</th>';
533        $h .= '<th scope="col">Status</th>';
534        $h .= '<th scope="col">Placements</th>';
535        $h .= '<th scope="col">Last viewed</th>';
536        // F121 (2026-07-31): new Health column + check-now button per row
537        $h .= '<th scope="col">Source health</th>';
538        $h .= '<th scope="col">Actions</th>';
539        $h .= '</tr></thead><tbody>';
540        foreach ($backlinks as $b) {
541            $id      = (int) ($b['id'] ?? 0);
542            $url     = (string) ($b['source_url'] ?? '');
543            $anchor  = (string) ($b['anchor_text'] ?? '');
544            $status  = (string) ($b['status'] ?? '');
545            $pcount  = (int) ($b['placement_count'] ?? 0);
546            $lastV   = $b['last_viewed_at'] ?? null;
547            // F121: source-check fields
548            $lastCheckAt    = (string) ($b['last_source_check_at'] ?? '');
549            $lastCheckSt    = strtolower(trim((string) ($b['last_source_check_status'] ?? '')));
550            $editUrl = esc_url(add_query_arg('edit', $id));
551            $delUrl  = esc_url(wp_nonce_url(
552                add_query_arg(['action' => 'swapads_delete_backlink', 'id' => $id]),
553                'swapads_delete_backlink_' . $id
554            ));
555            // F121: per-row "Check now" link (GET, same pattern as approve)
556            $checkUrl = esc_url(add_query_arg(
557                [
558                    'action'      => 'swapads_source_check_now',
559                    'backlink_id' => $id,
560                    '_wpnonce'    => wp_create_nonce('swapads_source_check_now'),
561                ],
562                admin_url('admin-post.php')
563            ));
564            $h .= '<tr>';
565            $h .= '<td>' . $id . '</td>';
566            $h .= '<td><code>' . esc_html(wp_parse_url($url, PHP_URL_HOST) ?: $url) . '</code><br><small>' . esc_html($url) . '</small></td>';
567            $h .= '<td>' . esc_html($anchor) . '</td>';
568            $h .= '<td><span class="swapads-status swapads-status--' . esc_attr($status) . '">' . esc_html(ucfirst($status)) . '</span></td>';
569            $h .= '<td>' . $pcount . '</td>';
570            $h .= '<td>' . ($lastV ? esc_html(mysql2date('Y-m-d H:i', (string) $lastV)) : '<span aria-hidden="true">—</span>') . '</td>';
571            // F121: Health cell — shows status badge + age + Check now link
572            $healthCell = '<span class="swapads-muted">Never checked</span>';
573            if ($lastCheckAt !== '' && $lastCheckAt !== '0000-00-00 00:00:00') {
574                $checkLabel = ucfirst($lastCheckSt !== '' ? $lastCheckSt : 'unknown');
575                $healthCell = '<span class="swapads-health swapads-health--' . esc_attr($lastCheckSt !== '' ? $lastCheckSt : 'unknown') . '">'
576                    . esc_html($checkLabel)
577                    . '</span><br><small>' . esc_html(mysql2date('Y-m-d H:i', $lastCheckAt)) . '</small>';
578            }
579            $healthCell .= '<br><a href="' . $checkUrl . '" class="button button-small" '
580                         . 'style="margin-top:4px;">Check now</a>';
581            $h .= '<td>' . $healthCell . '</td>';
582            $h .= '<td>';
583            $h .= '<a href="' . $editUrl . '" class="button button-small">Edit</a> ';
584            $h .= '<a href="' . $delUrl . '" class="button button-small button-link-delete"'
585                . ' onclick="return confirm(\'Remove backlink #' . $id . '? Partners who already placed it will keep the link, but no new placements will be made.\');">'
586                . 'Remove</a>';
587            $h .= '</td>';
588            $h .= '</tr>';
589        }
590        $h .= '</tbody></table>';
591        return $h;
592    }
593
594    /**
595     * Handle delete POST (admin-post.php?action=swapads_delete_backlink).
596     *
597     * Soft-delete via RestClient::deleteBacklink(). Redirects back to
598     * the hub page with status param.
599     *
600     * @since 1.5.2
601     */
602    public static function handleDelete(): void
603    {
604        if (!current_user_can('manage_options')) {
605            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
606        }
607        $id = (int) ($_REQUEST['id'] ?? 0);
608        if ($id <= 0) {
609            self::redirectWithError('invalid backlink id');
610            return;
611        }
612        check_admin_referer('swapads_delete_backlink_' . $id);
613        $rest = RestClient::fromOption();
614        $resp = $rest->deleteBacklink($id);
615        $err  = $rest->lastError();
616        if ($err !== null) {
617            $errMsg = (string) wp_json_encode($err);
618            self::redirectWithError($errMsg);
619            return;
620        }
621        $success = isset($resp['success']) ? (bool) $resp['success'] : false;
622        if (!$success) {
623            self::redirectWithError('Delete failed');
624            return;
625        }
626        wp_safe_redirect(add_query_arg(['page' => BacklinksHubPage::MENU_SLUG, 'removed' => $id], admin_url('admin.php')));
627        if (!defined('SWAPADS_TESTING_REDIRECT_EXIT')) { exit; }
628    }
629
630    /**
631     * Handle single source-check-now submission (per-row "Check now" link).
632     *
633     * F121 (2026-07-31): GET link from the per-row button (uses $_REQUEST).
634     * Triggers a single-round-trip source-check via RestClient::recheckSource().
635     *
636     * @since 1.5.2
637     */
638    public static function handleSourceCheckNow(): void
639    {
640        if (!current_user_can('manage_options')) {
641            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
642        }
643        check_admin_referer('swapads_source_check_now');
644
645        $backlinkId = (int) ($_REQUEST['backlink_id'] ?? 0);
646        if ($backlinkId <= 0) {
647            self::redirectWithMsg('source-check', 'error', 'backlink_id is required');
648            return;
649        }
650        $client = RestClient::fromOption();
651        $resp = $client->recheckSource([$backlinkId]);
652        if ($client->lastError() !== null) {
653            self::redirectWithMsg('source-check', 'error', 'Server unreachable');
654            return;
655        }
656        $results = is_array($resp) && isset($resp['results']) && is_array($resp['results']) ? $resp['results'] : [];
657        $first   = $results[0] ?? [];
658        $result  = (string) ($first['result'] ?? 'unknown');
659        $http    = (int) ($first['http_status'] ?? 0);
660        self::redirectWithMsg(
661            'source-check',
662            $result,
663            sprintf('Backlink #%d: %s (HTTP %d)', $backlinkId, strtoupper($result), $http)
664        );
665    }
666
667    /**
668     * Handle bulk source-check-now submission.
669     *
670     * F121 (2026-07-31): single round-trip for many backlinks.
671     *
672     * @since 1.5.2
673     */
674    public static function handleSourceCheckBulk(): void
675    {
676        if (!current_user_can('manage_options')) {
677            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
678        }
679        check_admin_referer('swapads_source_check_bulk');
680
681        $ids = $_POST['backlink_ids'] ?? null;
682        if (!is_array($ids) || empty($ids)) {
683            self::redirectWithMsg('source-check', 'error', 'No backlinks selected');
684            return;
685        }
686        $client = RestClient::fromOption();
687        $resp = $client->recheckSource($ids);
688        if ($client->lastError() !== null) {
689            self::redirectWithMsg('source-check', 'error', 'Server unreachable');
690            return;
691        }
692        $count = (int) ($resp['count'] ?? 0);
693        self::redirectWithMsg(
694            'source-check',
695            'ok',
696            sprintf('Source-checked %d backlink(s)', $count)
697        );
698    }
699
700    /**
701     * Helper: redirect back to the Your Backlinks tab with a flash msg.
702     *
703     * F121 (2026-07-31): used by handleSourceCheckNow / handleSourceCheckBulk.
704     * Centralizes the wp_safe_redirect + query-arg pattern.
705     *
706     * @since 1.5.2
707     */
708    private static function redirectWithMsg(string $context, string $status, string $message): void
709    {
710        wp_safe_redirect(add_query_arg(
711            [
712                'page'    => 'swapads-client-backlinks-hub',
713                'tab'     => 'your',
714                'msg_ctx' => $context,
715                'msg_st'  => $status,
716                'msg'     => rawurlencode($message),
717            ],
718            admin_url('admin.php')
719        ));
720        // No exit; — see SettingsPage::render() for rationale.
721    }
722
723}