Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
37.36% |
167 / 447 |
|
30.77% |
4 / 13 |
CRAP | |
0.00% |
0 / 1 |
| BacklinkCreatePage | |
37.36% |
167 / 447 |
|
30.77% |
4 / 13 |
2218.78 | |
0.00% |
0 / 1 |
| register | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| hideSubmenuFromSidebar | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| addMenu | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| render | |
98.86% |
87 / 88 |
|
0.00% |
0 / 1 |
9 | |||
| defaultValues | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| handleSubmit | |
0.00% |
0 / 62 |
|
0.00% |
0 / 1 |
420 | |||
| redirectWithError | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| renderYourBacklinksSection | |
0.00% |
0 / 141 |
|
0.00% |
0 / 1 |
812 | |||
| renderBacklinksTable | |
100.00% |
58 / 58 |
|
100.00% |
1 / 1 |
8 | |||
| handleDelete | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
56 | |||
| handleSourceCheckNow | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
56 | |||
| handleSourceCheckBulk | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
30 | |||
| redirectWithMsg | |
0.00% |
0 / 10 |
|
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 | } |