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-fixed-time-slots / class-wpbc-setup-wizard-fixed-time-slots.php

class-wpbc-setup-wizard-fixed-time-slots.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-fixed-time-slots/class-wpbc-setup-wizard-fixed-time-slots.php

334 lines 12.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Fixed Time Slots 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, generate, and present ordered fixed time-slot ranges.
14 *
15 * Each slot is an explicit same-day start/end pair. This preserves the order
16 * selected by the customer and maps directly to Form Builder's `rangetime`
17 * option contract without coupling this reusable editor to Form Builder.
18 */
19 final class WPBC_Setup_Wizard_Fixed_Time_Slots {
20
21 const MAX_TIME_SLOTS = 288;
22 const MAX_PAYLOAD_LENGTH = 20000;
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 slots aligned with the preferred two-column template.
41 *
42 * @return array{time_slots:array<int,array{start_time:string,end_time:string}>} Starter slots.
43 */
44 public function get_initial_fixed_time_slots() {
45 return array(
46 'time_slots' => $this->generate_time_slots( '10:00', '14:30', 30, 30 ),
47 );
48 }
49
50 /**
51 * Build presentation data for the dedicated fixed-slot editor.
52 *
53 * @param array<string,mixed> $step_values Validated values for this module.
54 * @param string $time_format Validated WordPress time format.
55 *
56 * @return array<string,mixed> Data-only template context.
57 */
58 public function get_context( array $step_values, $time_format ) {
59 $fixed_time_slots = isset( $step_values['fixed_time_slots'] ) && is_array( $step_values['fixed_time_slots'] )
60 ? $step_values['fixed_time_slots']
61 : $this->get_initial_fixed_time_slots();
62
63 return array(
64 'values' => $step_values,
65 'draft_field_name' => 'fixed_time_slots',
66 'draft_values' => $fixed_time_slots,
67 'editor_title' => __( 'Set fixed time slots', 'booking' ),
68 'editor_description' => __( 'Choose the fixed time slot options customers can use when booking.', 'booking' ),
69 'list_title' => __( 'Fixed time slots', 'booking' ),
70 'list_description' => __( 'Choices shown in the booking form.', 'booking' ),
71 'list_label' => __( 'Fixed time slots', 'booking' ),
72 'time_options' => $this->clock_times->get_time_options( $time_format ),
73 'end_time_options' => $this->get_end_time_options( $time_format ),
74 'interval_options' => $this->clock_times->get_interval_options(),
75 'duration_options' => $this->get_duration_options(),
76 'generator_from' => '10:00',
77 'generator_to' => '14:30',
78 'generator_interval' => 30,
79 'generator_duration' => 30,
80 'max_time_slots' => self::MAX_TIME_SLOTS,
81 'time_increment' => self::TIME_INCREMENT,
82 );
83 }
84
85 /**
86 * Validate one complete fixed-slot DTO.
87 *
88 * @param mixed $raw_fixed_time_slots JSON transport string or stored array.
89 * @param bool $require_complete Whether at least one slot is required.
90 *
91 * @return array{time_slots:array<int,array{start_time:string,end_time:string}>}|WP_Error Normalized slots or an error.
92 */
93 public function validate_fixed_time_slots( $raw_fixed_time_slots, $require_complete ) {
94 if ( is_string( $raw_fixed_time_slots ) ) {
95 if ( strlen( $raw_fixed_time_slots ) > self::MAX_PAYLOAD_LENGTH ) {
96 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slots_too_large', __( 'The fixed time slots draft is too large.', 'booking' ) );
97 }
98
99 $raw_fixed_time_slots = json_decode( $raw_fixed_time_slots, true );
100 if ( JSON_ERROR_NONE !== json_last_error() ) {
101 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slots_invalid_json', __( 'The fixed time slots draft is invalid.', 'booking' ) );
102 }
103 }
104
105 if ( ! is_array( $raw_fixed_time_slots ) ) {
106 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slots_invalid', __( 'Enter valid fixed time slots.', 'booking' ) );
107 }
108
109 if ( array_diff( array_keys( $raw_fixed_time_slots ), array( 'time_slots' ) ) ) {
110 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slots_unknown_field', __( 'The fixed time slots draft contains an unsupported field.', 'booking' ) );
111 }
112
113 $raw_slots = isset( $raw_fixed_time_slots['time_slots'] ) ? $raw_fixed_time_slots['time_slots'] : array();
114 if ( ! is_array( $raw_slots ) ) {
115 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slots_list_invalid', __( 'The fixed time slot list must contain valid ranges.', 'booking' ) );
116 }
117
118 $raw_slots = array_values( $raw_slots );
119 if ( count( $raw_slots ) > self::MAX_TIME_SLOTS ) {
120 /* translators: %d: Maximum number of fixed time slots. */
121 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slots_limit', sprintf( __( 'Add no more than %d fixed time slots.', 'booking' ), self::MAX_TIME_SLOTS ) );
122 }
123
124 $normalized_slots = array();
125 $seen_slots = array();
126 foreach ( $raw_slots as $raw_slot ) {
127 if ( ! is_array( $raw_slot ) || array_diff( array_keys( $raw_slot ), array( 'start_time', 'end_time' ) ) ) {
128 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slot_invalid', __( 'Every fixed time slot must contain only a start time and an end time.', 'booking' ) );
129 }
130
131 $start_time = isset( $raw_slot['start_time'] ) && is_scalar( $raw_slot['start_time'] ) ? (string) $raw_slot['start_time'] : '';
132 $end_time = isset( $raw_slot['end_time'] ) && is_scalar( $raw_slot['end_time'] ) ? (string) $raw_slot['end_time'] : '';
133 $start_minutes = $this->clock_times->time_to_minutes( $start_time );
134 $end_minutes = $this->end_time_to_minutes( $end_time );
135
136 if (
137 null === $start_minutes
138 || null === $end_minutes
139 || 0 !== $start_minutes % self::TIME_INCREMENT
140 || 0 !== $end_minutes % self::TIME_INCREMENT
141 || $end_minutes <= $start_minutes
142 ) {
143 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slot_range_invalid', __( 'Every fixed time slot must end after it starts and use five-minute values.', 'booking' ) );
144 }
145
146 $normalized_start = $this->minutes_to_time( $start_minutes );
147 $normalized_end = $this->minutes_to_time( $end_minutes );
148 $slot_key = $normalized_start . ' - ' . $normalized_end;
149 if ( isset( $seen_slots[ $slot_key ] ) ) {
150 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slot_duplicate', __( 'The same fixed time slot cannot appear twice.', 'booking' ) );
151 }
152
153 $seen_slots[ $slot_key ] = true;
154 $normalized_slots[] = array(
155 'start_time' => $normalized_start,
156 'end_time' => $normalized_end,
157 );
158 }
159
160 if ( $require_complete && empty( $normalized_slots ) ) {
161 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slots_required', __( 'Add at least one fixed time slot.', 'booking' ) );
162 }
163
164 return array( 'time_slots' => $normalized_slots );
165 }
166
167 /**
168 * Generate fixed ranges with independent spacing and duration controls.
169 *
170 * The `to` value is the last start time, matching the editor labels and
171 * allowing overlapping slots when duration is greater than spacing.
172 *
173 * @param string $from_time First start time in `HH:MM` format.
174 * @param string $to_time Last start time in `HH:MM` format.
175 * @param int $spacing_minutes Positive distance between slot starts.
176 * @param int $duration_minutes Positive length of each slot.
177 *
178 * @return array<int,array{start_time:string,end_time:string}> Generated slots, or an empty array for invalid input.
179 */
180 public function generate_time_slots( $from_time, $to_time, $spacing_minutes, $duration_minutes ) {
181 $from_minutes = $this->clock_times->time_to_minutes( $from_time );
182 $to_minutes = $this->clock_times->time_to_minutes( $to_time );
183 $spacing_minutes = absint( $spacing_minutes );
184 $duration_minutes = absint( $duration_minutes );
185
186 if (
187 null === $from_minutes
188 || null === $to_minutes
189 || $to_minutes < $from_minutes
190 || self::TIME_INCREMENT > $spacing_minutes
191 || self::TIME_INCREMENT > $duration_minutes
192 || 0 !== $spacing_minutes % self::TIME_INCREMENT
193 || 0 !== $duration_minutes % self::TIME_INCREMENT
194 || $to_minutes + $duration_minutes > 24 * 60
195 ) {
196 return array();
197 }
198
199 $time_slots = array();
200 for ( $start_minutes = $from_minutes; $start_minutes <= $to_minutes && count( $time_slots ) < self::MAX_TIME_SLOTS; $start_minutes += $spacing_minutes ) {
201 $end_minutes = $start_minutes + $duration_minutes;
202 if ( 24 * 60 < $end_minutes ) {
203 break;
204 }
205 $time_slots[] = array(
206 'start_time' => $this->minutes_to_time( $start_minutes ),
207 'end_time' => $this->minutes_to_time( $end_minutes ),
208 );
209 }
210
211 return $time_slots;
212 }
213
214 /**
215 * Convert legacy parallel Start/End lists to explicit fixed-slot pairs.
216 *
217 * @param mixed $start_times Legacy ordered start-time list.
218 * @param mixed $end_times Legacy ordered end-time list.
219 *
220 * @return array{time_slots:array<int,array{start_time:string,end_time:string}>}|WP_Error Normalized pairs or an error.
221 */
222 public function create_slots_from_time_lists( $start_times, $end_times ) {
223 if ( ! is_array( $start_times ) || ! is_array( $end_times ) || count( $start_times ) !== count( $end_times ) ) {
224 return new WP_Error( 'wpbc_setup_wizard_fixed_time_slots_legacy_lists_invalid', __( 'The previous time choices cannot be converted to fixed time slots.', 'booking' ) );
225 }
226
227 $time_slots = array();
228 foreach ( array_values( $start_times ) as $slot_index => $start_time ) {
229 $time_slots[] = array(
230 'start_time' => $start_time,
231 'end_time' => $end_times[ $slot_index ],
232 );
233 }
234
235 return $this->validate_fixed_time_slots( array( 'time_slots' => $time_slots ), true );
236 }
237
238 /**
239 * Return the generator duration choices.
240 *
241 * @return array<int,array{value:int,label:string}> Ordered duration choices.
242 */
243 public function get_duration_options() {
244 $durations = array( 5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240 );
245 $options = array();
246
247 foreach ( $durations as $duration_minutes ) {
248 /* translators: %d: Slot duration in minutes. */
249 $label = sprintf( _n( '%d minute', '%d minutes', $duration_minutes, 'booking' ), $duration_minutes );
250 $options[] = array(
251 'value' => $duration_minutes,
252 'label' => $label,
253 );
254 }
255
256 return $options;
257 }
258
259 /**
260 * Format one validated clock value for a fixed-slot label.
261 *
262 * @param string $time_value Validated `HH:MM` time, including `24:00` for an end value.
263 * @param string $time_format Validated WordPress time format.
264 *
265 * @return string Localized display label.
266 */
267 public function format_time_label( $time_value, $time_format ) {
268 $time_format = in_array( $time_format, array( 'g:i a', 'g:i A', 'H:i' ), true ) ? $time_format : 'H:i';
269 if ( '24:00' === $time_value ) {
270 if ( 'H:i' === $time_format ) {
271 return '24:00';
272 }
273
274 return sprintf(
275 /* translators: %s: Midnight formatted in the site's time format. */
276 __( '%s (next day)', 'booking' ),
277 wp_date( $time_format, 0, new DateTimeZone( 'UTC' ) )
278 );
279 }
280
281 $time_minutes = $this->clock_times->time_to_minutes( $time_value );
282 if ( null === $time_minutes ) {
283 return '';
284 }
285
286 return wp_date( $time_format, $time_minutes * MINUTE_IN_SECONDS, new DateTimeZone( 'UTC' ) );
287 }
288
289 /**
290 * Return all supported fixed-slot end values, including day-end `24:00`.
291 *
292 * @param string $time_format Validated WordPress time format.
293 *
294 * @return array<int,array{value:string,label:string}> Ordered end-time options.
295 */
296 private function get_end_time_options( $time_format ) {
297 $options = $this->clock_times->get_time_options( $time_format );
298 $options[] = array(
299 'value' => '24:00',
300 'label' => $this->format_time_label( '24:00', $time_format ),
301 );
302
303 return $options;
304 }
305
306 /**
307 * Convert a fixed-slot end value to minutes after day start.
308 *
309 * @param mixed $raw_time Untrusted end-time value.
310 *
311 * @return int|null Minutes from day start, including 1440, or null.
312 */
313 private function end_time_to_minutes( $raw_time ) {
314 if ( '24:00' === $raw_time ) {
315 return 24 * 60;
316 }
317
318 return $this->clock_times->time_to_minutes( $raw_time );
319 }
320
321 /**
322 * Convert bounded minutes to a normalized clock value.
323 *
324 * @param int $minutes Minutes from day start, from zero through 1440.
325 *
326 * @return string Normalized clock value.
327 */
328 private function minutes_to_time( $minutes ) {
329 $minutes = min( 24 * 60, max( 0, (int) $minutes ) );
330
331 return sprintf( '%02d:%02d', (int) floor( $minutes / 60 ), $minutes % 60 );
332 }
333 }
334