PluginProbe
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment / 3.1.14
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment v3.1.14
3.1.14 3.1.13 3.1.12 3.1.11 3.1.10 3.1.9 3.1.8 3.1.7 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 All 123 releases
← All changes | Inc/Core/FB/Box.php +625 -224 1.1.0 → 3.1.14 View file →
@@ -1,12 +1,12 @@
1 1 <?php
2 2 /**
3 3 * @package FireBox
4 - * @version 1.1.0 Free
4 + * @version 3.1.14 Free
5 5 *
6 6 * @author FirePlugins <[email protected]>
7 7 * @link https://www.fireplugins.com
8 - * @copyright Copyright © 2022 FirePlugins All Rights Reserved
8 + * @copyright Copyright © 2026 FirePlugins All Rights Reserved
9 9 * @license GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
10 10 */
11 11
12 12 namespace FireBox\Core\FB;
@@ -52,14 +52,23 @@
52 52 private $params = null;
53 53
54 54 /**
55 55 * Popup CSS.
56 - *
56 + *
57 57 * @var CSS
58 58 */
59 59 public $css = null;
60 60
61 61 /**
62 + * Display condition groups that must be resolved in the browser before the
63 + * campaign may open (session-dependent rules like Time on Site/Pageviews).
64 + * Populated by pass(), shipped to the frontend runtime via prepare().
65 + *
66 + * @var array
67 + */
68 + private $clientRuleGroups = [];
69 +
70 + /**
62 71 * Constructor.
63 72 *
64 73 * @param object $box
65 74 * @param object $factory
@@ -101,24 +110,24 @@
101 110 }
102 111
103 112 /**
104 113 * Get a box.
105 - *
114 + *
106 115 * @param int $id
107 116 * @param string $status
108 - *
109 - * @return object
117 + *
118 + * @return object|null
110 119 */
111 120 public function get($id = null, $status = null)
112 121 {
113 122 if (!$id)
114 123 {
115 - return;
124 + return null;
116 125 }
117 126
118 127 $payload = [
119 128 'where' => [
120 - 'ID' => ' = ' . esc_sql(intval($id)),
129 + 'ID' => ' = ' . intval($id),
121 130 'post_type' => " = 'firebox'"
122 131 ]
123 132 ];
124 133
@@ -124,19 +133,19 @@
124 133
125 134 // apply status if given
126 135 if ($status)
127 136 {
128 - $payload['where']['post_status'] = ' = \'' . esc_sql($status) . '\'';
137 + $payload['where']['post_status'] = ' = \'' . sanitize_key($status) . '\'';
129 138 }
130 -
139 +
131 140 if (!$box = firebox()->tables->box->getResults($payload))
132 141 {
133 - return [];
142 + return null;
134 143 }
135 144
136 145 if (!isset($box[0]))
137 146 {
138 - return [];
147 + return null;
139 148 }
140 149
141 150 $this->box = $box[0];
142 151
@@ -156,13 +165,18 @@
156 165 {
157 166 // Check Publishing Assignments
158 167 if (!$this->pass())
159 168 {
160 - return;
169 + return false;
161 170 }
162 171
163 172 $fbox = $this->box;
164 -
173 +
174 + /**
175 + * Runs before rendering the box.
176 + */
177 + $this->box = apply_filters('firebox/box/before_render', $this->box);
178 +
165 179 $this->prepare();
166 180
167 181 $css = $this->getCustomCSS();
168 182
@@ -168,44 +182,149 @@
168 182
169 183 add_action('wp_enqueue_scripts', function() use ($fbox, $css) {
170 184 // Loads all media files.
171 185 $this->loadBoxMedia($fbox);
172 -
186 +
173 187 // Load CSS
174 188 if ($css)
175 189 {
176 190 wp_add_inline_style('firebox', $css);
177 191 }
192 +
193 +
178 194 });
179 195
196 + // Allow to manipulate the box before rendering
197 + $this->box = apply_filters('firebox/box/edit', $this->box);
198 +
199 + // payload
200 + $payload = [
201 + 'box' => $this->box,
202 + 'params' => $this->params,
203 + ];
204 +
205 + // print campaign HTML
206 + add_action('wp_footer', function() use ($payload) {
207 + echo $this->getFinalCampaignHTML($payload); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
208 + });
209 +
210 + return true;
211 + }
212 +
213 + public function renderEmbed()
214 + {
215 + // Check Publishing Assignments
216 + if (!$this->pass())
217 + {
218 + return false;
219 + }
220 +
180 221 /**
181 222 * Runs before rendering the box.
182 223 */
183 - do_action('firebox/box/before_render', $this->box);
224 + $this->box = apply_filters('firebox/box/before_render', $this->box);
184 225
226 + $this->prepare();
227 +
228 + // Loads all media files.
229 + $this->loadBoxMedia($this->box);
230 +
231 + $css = $this->getCustomCSS();
232 +
233 + wp_register_style('fireboxStyle', false);
234 + wp_enqueue_style('fireboxStyle');
235 +
236 + // Load CSS
237 + if ($css)
238 + {
239 + wp_add_inline_style('fireboxStyle', $css);
240 + }
241 +
242 +
243 +
244 + // Allow to manipulate the box before rendering
245 + $this->box = apply_filters('firebox/box/edit', $this->box);
246 +
185 247 // payload
186 248 $payload = [
187 249 'box' => $this->box,
188 250 'params' => $this->params,
189 251 ];
190 -
252 +
191 253 // return box template
192 - add_action('wp_footer', function() use ($payload) {
193 - echo firebox()->renderer->public->render('box', $payload, true);
194 - });
254 + return $this->getFinalCampaignHTML($payload);
195 255 }
196 256
257 + public function getFinalCampaignHTML($payload)
258 + {
259 + $html = firebox()->renderer->public->render('box', $payload, true);
260 +
261 + /**
262 + * Runs after rendering the box.
263 + */
264 + return apply_filters('firebox/box/after_render', $html, $payload['box']);
265 + }
266 +
197 267 /**
198 268 * Gets the Custom CSS of the popup.
199 269 *
200 270 * @return string
201 271 */
202 - private function getCustomCSS()
272 + public function getCustomCSS()
203 273 {
204 - return $this->box->params->get('customcss', '');
274 + if (!is_object($this->box) || !isset($this->box->params))
275 + {
276 + return '';
277 + }
278 +
279 + $css = $this->box->params->get('customcss', '');
280 +
281 + if (!is_string($css))
282 + {
283 + return '';
284 + }
285 +
286 + /**
287 + * The CSS lands inside an inline <style> element, where a literal "</style>"
288 + * would end the element and let markup (e.g. a <script>) escape into the page.
289 + * Strip tags the same way core's wp_custom_css_cb() does — legitimate CSS
290 + * contains no tags, so this only neuters breakout attempts.
291 + */
292 + return wp_strip_all_tags($css);
205 293 }
206 294
207 295 /**
296 + * Recursively casts an object/array tree to a plain array, without the
297 + * json_decode(wp_json_encode()) round-trip.
298 + *
299 + * @param mixed $data
300 + *
301 + * @return array
302 + */
303 + private static function recursiveToArray($data)
304 + {
305 + if (is_object($data))
306 + {
307 + $data = get_object_vars($data);
308 + }
309 +
310 + if (!is_array($data))
311 + {
312 + return [];
313 + }
314 +
315 + foreach ($data as $key => $value)
316 + {
317 + if (is_object($value) || is_array($value))
318 + {
319 + $data[$key] = self::recursiveToArray($value);
320 + }
321 + }
322 +
323 + return $data;
324 + }
325 +
326 + /**
208 327 * Send a helpful object to JavaScript files
209 328 *
210 329 * @return void
211 330 */
@@ -216,16 +335,34 @@
216 335 return;
217 336 }
218 337 self::$loadedLocalizedScript = true;
219 338
220 - $data = array(
221 - 'ajax_url' => admin_url('admin-ajax.php'),
222 - 'nonce' => wp_create_nonce('fbox_js_nonce'),
223 - 'site_url' => site_url('/'),
224 - 'referrer' => isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : ''
225 - );
339 + /**
340 + * The referrer is deliberately not included here. On a full-page-cached site this
341 + * HTML is written once and served to everyone, so a baked-in referrer would report
342 + * whoever warmed the cache. The runtime reads `document.referrer` instead.
343 + */
344 + $data = [
345 + 'ajax_url' => admin_url('admin-ajax.php'),
346 + 'nonce' => wp_create_nonce('fbox_js_nonce'),
347 + 'site_url' => site_url('/'),
226 348
227 - wp_localize_script('firebox', 'fbox_js_object', $data);
349 + // Shown when a form submission never gets a usable response back (network error,
350 + // non-JSON body, PHP fatal). The form messages themselves come from the block.
351 + 'form_submit_failed' => firebox()->_('FB_FORM_SUBMIT_FAILED'),
352 +
353 + /**
354 + * The scripts write cookies PHP has to read back, so they need the
355 + * same name, path and domain it uses: the prefix keeps the sites of
356 + * a subdirectory network apart, the path is the site's own corner of
357 + * the domain, and the domain is whatever COOKIE_DOMAIN says.
358 + */
359 + 'cookie_prefix' => \FireBox\Core\Helpers\BoxHelper::cookiePrefix(),
360 + 'cookie_path' => \FireBox\Core\Helpers\BoxHelper::cookiePath(),
361 + 'cookie_domain' => \FireBox\Core\Helpers\BoxHelper::cookieDomain()
362 + ];
363 +
364 + wp_add_inline_script('firebox-main', 'const fbox_js_object = ' . wp_json_encode($data), 'before');
228 365 }
229 366
230 367 /**
231 368 * Load Box Media
@@ -235,66 +372,32 @@
235 372 public function loadBoxMedia($box)
236 373 {
237 374 $box = new Registry($box);
238 375
239 - // Add polyfills for Internet Explorer
240 - $browser = $this->factory->getBrowser();
241 - if ($browser && is_array($browser) && array_key_exists('name', $browser) && $browser['name'] === 'ie')
242 - {
243 - wp_enqueue_script(
244 - 'firebox-ie11-polyfill',
245 - 'https://polyfill.io/v3/polyfill.min.js?features=NodeList.prototype.forEach%2CElement.prototype.closest%2CArray.prototype.forEach%2CArray.prototype.find%2CIntersectionObserver%2CIntersectionObserverEntry',
246 - [],
247 - FBOX_VERSION,
248 - true
249 - );
250 - }
376 + $this->loadAnimationsMedia($box);
251 377
252 378 /**
253 - * Velocity
379 + * FireBox JS
254 380 */
255 - if ($this->params->get('loadVelocity', true))
256 - {
257 - wp_enqueue_script(
258 - 'firebox-velocity',
259 - FBOX_MEDIA_PUBLIC_URL . 'js/vendor/velocity.js',
260 - [],
261 - FBOX_VERSION,
262 - true
263 - );
264 - wp_enqueue_script(
265 - 'firebox-velocity-ui',
266 - FBOX_MEDIA_PUBLIC_URL . 'js/vendor/velocity.ui.js',
267 - [],
268 - FBOX_VERSION,
269 - true
270 - );
381 + wp_enqueue_script('firebox-main');
271 382
272 - /**
273 - * Animations
274 - */
275 - if (strpos($box->get('params.data.animationin'), 'firebox') !== false || strpos($box->get('params.data.animationout'), 'firebox') !== false)
276 - {
277 - wp_enqueue_script(
278 - 'firebox-animations',
279 - FBOX_MEDIA_PUBLIC_URL . 'js/animations.js',
280 - [],
281 - FBOX_VERSION,
282 - true
283 - );
284 - }
285 - }
383 +
286 384
287 385 /**
288 - * FireBox JS
386 + * Add Custom Javascript.
387 + *
388 + * Emitted verbatim into every visitor's page, so it is gated on the author
389 + * holding unfiltered_html — the campaign meta is writable by any
390 + * edit_fireboxes user (editor, REST, import) and must not be a way to run
391 + * script that user could not write elsewhere.
289 392 */
290 - wp_enqueue_script(
291 - 'firebox',
292 - FBOX_MEDIA_PUBLIC_URL . 'js/firebox.js',
293 - [],
294 - FBOX_VERSION,
295 - true
296 - );
393 + $custom_code = $box->get('params.data.customcode', '');
394 + if (is_string($custom_code) && !empty($custom_code)
395 + && \FireBox\Core\Helpers\CustomCode::isAllowedForCampaign($box->get('ID')))
396 + {
397 + $custom_code = html_entity_decode(stripslashes($custom_code));
398 + BoxHelper::addInlineScript($custom_code);
399 + }
297 400
298 401 // run above the main JS script to run only once
299 402 self::setJSObject();
300 403
@@ -300,17 +403,14 @@
300 403
301 404 /**
302 405 * FireBox CSS
303 406 */
304 - if ($this->params->get('loadCSS', true))
305 - {
306 - wp_enqueue_style(
307 - 'firebox',
308 - FBOX_MEDIA_PUBLIC_URL . 'css/firebox.css',
309 - [],
310 - FBOX_VERSION
311 - );
312 - }
407 + wp_enqueue_style(
408 + 'firebox',
409 + FBOX_MEDIA_PUBLIC_URL . 'css/firebox.css',
410 + [],
411 + FBOX_VERSION
412 + );
313 413
314 414 /**
315 415 * Page Slide mode JS
316 416 */
@@ -315,87 +415,134 @@
315 415 * Page Slide mode JS
316 416 */
317 417 if ($box->get('params.data.mode') == 'pageslide')
318 418 {
419 + // This script moves the campaign instances into the page-slide wrapper after
420 + // firebox-main creates them. Deferring is safe: deferred scripts execute in
421 + // document order and firebox-main is declared as a dependency, so it always
422 + // runs first. All inline scripts on firebox-main are attached in the "before"
423 + // position, which keeps its defer strategy intact.
319 424 wp_enqueue_script(
320 425 'firebox-pageslide-mode',
321 426 FBOX_MEDIA_PUBLIC_URL . 'js/pageslide_mode.js',
322 - [],
427 + ['firebox-main'],
323 428 FBOX_VERSION,
324 - true
429 + ['in_footer' => true, 'strategy' => 'defer']
325 430 );
326 431 }
327 432
328 433
329 -
330 - $this->loadThemeCSSOverrides();
331 434 }
332 435
333 436 /**
334 - * Some themes require overrides to preserve as much as we can the styling of the popups.
335 - *
437 + * Enqueues only the Animate.css animations the campaign actually uses.
438 + *
439 + * The full library is 71 KB of render-blocking CSS for what is almost always a
440 + * single fade, so it is split at build time (gulp build-animations) into one file
441 + * per animation plus a shared base. Campaigns rendering on the same page union
442 + * their animations naturally, since each gets its own handle.
443 + *
444 + * @param Registry $box
445 + *
336 446 * @return void
337 447 */
338 - private function loadThemeCSSOverrides()
448 + private function loadAnimationsMedia($box)
339 449 {
340 - $active_theme = wp_get_theme();
341 - $theme = $active_theme->template;
342 -
343 450 /**
344 - * This is the listed of the themes that we have created overrides
451 + * Escape hatch for campaigns whose custom code applies arbitrary
452 + * firebox__animate__* classes that we cannot see server-side.
345 453 */
346 - $themes = [
347 - 'twentytwentyone'
348 - ];
454 + if (apply_filters('firebox/box/load_full_animations', false))
455 + {
456 + wp_enqueue_style(
457 + 'firebox-animations',
458 + FBOX_MEDIA_PUBLIC_URL . 'css/vendor/animate.min.css',
459 + [],
460 + FBOX_VERSION
461 + );
349 462
350 - if (!in_array($theme, $themes))
463 + return;
464 + }
465 +
466 + $animations = array_filter([
467 + $box->get('params.data.animationin', ''),
468 + $box->get('params.data.animationout', '')
469 + ], 'is_string');
470 +
471 + $animations = array_unique(array_filter(array_map('trim', $animations)));
472 +
473 + if (!$animations)
351 474 {
352 475 return;
353 476 }
354 477
355 - wp_enqueue_style(
356 - 'firebox-theme-' . $theme . '-override',
357 - FBOX_MEDIA_PUBLIC_URL . 'css/themes/' . $theme . '.css',
358 - [],
359 - FBOX_VERSION
360 - );
478 + $dir = FBOX_PLUGIN_DIR . 'media/public/css/vendor/animations/';
479 + $url = FBOX_MEDIA_PUBLIC_URL . 'css/vendor/animations/';
480 +
481 + $enqueued = false;
482 +
483 + foreach ($animations as $animation)
484 + {
485 + // Animation names are a fixed camelCase slug set; anything else is either a
486 + // stale value or a name we no longer ship, and must not reach the filesystem.
487 + if (!preg_match('#^[A-Za-z0-9]+$#', $animation) || !file_exists($dir . $animation . '.css'))
488 + {
489 + continue;
490 + }
491 +
492 + if (!$enqueued)
493 + {
494 + wp_enqueue_style('firebox-animations-base', $url . 'base.css', [], FBOX_VERSION);
495 + $enqueued = true;
496 + }
497 +
498 + wp_enqueue_style(
499 + 'firebox-animation-' . $animation,
500 + $url . $animation . '.css',
501 + ['firebox-animations-base'],
502 + FBOX_VERSION
503 + );
504 + }
361 505 }
362 506
363 507 /**
364 508 * Prepares the box before rendering
365 - *
509 + *
366 510 * @return void
367 511 */
368 512 public function prepare()
369 513 {
370 - $this->css = new Styling\CSS($this->box);
514 + remove_filter('the_content', 'wptexturize');
371 515
372 - global $post;
373 - $original_post = $post;
374 -
375 - $GLOBALS['post'] = $this->box;
376 - setup_postdata($GLOBALS['post']);
377 -
378 516 $cParam = BoxHelper::getParams();
379 517 $cParam = new Registry($cParam);
380 518
381 519 $this->box->post_content = apply_filters('the_content', $this->box->post_content);
382 520
383 - $position = $this->box->params->get('position', '');
384 - $position = !is_string($position) ? '' : $position;
521 + $mode = $this->box->params->get('mode');
385 522
386 523 /* Classes */
387 524 $css_class = [
388 525 $this->box->ID,
389 - $position
526 + $mode
390 527 ];
528 +
529 + if (in_array($mode, ['popup', 'stickybar', 'sidebar', 'floating', 'slide-in']))
530 + {
531 + $position = $this->box->params->get('position', '');
532 + $position = !is_string($position) ? '' : $position;
533 + if ($position)
534 + {
535 + $css_class[] = $position;
536 + }
537 + }
538 + else if ($mode === 'fullscreen')
539 + {
540 + if ($center_content = $this->box->params->get('center_content', false)) {
541 + $css_class[] = 'center-content';
542 + }
543 + }
391 544
392 - $rtl = $this->box->params->get('rtl', '0');
393 - if ($rtl == '1')
394 - {
395 - $css_class[] = 'rtl';
396 - }
397 -
398 545 self::prefixCSSClasses($css_class);
399 546
400 547 // Class suffix
401 548 $classSuffix = $this->box->params->get('classsuffix', '');
@@ -404,13 +551,12 @@
404 551 $css_class[] = $classSuffix;
405 552
406 553 $this->box->classes = $css_class;
407 554
408 - // Box shadow
409 - $boxshadow = (is_string($this->box->params->get('boxshadow', '1')) || is_int($this->box->params->get('boxshadow', '1'))) ? $this->box->params->get('boxshadow', '1') : '0';
410 -
555 + // Dialog CSS Classes
411 556 $dialog_css_classes = [
412 - $boxshadow != '0' ? 'shd' . $boxshadow : null
557 + // Add Box shadow
558 + $this->box->params->get('boxshadow') ? 'shdelevation' : null
413 559 ];
414 560
415 561 // Align Content
416 562 $aligncontent = is_string($this->box->params->get('aligncontent')) ? explode(' ', $this->box->params->get('aligncontent')) : [];
@@ -419,41 +565,41 @@
419 565 self::prefixCSSClasses($dialog_css_classes);
420 566 $this->box->dialog_classes = $dialog_css_classes;
421 567
422 568 $trigger_point_methods = [
423 - 'pageheight' => 'onScrollDepth',
424 - 'element' => 'onElementVisibility',
425 - 'pageready' => 'onPageReady',
426 569 'pageload' => 'onPageLoad',
427 - 'userleave' => 'onExit',
428 570 'onclick' => 'onClick',
429 571 'elementHover' => 'onHover',
430 - 'ondemand' => 'onDemand'
572 + 'ondemand' => 'onDemand',
573 +
431 574 ];
432 575
433 576 /* Other Settings */
434 - $scroll_depth = $this->box->params->get('scroll_depth', 'percentage');
435 - $scroll_depth = is_string($scroll_depth) ? $scroll_depth : '';
577 + $this->box->params->set('animation_duration', $this->box->params->get('animation_duration', 0.2));
436 578
437 - $animation_duration = $this->box->params->get('duration') ? (float) $this->box->params->get('duration') : 0;
579 + $scroll_amount = $this->box->params->get('scroll_amount', '80%');
438 580
581 + // Parse scroll_amount to extract unit and value
582 + $scroll_amount_data = $this->parseScrollAmount($scroll_amount);
583 +
584 + $delay = in_array($this->box->params->get('triggermethod'), ['floatingbutton', 'onexternallink']) ? 0 : (int) $this->box->params->get('triggerdelay') * 1000;
585 +
586 + $trigger_method = (is_string($this->box->params->get('triggermethod'))) && array_key_exists($this->box->params->get('triggermethod'), $trigger_point_methods) ? $trigger_point_methods[$this->box->params->get('triggermethod')] : $this->box->params->get('triggermethod');
587 +
588 + $trigger_element = is_scalar($this->box->params->get('triggerelement', '')) ? $this->box->params->get('triggerelement', '') : '';
589 +
439 590 // Use Namespaced classes for each trigger point and let them manipulate the settings dynamicaly.
440 591 $this->box->settings = [
441 - 'trigger' => (is_string($this->box->params->get('triggermethod'))) && array_key_exists($this->box->params->get('triggermethod'), $trigger_point_methods) ? $trigger_point_methods[$this->box->params->get('triggermethod')] : $this->box->params->get('triggermethod'),
442 - 'trigger_selector' => $this->box->params->get('triggerelement'),
443 - 'delay' => (int) $this->box->params->get('triggerdelay') * 1000,
444 - 'scroll_depth' => $scroll_depth,
445 - 'scroll_depth_value' => $scroll_depth == 'percentage' ? (int) $this->box->params->get('triggerpercentage') : (int) $this->box->params->get('scroll_pixel'),
446 - 'firing_frequency' => (int) $this->box->params->get('firing_frequency', 1),
447 - 'reverse_scroll_close' => (bool) $this->box->params->get('autohide'),
448 - 'threshold' => (float) $this->box->params->get('threshold', 0) / 100,
449 - 'close_out_viewport' => (bool) $this->box->params->get('close_out_viewport', false),
450 - 'exit_timer' => (int) $this->box->params->get('exittimer') * 1000,
451 - 'idle_time' => (int) $this->box->params->get('idle_time') * 1000,
592 + 'name' => $this->box->post_title,
593 + 'trigger' => $trigger_method,
594 + 'trigger_selector' => $trigger_method === 'onExternalLink' ? '' : rtrim($trigger_element, ','),
595 + 'delay' => $delay,
596 +
597 + 'close_on_esc' => (bool) $this->box->params->get('close_on_esc', false),
452 598 'animation_open' => $this->box->params->get('animationin'),
453 599 'animation_close' => $this->box->params->get('animationout'),
454 - 'animation_duration' => (float) $animation_duration * 1000,
455 - 'prevent_default' => (bool) $this->box->params->get('preventdefault', true),
600 + 'animation_duration' => (float) $this->box->params->get('animation_duration') * 1000,
601 + 'prevent_default' => true,
456 602 'backdrop' => (bool) $this->box->params->get('overlay'),
457 603 'backdrop_color' => $this->box->params->get('overlay_color'),
458 604 'backdrop_click' => (bool) $this->box->params->get('overlayclick'),
459 605 'disable_page_scroll' => (bool) $this->box->params->get('preventpagescroll'),
@@ -458,27 +604,59 @@
458 604 'backdrop_click' => (bool) $this->box->params->get('overlayclick'),
459 605 'disable_page_scroll' => (bool) $this->box->params->get('preventpagescroll'),
460 606 'test_mode' => (bool) $this->box->params->get('testmode'),
461 607 'debug' => (bool) $cParam->get('debug', false),
462 - 'ga_tracking' => (bool) $cParam->get('gaTrack', 0),
463 - 'ga_tracking_id' => $cParam->get('gaID', 0),
464 - 'ga_tracking_label' => $cParam->get('gaCategory'),
465 - 'auto_focus' => (bool) $this->box->params->get('autofocus', false)
608 + 'auto_focus' => (bool) $this->box->params->get('autofocus', false),
609 + 'mode' => $this->box->params->get('mode'),
610 + // Session-dependent display conditions resolved in the browser (see pass())
611 + 'client_rules' => $this->clientRuleGroups
466 612 ];
467 613
614 + $this->css = new Styling\CSS($this->box);
615 +
468 616 // Apply Popup CSS
469 617 $this->box->params->set('customcss', $this->box->params->get('customcss') . $this->css->getCSS());
470 618
471 619 $this->replaceBoxSmartTags();
472 620
473 - $GLOBALS['post'] = $original_post;
474 - wp_reset_postdata();
621 + add_filter('the_content', 'wptexturize');
475 622 }
476 623
477 624 /**
625 + * Parses scroll_amount to extract unit and value
626 + *
627 + * @param mixed $scroll_amount
628 + *
629 + * @return array
630 + */
631 + private function parseScrollAmount($scroll_amount)
632 + {
633 + if (is_array($scroll_amount))
634 + {
635 + return [
636 + 'unit' => $scroll_amount['unit'] ?? '%',
637 + 'value' => $scroll_amount['value'] ?? 80
638 + ];
639 + }
640 +
641 + if (is_string($scroll_amount) && preg_match('/^(\d+)(px|%)$/', $scroll_amount, $matches))
642 + {
643 + return [
644 + 'unit' => $matches[2],
645 + 'value' => $matches[1]
646 + ];
647 + }
648 +
649 + return [
650 + 'unit' => '%',
651 + 'value' => 80
652 + ];
653 + }
654 +
655 + /**
478 656 * Replaces all box smart tags
479 - *
480 - * @return object
657 + *
658 + * @return void
481 659 */
482 660 public function replaceBoxSmartTags()
483 661 {
484 662 $tags = new \FPFramework\Base\SmartTags\SmartTags();
@@ -495,52 +673,239 @@
495 673 * @return boolean
496 674 */
497 675 public function pass()
498 676 {
677 + $this->clientRuleGroups = [];
678 +
499 679 if (!$this->box || !is_object($this->box))
500 680 {
501 681 return false;
502 682 }
503 683
504 - // Prepare boxes that mirror other boxes assignments
505 - if ($this->box->params->get('mirror', false) && $mirror_box_id = $this->box->params->get('mirror_box'))
684 + // Check first local assignments
685 + if (!$this->passLocalAssignments())
506 686 {
507 - $this->box->params->merge($this->getAssignmentsForMirroring($mirror_box_id));
687 + return false;
508 688 }
509 689
510 - // Check first local assignments
511 - if (!$this->passLocalAssignments())
690 + $displayConditionsType = $this->box->params->get('display_conditions_type', '');
691 +
692 + // If empty, display popup sitewide
693 + if (empty($displayConditionsType) || $displayConditionsType === 'all')
512 694 {
513 - return false;
695 + return true;
514 696 }
697 +
698 + // Mirror Display Conditions of another popup.
699 + if ($displayConditionsType == 'mirror' && $mirror_box_id = $this->box->params->get('mirror_box'))
700 + {
701 + $this->box->params->merge(self::getAssignmentsForMirroring($mirror_box_id));
702 + }
515 703
516 - // If testmode is enabled disable the User Groups assignment
704 + // Get a recursive array of all rules
705 + $rules = $this->box->params->get('rules', []);
706 + $rules = is_string($rules) ? json_decode($rules, true) : self::recursiveToArray($rules);
707 +
708 + // Normalize to an array; an empty or invalid-JSON "rules" string decodes to null.
709 + $rules = is_array($rules) ? $rules : [];
710 +
711 + // The "PHP" display condition runs campaign-authored PHP server-side on every
712 + // visitor request (Conditions\PHP -> Executer). Evaluate it only when the campaign
713 + // author holds firebox_execute_php; otherwise treat the PHP condition as unmet
714 + // rather than running it — an "all" group that requires it fails, an "any" group
715 + // falls back to its other conditions. If every group depended on PHP, there is
716 + // nothing left to match and the campaign does not show. Runs after the mirror merge
717 + // above, so it also covers PHP rules pulled in from a mirrored box.
718 + if (self::hasPHPRule($rules) && !\FireBox\Core\Helpers\PHPExecution::isAllowedForCampaign($this->box->ID))
719 + {
720 + $rules = self::withoutPHPRules($rules);
721 +
722 + if (empty($rules))
723 + {
724 + return false;
725 + }
726 + }
727 +
728 + // If testmode is enabled disable the User Groups condition
517 729 if ($this->box->params->get('testmode'))
518 730 {
519 - $this->box->params->set('assignments.assign_grouplevel.selection', '0');
520 - }
521 -
522 - $globalAssignments = $this->passGlobalAssignments();
523 -
524 - // Check framework based assignments
525 - return $globalAssignments;
731 + foreach ($rules as $key => &$group)
732 + {
733 + if (!isset($group['rules']) || !is_array($group['rules']))
734 + {
735 + continue;
736 + }
737 +
738 + foreach ($group['rules'] as $_key => &$rule)
739 + {
740 + if (!isset($rule['name']) || empty($rule['name']))
741 + {
742 + continue;
743 + }
744 +
745 + if ($rule['name'] === 'WP\UserGroup')
746 + {
747 + unset($group['rules'][$_key]);
748 + }
749 + }
750 + }
751 + unset($group);
752 + }
753 +
754 + // Check framework based conditions. Session-dependent (client-side) rules are
755 + // deferred: the box renders hidden and the frontend runtime resolves them.
756 + $result = \FPFramework\Base\Conditions\ConditionBuilder::passWithClientRules($rules, $this->factory);
757 +
758 + $this->clientRuleGroups = $result['client_groups'];
759 +
760 + return $result['pass'];
526 761 }
527 762
528 - /**
529 - * Passes framework based global assignments
530 - *
531 - * @return boolean
532 - */
533 - private function passGlobalAssignments()
763 + /**
764 + * Display condition groups deferred to the frontend runtime by pass().
765 + *
766 + * @return array
767 + */
768 + public function getClientRuleGroups()
534 769 {
535 - $assignments = new \FPFramework\Base\Assignments($this->factory);
536 - $pass = $assignments->passAll($this->box, $this->box->params->get('assignmentMatchingMethod', 'and'));
537 - return $pass;
770 + return $this->clientRuleGroups;
538 771 }
539 772
540 773 /**
541 - * Check if a box passes local assignments
774 + * Whether a single rule is a "PHP" condition that carries code to run.
542 775 *
776 + * The name is matched case-insensitively: PHP class names resolve regardless
777 + * of case, so ConditionsHelper::getCondition() would load the PHP condition for
778 + * 'php', 'Php', etc. An empty value is ignored: there is nothing to run.
779 + *
780 + * @param array $rule A single display-condition rule.
781 + *
782 + * @return bool
783 + */
784 + private static function isPHPRule($rule)
785 + {
786 + if (!is_array($rule) || !isset($rule['name']) || !is_string($rule['name']))
787 + {
788 + return false;
789 + }
790 +
791 + if (strtolower(trim($rule['name'])) !== 'php')
792 + {
793 + return false;
794 + }
795 +
796 + // Only a non-empty string value can be executed as PHP.
797 + return isset($rule['value']) && is_string($rule['value']) && trim($rule['value']) !== '';
798 + }
799 +
800 + /**
801 + * Whether any rule in the given groups is a "PHP" condition that carries code.
802 + *
803 + * @param array $rules Recursive display-condition groups.
804 + *
805 + * @return bool
806 + */
807 + private static function hasPHPRule($rules)
808 + {
809 + if (!is_array($rules))
810 + {
811 + return false;
812 + }
813 +
814 + foreach ($rules as $group)
815 + {
816 + if (!isset($group['rules']) || !is_array($group['rules']))
817 + {
818 + continue;
819 + }
820 +
821 + foreach ($group['rules'] as $rule)
822 + {
823 + if (self::isPHPRule($rule))
824 + {
825 + return true;
826 + }
827 + }
828 + }
829 +
830 + return false;
831 + }
832 +
833 + /**
834 + * Returns the rule groups with runnable "PHP" conditions treated as unmet, for a
835 + * campaign whose author may not run PHP. A group's matching_method decides how:
836 + *
837 + * - "all" (every rule must pass): the PHP condition can never be satisfied, so the
838 + * whole group fails and is dropped.
839 + * - "any" (one rule is enough): the PHP condition is removed so it cannot be the
840 + * reason the group passes, but the group's other conditions still evaluate. A group
841 + * left with no other conditions is dropped.
842 + *
843 + * Groups without a PHP condition are returned untouched.
844 + *
845 + * @param array $rules Recursive display-condition groups.
846 + *
847 + * @return array
848 + */
849 + private static function withoutPHPRules($rules)
850 + {
851 + if (!is_array($rules))
852 + {
853 + return [];
854 + }
855 +
856 + $filtered = [];
857 +
858 + foreach ($rules as $group)
859 + {
860 + if (!isset($group['rules']) || !is_array($group['rules']))
861 + {
862 + $filtered[] = $group;
863 + continue;
864 + }
865 +
866 + $kept = [];
867 + $hasPHP = false;
868 +
869 + foreach ($group['rules'] as $rule)
870 + {
871 + if (self::isPHPRule($rule))
872 + {
873 + $hasPHP = true;
874 + continue;
875 + }
876 +
877 + $kept[] = $rule;
878 + }
879 +
880 + if (!$hasPHP)
881 + {
882 + $filtered[] = $group;
883 + continue;
884 + }
885 +
886 + $method = isset($group['matching_method']) ? $group['matching_method'] : 'all';
887 +
888 + // "all": a required PHP condition is unmet, so the group can never pass.
889 + if ($method === 'all')
890 + {
891 + continue;
892 + }
893 +
894 + // "any": keep the remaining alternatives, unless PHP was the only one.
895 + if (!empty($kept))
896 + {
897 + $group['rules'] = $kept;
898 + $filtered[] = $group;
899 + }
900 + }
901 +
902 + return $filtered;
903 + }
904 +
905 + /**
906 + * Check if a box passes local conditions
907 + *
543 908 * @return boolean
544 909 */
545 910 private function passLocalAssignments()
546 911 {
@@ -555,48 +920,47 @@
555 920 *
556 921 * @return object
557 922 */
558 923 private function getAssignmentsForMirroring($box_id)
559 - {
560 - $payload = [
561 - 'where' => [
562 - 'ID' => ' = ' . intval($box_id),
563 - 'post_status' => " = 'publish'",
564 - 'post_type' => " = 'firebox'"
565 - ]
566 - ];
567 -
568 - // Load box
569 - if (!$box = firebox()->tables->box->getResults($payload))
924 + {
925 + // Several campaigns commonly mirror the same source, and every campaign is
926 + // evaluated on every page view, so resolve each source at most once per request.
927 + static $cache = [];
928 +
929 + $box_id = intval($box_id);
930 +
931 + if (array_key_exists($box_id, $cache))
570 932 {
571 - return;
933 + return $cache[$box_id];
572 934 }
573 -
574 - $box = $box[0];
575 -
576 - // get meta options for box
577 - $meta = get_post_meta($box_id, 'fpframework_meta_settings', true);
578 - $box->params = $meta;
579 935
580 - // To prevent user frustration, we ignore the following assignments because they are not displayed in the Publishing Assignments.
581 - $params_to_ignore = [
582 - 'assign_impressions'
583 - ];
936 + $cache[$box_id] = null;
584 937
585 - $assignments = [];
938 + // The published campaigns are already loaded (and their meta primed) for this
939 + // request, so read the mirrored campaign from that list instead of querying again.
940 + $boxes = BoxHelper::getAllBoxes();
941 + $mirrored = null;
586 942
587 - // Gather params to merge
588 - foreach ($box->params as $param_key => $param_value)
589 - {
590 - if (strpos($param_key, 'assign') === false || in_array($param_key, $params_to_ignore))
591 - {
592 - continue;
593 - }
943 + foreach ($boxes->posts as $box)
944 + {
945 + if ((int) $box->ID === $box_id)
946 + {
947 + $mirrored = $box;
948 + break;
949 + }
950 + }
594 951
595 - $assignments[$param_key] = $param_value;
952 + if (!$mirrored)
953 + {
954 + return $cache[$box_id];
596 955 }
597 -
598 - return new Registry($assignments);
956 +
957 + // get meta options for box
958 + $params = new Registry(BoxHelper::getMeta($mirrored->ID));
959 +
960 + $cache[$box_id] = new Registry(['rules' => $params->get('rules')]);
961 +
962 + return $cache[$box_id];
599 963 }
600 964
601 965 /**
602 966 * Prefixes the CSS classes
@@ -633,10 +997,15 @@
633 997 public function logOpenEvent($box_id, $page = null, $referrer = null)
634 998 {
635 999 $box = $this->get($box_id);
636 1000
1001 + if (!is_object($box) || !isset($box->params))
1002 + {
1003 + return;
1004 + }
1005 +
637 1006 // Do not track if statistics option is disabled
638 - $track_open_event = (bool) (is_null($box->params->get('stats', null)) ? BoxHelper::getParams()->get('stats', 1) : $box->params->get('stats'));
1007 + $track_open_event = (bool) (is_null($box->params->get('stats', null)) ? true : $box->params->get('stats'));
639 1008 if (!$track_open_event)
640 1009 {
641 1010 return;
642 1011 }
@@ -655,10 +1024,15 @@
655 1024 public function logCloseEvent($box_id, $box_log_id)
656 1025 {
657 1026 $box = $this->get($box_id);
658 1027
1028 + if (!is_object($box) || !isset($box->params))
1029 + {
1030 + return null;
1031 + }
1032 +
659 1033 // Do not track if statistics option is disabled
660 - $track_open_event = (bool) (is_null($box->params->get('stats', null)) ? BoxHelper::getParams()->get('stats', 1) : $box->params->get('stats'));
1034 + $track_open_event = (bool) (is_null($box->params->get('stats', null)) ? true : $box->params->get('stats'));
661 1035 if (!$track_open_event)
662 1036 {
663 1037 return null;
664 1038 }
@@ -674,11 +1048,11 @@
674 1048 * @return array
675 1049 */
676 1050 public function getTotalImpressions($payload)
677 1051 {
678 - $impressions = firebox()->tables->boxlog->getResults($payload);
679 -
680 - return count($impressions);
1052 + // Cached: the same campaign/period is counted once per request even when
1053 + // several conditions ask for it.
1054 + return firebox()->tables->boxlog->getResults($payload, true, true);
681 1055 }
682 1056
683 1057 /**
684 1058 * Returns the cookie instance.
@@ -691,9 +1065,9 @@
691 1065 {
692 1066 return;
693 1067 }
694 1068
695 - return new Cookie($this->box->id);
1069 + return new Cookie($this->box);
696 1070 }
697 1071
698 1072 /**
699 1073 * Returns the box.
@@ -717,5 +1091,32 @@
717 1091 $this->box = $box;
718 1092
719 1093 return $this;
720 1094 }
721 -}
1095 +
1096 + public function getParams()
1097 + {
1098 + return $this->params;
1099 + }
1100 +
1101 + public function setParams($params)
1102 + {
1103 + $this->params = $params;
1104 +
1105 + return $this;
1106 + }
1107 +
1108 + public function getCampaignParams()
1109 + {
1110 + return isset($this->box->params) ? $this->box->params : null;
1111 + }
1112 +
1113 + public function setCampaignParams($params)
1114 + {
1115 + if (isset($this->box->params))
1116 + {
1117 + $this->box->params = $params;
1118 + }
1119 +
1120 + return $this;
1121 + }
1122 +}