| @@ -2,13 +2,16 @@ | ||
| 2 | 2 | /** |
| 3 | 3 | * Email Report Renderer |
| 4 | 4 | * |
| 5 | 5 | * Walks the registered sections, calls collect() then render() on each, |
| 6 | - * and assembles the final HTML using templates/email-report/email.html.php. | |
| 6 | + * wraps each result in a card and assembles the final HTML using | |
| 7 | + * templates/email-report/email.html.php. | |
| 7 | 8 | * |
| 8 | - * Failure model: any single section that throws or returns empty drops to | |
| 9 | - * its fallback_html() — the whole report keeps rendering. Per PRD's | |
| 10 | - * "graceful degradation" acceptance criterion. | |
| 9 | + * Failure model: a section that throws, collects nothing or renders to | |
| 10 | + * nothing is dropped — the card is simply absent — and the rest of the | |
| 11 | + * report keeps rendering. A section may still supply fallback_html(); when | |
| 12 | + * it is non-empty it is shown in the section's card, which keeps sections | |
| 13 | + * written against 1.9 working unchanged (#742). | |
| 11 | 14 | * |
| 12 | 15 | * @package ThinkRank |
| 13 | 16 | * @subpackage SEO |
| 14 | 17 | * @since 1.9.0 |
| @@ -17,9 +20,9 @@ | ||
| 17 | 20 | declare(strict_types=1); |
| 18 | 21 | |
| 19 | 22 | namespace ThinkRank\SEO; |
| 20 | 23 | |
| 21 | -use ThinkRank\Core\Plan_Config; | |
| 24 | +use ThinkRank\SEO\Email_Report_Sections\Email_Report_Html; | |
| 22 | 25 | use Throwable; |
| 23 | 26 | |
| 24 | 27 | if (!defined('ABSPATH')) { |
| 25 | 28 | exit; |
| @@ -36,12 +39,12 @@ | ||
| 36 | 39 | |
| 37 | 40 | /** |
| 38 | 41 | * How many sections produced real data during the last render(). |
| 39 | 42 | * |
| 40 | - * A section that returns an empty payload (or renders to nothing) drops | |
| 41 | - * to its fallback_html() notice. That outcome is only known *inside* | |
| 42 | - * render_one_section(), so it is counted here for the caller to read | |
| 43 | - * back after rendering — see sections_with_data(). | |
| 43 | + * A section that returns an empty payload (or renders to nothing) is | |
| 44 | + * dropped. That outcome is only known *inside* render_one_section(), | |
| 45 | + * so it is counted here for the caller to read back after rendering — | |
| 46 | + * see sections_with_data(). | |
| 44 | 47 | */ |
| 45 | 48 | private int $sections_with_data = 0; |
| 46 | 49 | |
| 47 | 50 | public function __construct(Email_Report_Section_Registry $registry) { |
| @@ -52,12 +55,15 @@ | ||
| 52 | 55 | * Render the full HTML for a report. |
| 53 | 56 | * |
| 54 | 57 | * @param array $config Per-site config. |
| 55 | 58 | * @param array $context { |
| 56 | - * @type string $period_start ISO datetime. | |
| 57 | - * @type string $period_end ISO datetime. | |
| 58 | - * @type string $period_label Human-readable, e.g. "May 1 – May 30, 2026". | |
| 59 | - * @type bool $is_test True when called from "Send Test Email". | |
| 59 | + * @type string $period_start ISO datetime. | |
| 60 | + * @type string $period_end ISO datetime. | |
| 61 | + * @type string $period_label Human-readable, e.g. "May 1 – May 30, 2026". | |
| 62 | + * @type bool $is_test True when called from "Send Test Email". | |
| 63 | + * @type bool $not_connected True to render the "connect Search Console" | |
| 64 | + * state instead of the sections. | |
| 65 | + * @type array $readiness Data_Provider::readiness() result. | |
| 60 | 66 | * } |
| 61 | 67 | */ |
| 62 | 68 | public function render(array $config, array $context): string { |
| 63 | 69 | // Reset before walking the sections so sections_with_data() always |
| @@ -63,10 +69,8 @@ | ||
| 63 | 69 | // Reset before walking the sections so sections_with_data() always |
| 64 | 70 | // describes this render and never a previous one. |
| 65 | 71 | $this->sections_with_data = 0; |
| 66 | 72 | |
| 67 | - $caps = Plan_Config::email_report(); | |
| 68 | - | |
| 69 | 73 | $shared_context = array_merge([ |
| 70 | 74 | 'site_url' => (string) home_url(), |
| 71 | 75 | 'frequency_days' => (int) ($config['frequency_days'] ?? 30), |
| 72 | 76 | 'period_start' => '', |
| @@ -72,35 +76,43 @@ | ||
| 72 | 76 | 'period_start' => '', |
| 73 | 77 | 'period_end' => '', |
| 74 | 78 | 'period_label' => '', |
| 75 | 79 | 'is_test' => false, |
| 80 | + 'not_connected' => false, | |
| 81 | + 'readiness' => [], | |
| 76 | 82 | 'shared' => [], |
| 77 | 83 | ], $context); |
| 78 | 84 | |
| 79 | - $sections_html = $this->render_sections($config, $shared_context); | |
| 85 | + $sections_html = !empty($shared_context['not_connected']) | |
| 86 | + ? $this->render_not_connected($shared_context) | |
| 87 | + : $this->render_sections($config, $shared_context); | |
| 80 | 88 | |
| 81 | - $intro_text = empty($caps['intro_text']) ? '' : (string) ($config['intro_text'] ?? ''); | |
| 82 | - $footer_text = empty($caps['footer_text']) ? $this->default_footer() : (string) ($config['footer_text'] ?? $this->default_footer()); | |
| 89 | + $frequency_days = (int) $shared_context['frequency_days']; | |
| 83 | 90 | |
| 84 | 91 | $payload = [ |
| 85 | - 'config' => $config, | |
| 86 | - 'caps' => $caps, | |
| 87 | - 'context' => $shared_context, | |
| 88 | - 'sections_html' => $sections_html, | |
| 89 | - 'header_logo' => $this->resolve_logo($config, $caps), | |
| 90 | - 'header_bg' => empty($caps['header_background']) ? '' : (string) ($config['header_background'] ?? ''), | |
| 91 | - 'logo_link' => empty($caps['logo_link']) ? '' : (string) ($config['logo_link'] ?? ''), | |
| 92 | - 'intro_text' => $this->apply_text_tokens($intro_text, $shared_context), | |
| 93 | - 'footer_text' => $this->apply_text_tokens($footer_text, $shared_context), | |
| 94 | - 'additional_css' => empty($caps['additional_css']) ? '' : (string) ($config['additional_css'] ?? ''), | |
| 95 | - 'cta_url' => $this->resolve_cta_url($config, $caps), | |
| 96 | - 'site_title' => (string) get_bloginfo('name'), | |
| 92 | + 'config' => $config, | |
| 93 | + 'context' => $shared_context, | |
| 94 | + 'sections_html' => $sections_html, | |
| 95 | + 'header_logo' => $this->default_logo(), | |
| 96 | + 'header_bg' => '', | |
| 97 | + 'logo_link' => '', | |
| 98 | + 'intro_text' => '', | |
| 99 | + 'footer_text' => $this->default_footer($config), | |
| 100 | + 'additional_css' => '', | |
| 101 | + 'cta_url' => $this->dashboard_url(), | |
| 102 | + 'settings_url' => Email_Report_Html::admin_link('analytics', 'email-reporting'), | |
| 103 | + 'site_title' => (string) get_bloginfo('name'), | |
| 104 | + 'site_url' => (string) home_url(), | |
| 105 | + 'frequency_days' => $frequency_days, | |
| 106 | + 'frequency_label' => $this->frequency_label($frequency_days), | |
| 97 | 107 | ]; |
| 98 | 108 | |
| 99 | 109 | /** |
| 100 | 110 | * Filter the assembled payload before the layout template runs. |
| 101 | 111 | * |
| 102 | - * Pro can rewrite logo/header/footer here without touching the renderer. | |
| 112 | + * The layout's presentation slots — header logo, logo link, header | |
| 113 | + * background, intro, footer, extra CSS and the dashboard link — are | |
| 114 | + * filled here. ThinkRank Pro fills them from its branding settings. | |
| 103 | 115 | * |
| 104 | 116 | * @since 1.9.0 |
| 105 | 117 | * |
| 106 | 118 | * @param array $payload |
| @@ -108,8 +120,13 @@ | ||
| 108 | 120 | * @param array $shared_context |
| 109 | 121 | */ |
| 110 | 122 | $payload = (array) apply_filters('thinkrank_email_report_payload', $payload, $config, $shared_context); |
| 111 | 123 | |
| 124 | + // Tokens resolve after the filter, so text supplied through it gets | |
| 125 | + // the same %site_title% / %period% substitution as the defaults. | |
| 126 | + $payload['intro_text'] = $this->apply_text_tokens((string) ($payload['intro_text'] ?? ''), $shared_context); | |
| 127 | + $payload['footer_text'] = $this->apply_text_tokens((string) ($payload['footer_text'] ?? ''), $shared_context); | |
| 128 | + | |
| 112 | 129 | $layout = $this->locate_layout(); |
| 113 | 130 | if (!is_readable($layout)) { |
| 114 | 131 | return $this->emergency_fallback_html($payload); |
| 115 | 132 | } |
| @@ -145,8 +162,11 @@ | ||
| 145 | 162 | $html = ''; |
| 146 | 163 | |
| 147 | 164 | foreach ($sections as $section) { |
| 148 | 165 | $section_html = $this->render_one_section($section, $context); |
| 166 | + if ($section_html === '') { | |
| 167 | + continue; | |
| 168 | + } | |
| 149 | 169 | $html .= $this->wrap_section($section, $section_html); |
| 150 | 170 | } |
| 151 | 171 | |
| 152 | 172 | return $html; |
| @@ -155,13 +175,13 @@ | ||
| 155 | 175 | private function render_one_section($section, array $context): string { |
| 156 | 176 | try { |
| 157 | 177 | $payload = $section->collect($context); |
| 158 | 178 | if (empty($payload)) { |
| 159 | - return $section->fallback_html(); | |
| 179 | + return $this->fallback_for($section); | |
| 160 | 180 | } |
| 161 | 181 | $rendered = $section->render($payload); |
| 162 | 182 | if ($rendered === '') { |
| 163 | - return $section->fallback_html(); | |
| 183 | + return $this->fallback_for($section); | |
| 164 | 184 | } |
| 165 | 185 | |
| 166 | 186 | // A non-empty payload is not the same as data. Key Metrics |
| 167 | 187 | // collects whenever the provider reports itself "available", |
| @@ -167,20 +187,35 @@ | ||
| 167 | 187 | // collects whenever the provider reports itself "available", |
| 168 | 188 | // which it does even with no Search Console connection — and |
| 169 | 189 | // then renders a row of zeroes. That is a placeholder, not a |
| 170 | 190 | // report, so it must not make an empty send look non-empty. |
| 171 | - if ($this->payload_has_data($section, $payload)) { | |
| 172 | - $this->sections_with_data++; | |
| 191 | + if (!$this->payload_has_data($section, $payload)) { | |
| 192 | + return $this->fallback_for($section); | |
| 173 | 193 | } |
| 174 | 194 | |
| 195 | + $this->sections_with_data++; | |
| 196 | + | |
| 175 | 197 | return $rendered; |
| 176 | 198 | } catch (Throwable $e) { |
| 177 | 199 | // Don't let one section break the report. |
| 178 | - return $section->fallback_html(); | |
| 200 | + return $this->fallback_for($section); | |
| 179 | 201 | } |
| 180 | 202 | } |
| 181 | 203 | |
| 182 | 204 | /** |
| 205 | + * What a section shows when it has nothing: its fallback_html(), which | |
| 206 | + * the built-in sections leave empty so their card is dropped, and a | |
| 207 | + * section written for 1.9 may still fill with a notice. | |
| 208 | + */ | |
| 209 | + private function fallback_for($section): string { | |
| 210 | + try { | |
| 211 | + return (string) $section->fallback_html(); | |
| 212 | + } catch (Throwable $e) { | |
| 213 | + return ''; | |
| 214 | + } | |
| 215 | + } | |
| 216 | + | |
| 217 | + /** | |
| 183 | 218 | * Whether a section's payload carries anything worth reporting. |
| 184 | 219 | * |
| 185 | 220 | * A section may override the default judgement by implementing |
| 186 | 221 | * `has_data(array $payload): bool` — the interface does not require it, |
| @@ -238,54 +273,132 @@ | ||
| 238 | 273 | |
| 239 | 274 | /** |
| 240 | 275 | * How many sections produced real data in the most recent render(). |
| 241 | 276 | * |
| 242 | - * Zero means every section fell back to its "no data" notice, so the | |
| 243 | - * report is a header, a footer and a column of placeholders. Callers | |
| 244 | - * use this to decide whether that is worth sending. | |
| 277 | + * Zero means every section was dropped, so the report is a header and a | |
| 278 | + * footer with nothing between. Callers use this to decide whether that | |
| 279 | + * is worth sending. | |
| 245 | 280 | */ |
| 246 | 281 | public function sections_with_data(): int { |
| 247 | 282 | return $this->sections_with_data; |
| 248 | 283 | } |
| 249 | 284 | |
| 285 | + /** | |
| 286 | + * One white card. A section that draws its own heading (the built-in | |
| 287 | + * ones, via renders_own_heading()) gets the bare shell; any other gets | |
| 288 | + * its label as the card title, so a section written for 1.9 still reads | |
| 289 | + * as one card among the rest. | |
| 290 | + */ | |
| 250 | 291 | private function wrap_section($section, string $body_html): string { |
| 251 | - $heading = esc_html($section->label()); | |
| 252 | - return '<section class="tr-email-section" style="margin:0 0 24px 0;">' | |
| 253 | - . '<h2 style="font:600 18px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;margin:0 0 12px 0;color:#111827;">' | |
| 254 | - . $heading | |
| 255 | - . '</h2>' | |
| 256 | - . $body_html | |
| 257 | - . '</section>'; | |
| 292 | + $heading = ''; | |
| 293 | + $own = method_exists($section, 'renders_own_heading') && $section->renders_own_heading(); | |
| 294 | + if (!$own) { | |
| 295 | + $heading = Email_Report_Html::heading((string) $section->label()) . '<div style="height:14px;line-height:14px;font-size:0;"> </div>'; | |
| 296 | + } | |
| 297 | + | |
| 298 | + return self::card($heading . $body_html, 'tr-email-section tr-email-section-' . sanitize_html_class((string) $section->key())); | |
| 258 | 299 | } |
| 259 | 300 | |
| 260 | - private function resolve_logo(array $config, array $caps): string { | |
| 261 | - if (!empty($caps['custom_logo']) && !empty($config['logo_url'])) { | |
| 262 | - return (string) $config['logo_url']; | |
| 301 | + /** | |
| 302 | + * The card shell used by sections, the connect state and the layout. | |
| 303 | + */ | |
| 304 | + public static function card(string $inner_html, string $css_class = 'tr-email-section', string $padding = '24px 28px'): string { | |
| 305 | + return '<tr><td class="' . esc_attr($css_class) . '" style="padding:0 0 16px 0;">' | |
| 306 | + . '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid ' . Email_Report_Html::LINE . ';border-radius:14px;">' | |
| 307 | + . '<tr><td style="padding:' . esc_attr($padding) . ';">' . $inner_html . '</td></tr>' | |
| 308 | + . '</table></td></tr>'; | |
| 309 | + } | |
| 310 | + | |
| 311 | + /** | |
| 312 | + * The one-card email a test send delivers while Search Console is not | |
| 313 | + * connected: what the report will contain, how to connect, and that | |
| 314 | + * scheduled reports are paused until then (#742). | |
| 315 | + */ | |
| 316 | + private function render_not_connected(array $context): string { | |
| 317 | + $font = Email_Report_Html::FONT; | |
| 318 | + $site_title = (string) get_bloginfo('name'); | |
| 319 | + $connect = Email_Report_Html::admin_link('integrations', 'google-services'); | |
| 320 | + $settings = Email_Report_Html::admin_link('analytics', 'email-reporting'); | |
| 321 | + | |
| 322 | + $intro = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>' | |
| 323 | + . '<td width="56" valign="top"><img src="' . esc_url(Email_Report_Html::asset('icon-connect.png')) . '" width="52" height="52" alt="" style="display:block;border:0;border-radius:26px;" /></td>' | |
| 324 | + . '<td style="padding-left:16px;' . $font . '">' | |
| 325 | + . '<div style="font-size:20px;font-weight:800;color:' . Email_Report_Html::INK . ';letter-spacing:-0.01em;line-height:1.3;">' | |
| 326 | + . esc_html__('Connect Google Search Console to get your first report', 'thinkrank') . '</div>' | |
| 327 | + . '<div style="font-size:14px;color:' . Email_Report_Html::MUTED . ';line-height:1.6;margin-top:8px;">' | |
| 328 | + . esc_html(sprintf( | |
| 329 | + /* translators: %s: site title. */ | |
| 330 | + __('This report is built from Search Console data: clicks, impressions, growing pages and queries, and where your keywords rank. %s isn’t connected yet, so there is nothing to report.', 'thinkrank'), | |
| 331 | + $site_title | |
| 332 | + )) . '</div>' | |
| 333 | + . '<div style="font-size:14px;color:' . Email_Report_Html::MUTED . ';line-height:1.6;margin-top:8px;">' | |
| 334 | + . '<strong style="color:' . Email_Report_Html::INK . ';">' . esc_html__('Scheduled reports are paused', 'thinkrank') . '</strong> ' | |
| 335 | + . esc_html__('until Search Console is connected. Nothing will be sent to your recipients in the meantime.', 'thinkrank') . '</div>' | |
| 336 | + . '<div style="margin-top:18px;">' | |
| 337 | + . '<a href="' . esc_url($connect) . '" style="display:inline-block;background:' . Email_Report_Html::PRIMARY . ';color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;padding:12px 22px;border-radius:10px;">' | |
| 338 | + . esc_html__('Connect Search Console', 'thinkrank') . '</a>' | |
| 339 | + . ' <a href="' . esc_url($settings) . '" style="font-size:14px;font-weight:600;color:' . Email_Report_Html::PRIMARY . ';text-decoration:none;">' | |
| 340 | + . esc_html__('Report settings', 'thinkrank') . ' ›</a></div>' | |
| 341 | + . '</td></tr></table>'; | |
| 342 | + | |
| 343 | + $items = [ | |
| 344 | + __('Clicks, impressions, CTR and average position, with the change vs the previous period', 'thinkrank'), | |
| 345 | + __('Top growing pages and queries, and the ones losing ground', 'thinkrank'), | |
| 346 | + __('Where your keywords rank: top 3, page one, page two and beyond', 'thinkrank'), | |
| 347 | + __('Site traffic from Google Analytics 4 and visits from AI assistants, when those are connected', 'thinkrank'), | |
| 348 | + ]; | |
| 349 | + $list = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:12px;' . $font . 'font-size:14px;color:' . Email_Report_Html::INK . ';line-height:1.6;">'; | |
| 350 | + foreach ($items as $item) { | |
| 351 | + $list .= '<tr><td width="22" valign="top" style="padding:6px 0;color:' . Email_Report_Html::UP . ';font-weight:700;">✓</td>' | |
| 352 | + . '<td style="padding:6px 0;">' . esc_html($item) . '</td></tr>'; | |
| 263 | 353 | } |
| 264 | - // No bundled default logo asset yet — return empty so the layout | |
| 265 | - // falls back to a text-rendered site title in the header. Pro | |
| 266 | - // (or a follow-up) can ship a real PNG and wire it via this filter. | |
| 354 | + $list .= '</table>'; | |
| 355 | + | |
| 356 | + return self::card($intro, 'tr-email-section tr-email-connect', '28px') | |
| 357 | + . self::card(Email_Report_Html::heading(__('What you’ll get once connected', 'thinkrank')) . $list, 'tr-email-section tr-email-connect-list'); | |
| 358 | + } | |
| 359 | + | |
| 360 | + private function default_logo(): string { | |
| 267 | 361 | /** |
| 268 | 362 | * Filter the default email logo URL. |
| 269 | 363 | * |
| 364 | + * The bundled ThinkRank lockup by default (#742). Pro's branding | |
| 365 | + * logo replaces it through the payload filter, not here. | |
| 366 | + * | |
| 270 | 367 | * @since 1.9.0 |
| 271 | 368 | * |
| 272 | - * @param string $default Default logo URL — empty by default. | |
| 369 | + * @param string $default Default logo URL. | |
| 273 | 370 | */ |
| 274 | - return (string) apply_filters('thinkrank_email_report_default_logo', ''); | |
| 371 | + return (string) apply_filters('thinkrank_email_report_default_logo', Email_Report_Html::asset('thinkrank-logo.png')); | |
| 275 | 372 | } |
| 276 | 373 | |
| 277 | - private function resolve_cta_url(array $config, array $caps): string { | |
| 278 | - if (empty($config['link_to_full_report'])) { | |
| 279 | - return ''; | |
| 374 | + private function dashboard_url(): string { | |
| 375 | + return Email_Report_Html::admin_link('analytics', 'dashboard'); | |
| 376 | + } | |
| 377 | + | |
| 378 | + /** | |
| 379 | + * "Monthly SEO report" / "Weekly SEO report" / "SEO report every N days" | |
| 380 | + * for the header's right-hand label. | |
| 381 | + */ | |
| 382 | + private function frequency_label(int $days): string { | |
| 383 | + if ($days === 1) { | |
| 384 | + return __('Daily SEO report', 'thinkrank'); | |
| 280 | 385 | } |
| 281 | - // The dashboard analytics view URL — admin-side. The recipient must | |
| 282 | - // be logged in to see it, but the link still gives them a clear path. | |
| 283 | - return (string) admin_url('admin.php?page=thinkrank-essential-seo#analytics'); | |
| 386 | + if ($days === 7) { | |
| 387 | + return __('Weekly SEO report', 'thinkrank'); | |
| 388 | + } | |
| 389 | + if ($days >= 28 && $days <= 31) { | |
| 390 | + return __('Monthly SEO report', 'thinkrank'); | |
| 391 | + } | |
| 392 | + return sprintf( | |
| 393 | + /* translators: %d: number of days between reports. */ | |
| 394 | + _n('SEO report every %d day', 'SEO report every %d days', $days, 'thinkrank'), | |
| 395 | + $days | |
| 396 | + ); | |
| 284 | 397 | } |
| 285 | 398 | |
| 286 | 399 | /** |
| 287 | - * Substitute the documented %tokens% (see thinkrank_get_email_report_tokens) | |
| 400 | + * Substitute %site_title%, %site_url%, %date% and %period% | |
| 288 | 401 | * in free-text fields like the intro and footer. Previously only the subject |
| 289 | 402 | * line ran token substitution, so these tokens rendered literally in the body. |
| 290 | 403 | * |
| 291 | 404 | * @param string $text Raw text, possibly containing %tokens%. |
| @@ -319,14 +432,40 @@ | ||
| 319 | 432 | |
| 320 | 433 | return strtr($text, $tokens); |
| 321 | 434 | } |
| 322 | 435 | |
| 323 | - private function default_footer(): string { | |
| 324 | - return sprintf( | |
| 325 | - /* translators: %s: site title */ | |
| 326 | - esc_html__('This report was generated by ThinkRank for %s.', 'thinkrank'), | |
| 327 | - esc_html((string) get_bloginfo('name')) | |
| 436 | + /** | |
| 437 | + * "Sent by ThinkRank for example.com · every 30 days to a@x.com, b@x.com" | |
| 438 | + */ | |
| 439 | + private function default_footer(array $config): string { | |
| 440 | + $days = max(1, (int) ($config['frequency_days'] ?? 30)); | |
| 441 | + $recipients = array_values(array_filter((array) ($config['recipients'] ?? []), 'is_string')); | |
| 442 | + $host = (string) wp_parse_url((string) home_url(), PHP_URL_HOST); | |
| 443 | + | |
| 444 | + $line = sprintf( | |
| 445 | + /* translators: %s: site host name. */ | |
| 446 | + __('Sent by ThinkRank for %s', 'thinkrank'), | |
| 447 | + $host !== '' ? $host : (string) get_bloginfo('name') | |
| 328 | 448 | ); |
| 449 | + $cadence = $days === 1 | |
| 450 | + ? __('every day', 'thinkrank') | |
| 451 | + : sprintf( | |
| 452 | + /* translators: %d: number of days between reports. */ | |
| 453 | + _n('every %d day', 'every %d days', $days, 'thinkrank'), | |
| 454 | + $days | |
| 455 | + ); | |
| 456 | + if ($recipients !== []) { | |
| 457 | + $line .= ' · ' . sprintf( | |
| 458 | + /* translators: 1: cadence ("every 30 days"), 2: recipient list. */ | |
| 459 | + __('%1$s to %2$s', 'thinkrank'), | |
| 460 | + $cadence, | |
| 461 | + implode(', ', array_slice($recipients, 0, 3)) . (count($recipients) > 3 ? '…' : '') | |
| 462 | + ); | |
| 463 | + } else { | |
| 464 | + $line .= ' · ' . $cadence; | |
| 465 | + } | |
| 466 | + | |
| 467 | + return esc_html($line); | |
| 329 | 468 | } |
| 330 | 469 | |
| 331 | 470 | private function locate_layout(): string { |
| 332 | 471 | return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php'; |
| @@ -339,11 +478,11 @@ | ||
| 339 | 478 | */ |
| 340 | 479 | private function emergency_fallback_html(array $payload): string { |
| 341 | 480 | $title = esc_html($payload['site_title'] ?? ''); |
| 342 | 481 | $sections = $payload['sections_html'] ?? ''; |
| 343 | - return '<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#f4f4f5;padding:24px;">' | |
| 344 | - . '<div style="max-width:640px;margin:0 auto;background:#fff;padding:24px;border-radius:8px;">' | |
| 345 | - . '<h1 style="margin:0 0 16px 0;font-size:22px;color:#111827;">' . $title . '</h1>' | |
| 482 | + return '<!doctype html><html><body style="' . Email_Report_Html::FONT . 'background:' . Email_Report_Html::CANVAS . ';padding:24px;">' | |
| 483 | + . '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;">' | |
| 484 | + . '<tr><td style="padding:0 0 16px 0;"><h1 style="margin:0;font-size:22px;color:' . Email_Report_Html::INK . ';">' . $title . '</h1></td></tr>' | |
| 346 | 485 | . $sections |
| 347 | - . '</div></body></html>'; | |
| 486 | + . '</table></body></html>'; | |
| 348 | 487 | } |
| 349 | 488 | } |