PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.3.2
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.3.2
3.3.2 3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 All 157 releases
← All changes | includes/NotificationX.php +203 -6 3.2.113.3.2 View file →
@@ -17,8 +17,9 @@
17 17 use NotificationX\Core\PostType;
18 18 use NotificationX\Core\QuickBuild;
19 19 use NotificationX\Core\REST;
20 20 use NotificationX\Core\ShortcodeInline;
21 +use NotificationX\Core\Targeting;
21 22 use NotificationX\Core\Upgrader;
22 23 use NotificationX\Extensions\GlobalFields;
23 24 use NotificationX\FrontEnd\FrontEnd;
24 25 use NotificationX\Types\TypeFactory;
@@ -38,8 +39,18 @@
38 39 * @var NotificationX
39 40 */
40 41 use GetInstance;
41 42 /**
43 + * Option flag recording that NotificationX has already gone through its
44 + * initial (first-ever) activation on this site.
45 + *
46 + * Set on the first activation and seeded for pre-existing installs by
47 + * Upgrader, so a re-activation is never mistaken for a first run.
48 + *
49 + * @since 3.3.0
50 + */
51 + const FIRST_ACTIVATION_OPTION = 'nx_first_activation';
52 + /**
42 53 * Settings
43 54 * @var Settings
44 55 */
45 56 public $settings;
@@ -63,8 +74,9 @@
63 74 $args = Settings::get_instance()->get_role_map();
64 75 new WPDRoleManagement($args);
65 76
66 77 Upgrader::get_instance();
78 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reviewed for the NotificationX codebase: acceptable in this context.
67 79 if (is_admin() || empty($_GET['frontend']) || $_GET['frontend'] != true) {
68 80 Admin::get_instance();
69 81 }
70 82 FrontEnd::get_instance();
@@ -76,8 +88,10 @@
76 88 /**
77 89 * Register all REST Endpoint
78 90 */
79 91 REST::get_instance();
92 + // Country/user-role targeting for ALL notification types (decoupled from the bar module).
93 + Targeting::get_instance();
80 94 Cron::get_instance();
81 95 QuickBuild::get_instance();
82 96 ShortcodeInline::get_instance();
83 97 Blocks::get_instance();
@@ -92,8 +106,12 @@
92 106 } else {
93 107 add_action( 'elementor/loaded', [ ElementorManager::class, 'get_instance' ] );
94 108 }
95 109 EntriesMailReceiver::get_instance();
110 +
111 + // MCP server (AI-assistant integration). Progressive enhancement:
112 + // loads only on a capable PHP runtime and stays off until enabled.
113 + \NotificationX\MCP\Bootstrap::init();
96 114 }
97 115 /**
98 116 * The Plugin Activator
99 117 * @return void
@@ -100,20 +118,51 @@
100 118 */
101 119 public function activator(){
102 120 // nx_activated
103 121 Database::get_instance()->Create_DB();
122 +
123 + // A first-ever activation is the only moment the onboarding wizard may
124 + // be auto-launched; an abandoned wizard still counts as "already
125 + // activated", so re-activations never force it again.
126 + $is_first_activation = ! self::has_activated_before();
127 + if ( $is_first_activation ) {
128 + update_option( self::FIRST_ACTIVATION_OPTION, time(), 'no' );
129 + }
130 +
104 131 if( ! static::$WP_CLI && current_user_can( 'delete_users' ) ) {
105 - set_transient( 'nx_activated', true, 30 );
132 + set_transient( 'nx_activated', $is_first_activation ? 'first' : 'again', 30 );
106 133 }
134 +
135 + // Usage insights are normally wired up on `init`, which WordPress has
136 + // already fired by the time it activates a plugin — so the
137 + // register_activation_hook() callback registered inside the insights
138 + // constructor never runs and the tracking event is left unscheduled.
139 + // Build the instance and run its activation routine explicitly here so
140 + // data collection is live from activation onwards.
141 + Admin::get_instance()->plugin_usage_insights()->activate_this_plugin();
142 +
107 143 Upgrader::get_instance()->clear_transient();
108 144 }
109 145
146 + /**
147 + * Whether NotificationX has already gone through its initial activation on
148 + * this site — i.e. it has been activated before, regardless of whether the
149 + * Setup Wizard was ever opened or completed.
150 + *
151 + * @since 3.3.0
152 + * @return bool
153 + */
154 + public static function has_activated_before() {
155 + return (bool) get_option( self::FIRST_ACTIVATION_OPTION, false );
156 + }
157 +
110 158 public function maybe_redirect(){
111 159 if( static::$WP_CLI || wp_doing_ajax() ) {
112 160 return;
113 161 }
114 162 // Bail if no activation transient is set.
115 - if ( ! get_transient( 'nx_activated' ) ) {
163 + $activation = get_transient( 'nx_activated' );
164 + if ( ! $activation ) {
116 165 return;
117 166 }
118 167 // Delete the activation transient.
119 168 delete_transient( 'nx_activated' );
@@ -118,12 +167,15 @@
118 167 // Delete the activation transient.
119 168 delete_transient( 'nx_activated' );
120 169
121 170 if ( ! is_multisite() ) {
122 - // First activation: launch the onboarding Setup Wizard, otherwise
123 - // fall back to the dashboard. The wizard marks itself completed on
124 - // finish/skip so this only fires once.
125 - $page = \NotificationX\Core\SetupWizard::is_completed() ? 'nx-dashboard' : 'nx-setup-wizard';
171 + // Only a genuine first-ever activation may launch the onboarding
172 + // Setup Wizard (and only while it has not been completed/skipped).
173 + // Every returning activation lands on the dashboard, as before.
174 + // Transients written by an older version hold `true`, which is
175 + // treated as a returning activation.
176 + $is_first_activation = ( 'first' === $activation );
177 + $page = ( $is_first_activation && ! \NotificationX\Core\SetupWizard::is_completed() ) ? 'nx-setup-wizard' : 'nx-dashboard';
126 178 wp_safe_redirect( add_query_arg( array(
127 179 'page' => $page
128 180 ), admin_url( 'admin.php' ) ) );
129 181 }
@@ -253,9 +305,154 @@
253 305 $settings[$key] = isset($settings[$key]) ? $settings[$key] : 0;
254 306 $settings[$key] = is_numeric($settings[$key]) ? $settings[$key] + 0 : 0;
255 307 }
256 308 }
309 + return $this->normalize_field_types($settings, $fields);
310 + }
311 +
312 + /**
313 + * Coerce stored values to the shape their consumers expect, without filling
314 + * defaults. Runs on every read (normalize_post()) and before every save
315 + * (PostType::save_post()), so MCP/REST callers that send the wrong JSON type
316 + * can neither store it nor fatal the front end with it.
317 + *
318 + * @param array $settings Notification settings.
319 + * @param array|null $fields Field list from get_field_names(); fetched when null.
320 + * @return array
321 + */
322 + public function normalize_field_types($settings, $fields = null) {
323 + if (!is_array($settings)) {
324 + return $settings;
325 + }
326 + if (null === $fields) {
327 + $fields = $this->get_field_names();
328 + }
329 + foreach ($fields as $key => $value) {
330 + if (!array_key_exists($key, $settings)) {
331 + continue;
332 + }
333 + if (!empty($value['multiple'])) {
334 + $settings[$key] = $this->normalize_multiple_value($settings[$key]);
335 + } elseif (isset($value['type']) && in_array($value['type'], self::STRING_FIELD_TYPES, true)) {
336 + $settings[$key] = $this->normalize_string_value($settings[$key]);
337 + } elseif (isset($value['type']) && in_array($value['type'], self::ROWS_FIELD_TYPES, true)) {
338 + $settings[$key] = $this->normalize_rows_value($settings[$key]);
339 + }
340 + }
341 +
342 + // The field list above comes from a cached builder config, so a field
343 + // whose module was off when it was built is missing from it. These keys
344 + // fatal the front end when they have the wrong type; always coerce them.
345 + foreach (self::GUARDED_FIELDS as $key => $shape) {
346 + if (!array_key_exists($key, $settings)) {
347 + continue;
348 + }
349 + if ('string' === $shape) {
350 + $settings[$key] = $this->normalize_string_value($settings[$key]);
351 + } elseif ('map' === $shape) {
352 + $settings[$key] = is_array($settings[$key]) ? $settings[$key] : (is_object($settings[$key]) ? (array) $settings[$key] : []);
353 + } elseif ('rows' === $shape) {
354 + $settings[$key] = $this->normalize_rows_value($settings[$key]);
355 + }
356 + }
257 357 return $settings;
358 + }
359 +
360 + /**
361 + * Field types whose value every consumer treats as a string (explode(),
362 + * strtotime(), preg_match_all(), ...).
363 + */
364 + const STRING_FIELD_TYPES = ['text', 'textarea', 'date', 'timepicker', 'advanced-codeviewer'];
365 +
366 + /**
367 + * Field types whose value is a list of rows, each row an array.
368 + */
369 + const ROWS_FIELD_TYPES = ['advanced-repeater', 'repeater'];
370 +
371 + /**
372 + * Keys coerced even when they are missing from the cached field list.
373 + * `notification-template` is a `group` (param => tag map); `custom_contents`
374 + * is the Custom Notification repeater; `custom_ids` / `taxonomy_ids` are the
375 + * Pro "comma separated IDs" text fields that Locations explode().
376 + */
377 + const GUARDED_FIELDS = [
378 + 'notification-template' => 'map',
379 + 'custom_contents' => 'rows',
380 + 'custom_ids' => 'string',
381 + 'taxonomy_ids' => 'string',
382 + ];
383 +
384 + /**
385 + * MCP and REST saves store the JSON the caller sent, so a text field can
386 + * arrive as a list (e.g. custom_ids: [12, 34]). Join a list of scalars with
387 + * commas, the format these fields document; anything else becomes ''.
388 + *
389 + * @param mixed $value Stored value.
390 + * @return mixed The value unchanged when it is not an array/object.
391 + */
392 + public function normalize_string_value($value) {
393 + if (is_object($value)) {
394 + $value = (array) $value;
395 + }
396 + if (!is_array($value)) {
397 + return $value;
398 + }
399 + foreach ($value as $item) {
400 + if (!is_scalar($item)) {
401 + return '';
402 + }
403 + }
404 + return implode(',', $value);
405 + }
406 +
407 + /**
408 + * A repeater value must be a list of rows, each row an array. Drop anything
409 + * else, so row consumers (shuffle(), array_splice(), $row['key'] = ...)
410 + * never meet a string.
411 + *
412 + * @param mixed $value Stored value.
413 + * @return array
414 + */
415 + public function normalize_rows_value($value) {
416 + if (is_object($value)) {
417 + $value = (array) $value;
418 + }
419 + if (!is_array($value)) {
420 + return [];
421 + }
422 + $rows = [];
423 + foreach ($value as $key => $row) {
424 + if (is_object($row)) {
425 + $row = (array) $row;
426 + }
427 + if (is_array($row)) {
428 + $rows[$key] = $row;
429 + }
430 + }
431 + return $rows;
432 + }
433 +
434 + /**
435 + * Multi-select fields (`'multiple' => true`) mostly declare `'default' => ''`,
436 + * and MCP / Quick Builder creates store that '' as-is. Consumers pass these
437 + * values to array_diff() / in_array(), which throw a TypeError on PHP 8 for a
438 + * string. Always hand out an array: '' and null become [], a single scalar
439 + * becomes [ scalar ].
440 + *
441 + * @param mixed $value Stored value.
442 + * @return array
443 + */
444 + public function normalize_multiple_value($value) {
445 + if (is_array($value)) {
446 + return $value;
447 + }
448 + if (is_object($value)) {
449 + return (array) $value;
450 + }
451 + if (null === $value || '' === $value || false === $value) {
452 + return [];
453 + }
454 + return [$value];
258 455 }
259 456
260 457 public function get_tab(){
261 458 $tabs = get_transient('nx_builder_fields');