| 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 |
17 | declare(strict_types=1);
18 |
19 | namespace SwapAds\Client\Admin;
20 |
21 | use SwapAds\Client\Api\RestClient;
22 | use SwapAds\Client\License\LicenseManager;
23 |
24 |
25 | if (!defined('ABSPATH')) { /* test mode: skip WordPress bootstrap guard */ }
26 |
27 | final 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 | }