booking
/
includes
/
page-setup-wizard
/
step-start-duration-times
/
class-wpbc-setup-wizard-start-duration-times.php
class-wpbc-setup-wizard-start-duration-times.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-start-duration-times/class-wpbc-setup-wizard-start-duration-times.php
| 1 | <?php |
| 2 | /** |
| 3 | * Start Time and Duration 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 duration choices. |
| 14 | * |
| 15 | * Values use the `HH:MM` storage contract accepted by Form Builder's |
| 16 | * `starttime` and `durationtime` fields. The proposal remains checkpoint-only |
| 17 | * until the Booking Form step applies it to the selected template. |
| 18 | */ |
| 19 | final class WPBC_Setup_Wizard_Start_Duration_Times { |
| 20 | |
| 21 | const MAX_TIMES_PER_LIST = 288; |
| 22 | const MAX_PAYLOAD_LENGTH = 10000; |
| 23 | const TIME_INCREMENT = 5; |
| 24 | |
| 25 | /** @var WPBC_Setup_Wizard_Start_End_Times */ |
| 26 | private $clock_times; |
| 27 | |
| 28 | /** |
| 29 | * Build the service around the shared clock-time helper. |
| 30 | * |
| 31 | * @param WPBC_Setup_Wizard_Start_End_Times|null $clock_times Optional helper for tests or composition. |
| 32 | */ |
| 33 | public function __construct( $clock_times = null ) { |
| 34 | $this->clock_times = $clock_times instanceof WPBC_Setup_Wizard_Start_End_Times |
| 35 | ? $clock_times |
| 36 | : new WPBC_Setup_Wizard_Start_End_Times(); |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * Return starter choices aligned with the recommended Form Builder template. |
| 41 | * |
| 42 | * @return array{start_times:string[],duration_times:string[]} Starter choices. |
| 43 | */ |
| 44 | public function get_initial_start_duration_times() { |
| 45 | return array( |
| 46 | 'start_times' => $this->clock_times->generate_time_values( '10:00', '15:40', 20 ), |
| 47 | 'duration_times' => array( '00:20', '00:40', '01:00', '01:20', '01:40', '02:00' ), |
| 48 | ); |
| 49 | } |
| 50 | |
| 51 | /** |
| 52 | * Build presentation data for the shared sortable choice-list editor. |
| 53 | * |
| 54 | * @param array<string,mixed> $step_values Validated values for this module. |
| 55 | * @param string $time_format Validated WordPress time format. |
| 56 | * |
| 57 | * @return array<string,mixed> Data-only template context. |
| 58 | */ |
| 59 | public function get_context( array $step_values, $time_format ) { |
| 60 | $start_duration_times = isset( $step_values['start_duration_times'] ) && is_array( $step_values['start_duration_times'] ) |
| 61 | ? $step_values['start_duration_times'] |
| 62 | : $this->get_initial_start_duration_times(); |
| 63 | |
| 64 | return array( |
| 65 | 'values' => $step_values, |
| 66 | 'draft_field_name' => 'start_duration_times', |
| 67 | 'draft_values' => $start_duration_times, |
| 68 | 'editor_title' => __( 'Set start and duration times', 'booking' ), |
| 69 | 'editor_description' => __( 'Choose the start times and duration options customers can use when booking.', 'booking' ), |
| 70 | 'validation_rule' => 'independent_lists', |
| 71 | 'list_definitions' => array( |
| 72 | 'start_times' => array( |
| 73 | 'title' => __( 'Start times', 'booking' ), |
| 74 | 'description' => __( 'Choices shown in the booking form.', 'booking' ), |
| 75 | 'list_label' => __( 'Start times', 'booking' ), |
| 76 | 'clear_aria_label' => __( 'Clear Start times', 'booking' ), |
| 77 | 'clear_status' => __( 'Start times cleared.', 'booking' ), |
| 78 | 'add_label' => __( 'Add one time slot', 'booking' ), |
| 79 | 'generator_interval' => 20, |
| 80 | 'options' => $this->clock_times->get_time_options( $time_format ), |
| 81 | ), |
| 82 | 'duration_times' => array( |
| 83 | 'title' => __( 'Duration times', 'booking' ), |
| 84 | 'description' => __( 'Duration choices shown in the booking form.', 'booking' ), |
| 85 | 'list_label' => __( 'Duration times', 'booking' ), |
| 86 | 'clear_aria_label' => __( 'Clear Duration times', 'booking' ), |
| 87 | 'clear_status' => __( 'Duration times cleared.', 'booking' ), |
| 88 | 'add_label' => __( 'Add one duration', 'booking' ), |
| 89 | 'generator_interval' => 20, |
| 90 | 'options' => $this->get_duration_options(), |
| 91 | ), |
| 92 | ), |
| 93 | 'copy_action' => array(), |
| 94 | 'interval_options' => $this->clock_times->get_interval_options(), |
| 95 | 'max_times_per_list' => self::MAX_TIMES_PER_LIST, |
| 96 | 'time_increment' => self::TIME_INCREMENT, |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Validate one complete start-time and duration-choice DTO. |
| 102 | * |
| 103 | * @param mixed $raw_start_duration_times JSON transport string or stored array. |
| 104 | * @param bool $require_complete Whether both lists are required. |
| 105 | * |
| 106 | * @return array{start_times:string[],duration_times:string[]}|WP_Error Normalized choices or an error. |
| 107 | */ |
| 108 | public function validate_start_duration_times( $raw_start_duration_times, $require_complete ) { |
| 109 | if ( is_string( $raw_start_duration_times ) ) { |
| 110 | if ( strlen( $raw_start_duration_times ) > self::MAX_PAYLOAD_LENGTH ) { |
| 111 | return new WP_Error( 'wpbc_setup_wizard_start_duration_times_too_large', __( 'The start time and duration draft is too large.', 'booking' ) ); |
| 112 | } |
| 113 | |
| 114 | $raw_start_duration_times = json_decode( $raw_start_duration_times, true ); |
| 115 | if ( JSON_ERROR_NONE !== json_last_error() ) { |
| 116 | return new WP_Error( 'wpbc_setup_wizard_start_duration_times_invalid_json', __( 'The start time and duration draft is invalid.', 'booking' ) ); |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | if ( ! is_array( $raw_start_duration_times ) ) { |
| 121 | return new WP_Error( 'wpbc_setup_wizard_start_duration_times_invalid', __( 'Enter valid start time and duration choices.', 'booking' ) ); |
| 122 | } |
| 123 | |
| 124 | if ( array_diff( array_keys( $raw_start_duration_times ), array( 'start_times', 'duration_times' ) ) ) { |
| 125 | return new WP_Error( 'wpbc_setup_wizard_start_duration_times_unknown_field', __( 'The start time and duration draft contains an unsupported field.', 'booking' ) ); |
| 126 | } |
| 127 | |
| 128 | $start_times = $this->clock_times->validate_time_list( |
| 129 | isset( $raw_start_duration_times['start_times'] ) ? $raw_start_duration_times['start_times'] : array(), |
| 130 | $require_complete, |
| 131 | __( 'Add at least one start time.', 'booking' ) |
| 132 | ); |
| 133 | if ( is_wp_error( $start_times ) ) { |
| 134 | return $start_times; |
| 135 | } |
| 136 | |
| 137 | $duration_times = $this->validate_duration_list( |
| 138 | isset( $raw_start_duration_times['duration_times'] ) ? $raw_start_duration_times['duration_times'] : array(), |
| 139 | $require_complete |
| 140 | ); |
| 141 | if ( is_wp_error( $duration_times ) ) { |
| 142 | return $duration_times; |
| 143 | } |
| 144 | |
| 145 | return array( |
| 146 | 'start_times' => $start_times, |
| 147 | 'duration_times' => $duration_times, |
| 148 | ); |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Return every supported positive duration in five-minute increments. |
| 153 | * |
| 154 | * @return array<int,array{value:string,label:string}> Ordered duration options. |
| 155 | */ |
| 156 | public function get_duration_options() { |
| 157 | $options = array(); |
| 158 | for ( $minute = self::TIME_INCREMENT; $minute <= DAY_IN_SECONDS / MINUTE_IN_SECONDS; $minute += self::TIME_INCREMENT ) { |
| 159 | $options[] = array( |
| 160 | 'value' => $this->minutes_to_duration( $minute ), |
| 161 | 'label' => $this->format_duration_label( $minute ), |
| 162 | ); |
| 163 | } |
| 164 | |
| 165 | return $options; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Format one validated duration for customer-facing summaries and choices. |
| 170 | * |
| 171 | * @param int $duration_minutes Positive duration in minutes. |
| 172 | * |
| 173 | * @return string Localized duration label. |
| 174 | */ |
| 175 | public function format_duration_label( $duration_minutes ) { |
| 176 | $duration_minutes = absint( $duration_minutes ); |
| 177 | $hours = (int) floor( $duration_minutes / 60 ); |
| 178 | $minutes = $duration_minutes % 60; |
| 179 | |
| 180 | if ( 0 < $hours && 0 < $minutes ) { |
| 181 | /* translators: 1: Number of hours, 2: Number of minutes. */ |
| 182 | return sprintf( __( '%1$d hr %2$d min', 'booking' ), $hours, $minutes ); |
| 183 | } |
| 184 | if ( 0 < $hours ) { |
| 185 | /* translators: %d: Number of hours. */ |
| 186 | return sprintf( _n( '%d hour', '%d hours', $hours, 'booking' ), $hours ); |
| 187 | } |
| 188 | |
| 189 | /* translators: %d: Number of minutes. */ |
| 190 | return sprintf( _n( '%d minute', '%d minutes', $minutes, 'booking' ), $minutes ); |
| 191 | } |
| 192 | |
| 193 | /** |
| 194 | * Validate one ordered list of unique positive durations. |
| 195 | * |
| 196 | * @param mixed $raw_duration_list Untrusted duration-list candidate. |
| 197 | * @param bool $require_complete Whether an empty list is invalid. |
| 198 | * |
| 199 | * @return string[]|WP_Error Normalized durations or an error. |
| 200 | */ |
| 201 | private function validate_duration_list( $raw_duration_list, $require_complete ) { |
| 202 | if ( ! is_array( $raw_duration_list ) ) { |
| 203 | return new WP_Error( 'wpbc_setup_wizard_duration_list_invalid', __( 'The duration list must contain valid choices.', 'booking' ) ); |
| 204 | } |
| 205 | |
| 206 | $raw_duration_list = array_values( $raw_duration_list ); |
| 207 | if ( count( $raw_duration_list ) > self::MAX_TIMES_PER_LIST ) { |
| 208 | /* translators: %d: Maximum number of duration choices. */ |
| 209 | return new WP_Error( 'wpbc_setup_wizard_duration_list_limit', sprintf( __( 'Add no more than %d duration choices.', 'booking' ), self::MAX_TIMES_PER_LIST ) ); |
| 210 | } |
| 211 | |
| 212 | $normalized_durations = array(); |
| 213 | foreach ( $raw_duration_list as $raw_duration ) { |
| 214 | $duration_minutes = $this->duration_to_minutes( $raw_duration ); |
| 215 | if ( null === $duration_minutes || 0 !== $duration_minutes % self::TIME_INCREMENT ) { |
| 216 | return new WP_Error( 'wpbc_setup_wizard_duration_value_invalid', __( 'Use a valid positive five-minute duration for every choice.', 'booking' ) ); |
| 217 | } |
| 218 | |
| 219 | $normalized_duration = $this->minutes_to_duration( $duration_minutes ); |
| 220 | if ( in_array( $normalized_duration, $normalized_durations, true ) ) { |
| 221 | return new WP_Error( 'wpbc_setup_wizard_duration_value_duplicate', __( 'The same duration cannot appear twice in the list.', 'booking' ) ); |
| 222 | } |
| 223 | $normalized_durations[] = $normalized_duration; |
| 224 | } |
| 225 | |
| 226 | if ( $require_complete && empty( $normalized_durations ) ) { |
| 227 | return new WP_Error( 'wpbc_setup_wizard_duration_list_required', __( 'Add at least one duration.', 'booking' ) ); |
| 228 | } |
| 229 | |
| 230 | return $normalized_durations; |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * Convert one exact duration value to minutes. |
| 235 | * |
| 236 | * @param mixed $raw_duration Untrusted `HH:MM` duration. |
| 237 | * |
| 238 | * @return int|null Positive duration up to 24 hours, or null when invalid. |
| 239 | */ |
| 240 | private function duration_to_minutes( $raw_duration ) { |
| 241 | if ( ! is_scalar( $raw_duration ) || ! preg_match( '/^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/', (string) $raw_duration ) ) { |
| 242 | return null; |
| 243 | } |
| 244 | |
| 245 | list( $hour, $minute ) = array_map( 'intval', explode( ':', (string) $raw_duration ) ); |
| 246 | $duration_minutes = ( $hour * 60 ) + $minute; |
| 247 | |
| 248 | return 0 < $duration_minutes ? $duration_minutes : null; |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * Convert minutes to the Form Builder `HH:MM` duration contract. |
| 253 | * |
| 254 | * @param int $duration_minutes Positive duration up to 24 hours. |
| 255 | * |
| 256 | * @return string Normalized duration value. |
| 257 | */ |
| 258 | private function minutes_to_duration( $duration_minutes ) { |
| 259 | $duration_minutes = min( 24 * 60, max( self::TIME_INCREMENT, absint( $duration_minutes ) ) ); |
| 260 | |
| 261 | return sprintf( '%02d:%02d', (int) floor( $duration_minutes / 60 ), $duration_minutes % 60 ); |
| 262 | } |
| 263 | } |
| 264 |