| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 51.43% covered (warning) | 51.43% | 90 / 175 | 58.33% covered (warning) | 58.33% | 7 / 12 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
OperatorDashboardPage | 51.43% covered (warning) | 51.43% | 90 / 175 | 58.33% covered (warning) | 58.33% | 7 / 12 | 233.62 | 0.00% covered (danger) | 0.00% | 0 / 1
register | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
addMenu | 0.00% covered (danger) | 0.00% | 0 / 1 | 0.00% covered (danger) | 0.00% | 0 / 1 | 2 |
fetchDashboard | 0.00% covered (danger) | 0.00% | 0 / 19 | 0.00% covered (danger) | 0.00% | 0 / 1 | 30 |
render | 0.00% covered (danger) | 0.00% | 0 / 63 | 0.00% covered (danger) | 0.00% | 0 / 1 | 56 |
renderMetricTile | 90.00% covered (success) | 90.00% | 9 / 10 | 0.00% covered (danger) | 0.00% | 0 / 1 | 3.01 |
formatPair | 100.00% covered (success) | 100.00% | 4 / 4 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
formatQuad | 100.00% covered (success) | 100.00% | 4 / 4 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
renderQuickActions | 100.00% covered (success) | 100.00% | 10 / 10 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |
renderRecentEvents | 100.00% covered (success) | 100.00% | 16 / 16 | 100.00% covered (success) | 100.00% | 1 / 1 | 3 |
formatEventDetails | 100.00% covered (success) | 100.00% | 19 / 19 | 100.00% covered (success) | 100.00% | 1 / 1 | 8 |
renderRecentTransactions | 100.00% covered (success) | 100.00% | 23 / 23 | 100.00% covered (success) | 100.00% | 1 / 1 | 6 |
renderGeneratedFooter | 100.00% covered (success) | 100.00% | 5 / 5 | 100.00% covered (success) | 100.00% | 1 / 1 | 2 |

1 | <?php
2 | /**
3 | * Operator Dashboard (CF12 - client side).
4 | *
5 | * Per-operator dashboard with 6 metric tiles + recent activity feed.
6 | *
7 | * Per _UX-SPEC.md §2.2:
8 | * - 6 metric tiles: balance, backlinks offered, backlinks received,
9 | * approvals, placements verified, 24h clicks+impressions
10 | * - Recent activity feed (last 10 events)
11 | * - Latest credit transactions (last 5)
12 | *
13 | * All render paths go through Admin\Renderer (CF14 build-for-change rule).
14 | *
15 | * @package SwapAds\Client\Admin
16 | * @since 1.1.0
17 | */
18 |
19 | declare(strict_types=1);
20 |
21 | namespace SwapAds\Client\Admin;
22 |
23 | use SwapAds\Client\Admin\ClientErrorRenderer;
24 | use SwapAds\Client\Api\RestClient;
25 | use SwapAds\Client\License\LicenseManager;
26 |
27 | /**
28 | * Class OperatorDashboardPage.
29 | *
30 | * @since 1.1.0
31 | */
32 | final class OperatorDashboardPage
33 | {
34 | public const MENU_SLUG = 'swapads-client-dashboard';
35 | public const CACHE_TTL = 60; // seconds, dashboard data freshness tradeoff
36 |
37 | /**
38 | * Register WP hooks (admin menu).
39 | */
40 | public static function register(): void
41 | {
42 | add_action('admin_menu', [self::class, 'addMenu']);
43 | }
44 |
45 | /**
46 | * Register sub-menu page under swapads-client top level.
47 | */
48 | /**
49 | * Register the Operator Dashboard submenu.
50 | *
51 | * F201 (2026-07-30): the menu has moved to OperatorDashboardHubPage
52 | * (which is the FIRST submenu and has Balance + Setup tabs). This
53 | * `addMenu()` is now a no-op kept for back-compat with callers that
54 | * may still dispatch to MENU_SLUG.
55 | */
56 | public static function addMenu(): void
57 | {
58 | // intentionally empty — menu moved to OperatorDashboardHubPage (F201).
59 | }
60 |
61 | /**
62 | * Fetch dashboard data from server.
63 | *
64 | * F198: RestClient::request() never throws for REST/transport
65 | * failures — lastError() captures the normalized envelope and we
66 | * persist it so render() can show it inline.
67 | *
68 | * @return array<string, mixed>|null Null on error.
69 | */
70 | public const ERROR_CONTEXT = 'swapads_client_dashboard';
71 |
72 | public static function fetchDashboard(): ?array
73 | {
74 | $client = RestClient::fromOption();
75 | try {
76 | $response = $client->get('/v1/dashboard/operator');
77 | } catch (\Throwable $e) {
78 | set_transient(self::ERROR_CONTEXT, [
79 | 'error_code' => 'FATAL',
80 | 'message' => 'Unexpected fatal error: ' . $e->getMessage(),
81 | 'http_status' => 0,
82 | 'when' => time(),
83 | ], MINUTE_IN_SECONDS);
84 | return null;
85 | }
86 | if (!is_array($response)) {
87 | if (($err = $client->lastError()) !== null) {
88 | set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
89 | }
90 | return null;
91 | }
92 | if (empty($response['success'])) {
93 | $err = $client->lastError() ?? $response;
94 | set_transient(self::ERROR_CONTEXT, $err, MINUTE_IN_SECONDS);
95 | return null;
96 | }
97 | return $response;
98 | }
99 |
100 | /**
101 | * Render the operator dashboard.
102 | */
103 | public static function render(): void
104 | {
105 | if (!current_user_can('manage_options')) {
106 | wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
107 | }
108 |
109 | echo Renderer::pageHeader(
110 | 'Operator Dashboard',
111 | 'Overview of your SwapAds activity: credits, backlinks, approvals, and recent events.'
112 | );
113 |
114 | if (!LicenseManager::isLicensed()) {
115 | echo Renderer::card(
116 | 'License required',
117 | '<p>Activate your license to view the operator dashboard.</p>',
118 | 'swapads-card-warning'
119 | );
120 | echo Renderer::pageFooter();
121 | return;
122 | }
123 |
124 | // F198: surface any persisted error from the previous load attempt.
125 | $persisted = get_transient(self::ERROR_CONTEXT);
126 | if (is_array($persisted)) {
127 | echo ClientErrorRenderer::render(self::ERROR_CONTEXT, $persisted, 'load operator dashboard');
128 | delete_transient(self::ERROR_CONTEXT);
129 | }
130 |
131 | $data = self::fetchDashboard();
132 | if ($data === null) {
133 | // F198: also surface the error from THIS attempt, in case the
134 | // transient above wasn't populated (cold page load).
135 | echo Renderer::card(
136 | 'Dashboard unavailable',
137 | '<p>Could not fetch dashboard data. See the notice above for the exact server response.</p>',
138 | 'swapads-card-error'
139 | );
140 | echo Renderer::pageFooter();
141 | return;
142 | }
143 |
144 | // 6 metric tiles
145 | echo '<div class="swapads-metric-grid">';
146 | // Per F-AD-CREDITS (2026-07-30): two credit tiles (backlinks + ads)
147 | echo self::renderMetricTile(
148 | 'Backlink credits',
149 | (int) ($data['credits']['backlinks']['balance'] ?? 0),
150 | sprintf(
151 | 'Earned %d / Spent %d',
152 | (int) ($data['credits']['backlinks']['lifetime_earned'] ?? 0),
153 | (int) ($data['credits']['backlinks']['lifetime_spent'] ?? 0)
154 | )
155 | );
156 | echo self::renderMetricTile(
157 | 'Ad credits',
158 | (int) ($data['credits']['ads']['balance'] ?? 0),
159 | sprintf(
160 | 'Earned %d / Spent %d (cap %d/day)',
161 | (int) ($data['credits']['ads']['lifetime_earned'] ?? 0),
162 | (int) ($data['credits']['ads']['lifetime_spent'] ?? 0),
163 | (int) ($data['credits']['ads']['daily_earn_cap'] ?? 10000)
164 | )
165 | );
166 | echo self::renderMetricTile('Backlinks offered', self::formatPair($data['backlinks_offered'] ?? []), 'active / inactive / total');
167 | echo self::renderMetricTile('Backlinks received', self::formatPair($data['backlinks_received'] ?? []),'active approvals');
168 | echo self::renderMetricTile('Approvals', self::formatQuad($data['approvals'] ?? []), 'pending / approved / rejected / placed');
169 | echo self::renderMetricTile('Placements verified', self::formatQuad($data['placements'] ?? []), 'verified / failed / pending');
170 | echo self::renderMetricTile('24h clicks / impressions', self::formatPair($data['activity_24h'] ?? []), 'last 24 hours');
171 | echo '</div>';
172 |
173 | // Quick actions
174 | echo Renderer::card(
175 | 'Quick actions',
176 | self::renderQuickActions()
177 | );
178 |
179 | // Recent events
180 | $eventsHtml = self::renderRecentEvents($data['recent_events'] ?? []);
181 | echo Renderer::card('Recent activity (last 10)', $eventsHtml === '' ? '<p class="swapads-muted">No recent activity yet.</p>' : $eventsHtml);
182 |
183 | // Recent credit transactions
184 | $txHtml = self::renderRecentTransactions($data['recent_transactions'] ?? []);
185 | echo Renderer::card('Latest credit transactions (last 5)', $txHtml === '' ? '<p class="swapads-muted">No transactions yet.</p>' : $txHtml);
186 |
187 | echo self::renderGeneratedFooter($data);
188 | echo Renderer::pageFooter();
189 | }
190 |
191 | /**
192 | * Render one metric tile.
193 | */
194 | public static function renderMetricTile(string $label, $value, string $description = ''): string
195 | {
196 | if (is_int($value)) {
197 | $valueHtml = '<div class="swapads-metric-value">' . esc_html(number_format_i18n($value)) . '</div>';
198 | } else {
199 | $valueHtml = '<div class="swapads-metric-value">' . esc_html((string) $value) . '</div>';
200 | }
201 | $desc = $description !== '' ? '<div class="swapads-metric-desc">' . esc_html($description) . '</div>' : '';
202 | $html = '<div class="swapads-metric-tile">';
203 | $html .= '<div class="swapads-metric-label">' . esc_html($label) . '</div>';
204 | $html .= $valueHtml;
205 | $html .= $desc;
206 | $html .= '</div>';
207 | return $html;
208 | }
209 |
210 | /**
211 | * Format "X active / Y total" pair.
212 | */
213 | public static function formatPair(array $row): string
214 | {
215 | $a = (int) ($row['active'] ?? 0);
216 | $i = (int) ($row['inactive'] ?? 0);
217 | $t = (int) ($row['total'] ?? 0);
218 | return sprintf('%d active / %d total', $a, $t > 0 ? $t : ($a + $i));
219 | }
220 |
221 | /**
222 | * Format "X verified / Y failed / Z pending" quad.
223 | */
224 | public static function formatQuad(array $row): string
225 | {
226 | $v = (int) ($row['verified'] ?? 0);
227 | $f = (int) ($row['failed'] ?? 0);
228 | $p = (int) ($row['pending'] ?? 0);
229 | return sprintf('%d verified / %d failed / %d pending', $v, $f, $p);
230 | }
231 |
232 | /**
233 | * Render quick action buttons.
234 | */
235 | public static function renderQuickActions(): string
236 | {
237 | $actions = [
238 | ['label' => 'Create Backlink', 'url' => admin_url('admin.php?page=swapads-client-backlinks')],
239 | ['label' => 'Review Suggestions', 'url' => admin_url('admin.php?page=swapads-client-approval')],
240 | ['label' => 'Edit Site Settings', 'url' => admin_url('admin.php?page=swapads-client')],
241 | ];
242 | $html = '<p>';
243 | foreach ($actions as $a) {
244 | $html .= '<a class="button" href="' . esc_url($a['url']) . '">' . esc_html($a['label']) . '</a> ';
245 | }
246 | $html .= '</p>';
247 | return $html;
248 | }
249 |
250 | /**
251 | * Render recent events (mixed kinds: approval / credit / placement).
252 | *
253 | * @param array<int, array<string, mixed>> $events
254 | */
255 | public static function renderRecentEvents(array $events): string
256 | {
257 | if (count($events) === 0) {
258 | return '';
259 | }
260 | $html = '<table class="widefat striped">';
261 | $html .= '<thead><tr><th>When</th><th>Kind</th><th>Details</th></tr></thead>';
262 | $html .= '<tbody>';
263 | foreach ($events as $e) {
264 | $when = (string) ($e['when'] ?? '');
265 | $kind = (string) ($e['kind'] ?? '');
266 | $details = self::formatEventDetails($e);
267 | $html .= '<tr>';
268 | $html .= '<td>' . esc_html($when) . '</td>';
269 | $html .= '<td><code>' . esc_html($kind) . '</code></td>';
270 | $html .= '<td>' . $details . '</td>'; // details already escaped
271 | $html .= '</tr>';
272 | }
273 | $html .= '</tbody></table>';
274 | return $html;
275 | }
276 |
277 | /**
278 | * Format a single event into an HTML cell.
279 | *
280 | * @param array<string, mixed> $e
281 | */
282 | public static function formatEventDetails(array $e): string
283 | {
284 | $kind = (string) ($e['kind'] ?? '');
285 | switch ($kind) {
286 | case 'approval':
287 | $id = (int) ($e['id'] ?? 0);
288 | $bid = (int) ($e['backlink_id'] ?? 0);
289 | $status = (string) ($e['status'] ?? '');
290 | return sprintf('#%d backlink #%d status changed to %s', $id, $bid, esc_html($status));
291 | case 'credit':
292 | $id = (int) ($e['id'] ?? 0);
293 | $delta = (int) ($e['delta'] ?? 0);
294 | $action = (string) ($e['action'] ?? '');
295 | $balance = (int) ($e['balance_after'] ?? 0);
296 | $siteUrl = $e['site_url'] ?? null;
297 | $domain = $siteUrl ? esc_html(parse_url($siteUrl, PHP_URL_HOST) ?: $siteUrl) : '';
298 | return sprintf('#%d %s %+d credits (balance now %d)%s', $id, esc_html($action), $delta, $balance, $domain ? " — {$domain}" : '');
299 | case 'placement':
300 | $id = (int) ($e['id'] ?? 0);
301 | $status = (string) ($e['status'] ?? '');
302 | return sprintf('#%d verification status: %s', $id, esc_html($status));
303 | default:
304 | return '<code>' . esc_html(wp_json_encode($e)) . '</code>';
305 | }
306 | }
307 |
308 | /**
309 | * Render the recent credit transactions card.
310 | *
311 | * @param array<int, array<string, mixed>> $txns
312 | */
313 | public static function renderRecentTransactions(array $txns): string
314 | {
315 | if (count($txns) === 0) {
316 | return '';
317 | }
318 | $html = '<table class="widefat striped">';
319 | $html .= '<thead><tr><th>When</th><th>Action</th><th>Delta</th><th>Balance after</th><th>Domain</th><th>Reason</th></tr></thead>';
320 | $html .= '<tbody>';
321 | foreach ($txns as $t) {
322 | $when = (string) ($t['when'] ?? '');
323 | $action = (string) ($t['action'] ?? '');
324 | $delta = (int) ($t['delta'] ?? 0);
325 | $balance = (int) ($t['balance_after'] ?? 0);
326 | $reason = (string) ($t['reason'] ?? '');
327 | $siteUrl = $t['site_url'] ?? null;
328 | $domainHtml = $siteUrl ? esc_html(parse_url($siteUrl, PHP_URL_HOST) ?: $siteUrl) : '<span class="swapads-muted">—</span>';
329 | $html .= '<tr>';
330 | $html .= '<td>' . esc_html($when) . '</td>';
331 | $html .= '<td><code>' . esc_html($action) . '</code></td>';
332 | $html .= '<td>' . esc_html(($delta >= 0 ? '+' : '') . $delta) . '</td>';
333 | $html .= '<td>' . esc_html((string) $balance) . '</td>';
334 | $html .= '<td>' . $domainHtml . '</td>';
335 | $html .= '<td>' . esc_html($reason) . '</td>';
336 | $html .= '</tr>';
337 | }
338 | $html .= '</tbody></table>';
339 | return $html;
340 | }
341 |
342 | /**
343 | * Render the small "Generated at" footer with refresh hint.
344 | *
345 | * @param array<string, mixed> $data
346 | */
347 | public static function renderGeneratedFooter(array $data): string
348 | {
349 | $when = (string) ($data['generated_at'] ?? '');
350 | if ($when === '') {
351 | return '';
352 | }
353 | return '<p class="swapads-muted">Data fetched at <code>' . esc_html($when) . '</code>. ' .
354 | '<a href="#" onclick="location.reload(); return false;">Refresh</a></p>';
355 | }
356 | }