PluginProbe
Booking Calendar / 11.0
Booking Calendar v11.0
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.0, at includes/page-form-builder/ajax/bfb-ajax.php

1,795 lines 57.6 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 wp_send_json_success(
833 array(
834 'booking_form_id' => $booking_form_id,
835 'form_name' => $form_name,
836 'engine' => $engine,
837 'status' => $status,
838 'preview_url' => $preview_url,
839 'token' => $preview_token,
840 'title' => isset( $form_config['title'] ) ? (string) $form_config['title'] : '',
841 'description' => isset( $form_config['description'] ) ? (string) $form_config['description'] : '',
842 'picture_url' => isset( $form_config['picture_url'] ) ? (string) $form_config['picture_url'] : '',
843 )
844 );
845
846 }
847 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_SAVE_FORM_CONFIG', 'wpbc_bfb_ajax_save_form_config' );
848
849
850 /**
851 * Handle AJAX request: save FormConfig as TEMPLATE.
852 *
853 * This is a minimal wrapper around wpbc_bfb_ajax_save_form_config().
854 * It forces status='template' and reuses all validations/sanitizers.
855 *
856 * @since 11.0.0
857 *
858 * @return void
859 */
860 function wpbc_bfb_ajax_save_form_config_template() {
861
862 // Force template status (listing expects status='template').
863 $_POST['status'] = 'template';
864
865 // Reuse main save logic.
866 wpbc_bfb_ajax_save_form_config();
867 }
868 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_SAVE_FORM_CONFIG_TEMPLATE', 'wpbc_bfb_ajax_save_form_config_template' );
869
870
871 /**
872 * Handle AJAX request: load FormConfig for the Form Builder.
873 *
874 * Security:
875 * - Verifies wpbc_bfb_form_load nonce (sent as 'nonce').
876 * - Requires current_user_can( wpbc_bfb_get_manage_cap() ).
877 *
878 * Expects POST:
879 * - nonce : string Nonce for 'wpbc_bfb_form_load'.
880 * - form_name : string 'standard' or custom key (optional, default 'standard').
881 *
882 * Behaviour:
883 * - Loads FormConfig via wpbc_form_config_load().
884 * - For engine = 'bfb', decodes structure_json into 'structure' array.
885 * - For engine = 'legacy_*', returns a simple "notice" structure in Builder canvas.
886 *
887 * @since 11.0.0
888 *
889 * @return void
890 */
891 function wpbc_bfb_ajax_load_form_config() {
892
893 if ( ! check_ajax_referer( 'wpbc_bfb_form_load', 'nonce', false ) ) {
894 wp_send_json_error(
895 array(
896 'code' => 'invalid_nonce',
897 'message' => __( 'Security check failed.', 'booking' ),
898 )
899 );
900 }
901
902 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
903 wp_send_json_error(
904 array(
905 'code' => 'forbidden',
906 'message' => __( 'You are not allowed to load booking forms.', 'booking' ),
907 )
908 );
909 }
910
911 $form_name = isset( $_POST['form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['form_name'] ) ) : '';
912 if ( '' === $form_name ) {
913 $form_name = 'standard';
914 }
915 if ( ( defined( 'WPBC_BFB_DEBUG__FORM_NAME' ) ) && ( ! empty( WPBC_BFB_DEBUG__FORM_NAME ) ) ) {
916 $form_name = WPBC_BFB_DEBUG__FORM_NAME;
917 }
918
919
920 $status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : 'published';
921 $allowed_statuses = array( 'published', 'preview', 'template' );
922 if ( ! in_array( $status, $allowed_statuses, true ) ) {
923 $status = 'published';
924 }
925
926
927 // Check if owner of this form is "Regular User" in MU.
928 $user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
929
930 if ( ! empty( $user_id ) ) {
931 make_bk_action( 'check_multiuser_params_for_client_side_by_user_id', $user_id ); // == MU == // FixIn: 2026-03-06 11:57.
932 }
933
934 $form_config = wpbc_form_config_load( $form_name, $user_id, $status );
935
936 if ( ! empty( $user_id ) ) {
937 make_bk_action( 'finish_check_multiuser_params_for_client_side', null ); // == MU == // FixIn: 2026-03-06 11:57.
938 }
939
940 if ( empty( $form_config ) || ( ! is_array( $form_config ) ) ) {
941 wp_send_json_error(
942 array(
943 'code' => 'not_found',
944 'message' => __( 'Booking form configuration not found.', 'booking' ),
945 ),
946 404
947 );
948 }
949
950 $engine = isset( $form_config['engine'] ) ? (string) $form_config['engine'] : '';
951 $structure = array();
952
953 if ( ! empty( $form_config['structure_json'] ) ) {
954 $tmp = json_decode( $form_config['structure_json'], true );
955 if ( is_array( $tmp ) ) {
956 $structure = $tmp;
957 }
958 }
959
960 // Fallback notice only when no structure exists at all.
961 if ( empty( $structure ) && in_array( $engine, array( 'legacy_simple', 'legacy_advanced' ), true ) ) {
962
963 $structure = array(
964 array(
965 'page' => 1,
966 'content' => array(
967 array(
968 'type' => 'field',
969 'data' => array(
970 'id' => 'static_text_legacy_notice_1',
971 'type' => 'static_text',
972 'usage_key' => 'static_text',
973 'text' => __( 'This form is currently configured in Advanced Form mode only.', 'booking' ),
974 'tag' => 'p',
975 'align' => 'center',
976 'bold' => 1,
977 'italic' => 0,
978 'html_allowed' => 0,
979 'nl2br' => 1,
980 'name' => 'static_text_legacy_notice_1',
981 'html_id' => '',
982 'cssclass_extra' => '',
983 'label' => 'Static_text',
984 ),
985 ),
986 array(
987 'type' => 'field',
988 'data' => array(
989 'id' => 'static_text_legacy_notice_2',
990 'type' => 'static_text',
991 'usage_key' => 'static_text',
992 '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' ),
993 'tag' => 'p',
994 'align' => 'center',
995 'bold' => 0,
996 'italic' => 0,
997 'html_allowed' => 0,
998 'nl2br' => 1,
999 'name' => 'static_text_legacy_notice_2',
1000 'html_id' => '',
1001 'cssclass_extra' => '',
1002 'label' => 'Static_text',
1003 ),
1004 ),
1005 ),
1006 ),
1007 );
1008 }
1009
1010 $settings_out = wpbc_bfb__normalize_settings_array( isset( $form_config['settings'] ) ? $form_config['settings'] : array() );
1011
1012 wp_send_json_success(
1013 array(
1014 'form_name' => isset( $form_config['form_name'] ) ? (string) $form_config['form_name'] : $form_name,
1015 'engine' => $engine,
1016 'engine_version' => isset( $form_config['engine_version'] ) ? (string) $form_config['engine_version'] : '',
1017 'structure' => $structure,
1018 'settings' => $settings_out,
1019 'advanced_form' => isset( $form_config['advanced_form'] ) ? (string) $form_config['advanced_form'] : '',
1020 'content_form' => isset( $form_config['content_form'] ) ? (string) $form_config['content_form'] : '',
1021 'title' => isset( $form_config['title'] ) ? (string) $form_config['title'] : '',
1022 'description' => isset( $form_config['description'] ) ? (string) $form_config['description'] : '',
1023 'picture_url' => isset( $form_config['picture_url'] ) ? (string) $form_config['picture_url'] : '',
1024 )
1025 );
1026 }
1027 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_LOAD_FORM_CONFIG', 'wpbc_bfb_ajax_load_form_config' );
1028
1029
1030 /**
1031 * Handle AJAX request: create new FormConfig by cloning a template form,
1032 * or creating a blank form when template is not selected / not available.
1033 *
1034 * Expects POST:
1035 * - nonce
1036 * - form_name (new form key / slug)
1037 * - template_form_name (optional; '' or '__blank__' => blank form)
1038 * - title (optional)
1039 * - description (optional)
1040 * - image_url (optional)
1041 */
1042 function wpbc_bfb_ajax_create_form_config() {
1043
1044 if ( ! check_ajax_referer( 'wpbc_bfb_form_create', 'nonce', false ) ) {
1045 wp_send_json_error(
1046 array(
1047 'code' => 'invalid_nonce',
1048 'message' => __( 'Security check failed.', 'booking' ),
1049 )
1050 );
1051 }
1052
1053 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
1054 wp_send_json_error(
1055 array(
1056 'code' => 'forbidden',
1057 'message' => __( 'You are not allowed to create booking forms.', 'booking' ),
1058 )
1059 );
1060 }
1061
1062 // New form key.
1063 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1064 $form_name = isset( $_POST['form_name'] ) ? wpbc_bfb__sanitize_form_slug( wp_unslash( $_POST['form_name'] ) ) : '';
1065 if ( '' === $form_name ) {
1066 wp_send_json_error(
1067 array(
1068 'code' => 'invalid_form_name',
1069 'message' => __( 'Form key is required.', 'booking' ),
1070 )
1071 );
1072 }
1073 if ( 'standard' === $form_name ) {
1074 wp_send_json_error( array( 'code' => 'reserved', 'message' => __( 'This form key is reserved.', 'booking' ) ) );
1075 }
1076
1077 // Template key (optional).
1078 $template_form_name = isset( $_POST['template_form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['template_form_name'] ) ) : '';
1079
1080 $is_blank = wpbc_bfb__is_blank_template_key( $template_form_name );
1081
1082 // Meta.
1083 $title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
1084 $description = isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : '';
1085 $image_url = isset( $_POST['image_url'] ) ? esc_url_raw( wp_unslash( $_POST['image_url'] ) ) : '';
1086
1087 if ( '' === $title ) {
1088 $title = $form_name;
1089 }
1090
1091 // MU owner logic.
1092 $owner_user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
1093
1094 // Ensure new form does not already exist.
1095 $is_fallback_to_legacy = false;
1096 $existing = wpbc_form_config_load( $form_name, $owner_user_id, 'published', $is_fallback_to_legacy );
1097 if ( ! empty( $existing ) ) {
1098 wp_send_json_error(
1099 array(
1100 'code' => 'already_exists',
1101 'message' => __( 'Form key already exists. Please choose another.', 'booking' ),
1102 )
1103 );
1104 }
1105
1106 $template = array();
1107 $structure_arr = array();
1108 $settings_arr = array();
1109 $engine = 'bfb';
1110 $engine_version = '1.0';
1111 $advanced_form = '';
1112 $content_form = '';
1113
1114 // Try to load template only when requested.
1115 if ( ! $is_blank ) {
1116
1117 // 1) Prefer user-owned template (MU) if exists.
1118 if ( $owner_user_id > 0 ) {
1119 $template = wpbc_form_config_load( $template_form_name, $owner_user_id, 'template' );
1120 }
1121
1122 // 2) Fallback to global template.
1123 if ( empty( $template ) ) {
1124 $template = wpbc_form_config_load( $template_form_name, 0, 'template' );
1125 }
1126
1127 // 3) If still missing (template deleted), fallback to standard if it exists.
1128 if ( empty( $template ) ) {
1129 $template = wpbc_form_config_load( 'standard', $owner_user_id );
1130 }
1131
1132 // If still nothing, create blank.
1133 if ( empty( $template ) ) {
1134 $is_blank = true;
1135 }
1136 }
1137
1138 if ( $is_blank ) {
1139
1140 // Blank form seed.
1141 $structure_arr = wpbc_bfb__get_blank_structure_seed();
1142 $settings_arr = wpbc_bfb__normalize_settings_array( array() );
1143
1144 // IMPORTANT: blank forms start in Builder sync mode (Builder -> Advanced).
1145 if ( empty( $settings_arr['bfb_options'] ) || ! is_array( $settings_arr['bfb_options'] ) ) {
1146 $settings_arr['bfb_options'] = array();
1147 }
1148 $settings_arr['bfb_options']['advanced_mode_source'] = 'builder';
1149
1150 $engine = 'bfb';
1151 $engine_version = '1.0';
1152 $advanced_form = '';
1153 $content_form = '';
1154
1155 } else {
1156
1157 // Clone structure from template.
1158 if ( ! empty( $template['structure_json'] ) ) {
1159 $tmp = json_decode( $template['structure_json'], true );
1160 if ( is_array( $tmp ) ) {
1161 $structure_arr = $tmp;
1162 }
1163 }
1164
1165 // Clone settings from template.
1166 $settings_arr = wpbc_bfb__normalize_settings_array( isset( $template['settings'] ) ? $template['settings'] : array() );
1167
1168 $engine = ! empty( $template['engine'] ) ? (string) $template['engine'] : 'bfb';
1169 $engine_version = ! empty( $template['engine_version'] ) ? (string) $template['engine_version'] : '1.0';
1170
1171 $advanced_form = isset( $template['advanced_form'] ) ? (string) $template['advanced_form'] : '';
1172 $content_form = isset( $template['content_form'] ) ? (string) $template['content_form'] : '';
1173 }
1174
1175 $form_config = array(
1176 'form_name' => $form_name,
1177 'engine' => $engine,
1178 'engine_version' => $engine_version,
1179 'structure_json' => wpbc_form_config__encode_json( $structure_arr ),
1180 'settings' => $settings_arr,
1181 'advanced_form' => $advanced_form,
1182 'content_form' => $content_form,
1183 'owner_user_id' => $owner_user_id,
1184
1185 'title' => $title,
1186 'description' => $description,
1187 'picture_url' => $image_url,
1188
1189 'scope' => 'global',
1190 'status' => 'published',
1191 'is_default' => 0,
1192 'booking_resource_id' => null,
1193 );
1194
1195 $sync_legacy = false;
1196
1197 $booking_form_id = wpbc_form_config_save( $form_config, array( 'sync_legacy' => (bool) $sync_legacy ) );
1198
1199 if ( ! $booking_form_id ) {
1200 wp_send_json_error(
1201 array(
1202 'code' => 'create_failed',
1203 'message' => __( 'Error creating booking form.', 'booking' ),
1204 )
1205 );
1206 }
1207
1208 wp_send_json_success(
1209 array(
1210 'booking_form_id' => $booking_form_id,
1211 'form_name' => $form_name,
1212 )
1213 );
1214 }
1215 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_CREATE_FORM_CONFIG', 'wpbc_bfb_ajax_create_form_config' );
1216
1217
1218 /**
1219 * Handle AJAX request: list booking forms for current user (and optionally global ones).
1220 *
1221 * Security:
1222 * - Verifies wpbc_bfb_form_list nonce (sent as 'nonce').
1223 * - Requires current_user_can( wpbc_bfb_get_manage_cap() ).
1224 *
1225 * Expects POST:
1226 * - nonce : string Nonce for 'wpbc_bfb_form_list'.
1227 * - include_global : 0|1 If 1, include global forms (owner_user_id=0/NULL) in addition to user-owned.
1228 * - status : string Default 'published'. Allowed: published|preview|draft|archived|template
1229 * - search : string Optional filter by title/slug/description
1230 * - limit : int Optional max rows (default 20, max 500)
1231 * - page : int Optional page number, starts from 1
1232 *
1233 * Response (JSON):
1234 * - success: true|false
1235 * - data: { forms: [ ... ] }
1236 *
1237 * @since 11.0.0
1238 *
1239 * @return void
1240 */
1241 function wpbc_bfb_ajax_list_forms() {
1242
1243 global $wpdb;
1244
1245 if ( ! check_ajax_referer( 'wpbc_bfb_form_list', 'nonce', false ) ) {
1246 wp_send_json_error( array(
1247 'code' => 'invalid_nonce',
1248 'message' => __( 'Security check failed.', 'booking' ),
1249 ) );
1250 }
1251
1252 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
1253 wp_send_json_error( array(
1254 'code' => 'forbidden',
1255 'message' => __( 'You are not allowed to list booking forms.', 'booking' ),
1256 ) );
1257 }
1258
1259 // Allow global forms ONLY when listing templates.
1260 // Templates usually live as global rows (owner_user_id = 0 / NULL).
1261 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1262 $include_global = ( isset( $_POST['include_global'] ) && '1' === (string) wp_unslash( $_POST['include_global'] ) );
1263
1264 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1265 $status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : 'published';
1266 if ( '' === $status ) {
1267 $status = 'published';
1268 }
1269
1270 $allowed_statuses = array( 'published', 'preview', 'draft', 'archived', 'template' );
1271 if ( ! in_array( $status, $allowed_statuses, true ) ) {
1272 $status = 'published';
1273 }
1274
1275 // Security policy: include_global is allowed only for templates.
1276 if ( 'template' !== $status ) {
1277 $include_global = false;
1278 }
1279
1280 $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
1281
1282 // Pagination.
1283 $page = isset( $_POST['page'] ) ? absint( wp_unslash( $_POST['page'] ) ) : 1;
1284 if ( $page <= 0 ) {
1285 $page = 1;
1286 }
1287
1288 $limit = isset( $_POST['limit'] ) ? absint( wp_unslash( $_POST['limit'] ) ) : 20;
1289 if ( $limit <= 0 ) {
1290 $limit = 20;
1291 }
1292 if ( $limit > 500 ) {
1293 $limit = 500;
1294 }
1295
1296 $offset = ( $page - 1 ) * $limit;
1297 if ( $offset < 0 ) {
1298 $offset = 0;
1299 }
1300
1301 // MU owner logic (same as save/load/create).
1302 $owner_user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
1303
1304 $table = $wpdb->prefix . 'booking_form_structures';
1305
1306 // Base WHERE.
1307 $where_sql = " WHERE status = %s ";
1308 $where_args = array( $status );
1309
1310 // Owner/global logic.
1311 if ( $owner_user_id > 0 ) {
1312 if ( $include_global ) {
1313 $where_sql .= " AND ( owner_user_id = %d OR owner_user_id = 0 OR owner_user_id IS NULL ) ";
1314 $where_args[] = $owner_user_id;
1315 } else {
1316 $where_sql .= " AND owner_user_id = %d ";
1317 $where_args[] = $owner_user_id;
1318 }
1319 } else {
1320 // Non-MU (or super admin context): treat as global rows.
1321 $where_sql .= " AND ( owner_user_id = 0 OR owner_user_id IS NULL ) ";
1322 }
1323
1324 // Search filter. Supports OR search by configured separator (default "~"): "time~duration~slots"
1325 if ( '' !== $search ) {
1326
1327 $terms = wpbc_bfb__split_search_terms_by_or_separator( $search, 5 );
1328
1329 if ( empty( $terms ) ) {
1330 // No usable terms after splitting.
1331 } elseif ( 1 === count( $terms ) ) {
1332
1333 $like = '%' . $wpdb->esc_like( $terms[0] ) . '%';
1334 $where_sql .= " AND ( form_slug LIKE %s OR title LIKE %s OR description LIKE %s ) ";
1335 $where_args[] = $like;
1336 $where_args[] = $like;
1337 $where_args[] = $like;
1338
1339 } else {
1340
1341 $or_groups = array();
1342
1343 foreach ( $terms as $term ) {
1344
1345 $or_groups[] = "( form_slug LIKE %s OR title LIKE %s OR description LIKE %s )";
1346
1347 $like = '%' . $wpdb->esc_like( $term ) . '%';
1348 $where_args[] = $like;
1349 $where_args[] = $like;
1350 $where_args[] = $like;
1351 }
1352
1353 $where_sql .= " AND ( " . implode( ' OR ', $or_groups ) . " ) ";
1354 }
1355 }
1356
1357 // Order:
1358 // - prefer user-owned rows first (when include_global + owner_user_id > 0)
1359 // - default forms first
1360 // - newest first
1361 $order_sql = " ORDER BY is_default DESC, updated_at DESC, version DESC, booking_form_id DESC ";
1362
1363 if ( $owner_user_id > 0 && $include_global ) {
1364 $order_sql = " ORDER BY ( owner_user_id = " . intval( $owner_user_id ) . " ) DESC, is_default DESC, updated_at DESC, version DESC, booking_form_id DESC ";
1365 }
1366
1367 $limit_plus_one = $limit + 1;
1368
1369 $sql = "SELECT booking_form_id, form_slug, title, description, picture_url, updated_at, owner_user_id, status, scope, is_default, version
1370 FROM {$table}
1371 {$where_sql}
1372 {$order_sql}
1373 LIMIT " . intval( $limit_plus_one ) . ' OFFSET ' . intval( $offset );
1374
1375 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
1376 $rows = $wpdb->get_results( $wpdb->prepare( $sql, $where_args ) );
1377
1378 $has_more = ( count( (array) $rows ) > $limit );
1379 if ( $has_more ) {
1380 $rows = array_slice( (array) $rows, 0, $limit );
1381 }
1382
1383 $forms = array();
1384
1385 // If include_global + owner_user_id > 0: dedupe by slug, prefer owner over global.
1386 $seen_by_slug = array();
1387
1388 foreach ( (array) $rows as $r ) {
1389
1390 $slug = isset( $r->form_slug ) ? (string) $r->form_slug : '';
1391 if ( '' === $slug ) {
1392 continue;
1393 }
1394
1395 if ( $owner_user_id > 0 && $include_global ) {
1396 if ( isset( $seen_by_slug[ $slug ] ) ) {
1397 continue;
1398 }
1399 $seen_by_slug[ $slug ] = true;
1400 }
1401
1402
1403 $row_owner_user_id = isset( $r->owner_user_id ) ? absint( $r->owner_user_id ) : 0;
1404 $row_status = isset( $r->status ) ? (string) $r->status : '';
1405 $row_is_default = isset( $r->is_default ) ? absint( $r->is_default ) : 0;
1406
1407 $raw_picture_url = isset( $r->picture_url ) ? (string) $r->picture_url : '';
1408
1409 $final_picture_url = ( 'template' === $row_status ) ? wpbc_bfb_resolve_picture_url( $raw_picture_url ) : $raw_picture_url;
1410
1411 $can_delete = wpbc_bfb__can_delete_template_in_current_context(
1412 $slug,
1413 $row_owner_user_id,
1414 $owner_user_id,
1415 $row_is_default,
1416 $row_status
1417 );
1418
1419 $forms[] = array(
1420 'booking_form_id' => isset( $r->booking_form_id ) ? (int) $r->booking_form_id : 0,
1421 'form_slug' => $slug,
1422 'title' => isset( $r->title ) ? (string) $r->title : '',
1423 'description' => isset( $r->description ) ? (string) $r->description : '',
1424 'picture_url' => $final_picture_url,
1425 'updated_at' => isset( $r->updated_at ) ? (string) $r->updated_at : '',
1426 'owner_user_id' => $row_owner_user_id,
1427 'status' => $row_status,
1428 'scope' => isset( $r->scope ) ? (string) $r->scope : '',
1429 'is_default' => $row_is_default,
1430 'version' => isset( $r->version ) ? (int) $r->version : 0,
1431 'can_delete' => $can_delete ? 1 : 0,
1432 );
1433 }
1434
1435 wp_send_json_success( array(
1436 'forms' => $forms,
1437 'count' => count( $forms ),
1438 'page' => $page,
1439 'limit' => $limit,
1440 'has_more' => $has_more,
1441 ) );
1442 }
1443 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_LIST_FORMS', 'wpbc_bfb_ajax_list_forms' );
1444
1445
1446 /**
1447 * Handle AJAX request: delete a TEMPLATE FormConfig.
1448 *
1449 * Security:
1450 * - Verifies wpbc_bfb_form_delete nonce (or list nonce fallback).
1451 * - Requires current_user_can( wpbc_bfb_get_manage_cap() ).
1452 *
1453 * Expects POST:
1454 * - nonce : string Nonce for delete/list action.
1455 * - form_name : string Template slug/key to delete.
1456 *
1457 * Behaviour:
1458 * - Deletes ONLY template rows for the given slug.
1459 * - In MultiUser mode: a regular user can delete ONLY their own templates.
1460 * - Global templates shown to regular MU users are NOT deletable.
1461 *
1462 * Response (JSON):
1463 * - success: true|false
1464 * - data: {
1465 * form_name: string,
1466 * deleted: int
1467 * }
1468 *
1469 * @since 11.0.0
1470 *
1471 * @return void
1472 */
1473 function wpbc_bfb_ajax_delete_template_config() {
1474 global $wpdb;
1475
1476 if ( ! wpbc_bfb__verify_delete_request_nonce() ) {
1477 wp_send_json_error(
1478 array(
1479 'code' => 'invalid_nonce',
1480 'message' => __( 'Security check failed.', 'booking' ),
1481 )
1482 );
1483 }
1484
1485 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
1486 wp_send_json_error(
1487 array(
1488 'code' => 'forbidden',
1489 'message' => __( 'You are not allowed to delete templates.', 'booking' ),
1490 )
1491 );
1492 }
1493
1494 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
1495 $form_name = isset( $_POST['form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['form_name'] ) ) : '';
1496 if ( '' === $form_name ) {
1497 wp_send_json_error(
1498 array(
1499 'code' => 'invalid_form_name',
1500 'message' => __( 'Template key is required.', 'booking' ),
1501 )
1502 );
1503 }
1504
1505 if ( 'standard' === $form_name ) {
1506 wp_send_json_error(
1507 array(
1508 'code' => 'reserved',
1509 'message' => __( 'This template cannot be deleted.', 'booking' ),
1510 )
1511 );
1512 }
1513
1514 $owner_user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
1515
1516 /**
1517 * Filter whether deletion of a specific template is allowed.
1518 *
1519 * @since 11.0.0
1520 *
1521 * @param bool $is_allowed Default true.
1522 * @param string $form_name Template slug/key.
1523 * @param int $owner_user_id Owner user id in MU (0 for global).
1524 */
1525 $is_allowed = apply_filters( 'wpbc_bfb_delete_template_is_allowed', true, $form_name, $owner_user_id );
1526 if ( ! $is_allowed ) {
1527 wp_send_json_error(
1528 array(
1529 'code' => 'not_allowed',
1530 'message' => __( 'Deletion is not allowed for this template.', 'booking' ),
1531 )
1532 );
1533 }
1534
1535 $table = $wpdb->prefix . 'booking_form_structures';
1536
1537 $where_sql = " WHERE form_slug = %s AND status = %s ";
1538 $where_args = array( $form_name, 'template' );
1539
1540 if ( $owner_user_id > 0 ) {
1541 $where_sql .= " AND owner_user_id = %d ";
1542 $where_args[] = $owner_user_id;
1543 } else {
1544 $where_sql .= " AND ( owner_user_id = 0 OR owner_user_id IS NULL ) ";
1545 }
1546
1547 $sql = "SELECT booking_form_id, is_default, owner_user_id
1548 FROM {$table}
1549 {$where_sql}
1550 ORDER BY updated_at DESC, version DESC, booking_form_id DESC
1551 LIMIT 1";
1552
1553 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
1554 $row = $wpdb->get_row( $wpdb->prepare( $sql, $where_args ) );
1555
1556 if ( empty( $row ) ) {
1557 wp_send_json_error(
1558 array(
1559 'code' => 'not_found',
1560 'message' => __( 'Template not found.', 'booking' ),
1561 )
1562 );
1563 }
1564
1565 if ( 1 === absint( $row->is_default ) ) {
1566 wp_send_json_error(
1567 array(
1568 'code' => 'reserved',
1569 'message' => __( 'This template cannot be deleted.', 'booking' ),
1570 )
1571 );
1572 }
1573
1574 $delete_sql = "DELETE FROM {$table} {$where_sql}";
1575
1576 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
1577 $deleted = $wpdb->query( $wpdb->prepare( $delete_sql, $where_args ) );
1578
1579 if ( false === $deleted ) {
1580 wp_send_json_error(
1581 array(
1582 'code' => 'delete_failed',
1583 'message' => __( 'Error deleting template.', 'booking' ) . ( ! empty( $wpdb->last_error ) ? ' ' . $wpdb->last_error : '' ),
1584 )
1585 );
1586 }
1587
1588 wp_send_json_success(
1589 array(
1590 'form_name' => $form_name,
1591 /* translators: 1: template name */
1592 'message' => sprintf( __( 'Template %s deleted.', 'booking' ), "'" . $form_name . "'" ) . ' [' . absint( $deleted ) . ']',
1593 'deleted' => absint( $deleted ),
1594 )
1595 );
1596 }
1597 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_DELETE_TEMPLATE_CONFIG', 'wpbc_bfb_ajax_delete_template_config' );
1598
1599
1600 /**
1601 * Handle AJAX request: delete a custom FormConfig.
1602 *
1603 * Security:
1604 * - Verifies wpbc_bfb_form_delete nonce (sent as 'nonce').
1605 * - Requires current_user_can( wpbc_bfb_get_manage_cap() ).
1606 *
1607 * Expects POST:
1608 * - nonce : string Nonce for 'wpbc_bfb_form_delete'.
1609 * - form_name : string Custom form slug/key to delete (required).
1610 *
1611 * Behaviour:
1612 * - Blocks deletion of reserved/default forms (e.g. 'standard' or is_default=1).
1613 * - In MultiUser mode: a regular user can delete ONLY their own forms.
1614 * - Deletes ALL rows for this form_slug (all statuses/versions), excluding scope='template'.
1615 *
1616 * Response (JSON):
1617 * - success: true|false
1618 * - data: {
1619 * form_name: string,
1620 * deleted: int
1621 * }
1622 *
1623 * @since 11.0.0
1624 *
1625 * @return void
1626 */
1627 function wpbc_bfb_ajax_delete_form_config() {
1628 global $wpdb;
1629
1630 if ( ! wpbc_bfb__verify_delete_request_nonce() ) {
1631 wp_send_json_error(
1632 array(
1633 'code' => 'invalid_nonce',
1634 'message' => __( 'Security check failed.', 'booking' ),
1635 )
1636 );
1637 }
1638
1639 if ( ! current_user_can( wpbc_bfb_get_manage_cap() ) ) {
1640 wp_send_json_error(
1641 array(
1642 'code' => 'forbidden',
1643 'message' => __( 'You are not allowed to delete booking forms.', 'booking' ),
1644 )
1645 );
1646 }
1647
1648 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
1649 $form_name = isset( $_POST['form_name'] ) ? sanitize_text_field( wp_unslash( $_POST['form_name'] ) ) : '';
1650 if ( '' === $form_name ) {
1651 wp_send_json_error(
1652 array(
1653 'code' => 'invalid_form_name',
1654 'message' => __( 'Form key is required.', 'booking' ),
1655 )
1656 );
1657 }
1658
1659 // Block reserved key.
1660 if ( 'standard' === $form_name ) {
1661 wp_send_json_error(
1662 array(
1663 'code' => 'reserved',
1664 'message' => __( 'This form cannot be deleted.', 'booking' ),
1665 )
1666 );
1667 }
1668
1669 // MU owner logic (same approach as save/load/create/list).
1670 $owner_user_id = WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id();
1671
1672 /**
1673 * Filter whether deletion of a specific form is allowed.
1674 *
1675 * @since 11.0.0
1676 *
1677 * @param bool $is_allowed Default true.
1678 * @param string $form_name Form slug/key.
1679 * @param int $owner_user_id Owner user id in MU (0 for global).
1680 */
1681 $is_allowed = apply_filters( 'wpbc_bfb_delete_form_is_allowed', true, $form_name, $owner_user_id );
1682 if ( ! $is_allowed ) {
1683 wp_send_json_error(
1684 array(
1685 'code' => 'not_allowed',
1686 'message' => __( 'Deletion is not allowed for this form.', 'booking' ),
1687 )
1688 );
1689 }
1690
1691 $table = $wpdb->prefix . 'booking_form_structures';
1692
1693 // ---------------------------------------------------------------------------------
1694 // Check existence + protect default/template.
1695 // ---------------------------------------------------------------------------------
1696 $where_sql = " WHERE form_slug = %s ";
1697 $where_args = array( $form_name );
1698
1699 if ( $owner_user_id > 0 ) {
1700 $where_sql .= " AND owner_user_id = %d ";
1701 $where_args[] = $owner_user_id;
1702 } else {
1703 $where_sql .= " AND ( owner_user_id = 0 OR owner_user_id IS NULL ) ";
1704 }
1705
1706 // Exclude template scope rows from selection checks as well.
1707 $where_sql .= " AND ( scope IS NULL OR scope <> %s ) ";
1708 $where_args[] = 'template';
1709
1710 // Exclude template scope rows from selection checks as well.
1711 $where_sql .= " AND ( status IS NULL OR status <> %s ) ";
1712 $where_args[] = 'template';
1713
1714 $sql = "SELECT booking_form_id, is_default, scope, status
1715 FROM {$table}
1716 {$where_sql}
1717 ORDER BY updated_at DESC, version DESC, booking_form_id DESC
1718 LIMIT 1";
1719
1720 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
1721 $row = $wpdb->get_row( $wpdb->prepare( $sql, $where_args ) );
1722
1723 if ( empty( $row ) ) {
1724 wp_send_json_error(
1725 array(
1726 'code' => 'not_found',
1727 'message' => __( 'Booking form not found.', 'booking' ),
1728 )
1729 );
1730 }
1731
1732 $is_default = isset( $row->is_default ) ? absint( $row->is_default ) : 0;
1733 if ( 1 === $is_default ) {
1734 wp_send_json_error(
1735 array(
1736 'code' => 'reserved',
1737 'message' => __( 'This form cannot be deleted.', 'booking' ),
1738 )
1739 );
1740 }
1741
1742 $scope = isset( $row->scope ) ? (string) $row->scope : '';
1743 $status = isset( $row->status ) ? (string) $row->status : '';
1744 if ( ( 'template' === $scope ) || ( 'template' === $status ) ) {
1745 wp_send_json_error(
1746 array(
1747 'code' => 'reserved',
1748 'message' => __( 'Template forms cannot be deleted.', 'booking' ),
1749 )
1750 );
1751 }
1752
1753 // ---------------------------------------------------------------------------------
1754 // Delete ALL rows for this slug/owner (all statuses/versions), excluding templates.
1755 // ---------------------------------------------------------------------------------
1756 $delete_where_sql = " WHERE form_slug = %s ";
1757 $delete_where_args = array( $form_name );
1758
1759 if ( $owner_user_id > 0 ) {
1760 $delete_where_sql .= " AND owner_user_id = %d ";
1761 $delete_where_args[] = $owner_user_id;
1762 } else {
1763 $delete_where_sql .= " AND ( owner_user_id = 0 OR owner_user_id IS NULL ) ";
1764 }
1765
1766 $delete_where_sql .= " AND ( scope IS NULL OR scope <> %s ) ";
1767 $delete_where_args[] = 'template';
1768 $delete_where_sql .= " AND ( status IS NULL OR status <> %s ) ";
1769 $delete_where_args[] = 'template';
1770
1771 $delete_sql = "DELETE FROM {$table} {$delete_where_sql}";
1772
1773 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
1774 $deleted = $wpdb->query( $wpdb->prepare( $delete_sql, $delete_where_args ) );
1775
1776 if ( false === $deleted ) {
1777 wp_send_json_error(
1778 array(
1779 'code' => 'delete_failed',
1780 'message' => __( 'Error deleting booking form.', 'booking' ) . ( ! empty( $wpdb->last_error ) ? ' ' . $wpdb->last_error : '' ),
1781 )
1782 );
1783 }
1784
1785 wp_send_json_success(
1786 array(
1787 'form_name' => $form_name,
1788 /* translators: 1: template name */
1789 'message' => sprintf( __( 'Booking form %s deleted.', 'booking' ), "'" . $form_name . "'" ) . ' [' . absint( $deleted ) . ']',
1790 'deleted' => absint( $deleted ),
1791 )
1792 );
1793 }
1794 add_action( 'wp_ajax_' . 'WPBC_AJX_BFB_DELETE_FORM_CONFIG', 'wpbc_bfb_ajax_delete_form_config' );
1795