Filtered popover.
*/
function wpbc_appointment_services_filter_timeline_popover( $popover, $booking_id, $bookings, $is_frontend ) {
if ( $is_frontend || ! function_exists( 'wpbc_is_11_5_features_enabled' ) || ! wpbc_is_11_5_features_enabled() ) {
return $popover;
}
$snapshot = wpbc_appointment_services_get_cached_snapshot( $booking_id );
$booking = isset( $bookings[ $booking_id ] ) ? $bookings[ $booking_id ] : null;
$details = $snapshot ? wpbc_appointment_services_get_admin_time_details( $snapshot, $booking ) : array();
if ( empty( $details ) ) {
return $popover;
}
$metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
$provider_title = ! empty( $metadata['provider_title'] )
? sanitize_text_field( $metadata['provider_title'] )
: wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
$service_cost = function_exists( 'wpbc_booking_appointment_format_service_cost' ) ? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] ) : '';
$popover['content'] .= ''
. '' . esc_html__( 'Appointment', 'booking' ) . '
'
. esc_html__( 'Service', 'booking' ) . ': ' . esc_html( $snapshot['service_title'] ) . '
'
. esc_html__( 'Provider', 'booking' ) . ': ' . esc_html( $provider_title ) . '
'
. ( '' !== $service_cost ? esc_html__( 'Service price', 'booking' ) . ': ' . wp_kses_post( $service_cost ) . '
' : '' )
. esc_html__( 'Appointment time', 'booking' ) . ': ' . esc_html( $details['appointment_time_label'] ) . '
'
. esc_html__( 'Buffer before / after', 'booking' ) . ': ' . absint( $snapshot['buffer_before_minutes'] ) . ' / ' . absint( $snapshot['buffer_after_minutes'] ) . ' ' . esc_html__( 'min', 'booking' ) . '
'
. esc_html__( 'Provider reserved', 'booking' ) . ': ' . esc_html( $details['appointment_reserved_time_label'] )
. '
';
return $popover;
}
add_filter( 'wpbc_timeline_booking_popover', 'wpbc_appointment_services_filter_timeline_popover', 10, 4 );
/**
* Add immutable Appointment values to email and confirmation replacements.
*
* Available shortcodes are `[service_title]`, `[service_title_hint]`, `[service_duration]`,
* `[service_duration_minutes]`, `[provider_title]`, and
* `[appointment_summary]`. Non-Appointment bookings retain their existing
* replacement collection unchanged.
*
* @param array $replace Existing replacement values.
* @param int $booking_id Core booking ID.
* @param int $bktype Booking resource ID.
* @param string $formdata Stored booking form data.
*
* @return array Replacement values with Appointment context.
*/
function wpbc_appointment_services_add_replace_params( $replace, $booking_id, $bktype, $formdata ) {
$replace['service_title_hint'] = '';
$snapshot = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
if ( ! $snapshot ) {
return $replace;
}
$metadata = wpbc_appointment_services_decode_snapshot_metadata( $snapshot['metadata'] );
$service_title = sanitize_text_field( $snapshot['service_title'] );
$provider_title = ! empty( $metadata['provider_title'] )
? sanitize_text_field( $metadata['provider_title'] )
: wpbc_appointment_services_get_provider_title( $snapshot['resource_id'] );
$duration = function_exists( 'wpbc_booking_appointment_format_duration' )
? wpbc_booking_appointment_format_duration( $snapshot['duration_minutes'] )
: sprintf( _n( '%d minute', '%d minutes', absint( $snapshot['duration_minutes'] ), 'booking' ), absint( $snapshot['duration_minutes'] ) );
$pricing_available = wpbc_appointment_services_is_pricing_available();
$service_cost_digits = $pricing_available ? number_format( (float) $snapshot['base_cost'], 2, '.', '' ) : '';
$service_cost = $pricing_available && function_exists( 'wpbc_booking_appointment_format_service_cost' )
? wpbc_booking_appointment_format_service_cost( $snapshot['base_cost'], $snapshot['resource_id'] )
: '';
$replace['service_title'] = $service_title;
$replace['service_title_hint'] = $service_title;
$replace['service_duration'] = $duration;
$replace['service_duration_minutes'] = absint( $snapshot['duration_minutes'] );
$replace['service_cost'] = $service_cost;
$replace['service_cost_digits_only'] = $service_cost_digits;
$replace['provider_title'] = $provider_title;
$summary_parts = array( $service_title, $provider_title, $duration );
if ( '' !== $service_cost ) {
$summary_parts[] = function_exists( 'wpbc_booking_appointment_format_service_cost_text' )
? wpbc_booking_appointment_format_service_cost_text( $snapshot['base_cost'], $snapshot['resource_id'] )
: $service_cost_digits;
}
$replace['appointment_summary'] = implode( ' · ', $summary_parts );
return $replace;
}
add_filter( 'wpbc_replace_params_for_booking', 'wpbc_appointment_services_add_replace_params', 20, 4 );
/**
* Document Appointment replacement shortcodes in the existing email help UI.
*
* @param array $fields Existing email help rows.
* @param array $skip_shortcodes Shortcodes hidden by the email type.
* @param string $email_example Existing example text.
*
* @return array Help rows including Appointment replacements.
*/
function wpbc_appointment_services_add_email_help_shortcodes( $fields, $skip_shortcodes, $email_example ) {
$fields[] = '
';
$fields[] = '' . esc_html__( 'Appointment details', 'booking' ) . '';
$fields[] = '[service_title_hint] - ' . esc_html__( 'Service Hint value saved with the Appointment; empty for other bookings.', 'booking' );
$fields[] = '[service_title] — ' . esc_html__( 'Service title saved with the Appointment.', 'booking' );
$fields[] = '[service_duration] — ' . esc_html__( 'Formatted Service duration.', 'booking' );
$fields[] = '[service_duration_minutes] — ' . esc_html__( 'Service duration in minutes.', 'booking' );
if ( wpbc_appointment_services_is_pricing_available() ) {
$fields[] = '[service_cost] — ' . esc_html__( 'Effective Service price with currency.', 'booking' );
$fields[] = '[service_cost_digits_only] — ' . esc_html__( 'Effective Service price without currency.', 'booking' );
}
$fields[] = '[provider_title] — ' . esc_html__( 'Provider title saved with the Appointment.', 'booking' );
$fields[] = '[appointment_summary] — ' . esc_html__( 'Service, Provider, and duration in one line.', 'booking' );
return $fields;
}
add_filter( 'wpbc_email_help_shortcodes', 'wpbc_appointment_services_add_email_help_shortcodes', 20, 3 );
/**
* Document Appointment values in the Payment Description help panel.
*
* @param array $fields Existing payment-description help rows.
*
* @return array Help rows including Appointment replacements.
*/
function wpbc_appointment_services_add_payment_help_shortcodes( $fields ) {
$fields[] = '
' . esc_html__( 'Appointment details', 'booking' ) . '';
$service_cost_shortcode = wpbc_appointment_services_is_pricing_available() ? ', [service_cost]' : '';
$fields[] = '[service_title], [service_title_hint], [service_duration]' . $service_cost_shortcode . ', [provider_title], [appointment_summary]';
return $fields;
}
add_filter( 'wpbc_payment_help_shortcodes', 'wpbc_appointment_services_add_payment_help_shortcodes', 20, 1 );
/**
* Return the snapshotted Service ID for a booking.
*
* @param int $booking_id Booking ID.
*
* @return int Service ID, or zero for a non-Appointment booking.
*/
function wpbc_appointment_services_get_booking_service_id( $booking_id ) {
$snapshot = wpbc_appointment_services_repository()->get_appointment_snapshot( $booking_id );
return $snapshot ? absint( $snapshot['service_id'] ) : 0;
}
/**
* Prevent moving an Appointment to a Provider who cannot perform its Service.
*
* Non-Appointment bookings preserve the incoming validation result. Appointment
* bookings return WP_Error when the target resource lacks an active assignment.
* Core may deliberately bypass this filter through its force-change setting.
*
* @param true|WP_Error $valid Validation result from earlier callbacks.
* @param int $booking_id Booking being moved.
* @param int $resource_id Target Provider resource ID.
*
* @return true|WP_Error Incoming result or a Service/Provider mismatch error.
*/
function wpbc_appointment_services_validate_resource_change( $valid, $booking_id, $resource_id ) {
$service_id = wpbc_appointment_services_get_booking_service_id( $booking_id );
if ( ! $service_id ) {
return $valid;
}
$service = wpbc_appointment_services_repository()->find_active_for_resource( $service_id, $resource_id );
return is_wp_error( $service ) ? $service : $valid;
}
add_filter( 'wpbc_booking_validate_resource_change', 'wpbc_appointment_services_validate_resource_change', 10, 3 );
/**
* Keep the Appointment snapshot aligned after a successful resource move.
*
* @param int $booking_id Moved booking ID.
* @param int $resource_id New Provider resource ID.
*
* @return void
*/
function wpbc_appointment_services_after_resource_change( $booking_id, $resource_id ) {
if ( wpbc_appointment_services_get_booking_service_id( $booking_id ) ) {
wpbc_appointment_services_repository()->update_snapshot_resource( $booking_id, $resource_id );
}
}
add_action( 'wpbc_booking_action__change_booking_resource', 'wpbc_appointment_services_after_resource_change', 10, 2 );
/**
* Resolve the server-authoritative end time for an Appointment Service.
*
* @param array $service Effective Service values.
* @param int $start_seconds Selected start time as seconds in the day.
* @param int $maximum_duration_minutes Maximum allowed duration in minutes.
*
* @return int|WP_Error End time as seconds in the day, or a validation error.
*/
function wpbc_appointment_services_resolve_end_seconds( $service, $start_seconds, $maximum_duration_minutes = 1440 ) {
$duration_minutes = ! empty( $service['duration_minutes'] ) ? absint( $service['duration_minutes'] ) : 0;
$maximum_duration_minutes = absint( $maximum_duration_minutes );
if ( ! $duration_minutes || ( $maximum_duration_minutes && $duration_minutes > $maximum_duration_minutes ) ) {
return new WP_Error( 'appointment_service_duration_invalid', __( 'The selected Service duration is invalid. Please contact the website administrator.', 'booking' ) );
}
$end_seconds = absint( $start_seconds ) + ( $duration_minutes * MINUTE_IN_SECONDS );
if ( $end_seconds > DAY_IN_SECONDS ) {
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' ) );
}
return $end_seconds;
}
/**
* Convert Booking Calendar's stored boundary markers to exact interval times.
*
* Core stores timed starts with `+1` second and ends with `+2` seconds. An end
* at midnight is represented as `23:59:52`. Buffer comparison must remove
* those internal markers or adjacent zero-buffer Appointments look overlapped.
*
* @param string $starts_at Stored SQL start datetime.
* @param string $ends_at Stored SQL end datetime.
*
* @return array{0:int,1:int} Exact start and end timestamps.
*/
function wpbc_appointment_services_normalize_stored_interval( $starts_at, $ends_at ) {
$start_timestamp = wpbc_convert__sql_date__to_seconds( $starts_at, false );
$end_timestamp = wpbc_convert__sql_date__to_seconds( $ends_at, false );
$start_time = substr( (string) $starts_at, -8 );
$end_time = substr( (string) $ends_at, -8 );
if ( '01' === substr( $start_time, -2 ) ) {
$start_timestamp--;
}
if ( '02' === substr( $end_time, -2 ) ) {
$end_timestamp -= 2;
} elseif ( '23:59:52' === $end_time ) {
$end_timestamp += 8;
}
return array( $start_timestamp, $end_timestamp );
}
/**
* Determine whether two half-open scheduling intervals overlap.
*
* @param int $left_start First interval start timestamp.
* @param int $left_end First interval end timestamp.
* @param int $right_start Second interval start timestamp.
* @param int $right_end Second interval end timestamp.
*
* @return bool True only when the intervals overlap; touching boundaries pass.
*/
function wpbc_appointment_services_intervals_overlap( $left_start, $left_end, $right_start, $right_end ) {
return (int) $left_start < (int) $right_end && (int) $left_end > (int) $right_start;
}
/**
* Check one exact Appointment interval against buffered existing intervals.
*
* Existing rows must contain exact Unix timestamps in `start` and `end` plus
* optional `buffer_before_minutes` and `buffer_after_minutes` values. Keeping
* this calculation independent from SQL lets the save path, AJAX preflight,
* and browser test panel exercise the same boundary rules.
*
* @param int $new_start Exact new start timestamp.
* @param int $new_end Exact new end timestamp.
* @param int $new_buffer_before New Service buffer before in minutes.
* @param int $new_buffer_after New Service buffer after in minutes.
* @param array> $existing_intervals Exact existing intervals and buffers.
*
* @return bool True when any buffered interval overlaps.
*/
function wpbc_appointment_services_has_buffer_conflict( $new_start, $new_end, $new_buffer_before, $new_buffer_after, $existing_intervals ) {
$new_start = (int) $new_start - ( absint( $new_buffer_before ) * MINUTE_IN_SECONDS );
$new_end = (int) $new_end + ( absint( $new_buffer_after ) * MINUTE_IN_SECONDS );
if ( $new_end <= $new_start ) {
return false;
}
foreach ( (array) $existing_intervals as $existing_interval ) {
$old_start = isset( $existing_interval['start'] ) ? (int) $existing_interval['start'] : 0;
$old_end = isset( $existing_interval['end'] ) ? (int) $existing_interval['end'] : 0;
if ( ! $old_start || $old_end <= $old_start ) {
continue;
}
$old_start -= ( isset( $existing_interval['buffer_before_minutes'] ) ? absint( $existing_interval['buffer_before_minutes'] ) : 0 ) * MINUTE_IN_SECONDS;
$old_end += ( isset( $existing_interval['buffer_after_minutes'] ) ? absint( $existing_interval['buffer_after_minutes'] ) : 0 ) * MINUTE_IN_SECONDS;
if ( wpbc_appointment_services_intervals_overlap( $new_start, $new_end, $old_start, $old_end ) ) {
return true;
}
}
return false;
}
/**
* Load exact buffered intervals for one Provider in one bounded date range.
*
* One query is intentionally shared by the selected-time preflight, the bulk
* Start Time filter, and final save validation. Appointment snapshots preserve
* the buffers that applied when an existing booking was created. The 46-day
* SQL margin covers the complete unsigned SMALLINT minute range used by the
* existing Service schema, including legacy values larger than one day.
*
* @param int $resource_id Provider resource ID.
* @param string[] $dates Selected SQL dates.
* @param int $skip_booking_id Optional booking excluded during an update.
*
* @return array> Existing exact intervals and buffers.
*/
function wpbc_appointment_services_get_existing_buffer_intervals( $resource_id, $dates, $skip_booking_id = 0 ) {
global $wpdb;
$date_values = array_values( array_filter( array_map( 'sanitize_text_field', (array) $dates ) ) );
if ( ! absint( $resource_id ) || empty( $date_values ) ) {
return array();
}
$range_start = min( $date_values );
$range_end = max( $date_values );
$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,
COALESCE(ad.buffer_before_minutes,0) AS buffer_before_minutes,
COALESCE(ad.buffer_after_minutes,0) AS buffer_after_minutes
FROM {$wpdb->prefix}booking b
INNER JOIN {$wpdb->prefix}bookingdates bd ON bd.booking_id = b.booking_id
LEFT JOIN " . wpbc_appointment_services_table_name( 'appointment_details' ) . " ad ON ad.booking_id = b.booking_id
WHERE b.booking_type = %d AND b.booking_id <> %d AND b.trash = 0
AND DATE(bd.booking_date) BETWEEN DATE_SUB(%s, INTERVAL 46 DAY) AND DATE_ADD(%s, INTERVAL 46 DAY)
GROUP BY b.booking_id, DATE(bd.booking_date)";
$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
$intervals = array();
foreach ( (array) $existing as $booking ) {
list( $old_start, $old_end ) = wpbc_appointment_services_normalize_stored_interval( $booking['starts_at'], $booking['ends_at'] );
$intervals[] = array(
'booking_id' => absint( $booking['booking_id'] ),
'start' => $old_start,
'end' => $old_end,
'buffer_before_minutes' => absint( $booking['buffer_before_minutes'] ),
'buffer_after_minutes' => absint( $booking['buffer_after_minutes'] ),
);
}
return $intervals;
}
/**
* Check Service buffers against an already loaded interval collection.
*
* @param array $service Effective Service definition.
* @param string[] $dates Selected SQL dates.
* @param int[] $time_seconds Exact start and end seconds in the day.
* @param array> $existing_intervals Existing Provider intervals.
*
* @return true|WP_Error True when the requested interval is available.
*/
function wpbc_appointment_services_check_buffer_conflicts_in_intervals( $service, $dates, $time_seconds, $existing_intervals ) {
if ( empty( $service ) || count( $time_seconds ) < 2 || empty( $dates ) ) {
return true;
}
$new_before = isset( $service['buffer_before_minutes'] ) ? absint( $service['buffer_before_minutes'] ) : 0;
$new_after = isset( $service['buffer_after_minutes'] ) ? absint( $service['buffer_after_minutes'] ) : 0;
foreach ( (array) $dates as $date_value ) {
$date_value = sanitize_text_field( $date_value );
$new_start = wpbc_convert__sql_date__to_seconds( $date_value . ' ' . wpbc_transform__seconds__in__24_hours_his( $time_seconds[0] ), false );
$new_end = wpbc_convert__sql_date__to_seconds( $date_value . ' ' . wpbc_transform__seconds__in__24_hours_his( $time_seconds[1] ), false );
if ( wpbc_appointment_services_has_buffer_conflict( $new_start, $new_end, $new_before, $new_after, $existing_intervals ) ) {
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' ) );
}
}
return true;
}
/**
* Check Service buffers against existing bookings after the core availability
* engine has selected the actual Provider resource.
*
* @param array $service Selected Service definition.
* @param int $resource_id Provider resource ID.
* @param array $dates Selected booking dates.
* @param array $time_seconds Start and end time expressed as day seconds.
* @param int $skip_booking_id Optional booking excluded during an update.
*
* @return true|WP_Error True when buffers do not overlap, otherwise a conflict error.
*/
function wpbc_appointment_services_check_buffer_conflicts( $service, $resource_id, $dates, $time_seconds, $skip_booking_id = 0 ) {
if ( empty( $service ) || count( $time_seconds ) < 2 || empty( $dates ) ) {
return true;
}
$date_values = array_values( array_filter( array_map( 'sanitize_text_field', (array) $dates ) ) );
if ( empty( $date_values ) ) {
return true;
}
$existing_intervals = wpbc_appointment_services_get_existing_buffer_intervals( $resource_id, $date_values, $skip_booking_id );
return wpbc_appointment_services_check_buffer_conflicts_in_intervals( $service, $date_values, $time_seconds, $existing_intervals );
}