PluginProbe
WowOptin: Next-Gen Popup Maker – Create Stunning Popups and Optins for Lead Generation / trunk
WowOptin: Next-Gen Popup Maker – Create Stunning Popups and Optins for Lead Generation vtrunk
1.4.48 1.4.47 1.4.46 1.4.45 1.4.44 1.4.43 1.4.42 1.4.41 1.4.40 1.4.39 1.4.38 1.4.37 1.4.36 1.1.2 1.2.0 1.2.1 1.2.2 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 All 64 releases
optin / includes / notice / class-notice.php

class-notice.php in WowOptin: Next-Gen Popup Maker – Create Stunning Popups and Optins for Lead Generation trunk, at includes/notice/class-notice.php

582 lines 16.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php //phpcs:ignore
2 namespace OPTN\Includes\Notice;
3
4 use OPTN\Includes\Utils\DurbinClient;
5 use OPTN\Includes\Xpo;
6
7 defined( 'ABSPATH' ) || exit;
8 /**
9 * Promo system facade. Data lives in promos/, one file per surface; all
10 * surfaces share config() and the promo_is_live() date gate.
11 *
12 * promos/notice.php -> rendered here on admin_notices, via templates/<type>.php
13 * promos/hellobar.php -> get_hellobar_config(), localized for React
14 * promos/plugin-meta.php -> get_active_promo(), called by the plugin's
15 * plugin_action_links / plugin_row_meta handler
16 *
17 * One thing here is NOT promo-driven and just lives in this file:
18 * render_durbin_consent_box() -> the data-collection consent box
19 */
20 class Notice {
21
22
23 /**
24 * Per-plugin identity. See config.php.
25 *
26 * @var array
27 */
28 private $config = array();
29
30 /**
31 * The complete set of promo surfaces: every place in wp-admin a promo can
32 * appear. Deliberately a closed, hand-written list, unlike templates/ which
33 * is open-ended by design — see get_promos() and template_for().
34 *
35 * @var array<string, string> surface slug => file under promos/.
36 */
37 private const SURFACES = array(
38 'notice' => 'notice.php',
39 'hellobar' => 'hellobar.php',
40 'plugin-meta' => 'plugin-meta.php',
41 );
42
43
44 /**
45 * Notice Constructor
46 */
47 public function __construct() {
48 $this->config = self::config();
49
50 add_action( 'admin_notices', array( $this, 'admin_notices_callback' ) );
51 add_action( 'admin_init', array( $this, 'set_dismiss_notice_callback' ) );
52
53 // REST API routes.
54 add_action( 'rest_api_init', array( $this, 'register_rest_route' ) );
55
56 add_filter( 'xpo_active_notice_lists', array( $this, 'handle_xpo_active_notice_lists' ), 99, 1 );
57 }
58
59 /**
60 * Per-plugin identity, shared by every promo surface.
61 *
62 * Always read config.php through here: it RETURNS an array, so a caller
63 * using require_once would get `true` on the second call.
64 *
65 * @return array
66 */
67 public static function config() {
68 static $config = null;
69
70 if ( null === $config ) {
71 $config = require __DIR__ . '/config.php';
72 }
73
74 return $config;
75 }
76
77 /**
78 * A developer typo: throw in dev so it's caught, ignore in production so a
79 * shipped mistake can't white-screen every admin page.
80 *
81 * @param string $message What was wrong.
82 * @return void
83 */
84 private static function fail_in_dev( $message ) {
85 if ( ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || defined( 'XPO_DEV_MODE' ) ) {
86 $config = self::config();
87 throw new \RuntimeException( $config['brand_name'] . ' promos: ' . $message );
88 }
89 }
90
91 /**
92 * Load one surface's promo list from promos/.
93 *
94 * The filename comes from the fixed SURFACES map, not from $surface: the
95 * surface set is closed, unlike templates/. See "Adding a fourth surface"
96 * in README.md.
97 *
98 * The locals below are inherited by the included file — that is how promos/
99 * files reach $prefix, $asset_url, etc. without requiring config.php.
100 *
101 * @param string $surface One of the SURFACES keys.
102 * @return array
103 */
104 public static function get_promos( $surface = 'notice' ) {
105 $config = self::config();
106 $prefix = $config['prefix'];
107 $asset_url = $config['asset_url'];
108 $brand_name = $config['brand_name'];
109 $brand_color = $config['brand_color'];
110
111 if ( ! isset( self::SURFACES[ $surface ] ) ) {
112 self::fail_in_dev( sprintf( 'unknown surface "%s" (known: %s)', $surface, implode( ', ', array_keys( self::SURFACES ) ) ) );
113 return array();
114 }
115
116 // A known surface with no file is legitimate, not a typo: a plugin
117 // without a hello bar just deletes promos/hellobar.php.
118 $file = __DIR__ . '/promos/' . self::SURFACES[ $surface ];
119 if ( ! is_readable( $file ) ) {
120 return array();
121 }
122
123 // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- filename comes from SURFACES.
124 $promos = include $file;
125
126 return is_array( $promos ) ? $promos : array();
127 }
128
129 /**
130 * First live promo on a surface, for the surfaces that show only one thing.
131 * The notice surface shows one per type, so it loops in render_notices().
132 *
133 * @param string $surface Surface slug.
134 * @return array|null Promo entry, or null when nothing is live.
135 */
136 public static function get_active_promo( $surface ) {
137 foreach ( self::get_promos( $surface ) as $promo ) {
138 if ( self::promo_is_live( $promo ) ) {
139 return $promo;
140 }
141 }
142
143 return null;
144 }
145
146 /**
147 * Resolve a promo's `type` to templates/<type>.php. No map to maintain:
148 * adding a design means dropping in a template and naming the type after it.
149 *
150 * @param string $type Promo type, from a promos/ entry (never user input).
151 * @return string Absolute template path, or '' if the type is unknown.
152 */
153 private function template_for( $type ) {
154 if ( is_string( $type ) && preg_match( '/^[a-z0-9_-]+$/', $type ) ) {
155 $template = __DIR__ . '/templates/' . $type . '.php';
156 if ( is_readable( $template ) ) {
157 return $template;
158 }
159 }
160
161 self::fail_in_dev( sprintf( 'no template for type "%s" (expected templates/%s.php)', $type, $type ) );
162 return '';
163 }
164
165 /**
166 * The one date gate, shared by all three surfaces: dismiss-in-flight
167 * ($_GET), date window, visibility, dismissed transient.
168 *
169 * XPO_DEV_MODE forces every start date to 2026-01-01, so upcoming promos
170 * are visible without editing promos/.
171 *
172 * @param array $notice Promo entry.
173 * @return bool
174 */
175 private static function promo_is_live( $notice ) {
176 $config = self::config();
177
178 if ( empty( $notice['key'] ) ) {
179 return false;
180 }
181 $notice_key = $notice['key'];
182 $dismiss_arg = 'disable_' . $config['prefix'] . '_notice';
183
184 if ( isset( $_GET[ $dismiss_arg ] ) && $notice_key === sanitize_text_field( wp_unslash( $_GET[ $dismiss_arg ] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
185 return false;
186 }
187
188 $current_time = gmdate( 'U' );
189 $start = defined( 'XPO_DEV_MODE' ) ? '2026-01-01 00:00 Asia/Dhaka' : $notice['start'];
190 $notice_start = gmdate( 'U', strtotime( $start ) );
191 $notice_end = gmdate( 'U', strtotime( $notice['end'] ) );
192
193 if ( $current_time < $notice_start || $current_time > $notice_end || empty( $notice['visibility'] ) ) {
194 return false;
195 }
196
197 if ( 'off' === Xpo::get_transient_without_cache( $config['prefix'] . '_get_pro_notice_' . $notice_key ) ) {
198 return false;
199 }
200
201 return true;
202 }
203
204 /**
205 * Does this plugin have at least one promo eligible to show right now?
206 *
207 * @return bool
208 */
209 private function has_active_promo() {
210 foreach ( self::get_promos( 'notice' ) as $notice ) {
211 $type = isset( $notice['type'] ) ? $notice['type'] : '';
212 if ( $this->template_for( $type ) && self::promo_is_live( $notice ) ) {
213 return true;
214 }
215 }
216
217 return false;
218 }
219
220 /**
221 * Handle Plugin Notice for all plugins
222 *
223 * @param array $active_lists Lists of all active plugin notice.
224 * @return array
225 */
226 public function handle_xpo_active_notice_lists( $active_lists ) {
227
228 if ( $this->has_active_promo() ) {
229 $active_lists[ $this->config['priority_key'] ] = $this->config['priority'];
230 }
231
232 return $active_lists;
233 }
234
235 /**
236 * Handle Plugin Notice for all plugins
237 *
238 * @return bool
239 */
240 public function is_available_for_notice() {
241 $active_notices = apply_filters( 'xpo_active_notice_lists', array() );
242
243 if ( empty( $active_notices ) ) {
244 return true;
245 }
246
247 asort( $active_notices );
248
249 return array_key_first( $active_notices ) === $this->config['priority_key'];
250 }
251
252
253 /**
254 * Registers REST API endpoints.
255 *
256 * @return void
257 */
258 public function register_rest_route() {
259 $routes = array(
260 // Hello Bar.
261 array(
262 'endpoint' => 'hello_bar',
263 'methods' => 'POST',
264 'callback' => array( $this, 'hello_bar_callback' ),
265 'permission_callback' => function () {
266 return current_user_can( 'manage_options' );
267 },
268 ),
269 );
270
271 // Namespace tracks config.php's prefix, so this file carries no plugin
272 // literal. The React side must use the same string — see README.
273 // example api: optn/v1/hello_bar
274 $rest_namespace = $this->config['prefix'] . '/v1';
275
276 foreach ( $routes as $route ) {
277 register_rest_route(
278 $rest_namespace,
279 $route['endpoint'],
280 array(
281 array(
282 'methods' => $route['methods'],
283 'callback' => $route['callback'],
284 'permission_callback' => $route['permission_callback'],
285 ),
286 )
287 );
288 }
289 }
290
291 /**
292 * The live hello-bar promo, ready to render. The plugin's admin-menu class
293 * localizes this into its JS object under `helloBar`. PHP resolves which
294 * promo is live and builds its URL, so the React bundle holds no promo list
295 * or date logic.
296 *
297 * @return array|null Null when no promo is live (React renders nothing).
298 */
299 public static function get_hellobar_config() {
300 $promo = null;
301
302 foreach ( self::get_promos( 'hellobar' ) as $candidate ) {
303 // Hello-bar dismissal writes 'hide' under the promo key itself, not
304 // the notices' transient. Don't unify: it would un-dismiss the bar
305 // for everyone who already closed it.
306 if ( 'hide' === Xpo::get_transient_without_cache( $candidate['key'] ) ) {
307 continue;
308 }
309
310 if ( self::promo_is_live( $candidate ) ) {
311 $promo = $candidate;
312 break;
313 }
314 }
315
316 if ( ! $promo ) {
317 return null;
318 }
319
320 $config = self::config();
321
322 return array(
323 'id' => $promo['key'],
324 'brandColor' => $config['brand_color'],
325 'text' => isset( $promo['text'] ) ? $promo['text'] : '',
326 'highlight' => isset( $promo['highlight'] ) ? $promo['highlight'] : '',
327 'url' => isset( $promo['url'] ) ? $promo['url'] : '',
328 'countdownDuration' => isset( $promo['countdown_duration'] ) ? (int) $promo['countdown_duration'] : 0,
329 );
330 }
331
332 /**
333 * Handles Hello Bar dismissal action via REST API .
334 *
335 * @param \WP_REST_Request $request REST request object .
336 * @return \WP_REST_Response
337 */
338 public function hello_bar_callback( \WP_REST_Request $request ) {
339 $request_params = $request->get_params();
340 $type = isset( $request_params['type'] ) ? $request_params['type'] : '';
341 $id = isset( $request_params['id'] ) ? $request_params['id'] : '';
342
343 if ( 'hello_bar' === $type && ! empty( $id ) ) {
344 // we are setting the transient for 15 days to hide the hello bar.
345 Xpo::set_transient_without_cache( $id, 'hide', 15 * DAY_IN_SECONDS );
346 }
347
348 return new \WP_REST_Response(
349 array(
350 'success' => true,
351 'message' => __( 'Hello Bar Action performed', 'optin' ),
352 ),
353 200
354 );
355 }
356
357 /**
358 * Set Notice Dismiss Callback
359 *
360 * @return void
361 */
362 public function set_dismiss_notice_callback() {
363 $prefix = $this->config['prefix'];
364
365 if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['wpnonce'] ?? '' ) ), $prefix . '-nonce' ) ) {
366 return;
367 }
368
369 $durbin_key = sanitize_text_field( wp_unslash( $_GET[ $prefix . '_durbin_key' ] ?? '' ) );
370
371 // Durbin notice dismiss.
372 if ( ! empty( $durbin_key ) ) {
373 Xpo::set_transient_without_cache( $prefix . '_durbin_notice_' . $durbin_key, 'off' );
374
375 if ( 'get' === sanitize_text_field( wp_unslash( $_GET[ $prefix . '_get_durbin' ] ?? '' ) ) ) {
376 DurbinClient::send( DurbinClient::ACTIVATE_ACTION );
377 }
378 }
379
380 $notice_key = sanitize_text_field( wp_unslash( $_GET[ 'disable_' . $prefix . '_notice' ] ?? '' ) );
381 if ( ! empty( $notice_key ) ) {
382 $interval = (int) sanitize_text_field( wp_unslash( $_GET[ $prefix . '_interval' ] ?? '' ) );
383 if ( ! empty( $interval ) ) {
384 Xpo::set_transient_without_cache( $prefix . '_get_pro_notice_' . $notice_key, 'off', $interval );
385 } else {
386 Xpo::set_transient_without_cache( $prefix . '_get_pro_notice_' . $notice_key, 'off' );
387 }
388 }
389 }
390
391 /**
392 * Admin Notices Callback
393 *
394 * @return void
395 */
396 public function admin_notices_callback() {
397 if ( $this->is_available_for_notice() ) {
398 $this->render_notices( self::get_promos( 'notice' ) );
399 }
400 $this->render_durbin_consent_box();
401 }
402
403 /**
404 * Render at most one live promo per type (first match wins), each through
405 * its own template. XPO_DEV_MODE drops the one-per-type limit so every
406 * design can be checked at once.
407 *
408 * @param array $promos List of promo entries.
409 * @return void
410 */
411 private function render_notices( $promos ) {
412 $shown = array();
413 $enforce_one_per_type = ! defined( 'XPO_DEV_MODE' );
414 $prefix = $this->config['prefix'];
415 $brand_color = $this->config['brand_color'];
416
417 foreach ( $promos as $notice ) {
418 $type = isset( $notice['type'] ) ? $notice['type'] : '';
419
420 if ( $enforce_one_per_type && isset( $shown[ $type ] ) ) {
421 continue;
422 }
423
424 $template = $this->template_for( $type );
425 if ( ! $template || ! self::promo_is_live( $notice ) ) {
426 continue;
427 }
428
429 $query_args = array(
430 'disable_' . $prefix . '_notice' => $notice['key'],
431 'wpnonce' => wp_create_nonce( $prefix . '-nonce' ),
432 );
433 if ( ! empty( $notice['repeat_interval'] ) ) {
434 $query_args[ $prefix . '_interval' ] = $notice['repeat_interval'];
435 }
436
437 // $notice, $query_args, $prefix and $brand_color are in scope for the template.
438 include $template;
439
440 $shown[ $type ] = true;
441 }
442 }
443
444 /**
445 * The Durbin Html
446 *
447 * @return void
448 */
449 public function render_durbin_consent_box() {
450 $prefix = $this->config['prefix'];
451 $durbin_key = $prefix . '_durbin_dc1';
452 $consent_box = $prefix . '-consent-box';
453 $consent_cont = $prefix . '-consent-content';
454 $text_first = $prefix . '-consent-text-first';
455 $text_last = $prefix . '-consent-text-last';
456 $accept_btn = $prefix . '-consent-accept';
457 $close_link = $prefix . '-notice-close';
458 $close_icon = $prefix . '-notice-close-icon';
459 $text_group = $prefix . '-consent-text';
460 $wrapper = $prefix . '-notice-wrapper';
461 $brand_name = $this->config['brand_name'];
462
463 if (
464 isset( $_GET[ $prefix . '_durbin_key' ] ) || // phpcs:ignore
465 'off' === Xpo::get_transient_without_cache( $prefix . '_durbin_notice_' . $durbin_key )
466 ) {
467 return;
468 }
469
470 $db_nonce = wp_create_nonce( $prefix . '-nonce' );
471
472 ?>
473 <style>
474 .<?php echo esc_attr( $consent_box ); ?> {
475 width: 656px;
476 padding: 16px;
477 border: 1px solid #070707;
478 border-left-width: 4px;
479 border-radius: 4px;
480 background-color: #fff;
481 position: relative;
482 width: 100%;
483 box-sizing: border-box;
484 }
485 .<?php echo esc_attr( $consent_cont ); ?> {
486 display: flex;
487 justify-content: flex-start;
488 align-items: flex-end;
489 gap: 26px;
490 }
491
492 .<?php echo esc_attr( $text_first ); ?> {
493 font-size: 14px;
494 font-weight: 600;
495 color: #070707;
496 }
497 .<?php echo esc_attr( $text_last ); ?> {
498 margin: 4px 0 0;
499 font-size: 14px;
500 color: #070707;
501 }
502
503 .<?php echo esc_attr( $accept_btn ); ?> {
504 background-color: #070707;
505 color: #fff;
506 border: none;
507 padding: 6px 10px;
508 border-radius: 4px;
509 cursor: pointer;
510 font-size: 12px;
511 font-weight: 600;
512 text-decoration: none;
513 }
514 .<?php echo esc_attr( $accept_btn ); ?>:hover {
515 background-color:rgb(38, 38, 38);
516 color: #fff;
517 }
518 </style>
519 <div class="<?php echo esc_attr( $consent_box . ' ' . $wrapper ); ?> notice data_collection_notice">
520 <div class="<?php echo esc_attr( $consent_cont ); ?>">
521 <div class="<?php echo esc_attr( $text_group ); ?>">
522 <div class="<?php echo esc_attr( $text_first ); ?>">
523 <?php
524 /* translators: %s: plugin brand name, from config.php. */
525 printf( esc_html__( 'Want to help make %s even more awesome?', 'optin' ), esc_html( $brand_name ) );
526 ?>
527 </div>
528 <div class="<?php echo esc_attr( $text_last ); ?>">
529 <?php esc_html_e( 'Allow us to collect diagnostic data and usage information. see ', 'optin' ); ?>
530 <a href="https://www.wpxpo.com/data-collection-policy/" target="_blank" ><?php esc_html_e( 'what we collect.', 'optin' ); ?></a>
531 </div>
532 </div>
533 <a
534 class="<?php echo esc_attr( $accept_btn ); ?>"
535 href=
536 <?php
537 echo esc_url(
538 add_query_arg(
539 array(
540 $prefix . '_durbin_key' => $durbin_key,
541 $prefix . '_get_durbin' => 'get',
542 'wpnonce' => $db_nonce,
543 )
544 )
545 );
546 ?>
547 class="<?php echo esc_attr( $close_link ); ?>"
548 ><?php esc_html_e( 'Accept & Close', 'optin' ); ?></a>
549 </div>
550 <a href=
551 <?php
552 echo esc_url(
553 add_query_arg(
554 array(
555 $prefix . '_durbin_key' => $durbin_key,
556 'wpnonce' => $db_nonce,
557 )
558 )
559 );
560 ?>
561 class="<?php echo esc_attr( $close_link ); ?>"
562 style="
563 position: absolute;
564 right: 2px;
565 top: 5px;
566 text-decoration: unset;
567 color: #b6b6b6;
568 font-family: dashicons;
569 font-size: 16px;
570 font-style: normal;
571 font-weight: 400;
572 line-height: 20px;
573 "
574 >
575 <span
576 style="font-size: 14px;"
577 class="<?php echo esc_attr( $close_icon ); ?> dashicons dashicons-dismiss"> </span></a>
578 </div>
579 <?php
580 }
581 }
582