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-setup-wizard / step-booking-form-template / class-wpbc-setup-wizard-booking-form-time-options.php

class-wpbc-setup-wizard-booking-form-time-options.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-booking-form-template/class-wpbc-setup-wizard-booking-form-time-options.php

452 lines 18.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Booking Form time-option projection for the Setup Wizard.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Apply the saved Setup Wizard time proposal to one Form Builder definition.
14 *
15 * The route-specific time steps deliberately store presentation-neutral
16 * `HH:MM` values. This service is the single integration boundary that projects
17 * those values into both representations owned by Form Builder: the structured
18 * field tree used by the editor and the advanced shortcode source used by the
19 * public renderer. Keeping the transformation here makes preview and save use
20 * the same result without coupling the reusable time editor to Form Builder.
21 */
22 final class WPBC_Setup_Wizard_Booking_Form_Time_Options {
23
24 /** @var WPBC_Setup_Wizard_Start_End_Times */
25 private $start_end_times;
26
27 /** @var WPBC_Setup_Wizard_Start_Duration_Times */
28 private $start_duration_times;
29
30 /** @var WPBC_Setup_Wizard_Fixed_Time_Slots */
31 private $fixed_time_slots;
32
33 /**
34 * Build the projector around the route-specific time validators.
35 *
36 * @param WPBC_Setup_Wizard_Start_End_Times|null $start_end_times Optional Start/End validator.
37 * @param WPBC_Setup_Wizard_Start_Duration_Times|null $start_duration_times Optional Start/Duration validator.
38 * @param WPBC_Setup_Wizard_Fixed_Time_Slots|null $fixed_time_slots Optional Fixed Time Slots validator.
39 */
40 public function __construct( $start_end_times = null, $start_duration_times = null, $fixed_time_slots = null ) {
41 $this->start_end_times = $start_end_times instanceof WPBC_Setup_Wizard_Start_End_Times
42 ? $start_end_times
43 : new WPBC_Setup_Wizard_Start_End_Times();
44 $this->start_duration_times = $start_duration_times instanceof WPBC_Setup_Wizard_Start_Duration_Times
45 ? $start_duration_times
46 : new WPBC_Setup_Wizard_Start_Duration_Times( $this->start_end_times );
47 $this->fixed_time_slots = $fixed_time_slots instanceof WPBC_Setup_Wizard_Fixed_Time_Slots
48 ? $fixed_time_slots
49 : new WPBC_Setup_Wizard_Fixed_Time_Slots( $this->start_end_times );
50 }
51
52 /**
53 * Build a server-owned projection context from normalized wizard values.
54 *
55 * @param array<string,mixed> $wizard_values Checkpoint values keyed by step ID.
56 *
57 * @return array{customer_journey:string,start_end_times:mixed,start_duration_times:mixed,fixed_time_slots:mixed,time_format:string} Projection context.
58 */
59 public function get_context_from_values( array $wizard_values ) {
60 $customer_journey = isset( $wizard_values['customer_journey']['customer_journey'] )
61 ? sanitize_key( (string) $wizard_values['customer_journey']['customer_journey'] )
62 : '';
63 $start_end_times = isset( $wizard_values['start_end_times']['start_end_times'] )
64 ? $wizard_values['start_end_times']['start_end_times']
65 : array();
66 $start_duration_times = isset( $wizard_values['start_duration_times']['start_duration_times'] )
67 ? $wizard_values['start_duration_times']['start_duration_times']
68 : array();
69 $fixed_time_slots = isset( $wizard_values['fixed_time_slots']['fixed_time_slots'] )
70 ? $wizard_values['fixed_time_slots']['fixed_time_slots']
71 : array();
72 $time_format = isset( $wizard_values['date_time_formats']['time_format'] ) && is_scalar( $wizard_values['date_time_formats']['time_format'] )
73 ? (string) $wizard_values['date_time_formats']['time_format']
74 : '';
75
76 if ( ! in_array( $time_format, array( 'g:i a', 'g:i A', 'H:i' ), true ) ) {
77 $time_format = function_exists( 'get_bk_option' ) ? (string) get_bk_option( 'booking_time_format' ) : 'H:i';
78 }
79 if ( ! in_array( $time_format, array( 'g:i a', 'g:i A', 'H:i' ), true ) ) {
80 $time_format = 'H:i';
81 }
82
83 return array(
84 'customer_journey' => $customer_journey,
85 'start_end_times' => $start_end_times,
86 'start_duration_times' => $start_duration_times,
87 'fixed_time_slots' => $fixed_time_slots,
88 'time_format' => $time_format,
89 );
90 }
91
92 /**
93 * Project saved choices into a read-only preview payload.
94 *
95 * Templates without fields owned by the active journey are returned unchanged.
96 * Non-time journeys are also unchanged, which prevents unrelated customer
97 * journeys from inheriting stale time proposals from an earlier route.
98 *
99 * @param array<string,mixed> $preview_payload Server-owned preview payload.
100 * @param array<string,mixed> $projection_context Server-owned wizard context.
101 *
102 * @return array<string,mixed>|WP_Error Transformed payload or validation error.
103 */
104 public function apply_to_preview_payload( array $preview_payload, array $projection_context ) {
105 if ( ! $this->should_project( $projection_context ) ) {
106 return $preview_payload;
107 }
108
109 $structure = isset( $preview_payload['structure'] ) && is_array( $preview_payload['structure'] )
110 ? $preview_payload['structure']
111 : array();
112 $advanced_form = isset( $preview_payload['advanced_form'] ) && is_scalar( $preview_payload['advanced_form'] )
113 ? (string) $preview_payload['advanced_form']
114 : '';
115 $transformed = $this->transform_definition( $structure, $advanced_form, $projection_context );
116 if ( is_wp_error( $transformed ) ) {
117 return $transformed;
118 }
119
120 $preview_payload['structure'] = $transformed['structure'];
121 $preview_payload['advanced_form'] = $transformed['advanced_form'];
122
123 return $preview_payload;
124 }
125
126 /**
127 * Project saved choices into a canonical Form Builder save configuration.
128 *
129 * Both Form Builder representations are changed in memory before any write.
130 * A malformed structure or failed encoding therefore aborts the operation
131 * without partially changing the Standard form.
132 *
133 * @param array<string,mixed> $form_config Server-owned FormConfig.
134 * @param array<string,mixed> $projection_context Server-owned wizard context.
135 *
136 * @return array<string,mixed>|WP_Error Transformed FormConfig or validation error.
137 */
138 public function apply_to_form_config( array $form_config, array $projection_context ) {
139 if ( ! $this->should_project( $projection_context ) ) {
140 return $form_config;
141 }
142
143 $structure_json = isset( $form_config['structure_json'] ) && is_scalar( $form_config['structure_json'] )
144 ? (string) $form_config['structure_json']
145 : '';
146 $structure = json_decode( $structure_json, true );
147 if ( ! is_array( $structure ) || empty( $structure ) ) {
148 return new WP_Error(
149 'wpbc_setup_wizard_booking_form_time_structure_invalid',
150 __( 'The selected booking form cannot receive the configured times because its structure is invalid.', 'booking' )
151 );
152 }
153
154 $advanced_form = isset( $form_config['advanced_form'] ) && is_scalar( $form_config['advanced_form'] )
155 ? (string) $form_config['advanced_form']
156 : '';
157 $transformed = $this->transform_definition( $structure, $advanced_form, $projection_context );
158 if ( is_wp_error( $transformed ) ) {
159 return $transformed;
160 }
161
162 $encoded_structure = wp_json_encode( $transformed['structure'] );
163 if ( false === $encoded_structure || '' === $encoded_structure ) {
164 return new WP_Error(
165 'wpbc_setup_wizard_booking_form_time_structure_encode_failed',
166 __( 'The configured booking times could not be prepared for the Booking Form.', 'booking' )
167 );
168 }
169
170 $form_config['structure_json'] = $encoded_structure;
171 $form_config['advanced_form'] = $transformed['advanced_form'];
172
173 return $form_config;
174 }
175
176 /**
177 * Determine whether the active journey owns an explicit time proposal.
178 *
179 * @param array<string,mixed> $projection_context Server-owned wizard context.
180 *
181 * @return bool True when time choices must be projected.
182 */
183 private function should_project( array $projection_context ) {
184 $customer_journey = isset( $projection_context['customer_journey'] )
185 ? sanitize_key( (string) $projection_context['customer_journey'] )
186 : '';
187
188 return WPBC_Setup_Wizard_Customer_Journey_Policy::uses_time_configuration( $customer_journey );
189 }
190
191 /**
192 * Transform structured fields and their matching advanced shortcodes.
193 *
194 * @param array<int|string,mixed> $structure Form Builder structure.
195 * @param string $advanced_form Advanced shortcode source.
196 * @param array<string,mixed> $projection_context Server-owned wizard context.
197 *
198 * @return array{structure:array<int|string,mixed>,advanced_form:string}|WP_Error Transformed definition.
199 */
200 private function transform_definition( array $structure, $advanced_form, array $projection_context ) {
201 $customer_journey = isset( $projection_context['customer_journey'] ) ? sanitize_key( (string) $projection_context['customer_journey'] ) : '';
202 $time_format = isset( $projection_context['time_format'] ) && in_array( $projection_context['time_format'], array( 'g:i a', 'g:i A', 'H:i' ), true )
203 ? (string) $projection_context['time_format']
204 : 'H:i';
205
206 if ( WPBC_Setup_Wizard_Customer_Journey_Policy::uses_fixed_time_slots_configuration( $customer_journey ) ) {
207 $validated_times = $this->fixed_time_slots->validate_fixed_time_slots(
208 isset( $projection_context['fixed_time_slots'] ) ? $projection_context['fixed_time_slots'] : array(),
209 true
210 );
211 if ( is_wp_error( $validated_times ) ) {
212 return $validated_times;
213 }
214
215 $options_by_type = array(
216 'rangetime' => $this->build_range_field_options( $validated_times['time_slots'], $time_format ),
217 );
218 } elseif ( WPBC_Setup_Wizard_Customer_Journey_Policy::uses_start_duration_configuration( $customer_journey ) ) {
219 $validated_times = $this->start_duration_times->validate_start_duration_times(
220 isset( $projection_context['start_duration_times'] ) ? $projection_context['start_duration_times'] : array(),
221 true
222 );
223 if ( is_wp_error( $validated_times ) ) {
224 return $validated_times;
225 }
226
227 $options_by_type = array(
228 'starttime' => $this->build_field_options( $validated_times['start_times'], $time_format ),
229 'durationtime' => $this->build_duration_field_options( $validated_times['duration_times'] ),
230 );
231 } else {
232 $validated_times = $this->start_end_times->validate_start_end_times(
233 isset( $projection_context['start_end_times'] ) ? $projection_context['start_end_times'] : array(),
234 true
235 );
236 if ( is_wp_error( $validated_times ) ) {
237 return $validated_times;
238 }
239
240 $options_by_type = array(
241 'starttime' => $this->build_field_options( $validated_times['start_times'], $time_format ),
242 'endtime' => $this->build_field_options( $validated_times['end_times'], $time_format ),
243 );
244 }
245
246 $field_names = array();
247 $field_counts = array();
248 foreach ( array_keys( $options_by_type ) as $field_type ) {
249 $field_names[ $field_type ] = array( $field_type );
250 $field_counts[ $field_type ] = 0;
251 }
252
253 $this->replace_structure_time_options( $structure, $options_by_type, $field_names, $field_counts );
254
255 $advanced_result = $this->replace_advanced_time_options(
256 $advanced_form,
257 $options_by_type,
258 $field_names,
259 $field_counts
260 );
261 if ( is_wp_error( $advanced_result ) ) {
262 return $advanced_result;
263 }
264
265 return array(
266 'structure' => $structure,
267 'advanced_form' => $advanced_result,
268 );
269 }
270
271 /**
272 * Build Form Builder option DTOs in the saved user order.
273 *
274 * @param string[] $time_values Validated `HH:MM` values.
275 * @param string $time_format Validated display format.
276 *
277 * @return array<int,array{label:string,value:string,selected:bool}> Field options.
278 */
279 private function build_field_options( array $time_values, $time_format ) {
280 $field_options = array();
281 $timezone = new DateTimeZone( 'UTC' );
282
283 foreach ( $time_values as $time_value ) {
284 list( $hour, $minute ) = array_map( 'intval', explode( ':', $time_value ) );
285 $field_options[] = array(
286 'label' => wp_date( $time_format, ( ( $hour * 60 ) + $minute ) * MINUTE_IN_SECONDS, $timezone ),
287 'value' => $time_value,
288 'selected' => false,
289 );
290 }
291
292 return $field_options;
293 }
294
295 /**
296 * Build Form Builder duration option DTOs in the saved user order.
297 *
298 * @param string[] $duration_values Validated `HH:MM` duration values.
299 *
300 * @return array<int,array{label:string,value:string,selected:bool}> Field options.
301 */
302 private function build_duration_field_options( array $duration_values ) {
303 $field_options = array();
304 foreach ( $duration_values as $duration_value ) {
305 list( $hour, $minute ) = array_map( 'intval', explode( ':', $duration_value ) );
306 $field_options[] = array(
307 'label' => $this->start_duration_times->format_duration_label( ( $hour * 60 ) + $minute ),
308 'value' => $duration_value,
309 'selected' => false,
310 );
311 }
312
313 return $field_options;
314 }
315
316 /**
317 * Build Form Builder range-time options in the saved slot order.
318 *
319 * @param array<int,array{start_time:string,end_time:string}> $time_slots Validated fixed-slot ranges.
320 * @param string $time_format Validated display format.
321 *
322 * @return array<int,array{label:string,value:string,selected:bool}> Field options.
323 */
324 private function build_range_field_options( array $time_slots, $time_format ) {
325 $field_options = array();
326
327 foreach ( $time_slots as $time_slot ) {
328 $start_time = (string) $time_slot['start_time'];
329 $end_time = (string) $time_slot['end_time'];
330 $field_options[] = array(
331 'label' => $this->fixed_time_slots->format_time_label( $start_time, $time_format ) . ' - ' . $this->fixed_time_slots->format_time_label( $end_time, $time_format ),
332 'value' => $start_time . ' - ' . $end_time,
333 'selected' => false,
334 );
335 }
336
337 return $field_options;
338 }
339
340 /**
341 * Recursively replace only Form Builder fields owned by the active journey.
342 *
343 * @param array<int|string,mixed> $nodes Structure subtree, modified in place.
344 * @param array<string,array<int,array{label:string,value:string,selected:bool}>> $options_by_type Options keyed by field type.
345 * @param array<string,string[]> $field_names Discovered field names, modified in place.
346 * @param array<string,int> $field_counts Discovered field counts, modified in place.
347 *
348 * @return void
349 */
350 private function replace_structure_time_options( array &$nodes, array $options_by_type, array &$field_names, array &$field_counts ) {
351 if ( isset( $nodes['type'], $nodes['data'] ) && 'field' === $nodes['type'] && is_array( $nodes['data'] ) ) {
352 $field_type = isset( $nodes['data']['type'] ) ? sanitize_key( (string) $nodes['data']['type'] ) : '';
353 if ( ! isset( $options_by_type[ $field_type ] ) && isset( $nodes['data']['usage_key'] ) ) {
354 $field_type = sanitize_key( (string) $nodes['data']['usage_key'] );
355 }
356
357 if ( isset( $options_by_type[ $field_type ] ) ) {
358 $nodes['data']['options'] = $options_by_type[ $field_type ];
359 if ( array_key_exists( 'default_value', $nodes['data'] ) ) {
360 $nodes['data']['default_value'] = '';
361 }
362 if ( array_key_exists( 'defaultValue', $nodes['data'] ) ) {
363 $nodes['data']['defaultValue'] = '';
364 }
365 ++$field_counts[ $field_type ];
366 if ( isset( $nodes['data']['name'] ) && is_scalar( $nodes['data']['name'] ) ) {
367 $field_name = sanitize_key( (string) $nodes['data']['name'] );
368 if ( '' !== $field_name ) {
369 $field_names[ $field_type ][] = $field_name;
370 }
371 }
372 }
373 }
374
375 foreach ( $nodes as &$node ) {
376 if ( is_array( $node ) ) {
377 $this->replace_structure_time_options( $node, $options_by_type, $field_names, $field_counts );
378 }
379 }
380 unset( $node );
381 }
382
383 /**
384 * Replace matching selectbox shortcodes while preserving field modifiers.
385 *
386 * @param string $advanced_form Advanced shortcode source.
387 * @param array<string,array<int,array{label:string,value:string,selected:bool}>> $options_by_type Options keyed by field type.
388 * @param array<string,string[]> $field_names Canonical and discovered field names.
389 * @param array<string,int> $field_counts Structured fields found by type.
390 *
391 * @return string|WP_Error Transformed source or a synchronization error.
392 */
393 private function replace_advanced_time_options( $advanced_form, array $options_by_type, array $field_names, array $field_counts ) {
394 $advanced_form = (string) $advanced_form;
395
396 foreach ( $options_by_type as $field_type => $field_options ) {
397 $names = array_values( array_unique( array_filter( $field_names[ $field_type ] ) ) );
398 $escaped_names = array_map( 'preg_quote', $names, array_fill( 0, count( $names ), '/' ) );
399 $pattern = '/\[(selectbox\*?)\s+(' . implode( '|', $escaped_names ) . ')(?=\s|\])([^\]]*)\]/i';
400 $replacement_count = 0;
401 $advanced_form = preg_replace_callback(
402 $pattern,
403 function ( $matches ) use ( $field_options ) {
404 $tokens = array();
405 if ( preg_match_all( '/"((?:\\\\.|[^"\\\\])*)"|\'((?:\\\\.|[^\'\\\\])*)\'/', $matches[3], $quoted_tokens, PREG_SET_ORDER ) ) {
406 foreach ( $quoted_tokens as $quoted_token ) {
407 $double_quoted_value = isset( $quoted_token[1] ) ? (string) $quoted_token[1] : '';
408 $single_quoted_value = isset( $quoted_token[2] ) ? (string) $quoted_token[2] : '';
409 $token_value = '' !== $double_quoted_value ? $double_quoted_value : $single_quoted_value;
410 if ( 2 <= strlen( $token_value ) && '@@' === substr( $token_value, -2 ) ) {
411 $tokens[] = $quoted_token[0];
412 break;
413 }
414 }
415 }
416
417 $modifiers = preg_replace( '/\bdefault(?::|=)(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\s\]]+)/i', '', $matches[3] );
418 $modifiers = preg_replace( '/(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')/', '', (string) $modifiers );
419 $modifiers = trim( preg_replace( '/\s+/', ' ', (string) $modifiers ) );
420
421 foreach ( $field_options as $field_option ) {
422 $option_token = (string) $field_option['label'] . '@@' . (string) $field_option['value'];
423 $tokens[] = '"' . str_replace( array( '\\', '"' ), array( '\\\\', '\\"' ), $option_token ) . '"';
424 }
425
426 return '[' . $matches[1] . ' ' . $matches[2]
427 . ( '' !== $modifiers ? ' ' . $modifiers : '' )
428 . ' ' . implode( ' ', $tokens ) . ']';
429 },
430 $advanced_form,
431 -1,
432 $replacement_count
433 );
434
435 if ( null === $advanced_form ) {
436 return new WP_Error(
437 'wpbc_setup_wizard_booking_form_time_shortcode_invalid',
438 __( 'The configured booking times could not be applied to the Booking Form source.', 'booking' )
439 );
440 }
441 if ( $field_counts[ $field_type ] > 0 && 0 === $replacement_count && '' !== trim( $advanced_form ) ) {
442 return new WP_Error(
443 'wpbc_setup_wizard_booking_form_time_shortcode_missing',
444 __( 'The selected Booking Form contains a time field that could not be synchronized safely.', 'booking' )
445 );
446 }
447 }
448
449 return $advanced_form;
450 }
451 }
452