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 +1599 -308 11.4 → 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,20 +386,20 @@
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 395 wp_enqueue_script(
236 - 'wpbc-bfb-preview',
237 - wpbc_plugin_url( '/includes/page-form-builder/preview/_out/bfb-preview.js' ),
238 - array( 'wpbc-bfb_builder' ),
239 - WP_BK_VERSION_NUM,
240 - true
241 - );
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 + );
242 402 wp_enqueue_style(
243 403 'wpbc-bfb-preview-mode',
244 404 wpbc_plugin_url( '/includes/page-form-builder/preview/_out/bfb-preview.css' ),
245 405 array(),
@@ -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,14 +1003,613 @@
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 + }
662 1022
663 - $preview_html = do_shortcode( $shortcode );
1023 + $this->remove_preview_source_filters();
664 1024
665 1025 return $this->filter_wrapped_html_for_preview_form_style( $preview_html, array(), $resource_id, $form_name );
666 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 + }
667 1612
668 1613 public function filter_form_advanced_form_for_preview( $advanced_form, $form_id ) {
669 1614
670 1615 if ( empty( $this->current_preview_data ) ) {
@@ -670,9 +1615,9 @@
670 1615 if ( empty( $this->current_preview_data ) ) {
671 1616 return $advanced_form;
672 1617 }
673 1618
674 - 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'] ) {
675 1620 return $advanced_form;
676 1621 }
677 1622
678 1623 if ( isset( $this->current_preview_data['advanced_form'] ) ) {
@@ -687,9 +1632,9 @@
687 1632 if ( empty( $this->current_preview_data ) ) {
688 1633 return $content_form;
689 1634 }
690 1635
691 - 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'] ) {
692 1637 return $content_form;
693 1638 }
694 1639
695 1640 if ( isset( $this->current_preview_data['content_form'] ) ) {
@@ -709,15 +1654,15 @@
709 1654 * @param int $form_id Booking form ID.
710 1655 *
711 1656 * @return array
712 1657 */
713 - public function filter_form_structure_for_preview( $structure, $form_id ) {
1658 + public function filter_form_structure_for_preview( $structure, $form_id ) {
714 1659
715 1660 if ( empty( $this->current_preview_data ) ) {
716 1661 return $structure;
717 1662 }
718 1663
719 - 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'] ) {
720 1665 return $structure;
721 1666 }
722 1667
723 1668 if ( empty( $this->current_preview_data['structure'] ) || ! is_array( $this->current_preview_data['structure'] ) ) {
@@ -723,270 +1668,616 @@
723 1668 if ( empty( $this->current_preview_data['structure'] ) || ! is_array( $this->current_preview_data['structure'] ) ) {
724 1669 return $structure;
725 1670 }
726 1671
727 - return $this->current_preview_data['structure'];
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 );
728 1872 }
729 1873
730 1874 /**
731 - * Apply unsaved global Form Style values to the preview iframe only.
1875 + * Render one inline preview and return its data-only browser bootstrap.
732 1876 *
733 - * @param string $wrapped_html Wrapped booking form HTML.
734 - * @param array $bfb_settings BFB settings.
735 - * @param int $resource_id Booking resource ID.
736 - * @param string $custom_booking_form_name Form slug.
737 - * @return string
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.
738 1895 */
739 - 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
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 + );
740 1910
741 - $preview_style = $this->get_preview_form_style();
742 - if ( empty( $preview_style ) ) {
743 - return $wrapped_html;
1911 + if ( ! is_string( $preview_html ) || '' === trim( $preview_html ) ) {
1912 + return false;
744 1913 }
745 1914
746 - $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();
747 - $preset = wpbc_bfb_settings__get_form_style_preset( $style );
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 + }
748 1920
749 - $wrapped_html = $this->remove_classes_from_first_form_wrapper(
750 - $wrapped_html,
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,
751 1962 array(
752 - 'wpbc_theme_dark_1',
753 - 'wpbc_bfb_form_appearance_custom',
1963 + 'resource_id' => $preview_form_id,
1964 + 'custom_form' => $form_name,
754 1965 )
755 1966 );
756 - $wrapped_html = $this->remove_classes_from_first_tag_with_classes(
757 - $wrapped_html,
758 - array( 'wpbc_bfb_form' ),
759 - array(
760 - 'wpbc_theme_dark_1',
761 - 'wpbc_bfb_form_appearance_custom',
762 - )
763 - );
1967 + $request_uri = '';
764 1968
765 - $theme_class = isset( $preset['theme_class'] ) ? sanitize_html_class( (string) $preset['theme_class'] ) : '';
766 - if ( '' !== $theme_class ) {
767 - $wrapped_html = WPBC_FE_Form_Style_Injector::add_class_to_first_tag_with_classes( $wrapped_html, array( 'wpbc_container', 'wpbc_form' ), $theme_class );
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
768 1971 }
769 1972
770 - $css_vars = wpbc_bfb_settings__get_form_style_css_vars( $style, $preview_style );
771 - if ( ! empty( $css_vars ) ) {
772 - $wrapped_html = WPBC_FE_Form_Style_Injector::inject_css_vars_into_form_wrapper( $wrapped_html, $css_vars );
773 - $wrapped_html = WPBC_FE_Form_Style_Injector::inject_css_vars_into_bfb_root( $wrapped_html, $css_vars );
774 - }
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 + );
775 1992
776 - if ( wpbc_bfb_settings__is_custom_form_style( $style ) ) {
777 - $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' );
778 - $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' );
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' );
779 1997 }
780 1998
781 - return $wrapped_html;
782 - }
783 -
784 - /**
785 - * Get sanitized preview Form Style override from the current transient data.
786 - *
787 - * @return array
788 - */
789 - protected function get_preview_form_style() {
790 -
791 - if ( empty( $this->current_preview_data['form_style'] ) || ! is_array( $this->current_preview_data['form_style'] ) ) {
792 - return array();
1999 + if ( class_exists( 'wpdev_bk_biz_s' ) ) {
2000 + $calendar_parameters['booking_recurrent_time'] = (string) get_bk_option( 'booking_recurrent_time' );
793 2001 }
794 2002
795 - $style = isset( $this->current_preview_data['form_style']['booking_form_style'] ) ? $this->current_preview_data['form_style']['booking_form_style'] : '';
796 - $style = wpbc_bfb_settings__sanitize_form_style( $style );
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();
797 2013
798 - $custom_options = wpbc_bfb_settings__get_custom_form_style_options( $this->current_preview_data['form_style'] );
799 -
800 - return array_merge(
801 - array(
802 - 'booking_form_style' => $style,
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(),
803 2023 ),
804 - $custom_options
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 + ),
805 2037 );
806 2038 }
807 2039
808 2040 /**
809 - * Remove classes from the first outer booking form wrapper.
2041 + * Return normalized day-selection options for the inline calendar contract.
810 2042 *
811 - * @param string $html HTML.
812 - * @param array $class_names Class names to remove.
813 - * @return string
2043 + * @return array<string,mixed> Day-selection values.
814 2044 */
815 - protected function remove_classes_from_first_form_wrapper( $html, $class_names ) {
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();
816 2058
817 - return $this->remove_classes_from_first_tag_with_classes( $html, array( 'wpbc_container', 'wpbc_form' ), $class_names );
2059 + return wp_parse_args( is_array( $days_selection ) ? $days_selection : array(), $defaults );
818 2060 }
819 2061
820 2062 /**
821 - * Remove classes from the first tag that has all required classes.
2063 + * Convert a stored comma list into bounded JSON-safe integers.
822 2064 *
823 - * @param string $html HTML.
824 - * @param array $required_classes Required classes.
825 - * @param array $class_names Class names to remove.
826 - * @return string
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.
827 2069 */
828 - protected function remove_classes_from_first_tag_with_classes( $html, $required_classes, $class_names ) {
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();
829 2073
830 - $html = (string) $html;
831 - $required_classes = is_array( $required_classes ) ? $required_classes : array();
832 - $class_names = is_array( $class_names ) ? $class_names : array();
833 - $remove_map = array();
834 - $required_map = array();
835 -
836 - foreach ( $required_classes as $class_name ) {
837 - $class_name = sanitize_html_class( (string) $class_name );
838 - if ( '' !== $class_name ) {
839 - $required_map[ $class_name ] = true;
2074 + foreach ( $raw_values as $raw_value ) {
2075 + if ( ! is_scalar( $raw_value ) || ! preg_match( '/^-?\d+$/', trim( (string) $raw_value ) ) ) {
2076 + continue;
840 2077 }
841 - }
842 2078
843 - foreach ( $class_names as $class_name ) {
844 - $class_name = sanitize_html_class( (string) $class_name );
845 - if ( '' !== $class_name ) {
846 - $remove_map[ $class_name ] = true;
847 - }
2079 + $integers[] = (int) $raw_value;
848 2080 }
849 2081
850 - if ( empty( $remove_map ) || empty( $required_map ) ) {
851 - return $html;
852 - }
2082 + return empty( $integers ) ? $fallback : array_values( array_unique( $integers ) );
2083 + }
853 2084
854 - $done = false;
855 - $out = preg_replace_callback(
856 - '/<div\b[^>]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i',
857 - function ( $matches ) use ( &$done, $remove_map, $required_map ) {
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() ) {
858 2108
859 - $tag = $matches[0];
860 - if ( $done ) {
861 - return $tag;
862 - }
2109 + if ( ! class_exists( 'WPBC_FE_Render' ) || null !== $this->current_preview_data ) {
2110 + return false;
2111 + }
863 2112
864 - $quote = $matches[1];
865 - $classes = preg_split( '/\s+/', trim( (string) $matches[2] ) );
866 - $classes = is_array( $classes ) ? $classes : array();
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();
867 2135
868 - foreach ( $required_map as $required_class => $unused ) {
869 - if ( ! in_array( $required_class, $classes, true ) ) {
870 - return $tag;
871 - }
872 - }
2136 + if (
2137 + null === $structure
2138 + || null === $advanced_form
2139 + || null === $content_form
2140 + || null === $form_style
2141 + ) {
2142 + return false;
2143 + }
873 2144
874 - $filtered = array();
875 - foreach ( $classes as $class_name ) {
876 - if ( '' === $class_name || isset( $remove_map[ $class_name ] ) ) {
877 - continue;
878 - }
879 - $filtered[] = $class_name;
880 - }
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 + );
881 2160
882 - $done = true;
2161 + $this->register_preview_source_filters();
883 2162
884 - return preg_replace( '/\bclass\s*=\s*(["\'])(.*?)\1/i', 'class=' . $quote . esc_attr( implode( ' ', $filtered ) ) . $quote, $tag, 1 );
885 - },
886 - $html
887 - );
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 : '';
888 2175
889 - return ( null === $out ) ? $html : $out;
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 + }
890 2182 }
891 -
2183 +
2184 + // FixIn: 2026-01-03 14:31.
892 2185 /**
893 - * Hide the preview page from the Pages list in admin.
894 - *
895 - * @param WP_Query $query Main query.
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.
896 2198 */
897 - public function hide_preview_page_in_admin_list( $query ) {
898 -
899 - if ( ! is_admin() || ! $query->is_main_query() ) {
900 - return;
901 - }
902 -
903 - global $pagenow;
904 -
905 - if ( 'edit.php' !== $pagenow ) {
906 - return;
907 - }
908 -
909 - $post_type = $query->get( 'post_type' );
910 -
911 - if ( 'page' !== $post_type && '' !== $post_type ) {
912 - return;
913 - }
914 -
915 - $page_id = $this->get_preview_page_id();
916 -
917 - if ( $page_id <= 0 ) {
918 - return;
919 - }
920 -
921 - $not_in = (array) $query->get( 'post__not_in' );
922 - $not_in[] = (int) $page_id;
923 -
924 - $query->set( 'post__not_in', $not_in );
925 - }
926 -
927 - // FixIn: 2026-01-03 14:31.
928 - /**
929 - * Create a preview session: store transient snapshot and return preview URL + token.
930 - *
931 - * @param int $preview_form_id Preview form/resource ID.
932 - * @param int $user_id Current user ID.
933 - * @param array $structure Decoded BFB structure.
934 - *
935 - * @return array|false { preview_url, token } or false on failure.
936 - */
937 - public function create_preview_session( $preview_form_id, $user_id, $structure, $form_name = 'standard', $advanced_form = '', $content_form = '', $form_style = array() ) {
938 -
939 -
940 - $preview_form_id = (int) $preview_form_id;
941 - $user_id = (int) $user_id;
942 - $structure = ( is_array( $structure ) ? $structure : array() );
943 - $form_name = sanitize_text_field( (string) $form_name );
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 +
944 2243 if ( '' === $form_name ) {
945 2244 $form_name = 'standard';
946 2245 }
947 - $form_style = is_array( $form_style ) ? $form_style : array();
948 - if ( ! empty( $form_style ) ) {
949 - $style_key = isset( $form_style['booking_form_style'] ) ? $form_style['booking_form_style'] : '';
950 - $sanitized_style = wpbc_bfb_settings__sanitize_form_style( $style_key );
951 - $custom_style = wpbc_bfb_settings__get_custom_form_style_options( $form_style );
952 - $form_style = array_merge(
953 - array(
954 - 'booking_form_style' => $sanitized_style,
955 - ),
956 - $custom_style
957 - );
958 - }
959 - if ( $preview_form_id <= 0 ) {
960 - $preview_form_id = 1;
961 - }
962 - if ( $user_id <= 0 ) {
963 - return false;
964 - }
965 -
966 - $page_id = $this->get_preview_page_id();
2246 +
2247 + $page_id = $this->get_preview_page_id();
967 2248 if ( ! $page_id ) {
968 2249 return false;
969 2250 }
970 2251
971 - $token = wp_generate_password( 12, false, false );
972 - $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 );
973 2258
974 - $payload = array(
975 - 'user_id' => $user_id,
976 - // Keep key name as-is (your preview reader expects ['form_id']).
977 - // This is a *preview context resource/calendar id* (used for shortcode type="...").
978 - 'form_id' => $preview_form_id,
979 - 'form_name' => $form_name,
980 - 'scope' => 'preview',
981 - 'structure' => $structure,
982 - 'time' => time(),
983 - 'advanced_form' => (string) $advanced_form,
984 - 'content_form' => (string) $content_form,
985 - 'form_style' => $form_style,
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,
986 2275 );
987 2276
988 - set_transient( $transient_key, $payload, 10 * MINUTE_IN_SECONDS );
2277 + if ( ! set_transient( $transient_key, $payload, 10 * MINUTE_IN_SECONDS ) ) {
2278 + return false;
2279 + }
989 2280
990 2281 $preview_url = add_query_arg( array(
991 2282 'wpbc_bfb_preview' => 1,
992 2283 'wpbc_bfb_preview_token' => rawurlencode( $token ),
@@ -993,12 +2284,12 @@
993 2284 'wpbc_bfb_preview_form_id' => $preview_form_id,
994 2285 'nonce' => wp_create_nonce( 'wpbc_bfb_preview_' . $token ),
995 2286 ), get_permalink( $page_id ) );
996 2287
997 - return array(
998 - 'preview_url' => $preview_url,
999 - 'token' => $token,
1000 - );
2288 + return array(
2289 + 'preview_url' => esc_url_raw( $preview_url ),
2290 + 'token' => $token,
2291 + );
1001 2292 }
1002 2293
1003 2294 }
1004 2295
@@ -1125,5 +2416,5 @@
1125 2416 </div>
1126 2417 <span><?php esc_html_e( 'Loading', 'booking' ); ?>...</span>
1127 2418 </div>
1128 2419 <?php
1129 -}
2420 +}