booking
/
includes
/
page-setup-wizard
/
step-start-end-times
/
class-wpbc-setup-wizard-start-end-times.php
class-wpbc-setup-wizard-start-end-times.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-start-end-times/class-wpbc-setup-wizard-start-end-times.php
| 1 | <?php |
| 2 | /** |
| 3 | * Start and End Times proposal service for the Setup Wizard. |
| 4 | * |
| 5 | * @package Booking Calendar |
| 6 | */ |
| 7 | |
| 8 | if ( ! defined( 'ABSPATH' ) ) { |
| 9 | exit; |
| 10 | } |
| 11 | |
| 12 | /** |
| 13 | * Build, validate, and present reusable start-time and end-time choices. |
| 14 | * |
| 15 | * The saved DTO deliberately uses the same `HH:MM` values accepted by the |
| 16 | * Form Builder `starttime` and `endtime` fields. This module does not mutate a |
| 17 | * Booking Form; a later Booking Form step can apply these validated choices to |
| 18 | * its selected template without coupling this editor to the wizard shell. |
| 19 | */ |
| 20 | final class WPBC_Setup_Wizard_Start_End_Times { |
| 21 | |
| 22 | const MAX_TIMES_PER_LIST = 288; |
| 23 | const MAX_PAYLOAD_LENGTH = 10000; |
| 24 | const TIME_INCREMENT = 5; |
| 25 | |
| 26 | /** |
| 27 | * Return a safe starter configuration for both time lists. |
| 28 | * |
| 29 | * @return array{start_times:string[],end_times:string[]} Starter choices. |
| 30 | */ |
| 31 | public function get_initial_start_end_times() { |
| 32 | return array( |
| 33 | 'start_times' => $this->generate_time_values( '08:00', '12:30', 30 ), |
| 34 | 'end_times' => $this->generate_time_values( '13:00', '17:30', 30 ), |
| 35 | ); |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Build presentation data for the reusable time-list editor. |
| 40 | * |
| 41 | * @param array<string,mixed> $step_values Validated values for this module. |
| 42 | * @param string $time_format Validated WordPress time format. |
| 43 | * |
| 44 | * @return array<string,mixed> Data-only template context. |
| 45 | */ |
| 46 | public function get_context( array $step_values, $time_format ) { |
| 47 | $start_end_times = isset( $step_values['start_end_times'] ) && is_array( $step_values['start_end_times'] ) |
| 48 | ? $step_values['start_end_times'] |
| 49 | : $this->get_initial_start_end_times(); |
| 50 | |
| 51 | $time_options = $this->get_time_options( $time_format ); |
| 52 | |
| 53 | return array( |
| 54 | 'values' => $step_values, |
| 55 | 'start_end_times' => $start_end_times, |
| 56 | 'time_options' => $time_options, |
| 57 | 'draft_field_name' => 'start_end_times', |
| 58 | 'draft_values' => $start_end_times, |
| 59 | 'editor_title' => __( 'Set start and end times', 'booking' ), |
| 60 | 'editor_description' => __( 'Choose the start and end time options customers can use when booking.', 'booking' ), |
| 61 | 'validation_rule' => 'end_after_start', |
| 62 | 'list_definitions' => array( |
| 63 | 'start_times' => array( |
| 64 | 'title' => __( 'Start times', 'booking' ), |
| 65 | 'description' => __( 'Choices shown in the booking form.', 'booking' ), |
| 66 | 'list_label' => __( 'Start times', 'booking' ), |
| 67 | 'clear_aria_label' => __( 'Clear Start times', 'booking' ), |
| 68 | 'clear_status' => __( 'Start times cleared.', 'booking' ), |
| 69 | 'add_label' => __( 'Add one time slot', 'booking' ), |
| 70 | 'generator_interval' => 30, |
| 71 | 'options' => $time_options, |
| 72 | ), |
| 73 | 'end_times' => array( |
| 74 | 'title' => __( 'End times', 'booking' ), |
| 75 | 'description' => __( 'Choices shown in the booking form.', 'booking' ), |
| 76 | 'list_label' => __( 'End times', 'booking' ), |
| 77 | 'clear_aria_label' => __( 'Clear End times', 'booking' ), |
| 78 | 'clear_status' => __( 'End times cleared.', 'booking' ), |
| 79 | 'add_label' => __( 'Add one time slot', 'booking' ), |
| 80 | 'generator_interval' => 30, |
| 81 | 'options' => $time_options, |
| 82 | ), |
| 83 | ), |
| 84 | 'copy_action' => array( |
| 85 | 'source' => 'start_times', |
| 86 | 'target' => 'end_times', |
| 87 | 'label' => __( 'Copy start times to end times', 'booking' ), |
| 88 | 'aria_label' => __( 'Copy Start times to End times', 'booking' ), |
| 89 | 'status' => __( 'Start times copied to End times.', 'booking' ), |
| 90 | ), |
| 91 | 'interval_options' => $this->get_interval_options(), |
| 92 | 'max_times_per_list' => self::MAX_TIMES_PER_LIST, |
| 93 | 'time_increment' => self::TIME_INCREMENT, |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Validate one complete start/end time-choice DTO. |
| 99 | * |
| 100 | * Lists preserve the user's order because their order is also the future |
| 101 | * Form Builder option order. Duplicate values and values outside the shared |
| 102 | * five-minute precision are rejected instead of being silently rewritten. |
| 103 | * |
| 104 | * @param mixed $raw_start_end_times JSON transport string or stored array. |
| 105 | * @param bool $require_complete Whether both lists and a usable pair are required. |
| 106 | * |
| 107 | * @return array{start_times:string[],end_times:string[]}|WP_Error Normalized choices or an error. |
| 108 | */ |
| 109 | public function validate_start_end_times( $raw_start_end_times, $require_complete ) { |
| 110 | if ( is_string( $raw_start_end_times ) ) { |
| 111 | if ( strlen( $raw_start_end_times ) > self::MAX_PAYLOAD_LENGTH ) { |
| 112 | return new WP_Error( 'wpbc_setup_wizard_start_end_times_too_large', __( 'The start and end time draft is too large.', 'booking' ) ); |
| 113 | } |
| 114 | |
| 115 | $raw_start_end_times = json_decode( $raw_start_end_times, true ); |
| 116 | if ( JSON_ERROR_NONE !== json_last_error() ) { |
| 117 | return new WP_Error( 'wpbc_setup_wizard_start_end_times_invalid_json', __( 'The start and end time draft is invalid.', 'booking' ) ); |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | if ( ! is_array( $raw_start_end_times ) ) { |
| 122 | return new WP_Error( 'wpbc_setup_wizard_start_end_times_invalid', __( 'Enter valid start and end time choices.', 'booking' ) ); |
| 123 | } |
| 124 | |
| 125 | if ( array_diff( array_keys( $raw_start_end_times ), array( 'start_times', 'end_times' ) ) ) { |
| 126 | return new WP_Error( 'wpbc_setup_wizard_start_end_times_unknown_field', __( 'The start and end time draft contains an unsupported field.', 'booking' ) ); |
| 127 | } |
| 128 | |
| 129 | $start_times = $this->validate_time_list( |
| 130 | isset( $raw_start_end_times['start_times'] ) ? $raw_start_end_times['start_times'] : array(), |
| 131 | $require_complete, |
| 132 | __( 'Add at least one start time.', 'booking' ) |
| 133 | ); |
| 134 | if ( is_wp_error( $start_times ) ) { |
| 135 | return $start_times; |
| 136 | } |
| 137 | |
| 138 | $end_times = $this->validate_time_list( |
| 139 | isset( $raw_start_end_times['end_times'] ) ? $raw_start_end_times['end_times'] : array(), |
| 140 | $require_complete, |
| 141 | __( 'Add at least one end time.', 'booking' ) |
| 142 | ); |
| 143 | if ( is_wp_error( $end_times ) ) { |
| 144 | return $end_times; |
| 145 | } |
| 146 | |
| 147 | if ( $require_complete && ! $this->has_usable_time_pair( $start_times, $end_times ) ) { |
| 148 | return new WP_Error( 'wpbc_setup_wizard_start_end_times_pair_invalid', __( 'Add an end time that is later than at least one start time.', 'booking' ) ); |
| 149 | } |
| 150 | |
| 151 | return array( |
| 152 | 'start_times' => $start_times, |
| 153 | 'end_times' => $end_times, |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Generate inclusive `HH:MM` values for a bounded same-day range. |
| 159 | * |
| 160 | * @param string $start_time First time in `HH:MM` format. |
| 161 | * @param string $end_time Last time in `HH:MM` format. |
| 162 | * @param int $interval_minutes Positive interval in minutes. |
| 163 | * |
| 164 | * @return string[] Ordered time values, or an empty array for invalid input. |
| 165 | */ |
| 166 | public function generate_time_values( $start_time, $end_time, $interval_minutes ) { |
| 167 | $start_minutes = $this->time_to_minutes( $start_time ); |
| 168 | $end_minutes = $this->time_to_minutes( $end_time ); |
| 169 | $interval_minutes = absint( $interval_minutes ); |
| 170 | |
| 171 | if ( |
| 172 | null === $start_minutes |
| 173 | || null === $end_minutes |
| 174 | || $end_minutes < $start_minutes |
| 175 | || self::TIME_INCREMENT > $interval_minutes |
| 176 | || 0 !== $interval_minutes % self::TIME_INCREMENT |
| 177 | ) { |
| 178 | return array(); |
| 179 | } |
| 180 | |
| 181 | $time_values = array(); |
| 182 | for ( $minute = $start_minutes; $minute <= $end_minutes && count( $time_values ) < self::MAX_TIMES_PER_LIST; $minute += $interval_minutes ) { |
| 183 | $time_values[] = sprintf( '%02d:%02d', (int) floor( $minute / 60 ), $minute % 60 ); |
| 184 | } |
| 185 | |
| 186 | return $time_values; |
| 187 | } |
| 188 | |
| 189 | /** |
| 190 | * Return every time selectable by this editor. |
| 191 | * |
| 192 | * @param string $time_format Validated WordPress time format. |
| 193 | * |
| 194 | * @return array<int,array{value:string,label:string}> Ordered options. |
| 195 | */ |
| 196 | public function get_time_options( $time_format ) { |
| 197 | $time_format = in_array( $time_format, array( 'g:i a', 'g:i A', 'H:i' ), true ) ? $time_format : 'H:i'; |
| 198 | $options = array(); |
| 199 | |
| 200 | for ( $minute = 0; $minute < 24 * 60; $minute += self::TIME_INCREMENT ) { |
| 201 | $options[] = array( |
| 202 | 'value' => sprintf( '%02d:%02d', (int) floor( $minute / 60 ), $minute % 60 ), |
| 203 | 'label' => wp_date( $time_format, $minute * MINUTE_IN_SECONDS, new DateTimeZone( 'UTC' ) ), |
| 204 | ); |
| 205 | } |
| 206 | |
| 207 | return $options; |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * Return the supported generator intervals. |
| 212 | * |
| 213 | * @return array<int,array{value:int,label:string}> Ordered interval choices. |
| 214 | */ |
| 215 | public function get_interval_options() { |
| 216 | $intervals = array( 5, 10, 15, 20, 30, 60, 120 ); |
| 217 | $options = array(); |
| 218 | |
| 219 | foreach ( $intervals as $interval_minutes ) { |
| 220 | /* translators: %d: Time interval in minutes. */ |
| 221 | $label = sprintf( _n( '%d minute', '%d minutes', $interval_minutes, 'booking' ), $interval_minutes ); |
| 222 | $options[] = array( |
| 223 | 'value' => $interval_minutes, |
| 224 | 'label' => $label, |
| 225 | ); |
| 226 | } |
| 227 | |
| 228 | return $options; |
| 229 | } |
| 230 | |
| 231 | /** |
| 232 | * Validate one ordered list of unique time values. |
| 233 | * |
| 234 | * @param mixed $raw_time_list Untrusted time-list candidate. |
| 235 | * @param bool $require_complete Whether an empty list is invalid. |
| 236 | * @param string $required_message Translated list-specific required message. |
| 237 | * |
| 238 | * @return string[]|WP_Error Normalized list or an error. |
| 239 | */ |
| 240 | public function validate_time_list( $raw_time_list, $require_complete, $required_message ) { |
| 241 | if ( ! is_array( $raw_time_list ) ) { |
| 242 | return new WP_Error( 'wpbc_setup_wizard_time_list_invalid', __( 'Every time list must contain valid time choices.', 'booking' ) ); |
| 243 | } |
| 244 | |
| 245 | $raw_time_list = array_values( $raw_time_list ); |
| 246 | if ( count( $raw_time_list ) > self::MAX_TIMES_PER_LIST ) { |
| 247 | /* translators: %d: Maximum number of time choices. */ |
| 248 | return new WP_Error( 'wpbc_setup_wizard_time_list_limit', sprintf( __( 'Add no more than %d choices to one time list.', 'booking' ), self::MAX_TIMES_PER_LIST ) ); |
| 249 | } |
| 250 | |
| 251 | $normalized_times = array(); |
| 252 | foreach ( $raw_time_list as $raw_time ) { |
| 253 | $time_minutes = $this->time_to_minutes( $raw_time ); |
| 254 | if ( null === $time_minutes || 0 !== $time_minutes % self::TIME_INCREMENT ) { |
| 255 | return new WP_Error( 'wpbc_setup_wizard_time_value_invalid', __( 'Use a valid five-minute time for every choice.', 'booking' ) ); |
| 256 | } |
| 257 | |
| 258 | $normalized_time = sprintf( '%02d:%02d', (int) floor( $time_minutes / 60 ), $time_minutes % 60 ); |
| 259 | if ( in_array( $normalized_time, $normalized_times, true ) ) { |
| 260 | return new WP_Error( 'wpbc_setup_wizard_time_value_duplicate', __( 'The same time cannot appear twice in one list.', 'booking' ) ); |
| 261 | } |
| 262 | $normalized_times[] = $normalized_time; |
| 263 | } |
| 264 | |
| 265 | if ( $require_complete && empty( $normalized_times ) ) { |
| 266 | return new WP_Error( 'wpbc_setup_wizard_time_list_required', $required_message ); |
| 267 | } |
| 268 | |
| 269 | return $normalized_times; |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * Determine whether at least one end choice follows one start choice. |
| 274 | * |
| 275 | * @param string[] $start_times Validated start times. |
| 276 | * @param string[] $end_times Validated end times. |
| 277 | * |
| 278 | * @return bool True when the two lists can create a positive time range. |
| 279 | */ |
| 280 | private function has_usable_time_pair( array $start_times, array $end_times ) { |
| 281 | if ( empty( $start_times ) || empty( $end_times ) ) { |
| 282 | return false; |
| 283 | } |
| 284 | |
| 285 | $start_minutes = array_map( array( $this, 'time_to_minutes' ), $start_times ); |
| 286 | $end_minutes = array_map( array( $this, 'time_to_minutes' ), $end_times ); |
| 287 | |
| 288 | return max( $end_minutes ) > min( $start_minutes ); |
| 289 | } |
| 290 | |
| 291 | /** |
| 292 | * Convert one exact `HH:MM` value to minutes after midnight. |
| 293 | * |
| 294 | * @param mixed $raw_time Untrusted time value. |
| 295 | * |
| 296 | * @return int|null Minutes after midnight, or null when invalid. |
| 297 | */ |
| 298 | public function time_to_minutes( $raw_time ) { |
| 299 | if ( ! is_scalar( $raw_time ) || ! preg_match( '/^(?:[01]\d|2[0-3]):[0-5]\d$/', (string) $raw_time ) ) { |
| 300 | return null; |
| 301 | } |
| 302 | |
| 303 | list( $hour, $minute ) = array_map( 'intval', explode( ':', (string) $raw_time ) ); |
| 304 | |
| 305 | return ( $hour * 60 ) + $minute; |
| 306 | } |
| 307 | } |
| 308 |