PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.12.2
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.12.2
1.8.13.1 1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 All 212 releases
charitable / includes / admin / notifications / class-charitable-notifications.php

class-charitable-notifications.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.12.2, at includes/admin/notifications/class-charitable-notifications.php

1,359 lines 38.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Charitable Admin Plugin Notifications.
4 *
5 * @package Charitable/Classes/Charitable_Admin_Form
6 * @author David Bisset
7 * @copyright Copyright (c) 2023, WP Charitable LLC
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.8.3
10 * @version 1.8.3
11 */
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Notifications.
20 *
21 * @since 1.8.3
22 * @version 1.8.3
23 */
24 class Charitable_Notifications {
25
26 /**
27 * The single instance of this class.
28 *
29 * @var Charitable_Notifications|null
30 */
31 private static $instance = null;
32
33 /**
34 * Source of notifications content.
35 *
36 * @since 1.8.3
37 *
38 * @var string
39 */
40 const SOURCE_URL = 'https://plugin.wpcharitable.com/wp-content/charitable-notifications.json';
41
42 /**
43 * Array of license types, that are considered being Elite level.
44 *
45 * @since 1.8.3
46 *
47 * @var array
48 */
49 const LICENSES_ELITE = [ 'agency', 'pro', 'elite' ];
50
51 /**
52 * Option value.
53 *
54 * @since 1.8.3
55 *
56 * @var bool|array
57 */
58 public $option = false;
59
60 /**
61 * Current license type.
62 *
63 * @since 1.8.3
64 *
65 * @var string
66 */
67 private $license_type;
68
69 /**
70 * Cached result of has_alert_notifications() for the current request.
71 *
72 * @since 1.8.12
73 *
74 * @var bool|null
75 */
76 private $has_alerts_cache = null;
77
78 /**
79 * Returns and/or create the single instance of this class.
80 *
81 * @since 1.2.0
82 *
83 * @return Charitable_User_Dashboard
84 */
85 public static function get_instance() {
86 if ( is_null( self::$instance ) ) {
87 self::$instance = new self();
88 }
89
90 return self::$instance;
91 }
92
93 /**
94 * Constructor.
95 *
96 * @since 1.8.3
97 */
98 public function __construct() {
99
100 if ( ! defined( 'CHARITABLE_DISABLE_NOTIFICATIONS' ) || ! CHARITABLE_DISABLE_NOTIFICATIONS ) {
101 $this->init();
102 }
103 }
104
105 /**
106 * Initialize class.
107 *
108 * @since 1.8.3
109 */
110 public function init() {
111
112 $this->hooks();
113 }
114
115 /**
116 * Register hooks.
117 *
118 * @since 1.8.3
119 */
120 public function hooks() {
121 add_action( 'charitable_maybe_show_plugin_notifications', [ $this, 'output' ], 99 ); // where the notifications comes out in the admin.
122 add_action( 'charitable_admin_notifications_update', [ $this, 'update' ] );
123 add_action( 'deactivate_plugin', [ $this, 'delete' ], 10, 2 );
124 add_filter( 'charitable_notifications_get', [ $this, 'priority_sort' ], 99 );
125 }
126
127 /**
128 * Check if user has access and is enabled.
129 *
130 * @since 1.8.3
131 *
132 * @return bool
133 */
134 public function has_access() {
135
136 $access = charitable_current_user_can( 'administrator' ) && ! charitable_get_option( 'hide-announcements' );
137
138 /**
139 * Allow modifying state if a user has access.
140 *
141 * @since 1.7.0.3
142 *
143 * @param bool $access True if user has access.
144 */
145 return (bool) apply_filters( 'charitable_admin_notifications_has_access', $access );
146 }
147
148 /**
149 * Add notifications to the WordPress admin menu for Charitable.
150 *
151 * @since 1.8.3
152 *
153 * @param array $submenu Array of menu items.
154 * @return bool
155 */
156 public function add_notifications_to_menu( $submenu = array() ) {
157
158 if ( ( defined( 'CHARITABLE_NOTIFICATIONS_FORCE_OFF' ) && CHARITABLE_NOTIFICATIONS_FORCE_OFF ) ) {
159 return $submenu;
160 }
161
162 $notifications_menu_item = array(
163 'page_title' => __( 'Notifications', 'charitable' ),
164 'menu_title' => __( 'Notifications', 'charitable' ),
165 'menu_slug' => 'charitable-notifications',
166 'function' => array( $this, 'render_plugin_notifications_page' ),
167 'capability' => 'manage_charitable_settings',
168 );
169
170 // if there are new notifications add the indicator to the menu title.
171 if ( $this->get_new_notifications_count() ) {
172 $dot_type = $this->has_alert_notifications() ? 'alert' : 'info';
173 $notifications_menu_item['menu_title'] .= '<div class="charitable-menu-notification-indicator ' . $dot_type . '"></div>';
174 }
175
176 // If the checklist exists in the submenu, add this item after it.
177 $checklist_key = array_search( 'charitable-setup-checklist', array_column( $submenu, 'menu_slug' ), true );
178 if ( false !== $checklist_key ) {
179 $submenu = array_merge(
180 array_slice( $submenu, 0, $checklist_key + 1 ),
181 array( $notifications_menu_item ),
182 array_slice( $submenu, $checklist_key + 1 )
183 );
184 } else {
185 array_unshift( $submenu, $notifications_menu_item );
186 }
187
188 return $submenu;
189 }
190
191 /**
192 * Technically this should render the plugin notifications page.
193 * However, notifications aren't rendering with this method. There should be a redirect, but the existance of this function prevents fatal errors.
194 *
195 * @since 1.8.3
196 */
197 public function render_plugin_notifications_page() {
198 $this->output();
199 }
200
201 /**
202 * Get option value.
203 *
204 * @since 1.8.3
205 *
206 * @param bool $cache Reference property cache if available.
207 *
208 * @return array
209 */
210 public function get_option( $cache = true ) {
211
212 if ( $this->option && $cache ) {
213 return $this->option;
214 }
215
216 $option = (array) get_option( 'charitable_notifications', [] );
217
218 $this->option = [
219 'update' => ! empty( $option['update'] ) ? (int) $option['update'] : 0,
220 'feed' => ! empty( $option['feed'] ) ? (array) $option['feed'] : [],
221 'events' => ! empty( $option['events'] ) ? (array) $option['events'] : [],
222 'dismissed' => ! empty( $option['dismissed'] ) ? (array) $option['dismissed'] : [],
223 'local' => ! empty( $option['local'] ) ? (array) $option['local'] : [],
224 'new_feed' => ! empty( $option['new_feed'] ) ? (array) $option['new_feed'] : [],
225 'dismissed_times' => ! empty( $option['dismissed_times'] ) ? (array) $option['dismissed_times'] : [],
226 'settings' => ! empty( $option['settings'] ) ? (array) $option['settings'] : [],
227 ];
228
229 return $this->option;
230 }
231
232 /**
233 * Fetch notifications from feed.
234 *
235 * @since 1.8.3
236 *
237 * @return array
238 */
239 public function fetch_feed() {
240
241 $args = [
242 'timeout' => 10,
243 'sslverify' => false,
244 'user-agent' => charitable_get_default_user_agent(),
245 ];
246
247 $response = wp_remote_get(
248 self::SOURCE_URL,
249 $args
250 );
251
252 if ( is_wp_error( $response ) ) {
253 return [];
254 }
255
256 $body = wp_remote_retrieve_body( $response );
257
258 if ( empty( $body ) ) {
259 return [];
260 }
261
262 return $this->verify( json_decode( $body, true ) );
263 }
264
265 /**
266 * Verify notification data before it is saved.
267 *
268 * @since 1.8.3
269 *
270 * @param array $notifications Array of notifications items to verify.
271 *
272 * @return array
273 */
274 public function verify( $notifications ) {
275
276 $data = [];
277
278 if ( ! is_array( $notifications ) || empty( $notifications ) ) {
279 return $data;
280 }
281
282 foreach ( $notifications as $notification ) {
283
284 // Ignore if one of the conditional checks is true:
285 //
286 // 1. notification message is empty.
287 // 2. license type does not match.
288 // 3. notification is expired.
289 // 4. notification has already been dismissed.
290 // 5. notification existed before installing Charitable.
291 // (Prevents bombarding the user with notifications after activation).
292 if (
293 empty( $notification['content'] ) ||
294 ! $this->is_license_type_match( $notification ) ||
295 $this->is_expired( $notification ) ||
296 $this->is_dismissed( $notification ) ||
297 $this->is_existed( $notification )
298 ) {
299 continue;
300 }
301
302 $data[] = $notification;
303 }
304
305 return $data;
306 }
307
308 /**
309 * Verify saved notification data for active notifications.
310 *
311 * @since 1.8.3
312 *
313 * @param array $notifications Array of notifications items to verify.
314 *
315 * @return array
316 */
317 public function verify_active( $notifications ) {
318
319 if ( ! is_array( $notifications ) || empty( $notifications ) ) {
320 return [];
321 }
322
323 $current_timestamp = time();
324
325 // Remove notifications that are not active.
326 foreach ( $notifications as $key => $notification ) {
327 if (
328 ( ! empty( $notification['start'] ) && $current_timestamp < strtotime( $notification['start'] ) ) ||
329 ( ! empty( $notification['end'] ) && $current_timestamp > strtotime( $notification['end'] ) )
330 ) {
331 unset( $notifications[ $key ] );
332 }
333 }
334
335 return $notifications;
336 }
337
338 /**
339 * Get notification data.
340 *
341 * @since 1.8.3
342 * @version 1.8.12
343 *
344 * @return array
345 */
346 public function get() {
347
348 if ( defined( 'CHARITABLE_DISABLE_NOTIFICATIONS' ) && CHARITABLE_DISABLE_NOTIFICATIONS ) {
349 return [];
350 }
351
352 if ( ! $this->has_access() ) {
353 return [];
354 }
355
356 $option = $this->get_option();
357
358 // Update notifications using async task.
359 if ( empty( $option['update'] ) || time() > $option['update'] + DAY_IN_SECONDS ) {
360 $option = $this->update();
361 } elseif ( empty( $option['feed'] ) && empty( $option['events'] ) ) {
362 // regardless of the update time, if there are no notifications, update attempt.
363 $option = $this->update();
364 }
365
366 $legacy_enabled = ! class_exists( 'Charitable_Notification_Settings' ) ||
367 Charitable_Notification_Settings::get_instance()->is_category_enabled( 'legacy_feed' );
368
369 $feed = ( $legacy_enabled && ! empty( $option['feed'] ) ) ? $this->verify_active( $option['feed'] ) : [];
370 $events = ( $legacy_enabled && ! empty( $option['events'] ) ) ? $this->verify_active( $option['events'] ) : [];
371
372 $notifications = array_merge( $feed, $events );
373
374 /**
375 * Filter the final notifications array before output.
376 *
377 * Used by addons (e.g. Pro's Charitable_Dynamic_Notifications) to prepend license-state
378 * notifications (expiring/expired) above the remote feed.
379 *
380 * @since 1.8.12
381 *
382 * @param array $notifications Merged feed + events notifications.
383 */
384 return (array) apply_filters( 'charitable_notifications_get', $notifications );
385 }
386
387 /**
388 * Final sort — enforces spec priority order regardless of filter injection order.
389 *
390 * Order: new_feed (non-dismissible first) → license → health → legal →
391 * lifecycle → milestone → nudge → everything else (legacy feed).
392 *
393 * @since 1.8.12
394 *
395 * @param array $notifications
396 * @return array
397 */
398 public function priority_sort( array $notifications ) {
399 $order = [
400 'new_feed_pinned' => [],
401 'new_feed' => [],
402 'license' => [],
403 'health' => [],
404 'legal' => [],
405 'lifecycle' => [],
406 'milestone' => [],
407 'nudge' => [],
408 'other' => [],
409 ];
410
411 $license_ids = [ 'license_expired', 'license_expiring' ];
412
413 foreach ( $notifications as $n ) {
414 $id = isset( $n['id'] ) ? (string) $n['id'] : '';
415 $category = isset( $n['category'] ) ? $n['category'] : '';
416
417 if ( 'new_feed' === $category ) {
418 $dismissible = ! isset( $n['dismissible'] ) || (bool) $n['dismissible'];
419 $order[ $dismissible ? 'new_feed' : 'new_feed_pinned' ][] = $n;
420 } elseif ( in_array( $id, $license_ids, true ) ) {
421 $order['license'][] = $n;
422 } elseif ( isset( $order[ $category ] ) ) {
423 $order[ $category ][] = $n;
424 } else {
425 $order['other'][] = $n;
426 }
427 }
428
429 $result = [];
430 foreach ( $order as $group ) {
431 foreach ( $group as $item ) {
432 $result[] = $item;
433 }
434 }
435 return $result;
436 }
437
438 /**
439 * Get notification count in the feed, regardless if they are dismissed or not.
440 *
441 * @since 1.8.3
442 *
443 * @return int
444 */
445 public function get_count() {
446 return count( $this->get() );
447 }
448
449 /**
450 * Get new notifications by comparing those in the feed vs dismissed.
451 *
452 * @since 1.8.3
453 *
454 * @return int
455 */
456 public function get_new_notifications_count() {
457
458 $option = $this->get_option();
459 $dimissed_ids = ! empty( $option['dismissed'] ) ? $option['dismissed'] : [];
460 $notification_feed = $this->get();
461
462 // if $option is empty or if the feed is empty, there are no new notifications.
463 if ( empty( $option ) || empty( $notification_feed ) ) {
464 return 0;
465 }
466
467 $count = 0;
468
469 foreach ( $notification_feed as $notification ) {
470 // check the id of the notification against the dismissed ids.
471 if ( ! in_array( (string) $notification['id'], $dimissed_ids, true ) ) {
472 ++$count;
473 }
474 }
475
476 return (int) $count;
477 }
478
479 /**
480 * Check if any undismissed notifications are alerts (health or legal category).
481 *
482 * @since 1.8.12
483 *
484 * @return bool
485 */
486 public function has_alert_notifications() {
487
488 if ( null !== $this->has_alerts_cache ) {
489 return $this->has_alerts_cache;
490 }
491
492 $option = $this->get_option();
493 $dismissed_ids = ! empty( $option['dismissed'] ) ? $option['dismissed'] : [];
494 $notification_feed = $this->get();
495
496 if ( empty( $notification_feed ) ) {
497 $this->has_alerts_cache = false;
498 return $this->has_alerts_cache;
499 }
500
501 $alert_categories = [ 'health', 'legal' ];
502
503 foreach ( $notification_feed as $notification ) {
504 if ( in_array( (string) $notification['id'], $dismissed_ids, true ) ) {
505 continue;
506 }
507 if ( ! empty( $notification['category'] ) && in_array( $notification['category'], $alert_categories, true ) ) {
508 $this->has_alerts_cache = true;
509 return $this->has_alerts_cache;
510 }
511 }
512
513 $this->has_alerts_cache = false;
514 return $this->has_alerts_cache;
515 }
516
517 /**
518 * Add a new Event Driven notification.
519 *
520 * @since 1.8.3
521 *
522 * @param array $notification Notification data.
523 */
524 public function add( $notification ) {
525
526 if ( ! $this->is_valid( $notification ) ) {
527 return;
528 }
529
530 $option = $this->get_option();
531
532 // Notification ID already exists.
533 if ( ! empty( $option['events'][ $notification['id'] ] ) ) {
534 return;
535 }
536
537 update_option(
538 'charitable_notifications',
539 [
540 'update' => $option['update'],
541 'feed' => $option['feed'],
542 'events' => array_merge( $notification, $option['events'] ),
543 'dismissed' => $option['dismissed'],
544 ]
545 );
546 }
547
548 /**
549 * Determine if notification data is valid.
550 *
551 * @since 1.8.3
552 *
553 * @param array $notification Notification data.
554 *
555 * @return bool
556 */
557 public function is_valid( $notification ) {
558
559 if ( empty( $notification['id'] ) ) {
560 return false;
561 }
562
563 return ! empty( $this->verify( [ $notification ] ) );
564 }
565
566 /**
567 * Determine if notification has already been dismissed.
568 *
569 * @since 1.8.3
570 *
571 * @param array $notification Notification data.
572 *
573 * @return bool
574 */
575 private function is_dismissed( $notification ) {
576
577 $option = $this->get_option();
578
579 // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict
580 return ! empty( $option['dismissed'] ) && in_array( $notification['id'], $option['dismissed'] );
581 }
582
583 /**
584 * Determine if license type is match.
585 *
586 * @since 1.8.3
587 *
588 * @param array $notification Notification data.
589 *
590 * @return bool
591 */
592 private function is_license_type_match( $notification ) {
593
594 // A specific license type is not required.
595 if ( empty( $notification['type'] ) ) {
596 return true;
597 }
598
599 return in_array( $this->get_license_type(), (array) $notification['type'], true );
600 }
601
602 /**
603 * Determine if notification is expired.
604 *
605 * @since 1.8.3
606 *
607 * @param array $notification Notification data.
608 *
609 * @return bool
610 */
611 private function is_expired( $notification ) {
612 // Check if the notification 'end' parameter is set and not empty.
613 if ( empty( $notification['end'] ) ) {
614 return false;
615 }
616
617 // Get the WordPress timezone.
618 $timezone = wp_timezone();
619
620 // Convert the notification end date to a DateTime object in the WordPress timezone.
621 $end_time = new DateTime( $notification['end'], $timezone );
622
623 // Get the current server time as a DateTime object in the WordPress timezone.
624 $current_time = new DateTime( 'now', $timezone );
625
626 // Compare the current time with the notification end time.
627 return $current_time > $end_time;
628 }
629
630 /**
631 * Determine if notification existed before installing Charitable.
632 * If there is an "allow_on_activation" key, it will be used to determine to display even if the notification existed before.
633 *
634 * @since 1.8.3
635 *
636 * @param array $notification Notification data.
637 *
638 * @return bool
639 */
640 private function is_existed( $notification ) {
641
642 $activated = charitable_get_activated_timestamp();
643
644 if ( ! isset( $notification['allow_on_activation'] ) ) {
645 return ! empty( $activated ) &&
646 ! empty( $notification['start'] ) &&
647 $activated > strtotime( $notification['start'] );
648 } else {
649 return ! empty( $activated ) &&
650 ! empty( $notification['start'] ) &&
651 false === $notification['allow_on_activation'];
652 }
653 }
654
655 /**
656 * Update notification data from feed.
657 *
658 * @since 1.8.3
659 */
660 public function update() {
661
662 $option = $this->get_option();
663
664 $data = [
665 'update' => time(),
666 'feed' => $this->fetch_feed(),
667 'events' => $option['events'],
668 'dismissed' => $option['dismissed'],
669 'local' => $option['local'],
670 'new_feed' => $option['new_feed'],
671 'dismissed_times' => $option['dismissed_times'],
672 'settings' => $option['settings'],
673 ];
674
675 // phpcs:disable Charitable.PHP.ValidateHooks.InvalidHookName
676 /**
677 * Allow changing notification data before it will be updated in database.
678 *
679 * @since 1.8.3
680 *
681 * @param array $data New notification data.
682 */
683 $data = (array) apply_filters( 'charitable_admin_notifications_update_data', $data );
684 // phpcs:enable Charitable.PHP.ValidateHooks.InvalidHookName
685
686 update_option( 'charitable_notifications', $data );
687
688 return $data;
689 }
690
691 /**
692 * Save specific keys back to the option without triggering a full update.
693 *
694 * @since 1.8.12
695 *
696 * @param array $partial Associative array of keys to merge into the stored option.
697 * Intended keys: 'local', 'new_feed', 'dismissed_times', 'settings'.
698 * @return void
699 */
700 public function save_option_partial( array $partial ) {
701 $option = $this->get_option( false ); // bypass cache
702 $merged = array_merge( $option, $partial );
703 update_option( 'charitable_notifications', $merged );
704 $this->option = false; // invalidate cache
705 }
706
707 /**
708 * Remove notification data from database before a plugin is deactivated.
709 *
710 * @since 1.8.3
711 *
712 * @param string $plugin Path to the plugin file relative to the plugins directory.
713 * @param bool $network_deactivating Whether the plugin is deactivated for all sites in the network
714 * or just the current site. Multisite only. Default false.
715 */
716 public function delete( $plugin, $network_deactivating ) { // phpcs:ignore
717
718 $charitable_plugins = [
719 'charitable/charitable.php',
720 ];
721
722 if ( ! in_array( $plugin, $charitable_plugins, true ) ) {
723 return;
724 }
725
726 delete_option( 'charitable_notifications' );
727 }
728
729 /**
730 * Output notices.
731 *
732 * @since 1.8.3
733 */
734 public function output_notices() {
735 return $this->output( 'notices' );
736 }
737
738 /**
739 * Output notifications on Form Overview admin area.
740 *
741 * @since 1.8.3
742 * @version 1.8.12
743 *
744 * @param string $location Location where the notifications are output.
745 * @param int $limit_active Limit of active notifications to show.
746 */
747 public function output( $location = false, $limit_active = 4 ) {
748
749 if ( defined( 'CHARITABLE_DISABLE_NOTIFICATIONS' ) && CHARITABLE_DISABLE_NOTIFICATIONS ) {
750 return;
751 }
752
753 global $post;
754
755 if ( 'notices' === $location && ! $this->show_notifications() ) {
756 return;
757 }
758
759 $notifications = $this->get();
760
761 $active_notifications_html = '';
762 $dismissed_notifications_html = '';
763 $active_notifications_count = 0;
764 $dismissed_notifications_count = 0;
765 $current_class = ' current';
766 $content_allowed_tags = [
767 'em' => [],
768 'strong' => [],
769 'span' => [
770 'style' => [],
771 'class' => [],
772 ],
773 'a' => [
774 'href' => [],
775 'target' => [],
776 'rel' => [],
777 ],
778 ];
779 $template_location = $location === 'dashboard' ? '/admin/templates/notifications-dashboard' : '/admin/templates/notifications';
780 $template_location = apply_filters( 'charitable_admin_notifications_template_location', $template_location, $location );
781
782 if ( empty( $notifications ) ) {
783 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
784 echo charitable_render(
785 $template_location,
786 [
787 'notifications' => [
788 'active_count' => $active_notifications_count,
789 'dismissed_count' => $dismissed_notifications_count,
790 'active_html' => $active_notifications_html,
791 'dismissed_html' => $dismissed_notifications_html,
792 ],
793 ],
794 true
795 );
796 return;
797 }
798
799 // Get active notifications.
800 foreach ( $notifications as $notification ) {
801
802 // Prepare required arguments.
803 $notification = wp_parse_args(
804 $notification,
805 [
806 'id' => 0,
807 'title' => '',
808 'content' => '',
809 'video' => '',
810 ]
811 );
812
813 $title = $this->get_component_data( $notification['title'] );
814 $content = $this->get_component_data( $notification['content'] );
815
816 if ( ! $title && ! $content ) {
817 continue;
818 }
819
820 // Non-dismissible notifications (e.g. expired license) always render as active. @since 1.8.12
821 $is_dismissible = ! isset( $notification['dismissible'] ) || false !== $notification['dismissible'];
822
823 // Dynamic notifications (those with an explicit 'dismissible' key) suppress the relative date. @since 1.8.12
824 $is_dynamic = isset( $notification['dismissible'] );
825 $date_string = ! empty( $notification['start'] ) ? $this->human_readable_date_from_mysql( $notification['start'] ) : '';
826 $date_html = $date_string ? '<span class="charitable-notification-date">' . esc_html( $date_string ) . '</span>' : '';
827
828 // If the notification ID is not a part of the dismissed notifications, add it to the list.
829 if ( ! $is_dismissible || ! $this->is_dismissed( $notification ) ) {
830
831 // Dismiss link is suppressed for non-dismissible notifications (e.g. expired license). @since 1.8.12
832 $dismiss_link = $is_dismissible ? '<a href="#" class="dismiss charitable-notif-dismiss-x" aria-label="Dismiss"><svg viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" width="10" height="10"><path d="M11.8211 1.3415L10.6451 0.166504L5.98305 4.82484L1.32097 0.166504L0.14502 1.3415L4.80711 5.99984L0.14502 10.6582L1.32097 11.8332L5.98305 7.17484L10.6451 11.8332L11.8211 10.6582L7.159 5.99984L11.8211 1.3415Z" fill="currentColor"/></svg></a>' : '';
833
834 if ( 'dashboard' === $location ) {
835
836 // Notification HTML, slightyl different for dashboard.
837 $active_notifications_html .= sprintf(
838 '<div aria-expanded="true" class="charitable-notification charitable-notification-%5$s %8$s" data-notification-id="%6$s">
839 <div>
840 %7$s
841 <div class="body">
842 <div class="title dashboard-title">
843 <div>%1$s</div>
844 <div class="date">%3$s</div>
845 </div>
846 <div class="notification-content">%2$s</div>
847 <div class="actions">
848 %4$s
849 %9$s
850 </div>
851 </div>
852 </div>
853 </div>',
854 esc_html( $title ),
855 wp_kses( $content, $content_allowed_tags ),
856 $date_html,
857 $this->get_notification_buttons_html( $notification ),
858 esc_attr( $notification['id'] ),
859 esc_attr( $notification['id'] ),
860 $this->get_notification_icon_html( $notification ),
861 esc_attr( $current_class ),
862 $dismiss_link // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
863 // $this->get_video_badge_html( $this->get_component_data( $notification['video'] ) )
864 );
865
866 } else {
867
868 // Notification HTML.
869 $badge_html = $this->get_notification_badge_html( $notification );
870 $border_type = ! empty( $notification['badge_type'] ) ? sanitize_html_class( $notification['badge_type'] ) : '';
871 $border_class = $border_type ? ' charitable-notif-border--' . $border_type : '';
872
873 $active_notifications_html .= sprintf(
874 '<div aria-expanded="true" class="charitable-notification charitable-notification-%5$s %8$s" data-notification-id="%6$s">
875 <div class="charitable-notification-card-inner">
876 %9$s
877 <div class="notif-left">
878 %10$s
879 %7$s
880 </div>
881 <div class="body">
882 %3$s
883 <h4>%1$s</h4>
884 <div class="notification-content">%2$s</div>
885 <div class="actions">
886 %4$s
887 </div>
888 </div>
889 </div>
890 </div>',
891 esc_html( $title ),
892 wp_kses( $content, $content_allowed_tags ),
893 $date_html,
894 $this->get_notification_buttons_html( $notification ),
895 esc_attr( $notification['id'] ),
896 esc_attr( $notification['id'] ),
897 $this->get_notification_icon_html( $notification ),
898 esc_attr( $current_class ),
899 $dismiss_link, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
900 $badge_html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
901 );
902
903 }
904
905 ++$active_notifications_count;
906
907 } else {
908
909 // Dismissed notification.
910 $dismissed_notifications_html .= sprintf(
911 '<div aria-expanded="true" class="charitable-notification charitable-notification-%5$s %8$s" data-notification-id="%6$s">
912 <div>
913 %7$s
914 <div class="body">
915 <div class="title">
916 <div class="date">%3$s</div>
917 <div>%1$s</div>
918 </div>
919 <div class="notification-content">%2$s</div>
920 <div class="actions">
921 %4$s
922 </div>
923 </div>
924 </div>
925 </div>',
926 esc_html( $title ),
927 wp_kses( $content, $content_allowed_tags ),
928 $date_html,
929 $this->get_notification_buttons_html( $notification ),
930 esc_attr( $notification['id'] ),
931 esc_attr( $notification['id'] ),
932 $this->get_notification_icon_html( $notification ),
933 esc_attr( $current_class )
934 // $this->get_video_badge_html( $this->get_component_data( $notification['video'] ) )
935 );
936
937 ++$dismissed_notifications_count;
938 }
939
940 // Only first notification is current.
941 $current_class = '';
942
943 if ( 'dashboard' === $location && $active_notifications_count >= $limit_active ) {
944 break;
945 }
946 }
947
948 // For dashboard, we need to show the actual count being displayed (limited), not the total count
949 $display_count = $active_notifications_count;
950 if ( 'dashboard' === $location ) {
951 // Get the total count for comparison
952 $total_count = $this->get_new_notifications_count();
953
954 // If we have more than the limit, show the limit with a "+"
955 if ( $total_count > $limit_active ) {
956 $display_count = $limit_active . '+';
957 }
958 }
959
960 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
961 echo charitable_render(
962 $template_location,
963 [
964 'notifications' => [
965 'active_count' => $display_count,
966 'dismissed_count' => $dismissed_notifications_count,
967 'active_html' => $active_notifications_html,
968 'dismissed_html' => $dismissed_notifications_html,
969 ],
970 ],
971 true
972 );
973 }
974
975 /**
976 * Get notificaiton icon html based on if the type is a success, warning, info.
977 *
978 * @since 1.8.3
979 *
980 * @param array $notification Notification data.
981 *
982 * @return string
983 */
984 private function get_notification_icon_html( $notification ) {
985
986 $notification_type = ! empty( $notification['notification_type'] ) ? sanitize_text_field( $notification['notification_type'] ) : '';
987 $category = ! empty( $notification['category'] ) ? sanitize_text_field( $notification['category'] ) : '';
988
989 // Category-specific icons take priority over generic notification_type icons.
990 if ( 'celebration' === $notification_type ) {
991 // Trophy — campaign goal reached.
992 return '<div class="icon">
993 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="charitable-icon-celebration" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
994 <path d="M8 21h8M12 17v4"/>
995 <path d="M7 4H5a2 2 0 0 0-2 2v1a4 4 0 0 0 4 4h.5"/>
996 <path d="M17 4h2a2 2 0 0 1 2 2v1a4 4 0 0 1-4 4h-.5"/>
997 <path d="M7 4h10v7a5 5 0 0 1-10 0V4z"/>
998 </svg>
999 </div>';
1000 }
1001
1002 if ( 'legal' === $category ) {
1003 // Same circle exclamation as health alert, orange.
1004 return '<div class="icon">
1005 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="charitable-icon-compliance">
1006 <path fill-rule="evenodd" clip-rule="evenodd" d="M11.99 2.00012C6.47 2.00012 2 6.48012 2 12.0001C2 17.5201 6.47 22.0001 11.99 22.0001C17.52 22.0001 22 17.5201 22 12.0001C22 6.48012 17.52 2.00012 11.99 2.00012ZM13 13.0001V7.00012H11V13.0001H13ZM13 17.0001V15.0001H11V17.0001H13ZM4 12.0001C4 16.4201 7.58 20.0001 12 20.0001C16.42 20.0001 20 16.4201 20 12.0001C20 7.58012 16.42 4.00012 12 4.00012C7.58 4.00012 4 7.58012 4 12.0001Z" fill="currentColor"></path>
1007 </svg>
1008 </div>';
1009 }
1010
1011 if ( 'milestone' === $category ) {
1012 // Star — donor milestone.
1013 return '<div class="icon">
1014 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="charitable-icon-milestone" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
1015 <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
1016 </svg>
1017 </div>';
1018 }
1019
1020 if ( 'lifecycle' === $category ) {
1021 // Flag — campaign live or ended.
1022 return '<div class="icon">
1023 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="charitable-icon-lifecycle" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
1024 <path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/>
1025 <line x1="4" y1="22" x2="4" y2="15"/>
1026 </svg>
1027 </div>';
1028 }
1029
1030 if ( 'nudge' === $category ) {
1031 // Lightbulb — engagement nudge.
1032 return '<div class="icon">
1033 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="charitable-icon-nudge" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
1034 <path d="M9 18h6M10 22h4M12 2a7 7 0 00-4 12.87V17h8v-2.13A7 7 0 0012 2z"/>
1035 </svg>
1036 </div>';
1037 }
1038
1039 if ( 'new_feed' === $category ) {
1040 // Megaphone — announcement.
1041 return '<div class="icon">
1042 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="charitable-icon-announcement" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
1043 <path d="M19 3v18M5 8H3a1 1 0 00-1 1v4a1 1 0 001 1h2l4 3V5L5 8z"/>
1044 <path d="M19 7l-10 1M19 17L9 16"/>
1045 </svg>
1046 </div>';
1047 }
1048
1049 // Generic fallback by notification_type.
1050 switch ( $notification_type ) {
1051 case 'warning':
1052 // Circle exclamation — health alert (keep as-is).
1053 $html = '<div class="icon">
1054 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="charitable-circle-exclamation warning">
1055 <path fill-rule="evenodd" clip-rule="evenodd" d="M11.99 2.00012C6.47 2.00012 2 6.48012 2 12.0001C2 17.5201 6.47 22.0001 11.99 22.0001C17.52 22.0001 22 17.5201 22 12.0001C22 6.48012 17.52 2.00012 11.99 2.00012ZM13 13.0001V7.00012H11V13.0001H13ZM13 17.0001V15.0001H11V17.0001H13ZM4 12.0001C4 16.4201 7.58 20.0001 12 20.0001C16.42 20.0001 20 16.4201 20 12.0001C20 7.58012 16.42 4.00012 12 4.00012C7.58 4.00012 4 7.58012 4 12.0001Z" fill="currentColor"></path>
1056 </svg>
1057 </div>';
1058 break;
1059 case 'success':
1060 $html = '<div class="icon"><svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="charitable-circle-check success"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20ZM10 14.17L16.59 7.58L18 9L10 17L6 13L7.41 11.59L10 14.17Z" fill="currentColor"></path></svg></div>';
1061 break;
1062 default:
1063 $html = '<div class="icon"><svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="charitable-circle-check success"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20ZM10 14.17L16.59 7.58L18 9L10 17L6 13L7.41 11.59L10 14.17Z" fill="currentColor"></path></svg></div>';
1064 break;
1065 }
1066
1067 return $html;
1068 }
1069
1070 /**
1071 * Get badge HTML for a notification card.
1072 *
1073 * Badge displays the category label (e.g. "HEALTH ALERT", "MILESTONE").
1074 * Returns empty string if no badge_label is set.
1075 *
1076 * @since 1.8.12
1077 *
1078 * @param array $notification Notification data.
1079 * @return string
1080 */
1081 private function get_notification_badge_html( $notification ) {
1082 if ( empty( $notification['badge_label'] ) ) {
1083 return '';
1084 }
1085
1086 $badge_type = ! empty( $notification['badge_type'] ) ? sanitize_html_class( $notification['badge_type'] ) : 'info';
1087 $label = esc_html( strtoupper( $notification['badge_label'] ) );
1088
1089 return sprintf(
1090 '<span class="charitable-notif-badge charitable-notif-badge--%s">%s</span>',
1091 $badge_type,
1092 $label
1093 );
1094 }
1095
1096 /**
1097 * Get human readable date from MySQL timestamp.
1098 *
1099 * @since 1.8.3
1100 *
1101 * @param string $mysql_timestamp MySQL timestamp.
1102 *
1103 * @return bool
1104 */
1105 private function human_readable_date_from_mysql( $mysql_timestamp = false ) {
1106
1107 // Convert the MySQL timestamp to a Unix timestamp.
1108 $timestamp = strtotime( $mysql_timestamp );
1109
1110 // Get the current time as a Unix timestamp.
1111 $current_time = current_time( 'timestamp' ); // phpcs:ignore
1112
1113 // Calculate the difference in seconds.
1114 $time_diff = $current_time - $timestamp;
1115
1116 // 30 days in seconds
1117 $thirty_days = 30 * 24 * 60 * 60;
1118
1119 if ( $time_diff < $thirty_days ) {
1120 // If the timestamp is less than 30 days ago, return a 'time ago' format.
1121 return human_time_diff( $timestamp, $current_time ) . ' ago';
1122 } else {
1123 // If the timestamp is 30 days or more in the past, use 'F j, Y' format.
1124 return date( 'F j, Y', $timestamp ); // phpcs:ignore
1125 }
1126 }
1127
1128 /**
1129 * Retrieve notification's buttons HTML.
1130 *
1131 * @since 1.8.3
1132 *
1133 * @param array $notification Notification data.
1134 *
1135 * @return string
1136 */
1137 private function get_notification_buttons_html( $notification ) {
1138
1139 $html = '';
1140
1141 if ( empty( $notification['btns'] ) || ! is_array( $notification['btns'] ) ) {
1142 return $html;
1143 }
1144
1145 foreach ( $notification['btns'] as $btn_type => $btn ) {
1146
1147 $btn = $this->get_component_data( $btn );
1148
1149 if ( ! $btn ) {
1150 continue;
1151 }
1152
1153 $url = $this->prepare_btn_url( $btn );
1154 $target = ! empty( $btn['target'] ) ? $btn['target'] : '_blank';
1155 $target = ! empty( $url ) && strpos( $url, home_url() ) === 0 ? '_self' : $target;
1156
1157 $html .= sprintf(
1158 '<a href="%1$s" class="button button-%2$s"%3$s>%4$s</a>',
1159 esc_url( $url ),
1160 $btn_type === 'main' ? 'primary' : 'secondary',
1161 $target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '',
1162 ! empty( $btn['text'] ) ? esc_html( $btn['text'] ) : ''
1163 );
1164 }
1165
1166 return ! empty( $html ) ? sprintf( '<div class="charitable-notifications-buttons">%s</div>', $html ) : '';
1167 }
1168
1169 /**
1170 * Retrieve notification's component data by a license type.
1171 *
1172 * @since 1.8.3
1173 *
1174 * @param mixed $data Component data.
1175 *
1176 * @return false|mixed
1177 */
1178 private function get_component_data( $data ) {
1179
1180 if ( empty( $data['license'] ) ) {
1181 return $data;
1182 }
1183
1184 $license_type = $this->get_license_type();
1185
1186 if ( in_array( $license_type, self::LICENSES_ELITE, true ) ) {
1187 $license_type = 'elite';
1188 }
1189
1190 return ! empty( $data['license'][ $license_type ] ) ? $data['license'][ $license_type ] : false;
1191 }
1192
1193 /**
1194 * Retrieve the current installation license type (always lowercase).
1195 *
1196 * @since 1.8.3
1197 *
1198 * @return string
1199 */
1200 private function get_license_type() {
1201
1202 if ( $this->license_type ) {
1203 return $this->license_type;
1204 }
1205
1206 if ( charitable_is_pro() ) {
1207 return 'pro';
1208 } else {
1209 return 'lite';
1210 }
1211 }
1212
1213 /**
1214 * Dismiss notification via AJAX.
1215 *
1216 * @since 1.8.3
1217 */
1218 public function dismiss() {
1219
1220 // Check for required param, security and access.
1221 if (
1222 empty( $_POST['notification_id'] ) ||
1223 ! check_ajax_referer( 'charitable-admin', 'nonce', false ) ||
1224 ! $this->has_access()
1225 ) {
1226 wp_send_json_error();
1227 }
1228
1229 $id = sanitize_key( $_POST['notification_id'] );
1230 $type = is_numeric( $id ) ? 'feed' : 'events';
1231 $option = $this->get_option();
1232
1233 $option['dismissed'][] = $id;
1234 $option['dismissed'] = array_unique( $option['dismissed'] );
1235
1236 update_option( 'charitable_notifications', $option );
1237
1238 wp_send_json_success();
1239 }
1240
1241 /**
1242 * Dismiss multiple notifications via AJAX.
1243 *
1244 * @since 1.8.3
1245 */
1246 public function dismiss_multiple() {
1247
1248 // Check for required param, security and access.
1249 if (
1250 empty( $_POST['notification_ids'] ) ||
1251 ! check_ajax_referer( 'charitable-admin', 'nonce', false ) ||
1252 ! $this->has_access()
1253 ) {
1254 wp_send_json_error();
1255 }
1256
1257 if ( ! is_array( $_POST['notification_ids'] ) ) {
1258 wp_send_json_error();
1259 }
1260
1261 $option = $this->get_option();
1262 $notification_ids = array_map( 'sanitize_key', $_POST['notification_ids'] );
1263 foreach ( $notification_ids as $id ) {
1264 $type = is_numeric( $id ) ? 'feed' : 'events';
1265 $option['dismissed'][] = $id;
1266 $option['dismissed'] = array_unique( $option['dismissed'] );
1267
1268 }
1269
1270 update_option( 'charitable_notifications', $option );
1271
1272 wp_send_json_success();
1273 }
1274
1275 /**
1276 * Prepare button URL.
1277 *
1278 * @since 1.8.3
1279 *
1280 * @param array $btn Button data.
1281 *
1282 * @return string
1283 */
1284 private function prepare_btn_url( $btn ) {
1285
1286 if ( empty( $btn['url'] ) ) {
1287 return '';
1288 }
1289
1290 $replace_tags = [
1291 '{admin_url}' => admin_url(),
1292 ];
1293
1294 return str_replace( array_keys( $replace_tags ), array_values( $replace_tags ), $btn['url'] );
1295 }
1296
1297 /**
1298 * Get the notification's video badge HTML.
1299 *
1300 * @since 1.8.3
1301 *
1302 * @param string $video_url Valid video URL.
1303 *
1304 * @return string
1305 */
1306 private function get_video_badge_html( $video_url ) {
1307
1308 $video_url = wp_http_validate_url( $video_url );
1309
1310 if ( empty( $video_url ) ) {
1311 return '';
1312 }
1313
1314 $data_attr_lity = wp_is_mobile() ? '' : 'data-lity';
1315
1316 return sprintf(
1317 '<a class="charitable-notifications-badge" href="%1$s" %2$s>%3$s</a>',
1318 esc_url( $video_url ),
1319 esc_attr( $data_attr_lity ),
1320 esc_html__( 'Watch Video', 'charitable' )
1321 );
1322 }
1323
1324 /**
1325 * Determine to show notifications on this page or not.
1326 *
1327 * @since 1.7.0.3
1328 *
1329 * @return boolean
1330 */
1331 public function show_notifications() {
1332 if ( isset( $_GET['taxonomy'] ) ) { // phpcs:ignore
1333 return false;
1334 }
1335 if ( isset( $_GET['post_type'] ) && sanitize_text_field( $_GET['post_type'] ) === 'campaign' ) { // phpcs:ignore
1336 return true;
1337 }
1338 if ( isset( $_GET['post_type'] ) && sanitize_text_field( $_GET['post_type'] ) === 'donation' ) { // phpcs:ignore
1339 return true;
1340 }
1341 return false;
1342 }
1343
1344 /**
1345 * This will incercept the request and redirect to the dashboard page.
1346 *
1347 * @since 1.8.3
1348 * @version 1.8.3.1
1349 */
1350 public function maybe_redirect_from_notifications() {
1351
1352 if ( ! empty( $_GET['page'] ) && 'charitable-notifications' === $_GET['page'] ) { // phpcs:ignore
1353 delete_transient( 'charitable_autoshow_plugin_notifications' );
1354 wp_safe_redirect( admin_url( 'admin.php?page=charitable-dashboard' ) );
1355 exit;
1356 }
1357 }
1358 }
1359