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-date-selection / class-wpbc-setup-wizard-date-selection.php

class-wpbc-setup-wizard-date-selection.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-date-selection/class-wpbc-setup-wizard-date-selection.php

997 lines 42.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Date-selection policy data and validation for the isolated Setup Wizard.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Adapt canonical Booking Calendar day-selection options to a safe draft DTO.
14 */
15 final class WPBC_Setup_Wizard_Date_Selection {
16
17 /** Nonce action for request-local calendar preview data. */
18 const PREVIEW_NONCE_ACTION = 'wpbc_setup_wizard_date_selection_preview';
19
20 /** Maximum supported one-click fixed range length. */
21 const MAX_FIXED_DAYS = 180;
22
23 /** Maximum supported two-click flexible range length. */
24 const MAX_DYNAMIC_DAYS = 1095;
25
26 /**
27 * Authorize one request-local calendar data preview.
28 *
29 * The standard calendar-load endpoint is public because booking calendars
30 * must load for visitors. Setup Wizard option overrides are privileged, so
31 * this additional boundary requires the wizard capability and a page nonce
32 * before the canonical calendar calculations may consume draft settings.
33 *
34 * @param array<string,mixed> $request_params Sanitized calendar-load parameters.
35 *
36 * @return bool True when the current request may apply wizard preview overrides.
37 */
38 public static function is_calendar_preview_request_allowed( array $request_params ) {
39 if (
40 empty( $request_params['wpbc_setup_wizard_date_selection_preview'] )
41 || empty( $request_params['wpbc_setup_wizard_date_selection_preview_nonce'] )
42 || ! class_exists( 'WPBC_Setup_Wizard_Access' )
43 || ! WPBC_Setup_Wizard_Access::current_user_can_access()
44 ) {
45 return false;
46 }
47
48 $preview_nonce = (string) $request_params['wpbc_setup_wizard_date_selection_preview_nonce'];
49 return (bool) wp_verify_nonce( $preview_nonce, self::PREVIEW_NONCE_ACTION );
50 }
51
52 /**
53 * Determine whether Business Small range and changeover rules are available.
54 *
55 * @return bool True for Business Small or a higher edition.
56 */
57 public function supports_advanced_range_rules() {
58 return WPBC_Setup_Wizard_Customer_Journey_Policy::supports_advanced_range_rules();
59 }
60
61 /**
62 * Return the Date Selection policy owned by one Customer Journey.
63 *
64 * A policy distinguishes the journey's best mode from safe secondary modes.
65 * All other cards remain visible for comparison but are locked. Related
66 * settings are expressed as explicit states so PHP validation and the browser
67 * adapter enforce the same behavior without inferring rules from card text.
68 *
69 * @param string $journey_id Stable Customer Journey identifier.
70 *
71 * @return array<string,mixed> Data-only journey policy.
72 */
73 public function get_customer_journey_policy( $journey_id ) {
74 $journey_id = sanitize_key( is_scalar( $journey_id ) ? (string) $journey_id : '' );
75 $policies = array(
76 'guided_appointment_flow' => array(
77 'label' => __( 'Guided appointment flow', 'booking' ),
78 'best_mode' => 'single',
79 'supported_modes' => array( 'multiple' ),
80 ),
81 'start_end_time' => array(
82 'label' => __( 'Start & end time', 'booking' ),
83 'best_mode' => 'single',
84 'supported_modes' => array( 'multiple' ),
85 ),
86 'start_time_duration' => array(
87 'label' => __( 'Start time + duration', 'booking' ),
88 'best_mode' => 'single',
89 'supported_modes' => array( 'multiple' ),
90 ),
91 'fixed_time_slots' => array(
92 'label' => __( 'Fixed time slots', 'booking' ),
93 'best_mode' => 'single',
94 'supported_modes' => array( 'multiple' ),
95 ),
96 'single_full_day' => array(
97 'label' => __( 'Single full day', 'booking' ),
98 'best_mode' => 'single',
99 'supported_modes' => array(),
100 ),
101 'multiple_independent_days' => array(
102 'label' => __( 'Multiple independent full days', 'booking' ),
103 'best_mode' => 'multiple',
104 'supported_modes' => array(),
105 ),
106 'flexible_date_range' => array(
107 'label' => __( 'Flexible date range', 'booking' ),
108 'best_mode' => 'dynamic',
109 'supported_modes' => array(),
110 ),
111 'fixed_length_date_range' => array(
112 'label' => __( 'Fixed-length date range', 'booking' ),
113 'best_mode' => 'fixed',
114 'supported_modes' => array(),
115 ),
116 'check_in_out_changeover' => array(
117 'label' => __( 'Check-in & check-out changeover', 'booking' ),
118 'best_mode' => 'dynamic',
119 'supported_modes' => array(),
120 ),
121 'repeated_time_multiple_dates' => array(
122 'label' => __( 'Repeat one time on multiple dates', 'booking' ),
123 'best_mode' => 'multiple',
124 'supported_modes' => array( 'dynamic' ),
125 ),
126 'first_start_last_end' => array(
127 'label' => __( 'First-date start & last-date end', 'booking' ),
128 'best_mode' => 'dynamic',
129 'supported_modes' => array( 'multiple' ),
130 ),
131 );
132
133 if ( ! isset( $policies[ $journey_id ] ) ) {
134 $journey_id = 'guided_appointment_flow';
135 }
136
137 $policy = $policies[ $journey_id ];
138 $policy['journey_id'] = $journey_id;
139 $policy['mode_policy'] = array();
140 foreach ( array( 'single', 'multiple', 'dynamic', 'fixed' ) as $mode_id ) {
141 $policy['mode_policy'][ $mode_id ] = $this->get_customer_journey_mode_policy( $journey_id, $mode_id, $policy );
142 }
143
144 return $policy;
145 }
146
147 /**
148 * Normalize dependent settings to the selected Customer Journey and mode.
149 *
150 * Incompatible stored modes fall back to the journey's best mode for display.
151 * Forced settings are always normalized so a hidden control cannot retain a
152 * stale value from a previously selected journey.
153 *
154 * @param array<string,mixed> $field_values Date Selection draft values.
155 * @param string $journey_id Stable Customer Journey identifier.
156 *
157 * @return array<string,mixed> Journey-compatible values.
158 */
159 public function normalize_values_for_customer_journey( array $field_values, $journey_id ) {
160 $policy = $this->get_customer_journey_policy( $journey_id );
161 $selected_mode = isset( $field_values['date_selection_mode'] ) ? sanitize_key( (string) $field_values['date_selection_mode'] ) : '';
162 $mode_policy = isset( $policy['mode_policy'][ $selected_mode ] ) ? $policy['mode_policy'][ $selected_mode ] : array();
163
164 if ( empty( $mode_policy ) || 'locked' === $mode_policy['status'] ) {
165 $selected_mode = $policy['best_mode'];
166 $field_values['date_selection_mode'] = $selected_mode;
167 $mode_policy = $policy['mode_policy'][ $selected_mode ];
168 }
169
170 $field_values['date_selection_changeover_enabled'] = $this->normalize_policy_toggle(
171 isset( $field_values['date_selection_changeover_enabled'] ) ? $field_values['date_selection_changeover_enabled'] : 'Off',
172 $mode_policy['changeover']
173 );
174 $field_values['date_selection_recurrent_time'] = $this->normalize_policy_toggle(
175 isset( $field_values['date_selection_recurrent_time'] ) ? $field_values['date_selection_recurrent_time'] : 'Off',
176 $mode_policy['recurrent_time']
177 );
178 $field_values['date_selection_checkout_available'] = $this->normalize_policy_toggle(
179 isset( $field_values['date_selection_checkout_available'] ) ? $field_values['date_selection_checkout_available'] : 'Off',
180 $mode_policy['checkout_available']
181 );
182
183 return $field_values;
184 }
185
186 /**
187 * Apply the Date Selection default owned by a scheduled-booking journey.
188 *
189 * Continuing directly from Customer Journey to Date Selection starts the two
190 * multi-date time journeys at their recommended modes. The other scheduled
191 * booking journeys retain their established Single day transition default.
192 * Outside that exact transition, existing Setup Wizard values remain
193 * authoritative. A new, unsaved time-configuration step also retains the
194 * established Single day fallback for compatibility with checkpoints created
195 * before transition evidence was recorded; policy normalization then replaces
196 * any locked mode with the journey's recommended mode.
197 *
198 * @param array<string,mixed> $field_values Current Date Selection values.
199 * @param string $journey_id Stable Customer Journey identifier.
200 * @param bool $has_saved_values Whether this checkpoint already saved the step.
201 * @param bool $is_customer_journey_transition Whether Customer Journey was saved after Date Selection.
202 *
203 * @return array<string,mixed> Journey-compatible presentation values.
204 */
205 public function apply_customer_journey_defaults( array $field_values, $journey_id, $has_saved_values, $is_customer_journey_transition = false ) {
206 if (
207 $is_customer_journey_transition
208 && in_array( $journey_id, array( 'repeated_time_multiple_dates', 'first_start_last_end' ), true )
209 ) {
210 $journey_policy = $this->get_customer_journey_policy( $journey_id );
211 $field_values['date_selection_mode'] = $journey_policy['best_mode'];
212 } elseif (
213 (
214 $is_customer_journey_transition
215 && WPBC_Setup_Wizard_Customer_Journey_Policy::uses_single_day_transition_default( $journey_id )
216 )
217 || (
218 ! $has_saved_values
219 && WPBC_Setup_Wizard_Customer_Journey_Policy::uses_time_configuration( $journey_id )
220 )
221 ) {
222 $field_values['date_selection_mode'] = 'single';
223 }
224
225 return $this->normalize_values_for_customer_journey( $field_values, $journey_id );
226 }
227
228 /**
229 * Build the canonical Booking Calendar option values for Date Selection.
230 *
231 * Date Selection remains the single owner of the mapping between wizard
232 * field names and Booking Calendar's established option contract. The
233 * returned values are normalized data only; the progressive-save handler
234 * decides which edition-supported options may be persisted.
235 *
236 * @param array<string,mixed> $field_values Validated Date Selection values.
237 * @param string $journey_id Stable Customer Journey identifier.
238 *
239 * @return array<string,string> Canonical option values keyed by option name.
240 */
241 public function get_canonical_option_values( array $field_values, $journey_id ) {
242 $field_values = wp_parse_args( $field_values, $this->get_initial_values() );
243 $field_values = $this->normalize_values_for_customer_journey( $field_values, $journey_id );
244 $mode = isset( $field_values['date_selection_mode'] ) ? sanitize_key( (string) $field_values['date_selection_mode'] ) : 'multiple';
245
246 if ( 'fixed' === $mode && ! $this->supports_advanced_range_rules() ) {
247 $mode = 'dynamic';
248 }
249 if ( ! in_array( $mode, array( 'single', 'multiple', 'dynamic', 'fixed' ), true ) ) {
250 $mode = 'multiple';
251 }
252
253 $is_range_mode = in_array( $mode, array( 'dynamic', 'fixed' ), true );
254 $is_advanced = $this->supports_advanced_range_rules();
255 $changeover_active = $is_advanced && $is_range_mode && 'On' === (string) $field_values['date_selection_changeover_enabled'];
256 $triangles_active = $changeover_active && 'On' === (string) $field_values['date_selection_triangles'];
257 $checkout_active = $is_advanced && $is_range_mode && ! $changeover_active && 'On' === (string) $field_values['date_selection_checkout_available'];
258 $recurrent_time = ! $changeover_active && 'On' === (string) $field_values['date_selection_recurrent_time'];
259 $dynamic_min = $this->normalize_bounded_integer( $field_values['date_selection_dynamic_min'], 1, self::MAX_DYNAMIC_DAYS, 1 );
260 $dynamic_max = $this->normalize_bounded_integer( $field_values['date_selection_dynamic_max'], 1, self::MAX_DYNAMIC_DAYS, self::MAX_DYNAMIC_DAYS );
261 $dynamic_max = max( $dynamic_min, $dynamic_max );
262 $day_selection = in_array( $mode, array( 'single', 'multiple' ), true ) ? $mode : 'range';
263 $range_type = 'fixed' === $mode ? 'fixed' : 'dynamic';
264
265 $canonical_options = array(
266 'booking_type_of_day_selections' => $day_selection,
267 'booking_range_selection_type' => $range_type,
268 'booking_range_selection_days_count' => (string) $this->normalize_bounded_integer( $field_values['date_selection_fixed_days'], 1, self::MAX_FIXED_DAYS, 3 ),
269 'booking_range_start_day' => $this->normalize_weekdays( $field_values['date_selection_fixed_weekdays'] ),
270 'booking_range_selection_days_count_dynamic' => (string) $dynamic_min,
271 'booking_range_selection_days_max_count_dynamic' => (string) $dynamic_max,
272 'booking_range_selection_days_specific_num_dynamic' => $this->normalize_specific_days( $field_values['date_selection_dynamic_specific'] ),
273 'booking_range_start_day_dynamic' => $this->normalize_weekdays( $field_values['date_selection_dynamic_weekdays'] ),
274 'booking_range_selection_time_is_active' => $changeover_active ? 'On' : 'Off',
275 'booking_range_selection_start_time' => $this->normalize_time( $field_values['date_selection_check_in_time'], '15:00' ),
276 'booking_range_selection_end_time' => $this->normalize_time( $field_values['date_selection_check_out_time'], '11:00' ),
277 'booking_change_over_days_triangles' => $triangles_active ? 'On' : 'Off',
278 'booking_last_checkout_day_available' => $checkout_active ? 'On' : 'Off',
279 'booking_recurrent_time' => $recurrent_time ? 'On' : 'Off',
280 'booking_is_show_legend' => 'On' === (string) $field_values['date_selection_legend_enabled'] ? 'On' : 'Off',
281 'booking_legend_is_show_numbers' => 'On' === (string) $field_values['date_selection_legend_show_numbers'] ? 'On' : 'Off',
282 'booking_legend_is_vertical' => 'On' === (string) $field_values['date_selection_legend_vertical'] ? 'On' : 'Off',
283 );
284
285 foreach ( $this->get_legend_items() as $legend_item ) {
286 $legend_id = $legend_item['id'];
287 $show_key = 'date_selection_legend_item_' . $legend_id;
288 $text_key = 'date_selection_legend_text_' . $legend_id;
289
290 $canonical_options[ 'booking_legend_is_show_item_' . $legend_id ] = 'On' === (string) $field_values[ $show_key ] ? 'On' : 'Off';
291 $canonical_options[ 'booking_legend_text_for_item_' . $legend_id ] = $this->normalize_legend_text( $field_values[ $text_key ] );
292 }
293
294 return $canonical_options;
295 }
296
297 /**
298 * Build request-local overrides for the Date Selection editor only.
299 *
300 * Later Setup Wizard previews read the canonical options written when Date
301 * Selection advances. Keeping this override contract local to the editor
302 * prevents a checkpoint from becoming a second settings authority.
303 *
304 * @param array<string,mixed> $field_values Validated Date Selection editor values.
305 * @param string $journey_id Stable Customer Journey identifier.
306 *
307 * @return array<string,array<string,mixed>> Allow-listed editor preview overrides.
308 */
309 public function get_preview_configuration( array $field_values, $journey_id ) {
310 $option_overrides = $this->get_canonical_option_values( $field_values, $journey_id );
311 $day_selection = $option_overrides['booking_type_of_day_selections'];
312 $dynamic_min = (int) $option_overrides['booking_range_selection_days_count_dynamic'];
313 $dynamic_max = (int) $option_overrides['booking_range_selection_days_max_count_dynamic'];
314 $changeover_active = 'On' === $option_overrides['booking_range_selection_time_is_active'];
315 $triangles_active = 'On' === $option_overrides['booking_change_over_days_triangles'];
316 $checkout_active = 'On' === $option_overrides['booking_last_checkout_day_available'];
317 $recurrent_time = 'On' === $option_overrides['booking_recurrent_time'];
318
319 $calendar_parameters = array(
320 'is_enabled_change_over' => $changeover_active,
321 'days_select_mode' => $day_selection,
322 'fixed__days_num' => (int) $option_overrides['booking_range_selection_days_count'],
323 'fixed__week_days__start' => $this->preview_integer_list( $option_overrides['booking_range_start_day'], array( -1 ) ),
324 'dynamic__days_min' => $dynamic_min,
325 'dynamic__days_max' => $dynamic_max,
326 'dynamic__days_specific' => $this->preview_integer_list( $option_overrides['booking_range_selection_days_specific_num_dynamic'], array() ),
327 'dynamic__week_days__start' => $this->preview_integer_list( $option_overrides['booking_range_start_day_dynamic'], array( -1 ) ),
328 'booking_recurrent_time' => $recurrent_time ? 'On' : 'Off',
329 );
330
331 $calendar_request_overrides = array(
332 'wpbc_settings_calendar_preview' => 1,
333 'wpbc_setup_wizard_date_selection_preview' => 1,
334 'wpbc_setup_wizard_date_selection_preview_nonce' => wp_create_nonce( self::PREVIEW_NONCE_ACTION ),
335 'wpbc_settings_calendar_preview_changeover' => $changeover_active ? 'On' : 'Off',
336 'wpbc_settings_calendar_preview_triangles' => $triangles_active ? 'On' : 'Off',
337 'wpbc_settings_calendar_preview_recurrent_time' => $recurrent_time ? 'On' : 'Off',
338 'wpbc_settings_calendar_preview_last_checkout' => $checkout_active ? 'On' : 'Off',
339 'wpbc_settings_calendar_preview_show_legend' => $option_overrides['booking_is_show_legend'],
340 'wpbc_settings_calendar_preview_legend_show_numbers' => $option_overrides['booking_legend_is_show_numbers'],
341 'wpbc_settings_calendar_preview_legend_vertical' => $option_overrides['booking_legend_is_vertical'],
342 );
343
344 foreach ( $this->get_legend_items() as $legend_item ) {
345 $legend_id = $legend_item['id'];
346 $calendar_request_overrides[ 'wpbc_settings_calendar_preview_legend_show_' . $legend_id ] = $option_overrides[ 'booking_legend_is_show_item_' . $legend_id ];
347 $calendar_request_overrides[ 'wpbc_settings_calendar_preview_legend_text_' . $legend_id ] = $option_overrides[ 'booking_legend_text_for_item_' . $legend_id ];
348 }
349
350 return array(
351 'option_overrides' => $option_overrides,
352 'calendar_parameters' => $calendar_parameters,
353 'calendar_request_overrides' => $calendar_request_overrides,
354 );
355 }
356
357 /**
358 * Convert one validated comma list into JSON-safe preview integers.
359 *
360 * @param mixed $raw_list Candidate array or comma-separated list.
361 * @param int[] $fallback Fallback returned when no integers remain.
362 *
363 * @return int[] Normalized integer list.
364 */
365 private function preview_integer_list( $raw_list, array $fallback ) {
366 $raw_values = is_array( $raw_list ) ? $raw_list : explode( ',', (string) $raw_list );
367 $integers = array();
368
369 foreach ( $raw_values as $raw_value ) {
370 if ( is_scalar( $raw_value ) && preg_match( '/^-?\d+$/', trim( (string) $raw_value ) ) ) {
371 $integers[] = (int) $raw_value;
372 }
373 }
374
375 return empty( $integers ) ? $fallback : array_values( array_unique( $integers ) );
376 }
377
378 /**
379 * Validate that a submitted mode belongs to the selected Customer Journey.
380 *
381 * @param array<string,mixed> $field_values Date Selection values after field validation.
382 * @param string $journey_id Stable Customer Journey identifier.
383 *
384 * @return array<string,mixed>|WP_Error Journey-compatible values or a field error.
385 */
386 public function validate_values_for_customer_journey( array $field_values, $journey_id ) {
387 $policy = $this->get_customer_journey_policy( $journey_id );
388 $selected_mode = isset( $field_values['date_selection_mode'] ) ? sanitize_key( (string) $field_values['date_selection_mode'] ) : '';
389
390 if ( ! isset( $policy['mode_policy'][ $selected_mode ] ) || 'locked' === $policy['mode_policy'][ $selected_mode ]['status'] ) {
391 $message = __( 'Choose a date-selection mode that is compatible with the selected Customer Journey.', 'booking' );
392
393 return new WP_Error(
394 'wpbc_setup_wizard_date_selection_journey_mismatch',
395 $message,
396 array(
397 'field_errors' => array(
398 'date_selection_mode' => $message,
399 ),
400 )
401 );
402 }
403
404 return $this->normalize_values_for_customer_journey( $field_values, $journey_id );
405 }
406
407 /**
408 * Build one mode rule inside a Customer Journey policy.
409 *
410 * @param string $journey_id Stable Customer Journey identifier.
411 * @param string $mode_id Stable Date Selection mode.
412 * @param array<string,mixed> $policy Parent journey policy.
413 *
414 * @return array<string,string> Mode status, badge, reason, and dependent setting states.
415 */
416 private function get_customer_journey_mode_policy( $journey_id, $mode_id, array $policy ) {
417 $status = 'locked';
418 if ( $policy['best_mode'] === $mode_id ) {
419 $status = 'best';
420 } elseif ( in_array( $mode_id, $policy['supported_modes'], true ) ) {
421 $status = 'supported';
422 }
423
424 $mode_policy = array(
425 'status' => $status,
426 'badge_label' => 'best' === $status ? __( 'Best match', 'booking' ) : ( 'supported' === $status ? __( 'Also supported', 'booking' ) : '' ),
427 'disabled_reason' => '',
428 'changeover' => 'hidden_off',
429 'recurrent_time' => 'hidden_off',
430 'checkout_available' => 'hidden_off',
431 );
432
433 if ( 'locked' === $status ) {
434 $is_range_mode = in_array( $mode_id, array( 'dynamic', 'fixed' ), true );
435 $mode_policy['badge_label'] = $is_range_mode
436 ? __( 'Requires range booking journey', 'booking' )
437 : __( 'Requires a matching date journey', 'booking' );
438 $mode_policy['disabled_reason'] = $is_range_mode
439 ? __( 'Choose a range-based Customer Journey before using this date behavior.', 'booking' )
440 : __( 'Choose a Customer Journey designed for this date behavior.', 'booking' );
441
442 return $mode_policy;
443 }
444
445 if (
446 in_array( $journey_id, array( 'guided_appointment_flow', 'start_end_time', 'start_time_duration', 'fixed_time_slots' ), true )
447 && 'multiple' === $mode_id
448 ) {
449 $mode_policy['recurrent_time'] = 'optional_default_on';
450 }
451
452 if ( 'flexible_date_range' === $journey_id || 'fixed_length_date_range' === $journey_id ) {
453 $mode_policy['checkout_available'] = 'optional';
454 }
455
456 if ( 'check_in_out_changeover' === $journey_id ) {
457 $mode_policy['changeover'] = 'required_on';
458 }
459
460 if ( 'repeated_time_multiple_dates' === $journey_id ) {
461 $mode_policy['recurrent_time'] = 'required_on';
462 }
463
464 return $mode_policy;
465 }
466
467 /**
468 * Normalize one On/Off value according to a journey-owned control state.
469 *
470 * @param mixed $current_value Current draft value.
471 * @param string $policy_state Optional, default-on, forced-on, or forced-off state.
472 *
473 * @return string Normalized `On` or `Off` value.
474 */
475 private function normalize_policy_toggle( $current_value, $policy_state ) {
476 if ( in_array( $policy_state, array( 'hidden_on', 'required_on' ), true ) ) {
477 return 'On';
478 }
479
480 if ( in_array( $policy_state, array( 'optional', 'optional_default_on' ), true ) ) {
481 return 'On' === $current_value ? 'On' : 'Off';
482 }
483
484 return 'Off';
485 }
486
487 /**
488 * Return current canonical settings as read-only wizard suggestions.
489 *
490 * Missing or invalid legacy values are normalized without changing the
491 * stored options. Free and Personal editions retain Range selection but use
492 * the supported unrestricted two-click behavior.
493 *
494 * @return array<string,string> Normalized date-selection draft values.
495 */
496 public function get_initial_values() {
497 $day_selection_type = sanitize_key( (string) get_bk_option( 'booking_type_of_day_selections' ) );
498 $range_type = sanitize_key( (string) get_bk_option( 'booking_range_selection_type' ) );
499 if ( in_array( $day_selection_type, array( 'single', 'multiple' ), true ) ) {
500 $selection_mode = $day_selection_type;
501 } elseif ( 'range' === $day_selection_type ) {
502 $selection_mode = $this->supports_advanced_range_rules() && 'fixed' === $range_type ? 'fixed' : 'dynamic';
503 } else {
504 $selection_mode = 'multiple';
505 }
506 $dynamic_min = $this->normalize_bounded_integer( get_bk_option( 'booking_range_selection_days_count_dynamic' ), 1, self::MAX_DYNAMIC_DAYS, 1 );
507 $dynamic_max = $this->normalize_bounded_integer( get_bk_option( 'booking_range_selection_days_max_count_dynamic' ), 1, self::MAX_DYNAMIC_DAYS, self::MAX_DYNAMIC_DAYS );
508 $dynamic_max = max( $dynamic_min, $dynamic_max );
509 $is_advanced = $this->supports_advanced_range_rules();
510 $dynamic_specific = $is_advanced ? $this->normalize_specific_days( get_bk_option( 'booking_range_selection_days_specific_num_dynamic' ) ) : '';
511 $changeover_active = $is_advanced && 'On' === get_bk_option( 'booking_range_selection_time_is_active' );
512
513 $initial_values = array(
514 'date_selection_mode' => $selection_mode,
515 'date_selection_fixed_days' => (string) $this->normalize_bounded_integer( get_bk_option( 'booking_range_selection_days_count' ), 1, self::MAX_FIXED_DAYS, 3 ),
516 'date_selection_fixed_weekdays' => $this->normalize_weekdays( get_bk_option( 'booking_range_start_day' ) ),
517 'date_selection_dynamic_min' => (string) ( $is_advanced ? $dynamic_min : 1 ),
518 'date_selection_dynamic_max' => (string) $dynamic_max,
519 'date_selection_dynamic_weekdays' => $is_advanced ? $this->normalize_weekdays( get_bk_option( 'booking_range_start_day_dynamic' ) ) : '-1',
520 'date_selection_dynamic_specific' => $dynamic_specific,
521 'date_selection_changeover_enabled' => $changeover_active ? 'On' : 'Off',
522 'date_selection_check_in_time' => $this->normalize_time( get_bk_option( 'booking_range_selection_start_time' ), '15:00' ),
523 'date_selection_check_out_time' => $this->normalize_time( get_bk_option( 'booking_range_selection_end_time' ), '11:00' ),
524 'date_selection_triangles' => $is_advanced && 'Off' !== get_bk_option( 'booking_change_over_days_triangles' ) ? 'On' : 'Off',
525 'date_selection_checkout_available' => $is_advanced && in_array( $selection_mode, array( 'dynamic', 'fixed' ), true ) && ! $changeover_active && 'On' === get_bk_option( 'booking_last_checkout_day_available' ) ? 'On' : 'Off',
526 'date_selection_recurrent_time' => ! $changeover_active && 'On' === get_bk_option( 'booking_recurrent_time' ) ? 'On' : 'Off',
527 'date_selection_legend_enabled' => 'On' === get_bk_option( 'booking_is_show_legend' ) ? 'On' : 'Off',
528 'date_selection_legend_show_numbers' => 'On' === get_bk_option( 'booking_legend_is_show_numbers' ) ? 'On' : 'Off',
529 'date_selection_legend_vertical' => 'On' === get_bk_option( 'booking_legend_is_vertical' ) ? 'On' : 'Off',
530 );
531
532 foreach ( $this->get_legend_items() as $legend_item ) {
533 $legend_id = $legend_item['id'];
534 $initial_values[ 'date_selection_legend_item_' . $legend_id ] = 'On' === get_bk_option( 'booking_legend_is_show_item_' . $legend_id ) ? 'On' : 'Off';
535 $initial_values[ 'date_selection_legend_text_' . $legend_id ] = $this->normalize_legend_text( get_bk_option( 'booking_legend_text_for_item_' . $legend_id ) );
536 }
537
538 return $initial_values;
539 }
540
541 /**
542 * Return the canonical configurable calendar-legend items.
543 *
544 * The wizard owns only presentation metadata and draft field identifiers;
545 * the released Calendar Legend renderer remains the authority for the
546 * customer-facing legend cells.
547 *
548 * @return array<int,array{id:string,label:string,placeholder:string}> Legend item DTOs.
549 */
550 public function get_legend_items() {
551 return array(
552 array(
553 'id' => 'available',
554 'label' => __( 'Available item', 'booking' ),
555 'placeholder' => __( 'Available', 'booking' ),
556 ),
557 array(
558 'id' => 'pending',
559 'label' => __( 'Pending item', 'booking' ),
560 'placeholder' => __( 'Pending', 'booking' ),
561 ),
562 array(
563 'id' => 'approved',
564 'label' => __( 'Approved item', 'booking' ),
565 'placeholder' => __( 'Booked', 'booking' ),
566 ),
567 array(
568 'id' => 'partially',
569 'label' => __( 'Partially booked item', 'booking' ),
570 'placeholder' => __( 'Partially booked', 'booking' ),
571 ),
572 array(
573 'id' => 'unavailable',
574 'label' => __( 'Unavailable item', 'booking' ),
575 'placeholder' => __( 'Unavailable', 'booking' ),
576 ),
577 );
578 }
579
580 /**
581 * Return the safe preset choices for specific flexible-range lengths.
582 *
583 * Each preset is an explicit draft profile for the canonical flexible-range
584 * options. The selected-date counts are inclusive, and weekday values use
585 * Booking Calendar's Sunday-zero convention. Clear deliberately changes only
586 * the specific-length expression so it cannot discard manually configured
587 * minimum, maximum, or start-weekday rules.
588 *
589 * @return array<int,array<string,string|null>> Preset DTOs.
590 */
591 public function get_specific_day_presets() {
592 return array(
593 array(
594 'id' => 'weekly_days',
595 'label' => __( 'Weekly days', 'booking' ),
596 'value' => '7,14,21,28',
597 'minimum_days' => '7',
598 'maximum_days' => '28',
599 'start_weekdays' => '-1',
600 ),
601 array(
602 'id' => 'weekly_nights',
603 'label' => __( 'Weekly selection for nights', 'booking' ),
604 'value' => '8,15,22,29',
605 'minimum_days' => '8',
606 'maximum_days' => '29',
607 'start_weekdays' => '-1',
608 ),
609 array(
610 'id' => 'weekend_stay',
611 'label' => __( 'Weekend stays', 'booking' ),
612 'value' => '3,4',
613 'minimum_days' => '3',
614 'maximum_days' => '4',
615 'start_weekdays' => '5',
616 ),
617 array(
618 'id' => 'work_week',
619 'label' => __( 'Work week', 'booking' ),
620 'value' => '5',
621 'minimum_days' => '5',
622 'maximum_days' => '5',
623 'start_weekdays' => '1',
624 ),
625 array(
626 'id' => 'clear',
627 'label' => __( 'Clear', 'booking' ),
628 'value' => '',
629 'minimum_days' => null,
630 'maximum_days' => null,
631 'start_weekdays' => null,
632 ),
633 );
634 }
635
636 /**
637 * Return translated weekday records using the calendar's canonical 0-6 keys.
638 *
639 * @return array<int,array{value:string,label:string,short_label:string}> Weekday DTOs.
640 */
641 public function get_weekdays() {
642 $labels = array(
643 '0' => __( 'Sunday', 'booking' ),
644 '1' => __( 'Monday', 'booking' ),
645 '2' => __( 'Tuesday', 'booking' ),
646 '3' => __( 'Wednesday', 'booking' ),
647 '4' => __( 'Thursday', 'booking' ),
648 '5' => __( 'Friday', 'booking' ),
649 '6' => __( 'Saturday', 'booking' ),
650 );
651
652 return array_map(
653 static function ( $value, $label ) {
654 return array(
655 'value' => (string) $value,
656 'label' => $label,
657 'short_label' => function_exists( 'wpbc_get_weekday_short_name' ) ? wpbc_get_weekday_short_name( (int) $value ) : substr( $label, 0, 3 ),
658 );
659 },
660 array_keys( $labels ),
661 array_values( $labels )
662 );
663 }
664
665 /**
666 * Return 15-minute time choices while retaining valid non-standard options.
667 *
668 * @param string $current_time Current normalized time.
669 *
670 * @return array<string,string> Labels keyed by canonical 24-hour time.
671 */
672 public function get_time_options( $current_time = '' ) {
673 $time_options = array();
674
675 for ( $hour = 0; $hour < 24; $hour++ ) {
676 foreach ( array( 0, 15, 30, 45 ) as $minute ) {
677 $time = sprintf( '%02d:%02d', $hour, $minute );
678 $time_options[ $time ] = function_exists( 'wpbc_time_localized' ) ? wpbc_time_localized( $time ) : $time;
679 }
680 }
681
682 if ( '' !== $current_time && ! isset( $time_options[ $current_time ] ) && preg_match( '/^(?:[01]\d|2[0-3]):[0-5]\d$/', $current_time ) ) {
683 $time_options[ $current_time ] = function_exists( 'wpbc_time_localized' ) ? wpbc_time_localized( $current_time ) : $current_time;
684 ksort( $time_options );
685 }
686
687 return $time_options;
688 }
689
690 /**
691 * Validate one allow-listed date-selection draft field.
692 *
693 * @param string $field_id Stable draft field identifier.
694 * @param mixed $raw_value Untrusted submitted or stored value.
695 * @param bool $is_required Whether an empty value is invalid.
696 *
697 * @return string|WP_Error Normalized value or a validation error.
698 */
699 public function validate_field( $field_id, $raw_value, $is_required ) {
700 if ( ! is_scalar( $raw_value ) ) {
701 return new WP_Error( 'wpbc_setup_wizard_date_selection_invalid', __( 'Choose a valid date-selection setting.', 'booking' ) );
702 }
703
704 $raw_value = trim( (string) $raw_value );
705 if ( '' === $raw_value && ! $is_required ) {
706 return '';
707 }
708
709 switch ( $field_id ) {
710 case 'date_selection_mode':
711 if ( ! in_array( $raw_value, array( 'single', 'multiple', 'dynamic', 'fixed' ), true ) ) {
712 return new WP_Error( 'wpbc_setup_wizard_date_selection_mode_invalid', __( 'Choose an available date-selection mode.', 'booking' ) );
713 }
714 if ( 'fixed' === $raw_value && ! $this->supports_advanced_range_rules() ) {
715 return new WP_Error( 'wpbc_setup_wizard_date_selection_mode_unavailable', __( 'Fixed range selection requires Booking Calendar Business Small or higher.', 'booking' ) );
716 }
717
718 return $raw_value;
719
720 case 'date_selection_fixed_days':
721 $validated_fixed_days = $this->validate_integer( $raw_value, 1, self::MAX_FIXED_DAYS, __( 'Enter a fixed range between 1 and 180 days.', 'booking' ) );
722 if ( is_wp_error( $validated_fixed_days ) ) {
723 return $validated_fixed_days;
724 }
725
726 return $this->validate_advanced_field_value( $field_id, $validated_fixed_days );
727
728 case 'date_selection_dynamic_min':
729 case 'date_selection_dynamic_max':
730 $validated_integer = $this->validate_integer( $raw_value, 1, self::MAX_DYNAMIC_DAYS, __( 'Enter a flexible range between 1 and 1095 days.', 'booking' ) );
731 if ( is_wp_error( $validated_integer ) ) {
732 return $validated_integer;
733 }
734
735 return $this->validate_advanced_field_value( $field_id, $validated_integer );
736
737 case 'date_selection_fixed_weekdays':
738 $validated_fixed_weekdays = $this->validate_weekdays( $raw_value );
739 if ( is_wp_error( $validated_fixed_weekdays ) ) {
740 return $validated_fixed_weekdays;
741 }
742
743 return $this->validate_advanced_field_value( $field_id, $validated_fixed_weekdays );
744
745 case 'date_selection_dynamic_weekdays':
746 $validated_weekdays = $this->validate_weekdays( $raw_value );
747 if ( is_wp_error( $validated_weekdays ) ) {
748 return $validated_weekdays;
749 }
750
751 return $this->validate_advanced_field_value( $field_id, $validated_weekdays );
752
753 case 'date_selection_dynamic_specific':
754 $validated_specific_days = $this->validate_specific_days( $raw_value );
755 if ( is_wp_error( $validated_specific_days ) ) {
756 return $validated_specific_days;
757 }
758
759 return $this->validate_advanced_field_value( $field_id, $validated_specific_days );
760
761 case 'date_selection_changeover_enabled':
762 case 'date_selection_triangles':
763 case 'date_selection_checkout_available':
764 if ( ! in_array( $raw_value, array( 'On', 'Off' ), true ) ) {
765 return new WP_Error( 'wpbc_setup_wizard_date_selection_toggle_invalid', __( 'Choose a valid on or off setting.', 'booking' ) );
766 }
767 return $this->validate_advanced_field_value( $field_id, $raw_value );
768
769 case 'date_selection_recurrent_time':
770 case 'date_selection_legend_enabled':
771 case 'date_selection_legend_show_numbers':
772 case 'date_selection_legend_vertical':
773 case 'date_selection_legend_item_available':
774 case 'date_selection_legend_item_pending':
775 case 'date_selection_legend_item_approved':
776 case 'date_selection_legend_item_partially':
777 case 'date_selection_legend_item_unavailable':
778 if ( ! in_array( $raw_value, array( 'On', 'Off' ), true ) ) {
779 return new WP_Error( 'wpbc_setup_wizard_date_selection_toggle_invalid', __( 'Choose a valid on or off setting.', 'booking' ) );
780 }
781
782 return $raw_value;
783
784 case 'date_selection_legend_text_available':
785 case 'date_selection_legend_text_pending':
786 case 'date_selection_legend_text_approved':
787 case 'date_selection_legend_text_partially':
788 case 'date_selection_legend_text_unavailable':
789 $legend_text = sanitize_text_field( $raw_value );
790 $legend_text_length = function_exists( 'mb_strlen' ) ? mb_strlen( $legend_text ) : strlen( $legend_text );
791 if ( $legend_text_length > 255 ) {
792 return new WP_Error( 'wpbc_setup_wizard_date_selection_legend_text_invalid', __( 'Enter a calendar legend title with no more than 255 characters.', 'booking' ) );
793 }
794
795 return $legend_text;
796
797 case 'date_selection_check_in_time':
798 case 'date_selection_check_out_time':
799 if ( ! preg_match( '/^(?:[01]\d|2[0-3]):[0-5]\d$/', $raw_value ) ) {
800 return new WP_Error( 'wpbc_setup_wizard_date_selection_time_invalid', __( 'Choose a valid check-in or check-out time.', 'booking' ) );
801 }
802
803 return $this->validate_advanced_field_value( $field_id, $raw_value );
804 }
805
806 return new WP_Error( 'wpbc_setup_wizard_date_selection_field_unknown', __( 'The Date Selection editor received an unsupported field.', 'booking' ) );
807 }
808
809 /**
810 * Prevent lower editions from changing Business Small range-rule fields.
811 *
812 * The controls are disabled in the interface, but the server remains the
813 * authority. Their canonical values are still accepted because the complete
814 * draft contract is submitted on every navigation request.
815 *
816 * @param string $field_id Stable draft field identifier.
817 * @param string $validated_value Already normalized candidate value.
818 *
819 * @return string|WP_Error Unchanged canonical value or an edition error.
820 */
821 private function validate_advanced_field_value( $field_id, $validated_value ) {
822 if ( $this->supports_advanced_range_rules() ) {
823 return $validated_value;
824 }
825
826 $initial_values = $this->get_initial_values();
827 if ( isset( $initial_values[ $field_id ] ) && $initial_values[ $field_id ] === $validated_value ) {
828 return $validated_value;
829 }
830
831 return new WP_Error( 'wpbc_setup_wizard_date_selection_rule_unavailable', __( 'Advanced flexible range rules require Booking Calendar Business Small or higher.', 'booking' ) );
832 }
833
834 /**
835 * Validate and normalize a comma-delimited weekday list.
836 *
837 * @param string $raw_weekdays Candidate weekday list.
838 *
839 * @return string|WP_Error Canonical list or an error.
840 */
841 private function validate_weekdays( $raw_weekdays ) {
842 $raw_values = array_filter( array_map( 'trim', explode( ',', $raw_weekdays ) ), 'strlen' );
843
844 if ( in_array( '-1', $raw_values, true ) ) {
845 return '-1';
846 }
847
848 $weekdays = array();
849 foreach ( $raw_values as $raw_weekday ) {
850 if ( ! preg_match( '/^[0-6]$/', $raw_weekday ) ) {
851 return new WP_Error( 'wpbc_setup_wizard_date_selection_weekdays_invalid', __( 'Choose one or more valid starting weekdays.', 'booking' ) );
852 }
853 $weekdays[] = (int) $raw_weekday;
854 }
855
856 $weekdays = array_values( array_unique( $weekdays ) );
857 sort( $weekdays );
858
859 return empty( $weekdays ) ? '-1' : implode( ',', $weekdays );
860 }
861
862 /**
863 * Normalize a stored weekday value without trusting its shape.
864 *
865 * @param mixed $raw_weekdays Stored weekday value.
866 *
867 * @return string Canonical list or unrestricted marker.
868 */
869 private function normalize_weekdays( $raw_weekdays ) {
870 $validated_weekdays = $this->validate_weekdays( is_scalar( $raw_weekdays ) ? (string) $raw_weekdays : '' );
871
872 return is_wp_error( $validated_weekdays ) ? '-1' : $validated_weekdays;
873 }
874
875 /**
876 * Validate and expand a specific flexible-range length expression.
877 *
878 * The released setting accepts comma-separated values, ranges such as
879 * `3-5`, or a combination. The wizard stores the equivalent sorted comma
880 * list so the browser preview receives the same integer-list contract as
881 * the canonical calendar bootstrap without evaluating text as code.
882 *
883 * @param string $raw_specific_days Candidate comma/range expression.
884 *
885 * @return string|WP_Error Expanded comma list, an empty string, or an error.
886 */
887 private function validate_specific_days( $raw_specific_days ) {
888 $raw_specific_days = trim( sanitize_text_field( $raw_specific_days ) );
889 if ( '' === $raw_specific_days ) {
890 return '';
891 }
892
893 if ( strlen( $raw_specific_days ) > 500 || ! preg_match( '/^\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*$/', $raw_specific_days ) ) {
894 return new WP_Error( 'wpbc_setup_wizard_date_selection_specific_invalid', __( 'Enter specific day lengths as comma-separated numbers or ranges, for example 7,14,21,28 or 3-5.', 'booking' ) );
895 }
896
897 $specific_days = array();
898 foreach ( explode( ',', $raw_specific_days ) as $specific_part ) {
899 $range_bounds = array_map( 'intval', preg_split( '/\s*-\s*/', trim( $specific_part ) ) );
900 $range_start = min( $range_bounds );
901 $range_end = max( $range_bounds );
902
903 if ( $range_start < 1 || $range_end > self::MAX_DYNAMIC_DAYS ) {
904 return new WP_Error( 'wpbc_setup_wizard_date_selection_specific_invalid', __( 'Enter specific day lengths between 1 and 1095 days.', 'booking' ) );
905 }
906
907 for ( $specific_day = $range_start; $specific_day <= $range_end; $specific_day++ ) {
908 $specific_days[] = $specific_day;
909 }
910 }
911
912 $specific_days = array_values( array_unique( $specific_days ) );
913 sort( $specific_days, SORT_NUMERIC );
914
915 return implode( ',', $specific_days );
916 }
917
918 /**
919 * Normalize a stored specific-day expression without changing the option.
920 *
921 * @param mixed $raw_specific_days Stored candidate value.
922 *
923 * @return string Expanded safe comma list or an empty string.
924 */
925 private function normalize_specific_days( $raw_specific_days ) {
926 $validated_specific_days = $this->validate_specific_days( is_scalar( $raw_specific_days ) ? (string) $raw_specific_days : '' );
927
928 return is_wp_error( $validated_specific_days ) ? '' : $validated_specific_days;
929 }
930
931 /**
932 * Validate one bounded integer and return its canonical string form.
933 *
934 * @param string $raw_value Candidate numeric value.
935 * @param int $minimum Inclusive lower bound.
936 * @param int $maximum Inclusive upper bound.
937 * @param string $message Translated validation message.
938 *
939 * @return string|WP_Error Canonical integer string or an error.
940 */
941 private function validate_integer( $raw_value, $minimum, $maximum, $message ) {
942 if ( ! preg_match( '/^\d+$/', $raw_value ) ) {
943 return new WP_Error( 'wpbc_setup_wizard_date_selection_number_invalid', $message );
944 }
945
946 $integer_value = (int) $raw_value;
947 if ( $integer_value < $minimum || $integer_value > $maximum ) {
948 return new WP_Error( 'wpbc_setup_wizard_date_selection_number_invalid', $message );
949 }
950
951 return (string) $integer_value;
952 }
953
954 /**
955 * Normalize a possibly missing bounded integer.
956 *
957 * @param mixed $raw_value Candidate numeric value.
958 * @param int $minimum Inclusive lower bound.
959 * @param int $maximum Inclusive upper bound.
960 * @param int $fallback Value used when the candidate is invalid.
961 *
962 * @return int Normalized integer.
963 */
964 private function normalize_bounded_integer( $raw_value, $minimum, $maximum, $fallback ) {
965 $integer_value = is_scalar( $raw_value ) && preg_match( '/^\d+$/', (string) $raw_value ) ? (int) $raw_value : $fallback;
966
967 return min( $maximum, max( $minimum, $integer_value ) );
968 }
969
970 /**
971 * Normalize a stored 24-hour time without changing the option.
972 *
973 * @param mixed $raw_time Candidate time.
974 * @param string $fallback Valid fallback time.
975 *
976 * @return string Canonical time.
977 */
978 private function normalize_time( $raw_time, $fallback ) {
979 $raw_time = is_scalar( $raw_time ) ? trim( (string) $raw_time ) : '';
980
981 return preg_match( '/^(?:[01]\d|2[0-3]):[0-5]\d$/', $raw_time ) ? $raw_time : $fallback;
982 }
983
984 /**
985 * Normalize a stored calendar-legend title without changing the option.
986 *
987 * @param mixed $raw_text Stored legend title.
988 *
989 * @return string Safe draft legend title.
990 */
991 private function normalize_legend_text( $raw_text ) {
992 $legend_text = is_scalar( $raw_text ) ? sanitize_text_field( (string) $raw_text ) : '';
993
994 return function_exists( 'mb_substr' ) ? mb_substr( $legend_text, 0, 255 ) : substr( $legend_text, 0, 255 );
995 }
996 }
997