PluginProbe
Filter Everything — WordPress & WooCommerce Filters / 1.9.5
Filter Everything — WordPress & WooCommerce Filters v1.9.5
1.9.6 1.9.5 1.9.4 1.9.3 1.9.2.2 1.9.2.1 trunk 1.2.1 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.1 1.4.4 1.4.5 1.4.8 1.4.9 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 All 51 releases
filter-everything / src / Admin / AdminNotices.php

AdminNotices.php in Filter Everything — WordPress & WooCommerce Filters 1.9.5, at src/Admin/AdminNotices.php

347 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 namespace FilterEverything\Filter;
5
6 if ( ! defined('ABSPATH') ) {
7 exit;
8 }
9
10 /**
11 * Lightweight, reusable admin-notice manager.
12 *
13 * To add or change a notice, edit self::notices() only — each entry fully
14 * describes its text, type and the event it is tied to. The rest (free/PRO
15 * gating, capability, rendering, persistent dismissal) is handled generically.
16 *
17 * Notice fields:
18 * id unique slug, chars [a-z0-9_-] (dismissal key + CSS class + AJAX scope)
19 * type info | success | warning | error
20 * message string (pre-escaped) OR a callable returning the escaped HTML
21 * trigger 'always' | 'update' | callable returning bool
22 * free_only bool — hide in the PRO build (default false)
23 * capability capability required to see/dismiss it (default flrt_plugin_user_caps())
24 * dismissible bool — show the "X" and remember the dismissal permanently, per id
25 *
26 * The 'update' trigger fires only after an existing install is updated (never
27 * on a fresh install): Plugin::activate() stamps the version on fresh installs,
28 * so a missing/older stamp means an in-place update happened.
29 */
30 class AdminNotices
31 {
32 /** Last-seen plugin version on this site. */
33 const VERSION_OPTION = 'flrt_version';
34
35 /** Version the site was most recently updated to (drives the 'update' trigger). */
36 const UPDATED_OPTION = 'flrt_updated_to';
37
38 /** Array of permanently dismissed notice ids. */
39 const DISMISSED_OPTION = 'flrt_dismissed_notices';
40
41 /** Map of notice id => unix time when first shown (drives 'expires_after'). */
42 const STARTED_OPTION = 'flrt_notice_started';
43
44 /** Shared AJAX action / nonce for dismissing any notice. */
45 const DISMISS_ACTION = 'flrt_dismiss_notice';
46
47 /**
48 * Preview mode — for fine-tuning notice text and appearance during development.
49 *
50 * When true: every notice is shown on every admin page (trigger, expiry and
51 * dismissal state are ignored) and the "X" only hides it client-side, so it
52 * reappears on reload. Keep false in production.
53 */
54 const PREVIEW_MODE = false;
55
56 public function __construct()
57 {
58 add_action( 'admin_init', [ $this, 'detectUpdate' ] );
59 add_action( 'admin_init', [ $this, 'maybeAutoDismiss' ] );
60 add_action( 'admin_notices', [ $this, 'renderAll' ] );
61 add_action( 'wp_ajax_' . self::DISMISS_ACTION, [ $this, 'ajaxDismiss' ] );
62 }
63
64 /**
65 * Single source of truth for admin notices. Add one array entry per notice.
66 *
67 * @return array[]
68 */
69 protected function notices()
70 {
71 return [
72 [
73 'id' => 'security-1922',
74 'type' => 'warning',
75 'free_only' => false, // true = Free only; false = show in both Free and PRO
76 'trigger' => 'update',
77 'expires_after' => DAY_IN_SECONDS,
78 'auto_dismiss' => function () {
79 // Auto-hide once the user opens the Color Swatches (Experimental) settings tab.
80 return isset( $_GET['page'], $_GET['tab'] )
81 && sanitize_key( wp_unslash( $_GET['page'] ) ) === 'filters-settings'
82 && sanitize_key( wp_unslash( $_GET['tab'] ) ) === 'experimental';
83 },
84 'message' => function () {
85 $settings_url = admin_url( 'edit.php?post_type=' . FLRT_FILTERS_SET_POST_TYPE . '&page=filters-settings&tab=experimental' );
86
87 return sprintf(
88 /* translators: 1: opening <a> tag to the plugin settings page, 2: closing </a> tag. */
89 wp_kses(
90 __( 'Thank you for updating Filter Everything! This release includes a security update related to how <strong>Color swatches</strong> are rendered. Everything should work fine, but just in case, please check how your Color swatches look in the filters on your site\'s pages. You can see the list of Color swatches you use on %1$s<strong>this settings page</strong>%2$s.', 'filter-everything' ),
91 [ 'strong' => [], 'a' => [ 'href' => [] ] ]
92 ),
93 '<a href="' . esc_url( $settings_url ) . '">',
94 '</a>'
95 );
96 },
97 ],
98 ];
99 }
100
101 /**
102 * Records that an existing install was updated, so 'update' notices fire.
103 * Fresh installs are pre-stamped in Plugin::activate() and match here.
104 */
105 public function detectUpdate()
106 {
107 if ( self::PREVIEW_MODE ) {
108 return;
109 }
110
111 $stored = get_option( self::VERSION_OPTION, false );
112
113 if ( $stored === FLRT_PLUGIN_VER ) {
114 return;
115 }
116
117 update_option( self::UPDATED_OPTION, FLRT_PLUGIN_VER );
118 update_option( self::VERSION_OPTION, FLRT_PLUGIN_VER );
119 }
120
121 /**
122 * Runs each notice's optional 'auto_dismiss' condition (e.g. "the user opened
123 * the relevant settings page") and dismisses it permanently when it matches.
124 */
125 public function maybeAutoDismiss()
126 {
127 if ( self::PREVIEW_MODE ) {
128 return;
129 }
130
131 foreach ( $this->notices() as $notice ) {
132 if ( empty( $notice['id'] ) || empty( $notice['auto_dismiss'] ) || ! is_callable( $notice['auto_dismiss'] ) ) {
133 continue;
134 }
135 if ( $this->isDismissed( $notice['id'] ) ) {
136 continue;
137 }
138 if ( call_user_func( $notice['auto_dismiss'] ) ) {
139 $this->markDismissed( $notice['id'] );
140 }
141 }
142 }
143
144 public function renderAll()
145 {
146 foreach ( $this->notices() as $notice ) {
147 $this->maybeRender( $notice );
148 }
149 }
150
151 protected function maybeRender( array $notice )
152 {
153 $notice = array_merge(
154 [
155 'id' => '',
156 'type' => 'info',
157 'message' => '',
158 'trigger' => 'always',
159 'free_only' => false,
160 'capability' => flrt_plugin_user_caps(),
161 'dismissible' => true,
162 ],
163 $notice
164 );
165
166 if ( $notice['id'] === '' ) {
167 return;
168 }
169
170 if ( $notice['free_only'] && defined( 'FLRT_FILTERS_PRO' ) && FLRT_FILTERS_PRO ) {
171 return;
172 }
173
174 if ( $notice['capability'] && ! current_user_can( $notice['capability'] ) ) {
175 return;
176 }
177
178 if ( ! self::PREVIEW_MODE ) {
179 if ( $this->isDismissed( $notice['id'] ) ) {
180 return;
181 }
182 if ( ! $this->triggerPasses( $notice['trigger'] ) ) {
183 return;
184 }
185 if ( $this->hasExpired( $notice ) ) {
186 return;
187 }
188 }
189
190 $message = is_callable( $notice['message'] ) ? call_user_func( $notice['message'] ) : $notice['message'];
191 if ( $message === '' ) {
192 return;
193 }
194
195 $notice_class = 'flrt-notice-' . sanitize_html_class( $notice['id'] );
196
197 if ( function_exists( 'wp_admin_notice' ) ) {
198 // Modern WordPress notice API (WP 6.4+).
199 wp_admin_notice(
200 $message,
201 [
202 'type' => $notice['type'],
203 'dismissible' => (bool) $notice['dismissible'],
204 'additional_classes' => [ 'flrt-admin-notice', $notice_class ],
205 ]
206 );
207 } else {
208 // Fallback for WordPress < 6.4 ($message is already escaped above).
209 printf(
210 '<div class="notice notice-%1$s%2$s flrt-admin-notice %3$s"><p>%4$s</p></div>',
211 esc_attr( $notice['type'] ),
212 $notice['dismissible'] ? ' is-dismissible' : '',
213 esc_attr( $notice_class ),
214 $message // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built with wp_kses()/esc_url() above
215 );
216 }
217
218 // Persist the dismissal only in live mode; in preview the "X" is client-side only.
219 if ( ! self::PREVIEW_MODE && $notice['dismissible'] ) {
220 $this->printDismissScript( $notice['id'], $notice_class );
221 }
222 }
223
224 protected function triggerPasses( $trigger )
225 {
226 if ( $trigger === 'always' ) {
227 return true;
228 }
229
230 if ( $trigger === 'update' ) {
231 return get_option( self::UPDATED_OPTION ) === FLRT_PLUGIN_VER;
232 }
233
234 if ( is_callable( $trigger ) ) {
235 return (bool) call_user_func( $trigger );
236 }
237
238 return false;
239 }
240
241 protected function isDismissed( $id )
242 {
243 return in_array( $id, (array) get_option( self::DISMISSED_OPTION, [] ), true );
244 }
245
246 protected function markDismissed( $id )
247 {
248 $dismissed = (array) get_option( self::DISMISSED_OPTION, [] );
249 if ( ! in_array( $id, $dismissed, true ) ) {
250 $dismissed[] = $id;
251 update_option( self::DISMISSED_OPTION, $dismissed );
252 }
253 }
254
255 /**
256 * True once a notice with an 'expires_after' (seconds) has been visible that
257 * long. The first-shown time is stamped per id on the first eligible render.
258 */
259 protected function hasExpired( array $notice )
260 {
261 if ( empty( $notice['expires_after'] ) ) {
262 return false;
263 }
264
265 $started = (array) get_option( self::STARTED_OPTION, [] );
266 if ( ! isset( $started[ $notice['id'] ] ) ) {
267 $started[ $notice['id'] ] = time();
268 update_option( self::STARTED_OPTION, $started );
269 }
270
271 return ( time() - (int) $started[ $notice['id'] ] ) >= (int) $notice['expires_after'];
272 }
273
274 /**
275 * Persists the dismissal of a specific notice when its "X" is clicked.
276 */
277 protected function printDismissScript( $id, $notice_class )
278 {
279 ?>
280 <script>
281 ( function () {
282 var notice = document.querySelector( <?php echo wp_json_encode( '.' . $notice_class ); ?> );
283 if ( ! notice ) {
284 return;
285 }
286 notice.addEventListener( 'click', function ( e ) {
287 if ( ! e.target.closest( '.notice-dismiss' ) ) {
288 return;
289 }
290 var body = new URLSearchParams();
291 body.append( 'action', <?php echo wp_json_encode( self::DISMISS_ACTION ); ?> );
292 body.append( 'id', <?php echo wp_json_encode( $id ); ?> );
293 body.append( 'nonce', <?php echo wp_json_encode( wp_create_nonce( self::DISMISS_ACTION ) ); ?> );
294 fetch( <?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>, {
295 method: 'POST',
296 credentials: 'same-origin',
297 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
298 body: body.toString()
299 } );
300 } );
301 } )();
302 </script>
303 <?php
304 }
305
306 /**
307 * AJAX handler — permanently marks a notice as dismissed.
308 */
309 public function ajaxDismiss()
310 {
311 check_ajax_referer( self::DISMISS_ACTION, 'nonce' );
312
313 $id = isset( $_POST['id'] ) ? sanitize_key( wp_unslash( $_POST['id'] ) ) : '';
314 $notice = $this->findNotice( $id );
315
316 if ( ! $notice ) {
317 wp_send_json_error( null, 400 );
318 }
319
320 $capability = ! empty( $notice['capability'] ) ? $notice['capability'] : flrt_plugin_user_caps();
321 if ( ! current_user_can( $capability ) ) {
322 wp_send_json_error( null, 403 );
323 }
324
325 $this->markDismissed( $id );
326
327 wp_send_json_success();
328 }
329
330 protected function findNotice( $id )
331 {
332 if ( $id === '' ) {
333 return null;
334 }
335
336 foreach ( $this->notices() as $notice ) {
337 if ( isset( $notice['id'] ) && $notice['id'] === $id ) {
338 return $notice;
339 }
340 }
341
342 return null;
343 }
344 }
345
346 new AdminNotices();
347