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