PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / emails / email-reports.php

email-reports.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.1, at inc/emails/email-reports.php

629 lines 26.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Reports.
4 *
5 * The weekly donation digest: owns its settings (option key, defaults,
6 * sanitiser), the WP-Cron schedule that sends it, and the report email
7 * itself. Global Settings → General Settings → Email Reports.
8 *
9 * @package SureDonation
10 * @since 1.6.1
11 */
12
13 namespace SureDonation\Inc\Emails;
14
15 use DateTimeImmutable;
16 use DateTimeZone;
17 use SureDonation\Inc\Database\Tables\Donations;
18 use SureDonation\Inc\Helper;
19 use SureDonation\Inc\Payments\Payment_Helper;
20 use SureDonation\Inc\Traits\Get_Instance;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit; // Exit if accessed directly.
24 }
25
26 /**
27 * Email_Reports class.
28 *
29 * @since 1.6.1
30 */
31 class Email_Reports {
32 use Get_Instance;
33
34 /**
35 * Key within the consolidated suredonation_options array.
36 *
37 * @since 1.6.1
38 */
39 public const OPTION_KEY = 'email_reports';
40
41 /**
42 * Cron hook the weekly send runs on.
43 *
44 * @since 1.6.1
45 */
46 public const CRON_HOOK = 'suredonation_send_email_report';
47
48 /**
49 * Allowed send days. Stored lowercase English; the UI translates labels.
50 *
51 * @since 1.6.1
52 */
53 public const DAYS = [ 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' ];
54
55 /**
56 * Send time in the site timezone (HH:MM:SS).
57 *
58 * @since 1.6.1
59 */
60 public const DEFAULT_TIME = '09:00:00';
61
62 /**
63 * How many campaigns each list in the digest shows.
64 *
65 * @since 1.6.1
66 */
67 private const LIST_LIMIT = 5;
68
69 /**
70 * Constructor.
71 *
72 * @since 1.6.1
73 */
74 public function __construct() {
75 add_action( self::CRON_HOOK, [ $this, 'send_scheduled_report' ] );
76 add_action( 'admin_init', [ $this, 'ensure_scheduled' ] );
77 }
78
79 /**
80 * Default settings.
81 *
82 * @since 1.6.1
83 * @return array{enabled: bool, recipients: string, day: string}
84 */
85 public static function get_defaults() {
86 return [
87 'enabled' => false,
88 'recipients' => Helper::get_string_value( get_option( 'admin_email' ) ),
89 'day' => 'monday',
90 ];
91 }
92
93 /**
94 * Stored settings merged over the defaults.
95 *
96 * @since 1.6.1
97 * @return array{enabled: bool, recipients: string, day: string}
98 */
99 public static function get_settings() {
100 $stored = Helper::get_suredonation_option( self::OPTION_KEY, [] );
101 $settings = wp_parse_args( is_array( $stored ) ? $stored : [], self::get_defaults() );
102
103 return [
104 'enabled' => ! empty( $settings['enabled'] ),
105 'recipients' => Helper::get_string_value( $settings['recipients'] ?? '' ),
106 'day' => Helper::get_string_value( $settings['day'] ?? 'monday' ),
107 ];
108 }
109
110 /**
111 * Normalise a comma-separated recipients string into deliverable addresses.
112 *
113 * The single path every recipient takes — on save, on the scheduled send
114 * and on a test send — so an address that cannot reach wp_mail() headers
115 * intact never reaches them at all. Line breaks are stripped, invalid
116 * addresses dropped, duplicates collapsed.
117 *
118 * @since 1.6.1
119 * @param mixed $raw Comma-separated string (anything else yields []).
120 * @return array<int, string>
121 */
122 public static function parse_recipients( $raw ) {
123 if ( ! is_string( $raw ) ) {
124 return [];
125 }
126
127 $recipients = [];
128 foreach ( explode( ',', $raw ) as $candidate ) {
129 $candidate = trim( str_replace( [ "\r", "\n" ], '', $candidate ) );
130 if ( '' === $candidate || ! is_email( $candidate ) ) {
131 continue;
132 }
133 // First spelling wins; later case-variants of the same address are dropped.
134 $key = strtolower( $candidate );
135 if ( ! isset( $recipients[ $key ] ) ) {
136 $recipients[ $key ] = $candidate;
137 }
138 }
139
140 return array_values( $recipients );
141 }
142
143 /**
144 * Sanitize a raw settings payload against the schema.
145 *
146 * Unknown days fall back to Monday, and "enabled" cannot survive without
147 * at least one deliverable recipient.
148 *
149 * @since 1.6.1
150 * @param mixed $raw Raw values (e.g. from a REST request).
151 * @return array{enabled: bool, recipients: string, day: string}
152 */
153 public static function sanitize( $raw ) {
154 $raw = is_array( $raw ) ? $raw : [];
155
156 $recipients = self::parse_recipients( $raw['recipients'] ?? '' );
157
158 $day = isset( $raw['day'] ) ? strtolower( sanitize_key( Helper::get_string_value( $raw['day'] ) ) ) : 'monday';
159 if ( ! in_array( $day, self::DAYS, true ) ) {
160 $day = 'monday';
161 }
162
163 // Accepts real booleans and the form-encoded "true"/"false"/"1"/"0" strings.
164 $enabled = filter_var( $raw['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN );
165
166 return [
167 'enabled' => $enabled && [] !== $recipients,
168 'recipients' => implode( ', ', $recipients ),
169 'day' => $day,
170 ];
171 }
172
173 /**
174 * Sanitize, store and (re)schedule.
175 *
176 * @since 1.6.1
177 * @param mixed $raw Raw values.
178 * @return array{enabled: bool, recipients: string, day: string} The stored settings.
179 */
180 public static function save( $raw ) {
181 $settings = self::sanitize( $raw );
182 Helper::update_suredonation_option( self::OPTION_KEY, $settings );
183 self::reschedule( $settings );
184 return $settings;
185 }
186
187 /**
188 * Replace the scheduled send with one matching the given settings.
189 *
190 * A single event, re-armed by send_scheduled_report() after every run,
191 * rather than a `weekly` recurrence: a fixed 604800-second interval keeps
192 * the UTC instant and lets the local wall-clock time drift by an hour at
193 * each DST change, while the settings screen promises 09:00 site time.
194 * Computing the next occurrence from scratch each week is correct by
195 * construction, and it also survives a timezone change.
196 *
197 * @since 1.6.1
198 * @param array<string, mixed> $settings Sanitized settings.
199 * @return void
200 */
201 public static function reschedule( $settings ) {
202 wp_clear_scheduled_hook( self::CRON_HOOK );
203
204 if ( empty( $settings['enabled'] ) ) {
205 return;
206 }
207
208 $day = isset( $settings['day'] ) ? Helper::get_string_value( $settings['day'] ) : 'monday';
209 wp_schedule_single_event( self::next_run_timestamp( $day ), self::CRON_HOOK );
210 }
211
212 /**
213 * Unix timestamp of the next scheduled send, or null when none is armed.
214 *
215 * Exposed to the settings screen so an admin can see that a report is
216 * actually queued; on a host where WP-Cron never runs the absence of an
217 * event is the only visible symptom.
218 *
219 * @since 1.6.1
220 * @return int|null
221 */
222 public static function next_run() {
223 $next = wp_next_scheduled( self::CRON_HOOK );
224
225 return false === $next ? null : (int) $next;
226 }
227
228 /**
229 * Unix timestamp of the next send: the coming $day at the send time, in the
230 * site timezone. If that moment has already passed today, one week on.
231 *
232 * @since 1.6.1
233 * @param string $day One of self::DAYS (anything else is treated as Monday).
234 * @return int
235 */
236 public static function next_run_timestamp( $day ) {
237 $day = in_array( $day, self::DAYS, true ) ? $day : 'monday';
238
239 /**
240 * Filters the time of day the weekly report is sent, in the site timezone.
241 *
242 * @since 1.6.1
243 * @param string $time HH:MM:SS. Default 09:00:00.
244 */
245 $time = apply_filters( 'suredonation_email_report_time', self::DEFAULT_TIME );
246 if ( ! is_string( $time ) || ! preg_match( '/^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/', $time ) ) {
247 $time = self::DEFAULT_TIME;
248 }
249
250 // "friday 09:00:00" resolves to the coming Friday (today, if today is
251 // Friday). $day is whitelisted English, so the site locale cannot break it.
252 $next = new DateTimeImmutable( "{$day} {$time}", wp_timezone() );
253 if ( $next->getTimestamp() <= time() ) {
254 $next = $next->modify( '+1 week' );
255 }
256
257 return $next->getTimestamp();
258 }
259
260 /**
261 * Re-create the schedule when it is enabled but missing (cleared cron
262 * array, in-place upgrade). Admin screens only; ajax and cron requests
263 * must not pay for the option read.
264 *
265 * @since 1.6.1
266 * @return void
267 */
268 public function ensure_scheduled() {
269 if ( wp_doing_ajax() || wp_doing_cron() || ! current_user_can( 'manage_options' ) ) {
270 return;
271 }
272
273 $settings = self::get_settings();
274 if ( empty( $settings['enabled'] ) || false !== wp_next_scheduled( self::CRON_HOOK ) ) {
275 return;
276 }
277
278 self::reschedule( $settings );
279 }
280
281 /**
282 * Cron callback. Reads the stored settings rather than trusting the event,
283 * so a disabled report never sends even if an event survives.
284 *
285 * The next event is armed before anything else, so a report that is
286 * skipped or fails to send still runs again next week. A quiet week is
287 * sent (the admin learns the report is alive, and the quiet-campaigns
288 * section is most useful then); a site in test mode is not, because the
289 * plugin treats test donations as non-revenue everywhere else.
290 *
291 * @since 1.6.1
292 * @return void
293 */
294 public function send_scheduled_report() {
295 $settings = self::get_settings();
296 if ( empty( $settings['enabled'] ) ) {
297 return;
298 }
299
300 self::reschedule( $settings );
301
302 $recipients = self::parse_recipients( $settings['recipients'] );
303 if ( [] === $recipients ) {
304 return;
305 }
306
307 if ( 'live' !== Payment_Helper::get_payment_mode() ) {
308 return;
309 }
310
311 self::send_report( $recipients, false );
312 }
313
314 /**
315 * Build and send the weekly report.
316 *
317 * Built under the site locale whether this runs in an admin request (the
318 * test send, in the admin's own language) or under cron, so the preview is
319 * what recipients get.
320 *
321 * @since 1.6.1
322 * @param array<int, string> $recipients Addresses; re-validated here.
323 * @param bool $is_test A test send from the settings screen: goes out in test mode too, and says it is a test.
324 * @return bool wp_mail() result, or false when nothing was sent.
325 */
326 public static function send_report( array $recipients, $is_test = false ) {
327 $recipients = self::parse_recipients( implode( ',', $recipients ) );
328 if ( [] === $recipients ) {
329 return false;
330 }
331
332 $switched = switch_to_locale( get_locale() );
333
334 $currency = Payment_Helper::get_currency();
335 $payment_mode = Payment_Helper::get_payment_mode();
336 $report = self::collect_report_data( time(), $currency, $payment_mode );
337
338 $date_format = Helper::get_string_value( get_option( 'date_format' ) );
339 $subject = sprintf(
340 /* translators: 1: start date, 2: end date. */
341 __( 'Your weekly donation report: %1$s to %2$s', 'suredonation' ),
342 wp_date( $date_format, $report['after_ts'] ),
343 wp_date( $date_format, $report['now_ts'] )
344 );
345 $subject = str_replace( [ "\r", "\n" ], '', $subject );
346 $message = self::build_report_html( $report, $currency, $payment_mode, $is_test );
347
348 if ( $switched ) {
349 restore_previous_locale();
350 }
351
352 $headers = [ 'Content-Type: text/html; charset=UTF-8' ];
353 $admin_email = get_option( 'admin_email' );
354 if ( is_string( $admin_email ) && is_email( $admin_email ) ) {
355 $from_name = str_replace( [ "\r", "\n" ], '', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) );
356 $headers[] = sprintf( 'From: %s <%s>', $from_name, $admin_email );
357 }
358
359 return (bool) wp_mail( $recipients, $subject, $message, $headers );
360 }
361
362 /**
363 * Gather every figure the report prints, for the 7 days ending at $now_ts.
364 *
365 * Windows are computed in the site timezone and queried in GMT, matching
366 * how donations.created_at is stored. The current week has no upper
367 * bound so a donation landing this second is never lost to a boundary.
368 *
369 * @since 1.6.1
370 * @param int $now_ts Unix timestamp the report is generated at.
371 * @param string $currency Currency code to scope to.
372 * @param string $payment_mode 'test' or 'live'.
373 * @return array{after_ts: int, now_ts: int, week: array{raised: float, donations: int}, previous_week: array{raised: float, donations: int}, month: float, last_30_days: float, year: float, top_campaigns: array<int, array<string, mixed>>, quiet_campaigns: array<int, array<string, mixed>>}
374 */
375 public static function collect_report_data( $now_ts, $currency, $payment_mode ) {
376 $now_ts = (int) $now_ts;
377 $after_ts = $now_ts - WEEK_IN_SECONDS;
378 $after = gmdate( 'Y-m-d H:i:s', $after_ts );
379
380 $utc = new DateTimeZone( 'UTC' );
381 $now_local = ( new DateTimeImmutable( '@' . $now_ts ) )->setTimezone( wp_timezone() );
382 $month = $now_local->modify( 'first day of this month 00:00:00' )->setTimezone( $utc )->format( 'Y-m-d H:i:s' );
383 $year = $now_local->setDate( (int) $now_local->format( 'Y' ), 1, 1 )->setTime( 0, 0, 0 )->setTimezone( $utc )->format( 'Y-m-d H:i:s' );
384
385 $week = Donations::get_dashboard_stats( $currency, $payment_mode, $after );
386 $previous = Donations::get_dashboard_stats( $currency, $payment_mode, gmdate( 'Y-m-d H:i:s', $after_ts - WEEK_IN_SECONDS ), $after );
387
388 return [
389 'after_ts' => $after_ts,
390 'now_ts' => $now_ts,
391 'week' => [
392 'raised' => Helper::get_float_value( $week['total_raised'] ),
393 'donations' => Helper::get_integer_value( $week['total_donations'] ),
394 ],
395 'previous_week' => [
396 'raised' => Helper::get_float_value( $previous['total_raised'] ),
397 'donations' => Helper::get_integer_value( $previous['total_donations'] ),
398 ],
399 'month' => Helper::get_float_value( Donations::get_dashboard_stats( $currency, $payment_mode, $month )['total_raised'] ),
400 'last_30_days' => Helper::get_float_value( Donations::get_dashboard_stats( $currency, $payment_mode, gmdate( 'Y-m-d H:i:s', $now_ts - 30 * DAY_IN_SECONDS ) )['total_raised'] ),
401 'year' => Helper::get_float_value( Donations::get_dashboard_stats( $currency, $payment_mode, $year )['total_raised'] ),
402 'top_campaigns' => Donations::get_top_campaigns( self::LIST_LIMIT, $currency, $payment_mode, $after ),
403 // Quietness is not currency-specific: a campaign that took gifts in
404 // a currency the store has since moved away from is not "never
405 // donated". Scoped by mode only.
406 'quiet_campaigns' => Donations::get_stale_campaigns( $after, self::LIST_LIMIT, '', $payment_mode ),
407 ];
408 }
409
410 /**
411 * Render the report inside the shared email shell.
412 *
413 * Styled after the donation confirmation's success box and receipt card
414 * (src/blocks/styles/_common.scss): teal panel, white pill badge, white
415 * receipt cards with label/value rows and status-style badges. Everything
416 * is inline because mail clients drop stylesheets.
417 *
418 * Uses Email_Template::get_header()/get_footer() directly: render() runs
419 * nl2br() over the body, which litters table markup with <br>s.
420 *
421 * @since 1.6.1
422 * @param array<string, mixed> $report Output of collect_report_data().
423 * @param string $currency Currency code the figures are scoped to and formatted in.
424 * @param string $payment_mode 'live' or 'test'; anything but live is labelled as test figures.
425 * @param bool $is_test Whether this is a test send from the settings screen.
426 * @return string Complete HTML document.
427 */
428 public static function build_report_html( $report, $currency, $payment_mode = 'live', $is_test = false ) {
429 $date_format = Helper::get_string_value( get_option( 'date_format' ) );
430 $after_ts = Helper::get_integer_value( $report['after_ts'] ?? 0 );
431 $now_ts = Helper::get_integer_value( $report['now_ts'] ?? time() );
432
433 $week = isset( $report['week'] ) && is_array( $report['week'] ) ? $report['week'] : [];
434 $previous = isset( $report['previous_week'] ) && is_array( $report['previous_week'] ) ? $report['previous_week'] : [];
435
436 $week_raised = Helper::get_float_value( $week['raised'] ?? 0 );
437 $week_count = Helper::get_integer_value( $week['donations'] ?? 0 );
438 $previous_count = Helper::get_integer_value( $previous['donations'] ?? 0 );
439 $delta = $week_count - $previous_count;
440
441 if ( $delta > 0 ) {
442 $delta_color = '#15803d';
443 /* translators: %s: number of donations. */
444 $delta_text = '&#9650; ' . sprintf( _n( '+%s donation compared to last week', '+%s donations compared to last week', $delta, 'suredonation' ), number_format_i18n( $delta ) );
445 } elseif ( $delta < 0 ) {
446 $delta_color = '#b91c1c';
447 /* translators: %s: number of donations. */
448 $delta_text = '&#9660; ' . sprintf( _n( '-%s donation compared to last week', '-%s donations compared to last week', abs( $delta ), 'suredonation' ), number_format_i18n( abs( $delta ) ) );
449 } else {
450 $delta_color = '#4b5563';
451 $delta_text = __( 'Same number of donations as last week', 'suredonation' );
452 }
453
454 $totals = [
455 [ __( 'Raised this month', 'suredonation' ), Payment_Helper::format_amount( Helper::get_float_value( $report['month'] ?? 0 ), $currency ) ],
456 [ __( 'Raised in the past 30 days', 'suredonation' ), Payment_Helper::format_amount( Helper::get_float_value( $report['last_30_days'] ?? 0 ), $currency ) ],
457 [ __( 'Raised this year', 'suredonation' ), Payment_Helper::format_amount( Helper::get_float_value( $report['year'] ?? 0 ), $currency ) ],
458 ];
459
460 $top_campaigns = isset( $report['top_campaigns'] ) && is_array( $report['top_campaigns'] ) ? $report['top_campaigns'] : [];
461 $quiet_campaigns = isset( $report['quiet_campaigns'] ) && is_array( $report['quiet_campaigns'] ) ? $report['quiet_campaigns'] : [];
462
463 $site_name = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );
464 $donations_url = admin_url( 'admin.php?page=suredonation#/donations' );
465 $settings_url = admin_url( 'admin.php?page=suredonation#/settings?tab=general&subpage=email-reports' );
466 $is_live = 'live' === $payment_mode;
467 $currency_code = strtoupper( Helper::get_string_value( $currency ) );
468
469 // What the figures cover, said in the email: the Dashboard screen sums
470 // every currency and both modes, the Reports screen scopes by both,
471 // and this report follows Reports. Without the line the admin has no
472 // way to reconcile the two.
473 $scope_text = $is_live
474 /* translators: %s: currency code, e.g. GBP. */
475 ? sprintf( __( 'Figures cover live donations in %s.', 'suredonation' ), $currency_code )
476 /* translators: %s: currency code, e.g. GBP. */
477 : sprintf( __( 'Test mode: these figures come from test donations in %s. No real money was processed.', 'suredonation' ), $currency_code );
478
479 $campaign_url = static function ( $campaign ) {
480 $id = absint( Helper::get_string_value( $campaign['campaign_id'] ?? 0 ) );
481
482 return $id > 0 ? admin_url( 'admin.php?page=suredonation#/campaigns/' . $id ) : '';
483 };
484
485 // Receipt-card primitives, shared by the three cards below.
486 $card_style = 'border: 1px solid #e5e7eb; border-radius: 12px; padding: 8px 24px; background-color: #ffffff; color: #1f2937;';
487 $title_style = 'margin: 0; padding: 16px 0; font-size: 16px; line-height: 24px; font-weight: 600; color: #1f2937;';
488 $row_style = 'padding: 14px 0; border-top: 1px solid #e5e7eb; font-size: 14px; line-height: 20px;';
489 $label_style = 'color: #6b7280;';
490 $value_style = 'color: #1f2937; text-align: right;';
491 $badge_style = 'display: inline-block; padding: 3px 12px; border-radius: 9999px; font-size: 13px; line-height: 18px; font-weight: 500; white-space: nowrap;';
492
493 ob_start();
494 ?>
495 <table border="0" cellpadding="0" cellspacing="0" width="100%" style="border-radius: 16px; background-color: #0f6e8c;">
496 <tbody>
497 <tr>
498 <td align="center" style="padding: 32px 24px; color: #ffffff;">
499 <span style="display: inline-block; padding: 4px 14px; margin: 0 0 16px; border-radius: 9999px; background-color: #ffffff; color: #15803d; font-size: 13px; line-height: 18px; font-weight: 600;"><?php esc_html_e( 'Weekly Report', 'suredonation' ); ?></span>
500 <h1 style="margin: 0 0 8px; font-size: 20px; line-height: 28px; font-weight: 600; color: #ffffff;"><?php esc_html_e( 'Your week in donations', 'suredonation' ); ?></h1>
501 <p style="margin: 0; font-size: 14px; line-height: 22px; color: rgba(255, 255, 255, 0.85);"><?php echo esc_html( wp_date( $date_format, $after_ts ) . ' – ' . wp_date( $date_format, $now_ts ) ); ?></p>
502 <p style="margin: 24px 0 4px; font-size: 40px; line-height: 48px; font-weight: 700; color: #ffffff;"><?php echo esc_html( Payment_Helper::format_amount( $week_raised, $currency ) ); ?></p>
503 <p style="margin: 0 0 16px; font-size: 15px; line-height: 22px; color: rgba(255, 255, 255, 0.85);">
504 <?php
505 /* translators: %s: number of donations. */
506 echo esc_html( sprintf( _n( '%s donation this week', '%s donations this week', $week_count, 'suredonation' ), number_format_i18n( $week_count ) ) );
507 ?>
508 </p>
509 <span style="display: inline-block; padding: 4px 14px; border-radius: 9999px; background-color: #ffffff; color: <?php echo esc_attr( $delta_color ); ?>; font-size: 13px; line-height: 18px; font-weight: 600;"><?php echo wp_kses( $delta_text, [] ); ?></span>
510 <?php if ( $is_live ) { ?>
511 <p style="margin: 16px 0 0; font-size: 13px; line-height: 18px; color: rgba(255, 255, 255, 0.85);"><?php echo esc_html( $scope_text ); ?></p>
512 <?php } else { ?>
513 <p style="margin: 16px 0 0; padding: 8px 12px; border-radius: 8px; background-color: #fef9c3; font-size: 13px; line-height: 18px; font-weight: 600; color: #a16207;"><?php echo esc_html( $scope_text ); ?></p>
514 <?php } ?>
515 </td>
516 </tr>
517 </tbody>
518 </table>
519
520 <div style="margin: 24px 0 0; <?php echo esc_attr( $card_style ); ?>">
521 <h3 style="<?php echo esc_attr( $title_style ); ?>"><?php esc_html_e( 'Totals', 'suredonation' ); ?></h3>
522 <table border="0" cellpadding="0" cellspacing="0" width="100%">
523 <tbody>
524 <?php foreach ( $totals as $total ) { ?>
525 <tr>
526 <td style="<?php echo esc_attr( $row_style . $label_style ); ?>"><?php echo esc_html( $total[0] ); ?></td>
527 <td style="<?php echo esc_attr( $row_style . $value_style ); ?> font-weight: 700;"><?php echo esc_html( $total[1] ); ?></td>
528 </tr>
529 <?php } ?>
530 </tbody>
531 </table>
532 </div>
533
534 <div style="margin: 16px 0 0; <?php echo esc_attr( $card_style ); ?>">
535 <h3 style="<?php echo esc_attr( $title_style ); ?>"><?php esc_html_e( 'Best performing campaigns this week', 'suredonation' ); ?></h3>
536 <table border="0" cellpadding="0" cellspacing="0" width="100%">
537 <tbody>
538 <?php if ( [] === $top_campaigns ) { ?>
539 <tr>
540 <td style="<?php echo esc_attr( $row_style . $label_style ); ?>"><?php esc_html_e( 'No campaign received a donation this week.', 'suredonation' ); ?></td>
541 </tr>
542 <?php } ?>
543 <?php
544 foreach ( $top_campaigns as $campaign ) {
545 $count = Helper::get_integer_value( $campaign['donation_count'] ?? 0 );
546 ?>
547 <tr>
548 <td style="<?php echo esc_attr( $row_style ); ?>">
549 <a href="<?php echo esc_url( $campaign_url( $campaign ) ); ?>" style="display: block; color: #1f2937; font-weight: 600; text-decoration: none;"><?php echo esc_html( Helper::get_string_value( $campaign['campaign_title'] ?? '' ) ); ?></a>
550 <span style="display: block; color: #6b7280; font-size: 13px;">
551 <?php
552 /* translators: %s: number of donations. */
553 echo esc_html( sprintf( _n( '%s donation', '%s donations', $count, 'suredonation' ), number_format_i18n( $count ) ) );
554 ?>
555 </span>
556 </td>
557 <td valign="top" style="<?php echo esc_attr( $row_style . $value_style ); ?> font-weight: 700;"><?php echo esc_html( Payment_Helper::format_amount( Helper::get_float_value( $campaign['total_raised'] ?? 0 ), $currency ) ); ?></td>
558 </tr>
559 <?php } ?>
560 </tbody>
561 </table>
562 </div>
563
564 <?php if ( [] !== $quiet_campaigns ) { ?>
565 <div style="margin: 16px 0 0; <?php echo esc_attr( $card_style ); ?>">
566 <h3 style="<?php echo esc_attr( $title_style ); ?>"><?php esc_html_e( 'Campaigns that have not received a donation this week', 'suredonation' ); ?></h3>
567 <table border="0" cellpadding="0" cellspacing="0" width="100%">
568 <tbody>
569 <?php
570 foreach ( $quiet_campaigns as $campaign ) {
571 $last_at = isset( $campaign['last_donation_at'] ) && is_string( $campaign['last_donation_at'] ) ? strtotime( $campaign['last_donation_at'] ) : false;
572 if ( false === $last_at ) {
573 $badge = __( 'No donations yet', 'suredonation' );
574 $badge_tone = 'background-color: #f3f4f6; color: #4b5563;';
575 $detail = '';
576 } else {
577 // Exact days up to two months; human_time_diff() would round 10 days to "1 week".
578 $days = (int) floor( max( 0, $now_ts - $last_at ) / DAY_IN_SECONDS );
579 if ( $days < 60 ) {
580 /* translators: %s: number of days. */
581 $badge = sprintf( _n( 'Last donation %s day ago', 'Last donation %s days ago', $days, 'suredonation' ), number_format_i18n( $days ) );
582 } else {
583 /* translators: %s: human-readable time span, e.g. "3 months". */
584 $badge = sprintf( __( 'Last donation %s ago', 'suredonation' ), human_time_diff( $last_at, $now_ts ) );
585 }
586 $badge_tone = 'background-color: #fef9c3; color: #a16207;';
587 $detail = Helper::get_string_value( wp_date( $date_format, $last_at ) );
588 }
589 ?>
590 <tr>
591 <td style="<?php echo esc_attr( $row_style ); ?>">
592 <a href="<?php echo esc_url( $campaign_url( $campaign ) ); ?>" style="display: block; color: #1f2937; font-weight: 600; text-decoration: none;"><?php echo esc_html( Helper::get_string_value( $campaign['campaign_title'] ?? '' ) ); ?></a>
593 <?php if ( '' !== $detail ) { ?>
594 <span style="display: block; color: #6b7280; font-size: 13px;"><?php echo esc_html( $detail ); ?></span>
595 <?php } ?>
596 </td>
597 <td valign="top" style="<?php echo esc_attr( $row_style . $value_style ); ?>"><span style="<?php echo esc_attr( $badge_style . $badge_tone ); ?>"><?php echo esc_html( $badge ); ?></span></td>
598 </tr>
599 <?php } ?>
600 </tbody>
601 </table>
602 </div>
603 <?php } ?>
604
605 <p style="margin: 28px 0 0; text-align: center;">
606 <a href="<?php echo esc_url( $donations_url ); ?>" style="display: inline-block; background-color: #0f6e8c; color: #ffffff; text-decoration: none; font-size: 14px; line-height: 20px; font-weight: 600; padding: 12px 22px; border-radius: 8px;"><?php esc_html_e( 'View all donations', 'suredonation' ); ?></a>
607 </p>
608
609 <p style="margin: 28px 0 0; text-align: center; font-size: 13px; line-height: 20px; color: #6b7280;">
610 <a href="<?php echo esc_url( home_url( '/' ) ); ?>" style="color: #0f6e8c;"><?php echo esc_html( $site_name ); ?></a>
611 </p>
612 <p style="margin: 8px 0 0; text-align: center; font-size: 12px; line-height: 18px; color: #9ca3af;">
613 <?php
614 if ( $is_test ) {
615 esc_html_e( 'This is a test of your weekly email report.', 'suredonation' );
616 } else {
617 esc_html_e( 'You are receiving this because weekly email reports are turned on.', 'suredonation' );
618 }
619 ?>
620 <a href="<?php echo esc_url( $settings_url ); ?>" style="color: #6b7280;"><?php esc_html_e( 'Manage email reports', 'suredonation' ); ?></a>
621 </p>
622 <?php
623 $body = ob_get_clean();
624
625 $template = Email_Template::get_instance();
626 return $template->get_header( __( 'Weekly Donation Report', 'suredonation' ) ) . ( false !== $body ? $body : '' ) . $template->get_footer();
627 }
628 }
629