PluginProbe
Forumax – AI Powered Advanced Community Forum Plugin / 2.5.0
Forumax – AI Powered Advanced Community Forum Plugin v2.5.0
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 2.0.0 All 30 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.5.0, at includes/admin/notices/class-remote-notice-client.php

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