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
19declare(strict_types=1);
20
21namespace SwapAds\Client\Admin;
22
23use SwapAds\Client\Admin\ClientErrorRenderer;
24use SwapAds\Client\Api\RestClient;
25use SwapAds\Client\License\LicenseManager;
26
27/**
28 * Class OperatorDashboardPage.
29 *
30 * @since 1.1.0
31 */
32final 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}