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-local-notifications.php

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

297 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Charitable Local Notifications.
4 *
5 * Manages locally-generated notifications (milestones, lifecycle, health,
6 * compliance, nudges) and injects them into the sidebar feed via filter.
7 *
8 * @package Charitable/Classes/Charitable_Local_Notifications
9 * @since 1.8.12
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 class Charitable_Local_Notifications {
17
18 private static $instance = null;
19
20 /**
21 * Priority order for local notification categories (lower = higher priority).
22 */
23 const CATEGORY_PRIORITY = [
24 'health' => 10,
25 'legal' => 20,
26 'lifecycle' => 30,
27 'milestone' => 40,
28 'nudge' => 50,
29 ];
30
31 public static function get_instance() {
32 if ( is_null( self::$instance ) ) {
33 self::$instance = new self();
34 }
35 return self::$instance;
36 }
37
38 public function init() {
39 // Inject local notifications just after dynamic (license) notifications at priority 6.
40 add_filter( 'charitable_notifications_get', [ $this, 'inject_local_notifications' ], 6 );
41
42 // Handle dismiss AJAX to record dismissed_times for re-surface.
43 add_action( 'wp_ajax_charitable_notification_dismiss', [ $this, 'record_dismiss_time' ], 5 );
44 add_action( 'wp_ajax_charitable_notification_dismiss_multiple', [ $this, 'record_dismiss_time_multiple' ], 5 );
45 }
46
47 /**
48 * Add or update a local notification.
49 *
50 * If the notification already exists, it is replaced (allows updating content).
51 * Does NOT add if the category is currently disabled in settings.
52 *
53 * @param string $id Unique notification ID, e.g. 'health_failed_donations'.
54 * @param array $data Notification data. Same structure as feed notifications:
55 * id, title, content, notification_type, dismissible,
56 * allow_on_activation, btns, badge_label, badge_type, category.
57 * @return void
58 */
59 public function add( $id, array $data ) {
60 $notifications = Charitable_Notifications::get_instance();
61 $option = $notifications->get_option( false );
62
63 $data['id'] = $id;
64 $data['allow_on_activation'] = true;
65
66 $local = $option['local'];
67 $existing_start = '';
68
69 // Replace existing entry with same ID, preserving original start time.
70 foreach ( $local as $k => $item ) {
71 if ( isset( $item['id'] ) && $item['id'] === $id ) {
72 $existing_start = $item['start'] ?? '';
73 unset( $local[ $k ] );
74 break;
75 }
76 }
77
78 if ( ! isset( $data['start'] ) ) {
79 $data['start'] = $existing_start ?: current_time( 'mysql' );
80 }
81
82 $local[] = $data;
83
84 $notifications->save_option_partial( [ 'local' => array_values( $local ) ] );
85 }
86
87 /**
88 * Remove a local notification entirely (use when condition is resolved).
89 *
90 * @param string $id Notification ID.
91 * @return void
92 */
93 public function remove( $id ) {
94 $notifications = Charitable_Notifications::get_instance();
95 $option = $notifications->get_option( false );
96
97 $local = array_filter( $option['local'], function( $item ) use ( $id ) {
98 return ! ( isset( $item['id'] ) && $item['id'] === $id );
99 } );
100
101 $notifications->save_option_partial( [ 'local' => array_values( $local ) ] );
102 }
103
104 /**
105 * Get all local notifications that should currently be displayed,
106 * accounting for dismissal and re-surface intervals.
107 *
108 * @return array
109 */
110 public function get_displayable() {
111 $notifications = Charitable_Notifications::get_instance();
112 $option = $notifications->get_option();
113 $local = $option['local'];
114 $dismissed = $option['dismissed'];
115 $times = $option['dismissed_times'];
116 $settings = Charitable_Notification_Settings::get_instance();
117 $now = time();
118 $to_undismiss = [];
119
120 $result = [];
121
122 foreach ( $local as $item ) {
123 if ( empty( $item['id'] ) ) {
124 continue;
125 }
126
127 $id = (string) $item['id'];
128 $category = isset( $item['category'] ) ? $item['category'] : 'nudge';
129
130 // Skip if category disabled.
131 if ( ! $settings->is_category_enabled( $category ) ) {
132 continue;
133 }
134
135 // Check if dismissed.
136 if ( in_array( $id, $dismissed, true ) ) {
137 // Check re-surface interval.
138 if ( isset( $times[ $id ] ) ) {
139 $interval = $this->get_resurface_interval( $category );
140 if ( $interval > 0 && ( $now - (int) $times[ $id ] ) >= $interval ) {
141 // Interval passed — un-dismiss so it shows again.
142 $to_undismiss[] = $id;
143 } else {
144 continue; // Still suppressed.
145 }
146 } else {
147 continue; // Dismissed with no time recorded — permanent.
148 }
149 }
150
151 $result[] = $item;
152 }
153
154 // Un-dismiss any notifications whose resurface interval has passed.
155 if ( ! empty( $to_undismiss ) ) {
156 $this->clear_dismissed( $to_undismiss );
157 }
158
159 return $result;
160 }
161
162 /**
163 * Inject displayable local notifications into the main feed.
164 *
165 * Local notifications are sorted by category priority within the local group,
166 * then prepended to the main feed (after dynamic license notifications which
167 * are injected at priority 5).
168 *
169 * @param array $notifications Current notifications.
170 * @return array
171 */
172 public function inject_local_notifications( array $notifications ) {
173 $local = $this->get_displayable();
174
175 if ( empty( $local ) ) {
176 return $notifications;
177 }
178
179 // Sort local notifications by category priority.
180 usort( $local, function( $a, $b ) {
181 $pa = isset( self::CATEGORY_PRIORITY[ $a['category'] ] ) ? self::CATEGORY_PRIORITY[ $a['category'] ] : 99;
182 $pb = isset( self::CATEGORY_PRIORITY[ $b['category'] ] ) ? self::CATEGORY_PRIORITY[ $b['category'] ] : 99;
183 return $pa - $pb;
184 } );
185
186 // Insert after license notifications (which are at position 0-1) — find
187 // first non-license entry and insert before it.
188 $insert_at = 0;
189 $notifications = array_values( $notifications );
190 foreach ( $notifications as $i => $n ) {
191 $id = isset( $n['id'] ) ? $n['id'] : '';
192 if ( 'license_expired' !== $id && 'license_expiring' !== $id ) {
193 $insert_at = $i;
194 break;
195 }
196 $insert_at = $i + 1;
197 }
198
199 array_splice( $notifications, $insert_at, 0, $local );
200
201 return $notifications;
202 }
203
204 /**
205 * Record the dismissal timestamp for a local notification (called before the
206 * core dismiss handler at priority 5, so we can capture the ID).
207 */
208 public function record_dismiss_time() {
209 if (
210 ! check_ajax_referer( 'charitable-admin', 'nonce', false ) ||
211 ! current_user_can( 'manage_options' )
212 ) {
213 return; // Let the core handler send the error response.
214 }
215
216 $id = isset( $_POST['notification_id'] ) ? sanitize_key( $_POST['notification_id'] ) : ''; // phpcs:ignore
217 if ( empty( $id ) ) {
218 return;
219 }
220 $this->store_dismiss_time( $id );
221 }
222
223 /**
224 * Record dismissal timestamps for multiple notifications.
225 */
226 public function record_dismiss_time_multiple() {
227 if (
228 ! check_ajax_referer( 'charitable-admin', 'nonce', false ) ||
229 ! current_user_can( 'manage_options' )
230 ) {
231 return; // Let the core handler send the error response.
232 }
233
234 $ids = isset( $_POST['notification_ids'] ) ? (array) $_POST['notification_ids'] : []; // phpcs:ignore
235 $ids = array_slice( $ids, 0, 50 );
236 foreach ( $ids as $id ) {
237 $this->store_dismiss_time( sanitize_key( $id ) );
238 }
239 }
240
241 /**
242 * Write dismissal timestamp to dismissed_times in the option.
243 *
244 * @param string $id Notification ID.
245 */
246 private function store_dismiss_time( $id ) {
247 $notifications = Charitable_Notifications::get_instance();
248 $option = $notifications->get_option( false );
249 $times = $option['dismissed_times'];
250 $times[ $id ] = time();
251 $notifications->save_option_partial( [ 'dismissed_times' => $times ] );
252 }
253
254 /**
255 * Remove IDs from dismissed and dismissed_times.
256 *
257 * @param array $ids
258 */
259 private function clear_dismissed( array $ids ) {
260 $notifications = Charitable_Notifications::get_instance();
261 $option = $notifications->get_option( false );
262
263 $dismissed = array_filter( $option['dismissed'], function( $id ) use ( $ids ) {
264 return ! in_array( (string) $id, $ids, true );
265 } );
266
267 $times = $option['dismissed_times'];
268 foreach ( $ids as $id ) {
269 unset( $times[ $id ] );
270 }
271
272 $notifications->save_option_partial( [
273 'dismissed' => array_values( $dismissed ),
274 'dismissed_times' => $times,
275 ] );
276 }
277
278 /**
279 * Get the re-surface interval in seconds for a given category.
280 *
281 * All intervals filterable. 0 = never re-surface.
282 *
283 * @param string $category
284 * @return int Seconds.
285 */
286 private function get_resurface_interval( $category ) {
287 $defaults = [
288 'health' => apply_filters( 'charitable_notifications_health_resurface', DAY_IN_SECONDS ),
289 'legal' => apply_filters( 'charitable_notifications_compliance_resurface', WEEK_IN_SECONDS ),
290 'nudge' => apply_filters( 'charitable_notifications_nudges_resurface', WEEK_IN_SECONDS ),
291 'lifecycle' => 0, // one-time per event.
292 'milestone' => 0, // one-time per milestone.
293 ];
294 return isset( $defaults[ $category ] ) ? (int) $defaults[ $category ] : 0;
295 }
296 }
297