Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
84.27% |
75 / 89 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
| ClientErrorRenderer | |
84.27% |
75 / 89 |
|
75.00% |
3 / 4 |
51.54 | |
0.00% |
0 / 1 |
| render | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
16 | |||
| hintFor | |
68.89% |
31 / 45 |
|
0.00% |
0 / 1 |
46.36 | |||
| clearFor | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| resetAll | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Client-Side Error Renderer (F198). |
| 4 | * |
| 5 | * Renders inline `admin_notices` HTML for the operator when a client-side |
| 6 | * REST call fails. Every caller (BacklinkCreatePage, BacklinkApprovalPage, |
| 7 | * OnboardingWizardPage, OperatorDashboardPage, SettingsPage) should call |
| 8 | * ClientErrorRenderer::render($context, $error) AFTER a failed REST call |
| 9 | * so the operator sees the actual failure reason instead of a silent dead |
| 10 | * click. |
| 11 | * |
| 12 | * Design choices: |
| 13 | * - Inline notice on the caller page (NOT a global banner). Operator |
| 14 | * retains context: "this BACKLINK OFFER failed because of X". |
| 15 | * - One-line summary + collapsible details (error_code, http_status, |
| 16 | * request_route, raw response snippet). |
| 17 | * - Each call uses a stable $context string so we don't render the |
| 18 | * same error twice (the renderer returns '' after first call until |
| 19 | * clearFor($context) is called). |
| 20 | * - i18n via standard __() / esc_html__(). |
| 21 | * - Renders to a string; callers `echo` it directly. No global side |
| 22 | * effects (no admin_notices action) — keeps the renderer testable. |
| 23 | * |
| 24 | * @package SwapAds\Client\Admin |
| 25 | * @since 1.5.0 |
| 26 | */ |
| 27 | |
| 28 | declare(strict_types=1); |
| 29 | |
| 30 | namespace SwapAds\Client\Admin; |
| 31 | |
| 32 | final class ClientErrorRenderer |
| 33 | { |
| 34 | /** |
| 35 | * Per-request rendered contexts. Prevents the same error from being |
| 36 | * rendered twice if multiple pages call render() in the same load. |
| 37 | * |
| 38 | * @var array<string, true> |
| 39 | */ |
| 40 | private static array $rendered = []; |
| 41 | |
| 42 | /** |
| 43 | * Render an error as HTML for an inline admin notice. |
| 44 | * |
| 45 | * Returns '' if: |
| 46 | * - $error is null |
| 47 | * - $error has no error_code or message |
| 48 | * - $context was already rendered in this request |
| 49 | * |
| 50 | * @param string $context Stable identifier for this call site. |
| 51 | * e.g. 'swapads_client_backlink_create'. |
| 52 | * @param array<string, mixed>|null $error Normalized error array (from RestClient::lastError()). |
| 53 | * @param string|null $actionLabel Human label for the action that failed. |
| 54 | * e.g. 'create backlink offer'. |
| 55 | * |
| 56 | * @return string HTML (empty if nothing to render). |
| 57 | */ |
| 58 | public static function render(string $context, ?array $error, ?string $actionLabel = null): string |
| 59 | { |
| 60 | if ($error === null) { |
| 61 | return ''; |
| 62 | } |
| 63 | if (!isset($error['error_code']) && !isset($error['message'])) { |
| 64 | return ''; |
| 65 | } |
| 66 | if (isset(self::$rendered[$context])) { |
| 67 | return ''; |
| 68 | } |
| 69 | self::$rendered[$context] = true; |
| 70 | |
| 71 | $errorCode = (string) ($error['error_code'] ?? 'UNKNOWN_ERROR'); |
| 72 | $message = (string) ($error['message'] ?? 'Server returned an error without a message.'); |
| 73 | $httpStatus = isset($error['http_status']) ? (int) $error['http_status'] : 0; |
| 74 | $route = isset($error['request_route']) ? (string) $error['request_route'] : ''; |
| 75 | $raw = isset($error['raw']) ? (string) $error['raw'] : ''; |
| 76 | |
| 77 | // Translate known error codes into operator-friendly hints. |
| 78 | $hint = self::hintFor($errorCode, $message); |
| 79 | |
| 80 | $actionPhrase = $actionLabel !== null && $actionLabel !== '' |
| 81 | ? sprintf(' to %s', esc_html($actionLabel)) |
| 82 | : ''; |
| 83 | |
| 84 | // F2XX (2026-08-01): RECHECK_SUMMARY is a SUCCESS message with bad |
| 85 | // metadata (it's stashed via the error transient for routing). Render |
| 86 | // it as a green notice, not a red banner. |
| 87 | if ($errorCode === 'RECHECK_SUMMARY') { |
| 88 | $out = '<div class="notice notice-success swapads-client-summary-notice" data-swapads-context="' . esc_attr($context) . '" style="border-left-color:#46b450;padding:12px 14px;margin:12px 0;">'; |
| 89 | $out .= '<p style="margin:0 0 8px 0;"><strong>' . esc_html__('SwapAds: recheck completed', 'swapads-client') . '</strong></p>'; |
| 90 | $out .= '<p role="status" aria-live="polite" style="margin:0 0 0 0;">' . esc_html($message) . '</p>'; |
| 91 | $out .= '</div>'; |
| 92 | return $out; |
| 93 | } |
| 94 | |
| 95 | // Build the notice HTML. |
| 96 | $out = '<div class="notice notice-error swapads-client-error-notice" data-swapads-context="' . esc_attr($context) . '" style="border-left-color:#dc3232;padding:12px 14px;margin:12px 0;">'; |
| 97 | $out .= '<p style="margin:0 0 8px 0;"><strong>' . esc_html(sprintf(__('SwapAds: failed%s', 'swapads-client'), $actionPhrase)) . '</strong></p>'; |
| 98 | $out .= '<p role="status" aria-live="assertive" style="margin:0 0 8px 0;">' . esc_html($message) . '</p>'; |
| 99 | if ($hint !== '') { |
| 100 | $out .= '<p style="margin:0 0 8px 0;color:#666;font-size:13px;"><em>' . esc_html($hint) . '</em></p>'; |
| 101 | } |
| 102 | |
| 103 | // Collapsible details. |
| 104 | $detailsLabel = esc_html__('Technical details', 'swapads-client'); |
| 105 | $detailId = 'swapads-err-' . md5($context); |
| 106 | $out .= '<details style="margin-top:6px;"><summary style="cursor:pointer;color:#2271b1;">' . $detailsLabel . '</summary>'; |
| 107 | $out .= '<div style="margin-top:8px;font-family:monospace;font-size:12px;background:#f6f7f7;padding:8px;border-radius:3px;">'; |
| 108 | $out .= '<div><strong>error_code:</strong> ' . esc_html($errorCode) . '</div>'; |
| 109 | if ($httpStatus > 0) { |
| 110 | $out .= '<div><strong>http_status:</strong> ' . esc_html((string) $httpStatus) . '</div>'; |
| 111 | } |
| 112 | if ($route !== '') { |
| 113 | $out .= '<div><strong>request_route:</strong> ' . esc_html($route) . '</div>'; |
| 114 | } |
| 115 | if ($raw !== '') { |
| 116 | $snippet = strlen($raw) > 400 ? substr($raw, 0, 400) . '...' : $raw; |
| 117 | $out .= '<div><strong>raw_response:</strong> <pre style="white-space:pre-wrap;margin:4px 0 0 0;">' . esc_html($snippet) . '</pre></div>'; |
| 118 | } |
| 119 | $out .= '</div></details>'; |
| 120 | |
| 121 | $out .= '</div>'; |
| 122 | return $out; |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * Operator-friendly hint per known error code. Keeps the message |
| 127 | * field clean (server's technical message) and adds a plain-language |
| 128 | * next step. |
| 129 | */ |
| 130 | private static function hintFor(string $errorCode, string $message): string |
| 131 | { |
| 132 | switch ($errorCode) { |
| 133 | case 'LICENSE_NOT_ACTIVATED': |
| 134 | return __('Fix: open SwapAds → Settings and complete the Freemius opt-in.', 'swapads-client'); |
| 135 | case 'SERVER_SECRET_UNAVAILABLE': |
| 136 | return __('Fix: the SwapAds server is unreachable from this site. Check that staging.swapads.eu (or your configured server URL) is online, and that this site can reach it.', 'swapads-client'); |
| 137 | case 'NETWORK_ERROR': |
| 138 | return __('Fix: check that this site can reach the configured SwapAds server. A firewall, DNS issue, or offline server may be blocking the request.', 'swapads-client'); |
| 139 | case 'INVALID_RESPONSE': |
| 140 | return __('Fix: the SwapAds server returned a non-JSON response (often a 404 HTML page). Verify the configured server URL points to the WordPress site root (the REST API lives at /wp-json/swapads-server/v1/...).', 'swapads-client'); |
| 141 | case 'HMAC_INVALID': |
| 142 | case 'HMAC_MISSING_HEADERS': |
| 143 | case 'HMAC_BAD_TIMESTAMP': |
| 144 | case 'HMAC_TIMESTAMP_OUT_OF_WINDOW': |
| 145 | case 'HMAC_REPLAY_DETECTED': |
| 146 | return __('Fix: the HMAC handshake failed. The license key and HMAC secret are out of sync. Try SwapAds → Settings → "Resync license now" to refetch the server secret.', 'swapads-client'); |
| 147 | case 'LICENSE_UNKNOWN': |
| 148 | return __('Fix: the server has no record of this license. The license was just activated locally — try again in a few seconds, or check SwapAds → Settings → Activation status.', 'swapads-client'); |
| 149 | case 'LICENSE_NOT_FOUND': |
| 150 | return __('Fix: server has no license for this site. Try SwapAds → Settings → Resync to re-register.', 'swapads-client'); |
| 151 | case 'OFFER_REJECTED': |
| 152 | return __('Fix: the offer was rejected by the server (URL format, anchor format, or status). Review the backlink offer constraints in the form.', 'swapads-client'); |
| 153 | case 'PAYLOAD_TOO_LARGE': |
| 154 | return __('Fix: the request exceeded 64 KB. Try a shorter hint or fewer fields.', 'swapads-client'); |
| 155 | case 'RATE_LIMIT_EXCEEDED': |
| 156 | return __('Fix: too many requests in a short window. Wait a minute and try again.', 'swapads-client'); |
| 157 | case 'DAILY_CAP_EXCEEDED': |
| 158 | return __('Fix: you hit the daily ad-earning cap (10,000 credits/site/day). Try again tomorrow, or ask your account manager to raise the cap.', 'swapads-client'); |
| 159 | case 'INVALID_VIEWABILITY_PROOF': |
| 160 | return __('Fix: the impression did not pass the viewability check (no JavaScript, headless browser, or bot detected). Set viewability_flag=true on legitimate traffic only.', 'swapads-client'); |
| 161 | case 'INVALID_LICENSE': |
| 162 | return __('Fix: your license is suspended, expired, or revoked. Check SwapAds → Settings → Activation status, or contact support.', 'swapads-client'); |
| 163 | case 'FRAUD_DETECTED': |
| 164 | return __('Fix: your account triggered a fraud rule. Review the source URL + anchor text for compliance, then contact support.', 'swapads-client'); |
| 165 | case 'CIRCUIT_OPEN': |
| 166 | return __('Fix: the server is temporarily in circuit-breaker mode (too many recent failures). Try again in 60 seconds.', 'swapads-client'); |
| 167 | case 'UNAUTHORIZED': |
| 168 | return __('Fix: your session expired. Reload the page and try again.', 'swapads-client'); |
| 169 | case 'BALANCE_REQUIRED': |
| 170 | return __('Fix: your credit balance is 0. Earn credits by placing partner links, or accept a backlink offer to earn.', 'swapads-client'); |
| 171 | case 'NOT_FOUND': |
| 172 | return __('Fix: the resource does not exist. Refresh the page and try again.', 'swapads-client'); |
| 173 | case 'RECIPROCAL_RISK_REJECTED': |
| 174 | return __('Fix: this offer would create a reciprocal backlink loop. Pick a different partner to keep SEO healthy.', 'swapads-client'); |
| 175 | } |
| 176 | |
| 177 | // Generic catch-all: if HTTP status is 0 it's transport, 5xx is server, 4xx is client. |
| 178 | if ($message === '' || $message === '0') { |
| 179 | return __('No additional context available.', 'swapads-client'); |
| 180 | } |
| 181 | return ''; |
| 182 | } |
| 183 | |
| 184 | /** |
| 185 | * Clear the rendered flag for a context. Useful when the same page |
| 186 | * renders, processes a form, and re-renders (so the error can show |
| 187 | * again on the next page load). |
| 188 | * |
| 189 | * Note: per-request static state is intentionally NOT persisted across |
| 190 | * requests. Callers that want a sticky error should persist it in |
| 191 | * a transient + clear via SettingsPage action. |
| 192 | */ |
| 193 | public static function clearFor(string $context): void |
| 194 | { |
| 195 | unset(self::$rendered[$context]); |
| 196 | } |
| 197 | |
| 198 | /** |
| 199 | * Reset ALL rendered flags (for test isolation). |
| 200 | */ |
| 201 | public static function resetAll(): void |
| 202 | { |
| 203 | self::$rendered = []; |
| 204 | } |
| 205 | } |