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
← All changes | includes/admin/notices/class-remote-notice-client.php +1129 -42 2.4.2 → 2.5.0 View file →
@@ -4,10 +4,15 @@
4 4 *
5 5 * A reusable SDK for integrating remote HTML notices from the NoticePilot API.
6 6 * Bundle this file with your plugin to enable remote admin notices.
7 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 + *
8 13 * @package Noticepilot_Remote_Notice_Client
9 - * @version 1.1.1
14 + * @version 1.7.0
10 15 */
11 16
12 17 if ( ! defined( 'ABSPATH' ) ) {
13 18 exit;
@@ -12,10 +17,44 @@
12 17 if ( ! defined( 'ABSPATH' ) ) {
13 18 exit;
14 19 }
15 20
16 -if ( ! class_exists( 'Noticepilot_Remote_Notice_Client' ) ) {
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 + }
17 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 +
18 57 /**
19 58 * Remote Notice Client Class
20 59 */
21 60 class Noticepilot_Remote_Notice_Client {
@@ -22,11 +61,16 @@
22 61
23 62 /**
24 63 * SDK version.
25 64 */
26 - const SDK_VERSION = '1.1.1';
65 + const SDK_VERSION = '1.7.0';
27 66
28 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 + /**
29 73 * Registered instances
30 74 *
31 75 * @var array
32 76 */
@@ -60,9 +104,9 @@
60 104 */
61 105 private $capability;
62 106
63 107 /**
64 - * Dismiss duration in seconds (default: 1 week)
108 + * Dismiss duration in seconds (default: 1 week, 0 = permanent)
65 109 *
66 110 * @var int
67 111 */
68 112 private $dismiss_duration;
@@ -67,8 +111,36 @@
67 111 */
68 112 private $dismiss_duration;
69 113
70 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 + /**
71 143 * Plugin version for targeting (provided by integrating plugin)
72 144 *
73 145 * @var string
74 146 */
@@ -81,8 +153,23 @@
81 153 */
82 154 private $is_pro;
83 155
84 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 + /**
85 172 * Allow HTML tags for content
86 173 *
87 174 * @var array
88 175 */
@@ -125,10 +212,16 @@
125 212 $this->api_url = isset( $config['api_url'] ) ? esc_url_raw( $config['api_url'] ) : '';
126 213 $this->schedule = isset( $config['schedule'] ) ? $config['schedule'] : 'daily';
127 214 $this->capability = isset( $config['capability'] ) ? $config['capability'] : 'manage_options';
128 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'] );
129 220 $this->plugin_version = isset( $config['plugin_version'] ) ? sanitize_text_field( $config['plugin_version'] ) : '';
130 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'] : '';
131 224
132 225 // Define allowed HTML tags for notices
133 226 $this->allowed_html = array(
134 227 'div' => array( 'class' => array(), 'style' => array(), 'id' => array() ),
@@ -175,11 +268,136 @@
175 268 add_action( 'admin_notices', array( $this, 'display_notices' ) );
176 269
177 270 // AJAX handlers.
178 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();
179 288 }
180 289
181 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 + /**
182 400 * Enqueue inline CSS for notice styling via WordPress APIs
183 401 *
184 402 * Registers a virtual style handle and attaches the notice CSS
185 403 * so it prints in the document head before notices render.
@@ -212,12 +430,25 @@
212 430 *
213 431 * @return string CSS rules.
214 432 */
215 433 private function get_notice_css() {
216 - return '.rnc-notice-wrapper{margin:20px 0 20px 0!important;padding:0!important;background:transparent!important;border:0!important;position:relative;box-shadow:none}'
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}'
217 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}'
218 441 . '.rnc-notice-wrapper .notice-dismiss:hover{color:#c92c2c!important}'
219 - . '.rnc-notice-wrapper img{max-width:100%!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}';
220 451 }
221 452
222 453 /**
223 454 * Initialize cron schedule
@@ -267,8 +498,11 @@
267 498 }
268 499
269 500 $contents = isset( $data['contents'] ) ? $data['contents'] : array();
270 501
502 + // Daily fetch doubles as a goal check that doesn't depend on admin visits.
503 + $this->check_campaign_goals();
504 +
271 505 if ( empty( $contents ) ) {
272 506 $this->clear_contents();
273 507 return;
274 508 }
@@ -285,16 +519,31 @@
285 519 $key = $this->get_option_key( 'contents' );
286 520 $new_ids = wp_list_pluck( $contents, 'id' );
287 521
288 522 // Prune dismissed IDs that no longer exist on the hub.
289 - $dismissed_ids = $this->get_dismissed_ids();
290 - $cleaned = array_values( array_intersect( $dismissed_ids, $new_ids ) );
291 - if ( count( $cleaned ) !== count( $dismissed_ids ) ) {
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 ) ) {
292 526 update_option( $this->get_option_key( 'dismissed_ids' ), $cleaned, false );
293 527 }
294 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 +
295 536 update_option( $key, $contents, false );
296 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 );
297 546 }
298 547
299 548 /**
300 549 * Clear all stored contents
@@ -302,12 +551,13 @@
302 551 private function clear_contents() {
303 552 delete_option( $this->get_option_key( 'contents' ) );
304 553 delete_option( $this->get_option_key( 'fetched_time' ) );
305 554 delete_option( $this->get_option_key( 'dismissed_ids' ) );
555 + delete_option( $this->get_option_key( 'snoozed_ids' ) );
306 556 }
307 557
308 558 /**
309 - * Get non-dismissed contents
559 + * Get non-dismissed, non-snoozed contents
310 560 *
311 561 * @return array
312 562 */
313 563 private function get_non_dismissed_contents() {
@@ -312,8 +562,10 @@
312 562 */
313 563 private function get_non_dismissed_contents() {
314 564 $contents = get_option( $this->get_option_key( 'contents' ), array() );
315 565 $dismissed_ids = $this->get_dismissed_ids();
566 + $snoozed_ids = $this->get_active_snoozed_ids();
567 + $hidden_ids = array_merge( $dismissed_ids, $snoozed_ids );
316 568 $result = array();
317 569
318 570 if ( ! is_array( $contents ) ) {
319 571 return $result;
@@ -323,9 +575,9 @@
323 575 if ( ! isset( $content['id'] ) ) {
324 576 continue;
325 577 }
326 578
327 - if ( ! in_array( $content['id'], $dismissed_ids, true ) ) {
579 + if ( ! in_array( $content['id'], $hidden_ids, true ) ) {
328 580 $result[] = $content;
329 581 }
330 582 }
331 583
@@ -332,19 +584,65 @@
332 584 return $result;
333 585 }
334 586
335 587 /**
336 - * Get the dismissed campaign IDs array
588 + * Get the dismissed campaigns map (id => dismissal timestamp)
337 589 *
338 - * @return array List of dismissed content IDs.
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.
339 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 + */
340 626 private function get_dismissed_ids() {
341 - $ids = get_option( $this->get_option_key( 'dismissed_ids' ), array() );
342 - return is_array( $ids ) ? $ids : array();
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;
343 641 }
344 642
345 643 /**
346 - * Add a campaign ID to the dismissed list with FIFO cleanup
644 + * Add a campaign ID to the dismissed map with FIFO cleanup
347 645 *
348 646 * Stores up to 20 dismissed campaign IDs. When the limit is exceeded,
349 647 * the oldest entries are removed to keep the option lightweight.
350 648 *
@@ -350,26 +648,68 @@
350 648 *
351 649 * @param string $content_id The campaign UUID to dismiss.
352 650 */
353 651 private function add_dismissed_id( $content_id ) {
354 - $ids = $this->get_dismissed_ids();
652 + $map = $this->get_dismissed_map();
355 653
356 - // Already dismissed — nothing to do.
357 - if ( in_array( $content_id, $ids, true ) ) {
358 - return;
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 );
359 660 }
360 661
361 - $ids[] = $content_id;
662 + update_option( $this->get_option_key( 'dismissed_ids' ), $map, false );
663 + }
362 664
363 - // FIFO cleanup: keep only the 20 most recent dismissed IDs.
364 - if ( count( $ids ) > 20 ) {
365 - $ids = array_slice( $ids, -20 );
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 + }
366 687 }
367 688
368 - update_option( $this->get_option_key( 'dismissed_ids' ), $ids, false );
689 + return $active;
369 690 }
370 691
371 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 + /**
372 712 * Display admin notices
373 713 *
374 714 * CSS is already enqueued via enqueue_notice_assets().
375 715 * Per-notice JS is added via wp_add_inline_script() for footer output.
@@ -385,10 +725,26 @@
385 725 return;
386 726 }
387 727
388 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' );
389 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 +
390 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 +
391 747 if ( ! isset( $content['id'] ) || ! isset( $content['content'] ) ) {
392 748 continue;
393 749 }
394 750
@@ -396,16 +752,34 @@
396 752 if ( ! $this->should_display_content( $content ) ) {
397 753 continue;
398 754 }
399 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 );
400 765 $content_id = sanitize_key( $content['id'] );
401 - $html_content = wp_kses( $content['content'], $this->allowed_html );
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'] );
402 770 $nonce = wp_create_nonce( 'noticepilot_rnc_dismiss_' . $this->product . '_' . $content_id );
403 771 $ajax_action = 'noticepilot_rnc_dismiss_content_' . $this->product;
404 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 +
405 779 ?>
406 780 <div id="rnc-notice-<?php echo esc_attr( $this->product . '-' . $content_id ); ?>"
407 - class="notice notice-info rnc-notice-wrapper"
781 + class="<?php echo esc_attr( $wrapper_class ); ?>"
408 782 data-product="<?php echo esc_attr( $this->product ); ?>"
409 783 data-content-id="<?php echo esc_attr( $content_id ); ?>"
410 784 data-nonce="<?php echo esc_attr( $nonce ); ?>"
411 785 data-action="<?php echo esc_attr( $ajax_action ); ?>">
@@ -411,32 +785,69 @@
411 785 data-action="<?php echo esc_attr( $ajax_action ); ?>">
412 786 <div class="rnc-notice-content">
413 787 <?php echo $html_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already filtered through wp_kses() above. ?>
414 788 </div>
789 + <?php if ( $has_snooze ) : ?>
790 + <a href="#" class="rnc-notice-snooze">Remind me later</a>
791 + <?php endif; ?>
415 792 <button type="button" class="notice-dismiss"><span class="screen-reader-text">Dismiss this notice.</span></button>
416 793 </div>
417 794 <?php
418 795
419 - // Per-notice JS: analytics beacons + dismiss handler (printed in footer).
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.
420 815 $notice_js = '(function(){'
421 816 . 'var notice=document.getElementById("rnc-notice-' . esc_js( $this->product . '-' . $content_id ) . '");'
422 817 . 'if(!notice)return;'
423 - . 'var apiUrl="' . esc_js( $this->api_url ) . '";'
424 - . 'var trackUrl=apiUrl.replace(/\/content\/[^\/]+$/,"/analytics/track");'
818 + . 'var trackUrl="' . esc_js( $this->get_track_url( $this->api_url ) ) . '";'
425 819 . 'var endpoint="' . esc_js( $this->product ) . '";'
426 820 . 'var cid="' . esc_js( $content_id ) . '";'
427 - . 'function sendBeacon(t){if(typeof navigator.sendBeacon==="function"){'
428 - . 'var p=JSON.stringify({endpoint:endpoint,campaign_id:cid,event_type:t,site_url:window.location.origin});'
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});'
429 826 . 'navigator.sendBeacon(trackUrl,new Blob([p],{type:"application/json"}));}}'
430 - . 'var impKey="np_imp_"+cid;'
431 - . 'if(typeof sessionStorage!=="undefined"){'
432 - . 'if(!sessionStorage.getItem(impKey)){sessionStorage.setItem(impKey,"1");sendBeacon("impression");}}'
433 - . 'else{sendBeacon("impression");}'
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 + . '}'
434 847 . 'var ce=notice.querySelector(".rnc-notice-content");'
435 - . 'if(ce){ce.addEventListener("click",function(e){var a=e.target.closest("a");if(a){sendBeacon("click");}});}'
436 - . 'var cb=notice.querySelector(".notice-dismiss");'
437 - . 'if(cb){cb.addEventListener("click",function(e){'
438 - . 'e.preventDefault();sendBeacon("dismissal");'
848 + . 'if(ce){ce.addEventListener("click",function(e){var a=e.target.closest("a");if(a){sendBeacon("click");goalClick();}});}'
849 + . 'function hideViaAjax(snooze){'
439 850 . 'var act=notice.getAttribute("data-action");'
440 851 . 'var cid2=notice.getAttribute("data-content-id");'
441 852 . 'var nc=notice.getAttribute("data-nonce");'
442 853 . 'var x=new XMLHttpRequest();'
@@ -442,10 +853,13 @@
442 853 . 'var x=new XMLHttpRequest();'
443 854 . 'x.open("POST","' . esc_js( admin_url( 'admin-ajax.php' ) ) . '",true);'
444 855 . 'x.setRequestHeader("Content-Type","application/x-www-form-urlencoded");'
445 856 . 'x.onload=function(){if(x.status===200){notice.style.display="none";}};'
446 - . 'x.send("action="+encodeURIComponent(act)+"&content_id="+encodeURIComponent(cid2)+"&nonce="+encodeURIComponent(nc));'
447 - . '});}'
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);});}'
448 862 . '})();';
449 863
450 864 wp_enqueue_script( $script_handle );
451 865 wp_add_inline_script( $script_handle, $notice_js );
@@ -452,13 +866,626 @@
452 866 }
453 867 }
454 868
455 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 + /**
456 1482 * AJAX handler for dismissing content
457 1483 */
458 1484 public function ajax_dismiss_content() {
459 1485 $content_id = isset( $_POST['content_id'] ) ? sanitize_text_field( wp_unslash( $_POST['content_id'] ) ) : '';
460 1486 $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
1487 + $is_snooze = ! empty( $_POST['snooze'] );
461 1488
462 1489 if ( ! wp_verify_nonce( $nonce, 'noticepilot_rnc_dismiss_' . $this->product . '_' . $content_id ) ) {
463 1490 wp_send_json_error( array( 'message' => 'Invalid nonce' ) );
464 1491 }
@@ -470,14 +1497,67 @@
470 1497 if ( empty( $content_id ) ) {
471 1498 wp_send_json_error( array( 'message' => 'Invalid content ID' ) );
472 1499 }
473 1500
1501 + if ( $is_snooze ) {
1502 + $this->add_snoozed_id( $content_id );
1503 + wp_send_json_success( array( 'message' => 'Content snoozed' ) );
1504 + }
1505 +
474 1506 $this->add_dismissed_id( $content_id );
475 1507
476 1508 wp_send_json_success( array( 'message' => 'Content dismissed' ) );
477 1509 }
478 1510
1511 + /* -----------------------------------------------------------------
1512 + Analytics consent (WordPress.org guideline 7)
1513 + ----------------------------------------------------------------- */
1514 +
479 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 + /**
480 1560 * Get option key with product prefix
481 1561 *
482 1562 * @param string $key Key name.
483 1563 * @return string
@@ -508,8 +1588,15 @@
508 1588 * @param array $content Content array with optional 'targeting' key.
509 1589 * @return bool
510 1590 */
511 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 +
512 1599 // No targeting data — show to everyone (backward compat)
513 1600 if ( empty( $content['targeting'] ) || ! is_array( $content['targeting'] ) ) {
514 1601 return true;
515 1602 }