PluginProbe
Booking Calendar / 11.5
Booking Calendar v11.5
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.5, at includes/page-appointment-services/appointment_services__booking.php

1,059 lines 49.0 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 * @param int $booking_id Saved booking ID.
226 * @param array $create_params Normalized booking creation parameters.
227 * @param string $where_to_save_booking Booking save context supplied by core.
228 *
229 * @return void
230 */
231 function wpbc_appointment_services_after_booking_save( $booking_id, $create_params, $where_to_save_booking ) {
232 if ( empty( $create_params['appointment_service'] ) || empty( $create_params['resource_id'] ) ) {
233 return;
234 }
235 $saved = wpbc_appointment_services_repository()->save_appointment_snapshot( $booking_id, $create_params['resource_id'], $create_params['appointment_service'] );
236 if ( ! $saved ) {
237 do_action( 'wpbc_appointment_snapshot_save_failed', absint( $booking_id ), $create_params, $where_to_save_booking );
238 }
239 }
240
241 add_action( 'wpbc_booking_after_save', 'wpbc_appointment_services_after_booking_save', 10, 3 );
242
243 /**
244 * Remove Appointment snapshots when their core bookings are permanently deleted.
245 *
246 * @param int|int[]|string $booking_ids Booking ID, array, or comma-separated IDs.
247 *
248 * @return void
249 */
250 function wpbc_appointment_services_delete_booking_snapshots( $booking_ids ) {
251 global $wpdb;
252 if ( ! wpbc_appointment_services_tables_exist() ) {
253 return;
254 }
255 $ids = is_array( $booking_ids ) ? $booking_ids : explode( ',', (string) $booking_ids );
256 $ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
257 if ( empty( $ids ) ) {
258 return;
259 }
260 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
261 $sql = 'DELETE FROM ' . wpbc_appointment_services_table_name( 'appointment_details' ) . ' WHERE booking_id IN (' . $placeholders . ')';
262 $wpdb->query( $wpdb->prepare( $sql, $ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
263 }
264
265 add_action( 'wpbc_booking_action__delete', 'wpbc_appointment_services_delete_booking_snapshots', 10, 1 );
266 add_action( 'wpbc_booking_delete', 'wpbc_appointment_services_delete_booking_snapshots', 10, 1 );
267
268 /**
269 * Remove Service assignments when booking resources are permanently deleted.
270 *
271 * @param int|int[]|string $resource_ids Resource ID, array, or comma-separated IDs.
272 *
273 * @return void
274 */
275 function wpbc_appointment_services_delete_resource_assignments( $resource_ids ) {
276 global $wpdb;
277 if ( ! wpbc_appointment_services_tables_exist() ) {
278 return;
279 }
280 $ids = is_array( $resource_ids ) ? $resource_ids : explode( ',', (string) $resource_ids );
281 $ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
282 if ( empty( $ids ) ) {
283 return;
284 }
285 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
286 $sql = 'DELETE FROM ' . wpbc_appointment_services_table_name( 'service_resources' ) . ' WHERE resource_id IN (' . $placeholders . ')';
287 $wpdb->query( $wpdb->prepare( $sql, $ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
288 }
289
290 add_action( 'wpbc_deleted_booking_resources', 'wpbc_appointment_services_delete_resource_assignments', 10, 1 );
291
292 /**
293 * Return one immutable Appointment snapshot with request-local caching.
294 *
295 * @param int $booking_id Core booking ID.
296 *
297 * @return array<string,mixed>|false Snapshot row or false.
298 */
299 function wpbc_appointment_services_get_cached_snapshot( $booking_id ) {
300 static $snapshots = array();
301
302 $booking_id = absint( $booking_id );
303 if ( ! array_key_exists( $booking_id, $snapshots ) ) {
304 $snapshots[ $booking_id ] = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
305 }
306
307 return $snapshots[ $booking_id ];
308 }
309
310 /**
311 * Extract an exact Appointment interval from a core booking record.
312 *
313 * Listing records store date strings while Timeline records contain date
314 * objects. Both retain core's boundary seconds, which are normalized here.
315 *
316 * @param mixed $booking Core listing or Timeline booking record.
317 *
318 * @return array{0:int,1:int}|false Exact start/end timestamps or false.
319 */
320 function wpbc_appointment_services_get_booking_exact_interval( $booking ) {
321 if ( ! is_object( $booking ) || empty( $booking->dates ) ) {
322 return false;
323 }
324
325 $dates = array();
326 foreach ( (array) $booking->dates as $date_value ) {
327 if ( is_object( $date_value ) && isset( $date_value->booking_date ) ) {
328 $date_value = $date_value->booking_date;
329 } elseif ( is_array( $date_value ) && isset( $date_value['booking_date'] ) ) {
330 $date_value = $date_value['booking_date'];
331 }
332 if ( is_string( $date_value ) && preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date_value ) ) {
333 $dates[] = $date_value;
334 }
335 }
336 if ( empty( $dates ) ) {
337 return false;
338 }
339
340 sort( $dates );
341 return wpbc_appointment_services_normalize_stored_interval( reset( $dates ), end( $dates ) );
342 }
343
344 /**
345 * Format an exact interval for compact administrator-facing details.
346 *
347 * @param int $start_timestamp Exact start timestamp.
348 * @param int $end_timestamp Exact end timestamp.
349 *
350 * @return string Localized interval label.
351 */
352 function wpbc_appointment_services_format_interval( $start_timestamp, $end_timestamp ) {
353 $date_format = get_bk_option( 'booking_date_format' );
354 $time_format = get_bk_option( 'booking_time_format' );
355 $date_format = $date_format ? $date_format : 'm / d / Y, D';
356 $time_format = $time_format ? $time_format : 'h:i a';
357 $same_day = wpbc_datetime__no_wp_timezone( 'Y-m-d', $start_timestamp ) === wpbc_datetime__no_wp_timezone( 'Y-m-d', $end_timestamp );
358 $start = wpbc_datetime__no_wp_timezone( $same_day ? $time_format : $date_format . ' ' . $time_format, $start_timestamp );
359 $end = wpbc_datetime__no_wp_timezone( $same_day ? $time_format : $date_format . ' ' . $time_format, $end_timestamp );
360
361 return $start . ' - ' . $end;
362 }
363
364 /**
365 * Build administrator-facing time and buffer values from immutable data.
366 *
367 * @param array<string,mixed> $snapshot Immutable Appointment snapshot.
368 * @param mixed $booking Core booking record.
369 *
370 * @return array<string,mixed> Exact and formatted Appointment intervals.
371 */
372 function wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) {
373 $interval = wpbc_appointment_services_get_booking_exact_interval( $booking );
374 if ( false === $interval ) {
375 return array();
376 }
377
378 $buffer_before = absint( $snapshot['buffer_before_minutes'] );
379 $buffer_after = absint( $snapshot['buffer_after_minutes'] );
380 $reserved_start = $interval[0] - ( $buffer_before * MINUTE_IN_SECONDS );
381 $reserved_end = $interval[1] + ( $buffer_after * MINUTE_IN_SECONDS );
382
383 return array(
384 'appointment_start_timestamp' => $interval[0],
385 'appointment_end_timestamp' => $interval[1],
386 'appointment_reserved_start' => $reserved_start,
387 'appointment_reserved_end' => $reserved_end,
388 'appointment_time_label' => wpbc_appointment_services_format_interval( $interval[0], $interval[1] ),
389 'appointment_reserved_time_label' => wpbc_appointment_services_format_interval( $reserved_start, $reserved_end ),
390 );
391 }
392
393 /**
394 * Add Service identity to the existing AJAX booking-listing record.
395 *
396 * @param array<string,mixed> $fields Parsed listing fields.
397 * @param int $booking_id Booking ID.
398 * @param mixed $booking Original booking record.
399 *
400 * @return array<string,mixed> Filtered listing fields.
401 */
402 function wpbc_appointment_services_add_listing_fields( $fields, $booking_id, $booking ) {
403 if ( ! function_exists( 'wpbc_is_11_5_features_enabled' ) || ! wpbc_is_11_5_features_enabled() ) {
404 return $fields;
405 }
406
407 $snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
408 if ( $snapshot ) {
409 $pricing_available = wpbc_appointment_services_is_pricing_available();
410 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
411 $fields['appointment_service_id'] = absint( $snapshot['service_id'] );
412 $fields['appointment_service_title'] = sanitize_text_field( $snapshot['service_title'] );
413 $fields['appointment_duration_minutes'] = absint( $snapshot['duration_minutes'] );
414 $fields['appointment_provider_id'] = absint( $snapshot['resource_id'] );
415 $fields['appointment_provider_title'] = ! empty( $metadata['provider_title'] )
416 ? sanitize_text_field( $metadata['provider_title'] )
417 : wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
418 $fields['appointment_buffer_before_minutes'] = absint( $snapshot['buffer_before_minutes'] );
419 $fields['appointment_buffer_after_minutes'] = absint( $snapshot['buffer_after_minutes'] );
420 $fields['appointment_service_cost'] = $pricing_available ? number_format( (float) $snapshot['base_cost'], 2, '.', '' ) : '';
421 $fields['appointment_service_cost_formatted'] = function_exists( 'wpbc_booking_appointment_format_service_cost_text' )
422 ? wpbc_booking_appointment_format_service_cost_text( $snapshot['base_cost'], $snapshot['resource_id'] )
423 : '';
424 $fields = array_merge( $fields, wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) );
425 }
426
427 return $fields;
428 }
429
430 add_filter( 'wpbc_booking_listing_parsed_fields', 'wpbc_appointment_services_add_listing_fields', 10, 3 );
431
432 /**
433 * Determine whether Appointment-specific Booking Listing controls are active.
434 *
435 * Appointment data remains available in every presentation mode, but its
436 * Service filter belongs only to the Appointment administration workflow.
437 *
438 * @return bool True when 11.5 features and Appointment mode are active.
439 */
440 function wpbc_appointment_services_is_appointment_listing_mode() {
441 return function_exists( 'wpbc_is_11_5_features_enabled' )
442 && wpbc_is_11_5_features_enabled()
443 && function_exists( 'wpbc_booking_modes_get_selected_mode_id' )
444 && 'appointment' === wpbc_booking_modes_get_selected_mode_id();
445 }
446
447 /**
448 * Register the Service filter in the shared Booking Listing request contract.
449 *
450 * @param array<string,array<string,mixed>> $request_schema Existing request schema.
451 * @param string $structure_type Requested schema representation.
452 *
453 * @return array<string,array<string,mixed>> Extended request schema.
454 */
455 function wpbc_appointment_services_add_listing_request_rule( $request_schema, $structure_type ) {
456 $request_schema['wh_appointment_service'] = array(
457 'validate' => 'digit_or_csd',
458 'default' => array(),
459 );
460
461 return $request_schema;
462 }
463 add_filter( 'wpbc_booking_listing_request_params_schema', 'wpbc_appointment_services_add_listing_request_rule', 10, 2 );
464
465 /**
466 * Normalize scalar, array, or comma-separated Service filter values.
467 *
468 * The request sanitizer supports both scalar and array `digit_or_csd` values.
469 * This helper flattens those compatible representations into unique positive
470 * Service IDs and can restrict them to an authorized Service catalogue.
471 *
472 * @param mixed $raw_service_ids Sanitized scalar or array value.
473 * @param array<int,mixed>|null $allowed_service_ids Optional owner-visible Service IDs. Pass null to skip authorization filtering.
474 *
475 * @return array<int,int> Unique positive Service IDs.
476 */
477 function wpbc_appointment_services_normalize_listing_service_ids( $raw_service_ids, $allowed_service_ids = null ) {
478 $raw_values = is_array( $raw_service_ids ) ? $raw_service_ids : array( $raw_service_ids );
479 $service_ids = array();
480
481 foreach ( $raw_values as $raw_value ) {
482 $separated_values = is_scalar( $raw_value ) ? explode( ',', (string) $raw_value ) : array();
483 foreach ( $separated_values as $separated_value ) {
484 $service_id = absint( $separated_value );
485 if ( $service_id ) {
486 $service_ids[ $service_id ] = $service_id;
487 }
488 }
489 }
490
491 $service_ids = array_values( $service_ids );
492 if ( null !== $allowed_service_ids ) {
493 $allowed_service_ids = array_values( array_unique( array_filter( array_map( 'absint', $allowed_service_ids ) ) ) );
494 $service_ids = array_values( array_intersect( $service_ids, $allowed_service_ids ) );
495 }
496
497 return $service_ids;
498 }
499
500 /**
501 * Return owner-visible Services available to the Appointment listing filter.
502 *
503 * All statuses are intentionally included because historical Appointments must
504 * remain filterable after their Service is deactivated or archived.
505 *
506 * @return array<int,array<string,mixed>> Owner-visible Service rows.
507 */
508 function wpbc_appointment_services_get_listing_services() {
509 $repository = wpbc_appointment_services_get_data_provider();
510 $services = is_object( $repository ) && method_exists( $repository, 'list_items' )
511 ? $repository->list_items( array( 'status' => 'all' ) )
512 : array();
513
514 return is_wp_error( $services ) || ! is_array( $services ) ? array() : $services;
515 }
516
517 /**
518 * Restrict the shared Booking Listing to one or more snapshotted Appointment Services.
519 *
520 * The existing Booking Resource query remains authoritative for Provider and
521 * MultiUser ownership filtering. This additional EXISTS clause only narrows
522 * those already-authorized bookings by their immutable Appointment snapshot.
523 *
524 * @param array{where:string,args:array<int,mixed>} $query_parts Existing SQL WHERE and arguments.
525 * @param array<string,mixed> $request_params Sanitized request values.
526 * @param array<string,mixed> $params Values merged with defaults.
527 *
528 * @return array{where:string,args:array<int,mixed>} Filtered query parts.
529 */
530 function wpbc_appointment_services_filter_listing_query( $query_parts, $request_params, $params ) {
531 if ( ! wpbc_appointment_services_is_appointment_listing_mode() || ! wpbc_appointment_services_tables_exist() ) {
532 return $query_parts;
533 }
534
535 $listing_services = wpbc_appointment_services_get_listing_services();
536 $allowed_service_ids = wp_list_pluck( $listing_services, 'service_id' );
537 $service_ids = wpbc_appointment_services_normalize_listing_service_ids(
538 isset( $params['wh_appointment_service'] ) ? $params['wh_appointment_service'] : array(),
539 $allowed_service_ids
540 );
541 if ( empty( $service_ids ) || ! isset( $query_parts['where'], $query_parts['args'] ) ) {
542 return $query_parts;
543 }
544
545 $service_placeholders = implode( ', ', array_fill( 0, count( $service_ids ), '%d' ) );
546 $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 . ' ) )';
547 $query_parts['args'] = array_merge( $query_parts['args'], $service_ids );
548
549 return $query_parts;
550 }
551 add_filter( 'wpbc_booking_listing_sql_query_parts', 'wpbc_appointment_services_filter_listing_query', 10, 3 );
552
553 /**
554 * Render the owner-aware Service selector beside the existing Provider filter.
555 *
556 * @param array<string,mixed> $request_params Sanitized current filter values.
557 * @param array<string,mixed> $defaults Default filter values.
558 *
559 * @return void
560 */
561 function wpbc_appointment_services_render_listing_filter( $request_params, $defaults ) {
562 if ( ! wpbc_appointment_services_is_appointment_listing_mode() || ! wpbc_appointment_services_storage_is_ready() ) {
563 return;
564 }
565
566 $services = wpbc_appointment_services_get_listing_services();
567 $service_options = array();
568 $allowed_service_ids = array();
569 foreach ( $services as $service ) {
570 $service_id = absint( isset( $service['service_id'] ) ? $service['service_id'] : 0 );
571 $service_title = isset( $service['title'] ) ? sanitize_text_field( $service['title'] ) : '';
572 $service_status = isset( $service['status'] ) ? sanitize_key( $service['status'] ) : 'active';
573 if ( ! $service_id || '' === $service_title ) {
574 continue;
575 }
576 if ( 'active' !== $service_status ) {
577 $status_label = 'archived' === $service_status ? __( 'Archived', 'booking' ) : __( 'Inactive', 'booking' );
578 $service_title = sprintf( '%1$s (%2$s)', $service_title, $status_label );
579 }
580 $allowed_service_ids[] = $service_id;
581 $service_options[ $service_id ] = array(
582 'title' => $service_title,
583 'attr' => array( 'title' => $service_title ),
584 );
585 }
586
587 $selected_services = isset( $request_params['wh_appointment_service'] )
588 ? $request_params['wh_appointment_service']
589 : ( isset( $defaults['wh_appointment_service'] ) ? $defaults['wh_appointment_service'] : array() );
590 $selected_services = wpbc_appointment_services_normalize_listing_service_ids( $selected_services, $allowed_service_ids );
591
592 wpbc_ui_chosen_filter_enqueue_assets();
593 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Shared component escapes its complete output.
594 echo wpbc_ui_chosen_filter_get_html(
595 array(
596 'id' => 'wh_appointment_service',
597 'name' => 'wh_appointment_service',
598 'options' => $service_options,
599 'selected_values' => $selected_services,
600 'multiple' => true,
601 'placeholder' => empty( $service_options ) ? __( 'No Services', 'booking' ) : __( 'All Services', 'booking' ),
602 'clear_label' => __( 'Clear Service selection', 'booking' ),
603 'disabled' => empty( $service_options ),
604 'container_class' => 'wpbc_booking_listing__service_filter',
605 'attributes' => array( 'aria-label' => __( 'Filter Appointments by Service', 'booking' ) ),
606 'listing_param' => 'wh_appointment_service',
607 'listing_value_type' => 'integer_array',
608 'empty_request_value' => array(),
609 'clear_selected_values' => array(),
610 )
611 );
612 }
613 add_action( 'wpbc_booking_listing_toolbar_after_resources', 'wpbc_appointment_services_render_listing_filter', 10, 2 );
614
615 /**
616 * Add immutable Appointment timing to a Timeline pipeline tooltip.
617 *
618 * @param string $title Existing plain-text tooltip.
619 * @param int $booking_id Core booking ID.
620 * @param array<int,mixed> $bookings Timeline booking collection.
621 *
622 * @return string Filtered tooltip.
623 */
624 function wpbc_appointment_services_filter_timeline_pipeline_title( $title, $booking_id, $bookings ) {
625 if ( ! function_exists( 'wpbc_is_11_5_features_enabled' ) || ! wpbc_is_11_5_features_enabled() ) {
626 return $title;
627 }
628
629 $snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
630 $booking = isset( $bookings[ $booking_id ] ) ? $bookings[ $booking_id ] : null;
631 $details = $snapshot ? wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) : array();
632 if ( empty( $details ) ) {
633 return $title;
634 }
635
636 $title .= "\n" . sprintf( __( 'Service: %s', 'booking' ), sanitize_text_field( $snapshot['service_title'] ) );
637 $service_cost = function_exists( 'wpbc_booking_appointment_format_service_cost' ) ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] ) : '';
638 if ( '' !== $service_cost ) {
639 $title .= "\n" . sprintf( __( 'Service price: %s', 'booking' ), wp_strip_all_tags( $service_cost ) );
640 }
641 $title .= "\n" . sprintf( __( 'Appointment: %s', 'booking' ), $details['appointment_time_label'] );
642 $title .= "\n" . sprintf(
643 __( 'Provider reserved: %1$s (buffers %2$d / %3$d min)', 'booking' ),
644 $details['appointment_reserved_time_label'],
645 absint( $snapshot['buffer_before_minutes'] ),
646 absint( $snapshot['buffer_after_minutes'] )
647 );
648
649 return $title;
650 }
651 add_filter( 'wpbc_timeline_booking_pipeline_title', 'wpbc_appointment_services_filter_timeline_pipeline_title', 10, 3 );
652
653 /**
654 * Add Appointment timing to the administrator Timeline popover.
655 *
656 * @param array<string,string> $popover Existing popover title and content.
657 * @param int $booking_id Core booking ID.
658 * @param array<int,mixed> $bookings Timeline booking collection.
659 * @param bool $is_frontend Whether Timeline is public.
660 *
661 * @return array<string,string> Filtered popover.
662 */
663 function wpbc_appointment_services_filter_timeline_popover( $popover, $booking_id, $bookings, $is_frontend ) {
664 if ( $is_frontend || ! function_exists( 'wpbc_is_11_5_features_enabled' ) || ! wpbc_is_11_5_features_enabled() ) {
665 return $popover;
666 }
667
668 $snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
669 $booking = isset( $bookings[ $booking_id ] ) ? $bookings[ $booking_id ] : null;
670 $details = $snapshot ? wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) : array();
671 if ( empty( $details ) ) {
672 return $popover;
673 }
674
675 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
676 $provider_title = ! empty( $metadata['provider_title'] )
677 ? sanitize_text_field( $metadata['provider_title'] )
678 : wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
679 $service_cost = function_exists( 'wpbc_booking_appointment_format_service_cost' ) ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] ) : '';
680 $popover['content'] .= '<div class="wpbc_timeline_appointment_details">'
681 . '<strong>' . esc_html__( 'Appointment', 'booking' ) . '</strong><br>'
682 . esc_html__( 'Service', 'booking' ) . ': ' . esc_html( $snapshot['service_title'] ) . '<br>'
683 . esc_html__( 'Provider', 'booking' ) . ': ' . esc_html( $provider_title ) . '<br>'
684 . ( '' !== $service_cost ? esc_html__( 'Service price', 'booking' ) . ': ' . wp_kses_post( $service_cost ) . '<br>' : '' )
685 . esc_html__( 'Appointment time', 'booking' ) . ': ' . esc_html( $details['appointment_time_label'] ) . '<br>'
686 . esc_html__( 'Buffer before / after', 'booking' ) . ': ' . absint( $snapshot['buffer_before_minutes'] ) . ' / ' . absint( $snapshot['buffer_after_minutes'] ) . ' ' . esc_html__( 'min', 'booking' ) . '<br>'
687 . esc_html__( 'Provider reserved', 'booking' ) . ': ' . esc_html( $details['appointment_reserved_time_label'] )
688 . '</div>';
689
690 return $popover;
691 }
692 add_filter( 'wpbc_timeline_booking_popover', 'wpbc_appointment_services_filter_timeline_popover', 10, 4 );
693
694 /**
695 * Add immutable Appointment values to email and confirmation replacements.
696 *
697 * Available shortcodes are `[service_title]`, `[service_title_hint]`, `[service_duration]`,
698 * `[service_duration_minutes]`, `[provider_title]`, and
699 * `[appointment_summary]`. Non-Appointment bookings retain their existing
700 * replacement collection unchanged.
701 *
702 * @param array<string,mixed> $replace Existing replacement values.
703 * @param int $booking_id Core booking ID.
704 * @param int $bktype Booking resource ID.
705 * @param string $formdata Stored booking form data.
706 *
707 * @return array<string,mixed> Replacement values with Appointment context.
708 */
709 function wpbc_appointment_services_add_replace_params( $replace, $booking_id, $bktype, $formdata ) {
710 $replace['service_title_hint'] = '';
711 $snapshot = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
712 if ( ! $snapshot ) {
713 return $replace;
714 }
715
716 $metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
717 $service_title = sanitize_text_field( $snapshot['service_title'] );
718 $provider_title = ! empty( $metadata['provider_title'] )
719 ? sanitize_text_field( $metadata['provider_title'] )
720 : wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
721 $duration = function_exists( 'wpbc_booking_appointment_format_duration' )
722 ? wpbc_booking_appointment_format_duration( $snapshot['duration_minutes'] )
723 : sprintf( _n( '%d minute', '%d minutes', absint( $snapshot['duration_minutes'] ), 'booking' ), absint( $snapshot['duration_minutes'] ) );
724 $pricing_available = wpbc_appointment_services_is_pricing_available();
725 $service_cost_digits = $pricing_available ? number_format( (float) $snapshot['base_cost'], 2, '.', '' ) : '';
726 $service_cost = $pricing_available && function_exists( 'wpbc_booking_appointment_format_service_cost' )
727 ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] )
728 : '';
729
730 $replace['service_title'] = $service_title;
731 $replace['service_title_hint'] = $service_title;
732 $replace['service_duration'] = $duration;
733 $replace['service_duration_minutes'] = absint( $snapshot['duration_minutes'] );
734 $replace['service_cost'] = $service_cost;
735 $replace['service_cost_digits_only'] = $service_cost_digits;
736 $replace['provider_title'] = $provider_title;
737 $summary_parts = array( $service_title, $provider_title, $duration );
738 if ( '' !== $service_cost ) {
739 $summary_parts[] = function_exists( 'wpbc_booking_appointment_format_service_cost_text' )
740 ? wpbc_booking_appointment_format_service_cost_text( $snapshot['base_cost'], $snapshot['resource_id'] )
741 : $service_cost_digits;
742 }
743 $replace['appointment_summary'] = implode( ' · ', $summary_parts );
744
745 return $replace;
746 }
747
748 add_filter( 'wpbc_replace_params_for_booking', 'wpbc_appointment_services_add_replace_params', 20, 4 );
749
750 /**
751 * Document Appointment replacement shortcodes in the existing email help UI.
752 *
753 * @param array<int,string> $fields Existing email help rows.
754 * @param array<int,string> $skip_shortcodes Shortcodes hidden by the email type.
755 * @param string $email_example Existing example text.
756 *
757 * @return array<int,string> Help rows including Appointment replacements.
758 */
759 function wpbc_appointment_services_add_email_help_shortcodes( $fields, $skip_shortcodes, $email_example ) {
760 $fields[] = '<hr/>';
761 $fields[] = '<strong>' . esc_html__( 'Appointment details', 'booking' ) . '</strong>';
762 $fields[] = '<code>[service_title_hint]</code> - ' . esc_html__( 'Service Hint value saved with the Appointment; empty for other bookings.', 'booking' );
763 $fields[] = '<code>[service_title]</code> — ' . esc_html__( 'Service title saved with the Appointment.', 'booking' );
764 $fields[] = '<code>[service_duration]</code> — ' . esc_html__( 'Formatted Service duration.', 'booking' );
765 $fields[] = '<code>[service_duration_minutes]</code> — ' . esc_html__( 'Service duration in minutes.', 'booking' );
766 if ( wpbc_appointment_services_is_pricing_available() ) {
767 $fields[] = '<code>[service_cost]</code> — ' . esc_html__( 'Effective Service price with currency.', 'booking' );
768 $fields[] = '<code>[service_cost_digits_only]</code> — ' . esc_html__( 'Effective Service price without currency.', 'booking' );
769 }
770 $fields[] = '<code>[provider_title]</code> — ' . esc_html__( 'Provider title saved with the Appointment.', 'booking' );
771 $fields[] = '<code>[appointment_summary]</code> — ' . esc_html__( 'Service, Provider, and duration in one line.', 'booking' );
772
773 return $fields;
774 }
775
776 add_filter( 'wpbc_email_help_shortcodes', 'wpbc_appointment_services_add_email_help_shortcodes', 20, 3 );
777
778 /**
779 * Document Appointment values in the Payment Description help panel.
780 *
781 * @param array<int,string> $fields Existing payment-description help rows.
782 *
783 * @return array<int,string> Help rows including Appointment replacements.
784 */
785 function wpbc_appointment_services_add_payment_help_shortcodes( $fields ) {
786 $fields[] = '<hr/><strong>' . esc_html__( 'Appointment details', 'booking' ) . '</strong>';
787 $service_cost_shortcode = wpbc_appointment_services_is_pricing_available() ? ', <code>[service_cost]</code>' : '';
788 $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>';
789
790 return $fields;
791 }
792 add_filter( 'wpbc_payment_help_shortcodes', 'wpbc_appointment_services_add_payment_help_shortcodes', 20, 1 );
793
794 /**
795 * Return the snapshotted Service ID for a booking.
796 *
797 * @param int $booking_id Booking ID.
798 *
799 * @return int Service ID, or zero for a non-Appointment booking.
800 */
801 function wpbc_appointment_services_get_booking_service_id( $booking_id ) {
802 $snapshot = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
803
804 return $snapshot ? absint( $snapshot['service_id'] ) : 0;
805 }
806
807 /**
808 * Prevent moving an Appointment to a Provider who cannot perform its Service.
809 *
810 * Non-Appointment bookings preserve the incoming validation result. Appointment
811 * bookings return WP_Error when the target resource lacks an active assignment.
812 * Core may deliberately bypass this filter through its force-change setting.
813 *
814 * @param true|WP_Error $valid Validation result from earlier callbacks.
815 * @param int $booking_id Booking being moved.
816 * @param int $resource_id Target Provider resource ID.
817 *
818 * @return true|WP_Error Incoming result or a Service/Provider mismatch error.
819 */
820 function wpbc_appointment_services_validate_resource_change( $valid, $booking_id, $resource_id ) {
821 $service_id = wpbc_appointment_services_get_booking_service_id( $booking_id );
822 if ( ! $service_id ) {
823 return $valid;
824 }
825 $service = wpbc_appointment_services_repository()->find_active_for_resource( $service_id, $resource_id );
826
827 return is_wp_error( $service ) ? $service : $valid;
828 }
829
830 add_filter( 'wpbc_booking_validate_resource_change', 'wpbc_appointment_services_validate_resource_change', 10, 3 );
831
832 /**
833 * Keep the Appointment snapshot aligned after a successful resource move.
834 *
835 * @param int $booking_id Moved booking ID.
836 * @param int $resource_id New Provider resource ID.
837 *
838 * @return void
839 */
840 function wpbc_appointment_services_after_resource_change( $booking_id, $resource_id ) {
841 if ( wpbc_appointment_services_get_booking_service_id( $booking_id ) ) {
842 wpbc_appointment_services_repository()->update_snapshot_resource( $booking_id, $resource_id );
843 }
844 }
845
846 add_action( 'wpbc_booking_action__change_booking_resource', 'wpbc_appointment_services_after_resource_change', 10, 2 );
847
848 /**
849 * Resolve the server-authoritative end time for an Appointment Service.
850 *
851 * @param array<string,mixed> $service Effective Service values.
852 * @param int $start_seconds Selected start time as seconds in the day.
853 * @param int $maximum_duration_minutes Maximum allowed duration in minutes.
854 *
855 * @return int|WP_Error End time as seconds in the day, or a validation error.
856 */
857 function wpbc_appointment_services_resolve_end_seconds( $service, $start_seconds, $maximum_duration_minutes = 1440 ) {
858 $duration_minutes = ! empty( $service['duration_minutes'] ) ? absint( $service['duration_minutes'] ) : 0;
859 $maximum_duration_minutes = absint( $maximum_duration_minutes );
860 if ( ! $duration_minutes || ( $maximum_duration_minutes && $duration_minutes > $maximum_duration_minutes ) ) {
861 return new WP_Error( 'appointment_service_duration_invalid', __( 'The selected Service duration is invalid. Please contact the website administrator.', 'booking' ) );
862 }
863
864 $end_seconds = absint( $start_seconds ) + ( $duration_minutes * MINUTE_IN_SECONDS );
865 if ( $end_seconds > DAY_IN_SECONDS ) {
866 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' ) );
867 }
868
869 return $end_seconds;
870 }
871
872 /**
873 * Convert Booking Calendar's stored boundary markers to exact interval times.
874 *
875 * Core stores timed starts with `+1` second and ends with `+2` seconds. An end
876 * at midnight is represented as `23:59:52`. Buffer comparison must remove
877 * those internal markers or adjacent zero-buffer Appointments look overlapped.
878 *
879 * @param string $starts_at Stored SQL start datetime.
880 * @param string $ends_at Stored SQL end datetime.
881 *
882 * @return array{0:int,1:int} Exact start and end timestamps.
883 */
884 function wpbc_appointment_services_normalize_stored_interval( $starts_at, $ends_at ) {
885 $start_timestamp = wpbc_convert__sql_date__to_seconds( $starts_at, false );
886 $end_timestamp = wpbc_convert__sql_date__to_seconds( $ends_at, false );
887 $start_time = substr( (string) $starts_at, -8 );
888 $end_time = substr( (string) $ends_at, -8 );
889
890 if ( '01' === substr( $start_time, -2 ) ) {
891 $start_timestamp--;
892 }
893 if ( '02' === substr( $end_time, -2 ) ) {
894 $end_timestamp -= 2;
895 } elseif ( '23:59:52' === $end_time ) {
896 $end_timestamp += 8;
897 }
898
899 return array( $start_timestamp, $end_timestamp );
900 }
901
902 /**
903 * Determine whether two half-open scheduling intervals overlap.
904 *
905 * @param int $left_start First interval start timestamp.
906 * @param int $left_end First interval end timestamp.
907 * @param int $right_start Second interval start timestamp.
908 * @param int $right_end Second interval end timestamp.
909 *
910 * @return bool True only when the intervals overlap; touching boundaries pass.
911 */
912 function wpbc_appointment_services_intervals_overlap( $left_start, $left_end, $right_start, $right_end ) {
913 return (int) $left_start < (int) $right_end && (int) $left_end > (int) $right_start;
914 }
915
916 /**
917 * Check one exact Appointment interval against buffered existing intervals.
918 *
919 * Existing rows must contain exact Unix timestamps in `start` and `end` plus
920 * optional `buffer_before_minutes` and `buffer_after_minutes` values. Keeping
921 * this calculation independent from SQL lets the save path, AJAX preflight,
922 * and browser test panel exercise the same boundary rules.
923 *
924 * @param int $new_start Exact new start timestamp.
925 * @param int $new_end Exact new end timestamp.
926 * @param int $new_buffer_before New Service buffer before in minutes.
927 * @param int $new_buffer_after New Service buffer after in minutes.
928 * @param array<int,array<string,int>> $existing_intervals Exact existing intervals and buffers.
929 *
930 * @return bool True when any buffered interval overlaps.
931 */
932 function wpbc_appointment_services_has_buffer_conflict( $new_start, $new_end, $new_buffer_before, $new_buffer_after, $existing_intervals ) {
933 $new_start = (int) $new_start - ( absint( $new_buffer_before ) * MINUTE_IN_SECONDS );
934 $new_end = (int) $new_end + ( absint( $new_buffer_after ) * MINUTE_IN_SECONDS );
935 if ( $new_end <= $new_start ) {
936 return false;
937 }
938
939 foreach ( (array) $existing_intervals as $existing_interval ) {
940 $old_start = isset( $existing_interval['start'] ) ? (int) $existing_interval['start'] : 0;
941 $old_end = isset( $existing_interval['end'] ) ? (int) $existing_interval['end'] : 0;
942 if ( ! $old_start || $old_end <= $old_start ) {
943 continue;
944 }
945 $old_start -= ( isset( $existing_interval['buffer_before_minutes'] ) ? absint( $existing_interval['buffer_before_minutes'] ) : 0 ) * MINUTE_IN_SECONDS;
946 $old_end += ( isset( $existing_interval['buffer_after_minutes'] ) ? absint( $existing_interval['buffer_after_minutes'] ) : 0 ) * MINUTE_IN_SECONDS;
947 if ( wpbc_appointment_services_intervals_overlap( $new_start, $new_end, $old_start, $old_end ) ) {
948 return true;
949 }
950 }
951
952 return false;
953 }
954
955 /**
956 * Load exact buffered intervals for one Provider in one bounded date range.
957 *
958 * One query is intentionally shared by the selected-time preflight, the bulk
959 * Start Time filter, and final save validation. Appointment snapshots preserve
960 * the buffers that applied when an existing booking was created. The 46-day
961 * SQL margin covers the complete unsigned SMALLINT minute range used by the
962 * existing Service schema, including legacy values larger than one day.
963 *
964 * @param int $resource_id Provider resource ID.
965 * @param string[] $dates Selected SQL dates.
966 * @param int $skip_booking_id Optional booking excluded during an update.
967 *
968 * @return array<int,array<string,int>> Existing exact intervals and buffers.
969 */
970 function wpbc_appointment_services_get_existing_buffer_intervals( $resource_id, $dates, $skip_booking_id = 0 ) {
971 global $wpdb;
972
973 $date_values = array_values( array_filter( array_map( 'sanitize_text_field', (array) $dates ) ) );
974 if ( ! absint( $resource_id ) || empty( $date_values ) ) {
975 return array();
976 }
977
978 $range_start = min( $date_values );
979 $range_end = max( $date_values );
980 $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,
981 COALESCE(ad.buffer_before_minutes,0) AS buffer_before_minutes,
982 COALESCE(ad.buffer_after_minutes,0) AS buffer_after_minutes
983 FROM {$wpdb->prefix}booking b
984 INNER JOIN {$wpdb->prefix}bookingdates bd ON bd.booking_id = b.booking_id
985 LEFT JOIN " . wpbc_appointment_services_table_name( 'appointment_details' ) . " ad ON ad.booking_id = b.booking_id
986 WHERE b.booking_type = %d AND b.booking_id <> %d AND b.trash = 0
987 AND DATE(bd.booking_date) BETWEEN DATE_SUB(%s, INTERVAL 46 DAY) AND DATE_ADD(%s, INTERVAL 46 DAY)
988 GROUP BY b.booking_id, DATE(bd.booking_date)";
989 $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
990 $intervals = array();
991
992 foreach ( (array) $existing as $booking ) {
993 list( $old_start, $old_end ) = wpbc_appointment_services_normalize_stored_interval( $booking['starts_at'], $booking['ends_at'] );
994 $intervals[] = array(
995 'booking_id' => absint( $booking['booking_id'] ),
996 'start' => $old_start,
997 'end' => $old_end,
998 'buffer_before_minutes' => absint( $booking['buffer_before_minutes'] ),
999 'buffer_after_minutes' => absint( $booking['buffer_after_minutes'] ),
1000 );
1001 }
1002
1003 return $intervals;
1004 }
1005
1006 /**
1007 * Check Service buffers against an already loaded interval collection.
1008 *
1009 * @param array $service Effective Service definition.
1010 * @param string[] $dates Selected SQL dates.
1011 * @param int[] $time_seconds Exact start and end seconds in the day.
1012 * @param array<int,array<string,int>> $existing_intervals Existing Provider intervals.
1013 *
1014 * @return true|WP_Error True when the requested interval is available.
1015 */
1016 function wpbc_appointment_services_check_buffer_conflicts_in_intervals( $service, $dates, $time_seconds, $existing_intervals ) {
1017 if ( empty( $service ) || count( $time_seconds ) < 2 || empty( $dates ) ) {
1018 return true;
1019 }
1020
1021 $new_before = isset( $service['buffer_before_minutes'] ) ? absint( $service['buffer_before_minutes'] ) : 0;
1022 $new_after = isset( $service['buffer_after_minutes'] ) ? absint( $service['buffer_after_minutes'] ) : 0;
1023 foreach ( (array) $dates as $date_value ) {
1024 $date_value = sanitize_text_field( $date_value );
1025 $new_start = wpbc_convert__sql_date__to_seconds( $date_value . ' ' . wpbc_transform__seconds__in__24_hours_his( $time_seconds[0] ), false );
1026 $new_end = wpbc_convert__sql_date__to_seconds( $date_value . ' ' . wpbc_transform__seconds__in__24_hours_his( $time_seconds[1] ), false );
1027 if ( wpbc_appointment_services_has_buffer_conflict( $new_start, $new_end, $new_before, $new_after, $existing_intervals ) ) {
1028 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' ) );
1029 }
1030 }
1031
1032 return true;
1033 }
1034
1035 /**
1036 * Check Service buffers against existing bookings after the core availability
1037 * engine has selected the actual Provider resource.
1038 *
1039 * @param array $service Selected Service definition.
1040 * @param int $resource_id Provider resource ID.
1041 * @param array $dates Selected booking dates.
1042 * @param array $time_seconds Start and end time expressed as day seconds.
1043 * @param int $skip_booking_id Optional booking excluded during an update.
1044 *
1045 * @return true|WP_Error True when buffers do not overlap, otherwise a conflict error.
1046 */
1047 function wpbc_appointment_services_check_buffer_conflicts( $service, $resource_id, $dates, $time_seconds, $skip_booking_id = 0 ) {
1048 if ( empty( $service ) || count( $time_seconds ) < 2 || empty( $dates ) ) {
1049 return true;
1050 }
1051 $date_values = array_values( array_filter( array_map( 'sanitize_text_field', (array) $dates ) ) );
1052 if ( empty( $date_values ) ) {
1053 return true;
1054 }
1055 $existing_intervals = wpbc_appointment_services_get_existing_buffer_intervals( $resource_id, $date_values, $skip_booking_id );
1056
1057 return wpbc_appointment_services_check_buffer_conflicts_in_intervals( $service, $date_values, $time_seconds, $existing_intervals );
1058 }
1059