PluginProbe
Booking Calendar / 11.2
Booking Calendar v11.2
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 10.11.2 All 203 releases
booking / includes / page-form-builder / form-render / class-wpbc-bfb-form-shortcode-engine.php

class-wpbc-bfb-form-shortcode-engine.php in Booking Calendar 11.2, at includes/page-form-builder/form-render/class-wpbc-bfb-form-shortcode-engine.php

1,462 lines 57.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Description
4 *
5 * @package Booking Calendar.
6 * @author wpdevelop, oplugins
7 * @web-site https://wpbookingcalendar.com/
8 * @email info@wpbookingcalendar.com
9 *
10 * @modified 2025-08-09
11 * @version 1.0
12 */
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit; // Exit if accessed directly.
16 }
17
18
19 /**
20 * WPBC Form Shortcode Engine (modular)
21 * - Parses and renders booking form shortcodes.
22 * - Supports placeholder:"Enter first name" (quoted) and legacy placeholder:Enter_first_name
23 * - Field rendering split into small private methods.
24 */
25 class WPBC_BFB_FormShortcodeEngine {
26
27 /** @var string */
28 public $current_resource_id = '';
29
30
31 /** @var array */
32 public $current_edit_booking = array(); // expects ['parsed_form'=>...]
33
34
35 /* ============================== Public API ============================== */
36
37 /**
38 * Extend the allow-list of safe inline CSS properties for KSES.
39 *
40 * @param array<int,string> $props Existing list of allowed CSS properties.
41 * @return array<int,string> Updated, de-duplicated list of properties.
42 */
43 public function wpbc_bfa_safe_style_css( $props ) {
44 $props[] = 'display'; // allow display
45 $props[] = 'visibility'; // (optional) allow visibility
46 $props[] = 'opacity'; // (optional)
47
48 return array_values( array_unique( $props ) );
49 }
50
51 /**
52 * Render a booking form string by parsing supported shortcodes into HTML.
53 *
54 * @param string $form Raw form markup containing shortcodes.
55 * @return string Form HTML with shortcodes replaced.
56 */
57 public function render( $form ) {
58
59 // Allow some inline styles in SHORTCODESS of the booking form parser.
60 add_filter( 'safe_style_css', array( $this, 'wpbc_bfa_safe_style_css' ), 10, 1 );
61
62 $form = $this->form_elements( $form, true );
63
64 remove_filter( 'safe_style_css', array( $this, 'wpbc_bfa_safe_style_css' ) );
65
66 return $form;
67 }
68
69 /**
70 * Parse all shortcodes in a form string without replacing them.
71 *
72 * @param string $form Raw form markup containing shortcodes.
73 * @return array<int,array<string,mixed>> Token arrays describing each shortcode found.
74 */
75 public function parse( $form ) {
76 return $this->form_elements( $form, false );
77 }
78
79 /* ============================ Core Dispatcher ============================ */
80
81 /**
82 * Core dispatcher: finds shortcode tokens and either replaces them or returns parsed tokens.
83 *
84 * @param string $form Source form string.
85 * @param bool $replace When true, returns rendered HTML; when false, returns parsed tokens.
86 * @return string|array<int,array<string,mixed>> Rendered form HTML or parsed token arrays.
87 */
88 public function form_elements( $form, $replace = true ) {
89
90 // Deprected old code:
91 // $types = 'text[*]?|email[*]?|coupon[*]?|time[*]?|textarea[*]?|select[*]?|selectbox[*]?|checkbox[*]?|radio[*]?|acceptance|captchac|captchar|file[*]?|quiz';
92 // $regex = '%\[\s*(' . $types . ')(\s+[a-zA-Z][0-9a-zA-Z:._-]*)([-0-9a-zA-Z:#_/|\s]*)?((?:\s*(?:"[^"]*"|\'[^\']*\'))*)?\s*\]%';
93 // $regex_start_end_time = '%\[\s*(country[*]?|starttime[*]?|endtime[*]?)(\s*[a-zA-Z]*[0-9a-zA-Z:._-]*)([-0-9a-zA-Z:#_/|\s]*)*((?:\s*(?:"[^"]*"|\'[^\']*\'))*)?\s*\]%';
94 // $submit_regex = '%\[\s*submit(\s[-0-9a-zA-Z:#_/\s]*)?(\s+(?:"[^"]*"|\'[^\']*\'))?\s*\]%';
95
96 $types = 'text[*]?|email[*]?|coupon[*]?|time[*]?|textarea[*]?|select[*]?|selectbox[*]?|checkbox[*]?|radio[*]?|acceptance|captchac|captchar|file[*]?|quiz';
97
98 $regex = '%\[\s*(' . $types . ')' .
99 '(\s+[a-zA-Z][0-9a-zA-Z:._-]*)' .
100 '((?:\s*(?:"[^"]*"|\'[^\']*\'|[^"\]\s]+))*)' .
101 '\s*\]%u';
102
103 $regex_start_end_time = '%\[\s*(country[*]?|starttime[*]?|endtime[*]?)' .
104 '(\s*[a-zA-Z]*[0-9a-zA-Z:._-]*)' .
105 '((?:\s*(?:"[^"]*"|\'[^\']*\'|[^"\]\s]+))*)' .
106 '\s*\]%u';
107
108 $submit_regex = '%\[\s*submit' .
109 '((?:\s*(?:"[^"]*"|\'[^\']*\'|[^"\]\s]+))*)' .
110 '\s*\]%u';
111
112
113 if ( $replace ) {
114 $form = preg_replace_callback( $regex, array( $this, 'form_element_replace_callback' ), $form );
115 $form = preg_replace_callback( $regex_start_end_time, array(
116 $this,
117 'form_element_replace_callback',
118 ), $form );
119 $form = preg_replace_callback( $submit_regex, array( $this, 'submit_replace_callback' ), $form );
120
121 return $form;
122 }
123
124 $out = array();
125 preg_match_all( $regex, $form, $m1, PREG_SET_ORDER );
126 preg_match_all( $regex_start_end_time, $form, $m2, PREG_SET_ORDER );
127 foreach ( array_merge( $m1, $m2 ) as $m ) {
128 $out[] = (array) $this->form_element_parse( $m );
129 }
130
131 return $out;
132 }
133
134 /**
135 * preg_replace_callback handler for all non-submit shortcodes.
136 *
137 * @param array<int,mixed> $matches Regex matches for a single shortcode.
138 * @return string Rendered HTML for the matched shortcode.
139 */
140 public function form_element_replace_callback( $matches ) {
141 // Provide defaults for extract()
142 $type = $name = '';
143 $options = $raw_values = $values = array();
144 $placeholder_text = null;
145 $named = array();
146
147 extract( (array) $this->form_element_parse( $matches ) ); // $type, $name, $options, $values, $raw_values, $placeholder_text. Now includes $named.
148
149 // Normalize name for country.
150 if ( $type === 'country' || $type === 'country*' ) {
151 if ( $name === '' ) {
152 $name = $type;
153 }
154 }
155
156 // Suffix booking type for runtime uniqueness
157 $name_with_suffix = $name . $this->current_resource_id;
158
159 // Apply edit-booking defaults (select/radio/checkbox/country)
160 list( $values, $options ) = $this->apply_edit_defaults( $type, $name_with_suffix, $values, $options );
161
162 // Build common attributes: autocomplete, id, placeholder, class flags.
163 $atts = $this->build_common_atts( $type, $name, $name_with_suffix, $options, $placeholder_text, $named );
164
165 $validation_error = $this->build_validation_error( $name_with_suffix );
166
167 // Restore posted value on validation error (for text-ish types)
168 $restored_value = $this->restore_posted_value( $type, $name_with_suffix, isset( $values[0] ) ? $values[0]
169 : '' );
170
171 // Dispatch per field type
172 switch ( $type ) {
173 case 'text':
174 case 'text*':
175 case 'email':
176 case 'email*':
177 case 'coupon':
178 case 'coupon*':
179 case 'time':
180 case 'time*':
181 case 'starttime':
182 case 'starttime*':
183 case 'endtime':
184 case 'endtime*':
185 case 'captchar':
186 return $this->render_text_like( $type, $name_with_suffix, $atts, $options, $restored_value, $validation_error );
187
188 case 'textarea':
189 case 'textarea*':
190 return $this->render_textarea( $name_with_suffix, $atts, $options, $restored_value, $validation_error );
191
192 case 'country':
193 case 'country*':
194 return $this->render_country( $name_with_suffix, $atts, $options, $values, $validation_error );
195
196 case 'select':
197 case 'select*':
198 case 'selectbox':
199 case 'selectbox*':
200 return $this->render_select( $type, $name_with_suffix, $atts, $options, $values, $validation_error );
201
202 case 'checkbox':
203 case 'checkbox*':
204 case 'radio':
205 case 'radio*':
206 return $this->render_checkable_group( $type, $name_with_suffix, $atts, $options, $values, $validation_error );
207
208 case 'quiz':
209 return $this->render_quiz( $name_with_suffix, $atts, $options, $raw_values, $values, $validation_error );
210
211 case 'acceptance':
212 return $this->render_acceptance( $name_with_suffix, $atts, $options );
213
214 case 'captchac':
215 return $this->render_captcha( $name_with_suffix, $atts, $options );
216
217 case 'file':
218 case 'file*':
219 return $this->render_file( $name_with_suffix, $atts, $validation_error );
220 }
221
222 return '';
223 }
224
225 /**
226 * preg_replace_callback handler for the [submit ... "Label"] shortcode.
227 * Supports legacy options (id:, class:) and named attributes (id="", class="", aria-*, data-*, style, ...).
228 *
229 * @param array<int,mixed> $matches Regex matches for the submit shortcode.
230 * @return string Rendered <input type="button"> HTML.
231 */
232 public function submit_replace_callback( $matches ) {
233
234 $atts = '';
235 $options = array();
236 $value = '';
237
238 // Parse named attributes from the whole shortcode (id="...", class="...", title="...", aria-*, data-*, style, etc.)
239 list( $named_raw, $consumed ) = $this->parse_named_attributes_from_shortcode( $matches[0] );
240
241 // Legacy-style options (id:..., class:..., etc.) from the unquoted leftovers of $matches[1].
242 if ( isset( $matches[1] ) && trim( $matches[1] ) !== '' ) {
243 $opts_no_quotes = preg_replace( '/"[^"]*"|\'[^\']*\'/u', ' ', trim( $matches[1] ) );
244 $options = preg_split( '/\s+/u', trim( $opts_no_quotes ) );
245 $options = array_values( array_filter( (array) $options, 'strlen' ) );
246 }
247
248 // Legacy id:... has priority over named id="..."
249 $id_legacy = $this->shift_match( '%^id:([-0-9a-zA-Z_]+)$%', $options );
250 if ( $id_legacy ) {
251 $atts .= ' id="' . $id_legacy . '"';
252 } elseif ( isset( $named_raw['id'] ) && $named_raw['id'] !== '' ) {
253 $atts .= ' id="' . esc_attr( $named_raw['id'] ) . '"';
254 }
255
256 // Legacy class:... + named class="..."
257 $class_legacy = $this->collect_classes( $options );
258 $class_named = isset( $named_raw['class'] ) ? trim( $named_raw['class'] ) : '';
259 $btn_classes = trim( 'wpbc_button_light ' . $class_legacy . ' ' . $class_named );
260 $atts .= ' class="' . esc_attr( $btn_classes ) . '"';
261
262 // Add safe named attributes (title, aria-*, data-*, style if allowed, etc.)
263 foreach ( $named_raw as $k => $v ) {
264 $kk = $this->sanitize_attr_name( $k, 'submit', 'submit' );
265 if ( $kk === '' ) {
266 continue;
267 }
268 if ( in_array( $kk, array( 'id', 'class' ), true ) ) {
269 continue;
270 }
271 if ( $v === '' ) {
272 continue;
273 }
274 $atts .= ' ' . $kk . '="' . esc_attr( $v ) . '"';
275 }
276
277 // Label: take the *last* quoted string not consumed by named-attr values
278 preg_match_all( '/"[^"]*"|\'[^\']*\'/u', $matches[0], $qm );
279 $quoted = $this->strip_quote_deep( $qm[0] ); // unquoted strings
280 if ( is_array( $quoted ) ) {
281 foreach ( (array) $consumed as $q ) {
282 $idx = array_search( $q, $quoted, true );
283 if ( $idx !== false ) {
284 unset( $quoted[ $idx ] );
285 }
286 }
287 $quoted = array_values( $quoted );
288 if ( ! empty( $quoted ) ) {
289 $value = $quoted[ count( $quoted ) - 1 ];
290 }
291 }
292 if ( $value === '' ) {
293 $value = __( 'Send', 'booking' );
294 }
295
296 $html = '';
297
298 // -------------------------------------------------------------------------------------------------------------
299 // Cancel / Edit Buttons.
300 // -------------------------------------------------------------------------------------------------------------
301 if ( isset( $_GET['booking_hash'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
302 $get_booking_hash = ( ( isset( $_GET['booking_hash'] ) ) ? sanitize_text_field( wp_unslash( $_GET['booking_hash'] ) ) : '' ); /* phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */ /* FixIn: sanitize_unslash */
303 $my_booking_id_type = wpbc_hash__get_booking_id__resource_id( $get_booking_hash );
304
305 if ( $my_booking_id_type !== false ) {
306
307 $my_edited_bk_id = $my_booking_id_type[0];
308
309 $admin_uri = ltrim( str_replace( get_site_url( null, '', 'admin' ), '', admin_url( 'admin.php?' ) ), '/' );
310
311 $server_request_uri = ( ( isset( $_SERVER['REQUEST_URI'] ) ) ? sanitize_text_field( $_SERVER['REQUEST_URI'] ) : '' ); /* phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash */ /* FixIn: sanitize_unslash */
312 $server_http_referer_uri = ( ( isset( $_SERVER['HTTP_REFERER'] ) ) ? sanitize_text_field( $_SERVER['HTTP_REFERER'] ) : '' ); /* phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash */ /* FixIn: sanitize_unslash */
313
314 if ( ( strpos( $server_request_uri, $admin_uri ) !== false ) && ( isset( $server_http_referer_uri ) ) ) {
315 $html .= '<input type="hidden" name="wpdev_http_referer" id="wpdev_http_referer" value="' . $server_http_referer_uri . '" />';
316 }
317
318 $value = __( 'Change your Booking', 'booking' );
319
320 if ( isset( $_GET['booking_cancel'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
321
322 $value = __( 'Cancel Booking', 'booking' );
323
324 $wpbc_nonce = wp_nonce_field( 'DELETE_BY_VISITOR', ( "wpbc_nonce_delete" . $this->current_resource_id ), true, false );
325
326 $get_booking_hash = ( ( isset( $_GET['booking_hash'] ) ) ? sanitize_text_field( wp_unslash( $_GET['booking_hash'] ) ) : '' ); /* phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */ /* FixIn: sanitize_unslash */
327
328 $html .= $wpbc_nonce . '<input type="button" value="' . esc_attr( $value ) . '"' .
329 $atts .
330 ' onclick="wpbc_customer_action__booking_cancel(\'' . $get_booking_hash . '\',' . $this->current_resource_id . ', \'' . wpbc_get_maybe_reloaded_booking_locale() . '\' );wpbc_booking_form__this_button__disable( this );" />';
331
332 // FixIn: 8.4.2.5.
333 $html .= '<script type="text/javascript"> ' . wpbc_jq_ready_start(); // FixIn: 10.1.3.7.
334 $html .= ' jQuery( "#booking_form' . (int) $this->current_resource_id . '" ).find(":input").prop("disabled", true);';
335 $html .= ' jQuery( "#booking_form' . (int) $this->current_resource_id . '" ).find("input[type=\'button\']").prop("disabled", false );';
336 $html .= wpbc_jq_ready_end() . '</script>'; // FixIn: 10.1.3.7.
337
338 return $html;
339
340 } else {
341 // FixIn: 8.4.2.9.
342 if ( wpbc_is_new_booking_page() ) { // FixIn: 8.4.5.9.
343
344 $html .= '<input type="button" value="' . __( 'Duplicate Booking', 'booking' ) . '"' . $atts . ' style="margin:0 50px 20px 0;float: left;"' . ' onclick="if ( wpbc_are_you_sure(\'' . esc_js( __( 'Do you really want to do this ?', 'booking' ) ) . '\') ) { jQuery( \'#wpbc_other_action\' ).val(\'duplicate_booking\'); wpbc_booking_form_submit(this.form,' . $this->current_resource_id . ', \'' . wpbc_get_maybe_reloaded_booking_locale() . '\' ); }" />';
345 }
346 $html .= '<input type="text" name="wpbc_other_action" id="wpbc_other_action" value="" style="display:none;" />';
347 }
348 }
349 }
350
351 // -------------------------------------------------------------------------------------------------------------
352 // Submit Button.
353 // -------------------------------------------------------------------------------------------------------------
354 $html .= '<input type="button" value="' . esc_attr( $value ) . '"' .
355 $atts .
356 ' onclick="wpbc_booking_form_submit(this.form,' . esc_attr( $this->current_resource_id ) . ', \'' .
357 esc_js( function_exists( 'wpbc_get_maybe_reloaded_booking_locale' ) ? wpbc_get_maybe_reloaded_booking_locale() : '' ) .
358 '\');" />';
359
360 return $html;
361 }
362
363
364 /* ============================ Rendering methods ============================ */
365
366 /**
367 * Render text-like inputs (text, email, coupon, time, starttime, endtime, captchar).
368 *
369 * @param string $type Shortcode type (e.g., 'text', 'email*', 'starttime').
370 * @param string $name Field name (already suffixed with booking type).
371 * @param string $atts Prebuilt attribute string (leading space included).
372 * @param array<int,string> $options Legacy option tokens.
373 * @param string $value Initial value.
374 * @param string $validation_error Validation error HTML (or empty).
375 * @return string Wrapped control HTML.
376 */
377 private function render_text_like( $type, $name, $atts, $options, $value, $validation_error ) {
378 // Normalize name for start/end time fields
379 if ( $type === 'starttime' || $type === 'starttime*' ) {
380 $name = 'starttime' . $this->current_resource_id;
381 }
382 if ( $type === 'endtime' || $type === 'endtime*' ) {
383 $name = 'endtime' . $this->current_resource_id;
384 }
385
386 // size/maxlength (default size=40) — respect named attrs if already present
387 list( $size, $maxlength ) = $this->parse_size_maxlength( $options, 40 );
388 $this->append_att_once( $atts, 'size', $size );
389 if ( $maxlength > 0 ) {
390 $this->append_att_once( $atts, 'maxlength', $maxlength );
391 }
392
393 // coupon onchange handler
394 $additional_js = ( $type === 'coupon' || $type === 'coupon*' )
395 ? ' onchange="javascript:if(typeof( wpbc_show_cost_hints_after_few_seconds )==\'function\'){wpbc_show_cost_hints_after_few_seconds(' . $this->current_resource_id . ');}" '
396 : '';
397
398 // Input type
399 $field_type = ( $type === 'email' || $type === 'email*' ) ? 'type="email"' : 'type="text"';
400
401 $html = '<input ' . $field_type . ' name="' . $name . '" value="' . esc_attr( $value ) . '"' . $atts . $additional_js . ' />';
402
403 return '<span class="wpbc_wrap_text wpdev-form-control-wrap ' . $name . '">' . $html . $validation_error . '</span>';
404 }
405
406 /**
407 * Render a <textarea> control.
408 *
409 * @param string $name Field name (with booking type suffix).
410 * @param string $atts Attribute string.
411 * @param array<int,string> $options Legacy option tokens (may include 40x5).
412 * @param string $value Initial textarea value.
413 * @param string $validation_error Validation error HTML.
414 * @return string Wrapped control HTML.
415 */
416 private function render_textarea( $name, $atts, $options, $value, $validation_error ) {
417 // cols/rows
418 if ( $cr = $this->shift_match_raw( '%^[0-9]*[x/][0-9]*$%', $options ) ) {
419 if ( preg_match( '%^([0-9]*)[x/]([0-9]*)$%', $cr, $mm ) ) {
420 if ( ! empty( $mm[1] ) ) {
421 $atts .= ' cols="' . (int) $mm[1] . '"';
422 }
423 if ( ! empty( $mm[2] ) ) {
424 $atts .= ' rows="' . (int) $mm[2] . '"';
425 }
426 }
427 }
428 $html = '<textarea name="' . $name . '"' . $atts . '>' . esc_attr( $value ) . '</textarea>';
429
430 return '<span class="wpbc_wrap_textarea wpdev-form-control-wrap ' . $name . '">' . $html . $validation_error . '</span>';
431 }
432
433 /**
434 * Render a country <select>
435 *
436 * @param string $name Field name (with booking type suffix).
437 * @param string $atts Attribute string.
438 * @param array<int,string> $options Legacy option tokens (default:US_CA etc).
439 * @param array<int,string> $values Single value (e.g., ['US']) or empty.
440 * @param string $validation_error Validation error HTML.
441 * @return string Wrapped control HTML.
442 */
443 private function render_country( $name, $atts, $options, $values, $validation_error ) {
444 // Defaults from options default:
445 $scr_default = $this->collect_defaults_country( $options );
446
447 $value = isset( $values[0] ) ? $values[0] : '';
448 $html = '';
449 $wpbc_dataset_countries = wpbc_dataset_countries();
450 foreach ( $wpbc_dataset_countries as $code => $label ) {
451 $selected = '';
452 if ( is_array( $scr_default ) && in_array( $code, $scr_default, true ) ) {
453 $selected = ' selected="selected"';
454 }
455 if ( $value === $code ) {
456 $selected = ' selected="selected"';
457 }
458 $html .= '<option value="' . esc_attr( $code ) . '"' . $selected . '>' . esc_html( $label ) . '</option>';
459 }
460
461 $html = '<select name="' . $name . '"' . $atts . '>' . $html . '</select>';
462
463 return '<span class="wpbc_wrap_select wpdev-form-control-wrap ' . $name . '">' . $html . $validation_error . '</span>';
464 }
465
466 /**
467 * Render a generic <select> (and 'selectbox') with support for defaults and multiple selection.
468 *
469 * @param string $type 'select'|'select*'|'selectbox'|'selectbox*'.
470 * @param string $name Field name (with booking type suffix).
471 * @param string $atts Attribute string (may already contain multiple="...").
472 * @param array<int,string> $options Legacy option tokens (include_blank, multiple, default:...).
473 * @param array<int,string> $values Display/value pairs possibly using 'Label@@value'.
474 * @param string $validation_error Validation error HTML.
475 * @return string Wrapped control HTML.
476 */
477 private function render_select( $type, $name, $atts, $options, $values, $validation_error ) {
478
479 $multiple = (bool) preg_grep( '%^multiple$%', $options );
480 $include_blank = (bool) preg_grep( '%^include_blank$%', $options );
481 if ( empty( $values ) || $include_blank ) {
482 array_unshift( $values, '---' );
483 }
484
485 $scr_default = $this->collect_defaults_scalar( $options ); // default:value
486
487 $onclick = '';
488 if (
489 ( preg_match( '/^select[*]?$/', $type ) && $multiple && ( $name === 'rangetime' . $this->current_resource_id ) ) || ( preg_match( '/^selectbox[*]?$/', $type ) && $multiple && ( $name === 'rangetime' . $this->current_resource_id ) ) ) {
490 $onclick = ' wpbc_in_form__make_exclusive_selectbox(this); ';
491 }
492
493 $html = '';
494 foreach ( $values as $raw ) {
495 $selected = '';
496 $label = $raw;
497 $value = $raw;
498
499 // Title@@value support
500 if ( strpos( $raw, '@@' ) !== false ) {
501 list( $label, $value ) = explode( '@@', $raw, 2 );
502 }
503
504 if ( in_array( $value, $scr_default, true ) ) {
505 $selected = ' selected="selected"';
506 }
507
508
509
510 $html .= '<option value="' . esc_attr( $value ) . '"' . $selected . '>' . esc_html( $label ) . '</option>';
511 }
512
513 // If author supplied multiple via named attr (multiple="multiple" or ""), it will already be in $atts
514 if ( strpos( $atts, ' multiple="' ) !== false ) {
515 $multiple = true;
516 }
517
518 if ( $multiple ) {
519 // only add if not already present from named attrs
520 if ( strpos( $atts, ' multiple="' ) === false ) {
521 $atts .= ' multiple="multiple"';
522 }
523 }
524
525 $html = '<select onchange="javascript:' . $onclick . 'if(typeof( wpbc_show_cost_hints_after_few_seconds )==\'function\'){wpbc_show_cost_hints_after_few_seconds(' . $this->current_resource_id . ');}" ' . 'name="' . $name . ( $multiple
526 ? '[]' : '' ) . '"' . $atts . '>' . $html . '</select>';
527
528 return '<span class="wpbc_wrap_select wpdev-form-control-wrap ' . $name . '">' . $html . $validation_error . '</span>';
529 }
530
531 /**
532 * Render checkbox/radio groups with support for label options and defaults.
533 *
534 * @param string $type 'checkbox'|'checkbox*'|'radio'|'radio*'.
535 * @param string $name Field name (with booking type suffix).
536 * @param string $atts Attribute string (may include id for label for=... pairing).
537 * @param array<int,string> $options Legacy option tokens (use_label_element, label_first, etc).
538 * @param array<int,string> $values Items possibly in 'Label@@value' format.
539 * @param string $validation_error Validation error HTML.
540 * @return string Wrapped control HTML.
541 */
542 private function render_checkable_group( $type, $name, $atts, $options, $values, $validation_error ) {
543
544 // Never reuse the same id="" on multiple inputs.
545 $input_atts = preg_replace( '/\s+id="[^"]*"/i', '', (string) $atts );
546
547 $multiple = ( preg_match( '/^checkbox[*]?$/', $type ) && ! preg_grep( '%^exclusive$%', $options ) );
548 $onclick = ( preg_match( '/^checkbox[*]?$/', $type ) && ! $multiple ) ? ' onclick="wpbc_in_form__make_exclusive_checkbox(this);"' : '';
549 $default_on = (bool) preg_grep( '%^default:on$%', $options ) ? ' checked="checked"' : '';
550 $input_type = rtrim( $type, '*' );
551
552 $html = '';
553 $id_attr_for_group = '';
554
555 foreach ( (array) $values as $idx => $raw ) {
556 $checked = '';
557 $label_text = $raw;
558 $value = $raw;
559
560 if ( strpos( $raw, '@@' ) !== false ) {
561 list( $label_text, $value ) = explode( '@@', $raw, 2 );
562 }
563
564 // defaults: default:foo, default:bar,...
565 foreach ( $this->collect_defaults_scalar( $options ) as $dv ) {
566 if ( trim( $dv ) === trim( $value ) && $value !== '' ) {
567 $checked = ' checked="checked"';
568 }
569 }
570
571
572
573 // label options
574 $is_use_label = preg_grep( '%^use[_-]?label[_-]?element$%', $options ) ? 'label' : 'span';
575 $is_use_label_first = (bool) preg_grep( '%^label[_-]?first$%', $options );
576 $is_label_wrap = (bool) preg_grep( '%^label[_-]?wrap$%', $options );
577
578 $id_attr_for_checkbox = '';
579 $label_for_parameter = '';
580
581 if ( $is_use_label === 'label' ) {
582 if ( preg_match( '%id="([-0-9a-zA-Z_]+)"%', $atts, $id_matches ) ) {
583 $atts = str_replace( $id_matches[0], '', $atts );
584 $id_attr_for_group = ' id="' . $id_matches[1] . '" ';
585 $uniq = $id_matches[1] . time() . $idx . wp_rand( 1000, 10000 );
586 } else {
587 $uniq = 'checkboxid' . time() . $idx . wp_rand( 1000, 10000 );
588 }
589 $label_for_parameter = ' for="' . $uniq . '" ';
590 $id_attr_for_checkbox = ' id="' . $uniq . '" ';
591 }
592
593 $item = '<input ' . $input_atts . $id_attr_for_checkbox . ' onchange="javascript:if(typeof( wpbc_show_cost_hints_after_few_seconds )==\'function\'){wpbc_show_cost_hints_after_few_seconds(' . $this->current_resource_id . ');}" ' . ' type="' . $input_type . '" ' . ' name="' . $name . ( $multiple
594 ? '[]'
595 : '' ) . '" ' . ' value="' . esc_attr( $value ) . '"' . $checked . $onclick . $default_on . ' />';
596
597 if ( $is_label_wrap ) {
598 $item = $is_use_label_first
599 ? '<' . $is_use_label . $label_for_parameter . ' class="wpdev-list-item-label">' . esc_html( $label_text ) . $item . '</' . $is_use_label . '>'
600 : '<' . $is_use_label . $label_for_parameter . ' class="wpdev-list-item-label">' . $item . esc_html( $label_text ) . '</' . $is_use_label . '>';
601 } else {
602 $item_label = '<' . $is_use_label . $label_for_parameter . ' class="wpdev-list-item-label">' . esc_html( $label_text ) . '</' . $is_use_label . '>';
603 $item = $is_use_label_first ? ( $item_label . $item ) : ( $item . $item_label );
604 }
605
606 $html .= '<span class="wpdev-list-item">' . $item . '</span>';
607 }
608
609 $html = '<span' . $atts . $id_attr_for_group . '>' . $html . '</span>';
610
611 return '<span class="wpbc_wrap_checkbox wpdev-form-control-wrap ' . $name . '">' . $html . $validation_error . '</span>';
612 }
613
614 /**
615 * Render a simple anti-bot quiz (question + text input + hidden hashed answer).
616 *
617 * @param string $name Field name.
618 * @param string $atts Attribute string.
619 * @param array<int,string> $options Legacy option tokens (e.g., 20/80).
620 * @param array<int,string> $raw_values Raw quoted values including pipes ('Q|A').
621 * @param array<int,string> $values Extracted question labels.
622 * @param string $validation_error Validation error HTML.
623 * @return string Wrapped control HTML.
624 */
625 private function render_quiz( $name, $atts, $options, $raw_values, $values, $validation_error ) {
626 $raw_values = (array) $raw_values;
627 if ( count( $raw_values ) === 0 && count( $values ) === 0 ) {
628 $raw_values[] = '1+1=?|2';
629 $values[] = '1+1=?';
630 }
631
632 $pipes = $this->get_pipes( $raw_values );
633 $label = ( count( $values ) === 0 ) ? ''
634 : ( count( $values ) === 1 ? $values[0] : $values[ array_rand( $values ) ] );
635 $answer = $this->canonicalize( $this->pipe( $pipes, $label ) );
636
637 list( $size, $maxlength ) = $this->parse_size_maxlength( $options, 40 );
638 $atts .= ' size="' . (int) $size . '"';
639 if ( $maxlength > 0 ) {
640 $atts .= ' maxlength="' . (int) $maxlength . '"';
641 }
642
643 $html = '<span class="wpdev-quiz-label">' . esc_html( $label ) . '</span>&nbsp;';
644 $html .= '<input type="text" name="' . $name . '"' . $atts . ' />';
645 $html .= '<input type="hidden" name="wpdev_quiz_answer_' . $name . '" value="' . wp_hash( $answer, 'wpdev_quiz' ) . '" />';
646
647 return '<span class="wpdev-form-control-wrap ' . $name . '">' . $html . $validation_error . '</span>';
648 }
649
650 /**
651 * Render an acceptance checkbox that toggles submit availability.
652 *
653 * @param string $name Field name.
654 * @param string $atts Attribute string.
655 * @param array<int,string> $options Legacy option tokens (default:on, invert).
656 * @return string <input type="checkbox"> HTML.
657 */
658 private function render_acceptance( $name, $atts, $options ) {
659 $default = (bool) preg_grep( '%^default:on$%', $options );
660 $onclick = ' onclick="wpdevToggleSubmit(this.form);"';
661 $checked = $default ? ' checked="checked"' : '';
662
663 return '<input type="checkbox" name="' . $name . '" value="1"' . $atts . $onclick . $checked . ' />';
664 }
665
666 /**
667 * Render CAPTCHA image markup (Really Simple CAPTCHA integration).
668 *
669 * @param string $name Field name.
670 * @param string $atts Attribute string (width/height may be added).
671 * @param array<int,string> $options Legacy option tokens mapped to generator options.
672 * @return string Hidden challenge + <img> HTML, or message if plugin not active.
673 */
674 private function render_captcha( $name, $atts, $options ) {
675 if ( ! class_exists( 'ReallySimpleCaptcha' ) ) {
676 return '<em>' . esc_html__( 'To use CAPTCHA, you need Really Simple CAPTCHA plugin installed.', 'booking' ) . '</em>';
677 }
678 $op = array(
679 'img_size' => array( 72, 24 ),
680 'base' => array( 6, 18 ),
681 'font_size' => 14,
682 'font_char_width' => 15,
683 );
684 $op = array_merge( $op, $this->captchac_options( $options ) );
685 if ( ! $filename = $this->generate_captcha( $op ) ) {
686 return '';
687 }
688 if ( is_array( $op['img_size'] ) ) {
689 $atts .= ' width="' . (int) $op['img_size'][0] . '" height="' . (int) $op['img_size'][1] . '"';
690 }
691 $captcha_url = trailingslashit( $this->captcha_tmp_url() ) . $filename;
692 $html = '<img alt="' . esc_attr__( 'To show CAPTCHA, please deactivate cache plugin or exclude this page from caching or disable CAPTCHA at WP Booking Calendar - Settings General page in Form Options section.', 'booking' ) . '" src="' . esc_url( $captcha_url ) . '"' . $atts . ' />';
693 $ref = substr( $filename, 0, strrpos( $filename, '.' ) );
694
695 return '<input type="hidden" name="wpdev_captcha_challenge_' . $name . '" value="' . esc_attr( $ref ) . '" />' . $html;
696 }
697
698 /**
699 * Render a file upload input.
700 *
701 * @param string $name Field name.
702 * @param string $atts Attribute string.
703 * @param string $validation_error Validation error HTML.
704 * @return string Wrapped control HTML.
705 */
706 private function render_file( $name, $atts, $validation_error ) {
707 $html = '<input type="file" name="' . $name . '"' . $atts . ' value="1" />';
708
709 return '<span class="wpdev-form-control-wrap ' . $name . '">' . $html . $validation_error . '</span>';
710 }
711
712 /* =============================== Helpers =============================== */
713
714
715 /**
716 * Restore a previously submitted value after validation errors.
717 *
718 * @param string $type Shortcode type.
719 * @param string $name Field name (with booking type suffix).
720 * @param string $fallback Fallback value when not restoring.
721 * @return string Restored or fallback value.
722 */
723 private function restore_posted_value( $type, $name, $fallback ) {
724 return $fallback;
725 }
726
727 /**
728 * Produce inline validation error HTML for a named field, if present.
729 *
730 * @param string $name Field name (with booking type suffix).
731 * @return string Error span HTML or empty string.
732 */
733 private function build_validation_error( $name ) {
734 return '';
735 }
736
737 /**
738 * Build common HTML attributes for a field from legacy and named attributes.
739 *
740 * @param string $type Shortcode type.
741 * @param string $name_without_suffix Original field name (no booking-type suffix).
742 * @param string $name_with_suffix Field name with booking-type suffix.
743 * @param array<int,string> &$options Legacy options (consumed as needed).
744 * @param string|null $placeholder_text Named/legacy placeholder text, if any.
745 * @param array<string,string> $named Sanitized named attributes (id, class, aria-*, etc).
746 * @return string Attribute string beginning with a leading space.
747 */
748 private function build_common_atts( $type, $name_without_suffix, $name_with_suffix, &$options, $placeholder_text, $named = array() ) {
749
750 $atts = '';
751
752 // Base autocomplete using raw field name (BC).
753 $atts .= ' autocomplete="' . esc_attr( $name_without_suffix ) . '"';
754
755 // Legacy id:foo (wins over named id="...").
756 if ( $id = $this->shift_match( '%^id:([-0-9a-zA-Z_]+)$%', $options ) ) {
757 $atts .= ' id="' . $id . $this->current_resource_id . '"';
758
759 }
760
761 // Legacy placeholder:Enter_name (kept for BC if no named placeholder=...).
762 if ( empty( $named['placeholder'] ) ) {
763 if ( ! empty( $placeholder_text ) ) { // placeholder (quoted first).
764 $atts .= ' placeholder="' . esc_attr( $placeholder_text ) . '"';
765 } else {
766 if ( $legacy = $this->shift_match( '%^placeholder:([-0-9a-zA-Z_//]+)$%', $options ) ) {
767 $atts .= ' placeholder="' . esc_attr( str_replace( '_', ' ', $legacy ) ) . '"';
768 }
769 }
770 }
771
772 // Legacy classes first.
773 $class_att = $this->collect_classes( $options );
774
775 // Validation/role CSS (unchanged).
776 if ( preg_match( '/^email[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-email';
777 if ( preg_match( '/^coupon[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-coupon';
778 if ( preg_match( '/^time[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-time';
779 if ( preg_match( '/^starttime[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-time';
780 if ( preg_match( '/^endtime[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-time';
781 if ( preg_match( '/[*]$/', $type ) ) $class_att .= ' wpdev-validates-as-required';
782 if ( preg_match( '/^checkbox[*]?$/', $type ) ) $class_att .= ' wpdev-checkbox';
783 if ( preg_match( '/^radio[*]?$/', $type ) ) $class_att .= ' wpdev-radio';
784 if ( preg_match( '/^captchac$/', $type ) ) $class_att .= ' wpdev-captcha-' . $name_with_suffix;
785 if ( $type === 'acceptance' ) {
786 $class_att .= ' wpdev-acceptance';
787 if ( preg_grep( '%^invert$%', $options ) ) {
788 $class_att .= ' wpdev-invert';
789 }
790 }
791
792 // Merge named class="...".
793 if ( isset( $named['class'] ) && $named['class'] !== '' ) {
794 $class_att .= ' ' . trim( $named['class'] );
795 }
796 if ( $class_att ) {
797 $atts .= ' class="' . esc_attr( trim( $class_att ) ) . '"';
798 }
799
800 // Add the rest of named attributes safely (id/placeholder/class already handled).
801 $atts .= $this->build_named_atts_string( $named, $type, $name_without_suffix, $name_with_suffix, $atts, $options );
802
803 return $atts;
804 }
805
806 /**
807 * Parse a "size/maxlength" token (e.g., "30/100") from legacy options.
808 *
809 * @param array<int,string> $options Legacy option tokens.
810 * @param int $default_size Default input size if none provided.
811 * @return array{0:int,1:int} [size, maxlength] (maxlength may be 0 if not set).
812 */
813 private function parse_size_maxlength( $options, $default_size = 40 ) {
814 $size = (int) $default_size;
815 $maxlength = 0;
816 $sm = preg_grep( '%^[0-9]*[/x][0-9]*$%', $options );
817 if ( $raw = array_shift( $sm ) ) {
818 if ( preg_match( '%^([0-9]*)[/x]([0-9]*)$%', $raw, $mm ) ) {
819 if ( ! empty( $mm[1] ) ) {
820 $size = (int) $mm[1];
821 }
822 if ( ! empty( $mm[2] ) ) {
823 $maxlength = (int) $mm[2];
824 }
825 }
826 }
827
828 return array( $size, $maxlength );
829 }
830
831 /**
832 * Collect default values from tokens like "default:foo" (scalar values).
833 *
834 * @param array<int,string> $options Legacy option tokens.
835 * @return string[] Array of default values.
836 */
837 private function collect_defaults_scalar( $options ) {
838 $defs = array();
839 foreach ( preg_grep( '/^default:/', (array) $options ) as $dv ) {
840 // Capture everything after "default:" as-is (trim trailing/leading whitespace).
841 if ( preg_match( '/^default:(.+)$/u', $dv, $m ) ) {
842 $val = trim( (string) $m[1] );
843 // Historic BC: decode percent entity if present.
844 $val = str_replace( '&#37;', '%', $val );
845 $defs[] = $val;
846 }
847 }
848 return $defs;
849 }
850
851 /**
852 * Collect default country codes from a token like "default:US_FR".
853 *
854 * @param array<int,string> $options Legacy option tokens.
855 * @return string[] Array of ISO-like country codes.
856 */
857 private function collect_defaults_country( $options ) {
858 // returns array of country codes from default:US_FR (or similar)
859 $def = array_values( preg_grep( '/^default:/', (array) $options ) );
860 if ( isset( $def[0] ) && preg_match( '/^default:([0-9a-zA-Z_:\s-]+)$/', $def[0], $m ) ) {
861 return explode( '_', $m[1] );
862 }
863
864 return array();
865 }
866
867 /**
868 * Collect legacy classes from tokens like "class:my_class".
869 *
870 * @param array<int,string> $options Legacy option tokens.
871 * @return string Space-prefixed class list string (may be empty).
872 */
873 private function collect_classes( $options ) {
874 $acc = '';
875 foreach ( preg_grep( '%^class:[-0-9a-zA-Z_]+$%', (array) $options ) as $class ) {
876 if ( preg_match( '%^class:([-0-9a-zA-Z_]+)$%', $class, $m ) ) {
877 $acc .= ' ' . $m[1];
878 }
879 }
880
881 return $acc;
882 }
883
884 /**
885 * Find and remove the first option that matches a regex and return its first capturing group.
886 *
887 * @param string $pattern PCRE pattern with exactly one capturing group.
888 * @param array<int,string> $options Options array, passed by reference and mutated.
889 * @return string Captured value or empty string if not found.
890 */
891 private function shift_match( $pattern, &$options ) {
892 foreach ( (array) $options as $i => $opt ) {
893 if ( preg_match( $pattern, $opt, $m ) ) {
894 unset( $options[ $i ] );
895
896 return $m[1];
897 }
898 }
899
900 return '';
901 }
902
903 /**
904 * Find and remove the first option that matches a regex; return the raw matched token.
905 *
906 * @param string $pattern PCRE pattern.
907 * @param array<int,string> $options Options array, passed by reference and mutated.
908 * @return string Matched token or empty string if not found.
909 */
910 private function shift_match_raw( $pattern, &$options ) {
911 foreach ( (array) $options as $i => $opt ) {
912 if ( preg_match( $pattern, $opt ) ) {
913 unset( $options[ $i ] );
914
915 return $opt;
916 }
917 }
918
919 return '';
920 }
921
922 /**
923 * When editing an existing booking, convert previously saved values into current defaults.
924 *
925 * @param string $type Shortcode type.
926 * @param string $name_with_suffix Field name with booking-type suffix.
927 * @param array<int,string> $values Current values array (may be modified).
928 * @param array<int,string> $options Current legacy options (may be modified).
929 * @return array{0:array<int,string>,1:array<int,string>} Updated [$values, $options].
930 */
931 private function apply_edit_defaults( $type, $name_with_suffix, $values, $options ) {
932 $my_edited_bk_id = false;
933 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
934 if ( isset( $_GET['booking_hash'] ) ) {
935 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
936 $get_booking_hash = ( isset( $_GET['booking_hash'] ) ? sanitize_text_field( wp_unslash( $_GET['booking_hash'] ) ) : '' );
937
938 $pair = function_exists( 'wpbc_hash__get_booking_id__resource_id' ) ? wpbc_hash__get_booking_id__resource_id( $get_booking_hash ) : false;
939
940 if ( $pair !== false ) {
941 $my_edited_bk_id = $pair[0];
942 }
943 }
944
945 if ( $my_edited_bk_id === false ) {
946 return array( $values, $options );
947 }
948
949 // Select/radio/checkbox/country: convert previous saved selection into default:
950 if ( preg_match( '/^(?:select|selectbox|country|checkbox|radio)[*]?$/', $type ) ) {
951 if ( isset( $this->current_edit_booking['parsed_form'][ $name_with_suffix ]['value'] ) ) {
952 $options = (array) $options;
953 // Remove any existing default:
954 foreach ( $options as $k => $v ) {
955 if ( strpos( $v, 'default:' ) === 0 ) {
956 unset( $options[ $k ] );
957 }
958 }
959 // Preserve spaces and just trim surrounding whitespace on split parts.
960 $vals = array_map( 'trim', explode( ',', $this->current_edit_booking['parsed_form'][ $name_with_suffix ]['value'] ) );
961
962 foreach ( $vals as $v ) {
963 $options[] = 'default:' . $v;
964 }
965 }
966 } else {
967 $values[0] = '';
968 $map = array( 'starttime', 'starttime*', 'endtime', 'endtime*' );
969 if ( in_array( $type, $map, true ) ) {
970 $key = $type . $this->current_resource_id;
971 if ( isset( $this->current_edit_booking['parsed_form'][ $key ]['value'] ) ) {
972 $values[0] = $this->current_edit_booking['parsed_form'][ $key ]['value'];
973 }
974 } elseif ( $type === 'country' || $type === 'country*' ) {
975 $key = $type . $this->current_resource_id;
976 if ( isset( $this->current_edit_booking['parsed_form'][ $key ]['value'] ) ) {
977 $options[0] = $this->current_edit_booking['parsed_form'][ $key ]['value'];
978 }
979 } else {
980 if ( isset( $this->current_edit_booking['parsed_form'][ $name_with_suffix ]['value'] ) ) {
981 $values[0] = $this->current_edit_booking['parsed_form'][ $name_with_suffix ]['value'];
982 }
983 }
984 }
985
986 return array( $values, $options );
987 }
988
989 /* =============================== Parser =============================== */
990
991 /**
992 * Parse a matched shortcode token into a structured array (type, name, options, values, etc.).
993 *
994 * @param array<int,mixed> $element Regex match array for a shortcode.
995 * @return array{
996 * type:string,
997 * name:string,
998 * options:array<int,string>,
999 * values:array<int,string>,
1000 * raw_values:array<int,string>,
1001 * placeholder_text:?string,
1002 * named:array<string,string>
1003 * }
1004 */
1005 public function form_element_parse( $element ) {
1006 $type = trim( $element[1] );
1007 $name = trim( $element[2] );
1008
1009
1010 $opt_str = isset( $element[3] ) ? trim( $element[3] ) : '';
1011
1012 // 1) Build $options from the combined “rest” group, but ignore quoted substrings.
1013 $options = array();
1014 if ( $opt_str !== '' ) {
1015 $opts_no_quotes = preg_replace( '/"[^"]*"|\'[^\']*\'/u', ' ', $opt_str );
1016 $options = preg_split( '/\s+/u', trim( $opts_no_quotes ) );
1017 $options = array_values( array_filter( $options, 'strlen' ) );
1018 }
1019
1020 // 2) Quoted chunks: scan the FULL shortcode text (so order doesn’t matter).
1021 preg_match_all( '/"[^"]*"|\'[^\']*\'/u', $element[0], $m );
1022 $raw_values = $this->strip_quote_deep( $m[0] );
1023
1024 // 3) As you already do:
1025 list( $named_raw, $consumed ) = $this->parse_named_attributes_from_shortcode( $element[0] );
1026
1027 // Remove named-attr quoted values from $raw_values (unchanged logic).
1028 if ( is_array( $raw_values ) && is_array( $consumed ) ) {
1029 foreach ( $consumed as $q ) {
1030 $idx = array_search( $q, $raw_values, true );
1031 if ( $idx !== false ) {
1032 unset( $raw_values[ $idx ] );
1033 }
1034 }
1035 $raw_values = array_values( $raw_values );
1036 }
1037
1038 /* -------------------------------------------------------------------------------------------------
1039 * FixIn: 11.0.3 — Unify 'default' handling:
1040 * Treat named attribute default="VALUE" the same as legacy default:VALUE,
1041 * and preserve spaces (do NOT replace with underscores). This ensures both
1042 * syntaxes select the same option on first render.
1043 * ------------------------------------------------------------------------------------------------- */
1044 if ( isset( $named_raw['default'] ) && $named_raw['default'] !== '' ) {
1045 $options[] = 'default:' . $named_raw['default'];
1046 // De-duplicate if the author happened to include both forms.
1047 $options = array_values( array_unique( (array) $options ) );
1048 }
1049
1050 // Placeholder precedence: named placeholder=... first, then legacy placeholder:"..."/placeholder:token.
1051 $placeholder_text = null;
1052 if ( isset( $named_raw['placeholder'] ) && $named_raw['placeholder'] !== '' ) {
1053 $placeholder_text = $named_raw['placeholder'];
1054 } else {
1055 if ( preg_match( '/\bplaceholder\s*:\s*(?:"([^"]*)"|\'([^\']*)\')/u', $element[0], $pm ) ) {
1056 $placeholder_text = ( $pm[1] !== '' ) ? $pm[1] : ( isset( $pm[2] ) ? $pm[2] : null );
1057 if ( $placeholder_text !== null && is_array( $raw_values ) ) {
1058 $idx = array_search( $placeholder_text, $raw_values, true );
1059 if ( $idx !== false ) {
1060 unset( $raw_values[ $idx ] );
1061 $raw_values = array_values( $raw_values );
1062 }
1063 }
1064 }
1065 }
1066
1067 // For choice-like fields, keep your existing pipe logic
1068 if ( preg_match( '/^(select[*]?|selectbox[*]?|checkbox[*]?|radio[*]?)$/', $type ) || 'quiz' === $type ) {
1069 $pipes = $this->get_pipes( $raw_values );
1070 $values = $this->get_pipe_ins( $pipes );
1071 } else {
1072 $values =& $raw_values;
1073 }
1074
1075 // Sanitize named attribute keys (allow aria-*, data-*, block style)
1076 $named = array();
1077 foreach ( (array) $named_raw as $k => $v ) {
1078 $kk = $this->sanitize_attr_name( $k, $type, $name );
1079 if ( $kk !== '' ) {
1080 $named[ $kk ] = $v;
1081 }
1082 }
1083
1084 return compact( 'type', 'name', 'options', 'values', 'raw_values', 'placeholder_text', 'named' );
1085 }
1086
1087 /* ======================= Utility / existing helpers ======================= */
1088
1089 /**
1090 * Remove surrounding single/double quotes from a string.
1091 *
1092 * @param string $text Possibly quoted text.
1093 * @return string Unquoted text.
1094 */
1095 public function strip_quote( $text ) {
1096 $text = trim( $text );
1097 if ( preg_match( '/^"(.*)"$/s', $text, $m ) ) {
1098 $text = $m[1];
1099 } elseif ( preg_match( "/^'(.*)'$/s", $text, $m ) ) {
1100 $text = $m[1];
1101 }
1102
1103 return $text;
1104 }
1105
1106 /**
1107 * Apply strip_quote() to a string or every element of an array.
1108 *
1109 * @param string|array<int,string> $arr String or array of strings.
1110 * @return string|array<int,string> Unquoted string or array of unquoted strings.
1111 */
1112 public function strip_quote_deep( $arr ) {
1113 if ( is_string( $arr ) ) {
1114 return $this->strip_quote( $arr );
1115 }
1116 if ( is_array( $arr ) ) {
1117 $out = array();
1118 foreach ( $arr as $k => $text ) {
1119 $out[ $k ] = $this->strip_quote( $text );
1120 }
1121
1122 return $out;
1123 }
1124
1125 return array();
1126 }
1127
1128 /**
1129 * Map a value (or array of values) through "pipes" pairs [in, out]; returns mapped value(s).
1130 *
1131 * @param array<int,array{0:string,1:string}> $pipes Array of [label,binding] pairs.
1132 * @param string|array<int,string> $value Value(s) to map.
1133 * @return string|array<int,string> Mapped value(s).
1134 */
1135 public function pipe( $pipes, $value ) {
1136 if ( is_array( $value ) ) {
1137 $res = array();
1138 foreach ( $value as $k => $v ) {
1139 $res[ $k ] = $this->pipe( $pipes, $v );
1140 }
1141
1142 return $res;
1143 }
1144 foreach ( $pipes as $p ) {
1145 if ( $p[0] == $value ) {
1146 return $p[1];
1147 }
1148 }
1149
1150 return $value;
1151 }
1152
1153 /**
1154 * Return unique "in" values from pipes.
1155 *
1156 * @param array<int,array{0:string,1:string}> $pipes Pipes array.
1157 * @return string[] Unique list of "in" values.
1158 */
1159 public function get_pipe_ins( $pipes ) {
1160 $ins = array();
1161 foreach ( $pipes as $pipe ) {
1162 $in = $pipe[0];
1163 if ( ! in_array( $in, $ins, true ) ) {
1164 $ins[] = $in;
1165 }
1166 }
1167
1168 return $ins;
1169 }
1170
1171 /**
1172 * Convert raw values like "Label|Value" into [Label, Value] pipe pairs.
1173 *
1174 * @param array<int,string> $values Raw values (may include '|').
1175 * @return array<int,array{0:string,1:string}> Pipe pairs [label,binding].
1176 */
1177 public function get_pipes( $values ) {
1178 $pipes = array();
1179 foreach ( (array) $values as $value ) {
1180 $pos = strpos( $value, '|' );
1181 if ( false === $pos ) {
1182 $before = $after = $value;
1183 } else {
1184 $before = substr( $value, 0, $pos );
1185 $after = substr( $value, $pos + 1 );
1186 }
1187 $pipes[] = array( $before, $after );
1188 }
1189
1190 return $pipes;
1191 }
1192
1193 /* ---- Stubs you likely have elsewhere; keep signatures identical ---- */
1194 /**
1195 * Canonicalize a string for comparisons/hashing (override upstream if needed).
1196 *
1197 * @param string $str Input string.
1198 * @return string Canonical form (default: unchanged).
1199 */
1200 protected function canonicalize( $str ) {
1201 return $str;
1202 }
1203
1204 /**
1205 * Map legacy captcha options into generator options (override upstream if needed).
1206 *
1207 * @param array<int,string> $options Legacy option tokens.
1208 * @return array<string,mixed> Captcha generator options.
1209 */
1210 protected function captchac_options( $options ) {
1211 return array();
1212 }
1213
1214 /**
1215 * Generate a CAPTCHA image file and return its filename (override upstream if needed).
1216 *
1217 * @param array<string,mixed> $op Options for image generation.
1218 * @return string|false Filename (without path) on success, false on failure.
1219 */
1220 protected function generate_captcha( $op ) {
1221 return false;
1222 }
1223
1224 /**
1225 * Get temporary URL for CAPTCHA images (override upstream if needed).
1226 *
1227 * @return string Base URL used to serve generated CAPTCHA files.
1228 */
1229 protected function captcha_tmp_url() {
1230 return '';
1231 }
1232
1233 // =================================================================================================================
1234 // FixIn: 11.0.2. 2025-08-22 11:54
1235 // =================================================================================================================
1236
1237 /* == New helpers for named attributes ====================== */
1238
1239 /**
1240 * Parse named attributes (id, class, placeholder, aria-*, data-*, style, etc.) from a shortcode slice.
1241 * Also returns the set of quoted values that were consumed so they can be excluded from field values.
1242 *
1243 * @param string $shortcode_text Raw shortcode substring including brackets.
1244 * @return array{0:array<string,string>,1:array<int,string>} [named attributes, consumed quoted values].
1245 */
1246 private function parse_named_attributes_from_shortcode( $shortcode_text ) {
1247 $named = array();
1248 $consumed = array();
1249
1250 // key[:=]"double" | 'single' | unquoted
1251 $attr_pattern = '/\b([a-zA-Z][\w:-]*)\s*[:=]\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s\]"]+))/u';
1252
1253 if ( preg_match_all( $attr_pattern, $shortcode_text, $mm, PREG_SET_ORDER ) ) {
1254 foreach ( $mm as $m ) {
1255 $key = strtolower( $m[1] );
1256 $val = '';
1257 if ( isset( $m[2] ) && $m[2] !== '' ) {
1258 $val = $m[2];
1259 $consumed[] = $m[2]; // so we can remove this quoted piece from values
1260 } elseif ( isset( $m[3] ) && $m[3] !== '' ) {
1261 $val = $m[3];
1262 $consumed[] = $m[3];
1263 } elseif ( isset( $m[4] ) ) {
1264 $val = $m[4];
1265 }
1266 $named[ $key ] = $val;
1267 }
1268 }
1269
1270 // Normalize boolean-ish flags: multiple="", multiple="multiple", multiple=1 → multiple: true
1271 foreach ( array( 'multiple' ) as $bool_key ) {
1272 if ( array_key_exists( $bool_key, $named ) && $named[ $bool_key ] === '' ) {
1273 $named[ $bool_key ] = '1';
1274 }
1275 }
1276
1277 return array( $named, $consumed );
1278 }
1279
1280 /**
1281 * Validate/allow attribute names; permits aria-* and data-* and optionally 'style' via a filter.
1282 *
1283 * @param string $name Raw attribute name.
1284 * @param string $type Shortcode type (used for style gating).
1285 * @param string $field_name Original field name (used for style gating).
1286 * @return string Sanitized attribute name, or '' if disallowed.
1287 */
1288 private function sanitize_attr_name( $name, $type = '', $field_name = '' ) {
1289
1290 $name = strtolower( trim( $name ) );
1291
1292 if ( strpos( $name, 'on' ) === 0 ) {
1293 return '';
1294 }
1295
1296 if ( $name === 'style' ) {
1297 // Only allow if opt-in filter returns true for this field/type.
1298 return $this->allow_inline_style( $type, $field_name ) ? 'style' : '';
1299 }
1300
1301 // basic validity, allow aria-* and data-*
1302 if ( preg_match( '/^(?:[a-z][\w:-]*|aria-[\w:-]+|data-[\w:-]+)$/', $name ) ) {
1303 return $name;
1304 }
1305
1306 return '';
1307 }
1308
1309 /**
1310 * Build a string of allowed named attributes for an element, avoiding duplicates and honoring filters.
1311 *
1312 * @param array<string,string> $named Named attributes parsed from shortcode.
1313 * @param string $type Shortcode type.
1314 * @param string $name_without_suffix Field name without booking-type suffix.
1315 * @param string $name_with_suffix Field name with booking-type suffix.
1316 * @param string $existing_atts Current attribute string (to avoid duplicates).
1317 * @param array<int,string> &$options Legacy options (checked for id:... etc).
1318 * @return string Attribute fragment beginning with a leading space (or empty).
1319 */
1320 private function build_named_atts_string( $named, $type, $name_without_suffix, $name_with_suffix, $existing_atts, &$options ) {
1321 $atts = '';
1322
1323 // We'll avoid duplicating attributes already present in $existing_atts.
1324 $has = function ( $needle ) use ( $existing_atts ) {
1325 return ( strpos( $existing_atts, ' ' . $needle . '="' ) !== false );
1326 };
1327
1328 // id: prefer legacy id:... already handled elsewhere; otherwise allow id="..."
1329 if ( ! $has( 'id' ) && ! $this->shift_match( '%^id:([-0-9a-zA-Z_]+)$%', $options ) && isset( $named['id'] ) && $named['id'] !== '' ) {
1330 $atts .= ' id="' . esc_attr( $named['id'] . $this->current_resource_id ) . '"';
1331 }
1332
1333 // placeholder: prefer quoted named > legacy placeholder:token
1334 if ( ! $has( 'placeholder' ) && isset( $named['placeholder'] ) && $named['placeholder'] !== '' ) {
1335 $atts .= ' placeholder="' . esc_attr( $named['placeholder'] ) . '"';
1336 }
1337
1338 // class: merge legacy (collect_classes) with named class=""
1339 if ( isset( $named['class'] ) && $named['class'] !== '' ) {
1340 // We'll merge later in build_common_atts(); here we just mark presence to suppress duplicates.
1341 if ( ! $has( 'class' ) ) {
1342 $atts .= ' class="' . esc_attr( trim( $named['class'] ) ) . '"';
1343 }
1344 }
1345
1346 // Generic allowlist for simple inputs; these are safe + common.
1347 $generic_allow = array( 'maxlength', 'minlength', 'size', 'pattern', 'title', 'inputmode', 'list', 'step', 'min', 'max', 'dir', 'lang', 'tabindex' );
1348
1349 // Allow style only if opted-in.
1350 if ( $this->allow_inline_style( $type, $name_without_suffix ) ) {
1351 $generic_allow[] = 'style';
1352 }
1353
1354 foreach ( $named as $k => $v ) {
1355 $k = $this->sanitize_attr_name( $k, $type, $name_without_suffix );
1356 if ( '' === $k ) {
1357 continue;
1358 }
1359 if ( in_array( $k, array( 'id', 'class', 'placeholder' ), true ) ) {
1360 continue;
1361 }
1362
1363 $allow = ( in_array( $k, $generic_allow, true ) || str_starts_with( $k, 'aria-' ) || str_starts_with( $k, 'data-' ) );
1364 if ( ! $allow || '' === $v || $has( $k ) ) {
1365 continue;
1366 }
1367
1368 if ( 'style' === $k ) {
1369 $v = $this->sanitize_style_attr( $v );
1370 if ( '' === $v ) {
1371 continue; // dropped if unsafe/empty.
1372 }
1373 }
1374
1375 $atts .= ' ' . $k . '="' . esc_attr( $v ) . '"';
1376 }
1377
1378
1379 return $atts;
1380 }
1381
1382 /**
1383 * Append an HTML attribute only once to an attributes string.
1384 *
1385 * @param string &$atts Current attributes string (modified in place).
1386 * @param string $name Attribute name.
1387 * @param mixed $value Attribute value (will be escaped).
1388 * @return void
1389 */
1390 private function append_att_once( &$atts, $name, $value ) {
1391 if ( strpos( $atts, ' ' . $name . '="' ) === false ) {
1392 $atts .= ' ' . $name . '="' . esc_attr( $value ) . '"';
1393 }
1394 }
1395
1396 /**
1397 * Whether inline style="" attributes are allowed for a given field/type (via filter).
1398 *
1399 * @param string $type Shortcode type.
1400 * @param string $name Field name without suffix.
1401 * @return bool True if style is allowed.
1402 */
1403 private function allow_inline_style( $type = '', $name = '' ) {
1404 // Developers can enable per site / per field via filter.
1405 // Example: add_filter('wpbc_form_allow_inline_style_attr', fn($allow,$type,$name)=> true);
1406 return (bool) apply_filters( 'wpbc_form_allow_inline_style_attr', false, $type, $name, $this );
1407 }
1408
1409 /**
1410 * Sanitize a CSS style attribute value using WordPress KSES (or a conservative fallback).
1411 *
1412 * @param string $style Raw style attribute.
1413 * @return string Sanitized style (empty string if nothing allowed remains).
1414 */
1415 private function sanitize_style_attr( $style ) {
1416 $style = trim( (string) $style );
1417 if ( $style === '' ) {
1418 return '';
1419 }
1420
1421 // Prefer WP core’s CSS sanitizer if available.
1422 if ( function_exists( 'safecss_filter_attr' ) ) {
1423 $style = safecss_filter_attr( $style );
1424 } else {
1425 // Very conservative fallback: drop dangerous patterns.
1426 // Disallow expression() and url(...) which can hide JS.
1427 $style = preg_replace( '/expression\s*\([^)]*\)/i', '', $style );
1428 $style = preg_replace( '/url\s*\([^)]*\)/i', '', $style );
1429 // Normalize spacing around semicolons.
1430 $style = preg_replace( '/\s*;\s*/', '; ', $style );
1431 }
1432
1433 return trim( $style );
1434 }
1435
1436 }
1437
1438
1439 /**
1440 * Allow inline style="" for selected shortcode types via the 'wpbc_form_allow_inline_style_attr' filter.
1441 *
1442 * @param bool $allow Current decision (default false).
1443 * @param string $type Shortcode type being rendered (e.g., 'text', 'select*', 'submit').
1444 * @param string $name Field name (without booking-type suffix).
1445 * @return bool True to allow style for this field/type; otherwise previous $allow.
1446 */
1447 function wpbc_form_allow_inline_style_attr_filter_free( $allow, $type, $name ) {
1448
1449 $allow_shortcode_type_arr = array(
1450 'text', 'email', 'coupon', 'time', 'textarea', 'select', 'selectbox', 'checkbox', 'radio', 'submit',
1451 'text*', 'email*', 'coupon*', 'time*', 'textarea*', 'select*', 'selectbox*', 'checkbox*', 'radio'
1452 );
1453 // 'text[*]?|email[*]?|coupon[*]?|time[*]?|textarea[*]?|select[*]?|selectbox[*]?|checkbox[*]?|radio[*]?|submit'; //.
1454
1455 if ( in_array( $type, $allow_shortcode_type_arr, true ) ) {
1456 return true;
1457 }
1458
1459 return $allow;
1460 }
1461 add_filter( 'wpbc_form_allow_inline_style_attr', 'wpbc_form_allow_inline_style_attr_filter_free', 10, 3 );
1462