| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 84.27% covered (warning) | 84.27% | 75 / 89 | 75.00% covered (warning) | 75.00% | 3 / 4 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
ClientErrorRenderer | 84.27% covered (warning) | 84.27% | 75 / 89 | 75.00% covered (warning) | 75.00% | 3 / 4 | 51.54 | 0.00% covered (danger) | 0.00% | 0 / 1
render | 100.00% covered (success) | 100.00% | 42 / 42 | 100.00% covered (success) | 100.00% | 1 / 1 | 16 |
hintFor | 68.89% covered (warning) | 68.89% | 31 / 45 | 0.00% covered (danger) | 0.00% | 0 / 1 | 46.36 |
clearFor | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
resetAll | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 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 | }