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-appointment-services / appointment_services__booking.php

appointment_services__booking.php in Booking Calendar 11.9, at includes/page-appointment-services/appointment_services__booking.php

1,208 lines 54.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /** Appointment Service integration with the existing booking form and save pipeline. @package Booking Calendar */
3 if ( ! defined( 'ABSPATH' ) ) {
4 exit;
5 }
6
7 /**
8 * Determine whether the resource-specific frontend Service adapter is enabled.
9 *
10 * This compatibility adapter is disabled by default. Extensions or a future
11 * explicit setting may enable it through the existing filter. The dedicated
12 * [booking_appointment] controller remains the primary public workflow.
13 *
14 * @return bool True when the Service selector integration may run.
15 */
16 function wpbc_appointment_services_frontend_is_enabled() {
17 $enabled = false;
18
19 return (bool) apply_filters( 'wpbc_appointment_services_frontend_is_enabled', $enabled );
20 }
21
22 /**
23 * Manage the request-local Service context used while rendering one Appointment form.
24 *
25 * The Appointment controller sets this context immediately before the native
26 * booking form is rendered and clears it immediately afterwards. Keeping the
27 * context server-side prevents a normal booking form from inventing a Service.
28 *
29 * @param string $operation Context operation: get, set, or clear.
30 * @param array<string,mixed> $service Validated Service context for a set operation.
31 *
32 * @return array{service_id:int,resource_id:int,title:string}|array{} Current normalized context, or an empty array.
33 */
34 function wpbc_appointment_services_form_hint_context( $operation = 'get', $service = array() ) {
35 static $service_context = array();
36
37 if ( 'clear' === $operation ) {
38 $service_context = array();
39 } elseif ( 'set' === $operation ) {
40 $service_context = array(
41 'service_id' => absint( isset( $service['service_id'] ) ? $service['service_id'] : 0 ),
42 'resource_id' => absint( isset( $service['resource_id'] ) ? $service['resource_id'] : 0 ),
43 'title' => sanitize_text_field( isset( $service['title'] ) ? $service['title'] : '' ),
44 );
45 }
46
47 return $service_context;
48 }
49
50 /**
51 * Replace the Service Hint shortcode in a rendered booking form.
52 *
53 * Appointment forms receive a visible title plus a form field so the value can
54 * participate in the standard booking-data pipeline. All other forms receive
55 * an empty string. Repeated hints display the same title but only the first one
56 * emits the field that is submitted with the booking.
57 *
58 * @param string $form_html Rendered booking form markup.
59 * @param int $resource_id Booking resource used by the form.
60 * @param string $form_slug Booking Form slug used by the renderer.
61 *
62 * @return string Form markup with Service Hint shortcodes replaced.
63 */
64 function wpbc_appointment_services_replace_service_title_hint( $form_html, $resource_id, $form_slug ) {
65 $shortcode = '[service_title_hint]';
66 if ( false === strpos( $form_html, $shortcode ) ) {
67 return $form_html;
68 }
69
70 $service_context = wpbc_appointment_services_form_hint_context();
71 $resource_id = absint( $resource_id );
72 $service_title = isset( $service_context['title'] ) ? sanitize_text_field( $service_context['title'] ) : '';
73 if ( '' === $service_title || $resource_id !== absint( isset( $service_context['resource_id'] ) ? $service_context['resource_id'] : 0 ) ) {
74 return str_replace( $shortcode, '', $form_html );
75 }
76
77 $hint_id = 'service_title_hint_tip' . $resource_id;
78 $input_name = 'service_title_hint' . $resource_id;
79 $first_html = '<span class="wpbc_field_hint wpbc_appointment_service_hint" id="' . esc_attr( $hint_id ) . '">' . esc_html( $service_title ) . '</span>'
80 . '<input class="wpbc_field_hint wpbc_appointment_service_hint" id="' . esc_attr( $input_name ) . '" name="' . esc_attr( $input_name ) . '" value="' . esc_attr( $service_title ) . '" style="display:none;" type="text" />';
81
82 $form_html = preg_replace_callback(
83 '/\[service_title_hint\]/',
84 static function () use ( $first_html ) {
85 return $first_html;
86 },
87 $form_html,
88 1
89 );
90
91 $repeated_html = '<span class="wpbc_field_hint wpbc_appointment_service_hint service_title_hint_tip' . $resource_id . '">' . esc_html( $service_title ) . '</span>';
92
93 return str_replace( $shortcode, $repeated_html, $form_html );
94 }
95 add_filter( 'wpbc_replace_shortcodes_in_booking_form', 'wpbc_appointment_services_replace_service_title_hint', 30, 3 );
96
97 /**
98 * Synchronize Service Hint values with a repository-validated Appointment Service.
99 *
100 * Submitted Service Hint fields are deliberately removed first because browser
101 * form values are untrusted. A value is restored only when the core booking
102 * pipeline supplies the Service record that passed the signed Appointment
103 * context and resource-assignment checks.
104 *
105 * @param array<string,mixed> $structured_booking_data Values-only booking data.
106 * @param array<string,mixed> $all_booking_data Complete parsed booking fields.
107 * @param array<string,mixed> $appointment_service Validated Appointment Service, or an empty array.
108 * @param int $resource_id Submitted booking resource ID.
109 *
110 * @return array{structured_booking_data:array<string,mixed>,all_booking_data:array<string,mixed>} Trusted booking data.
111 */
112 function wpbc_appointment_services_sync_service_hint_booking_data( $structured_booking_data, $all_booking_data, $appointment_service, $resource_id ) {
113 unset( $structured_booking_data['service_title_hint'], $all_booking_data['service_title_hint'] );
114
115 $service_title = sanitize_text_field( isset( $appointment_service['title'] ) ? $appointment_service['title'] : '' );
116 if ( '' === $service_title ) {
117 return array(
118 'structured_booking_data' => $structured_booking_data,
119 'all_booking_data' => $all_booking_data,
120 );
121 }
122
123 $resource_id = absint( $resource_id );
124 $structured_booking_data['service_title_hint'] = $service_title;
125 $all_booking_data['service_title_hint'] = array(
126 'type' => 'text',
127 'original_name' => 'service_title_hint' . $resource_id,
128 'name' => 'service_title_hint',
129 'value' => $service_title,
130 );
131
132 return array(
133 'structured_booking_data' => $structured_booking_data,
134 'all_booking_data' => $all_booking_data,
135 );
136 }
137
138 /**
139 * Insert compatible Services before an existing resource-specific booking form.
140 *
141 * @param string $form_html Existing booking form markup.
142 * @param mixed $form_settings Existing form settings supplied by the filter.
143 * @param int $resource_id Booking resource acting as the Provider.
144 * @param string $custom_form Requested custom form name.
145 *
146 * @return string Filtered booking form markup.
147 */
148 function wpbc_appointment_services_add_frontend_selector( $form_html, $form_settings, $resource_id, $custom_form ) {
149 if ( ! wpbc_appointment_services_frontend_is_enabled() ) {
150 return $form_html;
151 }
152 $repository = wpbc_appointment_services_repository();
153 $services = $repository->list_active_for_resource( $resource_id );
154 if ( empty( $services ) ) {
155 return $form_html;
156 }
157 $select_id = 'wpbc_appointment_service_' . absint( $resource_id );
158 $html = '<div class="wpbc_appointment_service_selector" data-resource-id="' . absint( $resource_id ) . '">';
159 $html .= '<label for="' . esc_attr( $select_id ) . '">' . esc_html__( 'Service', 'booking' ) . '</label>';
160 $html .= '<select id="' . esc_attr( $select_id ) . '" class="wpbc_appointment_service_select" required>';
161 if ( count( $services ) > 1 ) {
162 $html .= '<option value="">' . esc_html__( 'Select a Service', 'booking' ) . '</option>';
163 }
164 foreach ( $services as $service ) {
165 $details = sprintf( _n( '%d minute', '%d minutes', absint( $service['duration_minutes'] ), 'booking' ), absint( $service['duration_minutes'] ) );
166 $context_token = function_exists( 'wpbc_booking_appointment_encode_submission_context' )
167 ? wpbc_booking_appointment_encode_submission_context( array(), $service['service_id'], $resource_id )
168 : '';
169 $html .= '<option value="' . absint( $service['service_id'] ) . '" data-duration="' . absint( $service['duration_minutes'] ) . '" data-appointment-context-token="' . esc_attr( $context_token ) . '">' . esc_html( $service['title'] . ' — ' . $details ) . '</option>';
170 }
171 $html .= '</select><p class="wpbc_appointment_service_summary" aria-live="polite"></p></div>';
172
173 return $html . $form_html;
174 }
175
176 add_filter( 'wpbc_booking_form__html__before_wrapper', 'wpbc_appointment_services_add_frontend_selector', 20, 4 );
177
178 /**
179 * Enqueue the resource-specific Service selector booking adapter.
180 *
181 * @param string $where_to_load Booking Calendar asset context.
182 *
183 * @return void
184 */
185 function wpbc_appointment_services_enqueue_frontend_js( $where_to_load ) {
186 if ( ! in_array( $where_to_load, array(
187 'client',
188 'both',
189 ), true ) || ! wpbc_appointment_services_frontend_is_enabled() ) {
190 return;
191 }
192 $base = trailingslashit( plugins_url( '', __FILE__ ) );
193 wp_enqueue_script( 'wpbc-appointment-services-client', $base . '_out/appointment_services_client.js', array(
194 'jquery',
195 'wpbc_capacity',
196 ), WP_BK_VERSION_NUM, array( 'in_footer' => WPBC_JS_IN_FOOTER ) );
197 }
198
199 add_action( 'wpbc_enqueue_js_files', 'wpbc_appointment_services_enqueue_frontend_js', 70 );
200
201 /**
202 * Enqueue Service selector styling for public forms and admin previews.
203 *
204 * @param string $where_to_load Booking Calendar asset context.
205 *
206 * @return void
207 */
208 function wpbc_appointment_services_enqueue_frontend_css( $where_to_load ) {
209 $is_admin_preview = 'admin' === $where_to_load && function_exists( 'wpbc_is_admin_page_with_frontend_booking_preview' ) && wpbc_is_admin_page_with_frontend_booking_preview();
210 if (
211 ( ! in_array( $where_to_load, array(
212 'client',
213 'both',
214 ), true ) && ! $is_admin_preview ) || ! wpbc_appointment_services_frontend_is_enabled() ) {
215 return;
216 }
217 wp_enqueue_style( 'wpbc-appointment-services-client', trailingslashit( plugins_url( '', __FILE__ ) ) . '_out/appointment_services_client.css', array( 'wpbc-all-client' ), WP_BK_VERSION_NUM );
218 }
219
220 add_action( 'wpbc_enqueue_css_files', 'wpbc_appointment_services_enqueue_frontend_css', 70 );
221
222 /**
223 * Persist an immutable Service snapshot after the core booking save succeeds.
224 *
225 * The core save path invokes this handler before releasing its availability
226 * guard, and the released public hook invokes it again for compatibility. A
227 * request-local content key makes that duplicate call harmless while still
228 * allowing a later save with changed Service data in the same request.
229 *
230 * @param int $booking_id Saved booking ID.
231 * @param array $create_params Normalized booking creation parameters.
232 * @param string $where_to_save_booking Booking save context supplied by core.
233 *
234 * @return void
235 */
236 function wpbc_appointment_services_after_booking_save( $booking_id, $create_params, $where_to_save_booking ) {
237 static $persisted_snapshot_keys = array();
238
239 if ( empty( $create_params['appointment_service'] ) || empty( $create_params['resource_id'] ) ) {
240 return;
241 }
242
243 $booking_id = absint( $booking_id );
244 $snapshot_key = $booking_id . ':' . md5(
245 wp_json_encode(
246 array(
247 'resource_id' => absint( $create_params['resource_id'] ),
248 'appointment_service' => $create_params['appointment_service'],
249 )
250 )
251 );
252 if ( isset( $persisted_snapshot_keys[ $snapshot_key ] ) ) {
253 return;
254 }
255
256 $saved = wpbc_appointment_services_repository()->save_appointment_snapshot( $booking_id, $create_params['resource_id'], $create_params['appointment_service'] );
257 if ( $saved ) {
258 $persisted_snapshot_keys[ $snapshot_key ] = true;
259 } else {
260 do_action( 'wpbc_appointment_snapshot_save_failed', $booking_id, $create_params, $where_to_save_booking );
261 }
262 }
263
264 add_action( 'wpbc_booking_after_save', 'wpbc_appointment_services_after_booking_save', 10, 3 );
265
266 /**
267 * Remove Appointment snapshots when their core bookings are permanently deleted.
268 *
269 * @param int|int[]|string $booking_ids Booking ID, array, or comma-separated IDs.
270 *
271 * @return void
272 */
273 function wpbc_appointment_services_delete_booking_snapshots( $booking_ids ) {
274 global $wpdb;
275 if ( ! wpbc_appointment_services_tables_exist() ) {
276 return;
277 }
278 $ids = is_array( $booking_ids ) ? $booking_ids : explode( ',', (string) $booking_ids );
279 $ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
280 if ( empty( $ids ) ) {
281 return;
282 }
283 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
284 $sql = 'DELETE FROM ' . wpbc_appointment_services_table_name( 'appointment_details' ) . ' WHERE booking_id IN (' . $placeholders . ')';
285 $wpdb->query( $wpdb->prepare( $sql, $ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
286 }
287
288 add_action( 'wpbc_booking_action__delete', 'wpbc_appointment_services_delete_booking_snapshots', 10, 1 );
289 add_action( 'wpbc_booking_delete', 'wpbc_appointment_services_delete_booking_snapshots', 10, 1 );
290
291 /**
292 * Remove Service assignments when booking resources are permanently deleted.
293 *
294 * @param int|int[]|string $resource_ids Resource ID, array, or comma-separated IDs.
295 *
296 * @return void
297 */
298 function wpbc_appointment_services_delete_resource_assignments( $resource_ids ) {
299 global $wpdb;
300 if ( ! wpbc_appointment_services_tables_exist() ) {
301 return;
302 }
303 $ids = is_array( $resource_ids ) ? $resource_ids : explode( ',', (string) $resource_ids );
304 $ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
305 if ( empty( $ids ) ) {
306 return;
307 }
308 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
309 $sql = 'DELETE FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE resource_id IN (' . $placeholders . ')';
310 $wpdb->query( $wpdb->prepare( $sql, $ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
311 }
312
313 add_action( 'wpbc_deleted_booking_resources', 'wpbc_appointment_services_delete_resource_assignments', 10, 1 );
314
315 /**
316 * Return one immutable Appointment snapshot with request-local caching.
317 *
318 * @param int $booking_id Core booking ID.
319 *
320 * @return array<string,mixed>|false Snapshot row or false.
321 */
322 function wpbc_appointment_services_get_cached_snapshot( $booking_id ) {
323 static $snapshots = array();
324
325 $booking_id = absint( $booking_id );
326 if ( ! array_key_exists( $booking_id, $snapshots ) ) {
327 $snapshots[ $booking_id ] = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
328 }
329
330 return $snapshots[ $booking_id ];
331 }
332
333 /**
334 * Extract an exact Appointment interval from a core booking record.
335 *
336 * Listing records store date strings while Timeline records contain date
337 * objects. Both retain core's boundary seconds, which are normalized here.
338 *
339 * @param mixed $booking Core listing or Timeline booking record.
340 *
341 * @return array{0:int,1:int}|false Exact start/end timestamps or false.
342 */
343 function wpbc_appointment_services_get_booking_exact_interval( $booking ) {
344 if ( ! is_object( $booking ) || empty( $booking->dates ) ) {
345 return false;
346 }
347
348 $dates = array();
349 foreach ( (array) $booking->dates as $date_value ) {
350 if ( is_object( $date_value ) && isset( $date_value->booking_date ) ) {
351 $date_value = $date_value->booking_date;
352 } elseif ( is_array( $date_value ) && isset( $date_value['booking_date'] ) ) {
353 $date_value = $date_value['booking_date'];
354 }
355 if ( is_string( $date_value ) && preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date_value ) ) {
356 $dates[] = $date_value;
357 }
358 }
359 if ( empty( $dates ) ) {
360 return false;
361 }
362
363 sort( $dates );
364 return wpbc_appointment_services_normalize_stored_interval( reset( $dates ), end( $dates ) );
365 }
366
367 /**
368 * Format an exact interval for compact administrator-facing details.
369 *
370 * @param int $start_timestamp Exact start timestamp.
371 * @param int $end_timestamp Exact end timestamp.
372 *
373 * @return string Localized interval label.
374 */
375 function wpbc_appointment_services_format_interval( $start_timestamp, $end_timestamp ) {
376 $date_format = get_bk_option( 'booking_date_format' );
377 $time_format = get_bk_option( 'booking_time_format' );
378 $date_format = $date_format ? $date_format : 'm / d / Y, D';
379 $time_format = $time_format ? $time_format : 'h:i a';
380 $same_day = wpbc_datetime__no_wp_timezone( 'Y-m-d', $start_timestamp ) === wpbc_datetime__no_wp_timezone( 'Y-m-d', $end_timestamp );
381 $start = wpbc_datetime__no_wp_timezone( $same_day ? $time_format : $date_format . ' ' . $time_format, $start_timestamp );
382 $end = wpbc_datetime__no_wp_timezone( $same_day ? $time_format : $date_format . ' ' . $time_format, $end_timestamp );
383
384 return $start . ' - ' . $end;
385 }
386
387 /**
388 * Build administrator-facing time and buffer values from immutable data.
389 *
390 * @param array<string,mixed> $snapshot Immutable Appointment snapshot.
391 * @param mixed $booking Core booking record.
392 *
393 * @return array<string,mixed> Exact and formatted Appointment intervals.
394 */
395 function wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) {
396 $interval = wpbc_appointment_services_get_booking_exact_interval( $booking );
397 if ( false === $interval ) {
398 return array();
399 }
400
401 $buffer_before = absint( $snapshot['buffer_before_minutes'] );
402 $buffer_after = absint( $snapshot['buffer_after_minutes'] );
403 $reserved_start = $interval[0] - ( $buffer_before * MINUTE_IN_SECONDS );
404 $reserved_end = $interval[1] + ( $buffer_after * MINUTE_IN_SECONDS );
405
406 return array(
407 'appointment_start_timestamp' => $interval[0],
408 'appointment_end_timestamp' => $interval[1],
409 'appointment_reserved_start' => $reserved_start,
410 'appointment_reserved_end' => $reserved_end,
411 'appointment_time_label' => wpbc_appointment_services_format_interval( $interval[0], $interval[1] ),
412 'appointment_reserved_time_label' => wpbc_appointment_services_format_interval( $reserved_start, $reserved_end ),
413 );
414 }
415
416 /**
417 * Add Service identity to the existing AJAX booking-listing record.
418 *
419 * @param array<string,mixed> $fields Parsed listing fields.
420 * @param int $booking_id Booking ID.
421 * @param mixed $booking Original booking record.
422 *
423 * @return array<string,mixed> Filtered listing fields.
424 */
425 function wpbc_appointment_services_add_listing_fields( $fields, $booking_id, $booking ) {
426 $snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
427 if ( $snapshot ) {
428 $pricing_available = wpbc_appointment_services_is_pricing_available();
429 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
430 $fields['appointment_service_id'] = absint( $snapshot['service_id'] );
431 $fields['appointment_service_title'] = sanitize_text_field( $snapshot['service_title'] );
432 $fields['appointment_duration_minutes'] = absint( $snapshot['duration_minutes'] );
433 $fields['appointment_provider_id'] = absint( $snapshot['resource_id'] );
434 $fields['appointment_provider_title'] = ! empty( $metadata['provider_title'] )
435 ? sanitize_text_field( $metadata['provider_title'] )
436 : wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
437 $fields['appointment_buffer_before_minutes'] = absint( $snapshot['buffer_before_minutes'] );
438 $fields['appointment_buffer_after_minutes'] = absint( $snapshot['buffer_after_minutes'] );
439 $fields['appointment_service_cost'] = $pricing_available ? number_format( (float) $snapshot['base_cost'], 2, '.', '' ) : '';
440 $fields['appointment_service_cost_formatted'] = function_exists( 'wpbc_booking_appointment_format_service_cost_text' )
441 ? wpbc_booking_appointment_format_service_cost_text( $snapshot['base_cost'], $snapshot['resource_id'] )
442 : '';
443 $fields = array_merge( $fields, wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) );
444 }
445
446 return $fields;
447 }
448
449 add_filter( 'wpbc_booking_listing_parsed_fields', 'wpbc_appointment_services_add_listing_fields', 10, 3 );
450
451 /**
452 * Determine whether Appointment-specific Booking Listing controls are active.
453 *
454 * Appointment data remains available in every presentation mode, but its
455 * Service filter belongs only to the Appointment administration workflow.
456 *
457 * @return bool True when Appointment mode is active.
458 */
459 function wpbc_appointment_services_is_appointment_listing_mode() {
460 return function_exists( 'wpbc_booking_modes_get_selected_mode_id' )
461 && 'appointment' === wpbc_booking_modes_get_selected_mode_id();
462 }
463
464 /**
465 * Register the Service filter in the shared Booking Listing request contract.
466 *
467 * @param array<string,array<string,mixed>> $request_schema Existing request schema.
468 * @param string $structure_type Requested schema representation.
469 *
470 * @return array<string,array<string,mixed>> Extended request schema.
471 */
472 function wpbc_appointment_services_add_listing_request_rule( $request_schema, $structure_type ) {
473 $request_schema['wh_appointment_service'] = array(
474 'validate' => 'digit_or_csd',
475 'default' => array(),
476 );
477
478 return $request_schema;
479 }
480 add_filter( 'wpbc_booking_listing_request_params_schema', 'wpbc_appointment_services_add_listing_request_rule', 10, 2 );
481
482 /**
483 * Normalize scalar, array, or comma-separated Service filter values.
484 *
485 * The request sanitizer supports both scalar and array `digit_or_csd` values.
486 * This helper flattens those compatible representations into unique positive
487 * Service IDs and can restrict them to an authorized Service catalogue.
488 *
489 * @param mixed $raw_service_ids Sanitized scalar or array value.
490 * @param array<int,mixed>|null $allowed_service_ids Optional owner-visible Service IDs. Pass null to skip authorization filtering.
491 *
492 * @return array<int,int> Unique positive Service IDs.
493 */
494 function wpbc_appointment_services_normalize_listing_service_ids( $raw_service_ids, $allowed_service_ids = null ) {
495 $raw_values = is_array( $raw_service_ids ) ? $raw_service_ids : array( $raw_service_ids );
496 $service_ids = array();
497
498 foreach ( $raw_values as $raw_value ) {
499 $separated_values = is_scalar( $raw_value ) ? explode( ',', (string) $raw_value ) : array();
500 foreach ( $separated_values as $separated_value ) {
501 $service_id = absint( $separated_value );
502 if ( $service_id ) {
503 $service_ids[ $service_id ] = $service_id;
504 }
505 }
506 }
507
508 $service_ids = array_values( $service_ids );
509 if ( null !== $allowed_service_ids ) {
510 $allowed_service_ids = array_values( array_unique( array_filter( array_map( 'absint', $allowed_service_ids ) ) ) );
511 $service_ids = array_values( array_intersect( $service_ids, $allowed_service_ids ) );
512 }
513
514 return $service_ids;
515 }
516
517 /**
518 * Return owner-visible Services available to the Appointment listing filter.
519 *
520 * All statuses are intentionally included because historical Appointments must
521 * remain filterable after their Service is deactivated or archived.
522 *
523 * @return array<int,array<string,mixed>> Owner-visible Service rows.
524 */
525 function wpbc_appointment_services_get_listing_services() {
526 $repository = wpbc_appointment_services_get_data_provider();
527 $services = is_object( $repository ) && method_exists( $repository, 'list_items' )
528 ? $repository->list_items( array( 'status' => 'all' ) )
529 : array();
530
531 return is_wp_error( $services ) || ! is_array( $services ) ? array() : $services;
532 }
533
534 /**
535 * Restrict the shared Booking Listing to one or more snapshotted Appointment Services.
536 *
537 * The existing Booking Resource query remains authoritative for Provider and
538 * MultiUser ownership filtering. This additional EXISTS clause only narrows
539 * those already-authorized bookings by their immutable Appointment snapshot.
540 *
541 * @param array{where:string,args:array<int,mixed>} $query_parts Existing SQL WHERE and arguments.
542 * @param array<string,mixed> $request_params Sanitized request values.
543 * @param array<string,mixed> $params Values merged with defaults.
544 *
545 * @return array{where:string,args:array<int,mixed>} Filtered query parts.
546 */
547 function wpbc_appointment_services_filter_listing_query( $query_parts, $request_params, $params ) {
548 if ( ! wpbc_appointment_services_is_appointment_listing_mode() || ! wpbc_appointment_services_tables_exist() ) {
549 return $query_parts;
550 }
551
552 $listing_services = wpbc_appointment_services_get_listing_services();
553 $allowed_service_ids = wp_list_pluck( $listing_services, 'service_id' );
554 $service_ids = wpbc_appointment_services_normalize_listing_service_ids(
555 isset( $params['wh_appointment_service'] ) ? $params['wh_appointment_service'] : array(),
556 $allowed_service_ids
557 );
558 if ( empty( $service_ids ) || ! isset( $query_parts['where'], $query_parts['args'] ) ) {
559 return $query_parts;
560 }
561
562 $service_placeholders = implode( ', ', array_fill( 0, count( $service_ids ), '%d' ) );
563 $query_parts['where'] .= ' AND EXISTS ( SELECT 1 FROM ' . wpbc_appointment_services_table_name( 'appointment_details' ) . ' appointment_filter WHERE appointment_filter.booking_id = bk.booking_id AND appointment_filter.service_id IN ( ' . $service_placeholders . ' ) )';
564 $query_parts['args'] = array_merge( $query_parts['args'], $service_ids );
565
566 return $query_parts;
567 }
568 add_filter( 'wpbc_booking_listing_sql_query_parts', 'wpbc_appointment_services_filter_listing_query', 10, 3 );
569
570 /**
571 * Render the owner-aware Service selector beside the existing Provider filter.
572 *
573 * @param array<string,mixed> $request_params Sanitized current filter values.
574 * @param array<string,mixed> $defaults Default filter values.
575 *
576 * @return void
577 */
578 function wpbc_appointment_services_render_listing_filter( $request_params, $defaults ) {
579 if ( ! wpbc_appointment_services_is_appointment_listing_mode() || ! wpbc_appointment_services_storage_is_ready() ) {
580 return;
581 }
582
583 $services = wpbc_appointment_services_get_listing_services();
584 $service_options = array();
585 $allowed_service_ids = array();
586 foreach ( $services as $service ) {
587 $service_id = absint( isset( $service['service_id'] ) ? $service['service_id'] : 0 );
588 $service_title = isset( $service['title'] ) ? sanitize_text_field( $service['title'] ) : '';
589 $service_status = isset( $service['status'] ) ? sanitize_key( $service['status'] ) : 'active';
590 if ( ! $service_id || '' === $service_title ) {
591 continue;
592 }
593 if ( 'active' !== $service_status ) {
594 $status_label = 'archived' === $service_status ? __( 'Archived', 'booking' ) : __( 'Inactive', 'booking' );
595 $service_title = sprintf( '%1$s (%2$s)', $service_title, $status_label );
596 }
597 $allowed_service_ids[] = $service_id;
598 $service_options[ $service_id ] = array(
599 'title' => $service_title,
600 'attr' => array( 'title' => $service_title ),
601 );
602 }
603
604 $selected_services = isset( $request_params['wh_appointment_service'] )
605 ? $request_params['wh_appointment_service']
606 : ( isset( $defaults['wh_appointment_service'] ) ? $defaults['wh_appointment_service'] : array() );
607 $selected_services = wpbc_appointment_services_normalize_listing_service_ids( $selected_services, $allowed_service_ids );
608
609 wpbc_ui_chosen_filter_enqueue_assets();
610 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Shared component escapes its complete output.
611 echo wpbc_ui_chosen_filter_get_html(
612 array(
613 'id' => 'wh_appointment_service',
614 'name' => 'wh_appointment_service',
615 'options' => $service_options,
616 'selected_values' => $selected_services,
617 'multiple' => true,
618 'placeholder' => empty( $service_options ) ? __( 'No Services', 'booking' ) : __( 'All Services', 'booking' ),
619 'clear_label' => __( 'Clear Service selection', 'booking' ),
620 'disabled' => empty( $service_options ),
621 'container_class' => 'wpbc_booking_listing__service_filter',
622 'attributes' => array( 'aria-label' => __( 'Filter Appointments by Service', 'booking' ) ),
623 'listing_param' => 'wh_appointment_service',
624 'listing_value_type' => 'integer_array',
625 'empty_request_value' => array(),
626 'clear_selected_values' => array(),
627 )
628 );
629 }
630 add_action( 'wpbc_booking_listing_toolbar_after_resources', 'wpbc_appointment_services_render_listing_filter', 10, 2 );
631
632 /**
633 * Add immutable Appointment timing to a Timeline pipeline tooltip.
634 *
635 * @param string $title Existing plain-text tooltip.
636 * @param int $booking_id Core booking ID.
637 * @param array<int,mixed> $bookings Timeline booking collection.
638 *
639 * @return string Filtered tooltip.
640 */
641 function wpbc_appointment_services_filter_timeline_pipeline_title( $title, $booking_id, $bookings ) {
642 $snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
643 $booking = isset( $bookings[ $booking_id ] ) ? $bookings[ $booking_id ] : null;
644 $details = $snapshot ? wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) : array();
645 if ( empty( $details ) ) {
646 return $title;
647 }
648
649 $title .= "\n" . sprintf( __( 'Service: %s', 'booking' ), sanitize_text_field( $snapshot['service_title'] ) );
650 $service_cost = function_exists( 'wpbc_booking_appointment_format_service_cost' ) ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] ) : '';
651 if ( '' !== $service_cost ) {
652 $title .= "\n" . sprintf( __( 'Service price: %s', 'booking' ), wp_strip_all_tags( $service_cost ) );
653 }
654 $title .= "\n" . sprintf( __( 'Appointment: %s', 'booking' ), $details['appointment_time_label'] );
655 $title .= "\n" . sprintf(
656 __( 'Provider reserved: %1$s (buffers %2$d / %3$d min)', 'booking' ),
657 $details['appointment_reserved_time_label'],
658 absint( $snapshot['buffer_before_minutes'] ),
659 absint( $snapshot['buffer_after_minutes'] )
660 );
661
662 return $title;
663 }
664 add_filter( 'wpbc_timeline_booking_pipeline_title', 'wpbc_appointment_services_filter_timeline_pipeline_title', 10, 3 );
665
666 /**
667 * Add Appointment timing to the administrator Timeline popover.
668 *
669 * @param array<string,string> $popover Existing popover title and content.
670 * @param int $booking_id Core booking ID.
671 * @param array<int,mixed> $bookings Timeline booking collection.
672 * @param bool $is_frontend Whether Timeline is public.
673 *
674 * @return array<string,string> Filtered popover.
675 */
676 function wpbc_appointment_services_filter_timeline_popover( $popover, $booking_id, $bookings, $is_frontend ) {
677 if ( $is_frontend ) {
678 return $popover;
679 }
680
681 $snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
682 $booking = isset( $bookings[ $booking_id ] ) ? $bookings[ $booking_id ] : null;
683 $details = $snapshot ? wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) : array();
684 if ( empty( $details ) ) {
685 return $popover;
686 }
687
688 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
689 $provider_title = ! empty( $metadata['provider_title'] )
690 ? sanitize_text_field( $metadata['provider_title'] )
691 : wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
692 $service_cost = function_exists( 'wpbc_booking_appointment_format_service_cost' ) ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] ) : '';
693 $popover['content'] .= '<div class="wpbc_timeline_appointment_details">'
694 . '<strong>' . esc_html__( 'Appointment', 'booking' ) . '</strong><br>'
695 . esc_html__( 'Service', 'booking' ) . ': ' . esc_html( $snapshot['service_title'] ) . '<br>'
696 . esc_html__( 'Provider', 'booking' ) . ': ' . esc_html( $provider_title ) . '<br>'
697 . ( '' !== $service_cost ? esc_html__( 'Service price', 'booking' ) . ': ' . wp_kses_post( $service_cost ) . '<br>' : '' )
698 . esc_html__( 'Appointment time', 'booking' ) . ': ' . esc_html( $details['appointment_time_label'] ) . '<br>'
699 . esc_html__( 'Buffer before / after', 'booking' ) . ': ' . absint( $snapshot['buffer_before_minutes'] ) . ' / ' . absint( $snapshot['buffer_after_minutes'] ) . ' ' . esc_html__( 'min', 'booking' ) . '<br>'
700 . esc_html__( 'Provider reserved', 'booking' ) . ': ' . esc_html( $details['appointment_reserved_time_label'] )
701 . '</div>';
702
703 return $popover;
704 }
705 add_filter( 'wpbc_timeline_booking_popover', 'wpbc_appointment_services_filter_timeline_popover', 10, 4 );
706
707 /**
708 * Add immutable Appointment values to email and confirmation replacements.
709 *
710 * Available shortcodes are `[service_title]`, `[service_title_hint]`, `[service_duration]`,
711 * `[service_duration_minutes]`, `[provider_title]`, and
712 * `[appointment_summary]`. Non-Appointment bookings retain their existing
713 * replacement collection unchanged.
714 *
715 * @param array<string,mixed> $replace Existing replacement values.
716 * @param int $booking_id Core booking ID.
717 * @param int $bktype Booking resource ID.
718 * @param string $formdata Stored booking form data.
719 *
720 * @return array<string,mixed> Replacement values with Appointment context.
721 */
722 function wpbc_appointment_services_add_replace_params( $replace, $booking_id, $bktype, $formdata ) {
723 $replace['service_title_hint'] = '';
724 $snapshot = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
725 if ( ! $snapshot ) {
726 return $replace;
727 }
728
729 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
730 $service_title = sanitize_text_field( $snapshot['service_title'] );
731 $provider_title = ! empty( $metadata['provider_title'] )
732 ? sanitize_text_field( $metadata['provider_title'] )
733 : wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
734 $duration = function_exists( 'wpbc_booking_appointment_format_duration' )
735 ? wpbc_booking_appointment_format_duration( $snapshot['duration_minutes'] )
736 : sprintf( _n( '%d minute', '%d minutes', absint( $snapshot['duration_minutes'] ), 'booking' ), absint( $snapshot['duration_minutes'] ) );
737 $pricing_available = wpbc_appointment_services_is_pricing_available();
738 $service_cost_digits = $pricing_available ? number_format( (float) $snapshot['base_cost'], 2, '.', '' ) : '';
739 $service_cost = $pricing_available && function_exists( 'wpbc_booking_appointment_format_service_cost' )
740 ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] )
741 : '';
742
743 $replace['service_title'] = $service_title;
744 $replace['service_title_hint'] = $service_title;
745 $replace['service_duration'] = $duration;
746 $replace['service_duration_minutes'] = absint( $snapshot['duration_minutes'] );
747 $replace['service_cost'] = $service_cost;
748 $replace['service_cost_digits_only'] = $service_cost_digits;
749 $replace['provider_title'] = $provider_title;
750 $summary_parts = array( $service_title, $provider_title, $duration );
751 if ( '' !== $service_cost ) {
752 $summary_parts[] = function_exists( 'wpbc_booking_appointment_format_service_cost_text' )
753 ? wpbc_booking_appointment_format_service_cost_text( $snapshot['base_cost'], $snapshot['resource_id'] )
754 : $service_cost_digits;
755 }
756 $replace['appointment_summary'] = implode( ' · ', $summary_parts );
757
758 return $replace;
759 }
760
761 add_filter( 'wpbc_replace_params_for_booking', 'wpbc_appointment_services_add_replace_params', 20, 4 );
762
763 /**
764 * Document Appointment replacement shortcodes in the existing email help UI.
765 *
766 * @param array<int,string> $fields Existing email help rows.
767 * @param array<int,string> $skip_shortcodes Shortcodes hidden by the email type.
768 * @param string $email_example Existing example text.
769 *
770 * @return array<int,string> Help rows including Appointment replacements.
771 */
772 function wpbc_appointment_services_add_email_help_shortcodes( $fields, $skip_shortcodes, $email_example ) {
773 $fields[] = '<hr/>';
774 $fields[] = '<strong>' . esc_html__( 'Appointment details', 'booking' ) . '</strong>';
775 $fields[] = '<code>[service_title_hint]</code> - ' . esc_html__( 'Service Hint value saved with the Appointment; empty for other bookings.', 'booking' );
776 $fields[] = '<code>[service_title]</code> — ' . esc_html__( 'Service title saved with the Appointment.', 'booking' );
777 $fields[] = '<code>[service_duration]</code> — ' . esc_html__( 'Formatted Service duration.', 'booking' );
778 $fields[] = '<code>[service_duration_minutes]</code> — ' . esc_html__( 'Service duration in minutes.', 'booking' );
779 if ( wpbc_appointment_services_is_pricing_available() ) {
780 $fields[] = '<code>[service_cost]</code> — ' . esc_html__( 'Effective Service price with currency.', 'booking' );
781 $fields[] = '<code>[service_cost_digits_only]</code> — ' . esc_html__( 'Effective Service price without currency.', 'booking' );
782 }
783 $fields[] = '<code>[provider_title]</code> — ' . esc_html__( 'Provider title saved with the Appointment.', 'booking' );
784 $fields[] = '<code>[appointment_summary]</code> — ' . esc_html__( 'Service, Provider, and duration in one line.', 'booking' );
785
786 return $fields;
787 }
788
789 add_filter( 'wpbc_email_help_shortcodes', 'wpbc_appointment_services_add_email_help_shortcodes', 20, 3 );
790
791 /**
792 * Document Appointment values in the Payment Description help panel.
793 *
794 * @param array<int,string> $fields Existing payment-description help rows.
795 *
796 * @return array<int,string> Help rows including Appointment replacements.
797 */
798 function wpbc_appointment_services_add_payment_help_shortcodes( $fields ) {
799 $fields[] = '<hr/><strong>' . esc_html__( 'Appointment details', 'booking' ) . '</strong>';
800 $service_cost_shortcode = wpbc_appointment_services_is_pricing_available() ? ', <code>[service_cost]</code>' : '';
801 $fields[] = '<code>[service_title]</code>, <code>[service_title_hint]</code>, <code>[service_duration]</code>' . $service_cost_shortcode . ', <code>[provider_title]</code>, <code>[appointment_summary]</code>';
802
803 return $fields;
804 }
805 add_filter( 'wpbc_payment_help_shortcodes', 'wpbc_appointment_services_add_payment_help_shortcodes', 20, 1 );
806
807 /**
808 * Return the snapshotted Service ID for a booking.
809 *
810 * @param int $booking_id Booking ID.
811 *
812 * @return int Service ID, or zero for a non-Appointment booking.
813 */
814 function wpbc_appointment_services_get_booking_service_id( $booking_id ) {
815 $snapshot = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
816
817 return $snapshot ? absint( $snapshot['service_id'] ) : 0;
818 }
819
820 /**
821 * Prevent moving an Appointment to a Provider who cannot perform its Service.
822 *
823 * Non-Appointment bookings preserve the incoming validation result. Appointment
824 * bookings return WP_Error when the target resource lacks an active assignment.
825 * Core may deliberately bypass this filter through its force-change setting.
826 *
827 * @param true|WP_Error $valid Validation result from earlier callbacks.
828 * @param int $booking_id Booking being moved.
829 * @param int $resource_id Target Provider resource ID.
830 *
831 * @return true|WP_Error Incoming result or a Service/Provider mismatch error.
832 */
833 function wpbc_appointment_services_validate_resource_change( $valid, $booking_id, $resource_id ) {
834 $service_id = wpbc_appointment_services_get_booking_service_id( $booking_id );
835 if ( ! $service_id ) {
836 return $valid;
837 }
838 $service = wpbc_appointment_services_repository()->find_active_for_resource( $service_id, $resource_id );
839
840 return is_wp_error( $service ) ? $service : $valid;
841 }
842
843 add_filter( 'wpbc_booking_validate_resource_change', 'wpbc_appointment_services_validate_resource_change', 10, 3 );
844
845 /**
846 * Keep the Appointment snapshot aligned after a successful resource move.
847 *
848 * @param int $booking_id Moved booking ID.
849 * @param int $resource_id New Provider resource ID.
850 *
851 * @return void
852 */
853 function wpbc_appointment_services_after_resource_change( $booking_id, $resource_id ) {
854 if ( wpbc_appointment_services_get_booking_service_id( $booking_id ) ) {
855 wpbc_appointment_services_repository()->update_snapshot_resource( $booking_id, $resource_id );
856 }
857 }
858
859 add_action( 'wpbc_booking_action__change_booking_resource', 'wpbc_appointment_services_after_resource_change', 10, 2 );
860
861 /**
862 * Resolve the server-authoritative end time for an Appointment Service.
863 *
864 * @param array<string,mixed> $service Effective Service values.
865 * @param int $start_seconds Selected start time as seconds in the day.
866 * @param int $maximum_duration_minutes Maximum allowed duration in minutes.
867 *
868 * @return int|WP_Error End time as seconds in the day, or a validation error.
869 */
870 function wpbc_appointment_services_resolve_end_seconds( $service, $start_seconds, $maximum_duration_minutes = 1440 ) {
871 $duration_minutes = ! empty( $service['duration_minutes'] ) ? absint( $service['duration_minutes'] ) : 0;
872 $maximum_duration_minutes = absint( $maximum_duration_minutes );
873 if ( ! $duration_minutes || ( $maximum_duration_minutes && $duration_minutes > $maximum_duration_minutes ) ) {
874 return new WP_Error( 'appointment_service_duration_invalid', __( 'The selected Service duration is invalid. Please contact the website administrator.', 'booking' ) );
875 }
876
877 $end_seconds = absint( $start_seconds ) + ( $duration_minutes * MINUTE_IN_SECONDS );
878 if ( $end_seconds > DAY_IN_SECONDS ) {
879 return new WP_Error( 'appointment_service_duration_invalid', __( 'The selected Service does not fit in the chosen day. Please select an earlier start time.', 'booking' ) );
880 }
881
882 return $end_seconds;
883 }
884
885 /**
886 * Normalize Working Time intervals into continuous scheduling ranges.
887 *
888 * Setup Wizard can define several intervals for one weekday. Gaps remain
889 * scheduling boundaries, while touching or overlapping legacy intervals are
890 * merged so one continuous range is evaluated consistently.
891 *
892 * @param array<int,array<string,int>> $working_intervals Working Time intervals.
893 *
894 * @return array<int,array{start_second:int,end_second:int}> Normalized continuous intervals.
895 */
896 function wpbc_appointment_services_normalize_working_intervals( $working_intervals ) {
897 $normalized_intervals = array();
898
899 foreach ( (array) $working_intervals as $working_interval ) {
900 if ( ! is_array( $working_interval ) || ! isset( $working_interval['start_second'], $working_interval['end_second'] ) ) {
901 continue;
902 }
903
904 $start_second = max( 0, min( DAY_IN_SECONDS, absint( $working_interval['start_second'] ) ) );
905 $end_second = max( 0, min( DAY_IN_SECONDS, absint( $working_interval['end_second'] ) ) );
906 if ( $start_second >= $end_second ) {
907 continue;
908 }
909
910 $normalized_intervals[] = array(
911 'start_second' => $start_second,
912 'end_second' => $end_second,
913 );
914 }
915
916 usort(
917 $normalized_intervals,
918 static function ( $left_interval, $right_interval ) {
919 return $left_interval['start_second'] - $right_interval['start_second'];
920 }
921 );
922
923 $continuous_intervals = array();
924 foreach ( $normalized_intervals as $normalized_interval ) {
925 $last_interval_index = count( $continuous_intervals ) - 1;
926 if ( $last_interval_index < 0 || $normalized_interval['start_second'] > $continuous_intervals[ $last_interval_index ]['end_second'] ) {
927 $continuous_intervals[] = $normalized_interval;
928 continue;
929 }
930
931 $continuous_intervals[ $last_interval_index ]['end_second'] = max(
932 $continuous_intervals[ $last_interval_index ]['end_second'],
933 $normalized_interval['end_second']
934 );
935 }
936
937 return $continuous_intervals;
938 }
939
940 /**
941 * Check one Service interval against Working Time intervals for a single day.
942 *
943 * The complete Provider-reserved interval must fit inside one continuous
944 * Working Time interval. This includes the Service's before and after buffers;
945 * a gap between two daily intervals cannot be crossed.
946 *
947 * @param array<string,mixed> $service Effective Service values.
948 * @param int[] $time_seconds Exact appointment start and end seconds.
949 * @param array<int,array<string,int>> $working_intervals Working Time intervals for the date.
950 *
951 * @return true|WP_Error True when the reserved interval fits, otherwise an availability error.
952 */
953 function wpbc_appointment_services_check_working_intervals( $service, $time_seconds, $working_intervals ) {
954 if ( empty( $service ) || ! is_array( $time_seconds ) || count( $time_seconds ) < 2 ) {
955 return true;
956 }
957
958 $appointment_start = (int) $time_seconds[0];
959 $appointment_end = (int) $time_seconds[1];
960 if ( $appointment_start < 0 || $appointment_end <= $appointment_start || $appointment_end > DAY_IN_SECONDS ) {
961 return new WP_Error( 'appointment_service_duration_invalid', __( 'The selected Service duration is invalid. Please contact the website administrator.', 'booking' ) );
962 }
963
964 $reserved_start = $appointment_start - ( ( isset( $service['buffer_before_minutes'] ) ? absint( $service['buffer_before_minutes'] ) : 0 ) * MINUTE_IN_SECONDS );
965 $reserved_end = $appointment_end + ( ( isset( $service['buffer_after_minutes'] ) ? absint( $service['buffer_after_minutes'] ) : 0 ) * MINUTE_IN_SECONDS );
966 foreach ( wpbc_appointment_services_normalize_working_intervals( $working_intervals ) as $working_interval ) {
967 if ( $reserved_start >= $working_interval['start_second'] && $reserved_end <= $working_interval['end_second'] ) {
968 return true;
969 }
970 }
971
972 return new WP_Error(
973 'appointment_service_outside_working_time',
974 __( "This start time is unavailable because the Service duration and required buffers do not fit within the Provider's Working Time. Please choose another time.", 'booking' )
975 );
976 }
977
978 /**
979 * Check a Service interval against the Provider's effective Working Time.
980 *
981 * Global Working Time disabled state and a Provider-specific disabled mode are
982 * deliberate opt-outs. Inherited and custom schedules use the canonical
983 * Working Time resolver, including every interval configured for a weekday.
984 *
985 * @param array<string,mixed> $service Effective Service values.
986 * @param int $resource_id Provider resource ID.
987 * @param string[] $dates Selected SQL dates.
988 * @param int[] $time_seconds Exact appointment start and end seconds.
989 *
990 * @return true|WP_Error True when Working Time is disabled or every date fits.
991 */
992 function wpbc_appointment_services_check_working_time( $service, $resource_id, $dates, $time_seconds ) {
993 if ( empty( $service ) || ! absint( $resource_id ) || empty( $dates ) || ! function_exists( 'wpbc_working_time__get_effective_rule' ) ) {
994 return true;
995 }
996
997 if ( false === wpbc_working_time__get_effective_rule( $resource_id ) ) {
998 return true;
999 }
1000
1001 foreach ( (array) $dates as $date_value ) {
1002 $date_value = sanitize_text_field( $date_value );
1003 $date_parts = array_map( 'absint', explode( '-', $date_value ) );
1004 if ( $date_value !== wpbc_sanitize_date( $date_value ) || 3 !== count( $date_parts ) || ! checkdate( $date_parts[1], $date_parts[2], $date_parts[0] ) ) {
1005 return new WP_Error( 'appointment_dates_invalid', __( 'Select a valid appointment date and try again.', 'booking' ) );
1006 }
1007
1008 $working_time_check = wpbc_appointment_services_check_working_intervals(
1009 $service,
1010 $time_seconds,
1011 wpbc_working_time__get_working_intervals_for_date( $resource_id, $date_value )
1012 );
1013 if ( is_wp_error( $working_time_check ) ) {
1014 return $working_time_check;
1015 }
1016 }
1017
1018 return true;
1019 }
1020
1021 /**
1022 * Convert Booking Calendar's stored boundary markers to exact interval times.
1023 *
1024 * Core stores timed starts with `+1` second and ends with `+2` seconds. An end
1025 * at midnight is represented as `23:59:52`. Buffer comparison must remove
1026 * those internal markers or adjacent zero-buffer Appointments look overlapped.
1027 *
1028 * @param string $starts_at Stored SQL start datetime.
1029 * @param string $ends_at Stored SQL end datetime.
1030 *
1031 * @return array{0:int,1:int} Exact start and end timestamps.
1032 */
1033 function wpbc_appointment_services_normalize_stored_interval( $starts_at, $ends_at ) {
1034 $start_timestamp = wpbc_convert__sql_date__to_seconds( $starts_at, false );
1035 $end_timestamp = wpbc_convert__sql_date__to_seconds( $ends_at, false );
1036 $start_time = substr( (string) $starts_at, -8 );
1037 $end_time = substr( (string) $ends_at, -8 );
1038
1039 if ( '01' === substr( $start_time, -2 ) ) {
1040 $start_timestamp--;
1041 }
1042 if ( '02' === substr( $end_time, -2 ) ) {
1043 $end_timestamp -= 2;
1044 } elseif ( '23:59:52' === $end_time ) {
1045 $end_timestamp += 8;
1046 }
1047
1048 return array( $start_timestamp, $end_timestamp );
1049 }
1050
1051 /**
1052 * Determine whether two half-open scheduling intervals overlap.
1053 *
1054 * @param int $left_start First interval start timestamp.
1055 * @param int $left_end First interval end timestamp.
1056 * @param int $right_start Second interval start timestamp.
1057 * @param int $right_end Second interval end timestamp.
1058 *
1059 * @return bool True only when the intervals overlap; touching boundaries pass.
1060 */
1061 function wpbc_appointment_services_intervals_overlap( $left_start, $left_end, $right_start, $right_end ) {
1062 return (int) $left_start < (int) $right_end && (int) $left_end > (int) $right_start;
1063 }
1064
1065 /**
1066 * Check one exact Appointment interval against buffered existing intervals.
1067 *
1068 * Existing rows must contain exact Unix timestamps in `start` and `end` plus
1069 * optional `buffer_before_minutes` and `buffer_after_minutes` values. Keeping
1070 * this calculation independent from SQL lets the save path, AJAX preflight,
1071 * and browser test panel exercise the same boundary rules.
1072 *
1073 * @param int $new_start Exact new start timestamp.
1074 * @param int $new_end Exact new end timestamp.
1075 * @param int $new_buffer_before New Service buffer before in minutes.
1076 * @param int $new_buffer_after New Service buffer after in minutes.
1077 * @param array<int,array<string,int>> $existing_intervals Exact existing intervals and buffers.
1078 *
1079 * @return bool True when any buffered interval overlaps.
1080 */
1081 function wpbc_appointment_services_has_buffer_conflict( $new_start, $new_end, $new_buffer_before, $new_buffer_after, $existing_intervals ) {
1082 $new_start = (int) $new_start - ( absint( $new_buffer_before ) * MINUTE_IN_SECONDS );
1083 $new_end = (int) $new_end + ( absint( $new_buffer_after ) * MINUTE_IN_SECONDS );
1084 if ( $new_end <= $new_start ) {
1085 return false;
1086 }
1087
1088 foreach ( (array) $existing_intervals as $existing_interval ) {
1089 $old_start = isset( $existing_interval['start'] ) ? (int) $existing_interval['start'] : 0;
1090 $old_end = isset( $existing_interval['end'] ) ? (int) $existing_interval['end'] : 0;
1091 if ( ! $old_start || $old_end <= $old_start ) {
1092 continue;
1093 }
1094 $old_start -= ( isset( $existing_interval['buffer_before_minutes'] ) ? absint( $existing_interval['buffer_before_minutes'] ) : 0 ) * MINUTE_IN_SECONDS;
1095 $old_end += ( isset( $existing_interval['buffer_after_minutes'] ) ? absint( $existing_interval['buffer_after_minutes'] ) : 0 ) * MINUTE_IN_SECONDS;
1096 if ( wpbc_appointment_services_intervals_overlap( $new_start, $new_end, $old_start, $old_end ) ) {
1097 return true;
1098 }
1099 }
1100
1101 return false;
1102 }
1103
1104 /**
1105 * Load exact buffered intervals for one Provider in one bounded date range.
1106 *
1107 * One query is intentionally shared by the selected-time preflight, the bulk
1108 * Start Time filter, and final save validation. Appointment snapshots preserve
1109 * the buffers that applied when an existing booking was created. The 46-day
1110 * SQL margin covers the complete unsigned SMALLINT minute range used by the
1111 * existing Service schema, including legacy values larger than one day.
1112 *
1113 * @param int $resource_id Provider resource ID.
1114 * @param string[] $dates Selected SQL dates.
1115 * @param int $skip_booking_id Optional booking excluded during an update.
1116 *
1117 * @return array<int,array<string,int>> Existing exact intervals and buffers.
1118 */
1119 function wpbc_appointment_services_get_existing_buffer_intervals( $resource_id, $dates, $skip_booking_id = 0 ) {
1120 global $wpdb;
1121
1122 $date_values = array_values( array_filter( array_map( 'sanitize_text_field', (array) $dates ) ) );
1123 if ( ! absint( $resource_id ) || empty( $date_values ) ) {
1124 return array();
1125 }
1126
1127 $range_start = min( $date_values );
1128 $range_end = max( $date_values );
1129 $sql = "SELECT b.booking_id, DATE(bd.booking_date) AS appointment_date, MIN(bd.booking_date) AS starts_at, MAX(bd.booking_date) AS ends_at,
1130 COALESCE(ad.buffer_before_minutes,0) AS buffer_before_minutes,
1131 COALESCE(ad.buffer_after_minutes,0) AS buffer_after_minutes
1132 FROM {$wpdb->prefix}booking b
1133 INNER JOIN {$wpdb->prefix}bookingdates bd ON bd.booking_id = b.booking_id
1134 LEFT JOIN " . wpbc_appointment_services_table_name( 'appointment_details' ) . " ad ON ad.booking_id = b.booking_id
1135 WHERE b.booking_type = %d AND b.booking_id <> %d AND b.trash = 0
1136 AND DATE(bd.booking_date) BETWEEN DATE_SUB(%s, INTERVAL 46 DAY) AND DATE_ADD(%s, INTERVAL 46 DAY)
1137 GROUP BY b.booking_id, DATE(bd.booking_date)";
1138 $existing = $wpdb->get_results( $wpdb->prepare( $sql, absint( $resource_id ), absint( $skip_booking_id ), $range_start, $range_end ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
1139 $intervals = array();
1140
1141 foreach ( (array) $existing as $booking ) {
1142 list( $old_start, $old_end ) = wpbc_appointment_services_normalize_stored_interval( $booking['starts_at'], $booking['ends_at'] );
1143 $intervals[] = array(
1144 'booking_id' => absint( $booking['booking_id'] ),
1145 'start' => $old_start,
1146 'end' => $old_end,
1147 'buffer_before_minutes' => absint( $booking['buffer_before_minutes'] ),
1148 'buffer_after_minutes' => absint( $booking['buffer_after_minutes'] ),
1149 );
1150 }
1151
1152 return $intervals;
1153 }
1154
1155 /**
1156 * Check Service buffers against an already loaded interval collection.
1157 *
1158 * @param array $service Effective Service definition.
1159 * @param string[] $dates Selected SQL dates.
1160 * @param int[] $time_seconds Exact start and end seconds in the day.
1161 * @param array<int,array<string,int>> $existing_intervals Existing Provider intervals.
1162 *
1163 * @return true|WP_Error True when the requested interval is available.
1164 */
1165 function wpbc_appointment_services_check_buffer_conflicts_in_intervals( $service, $dates, $time_seconds, $existing_intervals ) {
1166 if ( empty( $service ) || count( $time_seconds ) < 2 || empty( $dates ) ) {
1167 return true;
1168 }
1169
1170 $new_before = isset( $service['buffer_before_minutes'] ) ? absint( $service['buffer_before_minutes'] ) : 0;
1171 $new_after = isset( $service['buffer_after_minutes'] ) ? absint( $service['buffer_after_minutes'] ) : 0;
1172 foreach ( (array) $dates as $date_value ) {
1173 $date_value = sanitize_text_field( $date_value );
1174 $new_start = wpbc_convert__sql_date__to_seconds( $date_value . ' ' . wpbc_transform__seconds__in__24_hours_his( $time_seconds[0] ), false );
1175 $new_end = wpbc_convert__sql_date__to_seconds( $date_value . ' ' . wpbc_transform__seconds__in__24_hours_his( $time_seconds[1] ), false );
1176 if ( wpbc_appointment_services_has_buffer_conflict( $new_start, $new_end, $new_before, $new_after, $existing_intervals ) ) {
1177 return new WP_Error( 'appointment_service_buffer_conflict', __( 'This start time is unavailable because the Service duration or required buffer overlaps another appointment. Please choose another time.', 'booking' ) );
1178 }
1179 }
1180
1181 return true;
1182 }
1183
1184 /**
1185 * Check Service buffers against existing bookings after the core availability
1186 * engine has selected the actual Provider resource.
1187 *
1188 * @param array $service Selected Service definition.
1189 * @param int $resource_id Provider resource ID.
1190 * @param array $dates Selected booking dates.
1191 * @param array $time_seconds Start and end time expressed as day seconds.
1192 * @param int $skip_booking_id Optional booking excluded during an update.
1193 *
1194 * @return true|WP_Error True when buffers do not overlap, otherwise a conflict error.
1195 */
1196 function wpbc_appointment_services_check_buffer_conflicts( $service, $resource_id, $dates, $time_seconds, $skip_booking_id = 0 ) {
1197 if ( empty( $service ) || count( $time_seconds ) < 2 || empty( $dates ) ) {
1198 return true;
1199 }
1200 $date_values = array_values( array_filter( array_map( 'sanitize_text_field', (array) $dates ) ) );
1201 if ( empty( $date_values ) ) {
1202 return true;
1203 }
1204 $existing_intervals = wpbc_appointment_services_get_existing_buffer_intervals( $resource_id, $date_values, $skip_booking_id );
1205
1206 return wpbc_appointment_services_check_buffer_conflicts_in_intervals( $service, $date_values, $time_seconds, $existing_intervals );
1207 }
1208