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 +1436 -160 11.8.2 → 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 *
@@ -197,21 +342,25 @@
197 342 * Check if current request is a BFB preview request.
198 343 *
199 344 * @return bool
200 345 */
201 - 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 + }
202 362
203 - // Quick GET check (works before main query is parsed).
204 - if ( isset( $_GET['wpbc_bfb_preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
205 - return true;
206 - }
207 -
208 - // Fallback for places where query vars are already set.
209 - $is_preview = get_query_var( 'wpbc_bfb_preview' );
210 -
211 - return ! empty( $is_preview );
212 - }
213 -
214 363 /**
215 364 * Hide admin toolbar on front-end preview requests.
216 365 *
217 366 * This affects ONLY the preview page loaded in the iframe.
@@ -237,9 +386,9 @@
237 386 // Disable admin bar for this request only.
238 387 show_admin_bar( false );
239 388 }
240 389
241 - public function enqueue_js_files() {
390 + public function enqueue_js_files() {
242 391 if ( ! is_admin() ) {
243 392 return;
244 393 }
245 394 // BFB preview script on the Builder admin page.
@@ -316,74 +465,261 @@
316 465 * Try to load preview data from current request (front-end).
317 466 *
318 467 * @return array|null
319 468 */
320 - protected function get_preview_data_from_request() {
469 + protected function get_preview_data_from_request() {
321 470
322 471 if ( null !== $this->current_preview_data ) {
323 472 return $this->current_preview_data;
324 473 }
325 474
326 - // Check query flag.
327 - $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 + }
328 485
329 - if ( empty( $is_preview ) && ! isset( $_GET['wpbc_bfb_preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
330 - return null;
331 - }
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 + : '';
332 506
333 - $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 + }
334 721
335 - if ( ! is_user_logged_in() || ! current_user_can( $cap ) ) {
336 - return null;
337 - }
338 -
339 - $token = get_query_var( 'wpbc_bfb_preview_token' );
340 - $form_id = absint( get_query_var( 'wpbc_bfb_preview_form_id' ) );
341 -
342 - if ( empty( $token ) ) {
343 - $token = isset( $_GET['wpbc_bfb_preview_token'] ) ? sanitize_text_field( wp_unslash( $_GET['wpbc_bfb_preview_token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
344 - }
345 -
346 - if ( empty( $form_id ) ) {
347 - $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
348 - }
349 -
350 - $nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
351 -
352 - if ( empty( $token ) || empty( $form_id ) ) {
353 - return null;
354 - }
355 -
356 - if ( ! wp_verify_nonce( $nonce, 'wpbc_bfb_preview_' . $token ) ) {
357 - return null;
358 - }
359 -
360 - $user_id = wpbc_get_current_user_id();
361 -
362 - if ( $user_id <= 0 ) {
363 - return null;
364 - }
365 -
366 - $transient_key = $this->get_transient_key( $user_id, $token, $form_id );
367 - $data = get_transient( $transient_key );
368 -
369 - if ( empty( $data ) ) {
370 - return null;
371 - }
372 -
373 - $has_structure = ( ! empty( $data['structure'] ) && is_array( $data['structure'] ) );
374 - $has_advanced = ( ! empty( $data['advanced_form'] ) || ! empty( $data['content_form'] ) );
375 -
376 - if ( ! $has_structure && ! $has_advanced ) {
377 - return null;
378 - }
379 -
380 -
381 - $this->current_preview_data = $data;
382 -
383 - return $this->current_preview_data;
384 - }
385 -
386 722 // -----------------------------------------------------------------------------------------------------------------
387 723
388 724 /**
389 725 * Ensure preview page uses a "full width" template (if the current theme provides one).
@@ -655,15 +991,14 @@
655 991
656 992 // Store preview data for this request so other hooks can access it.
657 993 $this->current_preview_data = $preview_data;
658 994
659 - // Optional: expose a filter so BFB structure loader can override structure per preview.
660 - // Your wpbc_bfb_get_form_structure() can apply this filter when resolving structure.
661 - add_filter( 'wpbc_bfb_get_form_structure', array( $this, 'filter_form_structure_for_preview' ), 10, 2 );
662 - add_filter( 'wpbc_bfb_get_form_advanced_form', array( $this, 'filter_form_advanced_form_for_preview' ), 10, 2 );
663 - add_filter( 'wpbc_bfb_get_form_content_form', array( $this, 'filter_form_content_form_for_preview' ), 10, 2 );
995 + $this->register_preview_source_filters();
664 996
665 - $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 + }
666 1001
667 1002 $form_name = isset( $preview_data['form_name'] ) ? sanitize_text_field( wp_unslash( $preview_data['form_name'] ) ) : 'standard';
668 1003 if ( '' === $form_name ) {
669 1004 $form_name = 'standard';
@@ -668,15 +1003,614 @@
668 1003 if ( '' === $form_name ) {
669 1004 $form_name = 'standard';
670 1005 }
671 1006
672 - $shortcode = '[booking resource_id="' . esc_attr( $resource_id ) . '" form_type="' . esc_attr( $form_name ) . '" form_status="preview"]';
1007 + if ( 'appointment' === $this->get_current_preview_render_mode() ) {
1008 + $shortcode = '[booking_appointment form_type="' . esc_attr( $form_name ) . '"]';
1009 + $preview_html = do_shortcode( $shortcode );
1010 + } else {
1011 + $preview_html = WPBC_FE_Render::render_booking_form(
1012 + array(
1013 + 'resource_id' => $resource_id,
1014 + 'cal_count' => 1,
1015 + 'is_echo' => 0,
1016 + 'custom_booking_form' => $form_name,
1017 + 'form_status' => 'preview',
1018 + 'calendar_request_overrides' => $this->get_current_preview_calendar_request_overrides(),
1019 + )
1020 + );
1021 + }
1022 +
1023 + $this->remove_preview_source_filters();
1024 +
1025 + return $this->filter_wrapped_html_for_preview_form_style( $preview_html, array(), $resource_id, $form_name );
1026 + }
1027 +
1028 + /**
1029 + * Make the authenticated transient snapshot the source for this preview.
1030 + *
1031 + * The preview must not depend on a saved `preview` database row. The returned
1032 + * loader arguments include a server-owned marker that the paired loader
1033 + * filter recognizes during this request only.
1034 + *
1035 + * @param array $resolution Existing database or missing-source resolution.
1036 + * @param array $request Front-end form source request.
1037 + *
1038 + * @return array Preview resolution or the unchanged result when it is unrelated.
1039 + */
1040 + public function filter_form_source_resolution_for_preview( $resolution, $request ) {
1041 +
1042 + if ( ! $this->is_current_preview_request( $request ) ) {
1043 + return $resolution;
1044 + }
1045 +
1046 + $resolution = is_array( $resolution ) ? $resolution : array();
1047 +
1048 + $fallback_chain = isset( $resolution['fallback_chain'] ) && is_array( $resolution['fallback_chain'] )
1049 + ? $resolution['fallback_chain']
1050 + : array();
1051 +
1052 + $request_resource_id = isset( $request['resource_id'] ) ? absint( $request['resource_id'] ) : 0;
1053 + $loader_resource_id = 'appointment' === $this->get_current_preview_render_mode()
1054 + ? $request_resource_id
1055 + : $this->get_current_preview_resource_id();
1056 +
1057 + return array(
1058 + 'engine' => 'bfb_db',
1059 + 'apply_after_load_filter' => true,
1060 + 'bfb_loader_args' => array(
1061 + 'form_slug' => $this->get_current_preview_form_name(),
1062 + 'status' => 'preview',
1063 + 'resource_id' => $loader_resource_id,
1064 + 'wpbc_preview_snapshot' => 1,
1065 + ),
1066 + 'fallback_chain' => $fallback_chain,
1067 + );
1068 + }
1069 +
1070 + /**
1071 + * Return the transient form pair instead of a saved Form Builder row.
1072 + *
1073 + * @param array $form_pair Pair already returned by the Form Builder loader.
1074 + * @param array $loader_args Normalized Form Builder loader arguments.
1075 + *
1076 + * @return array Selected preview snapshot or the unchanged pair.
1077 + */
1078 + public function filter_form_pair_for_preview( $form_pair, $loader_args ) {
1079 +
1080 + if ( ! is_array( $loader_args ) || empty( $loader_args['wpbc_preview_snapshot'] ) ) {
1081 + return $form_pair;
1082 + }
1083 +
1084 + $request = array(
1085 + 'resource_id' => isset( $loader_args['resource_id'] ) ? $loader_args['resource_id'] : 0,
1086 + 'form_slug' => isset( $loader_args['form_slug'] ) ? $loader_args['form_slug'] : '',
1087 + 'form_status' => isset( $loader_args['status'] ) ? $loader_args['status'] : '',
1088 + );
1089 +
1090 + if ( ! $this->is_current_preview_request( $request ) ) {
1091 + return $form_pair;
1092 + }
1093 +
1094 + return array(
1095 + 'form' => isset( $this->current_preview_data['advanced_form'] ) ? (string) $this->current_preview_data['advanced_form'] : '',
1096 + 'content' => isset( $this->current_preview_data['content_form'] ) ? (string) $this->current_preview_data['content_form'] : '',
1097 + 'settings_json' => isset( $this->current_preview_data['settings_json'] ) ? (string) $this->current_preview_data['settings_json'] : '',
1098 + );
1099 + }
1100 +
1101 + /**
1102 + * Check whether a renderer or loader request belongs to this preview snapshot.
1103 + *
1104 + * @param array $request Source resolver or loader request values.
1105 + *
1106 + * @return bool True only for the exact preview resource, form, and status.
1107 + */
1108 + private function is_current_preview_request( $request ) {
1109 +
1110 + if ( empty( $this->current_preview_data ) || ! is_array( $request ) ) {
1111 + return false;
1112 + }
1113 +
1114 + $request_resource_id = isset( $request['resource_id'] ) ? absint( $request['resource_id'] ) : 0;
1115 + $request_form_name = isset( $request['form_slug'] ) ? sanitize_text_field( (string) $request['form_slug'] ) : '';
1116 + $request_status = isset( $request['form_status'] ) ? sanitize_key( (string) $request['form_status'] ) : '';
1117 +
1118 + $resource_matches = 'appointment' === $this->get_current_preview_render_mode()
1119 + ? $request_resource_id > 0
1120 + : $request_resource_id === $this->get_current_preview_resource_id();
1121 +
1122 + return $resource_matches
1123 + && $request_form_name === $this->get_current_preview_form_name()
1124 + && 'preview' === $request_status;
1125 + }
1126 +
1127 + /**
1128 + * Switch the selected Appointment form to the authenticated preview source.
1129 + *
1130 + * @param string $form_status Existing form status.
1131 + * @param string $form_slug Resolved Appointment form slug.
1132 + * @param int $provider_id Selected Provider resource ID.
1133 + * @param array<string,mixed> $config Signed Appointment configuration.
1134 + *
1135 + * @return string Preview only for the active snapshot and exact form slug.
1136 + */
1137 + public function filter_appointment_form_status_for_preview( $form_status, $form_slug, $provider_id, $config ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
1138 + if (
1139 + 'appointment' !== $this->get_current_preview_render_mode()
1140 + || absint( $provider_id ) <= 0
1141 + || sanitize_text_field( (string) $form_slug ) !== $this->get_current_preview_form_name()
1142 + ) {
1143 + return $form_status;
1144 + }
1145 +
1146 + return 'preview';
1147 + }
1148 +
1149 + /**
1150 + * Return the resource/calendar ID scoped to the active preview snapshot.
1151 + *
1152 + * Older transients stored this context only under `form_id`; retain that
1153 + * fallback until all ten-minute preview sessions have naturally expired.
1154 + *
1155 + * @return int Preview resource ID.
1156 + */
1157 + private function get_current_preview_resource_id() {
1158 +
1159 + if ( isset( $this->current_preview_data['resource_id'] ) ) {
1160 + return absint( $this->current_preview_data['resource_id'] );
1161 + }
1162 +
1163 + return isset( $this->current_preview_data['form_id'] ) ? absint( $this->current_preview_data['form_id'] ) : 0;
1164 + }
1165 +
1166 + /**
1167 + * Return the normalized form name scoped to the active preview snapshot.
1168 + *
1169 + * @return string Preview form name.
1170 + */
1171 + private function get_current_preview_form_name() {
1172 +
1173 + $form_name = isset( $this->current_preview_data['form_name'] )
1174 + ? sanitize_text_field( (string) $this->current_preview_data['form_name'] )
1175 + : 'standard';
1176 +
1177 + return '' === $form_name ? 'standard' : $form_name;
1178 + }
1179 +
1180 + /**
1181 + * Return the allow-listed renderer used by the active preview snapshot.
1182 + *
1183 + * @return string Either booking or appointment.
1184 + */
1185 + private function get_current_preview_render_mode() {
1186 + $render_mode = isset( $this->current_preview_data['render_mode'] )
1187 + ? sanitize_key( (string) $this->current_preview_data['render_mode'] )
1188 + : 'booking';
1189 +
1190 + return 'appointment' === $render_mode ? 'appointment' : 'booking';
1191 + }
1192 +
1193 + /**
1194 + * Return calendar-load overrides scoped to the active preview snapshot.
1195 + *
1196 + * Appointment rendering happens in a later signed AJAX request, so the
1197 + * renderer needs a public, read-only accessor rather than direct access to
1198 + * transient internals. Values were allow-list sanitized at the transient
1199 + * boundary and are sanitized again here for defense in depth.
1200 + *
1201 + * @return array<string,int|string> Safe calendar-load request overrides.
1202 + */
1203 + public function get_current_preview_calendar_request_overrides() {
1204 + $calendar_request_overrides = isset( $this->current_preview_data['calendar_request_overrides'] )
1205 + ? $this->current_preview_data['calendar_request_overrides']
1206 + : array();
1207 +
1208 + return $this->sanitize_preview_calendar_request_overrides( $calendar_request_overrides );
1209 + }
1210 +
1211 + /**
1212 + * Sanitize booking-form source before it enters a preview transient.
1213 + *
1214 + * Preview markup is intentionally rendered as booking-form markup rather than
1215 + * escaped as plain text. Therefore the shared preview service, rather than
1216 + * each caller, must enforce the established Form Builder KSES policy. An
1217 + * unavailable sanitizer fails closed for non-empty markup.
1218 + *
1219 + * @param mixed $form_source Raw advanced-form or content-form source.
1220 + *
1221 + * @return string|null Sanitized source, or null when it cannot be sanitized.
1222 + */
1223 + private function sanitize_preview_form_source( $form_source ) {
1224 +
1225 + if ( ! is_scalar( $form_source ) ) {
1226 + return '';
1227 + }
1228 +
1229 + $form_source = (string) $form_source;
1230 + if ( '' === $form_source ) {
1231 + return '';
1232 + }
1233 +
1234 + if ( ! function_exists( 'wpbc_bfb_sanitize_form_text' ) ) {
1235 + return null;
1236 + }
1237 +
1238 + return (string) wpbc_bfb_sanitize_form_text( $form_source );
1239 + }
1240 +
1241 + /**
1242 + * Normalize Form Builder structure at the shared preview write boundary.
1243 + *
1244 + * @param mixed $structure Candidate decoded Form Builder structure.
1245 + *
1246 + * @return array|null Sanitized structure, or null when normalization fails.
1247 + */
1248 + private function sanitize_preview_structure( $structure ) {
1249 +
1250 + $structure = is_array( $structure ) ? $structure : array();
1251 + $structure = apply_filters( 'wpbc_bfb_sanitize_structure_before_save', $structure );
1252 +
1253 + return is_array( $structure ) ? $structure : null;
1254 + }
1255 +
1256 + /**
1257 + * Normalize optional Form Style values for a preview snapshot.
1258 + *
1259 + * @param mixed $form_style Candidate Form Style overrides.
1260 + *
1261 + * @return array|null Sanitized style values, or null when required helpers are unavailable.
1262 + */
1263 + private function sanitize_preview_form_style( $form_style ) {
1264 +
1265 + if ( ! is_array( $form_style ) || empty( $form_style ) ) {
1266 + return array();
1267 + }
1268 +
1269 + $scalar_style = array();
1270 + foreach ( $form_style as $style_key => $style_value ) {
1271 + if ( is_scalar( $style_value ) ) {
1272 + $scalar_style[ $style_key ] = (string) $style_value;
1273 + }
1274 + }
1275 +
1276 + if ( empty( $scalar_style ) ) {
1277 + return array();
1278 + }
1279 +
1280 + if (
1281 + ! function_exists( 'wpbc_bfb_settings__sanitize_form_style' )
1282 + || ! function_exists( 'wpbc_bfb_settings__get_custom_form_style_options' )
1283 + || ! function_exists( 'wpbc_bfb_settings__get_form_accent_options' )
1284 + ) {
1285 + return null;
1286 + }
1287 +
1288 + $style_name = isset( $scalar_style['booking_form_style'] )
1289 + ? $scalar_style['booking_form_style']
1290 + : '';
1291 +
1292 + return array_merge(
1293 + array(
1294 + 'booking_form_style' => wpbc_bfb_settings__sanitize_form_style( $style_name ),
1295 + ),
1296 + wpbc_bfb_settings__get_custom_form_style_options( $scalar_style ),
1297 + wpbc_bfb_settings__get_form_accent_options( $scalar_style )
1298 + );
1299 + }
1300 +
1301 + /**
1302 + * Normalize the global options supported by transient previews.
1303 + *
1304 + * Calendar skin values must match the current server-side skin registry.
1305 + * Appearance and Date Selection modules share this narrow allow-list. Unknown
1306 + * keys are discarded so callers cannot turn the preview transient into a
1307 + * generic option override channel.
1308 + *
1309 + * @param mixed $option_overrides Candidate preview option overrides.
1310 + *
1311 + * @return array<string,string> Sanitized allow-listed overrides.
1312 + */
1313 + private function sanitize_preview_option_overrides( $option_overrides ) {
1314 + if ( ! is_array( $option_overrides ) ) {
1315 + return array();
1316 + }
1317 +
1318 + $sanitized = array();
1319 + $toggle_keys = array(
1320 + 'booking_timeslot_picker',
1321 + 'booking_range_selection_time_is_active',
1322 + 'booking_change_over_days_triangles',
1323 + 'booking_last_checkout_day_available',
1324 + 'booking_recurrent_time',
1325 + 'booking_is_show_legend',
1326 + 'booking_legend_is_show_numbers',
1327 + 'booking_legend_is_vertical',
1328 + 'booking_legend_is_show_item_available',
1329 + 'booking_legend_is_show_item_pending',
1330 + 'booking_legend_is_show_item_approved',
1331 + 'booking_legend_is_show_item_partially',
1332 + 'booking_legend_is_show_item_unavailable',
1333 + );
1334 + foreach ( $toggle_keys as $toggle_key ) {
1335 + if ( isset( $option_overrides[ $toggle_key ] ) && is_scalar( $option_overrides[ $toggle_key ] ) && in_array( (string) $option_overrides[ $toggle_key ], array( 'On', 'Off' ), true ) ) {
1336 + $sanitized[ $toggle_key ] = (string) $option_overrides[ $toggle_key ];
1337 + }
1338 + }
1339 +
1340 + $choice_keys = array(
1341 + 'booking_type_of_day_selections' => array( 'single', 'multiple', 'range' ),
1342 + 'booking_range_selection_type' => array( 'dynamic', 'fixed' ),
1343 + );
1344 + foreach ( $choice_keys as $choice_key => $allowed_choices ) {
1345 + if ( isset( $option_overrides[ $choice_key ] ) && is_scalar( $option_overrides[ $choice_key ] ) ) {
1346 + $choice = sanitize_key( (string) $option_overrides[ $choice_key ] );
1347 + if ( in_array( $choice, $allowed_choices, true ) ) {
1348 + $sanitized[ $choice_key ] = $choice;
1349 + }
1350 + }
1351 + }
1352 +
1353 + $bounded_integer_keys = array(
1354 + 'booking_range_selection_days_count' => array( 1, 180 ),
1355 + 'booking_range_selection_days_count_dynamic' => array( 1, 1095 ),
1356 + 'booking_range_selection_days_max_count_dynamic' => array( 1, 1095 ),
1357 + );
1358 + foreach ( $bounded_integer_keys as $integer_key => $bounds ) {
1359 + if ( isset( $option_overrides[ $integer_key ] ) && is_scalar( $option_overrides[ $integer_key ] ) && preg_match( '/^\d+$/', (string) $option_overrides[ $integer_key ] ) ) {
1360 + $integer_value = (int) $option_overrides[ $integer_key ];
1361 + if ( $integer_value >= $bounds[0] && $integer_value <= $bounds[1] ) {
1362 + $sanitized[ $integer_key ] = (string) $integer_value;
1363 + }
1364 + }
1365 + }
1366 +
1367 + foreach ( array( 'booking_range_start_day', 'booking_range_start_day_dynamic' ) as $weekday_key ) {
1368 + if ( isset( $option_overrides[ $weekday_key ] ) ) {
1369 + $weekdays = $this->sanitize_preview_integer_list( $option_overrides[ $weekday_key ], -1, 6, array( -1 ) );
1370 + $sanitized[ $weekday_key ] = implode( ',', $weekdays );
1371 + }
1372 + }
1373 +
1374 + if ( isset( $option_overrides['booking_range_selection_days_specific_num_dynamic'] ) ) {
1375 + $specific_days = $this->sanitize_preview_integer_list( $option_overrides['booking_range_selection_days_specific_num_dynamic'], 1, 1095, array() );
1376 + $sanitized['booking_range_selection_days_specific_num_dynamic'] = implode( ',', $specific_days );
1377 + }
1378 +
1379 + foreach ( array( 'booking_range_selection_start_time', 'booking_range_selection_end_time' ) as $time_key ) {
1380 + if ( isset( $option_overrides[ $time_key ] ) && is_scalar( $option_overrides[ $time_key ] ) && preg_match( '/^(?:[01]\d|2[0-3]):[0-5]\d$/', (string) $option_overrides[ $time_key ] ) ) {
1381 + $sanitized[ $time_key ] = (string) $option_overrides[ $time_key ];
1382 + }
1383 + }
1384 +
1385 + foreach ( array( 'available', 'pending', 'approved', 'partially', 'unavailable' ) as $legend_item ) {
1386 + $text_key = 'booking_legend_text_for_item_' . $legend_item;
1387 + if ( isset( $option_overrides[ $text_key ] ) && is_scalar( $option_overrides[ $text_key ] ) ) {
1388 + $legend_text = sanitize_text_field( (string) $option_overrides[ $text_key ] );
1389 + $sanitized[ $text_key ] = function_exists( 'mb_substr' ) ? mb_substr( $legend_text, 0, 255 ) : substr( $legend_text, 0, 255 );
1390 + }
1391 + }
1392 +
1393 + if ( array_key_exists( 'booking_skin', $option_overrides ) && is_scalar( $option_overrides['booking_skin'] ) ) {
1394 + $calendar_skin = $this->sanitize_preview_calendar_skin( (string) $option_overrides['booking_skin'] );
1395 + if ( '' !== $calendar_skin ) {
1396 + $sanitized['booking_skin'] = $calendar_skin;
1397 + }
1398 + }
1399 +
1400 + return $sanitized;
1401 + }
1402 +
1403 + /**
1404 + * Normalize Date Selection values used by the explicit browser bootstrap.
1405 + *
1406 + * @param mixed $calendar_parameters Candidate calendar parameters.
1407 + *
1408 + * @return array<string,mixed> Sanitized allow-listed parameters.
1409 + */
1410 + private function sanitize_preview_calendar_parameters( $calendar_parameters ) {
1411 + if ( ! is_array( $calendar_parameters ) ) {
1412 + return array();
1413 + }
1414 +
1415 + $sanitized = array();
1416 + if ( array_key_exists( 'is_enabled_change_over', $calendar_parameters ) ) {
1417 + $is_enabled_change_over = $this->sanitize_preview_boolean( $calendar_parameters['is_enabled_change_over'] );
1418 + if ( null !== $is_enabled_change_over ) {
1419 + $sanitized['is_enabled_change_over'] = $is_enabled_change_over;
1420 + }
1421 + }
1422 + if ( isset( $calendar_parameters['days_select_mode'] ) && is_scalar( $calendar_parameters['days_select_mode'] ) ) {
1423 + $days_select_mode = sanitize_key( (string) $calendar_parameters['days_select_mode'] );
1424 + if ( in_array( $days_select_mode, array( 'single', 'multiple', 'range' ), true ) ) {
1425 + $sanitized['days_select_mode'] = $days_select_mode;
1426 + }
1427 + }
1428 +
1429 + $integer_keys = array(
1430 + 'fixed__days_num' => array( 0, 180 ),
1431 + 'dynamic__days_min' => array( 0, 1095 ),
1432 + 'dynamic__days_max' => array( 0, 1095 ),
1433 + );
1434 + foreach ( $integer_keys as $integer_key => $bounds ) {
1435 + if ( isset( $calendar_parameters[ $integer_key ] ) && is_scalar( $calendar_parameters[ $integer_key ] ) && preg_match( '/^\d+$/', (string) $calendar_parameters[ $integer_key ] ) ) {
1436 + $integer_value = (int) $calendar_parameters[ $integer_key ];
1437 + if ( $integer_value >= $bounds[0] && $integer_value <= $bounds[1] ) {
1438 + $sanitized[ $integer_key ] = $integer_value;
1439 + }
1440 + }
1441 + }
1442 +
1443 + $list_keys = array(
1444 + 'fixed__week_days__start' => array( -1, 6, array( -1 ) ),
1445 + 'dynamic__days_specific' => array( 1, 1095, array() ),
1446 + 'dynamic__week_days__start' => array( -1, 6, array( -1 ) ),
1447 + );
1448 + foreach ( $list_keys as $list_key => $bounds ) {
1449 + if ( array_key_exists( $list_key, $calendar_parameters ) ) {
1450 + $sanitized[ $list_key ] = $this->sanitize_preview_integer_list( $calendar_parameters[ $list_key ], $bounds[0], $bounds[1], $bounds[2] );
1451 + }
1452 + }
1453 +
1454 + if ( isset( $calendar_parameters['booking_recurrent_time'] ) && in_array( (string) $calendar_parameters['booking_recurrent_time'], array( 'On', 'Off' ), true ) ) {
1455 + $sanitized['booking_recurrent_time'] = (string) $calendar_parameters['booking_recurrent_time'];
1456 + }
1457 +
1458 + return $sanitized;
1459 + }
1460 +
1461 + /**
1462 + * Normalize Date Selection values forwarded to calendar-load requests.
1463 + *
1464 + * @param mixed $request_overrides Candidate request overrides.
1465 + *
1466 + * @return array<string,int|string> Sanitized allow-listed request values.
1467 + */
1468 + private function sanitize_preview_calendar_request_overrides( $request_overrides ) {
1469 + if ( ! is_array( $request_overrides ) ) {
1470 + return array();
1471 + }
1472 +
1473 + $sanitized = array();
1474 + foreach ( array( 'wpbc_settings_calendar_preview', 'wpbc_setup_wizard_date_selection_preview' ) as $marker_key ) {
1475 + if (
1476 + isset( $request_overrides[ $marker_key ] )
1477 + && is_scalar( $request_overrides[ $marker_key ] )
1478 + && '1' === (string) $request_overrides[ $marker_key ]
1479 + ) {
1480 + $sanitized[ $marker_key ] = 1;
1481 + }
1482 + }
1483 + if ( isset( $request_overrides['wpbc_setup_wizard_date_selection_preview_nonce'] ) && is_scalar( $request_overrides['wpbc_setup_wizard_date_selection_preview_nonce'] ) ) {
1484 + $sanitized['wpbc_setup_wizard_date_selection_preview_nonce'] = sanitize_text_field( (string) $request_overrides['wpbc_setup_wizard_date_selection_preview_nonce'] );
1485 + }
1486 +
1487 + $toggle_keys = array(
1488 + 'wpbc_settings_calendar_preview_changeover',
1489 + 'wpbc_settings_calendar_preview_triangles',
1490 + 'wpbc_settings_calendar_preview_recurrent_time',
1491 + 'wpbc_settings_calendar_preview_last_checkout',
1492 + 'wpbc_settings_calendar_preview_show_legend',
1493 + 'wpbc_settings_calendar_preview_legend_show_numbers',
1494 + 'wpbc_settings_calendar_preview_legend_vertical',
1495 + );
1496 + foreach ( array( 'available', 'pending', 'approved', 'partially', 'unavailable' ) as $legend_item ) {
1497 + $toggle_keys[] = 'wpbc_settings_calendar_preview_legend_show_' . $legend_item;
1498 + }
1499 + foreach ( $toggle_keys as $toggle_key ) {
1500 + if ( isset( $request_overrides[ $toggle_key ] ) && is_scalar( $request_overrides[ $toggle_key ] ) && in_array( (string) $request_overrides[ $toggle_key ], array( 'On', 'Off' ), true ) ) {
1501 + $sanitized[ $toggle_key ] = (string) $request_overrides[ $toggle_key ];
1502 + }
1503 + }
1504 +
1505 + foreach ( array( 'available', 'pending', 'approved', 'partially', 'unavailable' ) as $legend_item ) {
1506 + $text_key = 'wpbc_settings_calendar_preview_legend_text_' . $legend_item;
1507 + if ( isset( $request_overrides[ $text_key ] ) && is_scalar( $request_overrides[ $text_key ] ) ) {
1508 + $legend_text = sanitize_text_field( (string) $request_overrides[ $text_key ] );
1509 + $sanitized[ $text_key ] = function_exists( 'mb_substr' ) ? mb_substr( $legend_text, 0, 255 ) : substr( $legend_text, 0, 255 );
1510 + }
1511 + }
1512 +
1513 + return $sanitized;
1514 + }
1515 +
1516 + /**
1517 + * Normalize one preview boolean without treating malformed containers as true.
1518 + *
1519 + * Preview context normally originates from a server-owned mapper, but the
1520 + * shared transient boundary validates it again so future callers cannot turn
1521 + * arrays or arbitrary strings into enabled behavior through PHP casting.
1522 + *
1523 + * @param mixed $raw_value Candidate boolean value.
1524 + *
1525 + * @return bool|null Normalized boolean, or null when the value is invalid.
1526 + */
1527 + private function sanitize_preview_boolean( $raw_value ) {
1528 + if ( true === $raw_value || 1 === $raw_value || '1' === $raw_value || 'On' === $raw_value ) {
1529 + return true;
1530 + }
1531 +
1532 + if ( false === $raw_value || 0 === $raw_value || '0' === $raw_value || 'Off' === $raw_value ) {
1533 + return false;
1534 + }
1535 +
1536 + return null;
1537 + }
1538 +
1539 + /**
1540 + * Normalize a scalar or array of integers against explicit bounds.
1541 + *
1542 + * @param mixed $raw_list Candidate array or comma-separated list.
1543 + * @param int $minimum Inclusive lower bound.
1544 + * @param int $maximum Inclusive upper bound.
1545 + * @param int[] $fallback Fallback list when no values remain.
1546 + *
1547 + * @return int[] Unique normalized integers.
1548 + */
1549 + private function sanitize_preview_integer_list( $raw_list, $minimum, $maximum, array $fallback ) {
1550 + $raw_values = is_array( $raw_list ) ? $raw_list : explode( ',', is_scalar( $raw_list ) ? (string) $raw_list : '' );
1551 + $integers = array();
1552 + foreach ( $raw_values as $raw_value ) {
1553 + if ( ! is_scalar( $raw_value ) || ! preg_match( '/^-?\d+$/', trim( (string) $raw_value ) ) ) {
1554 + continue;
1555 + }
1556 + $integer_value = (int) $raw_value;
1557 + if ( $integer_value >= $minimum && $integer_value <= $maximum ) {
1558 + $integers[] = $integer_value;
1559 + }
1560 + }
1561 +
1562 + return empty( $integers ) ? $fallback : array_values( array_unique( $integers ) );
1563 + }
1564 +
1565 + /**
1566 + * Validate one relative calendar skin path against the live skin registry.
1567 + *
1568 + * @param string $calendar_skin Candidate relative path or registered URL.
1569 + *
1570 + * @return string Valid relative skin path, or an empty string.
1571 + */
1572 + private function sanitize_preview_calendar_skin( $calendar_skin ) {
1573 + if ( ! function_exists( 'wpbc_get_calendar_skin_options' ) ) {
1574 + return '';
1575 + }
1576 +
1577 + $calendar_skin = $this->normalize_preview_calendar_skin( $calendar_skin );
1578 + foreach ( wpbc_get_calendar_skin_options() as $registered_skin => $registered_label ) {
1579 + if ( is_array( $registered_label ) && ! empty( $registered_label['optgroup'] ) ) {
1580 + continue;
1581 + }
1582 +
1583 + if ( $calendar_skin === $this->normalize_preview_calendar_skin( $registered_skin ) ) {
1584 + return $calendar_skin;
1585 + }
1586 + }
1587 +
1588 + return '';
1589 + }
1590 +
1591 + /**
1592 + * Convert a registered skin URL or filesystem path to canonical relative form.
1593 + *
1594 + * @param mixed $calendar_skin Calendar skin path or URL.
1595 + *
1596 + * @return string Normalized relative path.
1597 + */
1598 + private function normalize_preview_calendar_skin( $calendar_skin ) {
1599 + $calendar_skin = is_scalar( $calendar_skin ) ? sanitize_text_field( (string) $calendar_skin ) : '';
1600 + $replace = array( WPBC_PLUGIN_DIR, WPBC_PLUGIN_URL );
1601 + $upload_dir = wp_upload_dir();
1602 +
1603 + if ( ! empty( $upload_dir['basedir'] ) ) {
1604 + $replace[] = $upload_dir['basedir'];
1605 + }
1606 + if ( ! empty( $upload_dir['baseurl'] ) ) {
1607 + $replace[] = $upload_dir['baseurl'];
1608 + }
1609 +
1610 + return str_replace( $replace, '', $calendar_skin );
1611 + }
673 1612
674 - $preview_html = do_shortcode( $shortcode );
675 -
676 - return $this->filter_wrapped_html_for_preview_form_style( $preview_html, array(), $resource_id, $form_name );
677 - }
678 -
679 1613 public function filter_form_advanced_form_for_preview( $advanced_form, $form_id ) {
680 1614
681 1615 if ( empty( $this->current_preview_data ) ) {
682 1616 return $advanced_form;
@@ -681,9 +1615,9 @@
681 1615 if ( empty( $this->current_preview_data ) ) {
682 1616 return $advanced_form;
683 1617 }
684 1618
685 - 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'] ) {
686 1620 return $advanced_form;
687 1621 }
688 1622
689 1623 if ( isset( $this->current_preview_data['advanced_form'] ) ) {
@@ -698,9 +1632,9 @@
698 1632 if ( empty( $this->current_preview_data ) ) {
699 1633 return $content_form;
700 1634 }
701 1635
702 - 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'] ) {
703 1637 return $content_form;
704 1638 }
705 1639
706 1640 if ( isset( $this->current_preview_data['content_form'] ) ) {
@@ -726,9 +1660,9 @@
726 1660 if ( empty( $this->current_preview_data ) ) {
727 1661 return $structure;
728 1662 }
729 1663
730 - 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'] ) {
731 1665 return $structure;
732 1666 }
733 1667
734 1668 if ( empty( $this->current_preview_data['structure'] ) || ! is_array( $this->current_preview_data['structure'] ) ) {
@@ -906,9 +1840,9 @@
906 1840 * Hide the preview page from the Pages list in admin.
907 1841 *
908 1842 * @param WP_Query $query Main query.
909 1843 */
910 - public function hide_preview_page_in_admin_list( $query ) {
1844 + public function hide_preview_page_in_admin_list( $query ) {
911 1845
912 1846 if ( ! is_admin() || ! $query->is_main_query() ) {
913 1847 return;
914 1848 }
@@ -933,75 +1867,417 @@
933 1867
934 1868 $not_in = (array) $query->get( 'post__not_in' );
935 1869 $not_in[] = (int) $page_id;
936 1870
937 - $query->set( 'post__not_in', $not_in );
938 - }
939 -
940 - // FixIn: 2026-01-03 14:31.
1871 + $query->set( 'post__not_in', $not_in );
1872 + }
1873 +
1874 + /**
1875 + * Render one inline preview and return its data-only browser bootstrap.
1876 + *
1877 + * This is the reusable response boundary for authenticated administration
1878 + * screens. The HTML is rendered by the normal front-end renderer, while all
1879 + * script elements are removed. The browser receives only JSON-safe values and
1880 + * initializes the form through known Booking Calendar functions; response
1881 + * JavaScript is never evaluated.
1882 + *
1883 + * The caller must resolve templates from a server-owned allow-list and must
1884 + * perform its own capability and nonce checks before returning this payload.
1885 + *
1886 + * @param int $preview_form_id Preview resource/calendar ID.
1887 + * @param array $structure Decoded BFB structure.
1888 + * @param string $form_name Form slug rendered by the booking form.
1889 + * @param string $advanced_form Exported booking form source.
1890 + * @param string $content_form Exported booking-data content source.
1891 + * @param array $form_style Optional unsaved Form Style settings.
1892 + * @param array $preview_context Optional server-owned renderer and option context.
1893 + *
1894 + * @return array<string,mixed>|false Inline HTML and bootstrap data, or false on failure.
1895 + */
1896 + public function render_inline_preview_payload( $preview_form_id, $structure, $form_name = 'standard', $advanced_form = '', $content_form = '', $form_style = array(), $preview_context = array() ) {
1897 + $preview_form_id = is_scalar( $preview_form_id ) ? absint( $preview_form_id ) : 0;
1898 + $preview_form_id = $preview_form_id > 0 ? $preview_form_id : 1;
1899 + $form_name = is_scalar( $form_name ) ? sanitize_key( (string) $form_name ) : '';
1900 + $form_name = '' === $form_name ? 'standard' : $form_name;
1901 + $preview_html = $this->render_inline_preview(
1902 + $preview_form_id,
1903 + $structure,
1904 + $form_name,
1905 + $advanced_form,
1906 + $content_form,
1907 + $form_style,
1908 + $preview_context
1909 + );
1910 +
1911 + if ( ! is_string( $preview_html ) || '' === trim( $preview_html ) ) {
1912 + return false;
1913 + }
1914 +
1915 + return array(
1916 + 'html' => $this->remove_script_elements( $preview_html ),
1917 + 'bootstrap' => $this->get_inline_preview_bootstrap_data( $preview_form_id, $form_name, $preview_context ),
1918 + );
1919 + }
1920 +
1921 + /**
1922 + * Remove executable elements from renderer HTML before an AJAX response.
1923 + *
1924 + * The normal front-end renderer can return a legacy inline calendar bootstrap
1925 + * during AJAX requests. Inline previews use the structured bootstrap returned
1926 + * beside the HTML, so no script element is needed or permitted in this path.
1927 + *
1928 + * @param string $preview_html Server-rendered preview HTML.
1929 + *
1930 + * @return string Preview HTML without script elements.
1931 + */
1932 + private function remove_script_elements( $preview_html ) {
1933 + $preview_html = is_string( $preview_html ) ? $preview_html : '';
1934 + $preview_html = preg_replace( '#<script\b[^>]*>.*?</script\s*>#is', '', $preview_html );
1935 +
1936 + return is_string( $preview_html ) ? $preview_html : '';
1937 + }
1938 +
1939 + /**
1940 + * Build the explicit JSON-safe bootstrap contract for an inline form.
1941 + *
1942 + * @param int $preview_form_id Positive booking resource ID.
1943 + * @param string $form_name Validated Form Builder slug.
1944 + * @param array<string,mixed> $preview_context Optional server-owned calendar context.
1945 + *
1946 + * @return array<string,mixed> Allow-listed Booking Calendar initialization data.
1947 + */
1948 + private function get_inline_preview_bootstrap_data( $preview_form_id, $form_name, array $preview_context = array() ) {
1949 + $days_selection = $this->get_inline_preview_days_selection();
1950 + $balancer_max_threads = absint( get_bk_option( 'booking_load_balancer_max_threads' ) );
1951 + $balancer_max_threads = $balancer_max_threads > 0 ? $balancer_max_threads : 1;
1952 + $is_enabled_change_over = function_exists( 'wpbc_is_booking_used_check_in_out_time' )
1953 + ? (bool) wpbc_is_booking_used_check_in_out_time( false, $preview_form_id )
1954 + : false;
1955 + $range_guidance_default = function_exists( 'wpbc_frontend_messages__is_enabled' )
1956 + ? wpbc_frontend_messages__is_enabled( 'message_range_selection_click_last_date' )
1957 + : true;
1958 + $range_guidance_enabled = (bool) apply_filters(
1959 + 'wpbc_calendar_range_selection_guidance_is_enabled',
1960 + $range_guidance_default,
1961 + $preview_form_id,
1962 + array(
1963 + 'resource_id' => $preview_form_id,
1964 + 'custom_form' => $form_name,
1965 + )
1966 + );
1967 + $request_uri = '';
1968 +
1969 + if ( isset( $_SERVER['REQUEST_URI'] ) && is_scalar( $_SERVER['REQUEST_URI'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1970 + $request_uri = esc_url_raw( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1971 + }
1972 +
1973 + $calendar_parameters = array(
1974 + 'is_enabled_change_over' => $is_enabled_change_over,
1975 + 'calendar_scroll_to' => false,
1976 + 'calendar_dates_start' => '',
1977 + 'calendar_dates_end' => '',
1978 + 'booking_max_monthes_in_calendar' => (string) get_bk_option( 'booking_max_monthes_in_calendar' ),
1979 + 'booking_start_day_weeek' => (string) get_bk_option( 'booking_start_day_weeek' ),
1980 + 'calendar_number_of_months' => '1',
1981 + 'days_select_mode' => (string) $days_selection['days_select_mode'],
1982 + 'fixed__days_num' => (int) $days_selection['fixed__days_num'],
1983 + 'fixed__week_days__start' => $this->normalize_inline_preview_integer_list( $days_selection['fixed__week_days__start'], array( -1 ) ),
1984 + 'dynamic__days_min' => (int) $days_selection['dynamic__days_min'],
1985 + 'dynamic__days_max' => (int) $days_selection['dynamic__days_max'],
1986 + 'dynamic__days_specific' => $this->normalize_inline_preview_integer_list( $days_selection['dynamic__days_specific'], array() ),
1987 + 'dynamic__week_days__start' => $this->normalize_inline_preview_integer_list( $days_selection['dynamic__week_days__start'], array( -1 ) ),
1988 + 'range_selection_guidance_is_enabled' => $range_guidance_enabled,
1989 + 'booking_date_format' => (string) get_bk_option( 'booking_date_format' ),
1990 + 'booking_time_format' => (string) get_bk_option( 'booking_time_format' ),
1991 + );
1992 +
1993 + if ( class_exists( 'wpdev_bk_biz_l' ) ) {
1994 + $calendar_parameters['is_parent_resource'] = function_exists( 'wpbc_get_child_resources_number' ) && wpbc_get_child_resources_number( $preview_form_id ) ? 1 : 0;
1995 + $calendar_parameters['booking_capacity_field'] = function_exists( 'wpbc_get__booking_capacity_field__name' ) ? (string) wpbc_get__booking_capacity_field__name() : '';
1996 + $calendar_parameters['booking_is_dissbale_booking_for_different_sub_resources'] = (string) get_bk_option( 'booking_is_dissbale_booking_for_different_sub_resources' );
1997 + }
1998 +
1999 + if ( class_exists( 'wpdev_bk_biz_s' ) ) {
2000 + $calendar_parameters['booking_recurrent_time'] = (string) get_bk_option( 'booking_recurrent_time' );
2001 + }
2002 +
2003 + $preview_calendar_parameters = isset( $preview_context['calendar_parameters'] )
2004 + ? $this->sanitize_preview_calendar_parameters( $preview_context['calendar_parameters'] )
2005 + : array();
2006 + $calendar_parameters = array_replace( $calendar_parameters, $preview_calendar_parameters );
2007 + $is_enabled_change_over = isset( $calendar_parameters['is_enabled_change_over'] )
2008 + ? (bool) $calendar_parameters['is_enabled_change_over']
2009 + : $is_enabled_change_over;
2010 + $calendar_request_overrides = isset( $preview_context['calendar_request_overrides'] )
2011 + ? $this->sanitize_preview_calendar_request_overrides( $preview_context['calendar_request_overrides'] )
2012 + : array();
2013 +
2014 + return array(
2015 + 'balancer_max_threads' => $balancer_max_threads,
2016 + 'is_enabled_change_over' => $is_enabled_change_over,
2017 + 'classic_booking_context_token' => '',
2018 + 'calendar_parameters' => $calendar_parameters,
2019 + 'secure_parameters' => array(
2020 + 'nonce' => wp_create_nonce( 'wpbc_calendar_load_ajx_wpbcnonce' ),
2021 + 'user_id' => function_exists( 'wpbc_get_current_user_id' ) ? (string) wpbc_get_current_user_id() : (string) get_current_user_id(),
2022 + 'locale' => (string) get_user_locale(),
2023 + ),
2024 + 'calendar_request' => array_merge(
2025 + array(
2026 + 'resource_id' => $preview_form_id,
2027 + 'booking_hash' => '',
2028 + 'request_uri' => $request_uri,
2029 + 'custom_form' => $form_name,
2030 + 'aggregate_resource_id_str' => '',
2031 + 'aggregate_type' => 'all',
2032 + 'skip_general_availability' => 0,
2033 + 'classic_booking_context_token' => '',
2034 + ),
2035 + $calendar_request_overrides
2036 + ),
2037 + );
2038 + }
2039 +
2040 + /**
2041 + * Return normalized day-selection options for the inline calendar contract.
2042 + *
2043 + * @return array<string,mixed> Day-selection values.
2044 + */
2045 + private function get_inline_preview_days_selection() {
2046 + $defaults = array(
2047 + 'days_select_mode' => 'multiple',
2048 + 'fixed__days_num' => 0,
2049 + 'fixed__week_days__start' => '-1',
2050 + 'dynamic__days_min' => 0,
2051 + 'dynamic__days_max' => 0,
2052 + 'dynamic__days_specific' => '',
2053 + 'dynamic__week_days__start' => '-1',
2054 + );
2055 + $days_selection = function_exists( 'wpbc__calendar__js_params__get_days_selection_arr' )
2056 + ? wpbc__calendar__js_params__get_days_selection_arr()
2057 + : array();
2058 +
2059 + return wp_parse_args( is_array( $days_selection ) ? $days_selection : array(), $defaults );
2060 + }
2061 +
2062 + /**
2063 + * Convert a stored comma list into bounded JSON-safe integers.
2064 + *
2065 + * @param mixed $raw_list Candidate array or comma-separated list.
2066 + * @param array $fallback Fallback list when no integers are present.
2067 + *
2068 + * @return int[] Normalized integers.
2069 + */
2070 + private function normalize_inline_preview_integer_list( $raw_list, array $fallback ) {
2071 + $raw_values = is_array( $raw_list ) ? $raw_list : explode( ',', (string) $raw_list );
2072 + $integers = array();
2073 +
2074 + foreach ( $raw_values as $raw_value ) {
2075 + if ( ! is_scalar( $raw_value ) || ! preg_match( '/^-?\d+$/', trim( (string) $raw_value ) ) ) {
2076 + continue;
2077 + }
2078 +
2079 + $integers[] = (int) $raw_value;
2080 + }
2081 +
2082 + return empty( $integers ) ? $fallback : array_values( array_unique( $integers ) );
2083 + }
2084 +
2085 + /**
2086 + * Render one unsaved Form Builder snapshot directly in the current request.
2087 + *
2088 + * This is the synchronous counterpart to {@see create_preview_session()} for
2089 + * administration screens that already load the Booking Calendar front-end
2090 + * assets. It uses the same source, markup, option, and Form Style filters but
2091 + * creates no transient, page, post, or saved Form Builder record. All filters
2092 + * and request-scoped state are removed before returning.
2093 + *
2094 + * The caller must resolve template definitions from a server-owned allow-list
2095 + * before invoking this method. Raw request values must never be passed here.
2096 + *
2097 + * @param int $preview_form_id Preview resource/calendar ID.
2098 + * @param array $structure Decoded BFB structure.
2099 + * @param string $form_name Form slug rendered by the booking form.
2100 + * @param string $advanced_form Exported booking form source.
2101 + * @param string $content_form Exported booking-data content source.
2102 + * @param array $form_style Optional unsaved Form Style settings.
2103 + * @param array $preview_context Optional server-owned renderer and option context.
2104 + *
2105 + * @return string|false Rendered booking form HTML, or false when validation or rendering fails.
2106 + */
2107 + public function render_inline_preview( $preview_form_id, $structure, $form_name = 'standard', $advanced_form = '', $content_form = '', $form_style = array(), $preview_context = array() ) {
2108 +
2109 + if ( ! class_exists( 'WPBC_FE_Render' ) || null !== $this->current_preview_data ) {
2110 + return false;
2111 + }
2112 +
2113 + $preview_form_id = is_scalar( $preview_form_id ) ? absint( $preview_form_id ) : 0;
2114 + $preview_form_id = $preview_form_id > 0 ? $preview_form_id : 1;
2115 + $structure = $this->sanitize_preview_structure( $structure );
2116 + $form_name = is_scalar( $form_name ) ? sanitize_text_field( (string) $form_name ) : '';
2117 + $advanced_form = $this->sanitize_preview_form_source( $advanced_form );
2118 + $content_form = $this->sanitize_preview_form_source( $content_form );
2119 + $form_style = $this->sanitize_preview_form_style( $form_style );
2120 + $preview_context = is_array( $preview_context ) ? $preview_context : array();
2121 + $render_mode = isset( $preview_context['render_mode'] )
2122 + && is_scalar( $preview_context['render_mode'] )
2123 + && 'appointment' === sanitize_key( (string) $preview_context['render_mode'] )
2124 + ? 'appointment'
2125 + : 'booking';
2126 + $option_overrides = isset( $preview_context['option_overrides'] )
2127 + ? $this->sanitize_preview_option_overrides( $preview_context['option_overrides'] )
2128 + : array();
2129 + $calendar_parameters = isset( $preview_context['calendar_parameters'] )
2130 + ? $this->sanitize_preview_calendar_parameters( $preview_context['calendar_parameters'] )
2131 + : array();
2132 + $calendar_request_overrides = isset( $preview_context['calendar_request_overrides'] )
2133 + ? $this->sanitize_preview_calendar_request_overrides( $preview_context['calendar_request_overrides'] )
2134 + : array();
2135 +
2136 + if (
2137 + null === $structure
2138 + || null === $advanced_form
2139 + || null === $content_form
2140 + || null === $form_style
2141 + ) {
2142 + return false;
2143 + }
2144 +
2145 + $form_name = '' === $form_name ? 'standard' : $form_name;
2146 + $this->current_preview_data = array(
2147 + 'form_id' => $preview_form_id,
2148 + 'resource_id' => $preview_form_id,
2149 + 'form_name' => $form_name,
2150 + 'scope' => 'inline_preview',
2151 + 'render_mode' => $render_mode,
2152 + 'structure' => $structure,
2153 + 'advanced_form' => $advanced_form,
2154 + 'content_form' => $content_form,
2155 + 'form_style' => $form_style,
2156 + 'option_overrides' => $option_overrides,
2157 + 'calendar_parameters' => $calendar_parameters,
2158 + 'calendar_request_overrides' => $calendar_request_overrides,
2159 + );
2160 +
2161 + $this->register_preview_source_filters();
2162 +
2163 + try {
2164 + $preview_html = WPBC_FE_Render::render_booking_form(
2165 + array(
2166 + 'resource_id' => $preview_form_id,
2167 + 'cal_count' => 1,
2168 + 'is_echo' => 0,
2169 + 'custom_booking_form' => $form_name,
2170 + 'form_status' => 'preview',
2171 + 'calendar_request_overrides' => $calendar_request_overrides,
2172 + )
2173 + );
2174 + $preview_html = is_string( $preview_html ) ? $preview_html : '';
2175 +
2176 + return $this->filter_wrapped_html_for_preview_form_style( $preview_html, array(), $preview_form_id, $form_name );
2177 + } finally {
2178 + $this->remove_preview_source_filters();
2179 + $this->remove_preview_option_filter();
2180 + $this->current_preview_data = null;
2181 + }
2182 + }
2183 +
2184 + // FixIn: 2026-01-03 14:31.
941 2185 /**
942 - * Create a preview session: store transient snapshot and return preview URL + token.
943 - *
944 - * @param int $preview_form_id Preview form/resource ID.
945 - * @param int $user_id Current user ID.
946 - * @param array $structure Decoded BFB structure.
947 - *
948 - * @return array|false { preview_url, token } or false on failure.
2186 + * Create a preview session: store transient snapshot and return preview URL + token.
2187 + *
2188 + * @param int $preview_form_id Preview resource/calendar ID.
2189 + * @param int $user_id Current user ID.
2190 + * @param array $structure Decoded BFB structure.
2191 + * @param string $form_name Form slug rendered by the booking shortcode.
2192 + * @param string $advanced_form Exported booking form source.
2193 + * @param string $content_form Exported booking-data content source.
2194 + * @param array $form_style Optional unsaved Form Style settings.
2195 + * @param array $preview_context Optional server-owned renderer and option context.
2196 + *
2197 + * @return array|false Preview URL and token, or false on failure.
949 2198 */
950 - public function create_preview_session( $preview_form_id, $user_id, $structure, $form_name = 'standard', $advanced_form = '', $content_form = '', $form_style = array() ) {
951 -
952 -
953 - $preview_form_id = (int) $preview_form_id;
954 - $user_id = (int) $user_id;
955 - $structure = ( is_array( $structure ) ? $structure : array() );
956 - $form_name = sanitize_text_field( (string) $form_name );
957 - if ( '' === $form_name ) {
958 - $form_name = 'standard';
959 - }
960 - $form_style = is_array( $form_style ) ? $form_style : array();
961 - if ( ! empty( $form_style ) ) {
962 - $style_key = isset( $form_style['booking_form_style'] ) ? $form_style['booking_form_style'] : '';
963 - $sanitized_style = wpbc_bfb_settings__sanitize_form_style( $style_key );
964 - $custom_style = wpbc_bfb_settings__get_custom_form_style_options( $form_style );
965 - $accent_style = wpbc_bfb_settings__get_form_accent_options( $form_style );
966 - $form_style = array_merge(
967 - array(
968 - 'booking_form_style' => $sanitized_style,
969 - ),
970 - $custom_style,
971 - $accent_style
972 - );
973 - }
974 - if ( $preview_form_id <= 0 ) {
975 - $preview_form_id = 1;
976 - }
977 - if ( $user_id <= 0 ) {
978 - return false;
979 - }
980 -
981 - $page_id = $this->get_preview_page_id();
2199 + public function create_preview_session( $preview_form_id, $user_id, $structure, $form_name = 'standard', $advanced_form = '', $content_form = '', $form_style = array(), $preview_context = array() ) {
2200 +
2201 + $preview_form_id = is_scalar( $preview_form_id ) ? absint( $preview_form_id ) : 0;
2202 + $user_id = is_scalar( $user_id ) ? absint( $user_id ) : 0;
2203 + $authenticated_user_id = get_current_user_id();
2204 +
2205 + if ( $preview_form_id <= 0 ) {
2206 + $preview_form_id = 1;
2207 + }
2208 + if ( $authenticated_user_id <= 0 || $user_id !== $authenticated_user_id ) {
2209 + return false;
2210 + }
2211 + $user_id = $authenticated_user_id;
2212 +
2213 + $structure = $this->sanitize_preview_structure( $structure );
2214 + $form_name = is_scalar( $form_name ) ? sanitize_text_field( (string) $form_name ) : '';
2215 + $advanced_form = $this->sanitize_preview_form_source( $advanced_form );
2216 + $content_form = $this->sanitize_preview_form_source( $content_form );
2217 + $form_style = $this->sanitize_preview_form_style( $form_style );
2218 + $preview_context = is_array( $preview_context ) ? $preview_context : array();
2219 + $render_mode = isset( $preview_context['render_mode'] )
2220 + && is_scalar( $preview_context['render_mode'] )
2221 + && 'appointment' === sanitize_key( (string) $preview_context['render_mode'] )
2222 + ? 'appointment'
2223 + : 'booking';
2224 + $option_overrides = isset( $preview_context['option_overrides'] )
2225 + ? $this->sanitize_preview_option_overrides( $preview_context['option_overrides'] )
2226 + : array();
2227 + $calendar_parameters = isset( $preview_context['calendar_parameters'] )
2228 + ? $this->sanitize_preview_calendar_parameters( $preview_context['calendar_parameters'] )
2229 + : array();
2230 + $calendar_request_overrides = isset( $preview_context['calendar_request_overrides'] )
2231 + ? $this->sanitize_preview_calendar_request_overrides( $preview_context['calendar_request_overrides'] )
2232 + : array();
2233 +
2234 + if (
2235 + null === $structure
2236 + || null === $advanced_form
2237 + || null === $content_form
2238 + || null === $form_style
2239 + ) {
2240 + return false;
2241 + }
2242 +
2243 + if ( '' === $form_name ) {
2244 + $form_name = 'standard';
2245 + }
2246 +
2247 + $page_id = $this->get_preview_page_id();
982 2248 if ( ! $page_id ) {
983 2249 return false;
984 2250 }
985 2251
986 - $token = wp_generate_password( 12, false, false );
987 - $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 );
988 2258
989 - $payload = array(
990 - 'user_id' => $user_id,
991 - // Keep key name as-is (your preview reader expects ['form_id']).
992 - // This is a *preview context resource/calendar id* (used for shortcode type="...").
993 - 'form_id' => $preview_form_id,
994 - 'form_name' => $form_name,
995 - 'scope' => 'preview',
996 - 'structure' => $structure,
997 - 'time' => time(),
998 - 'advanced_form' => (string) $advanced_form,
999 - 'content_form' => (string) $content_form,
1000 - 'form_style' => $form_style,
1001 - );
2259 + $payload = array(
2260 + 'user_id' => $user_id,
2261 + // Keep form_id for the existing URL/transient and booking-submit contract.
2262 + 'form_id' => $preview_form_id,
2263 + 'resource_id' => $preview_form_id,
2264 + 'form_name' => $form_name,
2265 + 'scope' => 'preview',
2266 + 'render_mode' => $render_mode,
2267 + 'structure' => $structure,
2268 + 'time' => time(),
2269 + 'advanced_form' => $advanced_form,
2270 + 'content_form' => $content_form,
2271 + 'form_style' => $form_style,
2272 + 'option_overrides' => $option_overrides,
2273 + 'calendar_parameters' => $calendar_parameters,
2274 + 'calendar_request_overrides' => $calendar_request_overrides,
2275 + );
1002 2276
1003 - set_transient( $transient_key, $payload, 10 * MINUTE_IN_SECONDS );
2277 + if ( ! set_transient( $transient_key, $payload, 10 * MINUTE_IN_SECONDS ) ) {
2278 + return false;
2279 + }
1004 2280
1005 2281 $preview_url = add_query_arg( array(
1006 2282 'wpbc_bfb_preview' => 1,
1007 2283 'wpbc_bfb_preview_token' => rawurlencode( $token ),
@@ -1008,12 +2284,12 @@
1008 2284 'wpbc_bfb_preview_form_id' => $preview_form_id,
1009 2285 'nonce' => wp_create_nonce( 'wpbc_bfb_preview_' . $token ),
1010 2286 ), get_permalink( $page_id ) );
1011 2287
1012 - return array(
1013 - 'preview_url' => $preview_url,
1014 - 'token' => $token,
1015 - );
2288 + return array(
2289 + 'preview_url' => esc_url_raw( $preview_url ),
2290 + 'token' => $token,
2291 + );
1016 2292 }
1017 2293
1018 2294 }
1019 2295