PluginProbe
Forumax – AI Powered Advanced Community Forum Plugin / 2.4.4
Forumax – AI Powered Advanced Community Forum Plugin v2.4.4
2.5.1 2.5.0 2.4.4 2.4.3 2.4.2 2.4.1 2.4.0 trunk 1.0.8 1.1.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.4.1 All 31 releases
bbp-core / includes / admin / notices / class-remote-notice-client.php

class-remote-notice-client.php in Forumax – AI Powered Advanced Community Forum Plugin 2.4.4, at includes/admin/notices/class-remote-notice-client.php

1,376 lines 45.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Remote Notice Client SDK
4 *
5 * A reusable SDK for integrating remote HTML notices from the NoticePilot API.
6 * Bundle this file with your plugin to enable remote admin notices.
7 *
8 * @package Noticepilot_Remote_Notice_Client
9 * @version 1.6.1
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 if ( ! class_exists( 'Noticepilot_Remote_Notice_Client' ) ) {
17
18 /**
19 * Remote Notice Client Class
20 */
21 class Noticepilot_Remote_Notice_Client {
22
23 /**
24 * SDK version.
25 */
26 const SDK_VERSION = '1.6.1';
27
28 /**
29 * Registered instances
30 *
31 * @var array
32 */
33 private static $instances = array();
34
35 /**
36 * Product identifier
37 *
38 * @var string
39 */
40 private $product;
41
42 /**
43 * API URL
44 *
45 * @var string
46 */
47 private $api_url;
48
49 /**
50 * Cron schedule
51 *
52 * @var string
53 */
54 private $schedule;
55
56 /**
57 * Required capability
58 *
59 * @var string
60 */
61 private $capability;
62
63 /**
64 * Dismiss duration in seconds (default: 1 week, 0 = permanent)
65 *
66 * @var int
67 */
68 private $dismiss_duration;
69
70 /**
71 * Snooze duration in seconds for "Remind me later" (default: 1 week)
72 *
73 * @var int
74 */
75 private $snooze_duration;
76
77 /**
78 * Maximum notices displayed at once (frequency cap)
79 *
80 * @var int
81 */
82 private $max_notices;
83
84 /**
85 * Admin screen IDs where "plugin screens only" campaigns may render
86 *
87 * @var array
88 */
89 private $screen_ids;
90
91 /**
92 * Whether analytics beacons require explicit consent (wp.org guideline 7)
93 *
94 * @var bool
95 */
96 private $require_consent;
97
98 /**
99 * Plugin version for targeting (provided by integrating plugin)
100 *
101 * @var string
102 */
103 private $plugin_version;
104
105 /**
106 * Whether pro version is active (provided by integrating plugin)
107 *
108 * @var bool
109 */
110 private $is_pro;
111
112 /**
113 * Whether to show a deactivation-feedback modal for the host plugin.
114 *
115 * @var bool
116 */
117 private $deactivation_feedback;
118
119 /**
120 * Host plugin basename (e.g. my-plugin/my-plugin.php) for the
121 * deactivation-feedback modal to target on the Plugins screen.
122 *
123 * @var string
124 */
125 private $plugin_file;
126
127 /**
128 * Allow HTML tags for content
129 *
130 * @var array
131 */
132 private $allowed_html;
133
134 /**
135 * Initialize the client
136 *
137 * @param string $product Product identifier.
138 * @param array $config Configuration options.
139 * @return Noticepilot_Remote_Notice_Client|false Instance or false if disabled.
140 */
141 public static function init( $product, $config = array() ) {
142 $product = sanitize_key( $product );
143
144 // Check if disabled (e.g., when Pro is active)
145 if ( self::is_disabled( $product ) ) {
146 return false;
147 }
148
149 if ( isset( self::$instances[ $product ] ) ) {
150 return self::$instances[ $product ];
151 }
152
153 $instance = new self( $product, $config );
154 self::$instances[ $product ] = $instance;
155 $instance->setup_hooks();
156
157 return $instance;
158 }
159
160 /**
161 * Constructor
162 *
163 * @param string $product Product identifier.
164 * @param array $config Configuration options.
165 */
166 private function __construct( $product, $config ) {
167 $this->product = $product;
168 $this->api_url = isset( $config['api_url'] ) ? esc_url_raw( $config['api_url'] ) : '';
169 $this->schedule = isset( $config['schedule'] ) ? $config['schedule'] : 'daily';
170 $this->capability = isset( $config['capability'] ) ? $config['capability'] : 'manage_options';
171 $this->dismiss_duration = isset( $config['dismiss_duration'] ) ? absint( $config['dismiss_duration'] ) : WEEK_IN_SECONDS;
172 $this->snooze_duration = isset( $config['snooze_duration'] ) ? absint( $config['snooze_duration'] ) : WEEK_IN_SECONDS;
173 $this->max_notices = isset( $config['max_notices'] ) ? max( 1, absint( $config['max_notices'] ) ) : 3;
174 $this->screen_ids = isset( $config['screen_ids'] ) && is_array( $config['screen_ids'] ) ? array_map( 'sanitize_key', $config['screen_ids'] ) : array();
175 $this->require_consent = ! empty( $config['require_consent'] );
176 $this->plugin_version = isset( $config['plugin_version'] ) ? sanitize_text_field( $config['plugin_version'] ) : '';
177 $this->is_pro = isset( $config['is_pro'] ) ? (bool) $config['is_pro'] : false;
178 $this->deactivation_feedback = ! empty( $config['deactivation_feedback'] );
179 $this->plugin_file = isset( $config['plugin_file'] ) ? (string) $config['plugin_file'] : '';
180
181 // Define allowed HTML tags for notices
182 $this->allowed_html = array(
183 'div' => array( 'class' => array(), 'style' => array(), 'id' => array() ),
184 'p' => array( 'class' => array(), 'style' => array() ),
185 'span' => array( 'class' => array(), 'style' => array() ),
186 'strong' => array( 'class' => array() ),
187 'em' => array( 'class' => array() ),
188 'b' => array(),
189 'i' => array(),
190 'a' => array( 'href' => array(), 'title' => array(), 'target' => array(), 'class' => array(), 'rel' => array() ),
191 'br' => array(),
192 'h1' => array( 'class' => array() ),
193 'h2' => array( 'class' => array() ),
194 'h3' => array( 'class' => array() ),
195 'h4' => array( 'class' => array() ),
196 'ul' => array( 'class' => array() ),
197 'ol' => array( 'class' => array() ),
198 'li' => array( 'class' => array() ),
199 'img' => array( 'src' => array(), 'alt' => array(), 'class' => array(), 'width' => array(), 'height' => array() ),
200 'style' => array( 'type' => array(), 'media' => array() ),
201 );
202 }
203
204 /**
205 * Setup WordPress hooks
206 */
207 private function setup_hooks() {
208 // Cron. If admin_init has already fired (or is currently firing — e.g. the
209 // integrating plugin called init() from within its own admin_init callback),
210 // schedule immediately; otherwise hook normally. Without this, registering a
211 // callback on admin_init while admin_init is mid-fire silently drops the
212 // callback and the cron event is never scheduled.
213 if ( did_action( 'admin_init' ) || doing_action( 'admin_init' ) ) {
214 $this->init_cron();
215 } else {
216 add_action( 'admin_init', array( $this, 'init_cron' ) );
217 }
218 add_action( 'noticepilot_rnc_fetch_content_' . $this->product, array( $this, 'fetch_content' ) );
219
220 // Enqueue inline CSS/JS via WordPress APIs.
221 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_notice_assets' ) );
222
223 // Display notices.
224 add_action( 'admin_notices', array( $this, 'display_notices' ) );
225
226 // AJAX handlers.
227 add_action( 'wp_ajax_noticepilot_rnc_dismiss_content_' . $this->product, array( $this, 'ajax_dismiss_content' ) );
228
229 // Deactivation-feedback modal (opt-in, Plugins screen only).
230 if ( $this->deactivation_feedback && $this->plugin_file ) {
231 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_deactivation_assets' ) );
232 }
233
234 // Record first-seen once (basis for install-age smart triggers).
235 $this->ensure_first_seen();
236 }
237
238 /**
239 * Enqueue the deactivation-feedback modal assets on the Plugins screen.
240 *
241 * The modal intercepts the host plugin's Deactivate link, asks an
242 * optional (skippable) reason, posts it to the hub's /feedback endpoint
243 * via sendBeacon, then proceeds with deactivation. Never blocks.
244 *
245 * @param string $hook_suffix Current admin page.
246 */
247 public function enqueue_deactivation_assets( $hook_suffix ) {
248 if ( 'plugins.php' !== $hook_suffix ) {
249 return;
250 }
251 if ( ! current_user_can( 'deactivate_plugin', $this->plugin_file ) ) {
252 return;
253 }
254 if ( empty( $this->api_url ) ) {
255 return;
256 }
257
258 $handle = 'noticepilot-rnc-deact-' . $this->product;
259 wp_register_style( $handle, false );
260 wp_enqueue_style( $handle );
261 wp_add_inline_style( $handle, $this->get_deactivation_css() );
262
263 wp_register_script( $handle, false, array(), self::SDK_VERSION, true );
264 wp_enqueue_script( $handle );
265 wp_add_inline_script( $handle, $this->get_deactivation_js() );
266 }
267
268 /**
269 * CSS for the deactivation-feedback modal.
270 *
271 * @return string
272 */
273 private function get_deactivation_css() {
274 return '.rnc-deact-ov{display:none;position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.5);align-items:center;justify-content:center}'
275 . '.rnc-deact-ov.open{display:flex}'
276 . '.rnc-deact{background:#fff;border-radius:10px;max-width:460px;width:92%;padding:22px;box-shadow:0 10px 40px rgba(0,0,0,.25);font-size:13px}'
277 . '.rnc-deact h2{margin:0 0 4px;font-size:16px}'
278 . '.rnc-deact__sub{margin:0 0 14px;color:#6b7280;font-size:12px}'
279 . '.rnc-deact__reasons{list-style:none;margin:0 0 12px;padding:0}'
280 . '.rnc-deact__reasons li{padding:6px 0}'
281 . '.rnc-deact__reasons label{display:flex;gap:8px;align-items:flex-start;cursor:pointer}'
282 . '.rnc-deact textarea{width:100%;margin-top:8px;border:1px solid #d1d5db;border-radius:6px;padding:8px;font-size:13px}'
283 . '.rnc-deact__actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}'
284 . '.rnc-deact__actions .button-link{color:#6b7280;text-decoration:none}';
285 }
286
287 /**
288 * JS for the deactivation-feedback modal (inline, footer).
289 *
290 * @return string
291 */
292 private function get_deactivation_js() {
293 $reasons = array(
294 'better_plugin' => 'I found a better plugin',
295 'missing_feature' => 'It\'s missing a feature I need',
296 'not_working' => 'It stopped working',
297 'temporary' => 'It\'s a temporary deactivation',
298 'no_longer_needed' => 'I no longer need it',
299 'other' => 'Other',
300 );
301
302 $feedback_url = preg_replace( '/\/content\/[^\/]+$/', '/feedback', $this->api_url );
303
304 $li = '';
305 foreach ( $reasons as $key => $label ) {
306 $li .= '<li><label><input type="radio" name="rnc-reason" value="' . esc_attr( $key ) . '"> <span>' . esc_html( $label ) . '</span></label></li>';
307 }
308
309 $modal = '<div class="rnc-deact-ov" id="rnc-deact-' . esc_attr( $this->product ) . '">'
310 . '<div class="rnc-deact" role="dialog" aria-modal="true">'
311 . '<h2>Quick question before you go</h2>'
312 . '<p class="rnc-deact__sub">Anonymous — no personal data is sent. This is optional.</p>'
313 . '<ul class="rnc-deact__reasons">' . $li . '</ul>'
314 . '<textarea rows="2" placeholder="Anything we could do better? (optional)"></textarea>'
315 . '<div class="rnc-deact__actions">'
316 . '<a href="#" class="button-link rnc-deact__skip">Skip &amp; deactivate</a>'
317 . '<button type="button" class="button button-primary rnc-deact__submit">Submit &amp; deactivate</button>'
318 . '</div></div></div>';
319
320 return '(function(){'
321 . 'var pf=' . wp_json_encode( $this->plugin_file ) . ';'
322 . 'var ep=' . wp_json_encode( $this->product ) . ';'
323 . 'var url=' . wp_json_encode( $feedback_url ) . ';'
324 . 'var ver=' . wp_json_encode( $this->plugin_version ) . ';'
325 . 'var row=document.querySelector(\'tr[data-plugin="\'+pf+\'"]\');'
326 . 'if(!row)return;'
327 . 'var link=row.querySelector(".deactivate a");'
328 . 'if(!link)return;'
329 . 'var wrap=document.createElement("div");wrap.innerHTML=' . wp_json_encode( $modal ) . ';'
330 . 'var ov=wrap.firstChild;document.body.appendChild(ov);'
331 . 'var target="";'
332 . 'function go(){window.location.href=target;}'
333 . 'function send(){'
334 . 'var r=ov.querySelector(\'input[name="rnc-reason"]:checked\');'
335 . 'var c=ov.querySelector("textarea").value||"";'
336 . 'if(r&&typeof navigator.sendBeacon==="function"){'
337 . 'var p=JSON.stringify({endpoint:ep,reason:r.value,comment:c,version:ver,site_url:window.location.origin});'
338 . 'navigator.sendBeacon(url,new Blob([p],{type:"application/json"}));}'
339 . '}'
340 . 'link.addEventListener("click",function(e){e.preventDefault();target=link.href;ov.classList.add("open");});'
341 . 'ov.addEventListener("click",function(e){if(e.target===ov){ov.classList.remove("open");}});'
342 . 'ov.querySelector(".rnc-deact__skip").addEventListener("click",function(e){e.preventDefault();go();});'
343 . 'ov.querySelector(".rnc-deact__submit").addEventListener("click",function(){send();setTimeout(go,150);});'
344 . '})();';
345 }
346
347 /**
348 * Enqueue inline CSS for notice styling via WordPress APIs
349 *
350 * Registers a virtual style handle and attaches the notice CSS
351 * so it prints in the document head before notices render.
352 * Also registers the script handle used for per-notice JS.
353 */
354 public function enqueue_notice_assets() {
355 if ( ! current_user_can( $this->capability ) ) {
356 return;
357 }
358
359 $contents = $this->get_non_dismissed_contents();
360
361 if ( empty( $contents ) ) {
362 return;
363 }
364
365 $handle = 'noticepilot-rnc-notice-' . $this->product;
366
367 // Register and enqueue inline CSS.
368 wp_register_style( $handle, false );
369 wp_enqueue_style( $handle );
370 wp_add_inline_style( $handle, $this->get_notice_css() );
371
372 // Register script handle for per-notice inline JS (added in display_notices).
373 wp_register_script( $handle, false, array(), self::SDK_VERSION, true );
374 }
375
376 /**
377 * Get the CSS string for notice wrapper styling
378 *
379 * @return string CSS rules.
380 */
381 private function get_notice_css() {
382 return
383 /* Base — applies to every NoticePilot notice (styled or plain).
384 Only layout/behaviour here; it must NOT strip the border/bg so
385 the selected "Notice style" (WP notice-* classes) can show. */
386 '.rnc-notice-wrapper{position:relative;}'
387 . '.rnc-notice-wrapper img{max-width:100%!important;height:auto}'
388 . '.rnc-notice-wrapper .notice-dismiss{position:absolute!important;top:0!important;right:1px!important;border:none!important;margin:0!important;padding:9px!important;background:none!important;color:#787c82!important;cursor:pointer!important}'
389 . '.rnc-notice-wrapper .notice-dismiss:hover{color:#c92c2c!important}'
390 . '.rnc-notice-wrapper .rnc-notice-snooze{display:inline-block;margin:0 0 8px;font-size:12px;color:#787c82;text-decoration:underline}'
391 . '.rnc-notice-wrapper .rnc-notice-snooze:hover{color:#2271b1}'
392
393 /* Styled variants — keep the WP notice chrome and pin the left-border
394 colour to the chosen style, so it stays correct across WP versions. */
395 . '.rnc-notice-wrapper.notice{background:#fff;border-left-width:4px;box-shadow:0 1px 1px rgba(0,0,0,.04)}'
396
397 /* "None" — strip every bit of chrome so only the author's HTML shows. */
398 . '.rnc-notice-wrapper--plain{margin:10px 0!important;padding:0!important;background:transparent!important;border:0!important;box-shadow:none!important}';
399 }
400
401 /**
402 * Initialize cron schedule
403 */
404 public function init_cron() {
405 $hook = 'noticepilot_rnc_fetch_content_' . $this->product;
406
407 if ( ! wp_next_scheduled( $hook ) ) {
408 wp_schedule_event( time(), $this->schedule, $hook );
409 }
410 }
411
412 /**
413 * Fetch content from remote API
414 */
415 public function fetch_content() {
416 if ( empty( $this->api_url ) ) {
417 return;
418 }
419
420 $response = wp_remote_get(
421 $this->api_url,
422 array(
423 'timeout' => 15,
424 'sslverify' => true,
425 )
426 );
427
428 if ( is_wp_error( $response ) ) {
429 $this->log( 'API Error: ' . $response->get_error_message(), 'error' );
430 return;
431 }
432
433 $http_code = wp_remote_retrieve_response_code( $response );
434
435 if ( 200 !== $http_code ) {
436 $this->log( 'API returned HTTP ' . $http_code, 'error' );
437 return;
438 }
439
440 $body = wp_remote_retrieve_body( $response );
441 $data = json_decode( $body, true );
442
443 if ( ! is_array( $data ) || ! isset( $data['success'] ) || ! $data['success'] ) {
444 $this->log( 'Invalid API response', 'error' );
445 return;
446 }
447
448 $contents = isset( $data['contents'] ) ? $data['contents'] : array();
449
450 if ( empty( $contents ) ) {
451 $this->clear_contents();
452 return;
453 }
454
455 $this->store_contents( $contents );
456 }
457
458 /**
459 * Store contents in database
460 *
461 * @param array $contents Contents array.
462 */
463 private function store_contents( $contents ) {
464 $key = $this->get_option_key( 'contents' );
465 $new_ids = wp_list_pluck( $contents, 'id' );
466
467 // Prune dismissed IDs that no longer exist on the hub.
468 $dismissed_map = $this->get_dismissed_map();
469 $cleaned = array_intersect_key( $dismissed_map, array_flip( $new_ids ) );
470 if ( count( $cleaned ) !== count( $dismissed_map ) ) {
471 update_option( $this->get_option_key( 'dismissed_ids' ), $cleaned, false );
472 }
473
474 // Prune snoozed IDs the same way.
475 $snoozed_map = $this->get_snoozed_map();
476 $snoozed_cleaned = array_intersect_key( $snoozed_map, array_flip( $new_ids ) );
477 if ( count( $snoozed_cleaned ) !== count( $snoozed_map ) ) {
478 update_option( $this->get_option_key( 'snoozed_ids' ), $snoozed_cleaned, false );
479 }
480
481 update_option( $key, $contents, false );
482 update_option( $this->get_option_key( 'fetched_time' ), current_time( 'mysql' ), false );
483
484 // Persist the API URL + consent requirement so track_goal() can send a
485 // beacon (and honour consent) even on a request where init() hasn't run
486 // and no live instance exists.
487 if ( ! empty( $this->api_url ) ) {
488 update_option( $this->get_option_key( 'api_url' ), $this->api_url, false );
489 }
490 update_option( $this->get_option_key( 'require_consent' ), $this->require_consent ? 1 : 0, false );
491 }
492
493 /**
494 * Clear all stored contents
495 */
496 private function clear_contents() {
497 delete_option( $this->get_option_key( 'contents' ) );
498 delete_option( $this->get_option_key( 'fetched_time' ) );
499 delete_option( $this->get_option_key( 'dismissed_ids' ) );
500 delete_option( $this->get_option_key( 'snoozed_ids' ) );
501 }
502
503 /**
504 * Get non-dismissed, non-snoozed contents
505 *
506 * @return array
507 */
508 private function get_non_dismissed_contents() {
509 $contents = get_option( $this->get_option_key( 'contents' ), array() );
510 $dismissed_ids = $this->get_dismissed_ids();
511 $snoozed_ids = $this->get_active_snoozed_ids();
512 $hidden_ids = array_merge( $dismissed_ids, $snoozed_ids );
513 $result = array();
514
515 if ( ! is_array( $contents ) ) {
516 return $result;
517 }
518
519 foreach ( $contents as $content ) {
520 if ( ! isset( $content['id'] ) ) {
521 continue;
522 }
523
524 if ( ! in_array( $content['id'], $hidden_ids, true ) ) {
525 $result[] = $content;
526 }
527 }
528
529 return $result;
530 }
531
532 /**
533 * Get the dismissed campaigns map (id => dismissal timestamp)
534 *
535 * Converts the legacy plain-list format (pre-1.2.0) to the
536 * timestamped map on first read and persists the conversion.
537 *
538 * @return array Map of content ID => Unix timestamp of dismissal.
539 */
540 private function get_dismissed_map() {
541 $raw = get_option( $this->get_option_key( 'dismissed_ids' ), array() );
542
543 if ( ! is_array( $raw ) ) {
544 return array();
545 }
546
547 // Legacy format: numeric list of IDs — convert to id => now.
548 if ( isset( $raw[0] ) ) {
549 $map = array();
550 foreach ( $raw as $id ) {
551 if ( is_string( $id ) ) {
552 $map[ $id ] = time();
553 }
554 }
555 update_option( $this->get_option_key( 'dismissed_ids' ), $map, false );
556 return $map;
557 }
558
559 return $raw;
560 }
561
562 /**
563 * Get IDs of campaigns whose dismissal is still in effect
564 *
565 * A dismissal expires after `dismiss_duration` seconds, at which
566 * point the notice may reappear. Duration 0 means dismissals are
567 * permanent.
568 *
569 * @return array List of currently dismissed content IDs.
570 */
571 private function get_dismissed_ids() {
572 $map = $this->get_dismissed_map();
573
574 if ( 0 === $this->dismiss_duration ) {
575 return array_keys( $map );
576 }
577
578 $active = array();
579 foreach ( $map as $id => $timestamp ) {
580 if ( (int) $timestamp + $this->dismiss_duration > time() ) {
581 $active[] = $id;
582 }
583 }
584
585 return $active;
586 }
587
588 /**
589 * Add a campaign ID to the dismissed map with FIFO cleanup
590 *
591 * Stores up to 20 dismissed campaign IDs. When the limit is exceeded,
592 * the oldest entries are removed to keep the option lightweight.
593 *
594 * @param string $content_id The campaign UUID to dismiss.
595 */
596 private function add_dismissed_id( $content_id ) {
597 $map = $this->get_dismissed_map();
598
599 $map[ $content_id ] = time();
600
601 // FIFO cleanup: keep only the 20 most recent dismissals.
602 if ( count( $map ) > 20 ) {
603 asort( $map );
604 $map = array_slice( $map, -20, null, true );
605 }
606
607 update_option( $this->get_option_key( 'dismissed_ids' ), $map, false );
608 }
609
610 /**
611 * Get the snoozed campaigns map (id => reappear timestamp)
612 *
613 * @return array Map of content ID => Unix timestamp when it may reappear.
614 */
615 private function get_snoozed_map() {
616 $map = get_option( $this->get_option_key( 'snoozed_ids' ), array() );
617 return is_array( $map ) ? $map : array();
618 }
619
620 /**
621 * Get IDs of campaigns currently snoozed ("Remind me later")
622 *
623 * @return array List of snoozed content IDs still within their snooze window.
624 */
625 private function get_active_snoozed_ids() {
626 $active = array();
627
628 foreach ( $this->get_snoozed_map() as $id => $reappear_at ) {
629 if ( (int) $reappear_at > time() ) {
630 $active[] = $id;
631 }
632 }
633
634 return $active;
635 }
636
637 /**
638 * Snooze a campaign for the configured snooze duration
639 *
640 * @param string $content_id The campaign UUID to snooze.
641 */
642 private function add_snoozed_id( $content_id ) {
643 $map = $this->get_snoozed_map();
644
645 $map[ $content_id ] = time() + $this->snooze_duration;
646
647 // FIFO cleanup, same policy as dismissals.
648 if ( count( $map ) > 20 ) {
649 asort( $map );
650 $map = array_slice( $map, -20, null, true );
651 }
652
653 update_option( $this->get_option_key( 'snoozed_ids' ), $map, false );
654 }
655
656 /**
657 * Display admin notices
658 *
659 * CSS is already enqueued via enqueue_notice_assets().
660 * Per-notice JS is added via wp_add_inline_script() for footer output.
661 */
662 public function display_notices() {
663 if ( ! current_user_can( $this->capability ) ) {
664 return;
665 }
666
667 $contents = $this->get_non_dismissed_contents();
668
669 if ( empty( $contents ) ) {
670 return;
671 }
672
673 $script_handle = 'noticepilot-rnc-notice-' . $this->product;
674 // 'none' renders a bare, unstyled container (no WP notice box/padding);
675 // the others map to WordPress core notice-* styles.
676 $allowed_types = array( 'none', 'info', 'success', 'warning', 'error' );
677
678 $screen_id = '';
679 if ( function_exists( 'get_current_screen' ) ) {
680 $screen = get_current_screen();
681 $screen_id = $screen ? $screen->id : '';
682 }
683
684 $shown = 0;
685
686 foreach ( $contents as $content ) {
687 // Frequency cap: never stack more than max_notices at once.
688 if ( $shown >= $this->max_notices ) {
689 break;
690 }
691
692 if ( ! isset( $content['id'] ) || ! isset( $content['content'] ) ) {
693 continue;
694 }
695
696 // Evaluate targeting rules before display.
697 if ( ! $this->should_display_content( $content ) ) {
698 continue;
699 }
700
701 // Placement: "plugin screens only" campaigns render solely on
702 // the screen IDs the integrating plugin registered.
703 if ( isset( $content['placement'] ) && 'plugin_screens' === $content['placement'] ) {
704 if ( empty( $this->screen_ids ) || ! in_array( $screen_id, $this->screen_ids, true ) ) {
705 continue;
706 }
707 }
708
709 $picked = $this->pick_variant( $content );
710 $content_id = sanitize_key( $content['id'] );
711 $variant_id = $picked['variant_id'];
712 $html_content = wp_kses( $picked['content'], $this->allowed_html );
713 $notice_type = isset( $content['notice_type'] ) && in_array( $content['notice_type'], $allowed_types, true ) ? $content['notice_type'] : 'info';
714 $has_snooze = ! empty( $content['snooze'] );
715 $nonce = wp_create_nonce( 'noticepilot_rnc_dismiss_' . $this->product . '_' . $content_id );
716 $ajax_action = 'noticepilot_rnc_dismiss_content_' . $this->product;
717
718 // "none" → no WP notice-* box (and the wrapper already zeroes
719 // padding/border/background); anything else → the core notice style.
720 $wrapper_class = ( 'none' === $notice_type )
721 ? 'rnc-notice-wrapper rnc-notice-wrapper--plain'
722 : 'notice notice-' . $notice_type . ' rnc-notice-wrapper';
723
724 ?>
725 <div id="rnc-notice-<?php echo esc_attr( $this->product . '-' . $content_id ); ?>"
726 class="<?php echo esc_attr( $wrapper_class ); ?>"
727 data-product="<?php echo esc_attr( $this->product ); ?>"
728 data-content-id="<?php echo esc_attr( $content_id ); ?>"
729 data-nonce="<?php echo esc_attr( $nonce ); ?>"
730 data-action="<?php echo esc_attr( $ajax_action ); ?>">
731 <div class="rnc-notice-content">
732 <?php echo $html_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already filtered through wp_kses() above. ?>
733 </div>
734 <?php if ( $has_snooze ) : ?>
735 <a href="#" class="rnc-notice-snooze">Remind me later</a>
736 <?php endif; ?>
737 <button type="button" class="notice-dismiss"><span class="screen-reader-text">Dismiss this notice.</span></button>
738 </div>
739 <?php
740
741 $shown++;
742
743 // Record (throttled) that this campaign+variant was shown, so a
744 // later track_goal() call can attribute the conversion to it.
745 $this->record_shown( $content_id, $variant_id );
746
747 // Analytics beacons only fire when allowed: either the
748 // integrating plugin doesn't require consent, or the user
749 // granted it (wp.org guideline 7 — no tracking without opt-in).
750 $analytics_on = $this->analytics_allowed() ? '1' : '';
751
752 // Per-notice JS: analytics beacons + dismiss/snooze handlers (printed in footer).
753 $notice_js = '(function(){'
754 . 'var notice=document.getElementById("rnc-notice-' . esc_js( $this->product . '-' . $content_id ) . '");'
755 . 'if(!notice)return;'
756 . 'var apiUrl="' . esc_js( $this->api_url ) . '";'
757 . 'var trackUrl=apiUrl.replace(/\/content\/[^\/]+$/,"/analytics/track");'
758 . 'var endpoint="' . esc_js( $this->product ) . '";'
759 . 'var cid="' . esc_js( $content_id ) . '";'
760 . 'var vid="' . esc_js( $variant_id ) . '";'
761 . 'var analyticsOn=' . ( $analytics_on ? 'true' : 'false' ) . ';'
762 . 'function sendBeacon(t){if(analyticsOn&&typeof navigator.sendBeacon==="function"){'
763 . 'var p=JSON.stringify({endpoint:endpoint,campaign_id:cid,variant_id:vid,event_type:t,site_url:window.location.origin});'
764 . 'navigator.sendBeacon(trackUrl,new Blob([p],{type:"application/json"}));}}'
765 . 'var impKey="np_imp_"+cid;'
766 . 'if(typeof sessionStorage!=="undefined"){'
767 . 'if(!sessionStorage.getItem(impKey)){sessionStorage.setItem(impKey,"1");sendBeacon("impression");}}'
768 . 'else{sendBeacon("impression");}'
769 . 'var ce=notice.querySelector(".rnc-notice-content");'
770 . 'if(ce){ce.addEventListener("click",function(e){var a=e.target.closest("a");if(a){sendBeacon("click");}});}'
771 . 'function hideViaAjax(snooze){'
772 . 'var act=notice.getAttribute("data-action");'
773 . 'var cid2=notice.getAttribute("data-content-id");'
774 . 'var nc=notice.getAttribute("data-nonce");'
775 . 'var x=new XMLHttpRequest();'
776 . 'x.open("POST","' . esc_js( admin_url( 'admin-ajax.php' ) ) . '",true);'
777 . 'x.setRequestHeader("Content-Type","application/x-www-form-urlencoded");'
778 . 'x.onload=function(){if(x.status===200){notice.style.display="none";}};'
779 . 'x.send("action="+encodeURIComponent(act)+"&content_id="+encodeURIComponent(cid2)+"&nonce="+encodeURIComponent(nc)+(snooze?"&snooze=1":""));}'
780 . 'var cb=notice.querySelector(".notice-dismiss");'
781 . 'if(cb){cb.addEventListener("click",function(e){e.preventDefault();sendBeacon("dismissal");hideViaAjax(false);});}'
782 . 'var sn=notice.querySelector(".rnc-notice-snooze");'
783 . 'if(sn){sn.addEventListener("click",function(e){e.preventDefault();hideViaAjax(true);});}'
784 . '})();';
785
786 wp_enqueue_script( $script_handle );
787 wp_add_inline_script( $script_handle, $notice_js );
788 }
789 }
790
791 /**
792 * Pick the A/B variant for this site (deterministic, stateless)
793 *
794 * When a campaign carries a `variants` array (authored via the Pro
795 * add-on on the hub), the site is assigned a stable bucket derived
796 * from its URL + the campaign ID. The same site always sees the same
797 * variant, with no cookies or extra options, and the install base
798 * splits according to the variant weights.
799 *
800 * @param array $content Campaign array from the hub.
801 * @return array { content: string, variant_id: string }
802 */
803 private function pick_variant( $content ) {
804 $fallback = array(
805 'content' => isset( $content['content'] ) ? $content['content'] : '',
806 'variant_id' => '',
807 );
808
809 if ( empty( $content['variants'] ) || ! is_array( $content['variants'] ) ) {
810 return $fallback;
811 }
812
813 $variants = array_values( $content['variants'] );
814 $total = 0;
815
816 foreach ( $variants as $variant ) {
817 $total += isset( $variant['weight'] ) ? max( 0, (int) $variant['weight'] ) : 0;
818 }
819
820 if ( $total <= 0 || empty( $variants ) ) {
821 return $fallback;
822 }
823
824 // Stable bucket in [0, total): crc32 can be negative on 32-bit PHP.
825 $bucket = crc32( md5( get_site_url() . $content['id'] ) ) % $total;
826 if ( $bucket < 0 ) {
827 $bucket += $total;
828 }
829
830 $cumulative = 0;
831 foreach ( $variants as $variant ) {
832 $cumulative += isset( $variant['weight'] ) ? max( 0, (int) $variant['weight'] ) : 0;
833 if ( $bucket < $cumulative ) {
834 return array(
835 'content' => isset( $variant['content'] ) ? $variant['content'] : $fallback['content'],
836 'variant_id' => isset( $variant['id'] ) ? sanitize_key( $variant['id'] ) : '',
837 );
838 }
839 }
840
841 return $fallback;
842 }
843
844 /* =====================================================================
845 Conversion goals
846 ===================================================================== */
847
848 /**
849 * Record (throttled) that a campaign+variant was shown to this site.
850 *
851 * Kept as a small map (campaign_id => [variant_id, ts]) so a later
852 * track_goal() call can attribute the conversion to the most recently
853 * shown campaign. Writes at most once per campaign per hour to avoid
854 * an option write on every admin page load.
855 *
856 * @param string $content_id Campaign UUID.
857 * @param string $variant_id Picked variant ID ('' for non-A/B).
858 */
859 private function record_shown( $content_id, $variant_id ) {
860 $key = $this->get_option_key( 'last_shown' );
861 $map = get_option( $key, array() );
862 if ( ! is_array( $map ) ) {
863 $map = array();
864 }
865
866 // Throttle: skip if we already recorded this campaign within the hour.
867 if ( isset( $map[ $content_id ]['ts'] ) && ( time() - (int) $map[ $content_id ]['ts'] ) < HOUR_IN_SECONDS ) {
868 return;
869 }
870
871 $map[ $content_id ] = array(
872 'variant_id' => (string) $variant_id,
873 'ts' => time(),
874 );
875
876 // Keep only the 10 most recently shown campaigns.
877 if ( count( $map ) > 10 ) {
878 uasort(
879 $map,
880 function ( $a, $b ) {
881 return ( isset( $b['ts'] ) ? $b['ts'] : 0 ) <=> ( isset( $a['ts'] ) ? $a['ts'] : 0 );
882 }
883 );
884 $map = array_slice( $map, 0, 10, true );
885 }
886
887 update_option( $key, $map, false );
888 }
889
890 /**
891 * Report a conversion goal, attributed to the most recently shown campaign.
892 *
893 * Call this from your plugin when a user completes a valuable action —
894 * e.g. activates a Pro license:
895 *
896 * Noticepilot_Remote_Notice_Client::track_goal( 'my-product', 'upgraded_to_pro' );
897 *
898 * The goal is attributed (last-impression, within `window` seconds) to
899 * the campaign/variant this site most recently saw, so per-variant
900 * conversion rate (CVR) works in the hub's A/B analytics.
901 *
902 * @param string $product Product slug.
903 * @param string $goal_key Short goal identifier (e.g. 'upgraded_to_pro').
904 * @param int $window Attribution window in seconds (default 30 days).
905 * @return bool True if a goal beacon was sent.
906 */
907 public static function track_goal( $product, $goal_key, $window = null ) {
908 $product = sanitize_key( $product );
909
910 $instance = isset( self::$instances[ $product ] ) ? self::$instances[ $product ] : null;
911 if ( ! $instance ) {
912 // Build a lightweight instance so goals still fire when init()
913 // hasn't run on this particular request. Consent requirement is
914 // restored from the persisted flag so a goal never bypasses it.
915 $instance = new self(
916 $product,
917 array(
918 'api_url' => get_option( 'noticepilot_rnc_' . $product . '_api_url', '' ),
919 'require_consent' => (bool) get_option( 'noticepilot_rnc_' . $product . '_require_consent', false ),
920 )
921 );
922 }
923
924 return $instance->send_goal( $goal_key, $window );
925 }
926
927 /**
928 * Send the goal beacon for the last-shown campaign (instance helper).
929 *
930 * @param string $goal_key Goal identifier.
931 * @param int|null $window Attribution window in seconds.
932 * @return bool
933 */
934 private function send_goal( $goal_key, $window = null ) {
935 $goal_key = sanitize_key( $goal_key );
936 if ( '' === $goal_key ) {
937 return false;
938 }
939
940 // Respect the same consent gate as other beacons (wp.org guideline 7).
941 if ( ! $this->analytics_allowed() ) {
942 return false;
943 }
944
945 $api_url = $this->api_url ? $this->api_url : get_option( $this->get_option_key( 'api_url' ), '' );
946 if ( empty( $api_url ) ) {
947 return false;
948 }
949
950 $window = ( null === $window ) ? ( 30 * DAY_IN_SECONDS ) : absint( $window );
951
952 $map = get_option( $this->get_option_key( 'last_shown' ), array() );
953 if ( ! is_array( $map ) || empty( $map ) ) {
954 return false;
955 }
956
957 // Pick the most recently shown campaign still within the window.
958 $best_id = '';
959 $best_ts = 0;
960 $best_variant = '';
961 foreach ( $map as $cid => $data ) {
962 $ts = isset( $data['ts'] ) ? (int) $data['ts'] : 0;
963 if ( $ts <= 0 || ( time() - $ts ) > $window ) {
964 continue;
965 }
966 if ( $ts > $best_ts ) {
967 $best_ts = $ts;
968 $best_id = $cid;
969 $best_variant = isset( $data['variant_id'] ) ? (string) $data['variant_id'] : '';
970 }
971 }
972
973 if ( '' === $best_id ) {
974 return false;
975 }
976
977 $track_url = preg_replace( '/\/content\/[^\/]+$/', '/analytics/track', $api_url );
978
979 wp_remote_post(
980 $track_url,
981 array(
982 'timeout' => 8,
983 'blocking' => false,
984 'sslverify' => true,
985 'headers' => array( 'Content-Type' => 'application/json' ),
986 'body' => wp_json_encode(
987 array(
988 'endpoint' => $this->product,
989 'campaign_id' => $best_id,
990 'variant_id' => $best_variant,
991 'event_type' => 'goal',
992 'goal_key' => $goal_key,
993 'site_url' => home_url(),
994 )
995 ),
996 )
997 );
998
999 return true;
1000 }
1001
1002 /* =====================================================================
1003 Smart triggers (usage-based display conditions)
1004 ===================================================================== */
1005
1006 /**
1007 * Record a usage metric for milestone-based triggers.
1008 *
1009 * Call from your plugin as counts change, e.g.:
1010 * Noticepilot_Remote_Notice_Client::set_metric( 'my-product', 'forms_created', 120 );
1011 *
1012 * @param string $product Product slug.
1013 * @param string $key Metric key.
1014 * @param int $value Current value.
1015 */
1016 public static function set_metric( $product, $key, $value ) {
1017 $product = sanitize_key( $product );
1018 $key = sanitize_key( $key );
1019 if ( '' === $product || '' === $key ) {
1020 return;
1021 }
1022
1023 $option = 'noticepilot_rnc_' . $product . '_metrics';
1024 $metrics = get_option( $option, array() );
1025 if ( ! is_array( $metrics ) ) {
1026 $metrics = array();
1027 }
1028
1029 $metrics[ $key ] = (float) $value;
1030 update_option( $option, $metrics, true );
1031 }
1032
1033 /**
1034 * Ensure the first-seen timestamp exists (basis for install-age triggers).
1035 */
1036 private function ensure_first_seen() {
1037 $key = $this->get_option_key( 'first_seen' );
1038 if ( false === get_option( $key, false ) ) {
1039 update_option( $key, time(), true );
1040 }
1041 }
1042
1043 /**
1044 * Evaluate a campaign's smart-trigger conditions (all must pass).
1045 *
1046 * Supported (each optional):
1047 * triggers.install_age = [ 'operator' => 'gte', 'days' => 7 ]
1048 * triggers.metric = [ 'key' => 'forms_created', 'operator' => 'gte', 'value' => 100 ]
1049 *
1050 * @param array $content Campaign array.
1051 * @return bool True when there are no triggers or all pass.
1052 */
1053 private function evaluate_triggers( $content ) {
1054 if ( empty( $content['triggers'] ) || ! is_array( $content['triggers'] ) ) {
1055 return true;
1056 }
1057
1058 $triggers = $content['triggers'];
1059
1060 // Install-age condition.
1061 if ( ! empty( $triggers['install_age'] ) && is_array( $triggers['install_age'] ) ) {
1062 $days = isset( $triggers['install_age']['days'] ) ? (int) $triggers['install_age']['days'] : 0;
1063 $op = isset( $triggers['install_age']['operator'] ) ? $triggers['install_age']['operator'] : 'gte';
1064
1065 if ( $days > 0 ) {
1066 $first_seen = (int) get_option( $this->get_option_key( 'first_seen' ), time() );
1067 $age_days = ( time() - $first_seen ) / DAY_IN_SECONDS;
1068 if ( ! $this->compare_numeric( $age_days, $op, $days ) ) {
1069 return false;
1070 }
1071 }
1072 }
1073
1074 // Usage-metric condition.
1075 if ( ! empty( $triggers['metric'] ) && is_array( $triggers['metric'] ) ) {
1076 $metric_key = isset( $triggers['metric']['key'] ) ? sanitize_key( $triggers['metric']['key'] ) : '';
1077 $op = isset( $triggers['metric']['operator'] ) ? $triggers['metric']['operator'] : 'gte';
1078 $threshold = isset( $triggers['metric']['value'] ) ? (float) $triggers['metric']['value'] : 0;
1079
1080 if ( '' !== $metric_key ) {
1081 $metrics = get_option( $this->get_option_key( 'metrics' ), array() );
1082 $current = ( is_array( $metrics ) && isset( $metrics[ $metric_key ] ) ) ? (float) $metrics[ $metric_key ] : 0;
1083 if ( ! $this->compare_numeric( $current, $op, $threshold ) ) {
1084 return false;
1085 }
1086 }
1087 }
1088
1089 return true;
1090 }
1091
1092 /**
1093 * Numeric comparison with a named operator (lt/lte/eq/gte/gt).
1094 *
1095 * @param float $left Left operand.
1096 * @param string $operator Operator key.
1097 * @param float $right Right operand.
1098 * @return bool
1099 */
1100 private function compare_numeric( $left, $operator, $right ) {
1101 switch ( $operator ) {
1102 case 'lt':
1103 return $left < $right;
1104 case 'lte':
1105 return $left <= $right;
1106 case 'eq':
1107 return $left === $right;
1108 case 'gt':
1109 return $left > $right;
1110 case 'gte':
1111 default:
1112 return $left >= $right;
1113 }
1114 }
1115
1116 /**
1117 * AJAX handler for dismissing content
1118 */
1119 public function ajax_dismiss_content() {
1120 $content_id = isset( $_POST['content_id'] ) ? sanitize_text_field( wp_unslash( $_POST['content_id'] ) ) : '';
1121 $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
1122 $is_snooze = ! empty( $_POST['snooze'] );
1123
1124 if ( ! wp_verify_nonce( $nonce, 'noticepilot_rnc_dismiss_' . $this->product . '_' . $content_id ) ) {
1125 wp_send_json_error( array( 'message' => 'Invalid nonce' ) );
1126 }
1127
1128 if ( ! current_user_can( $this->capability ) ) {
1129 wp_send_json_error( array( 'message' => 'Unauthorized' ) );
1130 }
1131
1132 if ( empty( $content_id ) ) {
1133 wp_send_json_error( array( 'message' => 'Invalid content ID' ) );
1134 }
1135
1136 if ( $is_snooze ) {
1137 $this->add_snoozed_id( $content_id );
1138 wp_send_json_success( array( 'message' => 'Content snoozed' ) );
1139 }
1140
1141 $this->add_dismissed_id( $content_id );
1142
1143 wp_send_json_success( array( 'message' => 'Content dismissed' ) );
1144 }
1145
1146 /* -----------------------------------------------------------------
1147 Analytics consent (WordPress.org guideline 7)
1148 ----------------------------------------------------------------- */
1149
1150 /**
1151 * Whether analytics beacons may fire on this site
1152 *
1153 * True when the integrating plugin doesn't require consent, or when
1154 * consent was recorded via grant_consent().
1155 *
1156 * @return bool
1157 */
1158 private function analytics_allowed() {
1159 return ! $this->require_consent || self::has_consent( $this->product );
1160 }
1161
1162 /**
1163 * Record analytics consent for a product
1164 *
1165 * Call this from your own opt-in flow (e.g. a settings checkbox or
1166 * an activation opt-in screen). Until consent is granted, a client
1167 * initialized with `require_consent => true` sends no beacons.
1168 *
1169 * @param string $product Product identifier.
1170 */
1171 public static function grant_consent( $product ) {
1172 update_option( 'noticepilot_rnc_' . sanitize_key( $product ) . '_consent', 1, false );
1173 }
1174
1175 /**
1176 * Withdraw analytics consent for a product
1177 *
1178 * @param string $product Product identifier.
1179 */
1180 public static function revoke_consent( $product ) {
1181 delete_option( 'noticepilot_rnc_' . sanitize_key( $product ) . '_consent' );
1182 }
1183
1184 /**
1185 * Check whether analytics consent was granted for a product
1186 *
1187 * @param string $product Product identifier.
1188 * @return bool
1189 */
1190 public static function has_consent( $product ) {
1191 return (bool) get_option( 'noticepilot_rnc_' . sanitize_key( $product ) . '_consent', false );
1192 }
1193
1194 /**
1195 * Get option key with product prefix
1196 *
1197 * @param string $key Key name.
1198 * @return string
1199 */
1200 private function get_option_key( $key ) {
1201 return 'noticepilot_rnc_' . $this->product . '_' . $key;
1202 }
1203
1204 /**
1205 * Log activity (only in debug mode)
1206 *
1207 * @param string $message Message.
1208 * @param string $level Log level.
1209 */
1210 private function log( $message, $level = 'info' ) {
1211 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
1212 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
1213 error_log( '[Remote Notice Client - ' . strtoupper( $level ) . '] [' . $this->product . '] ' . $message );
1214 }
1215 }
1216
1217 /**
1218 * Evaluate targeting rules for a content item
1219 *
1220 * Returns true if the content should be displayed, false if it should be hidden.
1221 * Missing targeting data defaults to showing the content (backward compatible).
1222 *
1223 * @param array $content Content array with optional 'targeting' key.
1224 * @return bool
1225 */
1226 private function should_display_content( $content ) {
1227 // Smart triggers (Pro) — install-age / usage-metric gates. Evaluated
1228 // independently of targeting; a campaign whose triggers aren't met
1229 // stays cached and appears automatically once conditions are satisfied.
1230 if ( ! $this->evaluate_triggers( $content ) ) {
1231 return false;
1232 }
1233
1234 // No targeting data — show to everyone (backward compat)
1235 if ( empty( $content['targeting'] ) || ! is_array( $content['targeting'] ) ) {
1236 return true;
1237 }
1238
1239 $targeting = $content['targeting'];
1240
1241 // 1. Pro users check
1242 if ( ! empty( $targeting['pro_users'] ) && 'all' !== $targeting['pro_users'] ) {
1243 if ( 'free_only' === $targeting['pro_users'] && $this->is_pro ) {
1244 return false;
1245 }
1246 if ( 'pro_only' === $targeting['pro_users'] && ! $this->is_pro ) {
1247 return false;
1248 }
1249 }
1250
1251 // 2. Plugin version check
1252 if ( ! empty( $targeting['plugin_version'] ) && is_array( $targeting['plugin_version'] ) ) {
1253 $operator = isset( $targeting['plugin_version']['operator'] ) ? $targeting['plugin_version']['operator'] : '';
1254 $version = isset( $targeting['plugin_version']['version'] ) ? $targeting['plugin_version']['version'] : '';
1255
1256 if ( ! empty( $operator ) && ! empty( $version ) && ! empty( $this->plugin_version ) ) {
1257 $op_map = array(
1258 'lt' => '<',
1259 'lte' => '<=',
1260 'eq' => '==',
1261 'gte' => '>=',
1262 'gt' => '>',
1263 );
1264
1265 $php_op = isset( $op_map[ $operator ] ) ? $op_map[ $operator ] : '';
1266 if ( ! empty( $php_op ) && ! version_compare( $this->plugin_version, $version, $php_op ) ) {
1267 return false;
1268 }
1269 }
1270 }
1271
1272 // 3. User roles check
1273 if ( ! empty( $targeting['user_roles'] ) && is_array( $targeting['user_roles'] ) ) {
1274 $current_user = wp_get_current_user();
1275 if ( $current_user && ! empty( $current_user->roles ) ) {
1276 $intersect = array_intersect( $current_user->roles, $targeting['user_roles'] );
1277 if ( empty( $intersect ) ) {
1278 return false;
1279 }
1280 }
1281 }
1282
1283 return true;
1284 }
1285
1286 /**
1287 * Manually trigger fetch (useful for testing)
1288 *
1289 * @param string $product Product identifier.
1290 * @return bool True if triggered, false if instance doesn't exist.
1291 */
1292 public static function trigger_fetch( $product ) {
1293 $product = sanitize_key( $product );
1294
1295 if ( ! isset( self::$instances[ $product ] ) ) {
1296 return false;
1297 }
1298
1299 self::$instances[ $product ]->fetch_content();
1300 return true;
1301 }
1302
1303 /**
1304 * Clear all data for a product
1305 *
1306 * @param string $product Product identifier.
1307 * @return bool True if cleared, false if instance doesn't exist.
1308 */
1309 public static function clear_all( $product ) {
1310 $product = sanitize_key( $product );
1311
1312 if ( ! isset( self::$instances[ $product ] ) ) {
1313 // Still try to clear data even if instance doesn't exist
1314 $temp_instance = new self( $product, array() );
1315 $temp_instance->clear_contents();
1316 return true;
1317 }
1318
1319 self::$instances[ $product ]->clear_contents();
1320 return true;
1321 }
1322
1323 /**
1324 * Disable the notice client for a product
1325 *
1326 * Use this when Pro version is active to stop showing notices
1327 * and unschedule the cron job.
1328 *
1329 * @param string $product Product identifier.
1330 */
1331 public static function disable( $product ) {
1332 $product = sanitize_key( $product );
1333
1334 // Mark as disabled
1335 update_option( 'noticepilot_rnc_' . $product . '_disabled', true, false );
1336
1337 // Unschedule cron
1338 $hook = 'noticepilot_rnc_fetch_content_' . $product;
1339 $timestamp = wp_next_scheduled( $hook );
1340 if ( $timestamp ) {
1341 wp_unschedule_event( $timestamp, $hook );
1342 }
1343
1344 // Clear all stored data
1345 if ( isset( self::$instances[ $product ] ) ) {
1346 self::$instances[ $product ]->clear_contents();
1347 unset( self::$instances[ $product ] );
1348 } else {
1349 // Clear data even if instance doesn't exist
1350 $temp_instance = new self( $product, array() );
1351 $temp_instance->clear_contents();
1352 }
1353 }
1354
1355 /**
1356 * Re-enable the notice client for a product
1357 *
1358 * @param string $product Product identifier.
1359 */
1360 public static function enable( $product ) {
1361 $product = sanitize_key( $product );
1362 delete_option( 'noticepilot_rnc_' . $product . '_disabled' );
1363 }
1364
1365 /**
1366 * Check if the notice client is disabled for a product
1367 *
1368 * @param string $product Product identifier.
1369 * @return bool
1370 */
1371 public static function is_disabled( $product ) {
1372 $product = sanitize_key( $product );
1373 return (bool) get_option( 'noticepilot_rnc_' . $product . '_disabled', false );
1374 }
1375 }
1376 }