| 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;"> </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 |
|