PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.3.3
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.3.3
3.3.3 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 All 158 releases
← All changes | includes/Extensions/ExitIntent/ExitIntentNotification.php +990 -2 3.2.7 → 3.3.3 View file →
@@ -6,10 +6,12 @@
6 6 */
7 7
8 8 namespace NotificationX\Extensions\ExitIntent;
9 9
10 +use NotificationX\NotificationX;
10 11 use NotificationX\GetInstance;
11 12 use NotificationX\Core\Rules;
13 +use NotificationX\Core\Helper;
12 14 use NotificationX\Extensions\GlobalFields;
13 15 use NotificationX\Extensions\Extension;
14 16
15 17 /**
@@ -24,12 +26,571 @@
24 26 public $doc_link = 'https://notificationx.com/docs/';
25 27 public $types = 'exit_intent';
26 28 public $module = 'modules_exit_intent';
27 29
30 + /**
31 + * Elementor seed-theme registry for the "Build With Elementor" modal.
32 + * Populated alongside the JSON files in jsons/ (see Task 07).
33 + *
34 + * @var array<string, array{label:string, value:string, icon:string, column:string, title:string}>
35 + */
36 + public $elementor_themes = [];
37 +
28 38 public function __construct() {
29 39 parent::__construct();
40 + add_action( 'init', [ $this, 'register_post_type' ] );
41 + add_filter( 'get_edit_post_link', [ $this, 'filter_edit_post_link' ], 10, 3 );
42 + add_filter( 'nx_filtered_post', [ $this, 'inject_elementor_html' ], 10, 2 );
43 + add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_elementor_assets' ], 20 );
44 + add_action( 'wp_head', [ $this, 'print_section_constraint_css' ], 99 );
45 + add_action( 'elementor/documents/register_controls', [ $this, 'register_popup_layout_controls' ] );
46 + add_action( 'elementor/editor/after_enqueue_scripts', [ $this, 'enqueue_editor_live_preview' ] );
30 47 }
31 48
49 + /**
50 + * Print the popup section's width constraint inline in <head>.
51 + *
52 + * Two cases:
53 + * 1. Sections that have the `nx-exit-intent-section` class baked in via the
54 + * seed JSON's css_classes setting — constrained by the first rule.
55 + * 2. The Elementor editor preview iframe loads the nx_exit_intent post
56 + * directly as its own page (body.single-nx_exit_intent). The second
57 + * rule constrains ANY section on that page, including legacy imports
58 + * made before css_classes was added to the seed JSON.
59 + */
60 + public function print_section_constraint_css() {
61 + // Width is governed by the Layout panel's Width control via the
62 + // `--nx-exit-width` CSS variable; fall back to the historical 540px cap
63 + // when it is unset (older popups / default).
64 + $vars = '';
65 + if ( is_singular( 'nx_exit_intent' ) ) {
66 + // Editor preview / direct view: resolve the variable from the saved
67 + // Layout-panel settings on this document.
68 + $layout = $this->get_popup_layout_settings( get_the_ID() );
69 + if ( ! empty( $layout['width'] ) ) {
70 + $vars .= '--nx-exit-width:' . $layout['width'] . ';';
71 + }
72 + if ( ! empty( $layout['height'] ) ) {
73 + $vars .= '--nx-exit-height:' . $layout['height'] . ';';
74 + }
75 + }
76 + echo "<style id='nx-exit-intent-section-constraint'>\n";
77 + if ( '' !== $vars ) {
78 + echo "body.single-nx_exit_intent{" . esc_html( $vars ) . "}\n";
79 + }
80 + echo ".nx-exit-intent-section{width:100%!important;max-width:var(--nx-exit-width,540px)!important;margin-left:auto!important;margin-right:auto!important;}\n";
81 + if ( is_singular( 'nx_exit_intent' ) ) {
82 + echo "body.single-nx_exit_intent .elementor-section,body.single-nx_exit_intent .e-con{width:100%!important;max-width:var(--nx-exit-width,540px)!important;margin-left:auto!important;margin-right:auto!important;}\n";
83 + }
84 + echo "</style>\n";
85 + }
86 +
87 + /**
88 + * Server-side: inject the Elementor-rendered HTML + a `mode` flag into the
89 + * REST payload so the React popup shell can render it.
90 + *
91 + * Runs for every campaign settings array via the `nx_filtered_post` filter;
92 + * scoped to Exit Intent campaigns that have a linked, published nx_exit_intent.
93 + */
94 + public function inject_elementor_html( $settings, $params ) {
95 + if ( empty( $settings['source'] ) || $settings['source'] !== $this->id ) {
96 + return $settings;
97 + }
98 + $elementor_id = isset( $settings['elementor_id'] ) ? (int) $settings['elementor_id'] : 0;
99 + if ( ! $elementor_id || ! class_exists( '\\Elementor\\Plugin' ) ) {
100 + $settings['mode'] = 'built_in';
101 + return $settings;
102 + }
103 + if ( get_post_status( $elementor_id ) !== 'publish' ) {
104 + $settings['mode'] = 'built_in';
105 + return $settings;
106 + }
107 +
108 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
109 + $resolved_id = apply_filters( 'wpml_object_id', $elementor_id, 'nx_exit_intent', true );
110 + $html = \Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $resolved_id, false );
111 +
112 + // Surface the Layout-panel settings (registered in register_popup_layout_controls)
113 + // so the popup runtime can honour them, and set --nx-exit-width on a wrapper so
114 + // the section width constraint reflects the configured Width on the frontend.
115 + $layout = $this->get_popup_layout_settings( $resolved_id );
116 + $wrapper_vars = '';
117 + if ( ! empty( $layout['width'] ) ) {
118 + $wrapper_vars .= '--nx-exit-width:' . $layout['width'] . ';';
119 + }
120 + if ( ! empty( $layout['height'] ) ) {
121 + $wrapper_vars .= '--nx-exit-height:' . $layout['height'] . ';';
122 + }
123 + if ( '' !== $wrapper_vars ) {
124 + $html = '<div class="nx-exit-intent-elementor-wrap" style="' . esc_attr( $wrapper_vars ) . '">' . $html . '</div>';
125 + }
126 +
127 + $settings['mode'] = 'elementor';
128 + $settings['elementor_html'] = $html;
129 + $settings['popup_layout'] = $layout;
130 + return $settings;
131 + }
132 +
133 + /**
134 + * Read the Layout-panel settings off an nx_exit_intent Elementor document and
135 + * normalise them into a flat array the popup runtime can consume.
136 + *
137 + * @param int $elementor_id
138 + * @return array
139 + */
140 + protected function get_popup_layout_settings( $elementor_id ) {
141 + $defaults = [
142 + 'width' => '',
143 + 'height_mode' => 'fit',
144 + 'height' => '',
145 + 'horizontal' => 'center',
146 + 'vertical' => 'center',
147 + 'overlay' => true,
148 + 'close_button' => true,
149 + 'entrance_animation' => '',
150 + 'exit_animation' => '',
151 + ];
152 +
153 + if ( ! class_exists( '\\Elementor\\Plugin' ) ) {
154 + return $defaults;
155 + }
156 + $document = \Elementor\Plugin::$instance->documents->get( $elementor_id );
157 + if ( ! $document ) {
158 + return $defaults;
159 + }
160 +
161 + $size = static function ( $value ) {
162 + if ( is_array( $value ) && isset( $value['size'] ) && '' !== $value['size'] ) {
163 + return $value['size'] . ( isset( $value['unit'] ) ? $value['unit'] : 'px' );
164 + }
165 + return '';
166 + };
167 +
168 + $height_mode = $document->get_settings( 'nx_popup_height' );
169 +
170 + // Width precedence: the Layout-panel Width control if the user set one,
171 + // otherwise the design's own top-level container width (boxed_width).
172 + // Without this, every popup falls back to the CSS 540px default, which
173 + // crushes wider designs (e.g. the two-column 896px themes).
174 + $width = $size( $document->get_settings( 'nx_popup_width' ) );
175 + if ( '' === $width ) {
176 + $width = $this->resolve_design_width( $document );
177 + }
178 +
179 + return [
180 + 'width' => $width,
181 + 'height_mode' => $height_mode ? $height_mode : 'fit',
182 + 'height' => 'custom' === $height_mode ? $size( $document->get_settings( 'nx_popup_custom_height' ) ) : '',
183 + 'horizontal' => $document->get_settings( 'nx_popup_horizontal' ) ?: 'center',
184 + 'vertical' => $document->get_settings( 'nx_popup_vertical' ) ?: 'center',
185 + 'overlay' => 'yes' === $document->get_settings( 'nx_popup_overlay' ),
186 + 'close_button' => 'yes' === $document->get_settings( 'nx_popup_close_button' ),
187 + 'entrance_animation' => (string) $document->get_settings( 'nx_popup_entrance_animation' ),
188 + 'exit_animation' => (string) $document->get_settings( 'nx_popup_exit_animation' ),
189 + ];
190 + }
191 +
192 + /**
193 + * Derive the popup's intended width from its Elementor design — the widest
194 + * px width among the top-level elements (boxed_width / width / content_width).
195 + * Used as the default popup width when no Layout-panel Width is configured,
196 + * so each design keeps its own width instead of the 540px CSS fallback.
197 + * Only top-level elements are inspected so inner column widths are ignored.
198 + *
199 + * @param \Elementor\Core\Base\Document $document
200 + * @return string e.g. "896px", or '' when no explicit px width is defined.
201 + */
202 + protected function resolve_design_width( $document ) {
203 + if ( ! $document || ! method_exists( $document, 'get_elements_data' ) ) {
204 + return '';
205 + }
206 + $data = $document->get_elements_data();
207 + if ( empty( $data ) || ! is_array( $data ) ) {
208 + return '';
209 + }
210 + $max = 0;
211 + foreach ( $data as $element ) {
212 + if ( empty( $element['settings'] ) || ! is_array( $element['settings'] ) ) {
213 + continue;
214 + }
215 + foreach ( [ 'boxed_width', 'width', 'content_width' ] as $key ) {
216 + $val = isset( $element['settings'][ $key ] ) ? $element['settings'][ $key ] : null;
217 + if ( is_array( $val ) && isset( $val['size'] ) && is_numeric( $val['size'] )
218 + && ( ! isset( $val['unit'] ) || 'px' === $val['unit'] ) ) {
219 + $max = max( $max, (float) $val['size'] );
220 + }
221 + }
222 + }
223 + return $max > 0 ? $max . 'px' : '';
224 + }
225 +
226 + /**
227 + * Make sure Elementor's per-document CSS/JS is enqueued on every page where
228 + * an Exit Intent campaign can fire — get_builder_content_for_display alone
229 + * registers some assets, but not on pages other than the document's own URL.
230 + */
231 + public function enqueue_elementor_assets() {
232 + if ( ! class_exists( '\\Elementor\\Core\\Files\\CSS\\Post' ) ) {
233 + return;
234 + }
235 + $ids = [];
236 + try {
237 + $posts = \NotificationX\Core\PostType::get_instance()->get_posts( [ 'source' => $this->id ] );
238 + foreach ( $posts as $post ) {
239 + if ( ! empty( $post['elementor_id'] ) && empty( $post['enabled'] ) === false ) {
240 + $ids[] = (int) $post['elementor_id'];
241 + }
242 + }
243 + } catch ( \Exception $e ) {
244 + return;
245 + }
246 + foreach ( $ids as $id ) {
247 + try {
248 + $css = \Elementor\Core\Files\CSS\Post::create( $id );
249 + if ( $css ) {
250 + $css->enqueue();
251 + }
252 + } catch ( \Exception $e ) {
253 + // swallow — one broken doc shouldn't break the rest.
254 + }
255 + }
256 + }
257 +
258 + /**
259 + * Register the post type that backs Elementor-designed Exit Intent popups.
260 + *
261 + * Mirrors PressBar's `nx_bar`: not public, but publicly_queryable so Elementor's
262 + * preview routes resolve. `supports[]` must include `elementor` for Elementor's
263 + * documents API to treat this as a first-class document.
264 + *
265 + * @return void
266 + */
267 + public static function register_post_type() {
268 + register_post_type( 'nx_exit_intent', [
269 + 'label' => __( 'NotificationX Exit Intent', 'notificationx' ),
270 + 'public' => false,
271 + 'publicly_queryable' => true,
272 + 'show_ui' => false,
273 + 'show_in_menu' => true,
274 + 'show_in_nav_menus' => false,
275 + 'exclude_from_search' => true,
276 + 'rewrite' => false,
277 + 'capability_type' => 'post',
278 + 'hierarchical' => false,
279 + 'menu_icon' => 'dashicons-admin-page',
280 + 'supports' => [ 'title', 'content', 'author', 'elementor' ],
281 + ] );
282 + }
283 +
284 + /**
285 + * Add a "Layout" section to the Elementor document-settings panel, but ONLY
286 + * for nx_exit_intent documents. Mirrors Elementor Pro's Popup → Layout panel
287 + * (Width / Height / Position / Overlay / Close Button / Animations).
288 + *
289 + * Hooked on `elementor/documents/register_controls`, which fires for every
290 + * document right after its built-in controls register. We bail for any other
291 + * post type so these controls never leak into normal pages/posts.
292 + *
293 + * Width is wired live via the `--nx-exit-width` CSS variable that
294 + * print_section_constraint_css()/inject_elementor_html() read; the remaining
295 + * controls persist into the document's page settings (`_elementor_page_settings`)
296 + * so the popup runtime can consume them.
297 + *
298 + * @param \Elementor\Core\Base\Document $document
299 + * @return void
300 + */
301 + public function register_popup_layout_controls( $document ) {
302 + if ( ! is_object( $document ) || ! method_exists( $document, 'get_post' ) ) {
303 + return;
304 + }
305 + $post = $document->get_post();
306 + if ( ! $post || 'nx_exit_intent' !== $post->post_type ) {
307 + return;
308 + }
309 +
310 + $cm = '\Elementor\Controls_Manager';
311 +
312 + $document->start_controls_section(
313 + 'nx_popup_layout',
314 + [
315 + 'label' => __( 'Layout', 'notificationx' ),
316 + 'tab' => $cm::TAB_SETTINGS,
317 + ]
318 + );
319 +
320 + $document->add_responsive_control(
321 + 'nx_popup_width',
322 + [
323 + 'label' => __( 'Width', 'notificationx' ),
324 + 'type' => $cm::SLIDER,
325 + 'size_units' => [ 'px', '%', 'vw' ],
326 + 'range' => [
327 + 'px' => [ 'min' => 100, 'max' => 1140 ],
328 + '%' => [ 'min' => 10, 'max' => 100 ],
329 + 'vw' => [ 'min' => 10, 'max' => 100 ],
330 + ],
331 + // No default: when the user hasn't set a Width, the popup falls back
332 + // to the design's own container width (resolve_design_width()), not a
333 + // fixed 540px — otherwise wider designs (e.g. the 896px two-column
334 + // themes) get crushed. An explicit value here still wins.
335 + //
336 + // NOTE: no `selectors` key — Elementor silently drops document-level
337 + // controls that carry `selectors`. The value is applied server-side
338 + // via the --nx-exit-width variable in print_section_constraint_css()
339 + // (preview) and inject_elementor_html() (frontend) instead.
340 + 'default' => [ 'unit' => 'px', 'size' => '' ],
341 + ]
342 + );
343 +
344 + $document->add_control(
345 + 'nx_popup_height',
346 + [
347 + 'label' => __( 'Height', 'notificationx' ),
348 + 'type' => $cm::SELECT,
349 + 'default' => 'fit',
350 + 'options' => [
351 + 'fit' => __( 'Fit To Content', 'notificationx' ),
352 + 'custom' => __( 'Custom Height', 'notificationx' ),
353 + ],
354 + ]
355 + );
356 +
357 + $document->add_responsive_control(
358 + 'nx_popup_custom_height',
359 + [
360 + 'label' => __( 'Custom Height', 'notificationx' ),
361 + 'type' => $cm::SLIDER,
362 + 'size_units' => [ 'px', 'vh' ],
363 + 'range' => [
364 + 'px' => [ 'min' => 100, 'max' => 1000 ],
365 + 'vh' => [ 'min' => 10, 'max' => 100 ],
366 + ],
367 + 'default' => [ 'unit' => 'px', 'size' => 480 ],
368 + 'condition' => [ 'nx_popup_height' => 'custom' ],
369 + // No `selectors` (see nx_popup_width note); applied via --nx-exit-height.
370 + ]
371 + );
372 +
373 + $document->add_control(
374 + 'nx_popup_position_heading',
375 + [
376 + 'label' => __( 'Position', 'notificationx' ),
377 + 'type' => $cm::HEADING,
378 + 'separator' => 'before',
379 + ]
380 + );
381 +
382 + $document->add_responsive_control(
383 + 'nx_popup_horizontal',
384 + [
385 + 'label' => __( 'Horizontal', 'notificationx' ),
386 + 'type' => $cm::CHOOSE,
387 + 'default' => 'center',
388 + 'options' => [
389 + 'start' => [ 'title' => __( 'Start', 'notificationx' ), 'icon' => 'eicon-h-align-left' ],
390 + 'center' => [ 'title' => __( 'Center', 'notificationx' ), 'icon' => 'eicon-h-align-center' ],
391 + 'end' => [ 'title' => __( 'End', 'notificationx' ), 'icon' => 'eicon-h-align-right' ],
392 + ],
393 + ]
394 + );
395 +
396 + $document->add_responsive_control(
397 + 'nx_popup_vertical',
398 + [
399 + 'label' => __( 'Vertical', 'notificationx' ),
400 + 'type' => $cm::CHOOSE,
401 + 'default' => 'center',
402 + 'options' => [
403 + 'start' => [ 'title' => __( 'Top', 'notificationx' ), 'icon' => 'eicon-v-align-top' ],
404 + 'center' => [ 'title' => __( 'Middle', 'notificationx' ), 'icon' => 'eicon-v-align-middle' ],
405 + 'end' => [ 'title' => __( 'Bottom', 'notificationx' ), 'icon' => 'eicon-v-align-bottom' ],
406 + ],
407 + ]
408 + );
409 +
410 + $document->add_control(
411 + 'nx_popup_overlay',
412 + [
413 + 'label' => __( 'Overlay', 'notificationx' ),
414 + 'type' => $cm::SWITCHER,
415 + 'label_on' => __( 'Show', 'notificationx' ),
416 + 'label_off' => __( 'Hide', 'notificationx' ),
417 + 'return_value' => 'yes',
418 + 'default' => 'yes',
419 + 'separator' => 'before',
420 + ]
421 + );
422 +
423 + $document->add_control(
424 + 'nx_popup_close_button',
425 + [
426 + 'label' => __( 'Close Button', 'notificationx' ),
427 + 'type' => $cm::SWITCHER,
428 + 'label_on' => __( 'Show', 'notificationx' ),
429 + 'label_off' => __( 'Hide', 'notificationx' ),
430 + 'return_value' => 'yes',
431 + 'default' => 'yes',
432 + ]
433 + );
434 +
435 + $document->add_control(
436 + 'nx_popup_entrance_animation',
437 + [
438 + 'label' => __( 'Entrance Animation', 'notificationx' ),
439 + 'type' => $cm::ANIMATION,
440 + 'separator' => 'before',
441 + ]
442 + );
443 +
444 + $document->add_control(
445 + 'nx_popup_exit_animation',
446 + [
447 + 'label' => __( 'Exit Animation', 'notificationx' ),
448 + 'type' => $cm::EXIT_ANIMATION,
449 + ]
450 + );
451 +
452 + $document->end_controls_section();
453 + }
454 +
455 + /**
456 + * Live-preview bridge for the Layout panel inside the Elementor editor.
457 + *
458 + * Document-level controls can't carry a `selectors` key (Elementor drops
459 + * them), so Width/Height don't get Elementor's built-in live CSS injection.
460 + * This registers page-settings change callbacks that write the
461 + * `--nx-exit-width` / `--nx-exit-height` variables straight onto the preview
462 + * iframe's <body> as the slider moves — so changes reflect instantly instead
463 + * of only after a reload.
464 + *
465 + * Scoped to nx_exit_intent documents only.
466 + *
467 + * @return void
468 + */
469 + public function enqueue_editor_live_preview() {
470 + if ( ! class_exists( '\\Elementor\\Plugin' ) ) {
471 + return;
472 + }
473 + $post_id = \Elementor\Plugin::$instance->editor->get_post_id();
474 + if ( ! $post_id || 'nx_exit_intent' !== get_post_type( $post_id ) ) {
475 + return;
476 + }
477 +
478 + $js = <<<'JS'
479 +( function ( $ ) {
480 + function toCss( value ) {
481 + if ( value && '' !== value.size && null != value.size ) {
482 + return value.size + ( value.unit || 'px' );
483 + }
484 + return '';
485 + }
486 + function previewBody() {
487 + var frame = elementor.$preview && elementor.$preview[0];
488 + var win = frame && frame.contentWindow;
489 + return win && win.document ? win.document.body : null;
490 + }
491 + function applyVar( name, value ) {
492 + var body = previewBody();
493 + if ( ! body ) { return; }
494 + var css = toCss( value );
495 + if ( css ) { body.style.setProperty( name, css ); }
496 + else { body.style.removeProperty( name ); }
497 + }
498 + $( window ).on( 'elementor:init', function () {
499 + if ( 'undefined' === typeof elementor || ! elementor.settings || ! elementor.settings.page ) {
500 + return;
501 + }
502 + elementor.settings.page.addChangeCallback( 'nx_popup_width', function ( value ) {
503 + applyVar( '--nx-exit-width', value );
504 + } );
505 + elementor.settings.page.addChangeCallback( 'nx_popup_custom_height', function ( value ) {
506 + applyVar( '--nx-exit-height', value );
507 + } );
508 + } );
509 +} )( jQuery );
510 +JS;
511 +
512 + wp_add_inline_script( 'elementor-editor', $js );
513 + }
514 +
515 + /**
516 + * Create a new `nx_exit_intent` Elementor document from a seed theme and
517 + * respond to the admin with the new post ID + Elementor edit URL.
518 + *
519 + * Counterpart of PressBar::create_bar_of_type_bar_with_elementor().
520 + *
521 + * @param array $params { theme_id: string }
522 + * @return void Always responds via wp_send_json_{success,error}.
523 + */
524 + public function create_exit_intent_with_elementor( $params ) {
525 + if ( empty( $params['theme_id'] ) ) {
526 + wp_send_json_error( 'missing_theme_id' );
527 + }
528 +
529 + $theme = sanitize_text_field( $params['theme_id'] );
530 + $importer = new Importer();
531 +
532 + $ID = $importer->create_nx( [
533 + 'theme' => $theme,
534 + 'post_title' => 'Design for NotificationX Exit Intent - ',
535 + ] );
536 +
537 + if ( $ID && ! is_wp_error( $ID ) ) {
538 + // Strip theme chrome — popup HTML inlines into the host page.
539 + update_post_meta( $ID, '_wp_page_template', 'elementor_canvas' );
540 + wp_send_json_success( [
541 + 'context' => [
542 + 'themes' => null,
543 + 'elementor_id' => $ID,
544 + 'elementor_edit_link' => \Elementor\Plugin::$instance->documents->get( $ID )->get_edit_url(),
545 + ],
546 + ] );
547 + } else {
548 + wp_send_json_error( is_wp_error( $ID ) ? $ID->get_error_message() : 'failed' );
549 + }
550 + }
551 +
552 + /**
553 + * Delete the linked `nx_exit_intent` Elementor post (and its WPML translations).
554 + *
555 + * @param int|string $elementor_id
556 + * @return void
557 + */
558 + public function delete_elementor_post( $elementor_id ) {
559 + if ( empty( $elementor_id ) ) {
560 + return;
561 + }
562 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
563 + $languages = apply_filters( 'wpml_active_languages', null );
564 + if ( is_array( $languages ) ) {
565 + foreach ( $languages as $lang => $val ) {
566 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
567 + $translated_id = apply_filters( 'wpml_object_id', $elementor_id, 'nx_exit_intent', false, $lang );
568 + if ( $translated_id ) {
569 + Helper::delete_owned_post( $translated_id, 'nx_exit_intent' );
570 + }
571 + }
572 + return;
573 + }
574 + Helper::delete_owned_post( $elementor_id, 'nx_exit_intent' );
575 + }
576 +
577 + /**
578 + * Redirect WP "Edit" links for nx_exit_intent posts straight into the Elementor editor.
579 + *
580 + * @param string $link
581 + * @param int $id
582 + * @param string $context
583 + * @return string
584 + */
585 + public function filter_edit_post_link( $link, $id, $context ) {
586 + $post = get_post( $id );
587 + if ( $post && 'nx_exit_intent' === $post->post_type && class_exists( '\\Elementor\\Plugin' ) ) {
588 + return \Elementor\Plugin::$instance->documents->get( $id )->get_edit_url();
589 + }
590 + return $link;
591 + }
592 +
32 593 public function init_extension() {
33 594 $this->title = __( 'Exit Intent Popup', 'notificationx' );
34 595 $this->module_title = __( 'Exit Intent Popup', 'notificationx' );
35 596 $this->themes = [
@@ -143,8 +704,77 @@
143 704 'column' => '5',
144 705 'is_pro' => true,
145 706 ],
146 707 ];
708 +
709 + // Elementor seed themes for the "Build With Elementor" modal.
710 + // Each entry has a matching JSON file under jsons/{value}.json (Task 07).
711 + // Preview images live under assets/admin/images/extensions/themes/exit-intent-elementor/.
712 + // Until per-theme Elementor previews are rendered, we fall back to the existing
713 + // built-in screenshots so the modal renders with a real image.
714 + // Order intentionally mirrors the built-in $themes preset order above
715 + // (one, four, three, six, two, seven, five) so the Elementor modal lists
716 + // the same designs in the same sequence as the Default-theme picker.
717 + //
718 + // Unlike $themes (processed by Extension::__nx_themes), this array is fed
719 + // straight to the radio-card field, so we must resolve the pro badge here:
720 + // only flag a theme as pro when the Pro plugin is NOT active.
721 + $is_pro_badge = ! NotificationX::is_pro();
722 + $this->elementor_themes = [
723 + 'theme-one' => [
724 + 'label' => 'theme-one',
725 + 'value' => 'theme-one',
726 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/extensions/themes/exit-intent/exit-intent-theme-one.png',
727 + 'column' => '6',
728 + 'title' => 'Exit Intent Theme One',
729 + ],
730 + 'theme-four' => [
731 + 'label' => 'theme-four',
732 + 'value' => 'theme-four',
733 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/extensions/themes/exit-intent/exit-intent-theme-four.png',
734 + 'column' => '6',
735 + 'title' => 'Exit Intent Theme Four',
736 + ],
737 + 'theme-three' => [
738 + 'label' => 'theme-three',
739 + 'value' => 'theme-three',
740 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/extensions/themes/exit-intent/exit-intent-theme-three.png',
741 + 'column' => '6',
742 + 'title' => 'Exit Intent Theme Three',
743 + ],
744 + 'theme-six' => [
745 + 'label' => 'theme-six',
746 + 'value' => 'theme-six',
747 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/extensions/themes/exit-intent/exit-intent-theme-six.png',
748 + 'column' => '6',
749 + 'title' => 'Exit Intent Theme Six',
750 + 'is_pro' => $is_pro_badge,
751 + ],
752 + 'theme-two' => [
753 + 'label' => 'theme-two',
754 + 'value' => 'theme-two',
755 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/extensions/themes/exit-intent/exit-intent-theme-two.png',
756 + 'column' => '6',
757 + 'title' => 'Exit Intent Theme Two',
758 + 'is_pro' => $is_pro_badge,
759 + ],
760 + 'theme-seven' => [
761 + 'label' => 'theme-seven',
762 + 'value' => 'theme-seven',
763 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/extensions/themes/exit-intent/exit-intent-theme-seven.png',
764 + 'column' => '6',
765 + 'title' => 'Exit Intent Theme Seven',
766 + 'is_pro' => $is_pro_badge,
767 + ],
768 + 'theme-five' => [
769 + 'label' => 'theme-five',
770 + 'value' => 'theme-five',
771 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/extensions/themes/exit-intent/exit-intent-theme-five.png',
772 + 'column' => '6',
773 + 'title' => 'Exit Intent Theme Five',
774 + 'is_pro' => $is_pro_badge,
775 + ],
776 + ];
147 777 }
148 778
149 779 public function init_fields() {
150 780 parent::init_fields();
@@ -149,12 +779,361 @@
149 779 public function init_fields() {
150 780 parent::init_fields();
151 781 add_filter( 'nx_content_fields', [ $this, 'content_fields' ], 999 );
152 782 add_filter( 'nx_design_tab_fields', [ $this, 'design_fields' ], 99 );
783 + add_filter( 'nx_design_tab_fields', [ $this, 'builder_tabs_fields' ], 100 );
153 784 add_filter( 'nx_customize_fields', [ $this, 'customize_fields' ], 999 );
154 785 add_filter( 'nx_display_fields', [ $this, 'display_fields' ], 999 );
786 +
787 + // Hide per-theme content sections when an Elementor doc is linked (Task 06).
788 + // We deliberately do NOT hide the themes radio-card itself — keeping it
789 + // visible on the Default tab lets users switch back to a built-in theme
790 + // after they've imported an Elementor design.
791 + add_filter( 'nx_content_fields', [ $this, 'suppress_when_elementor' ], 1001 );
792 +
793 + // Hide the whole Content wizard step when an Elementor design is linked —
794 + // suppress_when_elementor() empties every content section in that case, so
795 + // the step would otherwise render as a blank tab.
796 + add_filter( 'nx_metabox_tabs', [ $this, 'hide_content_tab_for_elementor' ] );
155 797 }
156 798
799 + /**
800 + * Remove the Content step from the wizard for Exit Intent campaigns that are
801 + * built with Elementor. Elementor owns all of the popup's content there (every
802 + * `exit_intent_*_section` is suppressed by suppress_when_elementor()), leaving
803 + * the Content tab empty. Built-in themes keep the Content step, and all other
804 + * sources are untouched.
805 + *
806 + * The tab stays visible UNLESS (source is Exit Intent AND elementor_id is a
807 + * number), expressed below as the OR'd "show when" rule.
808 + */
809 + public function hide_content_tab_for_elementor( $tabs ) {
810 + if ( empty( $tabs['content_tab'] ) ) {
811 + return $tabs;
812 + }
813 + $show_rule = Rules::_logicalRule( [
814 + Rules::_is( 'source', $this->id, true ),
815 + Rules::_isOfType( 'elementor_id', 'number', true ),
816 + ], 'or' );
817 + $tabs['content_tab'] = Rules::add( $show_rule, $tabs['content_tab'] );
818 + return $tabs;
819 + }
820 +
821 + /**
822 + * Hide every per-theme content section once an Elementor design is linked.
823 + * Elementor owns all of these fields; showing them would let users edit
824 + * values with no visible effect on the rendered popup.
825 + */
826 + public function suppress_when_elementor( $fields ) {
827 + foreach ( $fields as $key => $field ) {
828 + if ( strpos( $key, 'exit_intent_' ) === 0 && substr( $key, -8 ) === '_section' ) {
829 + $fields[ $key ] = Rules::isOfType( 'elementor_id', 'number', true, $field );
830 + }
831 + }
832 + return $fields;
833 + }
834 +
835 + /**
836 + * @deprecated Kept for back-compat only; the filter that called this has
837 + * been removed so the themes radio stays visible on the Default tab even
838 + * when an Elementor design is linked (lets users switch back).
839 + */
840 + public function suppress_themes_radio( $fields ) {
841 + if ( ! isset( $fields['themes']['fields']['themes']['rules'] ) ) {
842 + return $fields;
843 + }
844 + $existing = $fields['themes']['fields']['themes']['rules'];
845 + // Additional clause: either we're NOT on Exit Intent (so this rule
846 + // shouldn't change anything for other sources), OR elementor_id is
847 + // not yet set. Combined via AND with the existing tab-gate so other
848 + // sources (e.g. PressBar) keep their tab-based hiding intact.
849 + $additional = Rules::logicalRule( [
850 + Rules::is( 'source', $this->id, true ),
851 + Rules::isOfType( 'elementor_id', 'number', true ),
852 + ], 'or' );
853 + $fields['themes']['fields']['themes']['rules'] = Rules::logicalRule( [
854 + $existing,
855 + $additional,
856 + ], 'and' );
857 + return $fields;
858 + }
859 +
860 + /**
861 + * Restructures the Themes section into two tabs:
862 + * 1. Default — original 7 React themes grid (the `themes` radio-card,
863 + * already gated to themes_tab='for_desktop' in GlobalFields).
864 + * 2. Custom — Build With Elementor button + modal, plus Edit/Remove
865 + * and Install fallback after a doc is linked.
866 + *
867 + * Matches Notification Bar's Default/Custom tab structure.
868 + */
869 + public function builder_tabs_fields( $fields ) {
870 + if ( empty( $fields['themes']['fields']['themes_section']['fields']['themes_tab']['fields'] ) ) {
871 + return $fields;
872 + }
873 + $tab =& $fields['themes']['fields']['themes_section']['fields']['themes_tab']['fields'];
874 +
875 + // Hide the global "For Desktop" / "For Mobile" tabs for Exit Intent only — we
876 + // ship our own Default + Custom tabs below.
877 + foreach ( [ 'for_desktop', 'for_mobile' ] as $key ) {
878 + if ( isset( $tab[ $key ] ) ) {
879 + $tab[ $key ] = Rules::is( 'source', $this->id, true, $tab[ $key ] );
880 + }
881 + }
882 +
883 + // Expand the global themes radio-card so it ALSO renders when our new
884 + // Default tab is active. Without this, the radio would never appear for
885 + // Exit Intent — its existing rule is hard-coded to themes_tab='for_desktop'.
886 + if ( isset( $fields['themes']['fields']['themes'] ) ) {
887 + $fields['themes']['fields']['themes']['rules'] = Rules::logicalRule( [
888 + Rules::is( 'themes_tab', 'for_desktop' ),
889 + Rules::is( 'themes_tab', 'exit_intent_default_tab' ),
890 + ], 'or' );
891 + }
892 +
893 + $is_installed = Helper::is_plugin_installed( 'elementor/elementor.php' );
894 + $install_activate_text = $is_installed
895 + ? __( 'Activate Elementor', 'notificationx' )
896 + : __( 'Install Elementor', 'notificationx' );
897 +
898 + // Default tab — unique name (no collision with global for_desktop).
899 + $tab[] = [
900 + 'label' => __( 'Default', 'notificationx' ),
901 + 'name' => 'exit_intent_default_tab',
902 + 'id' => 'exit_intent_default_tab',
903 + 'type' => 'section',
904 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/icons/nxbar-presets-icon.svg',
905 + 'rules' => Rules::is( 'source', $this->id ),
906 + ];
907 +
908 + // The Custom tab — only renders for Exit Intent campaigns.
909 + $tab[] = [
910 + 'label' => __( 'Custom', 'notificationx' ),
911 + 'name' => 'exit_intent_custom_tab',
912 + 'id' => 'exit_intent_custom_tab',
913 + 'type' => 'section',
914 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/icons/nxbar-custom-tab.svg',
915 + 'rules' => Rules::is( 'source', $this->id ),
916 + 'fields' => [
917 +
918 + // Preview image / empty-state illustration (reuses NxBarPresets).
919 + 'exit_intent_custom_preview' => [
920 + 'name' => 'exit_intent_custom_preview',
921 + 'type' => 'exit-intent-custom',
922 + 'label' => __( 'Exit Intent', 'notificationx' ),
923 + 'priority' => 5,
924 + ],
925 +
926 + // Edit With Elementor — shown when an Elementor doc is linked.
927 + 'elementor_edit_link' => [
928 + 'name' => 'elementor_edit_link',
929 + 'type' => 'button',
930 + 'text' => __( 'Edit With Elementor', 'notificationx' ),
931 + 'href' => -1,
932 + 'priority' => 10,
933 + 'target' => '_blank',
934 + 'rules' => Rules::logicalRule( [
935 + Rules::is( 'elementor_edit_link', false, true ),
936 + Rules::isOfType( 'elementor_edit_link', 'string' ),
937 + Rules::is( 'elementor_id', false, true ),
938 + ] ),
939 + ],
940 +
941 + // Remove — deletes the Elementor doc and resets state.
942 + 'nx-exit-intent_with_elementor-remove' => [
943 + 'name' => 'nx-exit-intent_with_elementor-remove',
944 + 'type' => 'button',
945 + 'text' => __( 'Remove', 'notificationx' ),
946 + 'priority' => 15,
947 + 'rules' => Rules::logicalRule( [
948 + Rules::is( 'elementor_id', false, true ),
949 + Rules::is( 'is_elementor', true ),
950 + Rules::is( 'source', $this->id ),
951 + ] ),
952 + 'ajax' => [
953 + 'on' => 'click',
954 + 'api' => '/notificationx/v1/exit-intent/elementor/remove',
955 + 'data' => [ 'elementor_id' => '@elementor_id' ],
956 + 'hideSwal' => true,
957 + ],
958 + 'trigger' => [
959 + [
960 + 'type' => 'setFieldValue',
961 + 'action' => [
962 + 'elementor_id' => false,
963 + 'elementor_edit_link' => '',
964 + 'elementor_exit_theme' => 'theme-one',
965 + 'is_confirmed' => false,
966 + 'themes' => $this->id . '_theme-one',
967 + ],
968 + ],
969 + ],
970 + ],
971 +
972 + // Build With Elementor — modal with seed-theme picker.
973 + 'nx-exit-intent_with_elementor' => [
974 + 'name' => 'nx-exit-intent_with_elementor',
975 + 'type' => 'modal',
976 + 'priority' => 20,
977 + 'button' => [
978 + 'name' => 'build_with_elementor',
979 + 'text' => __( 'Build With Elementor', 'notificationx' ),
980 + 'trigger' => [
981 + [
982 + 'type' => 'setFieldValue',
983 + 'action' => [ 'import_elementor_theme' => false ],
984 + ],
985 + ],
986 + ],
987 + 'confirm_button' => [
988 + 'type' => 'button',
989 + 'name' => 'import_elementor_theme',
990 + 'group' => true,
991 + 'fields' => [
992 + [
993 + 'type' => 'button',
994 + 'name' => 'import_elementor_theme',
995 + 'default' => false,
996 + 'text' => [
997 + 'normal' => __( 'Import', 'notificationx' ),
998 + 'saved' => __( 'Import', 'notificationx' ),
999 + 'loading' => __( 'Importing...', 'notificationx' ),
1000 + ],
1001 + 'ajax' => [
1002 + 'on' => 'click',
1003 + 'api' => '/notificationx/v1/exit-intent/elementor/import',
1004 + 'data' => [ 'theme_id' => '@elementor_exit_theme' ],
1005 + 'trigger' => '@is_confirmed:true',
1006 + 'hideSwal' => true,
1007 + ],
1008 + 'rules' => Rules::is( 'is_confirmed', true, true ),
1009 + ],
1010 + [
1011 + 'type' => 'button',
1012 + 'name' => 'import_elementor_theme_next',
1013 + 'default' => false,
1014 + 'text' => __( 'Next', 'notificationx' ),
1015 + 'rules' => Rules::is( 'is_confirmed', true ),
1016 + 'trigger' => [
1017 + [
1018 + 'type' => 'setContext',
1019 + 'action' => [ 'config.active' => 'display_tab' ],
1020 + ],
1021 + [
1022 + 'type' => 'setFieldValue',
1023 + 'action' => [ 'import_elementor_theme_next' => true ],
1024 + ],
1025 + ],
1026 + ],
1027 + ],
1028 + ],
1029 + 'cancel' => 'import_elementor_theme_next',
1030 + 'body' => [
1031 + 'header' => __( 'Choose Your ', 'notificationx' ),
1032 + 'fields' => [
1033 + 'themes' => [
1034 + 'type' => 'radio-card',
1035 + 'name' => 'elementor_exit_theme',
1036 + 'style' => [ 'label' => [ 'position' => 'top' ] ],
1037 + 'rules' => Rules::is( 'is_confirmed', true, true ),
1038 + 'default' => 'theme-one',
1039 + 'options' => $this->elementor_themes,
1040 + ],
1041 + ],
1042 + ],
1043 + 'rules' => Rules::logicalRule( [
1044 + Rules::is( 'elementor_id', false ),
1045 + Rules::is( 'is_elementor', true ),
1046 + Rules::is( 'is_confirmed', true, true ),
1047 + Rules::is( 'source', $this->id ),
1048 + ] ),
1049 + ],
1050 +
1051 + // Install / Activate Elementor — fallback when Elementor isn't active.
1052 + 'nx-exit-intent_with_elementor_install' => [
1053 + 'name' => 'nx-exit-intent_with_elementor_install',
1054 + 'type' => 'button',
1055 + 'priority' => 25,
1056 + 'text' => [
1057 + 'normal' => $install_activate_text,
1058 + 'saved' => $is_installed ? __( 'Activated Elementor', 'notificationx' ) : __( 'Installed Elementor', 'notificationx' ),
1059 + 'loading' => $is_installed ? __( 'Activating Elementor...', 'notificationx' ) : __( 'Installing Elementor...', 'notificationx' ),
1060 + ],
1061 + 'rules' => Rules::logicalRule( [
1062 + Rules::is( 'is_elementor', false ),
1063 + Rules::is( 'source', $this->id ),
1064 + ] ),
1065 + 'ajax' => [
1066 + 'on' => 'click',
1067 + 'api' => '/notificationx/v1/core-install',
1068 + 'data' => [
1069 + 'source' => $this->id,
1070 + 'slug' => 'elementor',
1071 + 'file' => 'elementor.php',
1072 + 'is_installed' => $is_installed,
1073 + ],
1074 + 'swal' => [
1075 + 'icon' => 'success',
1076 + 'text' => __( 'Successfully Activated', 'notificationx' ),
1077 + ],
1078 + 'trigger' => '@is_elementor:true',
1079 + ],
1080 + ],
1081 +
1082 + // Hidden state fields (Task 05).
1083 + 'is_elementor' => [
1084 + 'name' => 'is_elementor',
1085 + 'type' => 'hidden',
1086 + 'default' => class_exists( '\\Elementor\\Plugin' ),
1087 + 'rules' => Rules::is( 'source', $this->id ),
1088 + ],
1089 + 'elementor_id' => [
1090 + 'name' => 'elementor_id',
1091 + 'type' => 'hidden',
1092 + 'default' => false,
1093 + 'rules' => Rules::is( 'source', $this->id ),
1094 + ],
1095 + 'is_confirmed' => [
1096 + 'name' => 'is_confirmed',
1097 + 'type' => 'hidden',
1098 + 'default' => false,
1099 + ],
1100 + ],
1101 + ];
1102 +
1103 + // Build With AI tab — Pro feature. Free plugin only registers the tab
1104 + // shell + field type; the Pro plugin supplies the actual builder via
1105 + // the `nx_build_ai_render` filter (see PressBar's `nxbar_build_with_ai`
1106 + // for the same pattern already shipped for Notification Bar).
1107 + $tab[] = [
1108 + 'label' => __( 'Build With AI', 'notificationx' ),
1109 + 'name' => 'exit_intent_ai_tab',
1110 + 'id' => 'exit_intent_ai_tab',
1111 + 'type' => 'section',
1112 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/icons/build-with-ai-icon.svg',
1113 + 'rules' => Rules::is( 'source', $this->id ),
1114 + 'fields' => [
1115 + 'exit_intent_ai_tab' => [
1116 + 'label' => __( 'Build With AI', 'notificationx' ),
1117 + 'name' => 'exit_intent_ai_tab',
1118 + 'id' => 'exit_intent_ai_tab',
1119 + 'type' => 'section',
1120 + 'icon' => NOTIFICATIONX_ADMIN_URL . 'images/responsive/desktop.svg',
1121 + 'fields' => [
1122 + 'exit_intent_ai_tab_fields' => [
1123 + 'name' => 'exit_intent_ai_tab_fields',
1124 + 'type' => 'exit-intent-build_with_ai',
1125 + 'label' => __( 'Exit Intent', 'notificationx' ),
1126 + 'priority' => 10,
1127 + ],
1128 + ],
1129 + ],
1130 + ],
1131 + ];
1132 +
1133 + return $fields;
1134 + }
1135 +
157 1136 public function display_fields( $fields ) {
158 1137 if ( isset( $fields['image-section'] ) ) {
159 1138 $fields['image-section'] = Rules::is( 'source', $this->id, true, $fields['image-section'] );
160 1139 }
@@ -769,8 +1748,9 @@
769 1748 'name' => 'exit_intent_name_label',
770 1749 'type' => 'text',
771 1750 'default' => __( 'Name *', 'notificationx' ),
772 1751 'priority' => 75,
1752 + 'is_pro' => true,
773 1753 'rules' => Rules::is( 'exit_intent_show_name', true ),
774 1754 ],
775 1755 [
776 1756 'label' => __( 'Show Email Field', 'notificationx' ),
@@ -785,8 +1765,9 @@
785 1765 'name' => 'exit_intent_email_label',
786 1766 'type' => 'text',
787 1767 'default' => __( 'Enter Your Email *', 'notificationx' ),
788 1768 'priority' => 85,
1769 + 'is_pro' => true,
789 1770 'rules' => Rules::is( 'exit_intent_show_email', true ),
790 1771 ],
791 1772 [
792 1773 'label' => __( 'Show Message Field', 'notificationx' ),
@@ -1227,11 +2208,17 @@
1227 2208 'rules' => Rules::is( 'source', $this->id ),
1228 2209 ];
1229 2210 }
1230 2211
1231 - $fields['exit_intent_settings'] = [
2212 + // NOTE: keep this key unique. The Announcement extension (PopupNotification)
2213 + // also registers a section literally named `exit_intent_settings` for its
2214 + // "Convert to Exit Intent" feature; sharing the key here let Popup's filter
2215 + // (same `nx_customize_fields` priority, registered later) overwrite this
2216 + // section with a `source == popup_notification` rule, hiding it for the
2217 + // Exit Intent type and leaving the whole Customize tab blank.
2218 + $fields['exit_intent_popup_settings'] = [
1232 2219 'label' => __( 'Exit Intent Settings', 'notificationx' ),
1233 - 'name' => 'exit_intent_settings',
2220 + 'name' => 'exit_intent_popup_settings',
1234 2221 'type' => 'section',
1235 2222 'priority' => 15,
1236 2223 'rules' => Rules::is( 'source', $this->id ),
1237 2224 'fields' => [
@@ -1285,8 +2272,9 @@
1285 2272 return $fields;
1286 2273 }
1287 2274
1288 2275 public function doc() {
2276 + /* translators: %1$s: documentation link URL */
1289 2277 return sprintf(__('
1290 2278 <p>Show a targeted message at the exact moment someone is about to close your tab & bring them back into the funnel. Need help? Check out our <a target="_blank" href="%1$s">documentation</a>.</p>',
1291 2279 'notificationx'),
1292 2280 'https://notificationx.com/docs/how-to-configure-exit-intent-popup/',