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
| 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 |