PluginProbe
Booking Calendar / 11.3
Booking Calendar v11.3
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 10.11 All 204 releases
booking / includes / page-form-builder / ajax / bfb-ajax.php

bfb-ajax.php in Booking Calendar 11.3, at includes/page-form-builder/ajax/bfb-ajax.php

1,814 lines 58.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AJAX controller for Booking Form Builder (BFB) FormConfig.
4 *
5 * Responsibilities:
6 * - Save Builder structure (+ exported shortcodes) into booking_form_structures table.
7 * - Load FormConfig (DB first, legacy options fallback) for the Builder UI.
8 *
9 * This file exposes AJAX endpoints:
10 * - WPBC_AJX_BFB_SAVE_FORM_CONFIG -> wpbc_bfb_ajax_save_form_config()
11 * - WPBC_AJX_BFB_LOAD_FORM_CONFIG -> wpbc_bfb_ajax_load_form_config()
12 * - WPBC_AJX_BFB_CREATE_FORM_CONFIG -> wpbc_bfb_ajax_create_form_config()
13 * - WPBC_AJX_BFB_LIST_FORMS -> wpbc_bfb_ajax_list_forms()
14 * - WPBC_AJX_BFB_DELETE_FORM_CONFIG -> wpbc_bfb_ajax_delete_form_config()
15 * - WPBC_AJX_BFB_DELETE_TEMPLATE_CONFIG -> wpbc_bfb_ajax_delete_template_config()
16 *
17 * Both endpoints work with the normalized FormConfig structure defined in
18 * bfb-form-manager.php.
19 *
20 * @package Booking Calendar.
21 * @subpackage Form Builder
22 *
23 * @since 11.0.0
24 * @file ../includes/page-form-builder/ajax/bfb-ajax.php
25 */
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit;
29 }
30
31 /**
32 * OR separator for template search queries (UI + AJAX).
33 *
34 * Used by wpbc_bfb_ajax_list_forms() to support multi-keyword searches:
35 * "time|duration|slots"
36 *
37 * NOTE:
38 * - This separator is for AJAX search (POST) and UI input.
39 * - Do NOT use it in URLs. Some server configs can block "|" in URLs.
40 * - For URLs, use WPBC_BFB_TEMPLATE_SEARCH_OR_SEPARATOR_URL (default "^").
41 *
42 * @since 11.0.0
43 */
44 if ( ! defined( 'WPBC_BFB_TEMPLATE_SEARCH_OR_SEPARATOR' ) ) {
45 define( 'WPBC_BFB_TEMPLATE_SEARCH_OR_SEPARATOR', '|' );
46 }
47
48 /**
49 * OR separator for template search queries in URLs only.
50 *
51 * Used for redirects like:
52 * &auto_open_template=service^duration
53 *
54 * @since 11.0.0
55 */
56 if ( ! defined( 'WPBC_BFB_TEMPLATE_SEARCH_OR_SEPARATOR_URL' ) ) {
57 define( 'WPBC_BFB_TEMPLATE_SEARCH_OR_SEPARATOR_URL', '^' );
58 }
59
60 // == DEBUG ==
61 if ( ! defined( 'WPBC_BFB_DEBUG__FORM_NAME' ) ) {
62 // define( 'WPBC_BFB_DEBUG__FORM_NAME', 'wizard-1' );
63 }
64
65 // == Helpers == =======================================================================================================
66
67 /**
68 * Get capability required to manage booking forms in the Builder.
69 *
70 * Resolves to a WordPress capability based on the plugin setting
71 * booking_user_role_settings. This keeps Form Builder access aligned with the
72 * rest of the Booking Calendar admin UI.
73 *
74 * Mapping example:
75 * - administrator -> activate_plugins
76 * - editor -> publish_pages
77 * - author -> publish_posts
78 * - contributor -> edit_posts
79 * - subscriber -> read
80 *
81 * If the configured role is not recognized, falls back to manage_options.
82 *
83 * @since 11.0.0
84 *
85 * @return string Capability name.
86 */
87 function wpbc_bfb_get_manage_cap() {
88
89 $min_user_role = get_bk_option( 'booking_user_role_settings' );
90
91 $capability = array(
92 'administrator' => 'activate_plugins',
93 'editor' => 'publish_pages',
94 'author' => 'publish_posts',
95 'contributor' => 'edit_posts',
96 'subscriber' => 'read',
97 );
98
99 if ( isset( $capability[ $min_user_role ] ) ) {
100 return $capability[ $min_user_role ];
101 }
102
103 // Fallback: admins only.
104 return 'manage_options';
105 }
106
107 /**
108 * Extend the list of safe inline CSS properties for BFB-generated markup.
109 *
110 * Callback for the safe_style_css filter. It ensures that BFB-specific inline
111 * styles (including CSS custom properties used by the layout engine) pass
112 * through wp_kses() sanitization.
113 *
114 * The base list of allowed properties is provided by core; this function
115 * appends additional properties if they are not already present.
116 *
117 * You can modify the final list via the wpbc_bfb_safe_style_props filter.
118 *
119 * @since 11.0.0
120 *
121 * @param string[] $styles Array of allowed CSS properties from core.
122 *
123 * @return string[] Modified array including BFB-specific properties.
124 */
125 function wpbc_bfb_safe_style_props_filter( $styles ) {
126
127 $extra_css_props = array(
128 'display',
129 'clear', // used in wizard hidden_style (optional, but safe)
130 'flex-basis', // IMPORTANT: exported per-column layout width
131 // Optional but often useful if you ever output them:
132 'flex',
133 'flex-grow',
134 'flex-shrink',
135 'width',
136 'min-width',
137 'max-width',
138 'box-sizing',
139
140 'transform',
141 'align-self',
142 '--wpbc-bfb-col-dir',
143 '--wpbc-bfb-col-wrap',
144 '--wpbc-bfb-col-jc',
145 '--wpbc-bfb-col-ai',
146 '--wpbc-bfb-col-gap',
147 '--wpbc-bfb-col-ac',
148 '--wpbc-bfb-col-aself',
149 '--wpbc-col-min',
150 );
151
152 /**
153 * Filter extra safe CSS properties for BFB inline styles.
154 *
155 * @since 11.0.0
156 *
157 * @param string[] $extra_css_props List of extra CSS properties.
158 */
159 $extra_css_props = apply_filters( 'wpbc_bfb_safe_style_props', $extra_css_props );
160
161 foreach ( $extra_css_props as $prop ) {
162 if ( ! in_array( $prop, $styles, true ) ) {
163 $styles[] = $prop;
164 }
165 }
166
167 return $styles;
168 }
169
170 /**
171 * Allow STRICT ONLY: transform: translate(... , ...) with numeric/% values
172 *
173 * @param $allow
174 * @param $css_test_string
175 *
176 * @return bool|mixed
177 */
178 function wpbc_bfb_allow_transform_translate_only( $allow, $css_test_string ) {
179 if ( $allow ) {
180 return $allow;
181 }
182
183 $css_test_string = trim( (string) $css_test_string );
184
185 // Allow ONLY: transform: translate(... , ...) with numeric/% values. Also allow px (common for translate), still STRICT.
186 if ( preg_match( '/^transform\s*:\s*translate(?:3d|x|y)?\(\s*-?\d+(?:\.\d+)?(?:%|px)?\s*,\s*-?\d+(?:\.\d+)?(?:%|px)?\s*(?:,\s*-?\d+(?:\.\d+)?(?:%|px)?\s*)?\)\s*$/i', $css_test_string ) ) {
187 return true;
188 }
189
190 return false;
191 }
192
193 /**
194 * Sanitize advanced/content booking form text coming from the Builder.
195 *
196 * @since 11.0.0
197 *
198 * @param string $form_value Raw form markup (may be slashed).
199 *
200 * @return string Sanitized form markup.
201 */
202 function wpbc_bfb_sanitize_form_text( $form_value ) {
203
204 $form_value = (string) $form_value;
205
206 if ( '' === $form_value ) {
207 return '';
208 }
209
210 // Make function self-contained for all call-sites.
211 $form_value = wp_unslash( $form_value );
212 $form_value = wp_kses_no_null( $form_value );
213
214 // Optional but recommended: avoid comment encoding artifacts.
215 // Remove this if you must preserve comments in DB exactly as-is.
216 $form_value = preg_replace( '/<!--[\s\S]*?-->/', '', $form_value );
217
218 // Start with WP default allowed tags, then extend with our custom tags (custom wins).
219 $allowed_tags = array_merge(
220 wp_kses_allowed_html( 'post' ),
221 wpbc_get_allowed_simple_html_tags__for_wp_kses() // Custom short tags used in legacy / advanced markup. // FixIn: 10.15.5.6.
222 );
223
224 // Allow 'name' on <p> (if used by legacy markup).
225 if ( isset( $allowed_tags['p'] ) ) {
226 $allowed_tags['p']['name'] = true;
227 }
228
229 // Extra attributes for layout/structure wrappers.
230 foreach ( array( 'div', 'span', 'hr' ) as $tag ) {
231 if ( ! isset( $allowed_tags[ $tag ] ) ) {
232 $allowed_tags[ $tag ] = array();
233 }
234 $allowed_tags[ $tag ]['data-bfb-type'] = true;
235 $allowed_tags[ $tag ]['data-orientation'] = true;
236 $allowed_tags[ $tag ]['name'] = true;
237 $allowed_tags[ $tag ]['aria-orientation'] = true;
238 }
239
240 // Temporarily allow extra inline style properties for BFB.
241 add_filter( 'safe_style_css', 'wpbc_bfb_safe_style_props_filter', 10, 1 );
242
243 // Allow ONLY transform: translate*(...) patterns (your strict validator).
244 add_filter( 'safecss_filter_attr_allow_css', 'wpbc_bfb_allow_transform_translate_only', 10, 2 );
245
246 $sanitized = wp_kses( $form_value, $allowed_tags );
247
248 remove_filter( 'safecss_filter_attr_allow_css', 'wpbc_bfb_allow_transform_translate_only', 10 );
249 remove_filter( 'safe_style_css', 'wpbc_bfb_safe_style_props_filter', 10 );
250
251 return $sanitized;
252 }
253
254 /**
255 * Sanitize a form slug/key.
256 *
257 * Allows: a-z, 0-9, underscore, dash.
258 *
259 * @param string $raw
260 *
261 * @return string
262 */
263 function wpbc_bfb__sanitize_form_slug( $raw ) {
264
265 $raw = strtolower( trim( sanitize_text_field( (string) $raw ) ) );
266
267 // Replace spaces with underscore for readability.
268 $raw = preg_replace( '/\s+/', '_', $raw );
269
270 // Keep only: a-z 0-9 _ -
271 $raw = preg_replace( '/[^a-z0-9_\-]/', '_', $raw );
272
273 // Collapse multiple separators.
274 $raw = preg_replace( '/[_\-]{2,}/', '_', $raw );
275
276 // Trim separators.
277 $raw = trim( $raw, '_-' );
278
279 return $raw;
280 }
281
282 /**
283 * Read form_details from POST (array or JSON string) and sanitize values.
284 *
285 * Keys:
286 * - form_name
287 * - title
288 * - description
289 * - picture_url
290 *
291 * IMPORTANT: We keep "presence" checks with array_key_exists() in the caller,
292 * so UI can intentionally clear values by sending empty string.
293 *
294 * @param mixed $raw
295 *
296 * @return array
297 */
298 function wpbc_bfb__normalize_form_details_from_post( $raw ) {
299
300 if ( is_string( $raw ) && '' !== $raw ) {
301 $tmp = json_decode( $raw, true );
302 if ( is_array( $tmp ) ) {
303 $raw = $tmp;
304 }
305 }
306
307 if ( ! is_array( $raw ) ) {
308 return array();
309 }
310
311 $out = array();
312
313 if ( array_key_exists( 'form_name', $raw ) ) {
314 $out['form_name'] = sanitize_text_field( $raw['form_name'] );
315 }
316
317 if ( array_key_exists( 'title', $raw ) ) {
318 $out['title'] = sanitize_text_field( (string) $raw['title'] );
319 }
320
321 if ( array_key_exists( 'description', $raw ) ) {
322 $out['description'] = sanitize_textarea_field( (string) $raw['description'] );
323 }
324
325 if ( array_key_exists( 'picture_url', $raw ) ) {
326 $out['picture_url'] = esc_url_raw( (string) $raw['picture_url'] );
327 }
328
329 return $out;
330 }
331
332 /**
333 * Split a search string into OR-terms by configured separator.
334 *
335 * Example (default "~"):
336 * - "time~duration~slots" => array( 'time', 'duration', 'slots' )
337 * - " time ~ duration " => array( 'time', 'duration' )
338 *
339 * @since 11.0.0
340 *
341 * @param string $search_raw Raw search string.
342 * @param int $max_terms Max number of terms allowed (anti-abuse).
343 *
344 * @return array List of unique, trimmed terms.
345 */
346 function wpbc_bfb__split_search_terms_by_or_separator( $search_raw, $max_terms = 5 ) {
347
348 $search_raw = trim( (string) $search_raw );
349
350 if ( '' === $search_raw ) {
351 return array();
352 }
353
354 $max_terms = absint( $max_terms );
355 if ( $max_terms <= 0 ) {
356 $max_terms = 5;
357 }
358
359
360 $sep = ( defined( 'WPBC_BFB_TEMPLATE_SEARCH_OR_SEPARATOR' ) ) ? (string) WPBC_BFB_TEMPLATE_SEARCH_OR_SEPARATOR : '|';
361 if ( '' === $sep ) {
362 $sep = '|';
363 }
364
365 $pattern = '/\s*' . preg_quote( $sep, '/' ) . '\s*/';
366 $parts = preg_split( $pattern, $search_raw );
367
368 if ( ! is_array( $parts ) ) {
369 return array();
370 }
371
372 $terms = array();
373
374 foreach ( $parts as $p ) {
375 $t = trim( (string) $p );
376 if ( '' === $t ) {
377 continue;
378 }
379 $terms[] = $t;
380 if ( count( $terms ) >= $max_terms ) {
381 break;
382 }
383 }
384
385 $terms = array_values( array_unique( $terms ) );
386
387 return $terms;
388 }
389
390 /**
391 * Normalize settings into array() and ensure ONLY supported schema exists:
392 * {
393 * options : {},
394 * css_vars : [],
395 * bfb_options : { advanced_mode_source: 'builder'|'advanced'|'auto' }
396 * }
397 *
398 * @param mixed $settings
399 *
400 * @return array
401 */
402 function wpbc_bfb__normalize_settings_array( $settings ) {
403
404 if ( is_string( $settings ) && '' !== $settings ) {
405 $tmp = json_decode( $settings, true );
406 if ( is_array( $tmp ) ) {
407 $settings = $tmp;
408 }
409 }
410
411 if ( ! is_array( $settings ) ) {
412 $settings = array();
413 }
414
415 if ( empty( $settings['options'] ) || ! is_array( $settings['options'] ) ) {
416 $settings['options'] = array();
417 }
418
419 if ( empty( $settings['css_vars'] ) || ! is_array( $settings['css_vars'] ) ) {
420 $settings['css_vars'] = array();
421 }
422
423 if ( empty( $settings['bfb_options'] ) || ! is_array( $settings['bfb_options'] ) ) {
424 $settings['bfb_options'] = array();
425 }
426
427 $src = isset( $settings['bfb_options']['advanced_mode_source'] ) ? strtolower( trim( (string) $settings['bfb_options']['advanced_mode_source'] ) ) : 'auto';
428 if ( ! in_array( $src, array( 'builder', 'advanced', 'auto' ), true ) ) {
429 $src = 'auto';
430 }
431 $settings['bfb_options']['advanced_mode_source'] = $src;
432
433 return $settings;
434 }
435
436 /**
437 * Check whether template key means "blank form".
438 *
439 * @param string $template_form_name
440 *
441 * @return bool
442 */
443 function wpbc_bfb__is_blank_template_key( $template_form_name ) {
444
445 $template_form_name = (string) $template_form_name;
446
447 return ( '' === $template_form_name || '__blank__' === $template_form_name || 'blank' === $template_form_name );
448 }
449
450 /**
451 * Get blank Builder structure seed.
452 *
453 * @return array
454 */
455 function wpbc_bfb__get_blank_structure_seed() {
456
457 return array(
458 array(
459 'page' => 1,
460 'content' => array(),
461 ),
462 );
463 }
464
465 /**
466 * Resolve BFB form/template picture URL.
467 *
468 * Rules:
469 * - If value is already an absolute URL, return as is.
470 * - If value is only a file name, first try local bundled templates image folder:
471 * ../includes/page-form-builder/save-load/../assets/template-img/
472 * - If local file does not exist, use external fallback base URL.
473 *
474 * @param string $picture_url Raw picture_url value from DB.
475 *
476 * @return string
477 */
478 function wpbc_bfb_resolve_picture_url( $picture_url ) {
479
480 $picture_url = trim( (string) $picture_url );
481
482 if ( '' === $picture_url ) {
483 return '';
484 }
485
486 // Already absolute URL or protocol-relative URL.
487 if (
488 ( false !== strpos( $picture_url, '://' ) ) ||
489 ( 0 === strpos( $picture_url, '//' ) )
490 ) {
491 return $picture_url;
492 }
493
494 // If path contains directories, treat it as already prepared relative path.
495 // This helper is intended mainly for simple file names like "template_appointments_01.png".
496 if (
497 ( false !== strpos( $picture_url, '/' ) ) ||
498 ( false !== strpos( $picture_url, '\\' ) )
499 ) {
500 return $picture_url;
501 }
502
503 $file_name = sanitize_file_name( wp_basename( $picture_url ) );
504 if ( '' === $file_name ) {
505 return '';
506 }
507
508 $local_dir_path = trailingslashit( plugin_dir_path( __FILE__ ) ) . '../assets/template-img/';
509 $local_file_path = $local_dir_path . $file_name;
510
511 if ( file_exists( $local_file_path ) ) {
512 return trailingslashit( plugin_dir_url( __FILE__ ) ) . '../assets/template-img/' . rawurlencode( $file_name );
513 }
514
515 $fallback_base_url = apply_filters( 'wpbc_bfb_template_picture_fallback_base_url', 'https://wpbookingcalendar.com/assets/template-img/' );
516
517 return trailingslashit( $fallback_base_url ) . rawurlencode( $file_name );
518 }
519
520 /**
521 * Verify AJAX delete nonce for BFB delete operations.
522 *
523 * Preferred nonce:
524 * - wpbc_bfb_form_delete
525 *
526 * Backward-compatible fallback:
527 * - wpbc_bfb_form_list
528 *
529 * This fallback allows template deletion from the Apply Template modal
530 * even if only nonce_list is localized in older builder pages.
531 *
532 * @since 11.0.0
533 *
534 * @return bool
535 */
536 function wpbc_bfb__verify_delete_request_nonce() {
537
538 if ( check_ajax_referer( 'wpbc_bfb_form_delete', 'nonce', false ) ) {
539 return true;
540 }
541
542 if ( check_ajax_referer( 'wpbc_bfb_form_list', 'nonce', false ) ) {
543 return true;
544 }
545
546 return false;
547 }
548
549 /**
550 * Check whether a listed template can be deleted in the current owner context.
551 *
552 * Rules:
553 * - Only rows with status=template are deletable.
554 * - Reserved/default templates are never deletable.
555 * - In MU regular-user context, only own templates are deletable.
556 * - In global/admin context, only global templates are deletable.
557 *
558 * @since 11.0.0
559 *
560 * @param string $form_slug Template slug.
561 * @param int $row_owner_user_id Owner of listed row.
562 * @param int $current_owner_user_id Current owner context.
563 * @param int $is_default Default flag.
564 * @param string $status Row status.
565 *
566 * @return bool
567 */
568 function wpbc_bfb__can_delete_template_in_current_context( $form_slug, $row_owner_user_id, $current_owner_user_id, $is_default, $status ) {
569
570 if ( 'template' !== (string) $status ) {
571 return false;
572 }
573
574 if ( 'standard' === (string) $form_slug ) {
575 return false;
576 }
577
578 if ( 1 === absint( $is_default ) ) {
579 return false;
580 }
581
582 $row_owner_user_id = absint( $row_owner_user_id );
583 $current_owner_user_id = absint( $current_owner_user_id );
584
585 if ( $current_owner_user_id > 0 ) {
586 return ( $row_owner_user_id === $current_owner_user_id );
587 }
588
589 return ( 0 === $row_owner_user_id );
590 }
591
592 // == AJAX == ==========================================================================================================
593
594
595 /**
596 * Handle AJAX request: save FormConfig from the Form Builder.
597 *
598 * Security:
599 * - Verifies wpbc_bfb_form_save nonce (sent as 'nonce').
600 * - Requires current_user_can( wpbc_bfb_get_manage_cap() ).
601 *
602 * Expects POST:
603 * - nonce : string Nonce for 'wpbc_bfb_form_save'.
604 * - form_name : string 'standard' or custom key (optional, default 'standard').
605 * - engine : string Engine name, usually 'bfb' (optional, default 'bfb').
606 * - engine_version : string Engine version (optional, default '1.0').
607 * - structure : string JSON string (Builder structure).
608 * - settings : string JSON string (extra settings, optional).
609 * - advanced_form : string Shortcodes / markup for booking form (optional).
610 * - content_form : string Shortcodes / markup for "Content of booking fields data" (optional).
611 *
612 * On success:
613 * - Persists FormConfig via wpbc_form_config_save() (which writes to
614 * booking_form_structures and optionally syncs legacy options).
615 *
616 * Response (JSON):
617 * - success: true|false
618 * - data: {
619 * booking_form_id: int,
620 * form_name: string,
621 * engine: string
622 * }
623 *
624 * @since 11.0.0
625 *
626 * @return void
627 */
628 function wpbc_bfb_ajax_save_form_config() {
629 global $wpdb;
630
631 if ( ! check_ajax_referer( 'wpbc_bfb_form_save', 'nonce', false ) ) {
632 wp_send_json_error( array( 'code' => 'invalid_nonce', 'message' => __( 'Security check failed.', 'booking' ) ) );
633 }
634
635 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
636 wp_send_json_error( array( 'code' => 'forbidden', 'message' => __( 'You are not allowed to save booking forms.', 'booking' ) ) );
637 }
638
639 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
640 $form_name = isset( $_POST['form_name'] ) ? wpbc_bfb__sanitize_form_slug( wp_unslash( $_POST['form_name'] ) ) : '';
641 if ( '' === $form_name ) {
642 $form_name = 'standard';
643 }
644 if ( ( defined( 'WPBC_BFB_DEBUG__FORM_NAME' ) ) && ( ! empty( WPBC_BFB_DEBUG__FORM_NAME ) ) ) {
645 $form_name = WPBC_BFB_DEBUG__FORM_NAME;
646 }
647
648 $status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : 'published';
649 $allowed_statuses = array( 'published', 'preview', 'template' );
650 if ( ! in_array( $status, $allowed_statuses, true ) ) {
651 $status = 'published';
652 }
653
654 // Preview context ID (calendar/resource) used ONLY to build preview URL + render shortcode. It is NOT saved into FormConfig in BFB mode !
655 $preview_form_id = isset( $_POST['preview_form_id'] ) ? absint( wp_unslash( $_POST['preview_form_id'] ) ) : 0;
656 if ( $preview_form_id <= 0 ) {
657 $preview_form_id = 1;
658 }
659 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
660 $return_preview_url = ( isset( $_POST['return_preview_url'] ) && '1' === (string) wp_unslash( $_POST['return_preview_url'] ) );
661
662 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
663 $engine = isset( $_POST['engine'] ) ? sanitize_text_field( wp_unslash( $_POST['engine'] ) ) : 'bfb';
664 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
665 $engine_version = isset( $_POST['engine_version'] ) ? sanitize_text_field( wp_unslash( $_POST['engine_version'] ) ) : '1.0';
666
667 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
668 $structure_raw = isset( $_POST['structure'] ) ? wp_unslash( $_POST['structure'] ) : '';
669 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
670 $settings_raw = isset( $_POST['settings'] ) ? wp_unslash( $_POST['settings'] ) : '';
671
672 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
673 $content_form_raw = isset( $_POST['content_form'] ) ? wp_unslash( $_POST['content_form'] ) : '';
674 $content_form = wpbc_bfb_sanitize_form_text( $content_form_raw );
675
676 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
677 $advanced_form_raw = isset( $_POST['advanced_form'] ) ? wp_unslash( $_POST['advanced_form'] ) : '';
678 $advanced_form = wpbc_bfb_sanitize_form_text( $advanced_form_raw );
679
680
681 // Validate structure JSON.
682 $structure_arr = json_decode( $structure_raw, true );
683 if ( ! is_array( $structure_arr ) ) {
684 wp_send_json_error( array( 'code' => 'invalid_structure', 'message' => __( 'Form structure is not a valid JSON object.', 'booking' ) ) );
685 }
686
687 // Settings JSON (normalized to the ONLY supported schema).
688 $settings_arr = wpbc_bfb__normalize_settings_array( $settings_raw );
689 // $advanced_mode_source = ( isset( $settings_arr['bfb_options']['advanced_mode_source'] ) ) ? (string) $settings_arr['bfb_options']['advanced_mode_source'] : 'builder';
690
691 // Check if owner of this form is "Regular User" in MU.
692 $owner_user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
693
694 $form_config = array(
695 'form_name' => $form_name,
696 'engine' => $engine,
697 'engine_version' => $engine_version,
698 'structure_json' => wpbc_form_config__encode_json( $structure_arr ),
699 'settings' => $settings_arr,
700 'advanced_form' => $advanced_form,
701 'content_form' => $content_form,
702 'owner_user_id' => $owner_user_id,
703 'scope' => 'global',
704 'status' => $status,
705 'is_default' => ( ( 'standard' === $form_name ) && ( 'template' !== $status ) ) ? 1 : 0,
706 'booking_resource_id' => null,
707 );
708
709 // ---------------------------------------------------------------------
710 // Form Details (title/description/picture) coming from UI.
711 // - Preserve existing values unless UI explicitly sent a key.
712 // ---------------------------------------------------------------------
713
714 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
715 $form_details_raw = isset( $_POST['form_details'] ) ? wp_unslash( $_POST['form_details'] ) : null;
716 $form_details = wpbc_bfb__normalize_form_details_from_post( $form_details_raw );
717
718 $existing_cfg = wpbc_form_config_load( $form_name, $owner_user_id );
719
720 // Optional: rename slug/key (save by booking_form_id to avoid creating a duplicate).
721 if ( array_key_exists( 'form_name', $form_details ) && '' !== $form_details['form_name'] ) {
722
723 $new_form_name = (string) $form_details['form_name'];
724
725 // Block reserved.
726 if ( 'standard' === $new_form_name && 'standard' !== $form_name ) {
727 wp_send_json_error( array( 'code' => 'reserved', 'message' => __( 'This form key is reserved.', 'booking' ) ) );
728 }
729
730 // If slug changed, ensure no collision.
731 if ( $new_form_name !== $form_name ) {
732
733 $is_fallback_to_legacy = false;
734 $collision = wpbc_form_config_load( $new_form_name, $owner_user_id, 'published', $is_fallback_to_legacy );
735
736 $existing_id = ( is_array( $existing_cfg ) && isset( $existing_cfg['id'] ) ) ? absint( $existing_cfg['id'] ) : 0;
737 $collision_id = ( is_array( $collision ) && isset( $collision['id'] ) ) ? absint( $collision['id'] ) : 0;
738
739 if ( ! empty( $collision ) && $collision_id !== $existing_id ) {
740 wp_send_json_error( array( 'code' => 'already_exists', 'message' => __( 'Form key already exists. Please choose another.', 'booking' ) ) );
741 }
742
743 // Save by ID (so wpbc_form_config_save updates this row).
744 if ( $existing_id > 0 ) {
745 $form_config['booking_form_id'] = $existing_id;
746 }
747
748 $form_name = $new_form_name;
749
750 // Keep flags consistent.
751 $form_config['form_name'] = $form_name;
752 $form_config['is_default'] = ( 'standard' === $form_name ) ? 1 : 0;
753 }
754 }
755
756
757 $existing_title = ( is_array( $existing_cfg ) && isset( $existing_cfg['title'] ) ) ? (string) $existing_cfg['title'] : '';
758 $existing_desc = ( is_array( $existing_cfg ) && isset( $existing_cfg['description'] ) ) ? (string) $existing_cfg['description'] : '';
759 $existing_pic = ( is_array( $existing_cfg ) && isset( $existing_cfg['picture_url'] ) ) ? (string) $existing_cfg['picture_url'] : '';
760
761 // Default: keep existing if set, otherwise fallback.
762 $form_title = ( '' !== trim( $existing_title ) ) ? $existing_title : ( ( 'standard' === $form_name ) ? __( 'Standard', 'booking' ) : $form_name );
763 $form_desc = $existing_desc;
764 $form_pic = $existing_pic;
765
766 // 1) Preferred: override from form_details if key exists (supports clearing).
767 if ( array_key_exists( 'title', $form_details ) ) {
768 $form_title = (string) $form_details['title'];
769 }
770
771 if ( array_key_exists( 'description', $form_details ) ) {
772 $form_desc = (string) $form_details['description'];
773 }
774
775 if ( array_key_exists( 'picture_url', $form_details ) ) {
776 $form_pic = (string) $form_details['picture_url'];
777 }
778
779 // 2) Backward compatibility: keep your old options override (if still used elsewhere).
780 if ( ! empty( $settings_arr['options'] ) && is_array( $settings_arr['options'] ) ) {
781
782 $options = $settings_arr['options'];
783
784 if ( array_key_exists( 'booking_form_title', $options ) && ! array_key_exists( 'title', $form_details ) ) {
785 $form_title = sanitize_text_field( $options['booking_form_title'] );
786 }
787
788 if ( array_key_exists( 'booking_form_description', $options ) && ! array_key_exists( 'description', $form_details ) ) {
789 $form_desc = sanitize_textarea_field( $options['booking_form_description'] );
790 }
791 }
792
793 // Store final meta into columns.
794 $form_config['title'] = $form_title;
795 $form_config['description'] = $form_desc;
796 $form_config['picture_url'] = $form_pic;
797
798 // Apply (possibly adjusted) settings back into form_config (important).
799 $form_config['settings'] = wpbc_bfb__normalize_settings_array( $settings_arr );
800
801 // We do not need to update options: 'booking_form', etc... in BFB!
802 $sync_legacy = false;
803 // == One Saving point ==
804 $booking_form_id = wpbc_form_config_save( $form_config, array( 'sync_legacy' => (bool) $sync_legacy ) );
805
806 if ( ! $booking_form_id ) {
807
808 wp_send_json_error(
809 array(
810 'code' => 'save_failed',
811 'message' => __( 'Error saving booking form.', 'booking' ) . ( ! empty( $wpdb->last_error ) ? ' ' . $wpdb->last_error : '' ),
812 )
813 );
814 }
815
816
817 $preview_url = '';
818 $preview_token = '';
819
820 if ( $return_preview_url && 'preview' === $status && class_exists( 'WPBC_BFB_Preview_Service' ) ) {
821
822 $preview_service = WPBC_BFB_Preview_Service::get_instance();
823
824 $res = $preview_service->create_preview_session( $preview_form_id, wpbc_get_current_user_id(), $structure_arr, $form_name, $advanced_form, $content_form );
825
826 if ( is_array( $res ) && ! empty( $res['preview_url'] ) ) {
827 $preview_url = (string) $res['preview_url'];
828 $preview_token = ! empty( $res['token'] ) ? (string) $res['token'] : '';
829 }
830 }
831
832 $setup_step_saved = false;
833 $setup_step = isset( $_POST['wpbc_setup_step'] ) ? sanitize_key( wp_unslash( $_POST['wpbc_setup_step'] ) ) : '';
834 if ( ! empty( $setup_step ) && class_exists( 'WPBC_SETUP_WIZARD_STEPS' ) ) {
835 $setup_steps = new WPBC_SETUP_WIZARD_STEPS();
836 $steps_arr = $setup_steps->get_steps_arr();
837 if ( function_exists( 'wpbc_setup_wizard__detect_step_from_admin_url' ) ) {
838 $referer_step = wpbc_setup_wizard__detect_step_from_admin_url( wp_get_referer() );
839 if ( ! empty( $referer_step ) && isset( $steps_arr[ $referer_step ] ) ) {
840 $setup_step = $referer_step;
841 }
842 }
843 if ( isset( $steps_arr[ $setup_step ] ) ) {
844 $setup_steps->db__set_step_as_saved( $setup_step, true );
845 $setup_steps->db__save_current_step_name( $setup_step );
846 $setup_step_saved = true;
847 }
848 }
849
850 wp_send_json_success(
851 array(
852 'booking_form_id' => $booking_form_id,
853 'form_name' => $form_name,
854 'engine' => $engine,
855 'status' => $status,
856 'preview_url' => $preview_url,
857 'token' => $preview_token,
858 'title' => isset( $form_config['title'] ) ? (string) $form_config['title'] : '',
859 'description' => isset( $form_config['description'] ) ? (string) $form_config['description'] : '',
860 'picture_url' => isset( $form_config['picture_url'] ) ? (string) $form_config['picture_url'] : '',
861 'setup_step_saved' => $setup_step_saved,
862 )
863 );
864
865 }
866 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_SAVE_FORM_CONFIG', 'wpbc_bfb_ajax_save_form_config' );
867
868
869 /**
870 * Handle AJAX request: save FormConfig as TEMPLATE.
871 *
872 * This is a minimal wrapper around wpbc_bfb_ajax_save_form_config().
873 * It forces status='template' and reuses all validations/sanitizers.
874 *
875 * @since 11.0.0
876 *
877 * @return void
878 */
879 function wpbc_bfb_ajax_save_form_config_template() {
880
881 // Force template status (listing expects status='template').
882 $_POST['status'] = 'template';
883
884 // Reuse main save logic.
885 wpbc_bfb_ajax_save_form_config();
886 }
887 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_SAVE_FORM_CONFIG_TEMPLATE', 'wpbc_bfb_ajax_save_form_config_template' );
888
889
890 /**
891 * Handle AJAX request: load FormConfig for the Form Builder.
892 *
893 * Security:
894 * - Verifies wpbc_bfb_form_load nonce (sent as 'nonce').
895 * - Requires current_user_can( wpbc_bfb_get_manage_cap() ).
896 *
897 * Expects POST:
898 * - nonce : string Nonce for 'wpbc_bfb_form_load'.
899 * - form_name : string 'standard' or custom key (optional, default 'standard').
900 *
901 * Behaviour:
902 * - Loads FormConfig via wpbc_form_config_load().
903 * - For engine = 'bfb', decodes structure_json into 'structure' array.
904 * - For engine = 'legacy_*', returns a simple "notice" structure in Builder canvas.
905 *
906 * @since 11.0.0
907 *
908 * @return void
909 */
910 function wpbc_bfb_ajax_load_form_config() {
911
912 if ( ! check_ajax_referer( 'wpbc_bfb_form_load', 'nonce', false ) ) {
913 wp_send_json_error(
914 array(
915 'code' => 'invalid_nonce',
916 'message' => __( 'Security check failed.', 'booking' ),
917 )
918 );
919 }
920
921 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
922 wp_send_json_error(
923 array(
924 'code' => 'forbidden',
925 'message' => __( 'You are not allowed to load booking forms.', 'booking' ),
926 )
927 );
928 }
929
930 $form_name = isset( $_POST['form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['form_name'] ) ) : '';
931 if ( '' === $form_name ) {
932 $form_name = 'standard';
933 }
934 if ( ( defined( 'WPBC_BFB_DEBUG__FORM_NAME' ) ) && ( ! empty( WPBC_BFB_DEBUG__FORM_NAME ) ) ) {
935 $form_name = WPBC_BFB_DEBUG__FORM_NAME;
936 }
937
938
939 $status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : 'published';
940 $allowed_statuses = array( 'published', 'preview', 'template' );
941 if ( ! in_array( $status, $allowed_statuses, true ) ) {
942 $status = 'published';
943 }
944
945
946 // Check if owner of this form is "Regular User" in MU.
947 $user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
948
949 if ( ! empty( $user_id ) ) {
950 make_bk_action( 'check_multiuser_params_for_client_side_by_user_id', $user_id ); // == MU == // FixIn: 2026-03-06 11:57.
951 }
952
953 $form_config = wpbc_form_config_load( $form_name, $user_id, $status );
954
955 if ( ! empty( $user_id ) ) {
956 make_bk_action( 'finish_check_multiuser_params_for_client_side', null ); // == MU == // FixIn: 2026-03-06 11:57.
957 }
958
959 if ( empty( $form_config ) || ( ! is_array( $form_config ) ) ) {
960 wp_send_json_error(
961 array(
962 'code' => 'not_found',
963 'message' => __( 'Booking form configuration not found.', 'booking' ),
964 ),
965 404
966 );
967 }
968
969 $engine = isset( $form_config['engine'] ) ? (string) $form_config['engine'] : '';
970 $structure = array();
971
972 if ( ! empty( $form_config['structure_json'] ) ) {
973 $tmp = json_decode( $form_config['structure_json'], true );
974 if ( is_array( $tmp ) ) {
975 $structure = $tmp;
976 }
977 }
978
979 // Fallback notice only when no structure exists at all.
980 if ( empty( $structure ) && in_array( $engine, array( 'legacy_simple', 'legacy_advanced' ), true ) ) {
981
982 $structure = array(
983 array(
984 'page' => 1,
985 'content' => array(
986 array(
987 'type' => 'field',
988 'data' => array(
989 'id' => 'static_text_legacy_notice_1',
990 'type' => 'static_text',
991 'usage_key' => 'static_text',
992 'text' => __( 'This form is currently configured in Advanced Form mode only.', 'booking' ),
993 'tag' => 'p',
994 'align' => 'center',
995 'bold' => 1,
996 'italic' => 0,
997 'html_allowed' => 0,
998 'nl2br' => 1,
999 'name' => 'static_text_legacy_notice_1',
1000 'html_id' => '',
1001 'cssclass_extra' => '',
1002 'label' => 'Static_text',
1003 ),
1004 ),
1005 array(
1006 'type' => 'field',
1007 'data' => array(
1008 'id' => 'static_text_legacy_notice_2',
1009 'type' => 'static_text',
1010 'usage_key' => 'static_text',
1011 'text' => __( 'Nothing is broken - a Form Builder layout just has not been created yet. You can continue using Advanced Form, or start building visually by dragging fields from Add Fields (right sidebar) onto this canvas.', 'booking' ),
1012 'tag' => 'p',
1013 'align' => 'center',
1014 'bold' => 0,
1015 'italic' => 0,
1016 'html_allowed' => 0,
1017 'nl2br' => 1,
1018 'name' => 'static_text_legacy_notice_2',
1019 'html_id' => '',
1020 'cssclass_extra' => '',
1021 'label' => 'Static_text',
1022 ),
1023 ),
1024 ),
1025 ),
1026 );
1027 }
1028
1029 $settings_out = wpbc_bfb__normalize_settings_array( isset( $form_config['settings'] ) ? $form_config['settings'] : array() );
1030
1031 wp_send_json_success(
1032 array(
1033 'form_name' => isset( $form_config['form_name'] ) ? (string) $form_config['form_name'] : $form_name,
1034 'engine' => $engine,
1035 'engine_version' => isset( $form_config['engine_version'] ) ? (string) $form_config['engine_version'] : '',
1036 'structure' => $structure,
1037 'settings' => $settings_out,
1038 'advanced_form' => isset( $form_config['advanced_form'] ) ? (string) $form_config['advanced_form'] : '',
1039 'content_form' => isset( $form_config['content_form'] ) ? (string) $form_config['content_form'] : '',
1040 'title' => isset( $form_config['title'] ) ? (string) $form_config['title'] : '',
1041 'description' => isset( $form_config['description'] ) ? (string) $form_config['description'] : '',
1042 'picture_url' => isset( $form_config['picture_url'] ) ? (string) $form_config['picture_url'] : '',
1043 )
1044 );
1045 }
1046 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_LOAD_FORM_CONFIG', 'wpbc_bfb_ajax_load_form_config' );
1047
1048
1049 /**
1050 * Handle AJAX request: create new FormConfig by cloning a template form,
1051 * or creating a blank form when template is not selected / not available.
1052 *
1053 * Expects POST:
1054 * - nonce
1055 * - form_name (new form key / slug)
1056 * - template_form_name (optional; '' or '__blank__' => blank form)
1057 * - title (optional)
1058 * - description (optional)
1059 * - image_url (optional)
1060 */
1061 function wpbc_bfb_ajax_create_form_config() {
1062
1063 if ( ! check_ajax_referer( 'wpbc_bfb_form_create', 'nonce', false ) ) {
1064 wp_send_json_error(
1065 array(
1066 'code' => 'invalid_nonce',
1067 'message' => __( 'Security check failed.', 'booking' ),
1068 )
1069 );
1070 }
1071
1072 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
1073 wp_send_json_error(
1074 array(
1075 'code' => 'forbidden',
1076 'message' => __( 'You are not allowed to create booking forms.', 'booking' ),
1077 )
1078 );
1079 }
1080
1081 // New form key.
1082 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1083 $form_name = isset( $_POST['form_name'] ) ? wpbc_bfb__sanitize_form_slug( wp_unslash( $_POST['form_name'] ) ) : '';
1084 if ( '' === $form_name ) {
1085 wp_send_json_error(
1086 array(
1087 'code' => 'invalid_form_name',
1088 'message' => __( 'Form key is required.', 'booking' ),
1089 )
1090 );
1091 }
1092 if ( 'standard' === $form_name ) {
1093 wp_send_json_error( array( 'code' => 'reserved', 'message' => __( 'This form key is reserved.', 'booking' ) ) );
1094 }
1095
1096 // Template key (optional).
1097 $template_form_name = isset( $_POST['template_form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['template_form_name'] ) ) : '';
1098
1099 $is_blank = wpbc_bfb__is_blank_template_key( $template_form_name );
1100
1101 // Meta.
1102 $title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
1103 $description = isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : '';
1104 $image_url = isset( $_POST['image_url'] ) ? esc_url_raw( wp_unslash( $_POST['image_url'] ) ) : '';
1105
1106 if ( '' === $title ) {
1107 $title = $form_name;
1108 }
1109
1110 // MU owner logic.
1111 $owner_user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
1112
1113 // Ensure new form does not already exist.
1114 $is_fallback_to_legacy = false;
1115 $existing = wpbc_form_config_load( $form_name, $owner_user_id, 'published', $is_fallback_to_legacy );
1116 if ( ! empty( $existing ) ) {
1117 wp_send_json_error(
1118 array(
1119 'code' => 'already_exists',
1120 'message' => __( 'Form key already exists. Please choose another.', 'booking' ),
1121 )
1122 );
1123 }
1124
1125 $template = array();
1126 $structure_arr = array();
1127 $settings_arr = array();
1128 $engine = 'bfb';
1129 $engine_version = '1.0';
1130 $advanced_form = '';
1131 $content_form = '';
1132
1133 // Try to load template only when requested.
1134 if ( ! $is_blank ) {
1135
1136 // 1) Prefer user-owned template (MU) if exists.
1137 if ( $owner_user_id > 0 ) {
1138 $template = wpbc_form_config_load( $template_form_name, $owner_user_id, 'template' );
1139 }
1140
1141 // 2) Fallback to global template.
1142 if ( empty( $template ) ) {
1143 $template = wpbc_form_config_load( $template_form_name, 0, 'template' );
1144 }
1145
1146 // 3) If still missing (template deleted), fallback to standard if it exists.
1147 if ( empty( $template ) ) {
1148 $template = wpbc_form_config_load( 'standard', $owner_user_id );
1149 }
1150
1151 // If still nothing, create blank.
1152 if ( empty( $template ) ) {
1153 $is_blank = true;
1154 }
1155 }
1156
1157 if ( $is_blank ) {
1158
1159 // Blank form seed.
1160 $structure_arr = wpbc_bfb__get_blank_structure_seed();
1161 $settings_arr = wpbc_bfb__normalize_settings_array( array() );
1162
1163 // IMPORTANT: blank forms start in Builder sync mode (Builder -> Advanced).
1164 if ( empty( $settings_arr['bfb_options'] ) || ! is_array( $settings_arr['bfb_options'] ) ) {
1165 $settings_arr['bfb_options'] = array();
1166 }
1167 $settings_arr['bfb_options']['advanced_mode_source'] = 'builder';
1168
1169 $engine = 'bfb';
1170 $engine_version = '1.0';
1171 $advanced_form = '';
1172 $content_form = '';
1173
1174 } else {
1175
1176 // Clone structure from template.
1177 if ( ! empty( $template['structure_json'] ) ) {
1178 $tmp = json_decode( $template['structure_json'], true );
1179 if ( is_array( $tmp ) ) {
1180 $structure_arr = $tmp;
1181 }
1182 }
1183
1184 // Clone settings from template.
1185 $settings_arr = wpbc_bfb__normalize_settings_array( isset( $template['settings'] ) ? $template['settings'] : array() );
1186
1187 $engine = ! empty( $template['engine'] ) ? (string) $template['engine'] : 'bfb';
1188 $engine_version = ! empty( $template['engine_version'] ) ? (string) $template['engine_version'] : '1.0';
1189
1190 $advanced_form = isset( $template['advanced_form'] ) ? (string) $template['advanced_form'] : '';
1191 $content_form = isset( $template['content_form'] ) ? (string) $template['content_form'] : '';
1192 }
1193
1194 $form_config = array(
1195 'form_name' => $form_name,
1196 'engine' => $engine,
1197 'engine_version' => $engine_version,
1198 'structure_json' => wpbc_form_config__encode_json( $structure_arr ),
1199 'settings' => $settings_arr,
1200 'advanced_form' => $advanced_form,
1201 'content_form' => $content_form,
1202 'owner_user_id' => $owner_user_id,
1203
1204 'title' => $title,
1205 'description' => $description,
1206 'picture_url' => $image_url,
1207
1208 'scope' => 'global',
1209 'status' => 'published',
1210 'is_default' => 0,
1211 'booking_resource_id' => null,
1212 );
1213
1214 $sync_legacy = false;
1215
1216 $booking_form_id = wpbc_form_config_save( $form_config, array( 'sync_legacy' => (bool) $sync_legacy ) );
1217
1218 if ( ! $booking_form_id ) {
1219 wp_send_json_error(
1220 array(
1221 'code' => 'create_failed',
1222 'message' => __( 'Error creating booking form.', 'booking' ),
1223 )
1224 );
1225 }
1226
1227 wp_send_json_success(
1228 array(
1229 'booking_form_id' => $booking_form_id,
1230 'form_name' => $form_name,
1231 )
1232 );
1233 }
1234 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_CREATE_FORM_CONFIG', 'wpbc_bfb_ajax_create_form_config' );
1235
1236
1237 /**
1238 * Handle AJAX request: list booking forms for current user (and optionally global ones).
1239 *
1240 * Security:
1241 * - Verifies wpbc_bfb_form_list nonce (sent as 'nonce').
1242 * - Requires current_user_can( wpbc_bfb_get_manage_cap() ).
1243 *
1244 * Expects POST:
1245 * - nonce : string Nonce for 'wpbc_bfb_form_list'.
1246 * - include_global : 0|1 If 1, include global forms (owner_user_id=0/NULL) in addition to user-owned.
1247 * - status : string Default 'published'. Allowed: published|preview|draft|archived|template
1248 * - search : string Optional filter by title/slug/description
1249 * - limit : int Optional max rows (default 20, max 500)
1250 * - page : int Optional page number, starts from 1
1251 *
1252 * Response (JSON):
1253 * - success: true|false
1254 * - data: { forms: [ ... ] }
1255 *
1256 * @since 11.0.0
1257 *
1258 * @return void
1259 */
1260 function wpbc_bfb_ajax_list_forms() {
1261
1262 global $wpdb;
1263
1264 if ( ! check_ajax_referer( 'wpbc_bfb_form_list', 'nonce', false ) ) {
1265 wp_send_json_error( array(
1266 'code' => 'invalid_nonce',
1267 'message' => __( 'Security check failed.', 'booking' ),
1268 ) );
1269 }
1270
1271 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
1272 wp_send_json_error( array(
1273 'code' => 'forbidden',
1274 'message' => __( 'You are not allowed to list booking forms.', 'booking' ),
1275 ) );
1276 }
1277
1278 // Allow global forms ONLY when listing templates.
1279 // Templates usually live as global rows (owner_user_id = 0 / NULL).
1280 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1281 $include_global = ( isset( $_POST['include_global'] ) && '1' === (string) wp_unslash( $_POST['include_global'] ) );
1282
1283 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1284 $status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : 'published';
1285 if ( '' === $status ) {
1286 $status = 'published';
1287 }
1288
1289 $allowed_statuses = array( 'published', 'preview', 'draft', 'archived', 'template' );
1290 if ( ! in_array( $status, $allowed_statuses, true ) ) {
1291 $status = 'published';
1292 }
1293
1294 // Security policy: include_global is allowed only for templates.
1295 if ( 'template' !== $status ) {
1296 $include_global = false;
1297 }
1298
1299 $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
1300
1301 // Pagination.
1302 $page = isset( $_POST['page'] ) ? absint( wp_unslash( $_POST['page'] ) ) : 1;
1303 if ( $page <= 0 ) {
1304 $page = 1;
1305 }
1306
1307 $limit = isset( $_POST['limit'] ) ? absint( wp_unslash( $_POST['limit'] ) ) : 20;
1308 if ( $limit <= 0 ) {
1309 $limit = 20;
1310 }
1311 if ( $limit > 500 ) {
1312 $limit = 500;
1313 }
1314
1315 $offset = ( $page - 1 ) * $limit;
1316 if ( $offset < 0 ) {
1317 $offset = 0;
1318 }
1319
1320 // MU owner logic (same as save/load/create).
1321 $owner_user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
1322
1323 $table = $wpdb->prefix . 'booking_form_structures';
1324
1325 // Base WHERE.
1326 $where_sql = " WHERE status = %s ";
1327 $where_args = array( $status );
1328
1329 // Owner/global logic.
1330 if ( $owner_user_id > 0 ) {
1331 if ( $include_global ) {
1332 $where_sql .= " AND ( owner_user_id = %d OR owner_user_id = 0 OR owner_user_id IS NULL ) ";
1333 $where_args[] = $owner_user_id;
1334 } else {
1335 $where_sql .= " AND owner_user_id = %d ";
1336 $where_args[] = $owner_user_id;
1337 }
1338 } else {
1339 // Non-MU (or super admin context): treat as global rows.
1340 $where_sql .= " AND ( owner_user_id = 0 OR owner_user_id IS NULL ) ";
1341 }
1342
1343 // Search filter. Supports OR search by configured separator (default "~"): "time~duration~slots"
1344 if ( '' !== $search ) {
1345
1346 $terms = wpbc_bfb__split_search_terms_by_or_separator( $search, 5 );
1347
1348 if ( empty( $terms ) ) {
1349 // No usable terms after splitting.
1350 } elseif ( 1 === count( $terms ) ) {
1351
1352 $like = '%' . $wpdb->esc_like( $terms[0] ) . '%';
1353 $where_sql .= " AND ( form_slug LIKE %s OR title LIKE %s OR description LIKE %s ) ";
1354 $where_args[] = $like;
1355 $where_args[] = $like;
1356 $where_args[] = $like;
1357
1358 } else {
1359
1360 $or_groups = array();
1361
1362 foreach ( $terms as $term ) {
1363
1364 $or_groups[] = "( form_slug LIKE %s OR title LIKE %s OR description LIKE %s )";
1365
1366 $like = '%' . $wpdb->esc_like( $term ) . '%';
1367 $where_args[] = $like;
1368 $where_args[] = $like;
1369 $where_args[] = $like;
1370 }
1371
1372 $where_sql .= " AND ( " . implode( ' OR ', $or_groups ) . " ) ";
1373 }
1374 }
1375
1376 // Order:
1377 // - prefer user-owned rows first (when include_global + owner_user_id > 0)
1378 // - default forms first
1379 // - newest first
1380 $order_sql = " ORDER BY is_default DESC, updated_at DESC, version DESC, booking_form_id DESC ";
1381
1382 if ( $owner_user_id > 0 && $include_global ) {
1383 $order_sql = " ORDER BY ( owner_user_id = " . intval( $owner_user_id ) . " ) DESC, is_default DESC, updated_at DESC, version DESC, booking_form_id DESC ";
1384 }
1385
1386 $limit_plus_one = $limit + 1;
1387
1388 $sql = "SELECT booking_form_id, form_slug, title, description, picture_url, updated_at, owner_user_id, status, scope, is_default, version
1389 FROM {$table}
1390 {$where_sql}
1391 {$order_sql}
1392 LIMIT " . intval( $limit_plus_one ) . ' OFFSET ' . intval( $offset );
1393
1394 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
1395 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $where_args ) );
1396
1397 $has_more = ( count( (array) $rows ) > $limit );
1398 if ( $has_more ) {
1399 $rows = array_slice( (array) $rows, 0, $limit );
1400 }
1401
1402 $forms = array();
1403
1404 // If include_global + owner_user_id > 0: dedupe by slug, prefer owner over global.
1405 $seen_by_slug = array();
1406
1407 foreach ( (array) $rows as $r ) {
1408
1409 $slug = isset( $r->form_slug ) ? (string) $r->form_slug : '';
1410 if ( '' === $slug ) {
1411 continue;
1412 }
1413
1414 if ( $owner_user_id > 0 && $include_global ) {
1415 if ( isset( $seen_by_slug[ $slug ] ) ) {
1416 continue;
1417 }
1418 $seen_by_slug[ $slug ] = true;
1419 }
1420
1421
1422 $row_owner_user_id = isset( $r->owner_user_id ) ? absint( $r->owner_user_id ) : 0;
1423 $row_status = isset( $r->status ) ? (string) $r->status : '';
1424 $row_is_default = isset( $r->is_default ) ? absint( $r->is_default ) : 0;
1425
1426 $raw_picture_url = isset( $r->picture_url ) ? (string) $r->picture_url : '';
1427
1428 $final_picture_url = ( 'template' === $row_status ) ? wpbc_bfb_resolve_picture_url( $raw_picture_url ) : $raw_picture_url;
1429
1430 $can_delete = wpbc_bfb__can_delete_template_in_current_context(
1431 $slug,
1432 $row_owner_user_id,
1433 $owner_user_id,
1434 $row_is_default,
1435 $row_status
1436 );
1437
1438 $forms[] = array(
1439 'booking_form_id' => isset( $r->booking_form_id ) ? (int) $r->booking_form_id : 0,
1440 'form_slug' => $slug,
1441 'title' => isset( $r->title ) ? (string) $r->title : '',
1442 'description' => isset( $r->description ) ? (string) $r->description : '',
1443 'picture_url' => $final_picture_url,
1444 'updated_at' => isset( $r->updated_at ) ? (string) $r->updated_at : '',
1445 'owner_user_id' => $row_owner_user_id,
1446 'status' => $row_status,
1447 'scope' => isset( $r->scope ) ? (string) $r->scope : '',
1448 'is_default' => $row_is_default,
1449 'version' => isset( $r->version ) ? (int) $r->version : 0,
1450 'can_delete' => $can_delete ? 1 : 0,
1451 );
1452 }
1453
1454 wp_send_json_success( array(
1455 'forms' => $forms,
1456 'count' => count( $forms ),
1457 'page' => $page,
1458 'limit' => $limit,
1459 'has_more' => $has_more,
1460 ) );
1461 }
1462 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_LIST_FORMS', 'wpbc_bfb_ajax_list_forms' );
1463
1464
1465 /**
1466 * Handle AJAX request: delete a TEMPLATE FormConfig.
1467 *
1468 * Security:
1469 * - Verifies wpbc_bfb_form_delete nonce (or list nonce fallback).
1470 * - Requires current_user_can( wpbc_bfb_get_manage_cap() ).
1471 *
1472 * Expects POST:
1473 * - nonce : string Nonce for delete/list action.
1474 * - form_name : string Template slug/key to delete.
1475 *
1476 * Behaviour:
1477 * - Deletes ONLY template rows for the given slug.
1478 * - In MultiUser mode: a regular user can delete ONLY their own templates.
1479 * - Global templates shown to regular MU users are NOT deletable.
1480 *
1481 * Response (JSON):
1482 * - success: true|false
1483 * - data: {
1484 * form_name: string,
1485 * deleted: int
1486 * }
1487 *
1488 * @since 11.0.0
1489 *
1490 * @return void
1491 */
1492 function wpbc_bfb_ajax_delete_template_config() {
1493 global $wpdb;
1494
1495 if ( ! wpbc_bfb__verify_delete_request_nonce() ) {
1496 wp_send_json_error(
1497 array(
1498 'code' => 'invalid_nonce',
1499 'message' => __( 'Security check failed.', 'booking' ),
1500 )
1501 );
1502 }
1503
1504 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
1505 wp_send_json_error(
1506 array(
1507 'code' => 'forbidden',
1508 'message' => __( 'You are not allowed to delete templates.', 'booking' ),
1509 )
1510 );
1511 }
1512
1513 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
1514 $form_name = isset( $_POST['form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['form_name'] ) ) : '';
1515 if ( '' === $form_name ) {
1516 wp_send_json_error(
1517 array(
1518 'code' => 'invalid_form_name',
1519 'message' => __( 'Template key is required.', 'booking' ),
1520 )
1521 );
1522 }
1523
1524 if ( 'standard' === $form_name ) {
1525 wp_send_json_error(
1526 array(
1527 'code' => 'reserved',
1528 'message' => __( 'This template cannot be deleted.', 'booking' ),
1529 )
1530 );
1531 }
1532
1533 $owner_user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
1534
1535 /**
1536 * Filter whether deletion of a specific template is allowed.
1537 *
1538 * @since 11.0.0
1539 *
1540 * @param bool $is_allowed Default true.
1541 * @param string $form_name Template slug/key.
1542 * @param int $owner_user_id Owner user id in MU (0 for global).
1543 */
1544 $is_allowed = apply_filters( 'wpbc_bfb_delete_template_is_allowed', true, $form_name, $owner_user_id );
1545 if ( ! $is_allowed ) {
1546 wp_send_json_error(
1547 array(
1548 'code' => 'not_allowed',
1549 'message' => __( 'Deletion is not allowed for this template.', 'booking' ),
1550 )
1551 );
1552 }
1553
1554 $table = $wpdb->prefix . 'booking_form_structures';
1555
1556 $where_sql = " WHERE form_slug = %s AND status = %s ";
1557 $where_args = array( $form_name, 'template' );
1558
1559 if ( $owner_user_id > 0 ) {
1560 $where_sql .= " AND owner_user_id = %d ";
1561 $where_args[] = $owner_user_id;
1562 } else {
1563 $where_sql .= " AND ( owner_user_id = 0 OR owner_user_id IS NULL ) ";
1564 }
1565
1566 $sql = "SELECT booking_form_id, is_default, owner_user_id
1567 FROM {$table}
1568 {$where_sql}
1569 ORDER BY updated_at DESC, version DESC, booking_form_id DESC
1570 LIMIT 1";
1571
1572 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
1573 $row = $wpdb->get_row( $wpdb->prepare( $sql, $where_args ) );
1574
1575 if ( empty( $row ) ) {
1576 wp_send_json_error(
1577 array(
1578 'code' => 'not_found',
1579 'message' => __( 'Template not found.', 'booking' ),
1580 )
1581 );
1582 }
1583
1584 if ( 1 === absint( $row->is_default ) ) {
1585 wp_send_json_error(
1586 array(
1587 'code' => 'reserved',
1588 'message' => __( 'This template cannot be deleted.', 'booking' ),
1589 )
1590 );
1591 }
1592
1593 $delete_sql = "DELETE FROM {$table} {$where_sql}";
1594
1595 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
1596 $deleted = $wpdb->query( $wpdb->prepare( $delete_sql, $where_args ) );
1597
1598 if ( false === $deleted ) {
1599 wp_send_json_error(
1600 array(
1601 'code' => 'delete_failed',
1602 'message' => __( 'Error deleting template.', 'booking' ) . ( ! empty( $wpdb->last_error ) ? ' ' . $wpdb->last_error : '' ),
1603 )
1604 );
1605 }
1606
1607 wp_send_json_success(
1608 array(
1609 'form_name' => $form_name,
1610 /* translators: 1: template name */
1611 'message' => sprintf( __( 'Template %s deleted.', 'booking' ), "'" . $form_name . "'" ) . ' [' . absint( $deleted ) . ']',
1612 'deleted' => absint( $deleted ),
1613 )
1614 );
1615 }
1616 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_DELETE_TEMPLATE_CONFIG', 'wpbc_bfb_ajax_delete_template_config' );
1617
1618
1619 /**
1620 * Handle AJAX request: delete a custom FormConfig.
1621 *
1622 * Security:
1623 * - Verifies wpbc_bfb_form_delete nonce (sent as 'nonce').
1624 * - Requires current_user_can( wpbc_bfb_get_manage_cap() ).
1625 *
1626 * Expects POST:
1627 * - nonce : string Nonce for 'wpbc_bfb_form_delete'.
1628 * - form_name : string Custom form slug/key to delete (required).
1629 *
1630 * Behaviour:
1631 * - Blocks deletion of reserved/default forms (e.g. 'standard' or is_default=1).
1632 * - In MultiUser mode: a regular user can delete ONLY their own forms.
1633 * - Deletes ALL rows for this form_slug (all statuses/versions), excluding scope='template'.
1634 *
1635 * Response (JSON):
1636 * - success: true|false
1637 * - data: {
1638 * form_name: string,
1639 * deleted: int
1640 * }
1641 *
1642 * @since 11.0.0
1643 *
1644 * @return void
1645 */
1646 function wpbc_bfb_ajax_delete_form_config() {
1647 global $wpdb;
1648
1649 if ( ! wpbc_bfb__verify_delete_request_nonce() ) {
1650 wp_send_json_error(
1651 array(
1652 'code' => 'invalid_nonce',
1653 'message' => __( 'Security check failed.', 'booking' ),
1654 )
1655 );
1656 }
1657
1658 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
1659 wp_send_json_error(
1660 array(
1661 'code' => 'forbidden',
1662 'message' => __( 'You are not allowed to delete booking forms.', 'booking' ),
1663 )
1664 );
1665 }
1666
1667 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
1668 $form_name = isset( $_POST['form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['form_name'] ) ) : '';
1669 if ( '' === $form_name ) {
1670 wp_send_json_error(
1671 array(
1672 'code' => 'invalid_form_name',
1673 'message' => __( 'Form key is required.', 'booking' ),
1674 )
1675 );
1676 }
1677
1678 // Block reserved key.
1679 if ( 'standard' === $form_name ) {
1680 wp_send_json_error(
1681 array(
1682 'code' => 'reserved',
1683 'message' => __( 'This form cannot be deleted.', 'booking' ),
1684 )
1685 );
1686 }
1687
1688 // MU owner logic (same approach as save/load/create/list).
1689 $owner_user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
1690
1691 /**
1692 * Filter whether deletion of a specific form is allowed.
1693 *
1694 * @since 11.0.0
1695 *
1696 * @param bool $is_allowed Default true.
1697 * @param string $form_name Form slug/key.
1698 * @param int $owner_user_id Owner user id in MU (0 for global).
1699 */
1700 $is_allowed = apply_filters( 'wpbc_bfb_delete_form_is_allowed', true, $form_name, $owner_user_id );
1701 if ( ! $is_allowed ) {
1702 wp_send_json_error(
1703 array(
1704 'code' => 'not_allowed',
1705 'message' => __( 'Deletion is not allowed for this form.', 'booking' ),
1706 )
1707 );
1708 }
1709
1710 $table = $wpdb->prefix . 'booking_form_structures';
1711
1712 // ---------------------------------------------------------------------------------
1713 // Check existence + protect default/template.
1714 // ---------------------------------------------------------------------------------
1715 $where_sql = " WHERE form_slug = %s ";
1716 $where_args = array( $form_name );
1717
1718 if ( $owner_user_id > 0 ) {
1719 $where_sql .= " AND owner_user_id = %d ";
1720 $where_args[] = $owner_user_id;
1721 } else {
1722 $where_sql .= " AND ( owner_user_id = 0 OR owner_user_id IS NULL ) ";
1723 }
1724
1725 // Exclude template scope rows from selection checks as well.
1726 $where_sql .= " AND ( scope IS NULL OR scope <> %s ) ";
1727 $where_args[] = 'template';
1728
1729 // Exclude template scope rows from selection checks as well.
1730 $where_sql .= " AND ( status IS NULL OR status <> %s ) ";
1731 $where_args[] = 'template';
1732
1733 $sql = "SELECT booking_form_id, is_default, scope, status
1734 FROM {$table}
1735 {$where_sql}
1736 ORDER BY updated_at DESC, version DESC, booking_form_id DESC
1737 LIMIT 1";
1738
1739 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
1740 $row = $wpdb->get_row( $wpdb->prepare( $sql, $where_args ) );
1741
1742 if ( empty( $row ) ) {
1743 wp_send_json_error(
1744 array(
1745 'code' => 'not_found',
1746 'message' => __( 'Booking form not found.', 'booking' ),
1747 )
1748 );
1749 }
1750
1751 $is_default = isset( $row->is_default ) ? absint( $row->is_default ) : 0;
1752 if ( 1 === $is_default ) {
1753 wp_send_json_error(
1754 array(
1755 'code' => 'reserved',
1756 'message' => __( 'This form cannot be deleted.', 'booking' ),
1757 )
1758 );
1759 }
1760
1761 $scope = isset( $row->scope ) ? (string) $row->scope : '';
1762 $status = isset( $row->status ) ? (string) $row->status : '';
1763 if ( ( 'template' === $scope ) || ( 'template' === $status ) ) {
1764 wp_send_json_error(
1765 array(
1766 'code' => 'reserved',
1767 'message' => __( 'Template forms cannot be deleted.', 'booking' ),
1768 )
1769 );
1770 }
1771
1772 // ---------------------------------------------------------------------------------
1773 // Delete ALL rows for this slug/owner (all statuses/versions), excluding templates.
1774 // ---------------------------------------------------------------------------------
1775 $delete_where_sql = " WHERE form_slug = %s ";
1776 $delete_where_args = array( $form_name );
1777
1778 if ( $owner_user_id > 0 ) {
1779 $delete_where_sql .= " AND owner_user_id = %d ";
1780 $delete_where_args[] = $owner_user_id;
1781 } else {
1782 $delete_where_sql .= " AND ( owner_user_id = 0 OR owner_user_id IS NULL ) ";
1783 }
1784
1785 $delete_where_sql .= " AND ( scope IS NULL OR scope <> %s ) ";
1786 $delete_where_args[] = 'template';
1787 $delete_where_sql .= " AND ( status IS NULL OR status <> %s ) ";
1788 $delete_where_args[] = 'template';
1789
1790 $delete_sql = "DELETE FROM {$table} {$delete_where_sql}";
1791
1792 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
1793 $deleted = $wpdb->query( $wpdb->prepare( $delete_sql, $delete_where_args ) );
1794
1795 if ( false === $deleted ) {
1796 wp_send_json_error(
1797 array(
1798 'code' => 'delete_failed',
1799 'message' => __( 'Error deleting booking form.', 'booking' ) . ( ! empty( $wpdb->last_error ) ? ' ' . $wpdb->last_error : '' ),
1800 )
1801 );
1802 }
1803
1804 wp_send_json_success(
1805 array(
1806 'form_name' => $form_name,
1807 /* translators: 1: template name */
1808 'message' => sprintf( __( 'Booking form %s deleted.', 'booking' ), "'" . $form_name . "'" ) . ' [' . absint( $deleted ) . ']',
1809 'deleted' => absint( $deleted ),
1810 )
1811 );
1812 }
1813 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_DELETE_FORM_CONFIG', 'wpbc_bfb_ajax_delete_form_config' );
1814