PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / page-form-builder / preview / bfb-preview.php

bfb-preview.php in Booking Calendar 11.9, at includes/page-form-builder/preview/bfb-preview.php

2,421 lines 86.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Booking Form Builder - Preview Service (Option A).
4 *
5 * - Creates and manages a single private "Booking Form Preview" page.
6 * - Can render a sanitized unsaved snapshot directly in an authenticated admin request.
7 * - Handles AJAX to store a temporary snapshot of the current BFB structure.
8 * - Builds a secure preview URL for that page and injects the real booking shortcode.
9 * - Optionally exposes a filter hook to let BFB override form structure from snapshot.
10 *
11 * @package Booking Calendar
12 * @subpackage Form Builder
13 *
14 * @author wpdevelop
15 * @version 1.0.0
16 * @since 10.14.0
17 * @file: ../includes/page-form-builder/preview/bfb-preview.php
18 */
19
20 /**
21 == Preview pipeline ==
22 When click on update Preview, the client sends AJAX:
23 action = WPBC_AJX_BFB_SAVE_FORM_CONFIG
24 status = preview
25 structure = JSON.stringify( builder_structure )
26 settings = JSON.stringify( form_settings ) (options + css_vars)
27 and then either:
28 Advanced Mode text (if chosen / auto+dirty), OR
29 Builder export by calling WPBC_BFB_Exporter.export_all(structure, export_options) and sending:
30 advanced_form
31 content_form (from fields_data)
32 Server stores it temporarily in a transient via create_preview_session():
33 structure
34 advanced_form
35 content_form
36 Then it returns a secure URL, and the iframe loads that page.
37 On the preview page request, the service injects the booking shortcode and
38 uses the authenticated transient snapshot as the source resolver and loader
39 result for that exact resource, form name, and preview status. Compatibility
40 filters for structure, advanced form, and content form remain available.
41 */
42
43 if ( ! defined( 'ABSPATH' ) ) {
44 exit;
45 }
46
47 /**
48 * Service for handling BFB preview page and snapshots.
49 */
50 class WPBC_BFB_Preview_Service {
51
52 /**
53 * Singleton instance.
54 *
55 * @var WPBC_BFB_Preview_Service|null
56 */
57 protected static $instance = null;
58
59 /**
60 * Currently active preview data for this request (if any).
61 *
62 * @var array|null
63 */
64 protected $current_preview_data = null;
65
66 /**
67 * Whether the request-scoped Booking Calendar option filter is active.
68 *
69 * @var bool
70 */
71 protected $preview_option_filter_registered = false;
72
73 /**
74 * Register request-scoped source filters for a page or inline preview.
75 *
76 * @return void
77 */
78 private function register_preview_source_filters() {
79 $this->register_preview_option_filter();
80 add_filter( 'wpbc_bfb_get_form_structure', array( $this, 'filter_form_structure_for_preview' ), 10, 2 );
81 add_filter( 'wpbc_bfb_get_form_advanced_form', array( $this, 'filter_form_advanced_form_for_preview' ), 10, 2 );
82 add_filter( 'wpbc_bfb_get_form_content_form', array( $this, 'filter_form_content_form_for_preview' ), 10, 2 );
83 add_filter( 'wpbc_fe_form_source_resolution', array( $this, 'filter_form_source_resolution_for_preview' ), 10, 2 );
84 add_filter( 'wpbc_bfb_form_loader_from_builder', array( $this, 'filter_form_pair_for_preview' ), 100, 2 );
85 add_filter( 'wpbc_booking_appointment_form_status', array( $this, 'filter_appointment_form_status_for_preview' ), 10, 4 );
86 add_filter( 'wpbc_booking_form__should_collect_inline_scripts', array( $this, 'filter_inline_script_collection_for_preview' ), 10, 4 );
87 }
88
89 /**
90 * Remove request-scoped source filters after synchronous preview rendering.
91 *
92 * @return void
93 */
94 private function remove_preview_source_filters() {
95 remove_filter( 'wpbc_bfb_get_form_structure', array( $this, 'filter_form_structure_for_preview' ), 10 );
96 remove_filter( 'wpbc_bfb_get_form_advanced_form', array( $this, 'filter_form_advanced_form_for_preview' ), 10 );
97 remove_filter( 'wpbc_bfb_get_form_content_form', array( $this, 'filter_form_content_form_for_preview' ), 10 );
98 remove_filter( 'wpbc_fe_form_source_resolution', array( $this, 'filter_form_source_resolution_for_preview' ), 10 );
99 remove_filter( 'wpbc_bfb_form_loader_from_builder', array( $this, 'filter_form_pair_for_preview' ), 100 );
100 remove_filter( 'wpbc_booking_appointment_form_status', array( $this, 'filter_appointment_form_status_for_preview' ), 10 );
101 remove_filter( 'wpbc_booking_form__should_collect_inline_scripts', array( $this, 'filter_inline_script_collection_for_preview' ), 10 );
102 }
103
104 /**
105 * Prevent legacy page-level callbacks from escaping an inline preview render.
106 *
107 * Inline previews discard renderer scripts and initialize the returned form
108 * with a JSON-safe bootstrap contract. Queuing the logged-in-user autofill
109 * callback on the parent administration page can therefore target a preview
110 * form that has already been replaced. Full-page signed previews keep the
111 * normal front-end behavior.
112 *
113 * @since 11.9.0
114 *
115 * @param bool $should_collect Whether the renderer should collect legacy inline scripts.
116 * @param int $resource_id Booking resource ID used by the rendered form.
117 * @param string $custom_booking_form Booking form slug requested by the renderer.
118 * @param string $form_status Normalized renderer status.
119 *
120 * @return bool False for request-local inline previews; otherwise the prior decision.
121 */
122 public function filter_inline_script_collection_for_preview( $should_collect, $resource_id, $custom_booking_form, $form_status ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
123 if ( 'inline_preview' === ( isset( $this->current_preview_data['scope'] ) ? $this->current_preview_data['scope'] : '' ) ) {
124 return false;
125 }
126
127 return (bool) $should_collect;
128 }
129
130 /**
131 * Remove the request-scoped Booking Calendar option override filter.
132 *
133 * Front-end preview requests intentionally keep this filter for the complete
134 * request because assets are resolved before the preview shortcode. Inline
135 * administration previews have a narrower lifetime and must restore canonical
136 * options immediately after their synchronous render completes.
137 *
138 * @return void
139 */
140 private function remove_preview_option_filter() {
141 if ( ! $this->preview_option_filter_registered ) {
142 return;
143 }
144
145 remove_bk_filter( 'wpdev_bk_get_option', array( $this, 'filter_option_for_preview' ) );
146 $this->preview_option_filter_registered = false;
147 }
148
149 /**
150 * Get singleton instance.
151 *
152 * @return WPBC_BFB_Preview_Service
153 */
154 public static function get_instance() {
155 if ( null === self::$instance ) {
156 self::$instance = new self();
157 }
158
159 return self::$instance;
160 }
161
162 /**
163 * Constructor (private, use get_instance()).
164 */
165 private function __construct() {
166
167 // Ensure preview page exists (lazy).
168 add_action( 'init', array( $this, 'ensure_preview_page_exists' ), 20 );
169
170 // Re-apply template after switching theme.
171 add_action( 'after_switch_theme', array( $this, 'ensure_preview_page_template' ), 20 );
172
173 // Inject preview content into preview page.
174 add_filter( 'the_content', array( $this, 'filter_the_content_for_preview' ) );
175
176 // Optionally hide preview page from Pages list in admin.
177 // add_action( 'pre_get_posts', array( $this, 'hide_preview_page_in_admin_list' ) );
178
179 // Enqueue JS/CSS on Builder admin page.
180 add_action( 'wpbc_enqueue_js_files_on_page_done', array( $this, 'enqueue_js_files' ) );
181
182 // Hide admin bar in preview iframe only.
183 add_action( 'after_setup_theme', array( $this, 'maybe_hide_admin_bar_for_preview' ) );
184
185 /*
186 * Calendar skins and front-end JavaScript variables are resolved while
187 * assets are enqueued, before `the_content` renders the preview shortcode.
188 * Activate a validated transient override early enough for those consumers.
189 */
190 add_action( 'wp_enqueue_scripts', array( $this, 'activate_preview_option_overrides' ), 1 );
191
192 add_filter( 'wp_robots', array( $this, 'add_noindex_to_preview_page' ), 99 );
193 }
194
195 /**
196 * Activate allow-listed preview option overrides for the current front-end request.
197 *
198 * The method is intentionally read-only. It accepts only the authenticated,
199 * user-bound transient loaded by `get_preview_data_from_request()` and never
200 * changes canonical Booking Calendar options.
201 *
202 * @return void
203 */
204 public function activate_preview_option_overrides() {
205 $preview_data = $this->get_preview_data_from_request();
206 if ( empty( $preview_data['option_overrides'] ) ) {
207 return;
208 }
209
210 $this->register_preview_option_filter();
211 }
212
213 /**
214 * Register the internal Booking Calendar option filter once per request.
215 *
216 * @return void
217 */
218 private function register_preview_option_filter() {
219 if ( $this->preview_option_filter_registered || empty( $this->current_preview_data['option_overrides'] ) ) {
220 return;
221 }
222
223 add_bk_filter( 'wpdev_bk_get_option', array( $this, 'filter_option_for_preview' ) );
224 $this->preview_option_filter_registered = true;
225 }
226
227 /**
228 * Return one validated transient option override during preview rendering.
229 *
230 * @param mixed $value Current option value.
231 * @param string $option Booking Calendar option name.
232 * @param mixed $default Caller-provided default value.
233 *
234 * @return mixed Preview override or the unchanged option value.
235 */
236 public function filter_option_for_preview( $value, $option, $default = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
237 $option = is_scalar( $option ) ? sanitize_key( (string) $option ) : '';
238
239 if (
240 ! empty( $this->current_preview_data['option_overrides'] )
241 && array_key_exists( $option, $this->current_preview_data['option_overrides'] )
242 ) {
243 return $this->current_preview_data['option_overrides'][ $option ];
244 }
245
246 return $value;
247 }
248
249 /**
250 * Ensure that the preview page exists and is stored in options.
251 *
252 * This runs on 'init' and can also be called manually.
253 *
254 * @return int|false Page ID on success, false on failure.
255 */
256 public function ensure_preview_page_exists() {
257
258 $option_key = $this->get_page_option_key();
259 $page_id = (int) get_option( $option_key );
260
261 if ( $page_id > 0 ) {
262
263 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
264 if ( is_admin() && ( defined( 'DOING_AJAX' ) ) && ( DOING_AJAX ) && ( isset( $_POST['action'] ) ) && ( 'WPBC_AJX_BFB_SAVE_FORM_CONFIG' === $_POST['action'] ) ) {
265 // Probably saving preview before making this preview, so we can check about ensure preview page.
266 } else {
267 return false;
268 }
269
270 $page = get_post( $page_id );
271
272 if ( $page instanceof WP_Post && 'page' === $page->post_type ) {
273
274 // Ensure it uses full-width template (if available).
275 $this->ensure_preview_page_template();
276
277 return $page_id;
278 }
279
280 delete_option( $option_key );
281 }
282
283 $page_id = $this->create_preview_page();
284
285 if ( $page_id > 0 ) {
286 update_option( $option_key, $page_id );
287
288 // Set full-width template right after creation.
289 $this->ensure_preview_page_template();
290
291 return $page_id;
292 }
293
294 return false;
295 }
296
297 public function add_noindex_to_preview_page( $robots ) {
298
299 if ( ! $this->is_main_query_page() ) {
300 return $robots;
301 }
302
303 $page_id = (int) get_queried_object_id();
304 if ( $page_id !== (int) $this->get_preview_page_id() ) {
305 return $robots;
306 }
307
308 $robots['noindex'] = true;
309 $robots['nofollow'] = true;
310 $robots['noarchive'] = true;
311
312 return $robots;
313 }
314
315 /**
316 * Check the main query directly without calling a conditional query tag too early.
317 *
318 * @return bool
319 */
320 protected function is_main_query_page() {
321 return isset( $GLOBALS['wp_query'] )
322 && ( $GLOBALS['wp_query'] instanceof WP_Query )
323 && $GLOBALS['wp_query']->is_page();
324 }
325
326 /**
327 * Resolve capability used for preview / Builder access.
328 *
329 * Uses wpbc_bfb_get_manage_cap() when available, falls back to manage_options.
330 *
331 * @return string
332 */
333 protected function get_manage_cap() {
334 if ( function_exists( 'wpbc_bfb_get_manage_cap' ) ) {
335 return wpbc_bfb_get_manage_cap();
336 }
337
338 return 'manage_options';
339 }
340
341 /**
342 * Check if current request is a BFB preview request.
343 *
344 * @return bool
345 */
346 protected function is_preview_request() {
347
348 // Quick GET check (works before main query is parsed).
349 if (
350 isset( $_GET['wpbc_bfb_preview'] )
351 && is_scalar( $_GET['wpbc_bfb_preview'] )
352 && '' !== (string) wp_unslash( $_GET['wpbc_bfb_preview'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
353 ) {
354 return true;
355 }
356
357 // Fallback for places where query vars are already set.
358 $is_preview = get_query_var( 'wpbc_bfb_preview' );
359
360 return is_scalar( $is_preview ) && ! empty( $is_preview );
361 }
362
363 /**
364 * Hide admin toolbar on front-end preview requests.
365 *
366 * This affects ONLY the preview page loaded in the iframe.
367 */
368 public function maybe_hide_admin_bar_for_preview() {
369
370 // Do not affect wp-admin.
371 if ( is_admin() ) {
372 return;
373 }
374
375 if ( ! $this->is_preview_request() ) {
376 return;
377 }
378
379 $cap = $this->get_manage_cap();
380
381 // Optionally limit to users who can see this preview anyway.
382 if ( ! is_user_logged_in() || ! current_user_can( $cap ) ) {
383 return;
384 }
385
386 // Disable admin bar for this request only.
387 show_admin_bar( false );
388 }
389
390 public function enqueue_js_files() {
391 if ( ! is_admin() ) {
392 return;
393 }
394 // BFB preview script on the Builder admin page.
395 wp_enqueue_script(
396 'wpbc-bfb-preview',
397 wpbc_plugin_url( '/includes/page-form-builder/preview/_out/bfb-preview.js' ),
398 array( 'wpbc-bfb_builder' ),
399 WP_BK_VERSION_NUM,
400 true
401 );
402 wp_enqueue_style(
403 'wpbc-bfb-preview-mode',
404 wpbc_plugin_url( '/includes/page-form-builder/preview/_out/bfb-preview.css' ),
405 array(),
406 WP_BK_VERSION_NUM
407 );
408 }
409
410 /**
411 * Return the option key where preview page ID is stored.
412 *
413 * @return string
414 */
415 protected function get_page_option_key() {
416 return 'wpbc_bfb_preview_page_id';
417 }
418
419 /**
420 * Create the private "Booking Form Preview" page.
421 *
422 * @return int|false Page ID on success, false on failure.
423 */
424 protected function create_preview_page() {
425
426 $page_id = wpbc_bfb_activation__create_preview_page_raw();
427 return $page_id;
428 }
429
430 /**
431 * Get the preview page ID (ensures it exists).
432 *
433 * @return int|false
434 */
435 public function get_preview_page_id() {
436 $option_key = $this->get_page_option_key();
437 $page_id = (int) get_option( $option_key );
438
439 if ( $page_id > 0 ) {
440 return $page_id;
441 }
442
443 return $this->ensure_preview_page_exists();
444 }
445
446
447 /**
448 * Build transient key for storing preview snapshot.
449 *
450 * @param int $user_id Current user ID.
451 * @param string $token Random token.
452 * @param int $form_id Booking form ID.
453 *
454 * @return string
455 */
456 protected function get_transient_key( $user_id, $token, $form_id ) {
457
458 $user_id = (int) $user_id;
459 $form_id = (int) $form_id;
460
461 return 'wpbc_bfb_preview_' . $user_id . '_' . $form_id . '_' . sanitize_key( $token );
462 }
463
464 /**
465 * Try to load preview data from current request (front-end).
466 *
467 * @return array|null
468 */
469 protected function get_preview_data_from_request() {
470
471 if ( null !== $this->current_preview_data ) {
472 return $this->current_preview_data;
473 }
474
475 // Check query flag.
476 $is_preview = get_query_var( 'wpbc_bfb_preview' );
477 $is_preview = is_scalar( $is_preview ) ? (string) $is_preview : '';
478 $is_preview_get = isset( $_GET['wpbc_bfb_preview'] ) && is_scalar( $_GET['wpbc_bfb_preview'] )
479 ? (string) wp_unslash( $_GET['wpbc_bfb_preview'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
480 : '';
481
482 if ( empty( $is_preview ) && empty( $is_preview_get ) ) {
483 return null;
484 }
485
486 $token_raw = get_query_var( 'wpbc_bfb_preview_token' );
487 $form_id_raw = get_query_var( 'wpbc_bfb_preview_form_id' );
488 $token = is_scalar( $token_raw ) ? sanitize_text_field( wp_unslash( (string) $token_raw ) ) : '';
489 $form_id = is_scalar( $form_id_raw ) ? absint( $form_id_raw ) : 0;
490
491 if ( empty( $token ) ) {
492 $token = isset( $_GET['wpbc_bfb_preview_token'] ) && is_scalar( $_GET['wpbc_bfb_preview_token'] )
493 ? sanitize_text_field( wp_unslash( (string) $_GET['wpbc_bfb_preview_token'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
494 : '';
495 }
496
497 if ( empty( $form_id ) ) {
498 $form_id = isset( $_GET['wpbc_bfb_preview_form_id'] ) && is_scalar( $_GET['wpbc_bfb_preview_form_id'] )
499 ? absint( wp_unslash( $_GET['wpbc_bfb_preview_form_id'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
500 : 0;
501 }
502
503 $nonce = isset( $_GET['nonce'] ) && is_scalar( $_GET['nonce'] )
504 ? sanitize_text_field( wp_unslash( (string) $_GET['nonce'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
505 : '';
506
507 return $this->load_preview_data( $token, $form_id, $nonce );
508 }
509
510 /**
511 * Enqueue the reusable administration inline-preview renderer.
512 *
513 * Settings and setup pages may call this before replacing an authorized
514 * server-rendered booking form without duplicating Datepick cleanup or WPBC
515 * bootstrap ordering logic.
516 *
517 * @return void
518 */
519 public function enqueue_inline_preview_assets() {
520 if ( ! is_admin() ) {
521 return;
522 }
523
524 wp_enqueue_script(
525 'wpbc-bfb-inline-preview',
526 wpbc_plugin_url( '/includes/page-form-builder/preview/_out/bfb-inline-preview.js' ),
527 array( 'jquery', 'wpbc-main-client' ),
528 WP_BK_VERSION_NUM,
529 true
530 );
531 }
532
533 /**
534 * Activate an Appointment preview from its signed configuration return URL.
535 *
536 * The Appointment AJAX endpoint receives this URL only inside its HMAC-signed
537 * configuration. This method additionally verifies the preview nonce, current
538 * user, capability, transient scope, and expiry before registering temporary
539 * source filters. A normal public Appointment request therefore cannot opt in
540 * to an administrator's unsaved preview snapshot.
541 *
542 * @param mixed $preview_url Signed Appointment configuration return URL.
543 *
544 * @return bool True when an authenticated Appointment preview was activated.
545 */
546 public function activate_appointment_preview_from_url( $preview_url ) {
547 if ( ! is_scalar( $preview_url ) || '' === trim( (string) $preview_url ) ) {
548 return false;
549 }
550
551 $query_string = wp_parse_url( (string) $preview_url, PHP_URL_QUERY );
552 if ( ! is_string( $query_string ) || '' === $query_string ) {
553 return false;
554 }
555
556 $query_args = array();
557 wp_parse_str( $query_string, $query_args );
558 $is_preview = isset( $query_args['wpbc_bfb_preview'] ) && is_scalar( $query_args['wpbc_bfb_preview'] )
559 ? sanitize_text_field( (string) $query_args['wpbc_bfb_preview'] )
560 : '';
561 $token = isset( $query_args['wpbc_bfb_preview_token'] ) && is_scalar( $query_args['wpbc_bfb_preview_token'] )
562 ? sanitize_key( (string) $query_args['wpbc_bfb_preview_token'] )
563 : '';
564 $form_id = isset( $query_args['wpbc_bfb_preview_form_id'] ) && is_scalar( $query_args['wpbc_bfb_preview_form_id'] )
565 ? absint( $query_args['wpbc_bfb_preview_form_id'] )
566 : 0;
567 $nonce = isset( $query_args['nonce'] ) && is_scalar( $query_args['nonce'] )
568 ? sanitize_text_field( (string) $query_args['nonce'] )
569 : '';
570
571 if ( '1' !== $is_preview || null === $this->load_preview_data( $token, $form_id, $nonce ) ) {
572 return false;
573 }
574
575 if ( 'appointment' !== $this->get_current_preview_render_mode() ) {
576 $this->current_preview_data = null;
577
578 return false;
579 }
580
581 $this->register_preview_source_filters();
582
583 return true;
584 }
585
586 /**
587 * Load and validate one user-bound preview transient.
588 *
589 * @param string $token Random preview token.
590 * @param int $form_id Preview resource ID encoded into the transient key.
591 * @param string $nonce Nonce bound to the preview token.
592 *
593 * @return array|null Normalized preview data or null when validation fails.
594 */
595 private function load_preview_data( $token, $form_id, $nonce ) {
596 $token = is_scalar( $token ) ? sanitize_key( (string) $token ) : '';
597 $form_id = is_scalar( $form_id ) ? absint( $form_id ) : 0;
598 $nonce = is_scalar( $nonce ) ? sanitize_text_field( (string) $nonce ) : '';
599
600 if (
601 '' === $token
602 || $form_id <= 0
603 || ! is_user_logged_in()
604 || ! current_user_can( $this->get_manage_cap() )
605 || ! wp_verify_nonce( $nonce, 'wpbc_bfb_preview_' . $token )
606 ) {
607 return null;
608 }
609
610 $user_id = get_current_user_id();
611 if ( $user_id <= 0 ) {
612 return null;
613 }
614
615 $data = get_transient( $this->get_transient_key( $user_id, $token, $form_id ) );
616 $data = $this->normalize_preview_data( $data );
617 $current_time = time();
618
619 if (
620 null === $data
621 || $user_id !== $data['user_id']
622 || $form_id !== $data['form_id']
623 || $form_id !== $data['resource_id']
624 || 'preview' !== $data['scope']
625 || $data['time'] <= 0
626 || $data['time'] > ( $current_time + MINUTE_IN_SECONDS )
627 || $data['time'] < ( $current_time - ( 10 * MINUTE_IN_SECONDS ) )
628 ) {
629 return null;
630 }
631
632 $has_structure = ! empty( $data['structure'] ) && is_array( $data['structure'] );
633 $has_advanced = ! empty( $data['advanced_form'] ) || ! empty( $data['content_form'] );
634 if ( ! $has_structure && ! $has_advanced ) {
635 return null;
636 }
637
638 $this->current_preview_data = $data;
639
640 return $this->current_preview_data;
641 }
642
643 /**
644 * Normalize and validate a preview transient before any array offsets are read.
645 *
646 * Object-cache implementations and third-party code can return unexpected
647 * values for a transient. Keeping this normalization at the shared read
648 * boundary prevents malformed values from producing warnings or reaching the
649 * booking-form renderer.
650 *
651 * @param mixed $preview_data Raw transient value.
652 *
653 * @return array|null Normalized preview snapshot, or null for an invalid shape.
654 */
655 private function normalize_preview_data( $preview_data ) {
656
657 if ( ! is_array( $preview_data ) ) {
658 return null;
659 }
660
661 $user_id = isset( $preview_data['user_id'] ) && is_scalar( $preview_data['user_id'] )
662 ? absint( $preview_data['user_id'] )
663 : 0;
664 $form_id = isset( $preview_data['form_id'] ) && is_scalar( $preview_data['form_id'] )
665 ? absint( $preview_data['form_id'] )
666 : 0;
667 $resource_id = isset( $preview_data['resource_id'] ) && is_scalar( $preview_data['resource_id'] )
668 ? absint( $preview_data['resource_id'] )
669 : $form_id;
670 $form_name = isset( $preview_data['form_name'] ) && is_scalar( $preview_data['form_name'] )
671 ? sanitize_text_field( (string) $preview_data['form_name'] )
672 : 'standard';
673 $scope = isset( $preview_data['scope'] ) && is_scalar( $preview_data['scope'] )
674 ? sanitize_key( (string) $preview_data['scope'] )
675 : '';
676 $render_mode = isset( $preview_data['render_mode'] ) && is_scalar( $preview_data['render_mode'] )
677 ? sanitize_key( (string) $preview_data['render_mode'] )
678 : 'booking';
679 $render_mode = 'appointment' === $render_mode ? 'appointment' : 'booking';
680
681 if ( $user_id <= 0 || $form_id <= 0 || $resource_id <= 0 ) {
682 return null;
683 }
684
685 return array(
686 'user_id' => $user_id,
687 'form_id' => $form_id,
688 'resource_id' => $resource_id,
689 'form_name' => '' === $form_name ? 'standard' : $form_name,
690 'scope' => $scope,
691 'render_mode' => $render_mode,
692 'structure' => isset( $preview_data['structure'] ) && is_array( $preview_data['structure'] )
693 ? $preview_data['structure']
694 : array(),
695 'time' => isset( $preview_data['time'] ) && is_scalar( $preview_data['time'] )
696 ? absint( $preview_data['time'] )
697 : 0,
698 'advanced_form' => isset( $preview_data['advanced_form'] ) && is_scalar( $preview_data['advanced_form'] )
699 ? (string) $preview_data['advanced_form']
700 : '',
701 'content_form' => isset( $preview_data['content_form'] ) && is_scalar( $preview_data['content_form'] )
702 ? (string) $preview_data['content_form']
703 : '',
704 'settings_json' => isset( $preview_data['settings_json'] ) && is_scalar( $preview_data['settings_json'] )
705 ? (string) $preview_data['settings_json']
706 : '',
707 'form_style' => isset( $preview_data['form_style'] ) && is_array( $preview_data['form_style'] )
708 ? $preview_data['form_style']
709 : array(),
710 'option_overrides' => isset( $preview_data['option_overrides'] ) && is_array( $preview_data['option_overrides'] )
711 ? $this->sanitize_preview_option_overrides( $preview_data['option_overrides'] )
712 : array(),
713 'calendar_parameters' => isset( $preview_data['calendar_parameters'] ) && is_array( $preview_data['calendar_parameters'] )
714 ? $this->sanitize_preview_calendar_parameters( $preview_data['calendar_parameters'] )
715 : array(),
716 'calendar_request_overrides' => isset( $preview_data['calendar_request_overrides'] ) && is_array( $preview_data['calendar_request_overrides'] )
717 ? $this->sanitize_preview_calendar_request_overrides( $preview_data['calendar_request_overrides'] )
718 : array(),
719 );
720 }
721
722 // -----------------------------------------------------------------------------------------------------------------
723
724 /**
725 * Ensure preview page uses a "full width" template (if the current theme provides one).
726 *
727 * WordPress stores selected templates in post meta: _wp_page_template. :contentReference[oaicite:1]{index=1}
728 *
729 * Developers can hard-force a template via:
730 * apply_filters( 'wpbc_bfb_preview_page_template', $template, $page_id )
731 *
732 * - Classic theme template value: 'templates/full-width.php'
733 * - Block theme template value: 'page-no-title' (template slug)
734 *
735 * @return void
736 */
737 public function ensure_preview_page_template() {
738
739 $page_id = (int) $this->get_preview_page_id();
740 if ( $page_id <= 0 ) {
741 return;
742 }
743
744 // If already set and valid, do nothing (fast path).
745 $current = (string) get_post_meta( $page_id, '_wp_page_template', true );
746 if ( $this->is_preview_template_value_valid( $current ) ) {
747 return;
748 }
749
750 $template = $this->detect_full_width_template_value();
751
752 /**
753 * Force/override template for preview page.
754 *
755 * Return values:
756 * - '' or 'default' => keep theme default
757 * - classic: 'templates/full-width.php'
758 * - block: 'page-no-title' (slug)
759 */
760 $template = apply_filters( 'wpbc_bfb_preview_page_template', $template, $page_id );
761
762 $template = (string) $template;
763 $template = trim( $template );
764
765 if ( '' === $template || 'default' === $template ) {
766 // Use default template.
767 delete_post_meta( $page_id, '_wp_page_template' );
768
769 return;
770 }
771
772 // Store chosen template.
773 update_post_meta( $page_id, '_wp_page_template', $template );
774 }
775
776 /**
777 * Validate existing _wp_page_template meta value for classic themes.
778 * For block themes we can’t reliably "locate" the template file here, so we accept non-empty values.
779 *
780 * @param string $template_value Existing _wp_page_template meta value.
781 *
782 * @return bool
783 */
784 protected function is_preview_template_value_valid( $template_value ) {
785
786 $template_value = (string) $template_value;
787 $template_value = trim( $template_value );
788
789 if ( '' === $template_value || 'default' === $template_value ) {
790 return false;
791 }
792
793 // Classic themes: validate that file exists in theme.
794 if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
795 return true;
796 }
797
798 $located = locate_template( array( $template_value ), false, false );
799
800 return ( ! empty( $located ) );
801 }
802
803 /**
804 * Detect a full-width template value for current theme.
805 *
806 * @return string Template value for _wp_page_template, or '' if none found.
807 */
808 protected function detect_full_width_template_value() {
809
810 // Block themes: try to find a block template slug.
811 if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() && function_exists( 'get_block_templates' ) ) {
812 $slug = $this->detect_block_theme_full_width_slug();
813 if ( '' !== $slug ) {
814 return $slug;
815 }
816 }
817
818 // Classic themes: try registered page templates first.
819 $file = $this->detect_classic_theme_full_width_file();
820 if ( '' !== $file ) {
821 return $file;
822 }
823
824 return '';
825 }
826
827 /**
828 * Try to detect a "full width" template slug in block themes.
829 *
830 * @return string
831 */
832 protected function detect_block_theme_full_width_slug() {
833
834 $best_slug = '';
835 $best_score = 0;
836
837 $templates = get_block_templates( array(
838 'post_type' => 'page',
839 ), 'wp_template' );
840
841 if ( empty( $templates ) || ! is_array( $templates ) ) {
842 return '';
843 }
844
845 foreach ( $templates as $tpl ) {
846
847 $title = '';
848 $slug = '';
849
850 if ( is_object( $tpl ) ) {
851 $title = isset( $tpl->title ) ? (string) $tpl->title : '';
852 $slug = isset( $tpl->slug ) ? (string) $tpl->slug : '';
853 }
854
855 $score = $this->score_full_width_candidate( $title . ' ' . $slug );
856
857 if ( $score > $best_score && '' !== $slug ) {
858 $best_score = $score;
859 $best_slug = $slug;
860 }
861 }
862
863 return ( $best_score >= 60 ) ? $best_slug : '';
864 }
865
866 /**
867 * Try to detect a "full width" template file in classic themes.
868 *
869 * @return string
870 */
871 protected function detect_classic_theme_full_width_file() {
872
873 $theme = wp_get_theme();
874 $templates = $theme->get_page_templates( null, 'page' ); // array( 'Name' => 'file.php' ).
875
876 $best_file = '';
877 $best_score = 0;
878
879 if ( ! empty( $templates ) && is_array( $templates ) ) {
880 foreach ( $templates as $name => $file ) {
881 $score = $this->score_full_width_candidate( $name . ' ' . $file );
882 if ( $score > $best_score && ! empty( $file ) ) {
883 $best_score = $score;
884 $best_file = (string) $file;
885 }
886 }
887 }
888
889 // Accept strong match from registered templates.
890 if ( $best_score >= 60 && '' !== $best_file ) {
891 $located = locate_template( array( $best_file ), false, false );
892 if ( ! empty( $located ) ) {
893 return $best_file;
894 }
895 }
896
897 // Fallback: common filenames (theme-dependent).
898 $candidates = array(
899 'page-templates/full-width.php',
900 'page-templates/fullwidth.php',
901 'templates/full-width.php',
902 'templates/fullwidth.php',
903 'full-width.php',
904 'fullwidth.php',
905 'page-no-sidebar.php',
906 'page-nosidebar.php',
907 );
908
909 foreach ( $candidates as $candidate ) {
910 $located = locate_template( array( $candidate ), false, false );
911 if ( ! empty( $located ) ) {
912 return $candidate;
913 }
914 }
915
916 return '';
917 }
918
919 /**
920 * Score a candidate (template name/slug/file) as "full width".
921 *
922 * @param string $haystack Text to score.
923 *
924 * @return int
925 */
926 protected function score_full_width_candidate( $haystack ) {
927
928 $h = strtolower( (string) $haystack );
929 $s = 0;
930
931 if ( false !== strpos( $h, 'full' ) && false !== strpos( $h, 'width' ) ) {
932 $s += 100;
933 }
934 if ( false !== strpos( $h, 'no' ) && false !== strpos( $h, 'sidebar' ) ) {
935 $s += 95;
936 }
937 // My theme exception for local test server.
938 if ( ( isset( $_SERVER['HTTP_HOST'] ) && ( 'beta' === sanitize_key( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ) ) && ( false !== strpos( $h, 'no-title' ) ) ) {
939 $s += 61;
940 }
941 if ( false !== strpos( $h, 'wide' ) ) {
942 $s += 60;
943 }
944 if ( false !== strpos( $h, 'canvas' ) || false !== strpos( $h, 'blank' ) ) {
945 $s += 40;
946 }
947 if ( false !== strpos( $h, 'elementor' ) && false !== strpos( $h, 'full' ) ) {
948 $s += 30;
949 }
950
951 return $s;
952 }
953 // -----------------------------------------------------------------------------------------------------------------
954
955 /**
956 * Filter "the_content" to inject the real booking form into the preview page.
957 *
958 * Note:
959 * - This is where we run the actual booking shortcode.
960 * - The theme (classic or block) wraps this content as usual.
961 *
962 * @param string $content Original page content.
963 *
964 * @return string
965 */
966 public function filter_the_content_for_preview( $content ) {
967
968 if ( ! $this->is_main_query_page() ) {
969 return $content;
970 }
971
972 $page_id = get_the_ID();
973 $preview_page_id = $this->get_preview_page_id();
974
975 if ( $preview_page_id <= 0 || (int) $page_id !== (int) $preview_page_id ) {
976 return $content;
977 }
978
979 $preview_data = $this->get_preview_data_from_request();
980
981 if ( empty( $preview_data ) ) {
982 // No active preview snapshot, show a simple message.
983 return '<p>' . esc_html__( 'This is a booking form preview page. Please refresh the preview from the Builder.', 'booking' ) . '</p>';
984 }
985
986 // Safety: disable caching for this request.
987 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
988 if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); }
989
990 nocache_headers();
991
992 // Store preview data for this request so other hooks can access it.
993 $this->current_preview_data = $preview_data;
994
995 $this->register_preview_source_filters();
996
997 $resource_id = $this->get_current_preview_resource_id();
998 if ( $resource_id <= 0 ) {
999 $resource_id = WPBC_FE_Attr_Postprocessor::get_default_booking_resource_id();
1000 }
1001
1002 $form_name = isset( $preview_data['form_name'] ) ? sanitize_text_field( wp_unslash( $preview_data['form_name'] ) ) : 'standard';
1003 if ( '' === $form_name ) {
1004 $form_name = 'standard';
1005 }
1006
1007 if ( 'appointment' === $this->get_current_preview_render_mode() ) {
1008 $shortcode = '[booking_appointment form_type="' . esc_attr( $form_name ) . '"]';
1009 $preview_html = do_shortcode( $shortcode );
1010 } else {
1011 $preview_html = WPBC_FE_Render::render_booking_form(
1012 array(
1013 'resource_id' => $resource_id,
1014 'cal_count' => 1,
1015 'is_echo' => 0,
1016 'custom_booking_form' => $form_name,
1017 'form_status' => 'preview',
1018 'calendar_request_overrides' => $this->get_current_preview_calendar_request_overrides(),
1019 )
1020 );
1021 }
1022
1023 $this->remove_preview_source_filters();
1024
1025 return $this->filter_wrapped_html_for_preview_form_style( $preview_html, array(), $resource_id, $form_name );
1026 }
1027
1028 /**
1029 * Make the authenticated transient snapshot the source for this preview.
1030 *
1031 * The preview must not depend on a saved `preview` database row. The returned
1032 * loader arguments include a server-owned marker that the paired loader
1033 * filter recognizes during this request only.
1034 *
1035 * @param array $resolution Existing database or missing-source resolution.
1036 * @param array $request Front-end form source request.
1037 *
1038 * @return array Preview resolution or the unchanged result when it is unrelated.
1039 */
1040 public function filter_form_source_resolution_for_preview( $resolution, $request ) {
1041
1042 if ( ! $this->is_current_preview_request( $request ) ) {
1043 return $resolution;
1044 }
1045
1046 $resolution = is_array( $resolution ) ? $resolution : array();
1047
1048 $fallback_chain = isset( $resolution['fallback_chain'] ) && is_array( $resolution['fallback_chain'] )
1049 ? $resolution['fallback_chain']
1050 : array();
1051
1052 $request_resource_id = isset( $request['resource_id'] ) ? absint( $request['resource_id'] ) : 0;
1053 $loader_resource_id = 'appointment' === $this->get_current_preview_render_mode()
1054 ? $request_resource_id
1055 : $this->get_current_preview_resource_id();
1056
1057 return array(
1058 'engine' => 'bfb_db',
1059 'apply_after_load_filter' => true,
1060 'bfb_loader_args' => array(
1061 'form_slug' => $this->get_current_preview_form_name(),
1062 'status' => 'preview',
1063 'resource_id' => $loader_resource_id,
1064 'wpbc_preview_snapshot' => 1,
1065 ),
1066 'fallback_chain' => $fallback_chain,
1067 );
1068 }
1069
1070 /**
1071 * Return the transient form pair instead of a saved Form Builder row.
1072 *
1073 * @param array $form_pair Pair already returned by the Form Builder loader.
1074 * @param array $loader_args Normalized Form Builder loader arguments.
1075 *
1076 * @return array Selected preview snapshot or the unchanged pair.
1077 */
1078 public function filter_form_pair_for_preview( $form_pair, $loader_args ) {
1079
1080 if ( ! is_array( $loader_args ) || empty( $loader_args['wpbc_preview_snapshot'] ) ) {
1081 return $form_pair;
1082 }
1083
1084 $request = array(
1085 'resource_id' => isset( $loader_args['resource_id'] ) ? $loader_args['resource_id'] : 0,
1086 'form_slug' => isset( $loader_args['form_slug'] ) ? $loader_args['form_slug'] : '',
1087 'form_status' => isset( $loader_args['status'] ) ? $loader_args['status'] : '',
1088 );
1089
1090 if ( ! $this->is_current_preview_request( $request ) ) {
1091 return $form_pair;
1092 }
1093
1094 return array(
1095 'form' => isset( $this->current_preview_data['advanced_form'] ) ? (string) $this->current_preview_data['advanced_form'] : '',
1096 'content' => isset( $this->current_preview_data['content_form'] ) ? (string) $this->current_preview_data['content_form'] : '',
1097 'settings_json' => isset( $this->current_preview_data['settings_json'] ) ? (string) $this->current_preview_data['settings_json'] : '',
1098 );
1099 }
1100
1101 /**
1102 * Check whether a renderer or loader request belongs to this preview snapshot.
1103 *
1104 * @param array $request Source resolver or loader request values.
1105 *
1106 * @return bool True only for the exact preview resource, form, and status.
1107 */
1108 private function is_current_preview_request( $request ) {
1109
1110 if ( empty( $this->current_preview_data ) || ! is_array( $request ) ) {
1111 return false;
1112 }
1113
1114 $request_resource_id = isset( $request['resource_id'] ) ? absint( $request['resource_id'] ) : 0;
1115 $request_form_name = isset( $request['form_slug'] ) ? sanitize_text_field( (string) $request['form_slug'] ) : '';
1116 $request_status = isset( $request['form_status'] ) ? sanitize_key( (string) $request['form_status'] ) : '';
1117
1118 $resource_matches = 'appointment' === $this->get_current_preview_render_mode()
1119 ? $request_resource_id > 0
1120 : $request_resource_id === $this->get_current_preview_resource_id();
1121
1122 return $resource_matches
1123 && $request_form_name === $this->get_current_preview_form_name()
1124 && 'preview' === $request_status;
1125 }
1126
1127 /**
1128 * Switch the selected Appointment form to the authenticated preview source.
1129 *
1130 * @param string $form_status Existing form status.
1131 * @param string $form_slug Resolved Appointment form slug.
1132 * @param int $provider_id Selected Provider resource ID.
1133 * @param array<string,mixed> $config Signed Appointment configuration.
1134 *
1135 * @return string Preview only for the active snapshot and exact form slug.
1136 */
1137 public function filter_appointment_form_status_for_preview( $form_status, $form_slug, $provider_id, $config ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
1138 if (
1139 'appointment' !== $this->get_current_preview_render_mode()
1140 || absint( $provider_id ) <= 0
1141 || sanitize_text_field( (string) $form_slug ) !== $this->get_current_preview_form_name()
1142 ) {
1143 return $form_status;
1144 }
1145
1146 return 'preview';
1147 }
1148
1149 /**
1150 * Return the resource/calendar ID scoped to the active preview snapshot.
1151 *
1152 * Older transients stored this context only under `form_id`; retain that
1153 * fallback until all ten-minute preview sessions have naturally expired.
1154 *
1155 * @return int Preview resource ID.
1156 */
1157 private function get_current_preview_resource_id() {
1158
1159 if ( isset( $this->current_preview_data['resource_id'] ) ) {
1160 return absint( $this->current_preview_data['resource_id'] );
1161 }
1162
1163 return isset( $this->current_preview_data['form_id'] ) ? absint( $this->current_preview_data['form_id'] ) : 0;
1164 }
1165
1166 /**
1167 * Return the normalized form name scoped to the active preview snapshot.
1168 *
1169 * @return string Preview form name.
1170 */
1171 private function get_current_preview_form_name() {
1172
1173 $form_name = isset( $this->current_preview_data['form_name'] )
1174 ? sanitize_text_field( (string) $this->current_preview_data['form_name'] )
1175 : 'standard';
1176
1177 return '' === $form_name ? 'standard' : $form_name;
1178 }
1179
1180 /**
1181 * Return the allow-listed renderer used by the active preview snapshot.
1182 *
1183 * @return string Either booking or appointment.
1184 */
1185 private function get_current_preview_render_mode() {
1186 $render_mode = isset( $this->current_preview_data['render_mode'] )
1187 ? sanitize_key( (string) $this->current_preview_data['render_mode'] )
1188 : 'booking';
1189
1190 return 'appointment' === $render_mode ? 'appointment' : 'booking';
1191 }
1192
1193 /**
1194 * Return calendar-load overrides scoped to the active preview snapshot.
1195 *
1196 * Appointment rendering happens in a later signed AJAX request, so the
1197 * renderer needs a public, read-only accessor rather than direct access to
1198 * transient internals. Values were allow-list sanitized at the transient
1199 * boundary and are sanitized again here for defense in depth.
1200 *
1201 * @return array<string,int|string> Safe calendar-load request overrides.
1202 */
1203 public function get_current_preview_calendar_request_overrides() {
1204 $calendar_request_overrides = isset( $this->current_preview_data['calendar_request_overrides'] )
1205 ? $this->current_preview_data['calendar_request_overrides']
1206 : array();
1207
1208 return $this->sanitize_preview_calendar_request_overrides( $calendar_request_overrides );
1209 }
1210
1211 /**
1212 * Sanitize booking-form source before it enters a preview transient.
1213 *
1214 * Preview markup is intentionally rendered as booking-form markup rather than
1215 * escaped as plain text. Therefore the shared preview service, rather than
1216 * each caller, must enforce the established Form Builder KSES policy. An
1217 * unavailable sanitizer fails closed for non-empty markup.
1218 *
1219 * @param mixed $form_source Raw advanced-form or content-form source.
1220 *
1221 * @return string|null Sanitized source, or null when it cannot be sanitized.
1222 */
1223 private function sanitize_preview_form_source( $form_source ) {
1224
1225 if ( ! is_scalar( $form_source ) ) {
1226 return '';
1227 }
1228
1229 $form_source = (string) $form_source;
1230 if ( '' === $form_source ) {
1231 return '';
1232 }
1233
1234 if ( ! function_exists( 'wpbc_bfb_sanitize_form_text' ) ) {
1235 return null;
1236 }
1237
1238 return (string) wpbc_bfb_sanitize_form_text( $form_source );
1239 }
1240
1241 /**
1242 * Normalize Form Builder structure at the shared preview write boundary.
1243 *
1244 * @param mixed $structure Candidate decoded Form Builder structure.
1245 *
1246 * @return array|null Sanitized structure, or null when normalization fails.
1247 */
1248 private function sanitize_preview_structure( $structure ) {
1249
1250 $structure = is_array( $structure ) ? $structure : array();
1251 $structure = apply_filters( 'wpbc_bfb_sanitize_structure_before_save', $structure );
1252
1253 return is_array( $structure ) ? $structure : null;
1254 }
1255
1256 /**
1257 * Normalize optional Form Style values for a preview snapshot.
1258 *
1259 * @param mixed $form_style Candidate Form Style overrides.
1260 *
1261 * @return array|null Sanitized style values, or null when required helpers are unavailable.
1262 */
1263 private function sanitize_preview_form_style( $form_style ) {
1264
1265 if ( ! is_array( $form_style ) || empty( $form_style ) ) {
1266 return array();
1267 }
1268
1269 $scalar_style = array();
1270 foreach ( $form_style as $style_key => $style_value ) {
1271 if ( is_scalar( $style_value ) ) {
1272 $scalar_style[ $style_key ] = (string) $style_value;
1273 }
1274 }
1275
1276 if ( empty( $scalar_style ) ) {
1277 return array();
1278 }
1279
1280 if (
1281 ! function_exists( 'wpbc_bfb_settings__sanitize_form_style' )
1282 || ! function_exists( 'wpbc_bfb_settings__get_custom_form_style_options' )
1283 || ! function_exists( 'wpbc_bfb_settings__get_form_accent_options' )
1284 ) {
1285 return null;
1286 }
1287
1288 $style_name = isset( $scalar_style['booking_form_style'] )
1289 ? $scalar_style['booking_form_style']
1290 : '';
1291
1292 return array_merge(
1293 array(
1294 'booking_form_style' => wpbc_bfb_settings__sanitize_form_style( $style_name ),
1295 ),
1296 wpbc_bfb_settings__get_custom_form_style_options( $scalar_style ),
1297 wpbc_bfb_settings__get_form_accent_options( $scalar_style )
1298 );
1299 }
1300
1301 /**
1302 * Normalize the global options supported by transient previews.
1303 *
1304 * Calendar skin values must match the current server-side skin registry.
1305 * Appearance and Date Selection modules share this narrow allow-list. Unknown
1306 * keys are discarded so callers cannot turn the preview transient into a
1307 * generic option override channel.
1308 *
1309 * @param mixed $option_overrides Candidate preview option overrides.
1310 *
1311 * @return array<string,string> Sanitized allow-listed overrides.
1312 */
1313 private function sanitize_preview_option_overrides( $option_overrides ) {
1314 if ( ! is_array( $option_overrides ) ) {
1315 return array();
1316 }
1317
1318 $sanitized = array();
1319 $toggle_keys = array(
1320 'booking_timeslot_picker',
1321 'booking_range_selection_time_is_active',
1322 'booking_change_over_days_triangles',
1323 'booking_last_checkout_day_available',
1324 'booking_recurrent_time',
1325 'booking_is_show_legend',
1326 'booking_legend_is_show_numbers',
1327 'booking_legend_is_vertical',
1328 'booking_legend_is_show_item_available',
1329 'booking_legend_is_show_item_pending',
1330 'booking_legend_is_show_item_approved',
1331 'booking_legend_is_show_item_partially',
1332 'booking_legend_is_show_item_unavailable',
1333 );
1334 foreach ( $toggle_keys as $toggle_key ) {
1335 if ( isset( $option_overrides[ $toggle_key ] ) && is_scalar( $option_overrides[ $toggle_key ] ) && in_array( (string) $option_overrides[ $toggle_key ], array( 'On', 'Off' ), true ) ) {
1336 $sanitized[ $toggle_key ] = (string) $option_overrides[ $toggle_key ];
1337 }
1338 }
1339
1340 $choice_keys = array(
1341 'booking_type_of_day_selections' => array( 'single', 'multiple', 'range' ),
1342 'booking_range_selection_type' => array( 'dynamic', 'fixed' ),
1343 );
1344 foreach ( $choice_keys as $choice_key => $allowed_choices ) {
1345 if ( isset( $option_overrides[ $choice_key ] ) && is_scalar( $option_overrides[ $choice_key ] ) ) {
1346 $choice = sanitize_key( (string) $option_overrides[ $choice_key ] );
1347 if ( in_array( $choice, $allowed_choices, true ) ) {
1348 $sanitized[ $choice_key ] = $choice;
1349 }
1350 }
1351 }
1352
1353 $bounded_integer_keys = array(
1354 'booking_range_selection_days_count' => array( 1, 180 ),
1355 'booking_range_selection_days_count_dynamic' => array( 1, 1095 ),
1356 'booking_range_selection_days_max_count_dynamic' => array( 1, 1095 ),
1357 );
1358 foreach ( $bounded_integer_keys as $integer_key => $bounds ) {
1359 if ( isset( $option_overrides[ $integer_key ] ) && is_scalar( $option_overrides[ $integer_key ] ) && preg_match( '/^\d+$/', (string) $option_overrides[ $integer_key ] ) ) {
1360 $integer_value = (int) $option_overrides[ $integer_key ];
1361 if ( $integer_value >= $bounds[0] && $integer_value <= $bounds[1] ) {
1362 $sanitized[ $integer_key ] = (string) $integer_value;
1363 }
1364 }
1365 }
1366
1367 foreach ( array( 'booking_range_start_day', 'booking_range_start_day_dynamic' ) as $weekday_key ) {
1368 if ( isset( $option_overrides[ $weekday_key ] ) ) {
1369 $weekdays = $this->sanitize_preview_integer_list( $option_overrides[ $weekday_key ], -1, 6, array( -1 ) );
1370 $sanitized[ $weekday_key ] = implode( ',', $weekdays );
1371 }
1372 }
1373
1374 if ( isset( $option_overrides['booking_range_selection_days_specific_num_dynamic'] ) ) {
1375 $specific_days = $this->sanitize_preview_integer_list( $option_overrides['booking_range_selection_days_specific_num_dynamic'], 1, 1095, array() );
1376 $sanitized['booking_range_selection_days_specific_num_dynamic'] = implode( ',', $specific_days );
1377 }
1378
1379 foreach ( array( 'booking_range_selection_start_time', 'booking_range_selection_end_time' ) as $time_key ) {
1380 if ( isset( $option_overrides[ $time_key ] ) && is_scalar( $option_overrides[ $time_key ] ) && preg_match( '/^(?:[01]\d|2[0-3]):[0-5]\d$/', (string) $option_overrides[ $time_key ] ) ) {
1381 $sanitized[ $time_key ] = (string) $option_overrides[ $time_key ];
1382 }
1383 }
1384
1385 foreach ( array( 'available', 'pending', 'approved', 'partially', 'unavailable' ) as $legend_item ) {
1386 $text_key = 'booking_legend_text_for_item_' . $legend_item;
1387 if ( isset( $option_overrides[ $text_key ] ) && is_scalar( $option_overrides[ $text_key ] ) ) {
1388 $legend_text = sanitize_text_field( (string) $option_overrides[ $text_key ] );
1389 $sanitized[ $text_key ] = function_exists( 'mb_substr' ) ? mb_substr( $legend_text, 0, 255 ) : substr( $legend_text, 0, 255 );
1390 }
1391 }
1392
1393 if ( array_key_exists( 'booking_skin', $option_overrides ) && is_scalar( $option_overrides['booking_skin'] ) ) {
1394 $calendar_skin = $this->sanitize_preview_calendar_skin( (string) $option_overrides['booking_skin'] );
1395 if ( '' !== $calendar_skin ) {
1396 $sanitized['booking_skin'] = $calendar_skin;
1397 }
1398 }
1399
1400 return $sanitized;
1401 }
1402
1403 /**
1404 * Normalize Date Selection values used by the explicit browser bootstrap.
1405 *
1406 * @param mixed $calendar_parameters Candidate calendar parameters.
1407 *
1408 * @return array<string,mixed> Sanitized allow-listed parameters.
1409 */
1410 private function sanitize_preview_calendar_parameters( $calendar_parameters ) {
1411 if ( ! is_array( $calendar_parameters ) ) {
1412 return array();
1413 }
1414
1415 $sanitized = array();
1416 if ( array_key_exists( 'is_enabled_change_over', $calendar_parameters ) ) {
1417 $is_enabled_change_over = $this->sanitize_preview_boolean( $calendar_parameters['is_enabled_change_over'] );
1418 if ( null !== $is_enabled_change_over ) {
1419 $sanitized['is_enabled_change_over'] = $is_enabled_change_over;
1420 }
1421 }
1422 if ( isset( $calendar_parameters['days_select_mode'] ) && is_scalar( $calendar_parameters['days_select_mode'] ) ) {
1423 $days_select_mode = sanitize_key( (string) $calendar_parameters['days_select_mode'] );
1424 if ( in_array( $days_select_mode, array( 'single', 'multiple', 'range' ), true ) ) {
1425 $sanitized['days_select_mode'] = $days_select_mode;
1426 }
1427 }
1428
1429 $integer_keys = array(
1430 'fixed__days_num' => array( 0, 180 ),
1431 'dynamic__days_min' => array( 0, 1095 ),
1432 'dynamic__days_max' => array( 0, 1095 ),
1433 );
1434 foreach ( $integer_keys as $integer_key => $bounds ) {
1435 if ( isset( $calendar_parameters[ $integer_key ] ) && is_scalar( $calendar_parameters[ $integer_key ] ) && preg_match( '/^\d+$/', (string) $calendar_parameters[ $integer_key ] ) ) {
1436 $integer_value = (int) $calendar_parameters[ $integer_key ];
1437 if ( $integer_value >= $bounds[0] && $integer_value <= $bounds[1] ) {
1438 $sanitized[ $integer_key ] = $integer_value;
1439 }
1440 }
1441 }
1442
1443 $list_keys = array(
1444 'fixed__week_days__start' => array( -1, 6, array( -1 ) ),
1445 'dynamic__days_specific' => array( 1, 1095, array() ),
1446 'dynamic__week_days__start' => array( -1, 6, array( -1 ) ),
1447 );
1448 foreach ( $list_keys as $list_key => $bounds ) {
1449 if ( array_key_exists( $list_key, $calendar_parameters ) ) {
1450 $sanitized[ $list_key ] = $this->sanitize_preview_integer_list( $calendar_parameters[ $list_key ], $bounds[0], $bounds[1], $bounds[2] );
1451 }
1452 }
1453
1454 if ( isset( $calendar_parameters['booking_recurrent_time'] ) && in_array( (string) $calendar_parameters['booking_recurrent_time'], array( 'On', 'Off' ), true ) ) {
1455 $sanitized['booking_recurrent_time'] = (string) $calendar_parameters['booking_recurrent_time'];
1456 }
1457
1458 return $sanitized;
1459 }
1460
1461 /**
1462 * Normalize Date Selection values forwarded to calendar-load requests.
1463 *
1464 * @param mixed $request_overrides Candidate request overrides.
1465 *
1466 * @return array<string,int|string> Sanitized allow-listed request values.
1467 */
1468 private function sanitize_preview_calendar_request_overrides( $request_overrides ) {
1469 if ( ! is_array( $request_overrides ) ) {
1470 return array();
1471 }
1472
1473 $sanitized = array();
1474 foreach ( array( 'wpbc_settings_calendar_preview', 'wpbc_setup_wizard_date_selection_preview' ) as $marker_key ) {
1475 if (
1476 isset( $request_overrides[ $marker_key ] )
1477 && is_scalar( $request_overrides[ $marker_key ] )
1478 && '1' === (string) $request_overrides[ $marker_key ]
1479 ) {
1480 $sanitized[ $marker_key ] = 1;
1481 }
1482 }
1483 if ( isset( $request_overrides['wpbc_setup_wizard_date_selection_preview_nonce'] ) && is_scalar( $request_overrides['wpbc_setup_wizard_date_selection_preview_nonce'] ) ) {
1484 $sanitized['wpbc_setup_wizard_date_selection_preview_nonce'] = sanitize_text_field( (string) $request_overrides['wpbc_setup_wizard_date_selection_preview_nonce'] );
1485 }
1486
1487 $toggle_keys = array(
1488 'wpbc_settings_calendar_preview_changeover',
1489 'wpbc_settings_calendar_preview_triangles',
1490 'wpbc_settings_calendar_preview_recurrent_time',
1491 'wpbc_settings_calendar_preview_last_checkout',
1492 'wpbc_settings_calendar_preview_show_legend',
1493 'wpbc_settings_calendar_preview_legend_show_numbers',
1494 'wpbc_settings_calendar_preview_legend_vertical',
1495 );
1496 foreach ( array( 'available', 'pending', 'approved', 'partially', 'unavailable' ) as $legend_item ) {
1497 $toggle_keys[] = 'wpbc_settings_calendar_preview_legend_show_' . $legend_item;
1498 }
1499 foreach ( $toggle_keys as $toggle_key ) {
1500 if ( isset( $request_overrides[ $toggle_key ] ) && is_scalar( $request_overrides[ $toggle_key ] ) && in_array( (string) $request_overrides[ $toggle_key ], array( 'On', 'Off' ), true ) ) {
1501 $sanitized[ $toggle_key ] = (string) $request_overrides[ $toggle_key ];
1502 }
1503 }
1504
1505 foreach ( array( 'available', 'pending', 'approved', 'partially', 'unavailable' ) as $legend_item ) {
1506 $text_key = 'wpbc_settings_calendar_preview_legend_text_' . $legend_item;
1507 if ( isset( $request_overrides[ $text_key ] ) && is_scalar( $request_overrides[ $text_key ] ) ) {
1508 $legend_text = sanitize_text_field( (string) $request_overrides[ $text_key ] );
1509 $sanitized[ $text_key ] = function_exists( 'mb_substr' ) ? mb_substr( $legend_text, 0, 255 ) : substr( $legend_text, 0, 255 );
1510 }
1511 }
1512
1513 return $sanitized;
1514 }
1515
1516 /**
1517 * Normalize one preview boolean without treating malformed containers as true.
1518 *
1519 * Preview context normally originates from a server-owned mapper, but the
1520 * shared transient boundary validates it again so future callers cannot turn
1521 * arrays or arbitrary strings into enabled behavior through PHP casting.
1522 *
1523 * @param mixed $raw_value Candidate boolean value.
1524 *
1525 * @return bool|null Normalized boolean, or null when the value is invalid.
1526 */
1527 private function sanitize_preview_boolean( $raw_value ) {
1528 if ( true === $raw_value || 1 === $raw_value || '1' === $raw_value || 'On' === $raw_value ) {
1529 return true;
1530 }
1531
1532 if ( false === $raw_value || 0 === $raw_value || '0' === $raw_value || 'Off' === $raw_value ) {
1533 return false;
1534 }
1535
1536 return null;
1537 }
1538
1539 /**
1540 * Normalize a scalar or array of integers against explicit bounds.
1541 *
1542 * @param mixed $raw_list Candidate array or comma-separated list.
1543 * @param int $minimum Inclusive lower bound.
1544 * @param int $maximum Inclusive upper bound.
1545 * @param int[] $fallback Fallback list when no values remain.
1546 *
1547 * @return int[] Unique normalized integers.
1548 */
1549 private function sanitize_preview_integer_list( $raw_list, $minimum, $maximum, array $fallback ) {
1550 $raw_values = is_array( $raw_list ) ? $raw_list : explode( ',', is_scalar( $raw_list ) ? (string) $raw_list : '' );
1551 $integers = array();
1552 foreach ( $raw_values as $raw_value ) {
1553 if ( ! is_scalar( $raw_value ) || ! preg_match( '/^-?\d+$/', trim( (string) $raw_value ) ) ) {
1554 continue;
1555 }
1556 $integer_value = (int) $raw_value;
1557 if ( $integer_value >= $minimum && $integer_value <= $maximum ) {
1558 $integers[] = $integer_value;
1559 }
1560 }
1561
1562 return empty( $integers ) ? $fallback : array_values( array_unique( $integers ) );
1563 }
1564
1565 /**
1566 * Validate one relative calendar skin path against the live skin registry.
1567 *
1568 * @param string $calendar_skin Candidate relative path or registered URL.
1569 *
1570 * @return string Valid relative skin path, or an empty string.
1571 */
1572 private function sanitize_preview_calendar_skin( $calendar_skin ) {
1573 if ( ! function_exists( 'wpbc_get_calendar_skin_options' ) ) {
1574 return '';
1575 }
1576
1577 $calendar_skin = $this->normalize_preview_calendar_skin( $calendar_skin );
1578 foreach ( wpbc_get_calendar_skin_options() as $registered_skin => $registered_label ) {
1579 if ( is_array( $registered_label ) && ! empty( $registered_label['optgroup'] ) ) {
1580 continue;
1581 }
1582
1583 if ( $calendar_skin === $this->normalize_preview_calendar_skin( $registered_skin ) ) {
1584 return $calendar_skin;
1585 }
1586 }
1587
1588 return '';
1589 }
1590
1591 /**
1592 * Convert a registered skin URL or filesystem path to canonical relative form.
1593 *
1594 * @param mixed $calendar_skin Calendar skin path or URL.
1595 *
1596 * @return string Normalized relative path.
1597 */
1598 private function normalize_preview_calendar_skin( $calendar_skin ) {
1599 $calendar_skin = is_scalar( $calendar_skin ) ? sanitize_text_field( (string) $calendar_skin ) : '';
1600 $replace = array( WPBC_PLUGIN_DIR, WPBC_PLUGIN_URL );
1601 $upload_dir = wp_upload_dir();
1602
1603 if ( ! empty( $upload_dir['basedir'] ) ) {
1604 $replace[] = $upload_dir['basedir'];
1605 }
1606 if ( ! empty( $upload_dir['baseurl'] ) ) {
1607 $replace[] = $upload_dir['baseurl'];
1608 }
1609
1610 return str_replace( $replace, '', $calendar_skin );
1611 }
1612
1613 public function filter_form_advanced_form_for_preview( $advanced_form, $form_id ) {
1614
1615 if ( empty( $this->current_preview_data ) ) {
1616 return $advanced_form;
1617 }
1618
1619 if ( 'appointment' !== $this->get_current_preview_render_mode() && (int) $form_id !== (int) $this->current_preview_data['form_id'] ) {
1620 return $advanced_form;
1621 }
1622
1623 if ( isset( $this->current_preview_data['advanced_form'] ) ) {
1624 return (string) $this->current_preview_data['advanced_form'];
1625 }
1626
1627 return $advanced_form;
1628 }
1629
1630 public function filter_form_content_form_for_preview( $content_form, $form_id ) {
1631
1632 if ( empty( $this->current_preview_data ) ) {
1633 return $content_form;
1634 }
1635
1636 if ( 'appointment' !== $this->get_current_preview_render_mode() && (int) $form_id !== (int) $this->current_preview_data['form_id'] ) {
1637 return $content_form;
1638 }
1639
1640 if ( isset( $this->current_preview_data['content_form'] ) ) {
1641 return (string) $this->current_preview_data['content_form'];
1642 }
1643
1644 return $content_form;
1645 }
1646
1647 /**
1648 * Filter that allows BFB structure loader to receive snapshot for preview.
1649 *
1650 * Usage example in your loader:
1651 * $structure = apply_filters( 'wpbc_bfb_get_form_structure', $structure, $form_id );
1652 *
1653 * @param array $structure Default structure loaded from DB.
1654 * @param int $form_id Booking form ID.
1655 *
1656 * @return array
1657 */
1658 public function filter_form_structure_for_preview( $structure, $form_id ) {
1659
1660 if ( empty( $this->current_preview_data ) ) {
1661 return $structure;
1662 }
1663
1664 if ( 'appointment' !== $this->get_current_preview_render_mode() && (int) $form_id !== (int) $this->current_preview_data['form_id'] ) {
1665 return $structure;
1666 }
1667
1668 if ( empty( $this->current_preview_data['structure'] ) || ! is_array( $this->current_preview_data['structure'] ) ) {
1669 return $structure;
1670 }
1671
1672 return $this->current_preview_data['structure'];
1673 }
1674
1675 /**
1676 * Apply unsaved global Form Style values to the preview iframe only.
1677 *
1678 * @param string $wrapped_html Wrapped booking form HTML.
1679 * @param array $bfb_settings BFB settings.
1680 * @param int $resource_id Booking resource ID.
1681 * @param string $custom_booking_form_name Form slug.
1682 * @return string
1683 */
1684 public function filter_wrapped_html_for_preview_form_style( $wrapped_html, $bfb_settings, $resource_id, $custom_booking_form_name ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
1685
1686 $preview_style = $this->get_preview_form_style();
1687 if ( empty( $preview_style ) ) {
1688 return $wrapped_html;
1689 }
1690
1691 $style = isset( $preview_style['booking_form_style'] ) ? wpbc_bfb_settings__sanitize_form_style( $preview_style['booking_form_style'] ) : wpbc_bfb_settings__get_default_form_style();
1692 $preset = wpbc_bfb_settings__get_form_style_preset( $style );
1693
1694 $wrapped_html = $this->remove_classes_from_first_form_wrapper(
1695 $wrapped_html,
1696 array(
1697 'wpbc_theme_dark_1',
1698 'wpbc_bfb_form_appearance_custom',
1699 )
1700 );
1701 $wrapped_html = $this->remove_classes_from_first_tag_with_classes(
1702 $wrapped_html,
1703 array( 'wpbc_bfb_form' ),
1704 array(
1705 'wpbc_theme_dark_1',
1706 'wpbc_bfb_form_appearance_custom',
1707 )
1708 );
1709
1710 $theme_class = isset( $preset['theme_class'] ) ? sanitize_html_class( (string) $preset['theme_class'] ) : '';
1711 if ( '' !== $theme_class ) {
1712 $wrapped_html = WPBC_FE_Form_Style_Injector::add_class_to_first_tag_with_classes( $wrapped_html, array( 'wpbc_container', 'wpbc_form' ), $theme_class );
1713 }
1714
1715 $css_vars = wpbc_bfb_settings__get_form_style_css_vars( $style, $preview_style );
1716 if ( ! empty( $css_vars ) ) {
1717 $wrapped_html = WPBC_FE_Form_Style_Injector::inject_css_vars_into_form_wrapper( $wrapped_html, $css_vars );
1718 $wrapped_html = WPBC_FE_Form_Style_Injector::inject_css_vars_into_bfb_root( $wrapped_html, $css_vars );
1719 }
1720
1721 if ( wpbc_bfb_settings__is_custom_form_style( $style ) ) {
1722 $wrapped_html = WPBC_FE_Form_Style_Injector::add_class_to_first_tag_with_classes( $wrapped_html, array( 'wpbc_container', 'wpbc_form' ), 'wpbc_bfb_form_appearance_custom' );
1723 $wrapped_html = WPBC_FE_Form_Style_Injector::add_class_to_first_tag_with_classes( $wrapped_html, array( 'wpbc_bfb_form' ), 'wpbc_bfb_form_appearance_custom' );
1724 }
1725
1726 return $wrapped_html;
1727 }
1728
1729 /**
1730 * Get sanitized preview Form Style override from the current transient data.
1731 *
1732 * @return array
1733 */
1734 protected function get_preview_form_style() {
1735
1736 if ( empty( $this->current_preview_data['form_style'] ) || ! is_array( $this->current_preview_data['form_style'] ) ) {
1737 return array();
1738 }
1739
1740 $style = isset( $this->current_preview_data['form_style']['booking_form_style'] ) ? $this->current_preview_data['form_style']['booking_form_style'] : '';
1741 $style = wpbc_bfb_settings__sanitize_form_style( $style );
1742
1743 $custom_options = wpbc_bfb_settings__get_custom_form_style_options( $this->current_preview_data['form_style'] );
1744 $accent_options = wpbc_bfb_settings__get_form_accent_options( $this->current_preview_data['form_style'] );
1745
1746 return array_merge(
1747 array(
1748 'booking_form_style' => $style,
1749 ),
1750 $custom_options,
1751 $accent_options
1752 );
1753 }
1754
1755 /**
1756 * Remove classes from the first outer booking form wrapper.
1757 *
1758 * @param string $html HTML.
1759 * @param array $class_names Class names to remove.
1760 * @return string
1761 */
1762 protected function remove_classes_from_first_form_wrapper( $html, $class_names ) {
1763
1764 return $this->remove_classes_from_first_tag_with_classes( $html, array( 'wpbc_container', 'wpbc_form' ), $class_names );
1765 }
1766
1767 /**
1768 * Remove classes from the first tag that has all required classes.
1769 *
1770 * @param string $html HTML.
1771 * @param array $required_classes Required classes.
1772 * @param array $class_names Class names to remove.
1773 * @return string
1774 */
1775 protected function remove_classes_from_first_tag_with_classes( $html, $required_classes, $class_names ) {
1776
1777 $html = (string) $html;
1778 $required_classes = is_array( $required_classes ) ? $required_classes : array();
1779 $class_names = is_array( $class_names ) ? $class_names : array();
1780 $remove_map = array();
1781 $required_map = array();
1782
1783 foreach ( $required_classes as $class_name ) {
1784 $class_name = sanitize_html_class( (string) $class_name );
1785 if ( '' !== $class_name ) {
1786 $required_map[ $class_name ] = true;
1787 }
1788 }
1789
1790 foreach ( $class_names as $class_name ) {
1791 $class_name = sanitize_html_class( (string) $class_name );
1792 if ( '' !== $class_name ) {
1793 $remove_map[ $class_name ] = true;
1794 }
1795 }
1796
1797 if ( empty( $remove_map ) || empty( $required_map ) ) {
1798 return $html;
1799 }
1800
1801 $done = false;
1802 $out = preg_replace_callback(
1803 '/<div\b[^>]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i',
1804 function ( $matches ) use ( &$done, $remove_map, $required_map ) {
1805
1806 $tag = $matches[0];
1807 if ( $done ) {
1808 return $tag;
1809 }
1810
1811 $quote = $matches[1];
1812 $classes = preg_split( '/\s+/', trim( (string) $matches[2] ) );
1813 $classes = is_array( $classes ) ? $classes : array();
1814
1815 foreach ( $required_map as $required_class => $unused ) {
1816 if ( ! in_array( $required_class, $classes, true ) ) {
1817 return $tag;
1818 }
1819 }
1820
1821 $filtered = array();
1822 foreach ( $classes as $class_name ) {
1823 if ( '' === $class_name || isset( $remove_map[ $class_name ] ) ) {
1824 continue;
1825 }
1826 $filtered[] = $class_name;
1827 }
1828
1829 $done = true;
1830
1831 return preg_replace( '/\bclass\s*=\s*(["\'])(.*?)\1/i', 'class=' . $quote . esc_attr( implode( ' ', $filtered ) ) . $quote, $tag, 1 );
1832 },
1833 $html
1834 );
1835
1836 return ( null === $out ) ? $html : $out;
1837 }
1838
1839 /**
1840 * Hide the preview page from the Pages list in admin.
1841 *
1842 * @param WP_Query $query Main query.
1843 */
1844 public function hide_preview_page_in_admin_list( $query ) {
1845
1846 if ( ! is_admin() || ! $query->is_main_query() ) {
1847 return;
1848 }
1849
1850 global $pagenow;
1851
1852 if ( 'edit.php' !== $pagenow ) {
1853 return;
1854 }
1855
1856 $post_type = $query->get( 'post_type' );
1857
1858 if ( 'page' !== $post_type && '' !== $post_type ) {
1859 return;
1860 }
1861
1862 $page_id = $this->get_preview_page_id();
1863
1864 if ( $page_id <= 0 ) {
1865 return;
1866 }
1867
1868 $not_in = (array) $query->get( 'post__not_in' );
1869 $not_in[] = (int) $page_id;
1870
1871 $query->set( 'post__not_in', $not_in );
1872 }
1873
1874 /**
1875 * Render one inline preview and return its data-only browser bootstrap.
1876 *
1877 * This is the reusable response boundary for authenticated administration
1878 * screens. The HTML is rendered by the normal front-end renderer, while all
1879 * script elements are removed. The browser receives only JSON-safe values and
1880 * initializes the form through known Booking Calendar functions; response
1881 * JavaScript is never evaluated.
1882 *
1883 * The caller must resolve templates from a server-owned allow-list and must
1884 * perform its own capability and nonce checks before returning this payload.
1885 *
1886 * @param int $preview_form_id Preview resource/calendar ID.
1887 * @param array $structure Decoded BFB structure.
1888 * @param string $form_name Form slug rendered by the booking form.
1889 * @param string $advanced_form Exported booking form source.
1890 * @param string $content_form Exported booking-data content source.
1891 * @param array $form_style Optional unsaved Form Style settings.
1892 * @param array $preview_context Optional server-owned renderer and option context.
1893 *
1894 * @return array<string,mixed>|false Inline HTML and bootstrap data, or false on failure.
1895 */
1896 public function render_inline_preview_payload( $preview_form_id, $structure, $form_name = 'standard', $advanced_form = '', $content_form = '', $form_style = array(), $preview_context = array() ) {
1897 $preview_form_id = is_scalar( $preview_form_id ) ? absint( $preview_form_id ) : 0;
1898 $preview_form_id = $preview_form_id > 0 ? $preview_form_id : 1;
1899 $form_name = is_scalar( $form_name ) ? sanitize_key( (string) $form_name ) : '';
1900 $form_name = '' === $form_name ? 'standard' : $form_name;
1901 $preview_html = $this->render_inline_preview(
1902 $preview_form_id,
1903 $structure,
1904 $form_name,
1905 $advanced_form,
1906 $content_form,
1907 $form_style,
1908 $preview_context
1909 );
1910
1911 if ( ! is_string( $preview_html ) || '' === trim( $preview_html ) ) {
1912 return false;
1913 }
1914
1915 return array(
1916 'html' => $this->remove_script_elements( $preview_html ),
1917 'bootstrap' => $this->get_inline_preview_bootstrap_data( $preview_form_id, $form_name, $preview_context ),
1918 );
1919 }
1920
1921 /**
1922 * Remove executable elements from renderer HTML before an AJAX response.
1923 *
1924 * The normal front-end renderer can return a legacy inline calendar bootstrap
1925 * during AJAX requests. Inline previews use the structured bootstrap returned
1926 * beside the HTML, so no script element is needed or permitted in this path.
1927 *
1928 * @param string $preview_html Server-rendered preview HTML.
1929 *
1930 * @return string Preview HTML without script elements.
1931 */
1932 private function remove_script_elements( $preview_html ) {
1933 $preview_html = is_string( $preview_html ) ? $preview_html : '';
1934 $preview_html = preg_replace( '#<script\b[^>]*>.*?</script\s*>#is', '', $preview_html );
1935
1936 return is_string( $preview_html ) ? $preview_html : '';
1937 }
1938
1939 /**
1940 * Build the explicit JSON-safe bootstrap contract for an inline form.
1941 *
1942 * @param int $preview_form_id Positive booking resource ID.
1943 * @param string $form_name Validated Form Builder slug.
1944 * @param array<string,mixed> $preview_context Optional server-owned calendar context.
1945 *
1946 * @return array<string,mixed> Allow-listed Booking Calendar initialization data.
1947 */
1948 private function get_inline_preview_bootstrap_data( $preview_form_id, $form_name, array $preview_context = array() ) {
1949 $days_selection = $this->get_inline_preview_days_selection();
1950 $balancer_max_threads = absint( get_bk_option( 'booking_load_balancer_max_threads' ) );
1951 $balancer_max_threads = $balancer_max_threads > 0 ? $balancer_max_threads : 1;
1952 $is_enabled_change_over = function_exists( 'wpbc_is_booking_used_check_in_out_time' )
1953 ? (bool) wpbc_is_booking_used_check_in_out_time( false, $preview_form_id )
1954 : false;
1955 $range_guidance_default = function_exists( 'wpbc_frontend_messages__is_enabled' )
1956 ? wpbc_frontend_messages__is_enabled( 'message_range_selection_click_last_date' )
1957 : true;
1958 $range_guidance_enabled = (bool) apply_filters(
1959 'wpbc_calendar_range_selection_guidance_is_enabled',
1960 $range_guidance_default,
1961 $preview_form_id,
1962 array(
1963 'resource_id' => $preview_form_id,
1964 'custom_form' => $form_name,
1965 )
1966 );
1967 $request_uri = '';
1968
1969 if ( isset( $_SERVER['REQUEST_URI'] ) && is_scalar( $_SERVER['REQUEST_URI'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1970 $request_uri = esc_url_raw( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1971 }
1972
1973 $calendar_parameters = array(
1974 'is_enabled_change_over' => $is_enabled_change_over,
1975 'calendar_scroll_to' => false,
1976 'calendar_dates_start' => '',
1977 'calendar_dates_end' => '',
1978 'booking_max_monthes_in_calendar' => (string) get_bk_option( 'booking_max_monthes_in_calendar' ),
1979 'booking_start_day_weeek' => (string) get_bk_option( 'booking_start_day_weeek' ),
1980 'calendar_number_of_months' => '1',
1981 'days_select_mode' => (string) $days_selection['days_select_mode'],
1982 'fixed__days_num' => (int) $days_selection['fixed__days_num'],
1983 'fixed__week_days__start' => $this->normalize_inline_preview_integer_list( $days_selection['fixed__week_days__start'], array( -1 ) ),
1984 'dynamic__days_min' => (int) $days_selection['dynamic__days_min'],
1985 'dynamic__days_max' => (int) $days_selection['dynamic__days_max'],
1986 'dynamic__days_specific' => $this->normalize_inline_preview_integer_list( $days_selection['dynamic__days_specific'], array() ),
1987 'dynamic__week_days__start' => $this->normalize_inline_preview_integer_list( $days_selection['dynamic__week_days__start'], array( -1 ) ),
1988 'range_selection_guidance_is_enabled' => $range_guidance_enabled,
1989 'booking_date_format' => (string) get_bk_option( 'booking_date_format' ),
1990 'booking_time_format' => (string) get_bk_option( 'booking_time_format' ),
1991 );
1992
1993 if ( class_exists( 'wpdev_bk_biz_l' ) ) {
1994 $calendar_parameters['is_parent_resource'] = function_exists( 'wpbc_get_child_resources_number' ) && wpbc_get_child_resources_number( $preview_form_id ) ? 1 : 0;
1995 $calendar_parameters['booking_capacity_field'] = function_exists( 'wpbc_get__booking_capacity_field__name' ) ? (string) wpbc_get__booking_capacity_field__name() : '';
1996 $calendar_parameters['booking_is_dissbale_booking_for_different_sub_resources'] = (string) get_bk_option( 'booking_is_dissbale_booking_for_different_sub_resources' );
1997 }
1998
1999 if ( class_exists( 'wpdev_bk_biz_s' ) ) {
2000 $calendar_parameters['booking_recurrent_time'] = (string) get_bk_option( 'booking_recurrent_time' );
2001 }
2002
2003 $preview_calendar_parameters = isset( $preview_context['calendar_parameters'] )
2004 ? $this->sanitize_preview_calendar_parameters( $preview_context['calendar_parameters'] )
2005 : array();
2006 $calendar_parameters = array_replace( $calendar_parameters, $preview_calendar_parameters );
2007 $is_enabled_change_over = isset( $calendar_parameters['is_enabled_change_over'] )
2008 ? (bool) $calendar_parameters['is_enabled_change_over']
2009 : $is_enabled_change_over;
2010 $calendar_request_overrides = isset( $preview_context['calendar_request_overrides'] )
2011 ? $this->sanitize_preview_calendar_request_overrides( $preview_context['calendar_request_overrides'] )
2012 : array();
2013
2014 return array(
2015 'balancer_max_threads' => $balancer_max_threads,
2016 'is_enabled_change_over' => $is_enabled_change_over,
2017 'classic_booking_context_token' => '',
2018 'calendar_parameters' => $calendar_parameters,
2019 'secure_parameters' => array(
2020 'nonce' => wp_create_nonce( 'wpbc_calendar_load_ajx_wpbcnonce' ),
2021 'user_id' => function_exists( 'wpbc_get_current_user_id' ) ? (string) wpbc_get_current_user_id() : (string) get_current_user_id(),
2022 'locale' => (string) get_user_locale(),
2023 ),
2024 'calendar_request' => array_merge(
2025 array(
2026 'resource_id' => $preview_form_id,
2027 'booking_hash' => '',
2028 'request_uri' => $request_uri,
2029 'custom_form' => $form_name,
2030 'aggregate_resource_id_str' => '',
2031 'aggregate_type' => 'all',
2032 'skip_general_availability' => 0,
2033 'classic_booking_context_token' => '',
2034 ),
2035 $calendar_request_overrides
2036 ),
2037 );
2038 }
2039
2040 /**
2041 * Return normalized day-selection options for the inline calendar contract.
2042 *
2043 * @return array<string,mixed> Day-selection values.
2044 */
2045 private function get_inline_preview_days_selection() {
2046 $defaults = array(
2047 'days_select_mode' => 'multiple',
2048 'fixed__days_num' => 0,
2049 'fixed__week_days__start' => '-1',
2050 'dynamic__days_min' => 0,
2051 'dynamic__days_max' => 0,
2052 'dynamic__days_specific' => '',
2053 'dynamic__week_days__start' => '-1',
2054 );
2055 $days_selection = function_exists( 'wpbc__calendar__js_params__get_days_selection_arr' )
2056 ? wpbc__calendar__js_params__get_days_selection_arr()
2057 : array();
2058
2059 return wp_parse_args( is_array( $days_selection ) ? $days_selection : array(), $defaults );
2060 }
2061
2062 /**
2063 * Convert a stored comma list into bounded JSON-safe integers.
2064 *
2065 * @param mixed $raw_list Candidate array or comma-separated list.
2066 * @param array $fallback Fallback list when no integers are present.
2067 *
2068 * @return int[] Normalized integers.
2069 */
2070 private function normalize_inline_preview_integer_list( $raw_list, array $fallback ) {
2071 $raw_values = is_array( $raw_list ) ? $raw_list : explode( ',', (string) $raw_list );
2072 $integers = array();
2073
2074 foreach ( $raw_values as $raw_value ) {
2075 if ( ! is_scalar( $raw_value ) || ! preg_match( '/^-?\d+$/', trim( (string) $raw_value ) ) ) {
2076 continue;
2077 }
2078
2079 $integers[] = (int) $raw_value;
2080 }
2081
2082 return empty( $integers ) ? $fallback : array_values( array_unique( $integers ) );
2083 }
2084
2085 /**
2086 * Render one unsaved Form Builder snapshot directly in the current request.
2087 *
2088 * This is the synchronous counterpart to {@see create_preview_session()} for
2089 * administration screens that already load the Booking Calendar front-end
2090 * assets. It uses the same source, markup, option, and Form Style filters but
2091 * creates no transient, page, post, or saved Form Builder record. All filters
2092 * and request-scoped state are removed before returning.
2093 *
2094 * The caller must resolve template definitions from a server-owned allow-list
2095 * before invoking this method. Raw request values must never be passed here.
2096 *
2097 * @param int $preview_form_id Preview resource/calendar ID.
2098 * @param array $structure Decoded BFB structure.
2099 * @param string $form_name Form slug rendered by the booking form.
2100 * @param string $advanced_form Exported booking form source.
2101 * @param string $content_form Exported booking-data content source.
2102 * @param array $form_style Optional unsaved Form Style settings.
2103 * @param array $preview_context Optional server-owned renderer and option context.
2104 *
2105 * @return string|false Rendered booking form HTML, or false when validation or rendering fails.
2106 */
2107 public function render_inline_preview( $preview_form_id, $structure, $form_name = 'standard', $advanced_form = '', $content_form = '', $form_style = array(), $preview_context = array() ) {
2108
2109 if ( ! class_exists( 'WPBC_FE_Render' ) || null !== $this->current_preview_data ) {
2110 return false;
2111 }
2112
2113 $preview_form_id = is_scalar( $preview_form_id ) ? absint( $preview_form_id ) : 0;
2114 $preview_form_id = $preview_form_id > 0 ? $preview_form_id : 1;
2115 $structure = $this->sanitize_preview_structure( $structure );
2116 $form_name = is_scalar( $form_name ) ? sanitize_text_field( (string) $form_name ) : '';
2117 $advanced_form = $this->sanitize_preview_form_source( $advanced_form );
2118 $content_form = $this->sanitize_preview_form_source( $content_form );
2119 $form_style = $this->sanitize_preview_form_style( $form_style );
2120 $preview_context = is_array( $preview_context ) ? $preview_context : array();
2121 $render_mode = isset( $preview_context['render_mode'] )
2122 && is_scalar( $preview_context['render_mode'] )
2123 && 'appointment' === sanitize_key( (string) $preview_context['render_mode'] )
2124 ? 'appointment'
2125 : 'booking';
2126 $option_overrides = isset( $preview_context['option_overrides'] )
2127 ? $this->sanitize_preview_option_overrides( $preview_context['option_overrides'] )
2128 : array();
2129 $calendar_parameters = isset( $preview_context['calendar_parameters'] )
2130 ? $this->sanitize_preview_calendar_parameters( $preview_context['calendar_parameters'] )
2131 : array();
2132 $calendar_request_overrides = isset( $preview_context['calendar_request_overrides'] )
2133 ? $this->sanitize_preview_calendar_request_overrides( $preview_context['calendar_request_overrides'] )
2134 : array();
2135
2136 if (
2137 null === $structure
2138 || null === $advanced_form
2139 || null === $content_form
2140 || null === $form_style
2141 ) {
2142 return false;
2143 }
2144
2145 $form_name = '' === $form_name ? 'standard' : $form_name;
2146 $this->current_preview_data = array(
2147 'form_id' => $preview_form_id,
2148 'resource_id' => $preview_form_id,
2149 'form_name' => $form_name,
2150 'scope' => 'inline_preview',
2151 'render_mode' => $render_mode,
2152 'structure' => $structure,
2153 'advanced_form' => $advanced_form,
2154 'content_form' => $content_form,
2155 'form_style' => $form_style,
2156 'option_overrides' => $option_overrides,
2157 'calendar_parameters' => $calendar_parameters,
2158 'calendar_request_overrides' => $calendar_request_overrides,
2159 );
2160
2161 $this->register_preview_source_filters();
2162
2163 try {
2164 $preview_html = WPBC_FE_Render::render_booking_form(
2165 array(
2166 'resource_id' => $preview_form_id,
2167 'cal_count' => 1,
2168 'is_echo' => 0,
2169 'custom_booking_form' => $form_name,
2170 'form_status' => 'preview',
2171 'calendar_request_overrides' => $calendar_request_overrides,
2172 )
2173 );
2174 $preview_html = is_string( $preview_html ) ? $preview_html : '';
2175
2176 return $this->filter_wrapped_html_for_preview_form_style( $preview_html, array(), $preview_form_id, $form_name );
2177 } finally {
2178 $this->remove_preview_source_filters();
2179 $this->remove_preview_option_filter();
2180 $this->current_preview_data = null;
2181 }
2182 }
2183
2184 // FixIn: 2026-01-03 14:31.
2185 /**
2186 * Create a preview session: store transient snapshot and return preview URL + token.
2187 *
2188 * @param int $preview_form_id Preview resource/calendar ID.
2189 * @param int $user_id Current user ID.
2190 * @param array $structure Decoded BFB structure.
2191 * @param string $form_name Form slug rendered by the booking shortcode.
2192 * @param string $advanced_form Exported booking form source.
2193 * @param string $content_form Exported booking-data content source.
2194 * @param array $form_style Optional unsaved Form Style settings.
2195 * @param array $preview_context Optional server-owned renderer and option context.
2196 *
2197 * @return array|false Preview URL and token, or false on failure.
2198 */
2199 public function create_preview_session( $preview_form_id, $user_id, $structure, $form_name = 'standard', $advanced_form = '', $content_form = '', $form_style = array(), $preview_context = array() ) {
2200
2201 $preview_form_id = is_scalar( $preview_form_id ) ? absint( $preview_form_id ) : 0;
2202 $user_id = is_scalar( $user_id ) ? absint( $user_id ) : 0;
2203 $authenticated_user_id = get_current_user_id();
2204
2205 if ( $preview_form_id <= 0 ) {
2206 $preview_form_id = 1;
2207 }
2208 if ( $authenticated_user_id <= 0 || $user_id !== $authenticated_user_id ) {
2209 return false;
2210 }
2211 $user_id = $authenticated_user_id;
2212
2213 $structure = $this->sanitize_preview_structure( $structure );
2214 $form_name = is_scalar( $form_name ) ? sanitize_text_field( (string) $form_name ) : '';
2215 $advanced_form = $this->sanitize_preview_form_source( $advanced_form );
2216 $content_form = $this->sanitize_preview_form_source( $content_form );
2217 $form_style = $this->sanitize_preview_form_style( $form_style );
2218 $preview_context = is_array( $preview_context ) ? $preview_context : array();
2219 $render_mode = isset( $preview_context['render_mode'] )
2220 && is_scalar( $preview_context['render_mode'] )
2221 && 'appointment' === sanitize_key( (string) $preview_context['render_mode'] )
2222 ? 'appointment'
2223 : 'booking';
2224 $option_overrides = isset( $preview_context['option_overrides'] )
2225 ? $this->sanitize_preview_option_overrides( $preview_context['option_overrides'] )
2226 : array();
2227 $calendar_parameters = isset( $preview_context['calendar_parameters'] )
2228 ? $this->sanitize_preview_calendar_parameters( $preview_context['calendar_parameters'] )
2229 : array();
2230 $calendar_request_overrides = isset( $preview_context['calendar_request_overrides'] )
2231 ? $this->sanitize_preview_calendar_request_overrides( $preview_context['calendar_request_overrides'] )
2232 : array();
2233
2234 if (
2235 null === $structure
2236 || null === $advanced_form
2237 || null === $content_form
2238 || null === $form_style
2239 ) {
2240 return false;
2241 }
2242
2243 if ( '' === $form_name ) {
2244 $form_name = 'standard';
2245 }
2246
2247 $page_id = $this->get_preview_page_id();
2248 if ( ! $page_id ) {
2249 return false;
2250 }
2251
2252 $token = sanitize_key( wp_generate_password( 24, false, false ) );
2253 if ( '' === $token ) {
2254 return false;
2255 }
2256
2257 $transient_key = $this->get_transient_key( $user_id, $token, $preview_form_id );
2258
2259 $payload = array(
2260 'user_id' => $user_id,
2261 // Keep form_id for the existing URL/transient and booking-submit contract.
2262 'form_id' => $preview_form_id,
2263 'resource_id' => $preview_form_id,
2264 'form_name' => $form_name,
2265 'scope' => 'preview',
2266 'render_mode' => $render_mode,
2267 'structure' => $structure,
2268 'time' => time(),
2269 'advanced_form' => $advanced_form,
2270 'content_form' => $content_form,
2271 'form_style' => $form_style,
2272 'option_overrides' => $option_overrides,
2273 'calendar_parameters' => $calendar_parameters,
2274 'calendar_request_overrides' => $calendar_request_overrides,
2275 );
2276
2277 if ( ! set_transient( $transient_key, $payload, 10 * MINUTE_IN_SECONDS ) ) {
2278 return false;
2279 }
2280
2281 $preview_url = add_query_arg( array(
2282 'wpbc_bfb_preview' => 1,
2283 'wpbc_bfb_preview_token' => rawurlencode( $token ),
2284 'wpbc_bfb_preview_form_id' => $preview_form_id,
2285 'nonce' => wp_create_nonce( 'wpbc_bfb_preview_' . $token ),
2286 ), get_permalink( $page_id ) );
2287
2288 return array(
2289 'preview_url' => esc_url_raw( $preview_url ),
2290 'token' => $token,
2291 );
2292 }
2293
2294 }
2295
2296
2297 /**
2298 * Render BFB Preview Panel in the Builder admin page.
2299 *
2300 * @param int $form_id Current booking form / resource ID.
2301 */
2302 function wpbc_bfb_render_preview_panel( $form_id = 1 ) {
2303
2304 $form_id = absint( $form_id );
2305 if ( $form_id <= 0 ) {
2306 $form_id = 1;
2307 }
2308
2309 $preview_nonce = wp_create_nonce( 'wpbc_bfb_preview' );
2310 ?>
2311 <div id="wpbc_bfb__preview_panel"
2312 class="wpbc_bfb__preview_panel"
2313 data-wpbc-bfb-preview-root="1"
2314 data-form-id="<?php echo esc_attr( $form_id ); ?>"
2315 data-preview-nonce="<?php echo esc_attr( $preview_nonce ); ?>">
2316
2317 <div class="wpbc_bfb__preview_panel__toolbar">
2318 <h1 class="wpbc_settings_page_header_title wpbc_bfb_ui__elements_show_in_preview">
2319 <?php esc_html_e( 'Booking Form Preview', 'booking' ); ?>
2320 </h1>
2321 <span class="description">
2322 <?php esc_html_e( 'The preview shows the live booking form inside your site theme.', 'booking' ); ?>
2323 </span>
2324 <style type="text/css">
2325 html[data-wpbc-bfb-live-source="advanced"] .warning_description {
2326 display: block;
2327 }
2328 .warning_description {
2329 display: none;
2330 width: 700px;
2331 color: #900;
2332 width: 630px;
2333 color: #44270d;
2334 text-align: center;
2335 font-size: 14px;
2336 line-height: 2;
2337 background: #fff;
2338 padding: 10px;
2339 padding: 0.75em 2em;
2340 margin: 20px auto 0;
2341 border-radius: 8px;
2342 border: 2px solid #b97131;
2343 }
2344 .warning_description .wpbc_bfb__live_status__advanced {
2345 font-weight: 550;
2346 display: inline !important;
2347 }
2348 </style>
2349 <div class="warning_description">
2350 <?php
2351 /* translators: 1: <strong>, 2: </strong>, 3: <a ...>, 4: </a>, 5: <a ...>, 6: </a> */
2352 printf( __( '%1$sNote!%2$s This preview is generated from %3$sAdvanced (manual)%4$s, which is not synced with the Form Builder. To sync it, enable the corresponding checkbox %5$shere%6$s.', 'booking' ), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
2353 '<strong>',
2354 '</strong>',
2355 '<a class="wpbc_bfb__live_status__advanced" href="javascript:void(0);" onclick="javascript:WPBC_BFB_Preview.show_advanced_tab({});">',
2356 '</a>',
2357 '<a href="javascript:void(0);" onclick="javascript:WPBC_BFB_Preview.show_advanced_tab({});">',
2358 '</a>'
2359 );
2360 ?>
2361 </div>
2362 </div>
2363
2364 <div class="wpbc_bfb__preview_panel__frame_wrapper">
2365 <div class="wpbc_bfb__preview_loader" data-wpbc-bfb-preview-loader="1" aria-hidden="true">
2366 <?php
2367 wpbc_bfb_spins_loading_container_mini();
2368 ?>
2369 </div>
2370
2371 <iframe
2372 id="wpbc_bfb__preview_iframe"
2373 class="wpbc_bfb__preview_iframe"
2374 data-wpbc-bfb-preview-iframe="1"
2375 src="about:blank"
2376 title="<?php esc_attr_e( 'Booking form preview', 'booking' ); ?>"
2377 loading="lazy"
2378 referrerpolicy="no-referrer-when-downgrade">
2379 </iframe>
2380 </div>
2381 </div>
2382 <?php
2383 }
2384
2385 /**
2386 * Show Loader Spin.
2387 *
2388 * @return void
2389 */
2390 function wpbc_bfb_spins_loading_container() {
2391 ?>
2392 <div class="wpbc_spins_loading_container">
2393 <div class="wpbc_booking_form_spin_loader">
2394 <div class="wpbc_spins_loader_wrapper">
2395 <div class="wpbc_spin_loader_one_new"></div>
2396 </div>
2397 </div>
2398 <span><?php echo esc_html__( 'Loading', 'booking' ); ?> ...</span>
2399 </div>
2400 <?php
2401 }
2402
2403
2404 /**
2405 * Show Mini Loader Spin.
2406 *
2407 * @return void
2408 */
2409 function wpbc_bfb_spins_loading_container_mini() {
2410 ?>
2411 <div class="wpbc_spins_loading_container wpbc_bfb_spins_loading_container">
2412 <div class="wpbc_booking_form_spin_loader">
2413 <div class="wpbc_spins_loader_wrapper">
2414 <div class="wpbc_one_spin_loader_mini2"></div>
2415 </div>
2416 </div>
2417 <span><?php esc_html_e( 'Loading', 'booking' ); ?>...</span>
2418 </div>
2419 <?php
2420 }
2421