PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / page-form-builder / ajax / bfb-ajax.php

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

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