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