Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
BacklinkStatsWidget
0.00% covered (danger)
0.00%
0 / 124
0.00% covered (danger)
0.00%
0 / 4
272
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
 registerWidget
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 render
0.00% covered (danger)
0.00%
0 / 93
0.00% covered (danger)
0.00%
0 / 1
72
 fetchSummary
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * SwapAds Client — BacklinkStatsWidget (F238).
4 *
5 * WP-admin dashboard widget that shows per-partner backlink statistics.
6 * Operator sees: "12 partners viewed your backlink this week, 47 humans
7 * clicked, 8 search-engine bots crawled".
8 *
9 * Data source: server endpoint /v1/backlinks/statistics/summary (HMAC-signed).
10 * Cached 5-min transient to avoid server spam from dashboard auto-refresh.
11 *
12 * Naming-convention-locked (avoid tracker/ad/beacon trigger words).
13 *
14 * @since 1.5.3
15 */
16
17declare(strict_types=1);
18
19namespace SwapAds\Client\Admin;
20
21use SwapAds\Client\Api\RestClient;
22use SwapAds\Client\License\LicenseManager;
23
24
25if (!defined('ABSPATH')) { /* test mode: skip WordPress bootstrap guard */ }
26
27final class BacklinkStatsWidget
28{
29    /** Transient key for caching server response. */
30    public const CACHE_KEY_PREFIX = 'swapads_stats_summary_';
31    public const CACHE_TTL = 300;  // 5 minutes
32
33    /**
34     * Register the wp_add_dashboard_widget hook.
35     */
36    public static function register(): void
37    {
38        add_action('wp_dashboard_setup', [self::class, 'registerWidget']);
39    }
40
41    public static function registerWidget(): void
42    {
43        // Only show to users who can manage options + operators with licensed sites.
44        if (!current_user_can('manage_options')) {
45            return;
46        }
47        if (!LicenseManager::isLicensed()) {
48            return;
49        }
50        wp_add_dashboard_widget(
51            'swapads_backlink_statistics',
52            __('Backlink statistics', 'swapads'),
53            [self::class, 'render'],
54            null,
55            [],
56            'normal',
57            'high'  // show near top of dashboard
58        );
59    }
60
61    /**
62     * Render the widget.
63     */
64    public static function render(): void
65    {
66        $summary = self::fetchSummary(30);
67        if ($summary === null) {
68            // Server unreachable or no data yet.
69            echo '<div class="swapads-stats-empty">';
70            echo \SwapAds\Client\Admin\Renderer::noticeAccessible(
71                __('No backlink statistics yet. Your backlinks haven\'t received any views or clicks in the last 30 days.', 'swapads'),
72                'info'
73            );
74            echo '</div>';
75            return;
76        }
77
78        $total = $summary['summary'] ?? [];
79        $partners = $summary['partners'] ?? [];
80        $days = (int) ($summary['days'] ?? 30);
81
82        $humanViews = (int) ($total['human_views'] ?? 0);
83        $botViews = (int) ($total['bot_views'] ?? 0);
84        $humanClicks = (int) ($total['human_clicks'] ?? 0);
85        $botClicks = (int) ($total['bot_clicks'] ?? 0);
86        $ctr = isset($total['ctr']) ? (float) $total['ctr'] : 0.0;
87        $ctrPct = number_format($ctr * 100, 1);
88
89        echo '<div class="swapads-stats-widget">';
90
91        // Top-line totals.
92        echo '<div class="swapads-stats-totals">';
93        echo '<span class="swapads-stats-totals-views">';
94        echo '<strong>' . esc_html(number_format($humanViews)) . '</strong> ';
95        echo esc_html__('human views', 'swapads');
96        echo '</span>';
97
98        echo '<span class="swapads-stats-totals-clicks">';
99        echo '<strong>' . esc_html(number_format($humanClicks)) . '</strong> ';
100        echo esc_html__('human clicks', 'swapads');
101        echo '</span>';
102
103        echo '<span class="swapads-stats-totals-ctr">';
104        echo '<strong>' . esc_html($ctrPct) . '%</strong> ';
105        echo esc_html__('CTR', 'swapads');
106        echo '</span>';
107        echo '</div>';
108
109        // Bot breakdown (collapsed but visible).
110        echo '<p class="swapads-stats-bot-line">';
111        echo esc_html(
112            sprintf(
113                /* translators: 1: bot views, 2: bot clicks */
114                _n(
115                    'Bots: %1$s view, %2$s click (last %3$d days)',
116                    'Bots: %1$s views, %2$s clicks (last %3$d days)',
117                    max(1, $botViews),
118                    'swapads'
119                ),
120                number_format($botViews),
121                number_format($botClicks),
122                $days
123            )
124        );
125        echo '</p>';
126
127        // Per-partner table (top 10).
128        if (!empty($partners)) {
129            echo '<h4>' . esc_html__('Top partners', 'swapads') . '</h4>';
130            echo '<table class="widefat swapads-stats-partners">';
131            echo '<thead><tr>';
132            echo '<th>' . esc_html__('Partner', 'swapads') . '</th>';
133            echo '<th>' . esc_html__('Views', 'swapads') . '</th>';
134            echo '<th>' . esc_html__('Clicks', 'swapads') . '</th>';
135            echo '<th>' . esc_html__('CTR', 'swapads') . '</th>';
136            echo '</tr></thead>';
137            echo '<tbody>';
138            $shown = 0;
139            foreach ($partners as $p) {
140                if ($shown >= 10) {
141                    break;
142                }
143                $domain = (string) ($p['partner_domain'] ?? '');
144                if ($domain === '') {
145                    continue;
146                }
147                echo '<tr>';
148                echo '<td>' . esc_html($domain) . '</td>';
149                echo '<td>' . esc_html(number_format((int) ($p['total_views'] ?? 0))) . '</td>';
150                echo '<td>' . esc_html(number_format((int) ($p['total_clicks'] ?? 0))) . '</td>';
151                echo '<td>' . esc_html(number_format(((float) ($p['ctr'] ?? 0)) * 100, 1)) . '%</td>';
152                echo '</tr>';
153                $shown++;
154            }
155            echo '</tbody></table>';
156            if (count($partners) > 10) {
157                echo '<p class="swapads-stats-more">';
158                echo esc_html(sprintf(
159                    /* translators: %d: number of additional partners */
160                    _n(
161                        '...and %d more partner.',
162                        '...and %d more partners.',
163                        count($partners) - 10,
164                        'swapads'
165                    ),
166                    count($partners) - 10
167                ));
168                echo '</p>';
169            }
170        }
171
172        // Link to full stats (in case there's a deeper page later).
173        echo '<p class="swapads-stats-link">';
174        echo '<a href="' . esc_url(admin_url('admin.php?page=swapads-client-hub&tab=setup')) . '">';
175        echo esc_html__('Manage backlink setup', 'swapads');
176        echo '</a>';
177        echo '</p>';
178
179        echo '</div>';
180
181        // Inline CSS for the widget (kept lightweight).
182        echo '<style>
183            .swapads-stats-totals { display: flex; gap: 1.5em; margin-bottom: 0.75em; }
184            .swapads-stats-totals > span { display: flex; flex-direction: column; }
185            .swapads-stats-totals strong { font-size: 1.4em; color: #2271b1; }
186            .swapads-stats-bot-line { color: #50575e; font-size: 0.85em; margin: 0.5em 0; }
187            .swapads-stats-partners { margin-top: 0.75em; }
188            .swapads-stats-more { color: #50575e; font-style: italic; font-size: 0.85em; }
189            .swapads-stats-link { margin-top: 0.75em; }
190        </style>';
191    }
192
193    /**
194     * Fetch summary from server (with caching).
195     *
196     * @param int $days 1..365
197     * @return array|null Server response (decoded), or null on error.
198     */
199    private static function fetchSummary(int $days): ?array
200    {
201        $key = self::CACHE_KEY_PREFIX . $days;
202        $cached = get_transient($key);
203        if (is_array($cached)) {
204            return $cached;
205        }
206
207        $client = RestClient::fromOption();
208        $response = $client->get('/backlinks/statistics/summary', ['days' => $days]);
209        if (!is_array($response)) {
210            return null;
211        }
212        if (($response['success'] ?? false) !== true) {
213            return null;
214        }
215        $payload = [
216            'summary'  => $response['summary'] ?? [],
217            'partners' => $response['partners'] ?? [],
218            'days'     => $response['days'] ?? $days,
219        ];
220        set_transient($key, $payload, self::CACHE_TTL);
221        return $payload;
222    }
223}