PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.8.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.8.0
2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 All 49 releases
thinkrank / includes / seo / email-report-sections / class-email-report-html.php

class-email-report-html.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.8.0, at includes/seo/email-report-sections/class-email-report-html.php

278 lines 11.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Report HTML helpers
4 *
5 * The small vocabulary every card in the report is built from: a heading, a
6 * stat tile with a change pill, a ranked list with +N / −N pills, a bar row,
7 * a link. Everything is a table with inline styles so it survives Gmail,
8 * Apple Mail and Outlook (#742).
9 *
10 * Stateless. Sections call these; the renderer's card shell and the layout
11 * template share the same tokens so the whole email reads as one design.
12 *
13 * @package ThinkRank
14 * @subpackage SEO\Email_Report_Sections
15 * @since 2.8.0
16 */
17
18 declare(strict_types=1);
19
20 namespace ThinkRank\SEO\Email_Report_Sections;
21
22 if (!defined('ABSPATH')) {
23 exit;
24 }
25
26 final class Email_Report_Html {
27
28 public const FONT = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;";
29 public const INK = '#14161f';
30 public const MUTED = '#5f6473';
31 public const LINE = '#e6e8ef';
32 public const CANVAS = '#f3f4f8';
33 public const PRIMARY = '#4451ff';
34 public const UP = '#0f8a4f';
35 public const UP_BG = '#e5f6ec';
36 public const DOWN = '#c62828';
37 public const DOWN_BG = '#fdecea';
38 public const FLAT = '#5f6473';
39 public const FLAT_BG = '#eef0f6';
40
41 /**
42 * URL of a bundled email image (logo, stat icons).
43 */
44 public static function asset(string $file): string {
45 return THINKRANK_PLUGIN_URL . 'static/img/email/' . $file;
46 }
47
48 /**
49 * Card heading: title and an optional one-line subtitle.
50 */
51 public static function heading(string $title, string $subtitle = ''): string {
52 $html = '<div style="' . self::FONT . 'font-size:17px;font-weight:700;color:' . self::INK . ';line-height:1.3;">'
53 . esc_html($title) . '</div>';
54 if ($subtitle !== '') {
55 $html .= '<div style="' . self::FONT . 'font-size:13px;color:' . self::MUTED . ';margin-top:4px;line-height:1.5;">'
56 . esc_html($subtitle) . '</div>';
57 }
58 return $html;
59 }
60
61 /**
62 * A rounded pill. $tone is 'up', 'down' or 'flat'.
63 */
64 public static function pill(string $text, string $tone = 'flat', string $margin = ''): string {
65 [$color, $bg] = self::tone_colors($tone);
66 return '<span style="display:inline-block;' . $margin . 'padding:3px 9px;border-radius:999px;background:' . $bg
67 . ';color:' . $color . ';' . self::FONT . 'font-size:12px;font-weight:700;white-space:nowrap;">'
68 . esc_html($text) . '</span>';
69 }
70
71 /**
72 * Change pill under a stat. $delta is the signed change already formatted
73 * ("12.4%", "1.8"), $direction 'up'/'down'/'flat', $good whether that
74 * direction is good news (clicks up = good; position number up = bad).
75 */
76 public static function change(?string $delta, string $direction, bool $good, string $suffix = ''): string {
77 if ($delta === null) {
78 return '';
79 }
80 if ($direction === 'flat') {
81 return self::pill(__('No change', 'thinkrank'), 'flat', 'margin-top:6px;');
82 }
83 $arrow = $direction === 'up' ? '' : '';
84 $text = $arrow . ' ' . $delta . ($suffix !== '' ? ' ' . $suffix : '');
85 return self::pill($text, $good ? 'up' : 'down', 'margin-top:6px;');
86 }
87
88 /**
89 * One stat tile: icon circle, label, big number, change pill. Returns a
90 * <td> — pair them with stat_grid().
91 *
92 * @param array{label:string,value:string,change?:string,icon?:string} $stat
93 */
94 public static function stat(array $stat): string {
95 $icon = '';
96 if (!empty($stat['icon'])) {
97 $icon = '<td width="44" valign="top"><img src="' . esc_url(self::asset('icon-' . $stat['icon'] . '.png'))
98 . '" width="40" height="40" alt="" style="display:block;border:0;border-radius:20px;" /></td>';
99 }
100 return '<td width="50%" valign="top" style="padding:12px 8px;">'
101 . '<table role="presentation" cellpadding="0" cellspacing="0" width="100%"><tr>' . $icon
102 . '<td style="padding-left:' . ($icon !== '' ? '12px' : '0') . ';' . self::FONT . '">'
103 . '<div style="font-size:13px;color:' . self::MUTED . ';">' . esc_html($stat['label']) . '</div>'
104 . '<div style="font-size:28px;font-weight:800;color:' . self::INK . ';letter-spacing:-0.02em;line-height:1.15;margin-top:2px;">'
105 . esc_html($stat['value']) . '</div>'
106 . ($stat['change'] ?? '')
107 . '</td></tr></table></td>';
108 }
109
110 /**
111 * Lay stat tiles out two per row.
112 *
113 * @param string[] $tiles Output of stat().
114 */
115 public static function stat_grid(array $tiles): string {
116 if ($tiles === []) {
117 return '';
118 }
119 $html = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:14px;">';
120 foreach (array_chunk($tiles, 2) as $pair) {
121 $html .= '<tr>' . implode('', $pair) . (count($pair) === 1 ? '<td width="50%"></td>' : '') . '</tr>';
122 }
123 return $html . '</table>';
124 }
125
126 /**
127 * Ranked list: a title, a muted line under it, and a pill on the right.
128 *
129 * @param array<int,array{title:string,subtitle?:string,pill:string,href?:string}> $rows
130 * @param string $tone 'up' or 'down' for the pills.
131 */
132 public static function list_rows(array $rows, string $tone): string {
133 if ($rows === []) {
134 return '';
135 }
136 $html = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:14px;">';
137 foreach (array_values($rows) as $i => $row) {
138 $border = $i > 0 ? 'border-top:1px solid ' . self::LINE . ';' : '';
139 $title = esc_html((string) $row['title']);
140 if (!empty($row['href'])) {
141 $title = '<a href="' . esc_url((string) $row['href']) . '" style="color:' . self::INK . ';text-decoration:none;">' . $title . '</a>';
142 }
143 $sub = '';
144 if (!empty($row['subtitle'])) {
145 $sub = '<div style="font-size:12px;color:' . self::MUTED . ';margin-top:2px;word-break:break-all;">' . esc_html((string) $row['subtitle']) . '</div>';
146 }
147 $html .= '<tr>'
148 . '<td style="padding:12px 0;' . $border . self::FONT . '">'
149 . '<div style="font-size:14px;font-weight:600;color:' . self::INK . ';line-height:1.35;word-break:break-word;">' . $title . '</div>' . $sub
150 . '</td>'
151 . '<td align="right" valign="middle" style="padding:12px 0 12px 12px;' . $border . 'white-space:nowrap;">'
152 . self::pill((string) $row['pill'], $tone)
153 . '</td></tr>';
154 }
155 return $html . '</table>';
156 }
157
158 /**
159 * One horizontal bar row: label, bar, count (share).
160 */
161 public static function bar_row(string $label, int $count, int $total, string $color): string {
162 $pct = $total > 0 ? (int) round($count / $total * 100) : 0;
163 return '<tr>'
164 . '<td style="padding:7px 0;' . self::FONT . 'font-size:13px;color:' . self::INK . ';width:120px;white-space:nowrap;">' . esc_html($label) . '</td>'
165 . '<td style="padding:7px 8px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
166 . '<td style="background:' . self::FLAT_BG . ';border-radius:6px;"><div style="width:' . $pct . '%;height:10px;border-radius:6px;background:' . $color . ';font-size:0;line-height:0;">&nbsp;</div></td>'
167 . '</tr></table></td>'
168 . '<td align="right" style="padding:7px 0;' . self::FONT . 'font-size:13px;font-weight:700;color:' . self::INK . ';width:96px;white-space:nowrap;">'
169 . esc_html(number_format_i18n($count)) . ' <span style="color:' . self::MUTED . ';font-weight:500;">(' . $pct . '%)</span></td>'
170 . '</tr>';
171 }
172
173 /**
174 * Brand-coloured "See all ›" link.
175 */
176 public static function link(string $text, string $href): string {
177 if ($href === '') {
178 return '';
179 }
180 return '<div style="margin-top:14px;"><a href="' . esc_url($href) . '" style="' . self::FONT
181 . 'font-size:14px;font-weight:600;color:' . self::PRIMARY . ';text-decoration:none;">' . esc_html($text) . ' ›</a></div>';
182 }
183
184 /**
185 * Muted note under a card's content.
186 */
187 public static function note(string $text, string $margin_top = '10px'): string {
188 return '<div style="' . self::FONT . 'font-size:13px;color:' . self::MUTED . ';margin-top:' . $margin_top . ';line-height:1.5;">'
189 . esc_html($text) . '</div>';
190 }
191
192 /**
193 * Percentage change between two totals, or null when there is no previous
194 * value to compare with (a division by zero is not "+100%").
195 *
196 * @return array{text:string,direction:string}|null
197 */
198 public static function pct_change(float $current, float $previous): ?array {
199 if ($previous <= 0.0) {
200 return null;
201 }
202 $pct = ($current - $previous) / $previous * 100;
203 if (abs($pct) < 0.05) {
204 return ['text' => '0%', 'direction' => 'flat'];
205 }
206 return [
207 'text' => number_format_i18n(abs($pct), 1) . '%',
208 'direction' => $pct > 0 ? 'up' : 'down',
209 ];
210 }
211
212 /**
213 * Signed percentage as a subject-line token: "+12.4%", "−3.1%", "".
214 */
215 public static function signed_pct(?array $change): string {
216 if ($change === null || $change['direction'] === 'flat') {
217 return '';
218 }
219 return ($change['direction'] === 'up' ? '+' : '') . $change['text'];
220 }
221
222 /**
223 * Path of a URL for a list subtitle: "/blog/post/" rather than the full
224 * address, which is the site's own and already known to the reader.
225 */
226 public static function display_path(string $url): string {
227 $path = (string) wp_parse_url($url, PHP_URL_PATH);
228 $query = (string) wp_parse_url($url, PHP_URL_QUERY);
229 if ($path === '') {
230 return $url;
231 }
232 return $path . ($query !== '' ? '?' . $query : '');
233 }
234
235 /**
236 * Deep link into the Essential SEO screen. The recipient must be logged
237 * in, but the link still lands them on the right panel.
238 */
239 public static function admin_link(string $section, string $item): string {
240 return (string) admin_url(
241 'admin.php?page=thinkrank-essential-seo&nav_section=' . rawurlencode($section) . '&nav_item=' . rawurlencode($item)
242 );
243 }
244
245 /**
246 * A page's title for a list row, when the URL belongs to this site and
247 * resolves to a post; the URL path otherwise. A row that reads "How to
248 * add a table of contents" is worth more than one that reads
249 * "/blog/gutenberg-table-of-contents/".
250 */
251 public static function page_title(string $url): string {
252 if (function_exists('url_to_postid') && function_exists('get_the_title')) {
253 $post_id = (int) url_to_postid($url);
254 if ($post_id > 0) {
255 $title = trim((string) get_the_title($post_id));
256 if ($title !== '') {
257 return $title;
258 }
259 }
260 }
261 return self::display_path($url);
262 }
263
264 /**
265 * @return array{0:string,1:string} [text colour, background]
266 */
267 private static function tone_colors(string $tone): array {
268 switch ($tone) {
269 case 'up':
270 return [self::UP, self::UP_BG];
271 case 'down':
272 return [self::DOWN, self::DOWN_BG];
273 default:
274 return [self::FLAT, self::FLAT_BG];
275 }
276 }
277 }
278