PluginProbe
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment / trunk
FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment vtrunk
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 1.1.1 All 122 releases
firebox / Inc / Core / Helpers / BoxHelper.php

BoxHelper.php in FireBox – WooCommerce Popup Builder, Exit Intent Popup, Email Optin & Cart Abandonment trunk, at Inc/Core/Helpers/BoxHelper.php

832 lines 20.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * @package FireBox
4 * @version 3.1.13 Free
5 *
6 * @author FirePlugins <info@fireplugins.com>
7 * @link https://www.fireplugins.com
8 * @copyright Copyright © 2026 FirePlugins All Rights Reserved
9 * @license GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
10 */
11
12 namespace FireBox\Core\Helpers;
13
14 if (!defined('ABSPATH'))
15 {
16 exit; // Exit if accessed directly.
17 }
18
19 use FPFramework\Libs\Registry;
20
21 class BoxHelper
22 {
23 /**
24 * Option holding the cached campaign lists, keyed by status/limit.
25 *
26 * An autoloaded option rather than a transient on purpose: a transient with an expiry is
27 * stored non-autoloaded, so reading it costs 2 queries per request on stock WordPress —
28 * exactly what the campaign query it replaces costs. Autoloaded means WordPress has
29 * already loaded it by the time Boxes::render() runs. There is no TTL either: the option
30 * is deleted on every campaign write, so the cache is always current.
31 *
32 * @var string
33 */
34 const BOXES_CACHE_OPTION = 'firebox_boxes_cache';
35
36 /**
37 * Returns the prefix every FireBox cookie name is built from.
38 *
39 * Cookies are set with path=/, so on a subdirectory network every site of
40 * example.com/store, example.com/blog … shares one cookie namespace. Post
41 * IDs restart per site, so campaign 42 exists on both and one campaign's
42 * "already seen" cookie would suppress an unrelated campaign on another
43 * site. The blog ID in the name is what keeps them apart.
44 *
45 * Single sites — and the main site of a network, which owns the unprefixed
46 * namespace already — keep the original prefix, so upgrading does not show
47 * a campaign again to visitors who had dismissed it.
48 *
49 * @return string
50 */
51 public static function cookiePrefix()
52 {
53 if (!is_multisite() || is_main_site())
54 {
55 return 'firebox_';
56 }
57
58 return 'firebox_' . get_current_blog_id() . '_';
59 }
60
61 /**
62 * The path FireBox cookies are written to.
63 *
64 * WordPress derives COOKIEPATH from the site's home URL, so it already
65 * points at exactly the part of the domain this site occupies — the whole
66 * domain for a normal install, /store/ for that site of a subdirectory
67 * network. It is what WooCommerce and AffiliateWP scope their cookies with,
68 * and it keeps a network's sites from writing over each other.
69 *
70 * @return string
71 */
72 public static function cookiePath()
73 {
74 return \FPFramework\Helpers\CookieScope::path();
75 }
76
77 /**
78 * The domain FireBox cookies are written to.
79 *
80 * Empty unless the site defines COOKIE_DOMAIN, which is how a site tells
81 * WordPress its cookies belong to something other than the current host —
82 * shared between www and non-www, or across the subdomains of a network.
83 * Ignoring it means writing a cookie the site cannot read back.
84 *
85 * @return string
86 */
87 public static function cookieDomain()
88 {
89 return \FPFramework\Helpers\CookieScope::domain();
90 }
91
92 /**
93 * Paths a cookie of ours may have been written to before.
94 *
95 * Cookies are keyed by name *and* path, so a cookie written at '/' is a
96 * different cookie from the same name at '/store/'. FireBox wrote every
97 * cookie to '/' until it started honouring COOKIEPATH, and expiring only
98 * the new path would leave the old one behind — still sent, still
99 * suppressing the campaign it belongs to.
100 *
101 * @return array
102 */
103 public static function cookiePathsToClear()
104 {
105 return \FPFramework\Helpers\CookieScope::pathsToClear();
106 }
107
108 /**
109 * Get box meta
110 *
111 * @param int $id
112 *
113 * @return array
114 */
115 public static function getMeta($id)
116 {
117 $meta = get_post_meta($id, 'firebox_meta', false);
118 $meta = isset($meta[0]) && is_array($meta[0]) ? $meta[0] : [];
119
120 return $meta;
121 }
122
123 /**
124 * Attaches inline JavaScript that runs once the FireBox runtime has created all
125 * campaign instances.
126 *
127 * The script is attached in the "before" position on purpose: WordPress downgrades a
128 * defer-strategy script to a blocking one as soon as it carries an "after" inline
129 * script, so a single campaign with custom code would make the whole runtime
130 * parse-blocking. Printing before the runtime keeps defer intact.
131 *
132 * Waiting for DOMContentLoaded is what makes that safe: firebox-main is deferred, so
133 * the browser executes it once parsing is done — at which point FireBox.onReady()
134 * runs immediately (readyState is already "interactive") and builds every campaign
135 * instance. DOMContentLoaded fires after all deferred scripts, so by the time this
136 * code runs the instances exist.
137 *
138 * @param string $js
139 *
140 * @return void
141 */
142 public static function addInlineScript($js)
143 {
144 if (!is_string($js) || trim($js) === '')
145 {
146 return;
147 }
148
149 $wrapped = 'document.addEventListener("DOMContentLoaded", function(){' . $js . '});';
150
151 wp_add_inline_script('firebox-main', $wrapped, 'before');
152 }
153
154 /**
155 * Whether any of the given campaigns uses session-dependent behavior:
156 * the Pageviews/Time on Site display conditions, or an impressions
157 * limit scoped to the visitor's session (fpfsid).
158 *
159 * These are stamped and evaluated against JS-set session cookies, so
160 * when in use the frontend runtime must be enqueued on every page.
161 * Skipped entirely when no campaign needs them, so other sites load no
162 * extra JS or cookies.
163 *
164 * @param \WP_Query $boxes
165 *
166 * @return bool
167 */
168 public static function sessionConditionsInUse($boxes)
169 {
170 // The answer only changes when a campaign is written to, so keep it in an
171 // autoloaded option instead of re-scanning every campaign's meta per page view.
172 $cached = get_option('firebox_session_conditions_in_use');
173
174 if ($cached === 'yes' || $cached === 'no')
175 {
176 return $cached === 'yes';
177 }
178
179 $inUse = self::computeSessionConditionsInUse($boxes);
180
181 update_option('firebox_session_conditions_in_use', $inUse ? 'yes' : 'no', true);
182
183 return $inUse;
184 }
185
186 /**
187 * Drops the cached session-conditions flag so it is recomputed on the next
188 * frontend request. Hooked on every write path that can change the answer.
189 *
190 * @return void
191 */
192 public static function invalidateSessionConditionsFlag()
193 {
194 delete_option('firebox_session_conditions_in_use');
195 }
196
197 /**
198 * Drops every campaign-derived cache: the session-conditions flag and the
199 * cached box list. Hooked on each write path that can change either answer.
200 *
201 * @return void
202 */
203 public static function invalidateCaches()
204 {
205 self::invalidateSessionConditionsFlag();
206 self::invalidateBoxesCache();
207 }
208
209 /**
210 * Registers the invalidation hooks for the campaign-derived caches.
211 *
212 * @return void
213 */
214 public static function registerCacheInvalidation()
215 {
216 add_action('save_post_firebox', [self::class, 'invalidateCaches']);
217
218 // These fire for every post type, so only react to campaigns — otherwise
219 // ordinary content edits would keep busting the campaign caches.
220 $onPostChange = function($post_id, $post = null)
221 {
222 if (self::isCampaign($post_id, $post))
223 {
224 self::invalidateCaches();
225 }
226 };
227
228 add_action('deleted_post', $onPostChange, 10, 2);
229 add_action('trashed_post', $onPostChange, 10, 1);
230 add_action('untrashed_post', $onPostChange, 10, 1);
231
232 // firebox_meta is also written outside the save_post flow (Gutenberg REST meta
233 // updates, the campaign importer, duplication).
234 $onMetaWrite = function($meta_id, $post_id, $meta_key)
235 {
236 if ($meta_key === 'firebox_meta')
237 {
238 self::invalidateCaches();
239 }
240 };
241
242 add_action('added_postmeta', $onMetaWrite, 10, 3);
243 add_action('updated_postmeta', $onMetaWrite, 10, 3);
244 }
245
246 /**
247 * Whether the given post is a campaign. `deleted_post` passes the post object
248 * along because the row is already gone by the time it fires.
249 *
250 * @param int $post_id
251 * @param \WP_Post $post
252 *
253 * @return bool
254 */
255 private static function isCampaign($post_id, $post = null)
256 {
257 if ($post instanceof \WP_Post)
258 {
259 return $post->post_type === 'firebox';
260 }
261
262 return get_post_type($post_id) === 'firebox';
263 }
264
265 /**
266 * Scans the given campaigns for session-dependent behavior.
267 *
268 * @param \WP_Query $boxes
269 *
270 * @return bool
271 */
272 private static function computeSessionConditionsInUse($boxes)
273 {
274 if (!$boxes || empty($boxes->posts))
275 {
276 return false;
277 }
278
279 foreach ($boxes->posts as $box)
280 {
281 $meta = self::getMeta($box->ID);
282
283 if (!$meta)
284 {
285 continue;
286 }
287
288 // Impressions limit scoped to the browser session
289 $impressionsPeriod = isset($meta['assign_impressions_param_type']) ? $meta['assign_impressions_param_type'] : '';
290
291 if ($impressionsPeriod === 'custom')
292 {
293 $impressionsPeriod = isset($meta['assign_impressions_param_custom_period']) ? $meta['assign_impressions_param_custom_period'] : '';
294 }
295
296 if ($impressionsPeriod === 'session')
297 {
298 return true;
299 }
300
301 // Session-dependent display conditions
302 $metaJson = wp_json_encode($meta);
303
304 if (strpos($metaJson, 'Pageviews') !== false || strpos($metaJson, 'TimeOnSite') !== false)
305 {
306 return true;
307 }
308 }
309
310 return false;
311 }
312
313 /**
314 * Gets all Boxes.
315 *
316 * Only the campaign IDs are cached, in an autoloaded option so the list also
317 * survives across requests on hosts without a persistent object cache — where
318 * wp_cache is per-request and the query therefore ran on every page view. The
319 * autoloaded options are already in memory by the time this runs, so a cache hit
320 * costs no query of its own.
321 *
322 * There is no TTL: the cache is dropped on every campaign write, which also means
323 * editors see their changes immediately.
324 *
325 * @param array $status
326 * @param int $limit
327 *
328 * @return \WP_Query
329 */
330 public static function getAllBoxes($status = ['publish'], $limit = -1)
331 {
332 $key = self::getBoxesCacheKey($status, $limit);
333 $cache = get_option(self::BOXES_CACHE_OPTION, []);
334 $cache = is_array($cache) ? $cache : [];
335
336 if (isset($cache[$key]) && is_array($cache[$key]))
337 {
338 return self::hydrateBoxesQuery($cache[$key]);
339 }
340
341 $args = [
342 'post_status' => $status,
343 'post_type' => 'firebox',
344 'posts_per_page' => $limit,
345 'no_found_rows' => true,
346 'update_post_term_cache' => false,
347 'cache_results' => true
348 ];
349
350 // Get the query.
351 $query = new \WP_Query($args);
352
353 wp_reset_postdata();
354
355 $cache[$key] = wp_list_pluck($query->posts, 'ID');
356
357 update_option(self::BOXES_CACHE_OPTION, $cache, true);
358
359 return $query;
360 }
361
362 /**
363 * Rebuilds a WP_Query from a cached list of campaign IDs.
364 *
365 * @param array $ids
366 *
367 * @return \WP_Query
368 */
369 private static function hydrateBoxesQuery($ids)
370 {
371 $query = new \WP_Query();
372
373 // A hand-built query has no query vars of its own, and the loop reads several
374 // while setting up each post. "fields => all" states that $posts already holds
375 // full post objects, which is what it takes for the loop to use them as-is.
376 $query->query_vars = $query->fill_query_vars(['fields' => 'all']);
377
378 $posts = [];
379
380 if ($ids)
381 {
382 // Two queries, both by primary key, priming the same post and postmeta
383 // caches the original WP_Query did.
384 _prime_post_caches($ids, false, true);
385
386 // array_filter drops campaigns deleted since the cache was written.
387 $posts = array_values(array_filter(array_map('get_post', $ids)));
388 }
389
390 $query->posts = $posts;
391 $query->post_count = count($posts);
392 $query->found_posts = $query->post_count;
393
394 return $query;
395 }
396
397 /**
398 * The cache entry a box list is stored under.
399 *
400 * @param array $status
401 * @param int $limit
402 *
403 * @return string
404 */
405 private static function getBoxesCacheKey($status, $limit)
406 {
407 return md5(implode(',', (array) $status) . '_' . (int) $limit);
408 }
409
410 /**
411 * Drops every cached box list.
412 *
413 * @return void
414 */
415 public static function invalidateBoxesCache()
416 {
417 delete_option(self::BOXES_CACHE_OPTION);
418 }
419
420 /**
421 * Retrieves all boxes in a key => value array of ID => title
422 *
423 * @return array
424 */
425 public static function getAllBoxesParsedByKeyValue()
426 {
427 if (!$boxes = self::getAllBoxes())
428 {
429 return [];
430 }
431
432 return self::produceKeyValueBoxes($boxes->posts);
433 }
434
435 /**
436 * Produce a key,value pair of boxes containg their ID,title
437 *
438 * @return array
439 */
440 public static function produceKeyValueBoxes($boxes)
441 {
442 if (!$boxes)
443 {
444 return [];
445 }
446
447 $data = [];
448
449 foreach ($boxes as $key => $box)
450 {
451 $data[$box->ID] = $box->post_title;
452 }
453
454 return $data;
455 }
456
457 /**
458 * Gets all published Boxes except the given id.
459 * The array structure is [ID, title] to properly appear in a Dropdown field.
460 *
461 * @param integer $id
462 *
463 * @return array
464 */
465 public static function getAllMirrorBoxesExceptID($id)
466 {
467 if (!$id)
468 {
469 return [];
470 }
471
472 $boxes = firebox()->tables->box->getResults([
473 'where' => [
474 'ID' => ' NOT IN (' . absint($id) . ')',
475 'post_status' => " = 'publish'",
476 'post_type' => " = 'firebox'"
477 ]
478 ]);
479
480 $boxes_parsed = [];
481
482 foreach ($boxes as $key => $p)
483 {
484 $boxes_parsed[$p->ID] = $p->post_title . ' (' . $p->ID . ')';
485 }
486
487 return $boxes_parsed;
488 }
489
490 /**
491 * Get box data
492 *
493 * @param int $box
494 *
495 * @return array
496 */
497 public static function getBoxData($box)
498 {
499 if (!$box)
500 {
501 return false;
502 }
503
504 $box = (int) $box;
505
506 $box = firebox()->tables->box->getResults([
507 'where' => [
508 'ID' => " = '" . absint($box) . "'"
509 ]
510 ]);
511
512 return isset($box[0]) ? $box[0] : [];
513 }
514
515 /**
516 * Checks whether the box exist
517 *
518 * @param int $box
519 *
520 * @return boolean
521 */
522 public static function boxExist($box)
523 {
524 if (!$box)
525 {
526 return false;
527 }
528
529 $box = (int) $box;
530
531 $box = firebox()->tables->box->getResults([
532 'where' => [
533 'ID' => " = '" . absint($box) . "'"
534 ]
535 ]);
536
537 if (!$box)
538 {
539 return false;
540 }
541
542 return true;
543 }
544
545 /**
546 * Gets boxes in a [id, title] pair from a list of Box IDs
547 *
548 * @param array $items
549 *
550 * @return array
551 */
552 public static function getSelectedSearchItems($items)
553 {
554 $boxes = firebox()->tables->box->getResults([
555 'where' => [
556 'ID' => ' IN(' . implode(',', array_map('intval', $items)) . ')',
557 'post_status' => " = 'publish'",
558 'post_type' => " = 'firebox'"
559 ]
560 ]);
561
562 $boxes_parsed = [];
563
564 foreach ($boxes as $key => $p)
565 {
566 $boxes_parsed[] = [
567 'id' => $p->ID,
568 'title' => $p->post_title
569 ];
570 }
571
572 return $boxes_parsed;
573 }
574
575 /**
576 * Gets Settings Data
577 *
578 * @return array
579 */
580 public static function getParams()
581 {
582 // cache key
583 $cache_key = md5('fboxSettings');
584
585 // check cache
586 if ($params = wp_cache_get($cache_key, 'firebox'))
587 {
588 return $params;
589 }
590
591 // get params
592 $params = get_option('firebox_settings');
593
594 // set cache
595 wp_cache_set($cache_key, $params, 'firebox', 5 * MINUTE_IN_SECONDS);
596
597 return $params;
598 }
599
600 /**
601 * Duplicates a box
602 *
603 * @param integer $box_id
604 *
605 * @return bool
606 */
607 public static function duplicateBox($box_id)
608 {
609 // get box
610 $box = firebox()->tables->box->getResults([
611 'where' => [
612 'ID' => " = '" . absint($box_id) . "'",
613 'post_status' => " = '" . sanitize_key(get_post_status($box_id)) . "'",
614 'post_type' => " = 'firebox'"
615 ],
616 'limit' => 1
617 ]);
618
619 if (empty($box))
620 {
621 return false;
622 }
623
624 // reset box ID and make it a draft
625 $box = $box[0];
626 $box->ID = '';
627 $box->post_title = 'Copy of ' . $box->post_title;
628 $box->post_status = 'draft';
629
630 /**
631 * The copy belongs to whoever duplicates, never the source author — the same
632 * rule the importer applies. The PHP and custom-JavaScript gates are a
633 * function of the post author, so keeping the source author would let a copy
634 * of a higher-privileged user's campaign keep running code its new owner is
635 * not allowed to write.
636 */
637 $box->post_author = get_current_user_id();
638
639 $factory = new \FPFramework\Base\Factory();
640
641 $tz = wp_timezone();
642 $date_without_tz = $factory->getDate();
643 $date_with_tz = $factory->getDate()->setTimezone($tz);
644
645 $box->post_date = $date_with_tz->format('Y-m-d H:i:s');
646 $box->post_date_gmt = $date_without_tz->format('Y-m-d H:i:s');
647
648 Form\Form::ensureUniqueFormIDs($box->post_content);
649
650 // get meta options
651 $meta = self::getMeta($box_id);
652
653 // insert new box
654 $new_box_id = firebox()->tables->box->insert($box);
655
656 // add meta options for new box
657 // TODO: In the future, use "firebox_meta". This is a temporary fix for backwards compatibility.
658 $checkMeta = (array) $meta;
659 $meta_key = isset($checkMeta['width']) ? 'firebox_meta' : 'fpframework_meta_settings';
660 update_post_meta($new_box_id, $meta_key, wp_slash($meta));
661
662 return $new_box_id;
663 }
664
665 /**
666 * Reset Box Stats
667 *
668 * @param array $box_ids
669 *
670 * @return void
671 */
672 public static function resetBoxStats($box_ids)
673 {
674 $logs_table = firebox()->tables->boxlog->getFullTableName();
675 $logs_details_table = firebox()->tables->boxlogdetails->getFullTableName();
676
677 // delete box logs details
678 firebox()->tables->boxlogdetails->executeRaw("DELETE FROM `$logs_details_table` WHERE log_id IN (SELECT id FROM `$logs_table` WHERE box IN (" . implode(",", $box_ids) . "))");
679
680 // delete box logs
681 firebox()->tables->boxlog->deleteRaw('WHERE box IN (' . implode(',', $box_ids) . ')');
682 }
683
684 /**
685 * Builds the export payload (boxes + meta) and a suggested filename for the given box ids,
686 * without emitting any headers or output. Shared by exportBoxes() (legacy file-download
687 * action) and the Campaigns REST export endpoint.
688 *
689 * @param array $box_ids
690 *
691 * @return array|null ['filename' => string, 'exported' => array] or null if no boxes matched
692 */
693 public static function getExportPayload($box_ids)
694 {
695 // get boxes
696 $boxes = firebox()->tables->box->getResults([
697 'where' => [
698 'ID' => ' IN (' . implode(',', array_map('intval', $box_ids)) . ')',
699 'post_type' => " = 'firebox'"
700 ]
701 ]);
702
703 $boxes = (array) $boxes;
704
705 if (!count($boxes))
706 {
707 return null;
708 }
709
710 $exported = [];
711
712 $filename = firebox()->_('FB_PLUGIN_NAME') . ' Items';
713
714 // name for 1 box
715 if (count($boxes) == 1)
716 {
717 $name = mb_strtolower(html_entity_decode($boxes['0']->post_title));
718 // preg_replace() returns null when PCRE fails (backtrack limit, invalid UTF-8),
719 // and passing null on is deprecated since PHP 8.1. Keep the previous value.
720 $name = preg_replace('#[^a-z0-9_-]#', '_', $name) ?? $name;
721 $name = trim(preg_replace('#__+#', '_', $name) ?? $name, '_-');
722
723 $filename = firebox()->_('FB_PLUGIN_NAME') . ' Item (' . $name . ')';
724 }
725
726 foreach ($boxes as $box)
727 {
728 $meta = self::getMeta($box->ID);
729
730 $exported[] = [
731 'box' => $box,
732 'meta' => $meta
733 ];
734 }
735
736 return [
737 'filename' => $filename,
738 'exported' => $exported,
739 ];
740 }
741
742 /**
743 * Exports boxes
744 *
745 * @param array $box_ids
746 *
747 * @return string
748 */
749 public static function exportBoxes($box_ids)
750 {
751 if (!$payload = self::getExportPayload($box_ids))
752 {
753 return;
754 }
755
756 $filename = $payload['filename'];
757 $exported = $payload['exported'];
758
759 // SET DOCUMENT HEADER
760 $userAgent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '';
761 if (preg_match('#Opera(/| )([0-9].[0-9]{1,2})#', $userAgent))
762 {
763 $UserBrowser = "Opera";
764 }
765 elseif (preg_match('#MSIE ([0-9].[0-9]{1,2})#', $userAgent))
766 {
767 $UserBrowser = "IE";
768 }
769 else
770 {
771 $UserBrowser = '';
772 }
773 $mime_type = ($UserBrowser == 'IE' || $UserBrowser == 'Opera') ? 'application/octetstream' : 'application/octet-stream';
774 @ob_end_clean();
775 ob_start();
776
777 header('Content-Type: ' . $mime_type);
778 header('Expires: ' . gmdate('D, d M Y H:i:s') . ' GMT');
779
780 if ($UserBrowser == 'IE')
781 {
782 header('Content-Disposition: inline; filename="' . $filename . '.fbox"');
783 header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
784 header('Pragma: public');
785 }
786 else
787 {
788 header('Content-Disposition: attachment; filename="' . $filename . '.fbox"');
789 header('Pragma: no-cache');
790 }
791
792 // PRINT STRING
793 echo wp_json_encode($exported);
794
795 if (ob_get_level())
796 {
797 @ob_end_flush();
798 }
799 die();
800 }
801
802 /**
803 * Returns the last date viewed of a campaign.
804 *
805 * @param int $id
806 *
807 * @return string
808 */
809 public static function getCampaignLastDateViewed($id = null)
810 {
811 if (!$id)
812 {
813 return;
814 }
815
816 $last_date_viewed = firebox()->tables->boxlog->getResults([
817 'select' => [
818 'date as last_date_viewed'
819 ],
820 'where' => [
821 'box' => ' = ' . absint($id),
822 ],
823 'orderby' => ' date desc',
824 'limit' => 1
825 ]);
826
827 return isset($last_date_viewed[0]->last_date_viewed) ? get_date_from_gmt($last_date_viewed[0]->last_date_viewed) : null;
828 }
829
830
831 }
832