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
28declare(strict_types=1);
29
30namespace SwapAds\Client\Admin;
31
32final 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}