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
← All changes | includes/page-form-builder/preview/bfb-preview.php +1615 -147 10.15.6 → 11.9 View file →
@@ -1,9 +1,10 @@
1 1 <?php
2 2 /**
3 3 * Booking Form Builder - Preview Service (Option A).
4 4 *
5 - * - Creates and manages a single private "Booking Form Preview" page.
5 + * - Creates and manages a single private "Booking Form Preview" page.
6 + * - Can render a sanitized unsaved snapshot directly in an authenticated admin request.
6 7 * - Handles AJAX to store a temporary snapshot of the current BFB structure.
7 8 * - Builds a secure preview URL for that page and injects the real booking shortcode.
8 9 * - Optionally exposes a filter hook to let BFB override form structure from snapshot.
9 10 *
@@ -32,12 +33,12 @@
32 33 structure
33 34 advanced_form
34 35 content_form
35 36 Then it returns a secure URL, and the iframe loads that page.
36 - On the preview page request, your service injects the booking shortcode and applies filters:
37 - wpbc_bfb_get_form_structure
38 - wpbc_bfb_get_form_advanced_form
39 - wpbc_bfb_get_form_content_form
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.
40 41 */
41 42
42 43 if ( ! defined( 'ABSPATH' ) ) {
43 44 exit;
@@ -59,9 +60,92 @@
59 60 * Currently active preview data for this request (if any).
60 61 *
61 62 * @var array|null
62 63 */
63 - protected $current_preview_data = null;
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 + }
64 148
65 149 /**
66 150 * Get singleton instance.
67 151 *
@@ -94,13 +178,74 @@
94 178
95 179 // Enqueue JS/CSS on Builder admin page.
96 180 add_action( 'wpbc_enqueue_js_files_on_page_done', array( $this, 'enqueue_js_files' ) );
97 181
98 - // Hide admin bar in preview iframe only.
99 - add_action( 'after_setup_theme', array( $this, 'maybe_hide_admin_bar_for_preview' ) );
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 );
100 191
101 - add_filter( 'wp_robots', array( $this, 'add_noindex_to_preview_page' ), 99 );
102 - }
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 + }
103 248
104 249 /**
105 250 * Ensure that the preview page exists and is stored in options.
106 251 *
@@ -150,9 +295,9 @@
150 295 }
151 296
152 297 public function add_noindex_to_preview_page( $robots ) {
153 298
154 - if ( ! is_page() ) {
299 + if ( ! $this->is_main_query_page() ) {
155 300 return $robots;
156 301 }
157 302
158 303 $page_id = (int) get_queried_object_id();
@@ -167,8 +312,19 @@
167 312 return $robots;
168 313 }
169 314
170 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 + /**
171 327 * Resolve capability used for preview / Builder access.
172 328 *
173 329 * Uses wpbc_bfb_get_manage_cap() when available, falls back to manage_options.
174 330 *
@@ -186,21 +342,25 @@
186 342 * Check if current request is a BFB preview request.
187 343 *
188 344 * @return bool
189 345 */
190 - protected function is_preview_request() {
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 + }
191 362
192 - // Quick GET check (works before main query is parsed).
193 - if ( isset( $_GET['wpbc_bfb_preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
194 - return true;
195 - }
196 -
197 - // Fallback for places where query vars are already set.
198 - $is_preview = get_query_var( 'wpbc_bfb_preview' );
199 -
200 - return ! empty( $is_preview );
201 - }
202 -
203 363 /**
204 364 * Hide admin toolbar on front-end preview requests.
205 365 *
206 366 * This affects ONLY the preview page loaded in the iframe.
@@ -226,9 +386,9 @@
226 386 // Disable admin bar for this request only.
227 387 show_admin_bar( false );
228 388 }
229 389
230 - public function enqueue_js_files() {
390 + public function enqueue_js_files() {
231 391 if ( ! is_admin() ) {
232 392 return;
233 393 }
234 394 // BFB preview script on the Builder admin page.
@@ -235,9 +395,9 @@
235 395 wp_enqueue_script(
236 396 'wpbc-bfb-preview',
237 397 wpbc_plugin_url( '/includes/page-form-builder/preview/_out/bfb-preview.js' ),
238 398 array( 'wpbc-bfb_builder' ),
239 - '1.0.0',
399 + WP_BK_VERSION_NUM,
240 400 true
241 401 );
242 402 wp_enqueue_style(
243 403 'wpbc-bfb-preview-mode',
@@ -305,74 +465,261 @@
305 465 * Try to load preview data from current request (front-end).
306 466 *
307 467 * @return array|null
308 468 */
309 - protected function get_preview_data_from_request() {
469 + protected function get_preview_data_from_request() {
310 470
311 471 if ( null !== $this->current_preview_data ) {
312 472 return $this->current_preview_data;
313 473 }
314 474
315 - // Check query flag.
316 - $is_preview = get_query_var( 'wpbc_bfb_preview' );
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 + }
317 485
318 - if ( empty( $is_preview ) && ! isset( $_GET['wpbc_bfb_preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
319 - return null;
320 - }
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 + : '';
321 506
322 - $cap = $this->get_manage_cap();
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 + }
323 721
324 - if ( ! is_user_logged_in() || ! current_user_can( $cap ) ) {
325 - return null;
326 - }
327 -
328 - $token = get_query_var( 'wpbc_bfb_preview_token' );
329 - $form_id = absint( get_query_var( 'wpbc_bfb_preview_form_id' ) );
330 -
331 - if ( empty( $token ) ) {
332 - $token = isset( $_GET['wpbc_bfb_preview_token'] ) ? sanitize_text_field( wp_unslash( $_GET['wpbc_bfb_preview_token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
333 - }
334 -
335 - if ( empty( $form_id ) ) {
336 - $form_id = isset( $_GET['wpbc_bfb_preview_form_id'] ) ? absint( wp_unslash( $_GET['wpbc_bfb_preview_form_id'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
337 - }
338 -
339 - $nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
340 -
341 - if ( empty( $token ) || empty( $form_id ) ) {
342 - return null;
343 - }
344 -
345 - if ( ! wp_verify_nonce( $nonce, 'wpbc_bfb_preview_' . $token ) ) {
346 - return null;
347 - }
348 -
349 - $user_id = wpbc_get_current_user_id();
350 -
351 - if ( $user_id <= 0 ) {
352 - return null;
353 - }
354 -
355 - $transient_key = $this->get_transient_key( $user_id, $token, $form_id );
356 - $data = get_transient( $transient_key );
357 -
358 - if ( empty( $data ) ) {
359 - return null;
360 - }
361 -
362 - $has_structure = ( ! empty( $data['structure'] ) && is_array( $data['structure'] ) );
363 - $has_advanced = ( ! empty( $data['advanced_form'] ) || ! empty( $data['content_form'] ) );
364 -
365 - if ( ! $has_structure && ! $has_advanced ) {
366 - return null;
367 - }
368 -
369 -
370 - $this->current_preview_data = $data;
371 -
372 - return $this->current_preview_data;
373 - }
374 -
375 722 // -----------------------------------------------------------------------------------------------------------------
376 723
377 724 /**
378 725 * Ensure preview page uses a "full width" template (if the current theme provides one).
@@ -617,9 +964,9 @@
617 964 * @return string
618 965 */
619 966 public function filter_the_content_for_preview( $content ) {
620 967
621 - if ( ! is_page() ) {
968 + if ( ! $this->is_main_query_page() ) {
622 969 return $content;
623 970 }
624 971
625 972 $page_id = get_the_ID();
@@ -644,15 +991,14 @@
644 991
645 992 // Store preview data for this request so other hooks can access it.
646 993 $this->current_preview_data = $preview_data;
647 994
648 - // Optional: expose a filter so BFB structure loader can override structure per preview.
649 - // Your wpbc_bfb_get_form_structure() can apply this filter when resolving structure.
650 - add_filter( 'wpbc_bfb_get_form_structure', array( $this, 'filter_form_structure_for_preview' ), 10, 2 );
651 - add_filter( 'wpbc_bfb_get_form_advanced_form', array( $this, 'filter_form_advanced_form_for_preview' ), 10, 2 );
652 - add_filter( 'wpbc_bfb_get_form_content_form', array( $this, 'filter_form_content_form_for_preview' ), 10, 2 );
995 + $this->register_preview_source_filters();
653 996
654 - $resource_id = WPBC_FE_Attr_Postprocessor::get_default_booking_resource_id();
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 + }
655 1001
656 1002 $form_name = isset( $preview_data['form_name'] ) ? sanitize_text_field( wp_unslash( $preview_data['form_name'] ) ) : 'standard';
657 1003 if ( '' === $form_name ) {
658 1004 $form_name = 'standard';
@@ -657,13 +1003,614 @@
657 1003 if ( '' === $form_name ) {
658 1004 $form_name = 'standard';
659 1005 }
660 1006
661 - $shortcode = '[booking resource_id="' . esc_attr( $resource_id ) . '" form_type="' . esc_attr( $form_name ) . '" form_status="preview"]';
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 + }
662 1612
663 - return do_shortcode( $shortcode );
664 - }
665 -
666 1613 public function filter_form_advanced_form_for_preview( $advanced_form, $form_id ) {
667 1614
668 1615 if ( empty( $this->current_preview_data ) ) {
669 1616 return $advanced_form;
@@ -668,9 +1615,9 @@
668 1615 if ( empty( $this->current_preview_data ) ) {
669 1616 return $advanced_form;
670 1617 }
671 1618
672 - if ( (int) $form_id !== (int) $this->current_preview_data['form_id'] ) {
1619 + if ( 'appointment' !== $this->get_current_preview_render_mode() && (int) $form_id !== (int) $this->current_preview_data['form_id'] ) {
673 1620 return $advanced_form;
674 1621 }
675 1622
676 1623 if ( isset( $this->current_preview_data['advanced_form'] ) ) {
@@ -685,9 +1632,9 @@
685 1632 if ( empty( $this->current_preview_data ) ) {
686 1633 return $content_form;
687 1634 }
688 1635
689 - if ( (int) $form_id !== (int) $this->current_preview_data['form_id'] ) {
1636 + if ( 'appointment' !== $this->get_current_preview_render_mode() && (int) $form_id !== (int) $this->current_preview_data['form_id'] ) {
690 1637 return $content_form;
691 1638 }
692 1639
693 1640 if ( isset( $this->current_preview_data['content_form'] ) ) {
@@ -713,9 +1660,9 @@
713 1660 if ( empty( $this->current_preview_data ) ) {
714 1661 return $structure;
715 1662 }
716 1663
717 - if ( (int) $form_id !== (int) $this->current_preview_data['form_id'] ) {
1664 + if ( 'appointment' !== $this->get_current_preview_render_mode() && (int) $form_id !== (int) $this->current_preview_data['form_id'] ) {
718 1665 return $structure;
719 1666 }
720 1667
721 1668 if ( empty( $this->current_preview_data['structure'] ) || ! is_array( $this->current_preview_data['structure'] ) ) {
@@ -725,13 +1672,177 @@
725 1672 return $this->current_preview_data['structure'];
726 1673 }
727 1674
728 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 + /**
729 1840 * Hide the preview page from the Pages list in admin.
730 1841 *
731 1842 * @param WP_Query $query Main query.
732 1843 */
733 - public function hide_preview_page_in_admin_list( $query ) {
1844 + public function hide_preview_page_in_admin_list( $query ) {
734 1845
735 1846 if ( ! is_admin() || ! $query->is_main_query() ) {
736 1847 return;
737 1848 }
@@ -756,60 +1867,417 @@
756 1867
757 1868 $not_in = (array) $query->get( 'post__not_in' );
758 1869 $not_in[] = (int) $page_id;
759 1870
760 - $query->set( 'post__not_in', $not_in );
761 - }
762 -
763 - // FixIn: 2026-01-03 14:31.
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.
764 2185 /**
765 - * Create a preview session: store transient snapshot and return preview URL + token.
766 - *
767 - * @param int $preview_form_id Preview form/resource ID.
768 - * @param int $user_id Current user ID.
769 - * @param array $structure Decoded BFB structure.
770 - *
771 - * @return array|false { preview_url, token } or false on failure.
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.
772 2198 */
773 - public function create_preview_session( $preview_form_id, $user_id, $structure, $form_name = 'standard', $advanced_form = '', $content_form = '' ) {
774 -
775 -
776 - $preview_form_id = (int) $preview_form_id;
777 - $user_id = (int) $user_id;
778 - $structure = ( is_array( $structure ) ? $structure : array() );
779 - $form_name = sanitize_text_field( (string) $form_name );
780 - if ( '' === $form_name ) {
781 - $form_name = 'standard';
782 - }
783 - if ( $preview_form_id <= 0 ) {
784 - $preview_form_id = 1;
785 - }
786 - if ( $user_id <= 0 ) {
787 - return false;
788 - }
789 -
790 - $page_id = $this->get_preview_page_id();
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();
791 2248 if ( ! $page_id ) {
792 2249 return false;
793 2250 }
794 2251
795 - $token = wp_generate_password( 12, false, false );
796 - $transient_key = $this->get_transient_key( $user_id, $token, $preview_form_id );
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 );
797 2258
798 - $payload = array(
799 - 'user_id' => $user_id,
800 - // Keep key name as-is (your preview reader expects ['form_id']).
801 - // This is a *preview context resource/calendar id* (used for shortcode type="...").
802 - 'form_id' => $preview_form_id,
803 - 'form_name' => $form_name,
804 - 'scope' => 'preview',
805 - 'structure' => $structure,
806 - 'time' => time(),
807 - 'advanced_form' => (string) $advanced_form,
808 - 'content_form' => (string) $content_form,
809 - );
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 + );
810 2276
811 - set_transient( $transient_key, $payload, 10 * MINUTE_IN_SECONDS );
2277 + if ( ! set_transient( $transient_key, $payload, 10 * MINUTE_IN_SECONDS ) ) {
2278 + return false;
2279 + }
812 2280
813 2281 $preview_url = add_query_arg( array(
814 2282 'wpbc_bfb_preview' => 1,
815 2283 'wpbc_bfb_preview_token' => rawurlencode( $token ),
@@ -816,12 +2284,12 @@
816 2284 'wpbc_bfb_preview_form_id' => $preview_form_id,
817 2285 'nonce' => wp_create_nonce( 'wpbc_bfb_preview_' . $token ),
818 2286 ), get_permalink( $page_id ) );
819 2287
820 - return array(
821 - 'preview_url' => $preview_url,
822 - 'token' => $token,
823 - );
2288 + return array(
2289 + 'preview_url' => esc_url_raw( $preview_url ),
2290 + 'token' => $token,
2291 + );
824 2292 }
825 2293
826 2294 }
827 2295
@@ -948,5 +2416,5 @@
948 2416 </div>
949 2417 <span><?php esc_html_e( 'Loading', 'booking' ); ?>...</span>
950 2418 </div>
951 2419 <?php
952 -}
2420 +}