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__functions.php

appointment_services__functions.php in Booking Calendar 11.5, at includes/page-appointment-services/appointment_services__functions.php

809 lines 32.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /** Appointment Services helpers and repository boundary. @package Booking Calendar */
3 if ( ! defined( 'ABSPATH' ) ) { exit; }
4
5 /**
6 * Determine whether the current request is the Appointment Services page.
7 *
8 * @return bool True only for the wpbc-services WordPress admin route.
9 */
10 function wpbc_appointment_services__is_page() {
11 if ( ! is_admin() ) { return false; }
12 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
13 $page = isset( $_REQUEST['page'] ) ? sanitize_key( wp_unslash( $_REQUEST['page'] ) ) : '';
14 return 'wpbc-services' === $page;
15 }
16
17 /**
18 * Return the capability required to manage Appointment Services.
19 *
20 * @return string WordPress capability name.
21 */
22 function wpbc_appointment_services_get_manage_capability() {
23 $settings_role = get_bk_option( 'booking_user_role_settings' );
24 $capabilities = array(
25 'administrator' => 'activate_plugins',
26 'editor' => 'publish_pages',
27 'author' => 'publish_posts',
28 'contributor' => 'edit_posts',
29 'subscriber' => 'read',
30 );
31 $capability = isset( $capabilities[ $settings_role ] ) ? $capabilities[ $settings_role ] : 'manage_options';
32
33 /**
34 * Filter the capability required to manage Appointment Services.
35 *
36 * @param string $capability WordPress capability name.
37 */
38 return (string) apply_filters( 'wpbc_appointment_services_manage_capability', $capability );
39 }
40
41 /**
42 * Determine whether Service pricing can use the Booking Calendar cost engine.
43 *
44 * The database fields remain available in every edition so upgrades and
45 * downgrades never require a destructive schema change. Presentation, editing,
46 * calculation, and public output use this single runtime check so editions
47 * below Business Small cannot advertise or apply an unsupported price.
48 *
49 * @return bool True when Service pricing is available in the active edition.
50 */
51 function wpbc_appointment_services_is_pricing_available() {
52 $is_available = class_exists( 'wpdev_bk_biz_s' );
53
54 /**
55 * Filter whether the active edition or an extension provides Service pricing.
56 *
57 * @param bool $is_available Whether the Business Small cost engine is loaded.
58 */
59 return (bool) apply_filters( 'wpbc_appointment_services_pricing_available', $is_available );
60 }
61
62 /**
63 * Read the requested Service inspector focus from the administration URL.
64 *
65 * The value controls only presentation after an authorized Service has loaded.
66 * Keeping an explicit allow-list prevents request data from becoming a CSS or
67 * DOM selector in the Services JavaScript client.
68 *
69 * @return string Supported inspector focus key, or an empty string.
70 */
71 function wpbc_appointment_services_get_requested_focus_section() {
72 if ( ! isset( $_GET['wpbc_service_focus'] ) || is_array( $_GET['wpbc_service_focus'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
73 return '';
74 }
75
76 $requested_focus = sanitize_key( wp_unslash( $_GET['wpbc_service_focus'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
77 $allowed_focuses = array( 'booking_form' );
78
79 return in_array( $requested_focus, $allowed_focuses, true ) ? $requested_focus : '';
80 }
81
82 /**
83 * Determine whether the current user satisfies the configured Availability role.
84 *
85 * Booking Calendar stores a minimum WordPress role in
86 * `booking_user_role_availability`. The native role helper maps that role to a
87 * hierarchical capability, allowing administrators to access pages configured
88 * for editors or lower roles. Passing the stored role directly to
89 * `current_user_can()` would incorrectly reject those higher roles.
90 *
91 * @return bool True when the current user may manage Booking Calendar availability.
92 */
93 function wpbc_appointment_services_can_manage_availability() {
94 $availability_role = sanitize_key( (string) get_bk_option( 'booking_user_role_availability' ) );
95
96 if ( '' === $availability_role ) {
97 return false;
98 }
99 if ( function_exists( 'wpbc_is_current_user_have_this_role' ) ) {
100 return wpbc_is_current_user_have_this_role( $availability_role );
101 }
102
103 return current_user_can( $availability_role );
104 }
105
106 /**
107 * Render the compact Monday-to-Sunday labels in the Service listing header.
108 *
109 * @param array<string,mixed> $column Normalized shared listing column settings.
110 * @param WPBC_UI_Listing $listing Shared listing instance.
111 *
112 * @return void
113 */
114 function wpbc_appointment_services_render_weekday_listing_header( $column, $listing ) {
115 unset( $column, $listing );
116 ?>
117 <span><?php esc_html_e( 'Weekly Availability', 'booking' ); ?></span>
118 <span class="wpbc_appointment_services__weekday_labels" aria-hidden="true">
119 <i><?php esc_html_e( 'Mon', 'booking' ); ?></i>
120 <i><?php esc_html_e( 'Tue', 'booking' ); ?></i>
121 <i><?php esc_html_e( 'Wed', 'booking' ); ?></i>
122 <i><?php esc_html_e( 'Thu', 'booking' ); ?></i>
123 <i><?php esc_html_e( 'Fri', 'booking' ); ?></i>
124 <i><?php esc_html_e( 'Sat', 'booking' ); ?></i>
125 <i><?php esc_html_e( 'Sun', 'booking' ); ?></i>
126 </span>
127 <?php
128 }
129
130 /**
131 * Render an accessible-only title for the Service row-actions column.
132 *
133 * @param array<string,mixed> $column Normalized shared listing column settings.
134 * @param WPBC_UI_Listing $listing Shared listing instance.
135 *
136 * @return void
137 */
138 function wpbc_appointment_services_render_actions_listing_header( $column, $listing ) {
139 unset( $column, $listing );
140 ?><span class="screen-reader-text"><?php esc_html_e( 'Actions', 'booking' ); ?></span><?php
141 }
142
143 /**
144 * Render Status and Service ID sorting controls in one listing column.
145 *
146 * Both sort keys use the shared listing request and AJAX controller. Combining
147 * their controls keeps Service ID visible without dedicating another column.
148 *
149 * @param array<string,mixed> $column Normalized shared listing column settings.
150 * @param WPBC_UI_Listing $listing Shared listing instance.
151 *
152 * @return void
153 */
154 function wpbc_appointment_services_render_status_listing_header( $column, $listing ) {
155 unset( $column );
156 $current_sorting = $listing->get_sorting_request();
157 $sort_controls = array(
158 'status' => array(
159 'label' => __( 'Status', 'booking' ),
160 'class' => 'wpbc_appointment_services__status_sort',
161 ),
162 'service_id' => array(
163 'label' => __( 'ID', 'booking' ),
164 'class' => 'wpbc_appointment_services__id_sort',
165 ),
166 );
167 ?>
168 <div class="wpbc_appointment_services__status_header">
169 <?php foreach ( $sort_controls as $sort_key => $sort_control ) : ?>
170 <?php
171 $is_active = $sort_key === $current_sorting['sort_by'];
172 $link_class = 'wpbc_ui_listing__sort_link ' . $sort_control['class'] . ( $is_active ? ' is-active' : '' );
173 $icon_class = $is_active
174 ? ( 'desc' === $current_sorting['sort_order'] ? 'wpbc-bi-arrow-down' : 'wpbc-bi-arrow-up' )
175 : 'wpbc_icn_import_export';
176 ?>
177 <a href="#"
178 class="<?php echo esc_attr( $link_class ); ?>"
179 data-wpbc-listing-sort="<?php echo esc_attr( $listing->get_listing_id() ); ?>"
180 data-wpbc-listing-sort-key="<?php echo esc_attr( $sort_key ); ?>">
181 <span><?php echo esc_html( $sort_control['label'] ); ?></span>
182 <i class="wpbc_ui_listing__sort_icon <?php echo esc_attr( $icon_class ); ?>" aria-hidden="true"></i>
183 </a>
184 <?php endforeach; ?>
185 </div>
186 <?php
187 }
188
189 /**
190 * Return the shared listing configured for the Services catalog.
191 *
192 * The same instance is used by the page renderer and AJAX endpoint so the
193 * displayed and persisted page sizes always follow one allow-list.
194 *
195 * @return WPBC_UI_Listing Services catalog listing component.
196 */
197 function wpbc_appointment_services_get_catalog_listing() {
198 static $service_listing = null;
199
200 if ( null === $service_listing ) {
201 $columns = array(
202 'service' => array(
203 'label' => __( 'Service', 'booking' ),
204 'class' => 'column-service',
205 'sortable' => 'title',
206 ),
207 'duration' => array(
208 'label' => __( 'Duration', 'booking' ),
209 'class' => 'column-duration',
210 'sortable' => 'duration',
211 ),
212 );
213 if ( wpbc_appointment_services_is_pricing_available() ) {
214 $columns['price'] = array(
215 'label' => __( 'Price', 'booking' ),
216 'class' => 'column-price',
217 'sortable' => 'price',
218 );
219 }
220 $columns += array(
221 'providers' => array(
222 'label' => __( 'Providers', 'booking' ),
223 'class' => 'column-providers',
224 ),
225 'weekdays' => array(
226 'class' => 'column-weekdays',
227 'header_callback' => 'wpbc_appointment_services_render_weekday_listing_header',
228 ),
229 'status' => array(
230 'label' => __( 'Status', 'booking' ),
231 'class' => 'column-status',
232 'sortable' => 'status',
233 'header_callback' => 'wpbc_appointment_services_render_status_listing_header',
234 ),
235 'actions' => array(
236 'class' => 'column-actions',
237 'header_callback' => 'wpbc_appointment_services_render_actions_listing_header',
238 ),
239 );
240
241 $service_listing = new WPBC_UI_Listing(
242 'appointment_services_catalog',
243 array(
244 'aria_label' => __( 'Services catalog', 'booking' ),
245 'items_per_page_default' => 10,
246 'items_per_page_options' => array( 5, 10, 50, 100 ),
247 'sort_by' => 'service_id',
248 'sort_order' => 'desc',
249 'sort_keys' => array( 'service_id' ),
250 'columns' => $columns,
251 'classes' => array(
252 'container' => 'wpbc_appointment_services__list',
253 'table_wrap' => 'wpbc_appointment_services__table_wrap',
254 'table' => 'wpbc_appointment_services__table',
255 'footer' => 'wpbc_appointment_services__list_footer',
256 'result_count' => 'wpbc_appointment_services__result_count',
257 'items_per_page' => 'wpbc_appointment_services__items_per_page',
258 'pagination' => 'wpbc_appointment_services__pagination',
259 'previous' => 'wpbc_appointment_services__page_prev',
260 'page_label' => 'wpbc_appointment_services__page_label',
261 'next' => 'wpbc_appointment_services__page_next',
262 ),
263 )
264 );
265 }
266
267 return $service_listing;
268 }
269
270 /**
271 * Resolve the MultiUser-aware owner for Service queries and writes.
272 *
273 * @return int Owner user ID, or zero for site-wide ownership.
274 */
275 function wpbc_appointment_services_get_owner_user_id() {
276 if ( class_exists( 'WPBC_FE_Custom_Form_Helper' ) ) {
277 return absint( WPBC_FE_Custom_Form_Helper::wpbc_mu__get_current__owner_user_id() );
278 }
279 if ( class_exists( 'wpdev_bk_multiuser' ) ) {
280 $current_user_id = wpbc_get_current_user_id();
281 if ( ! apply_bk_filter( 'is_user_super_admin', $current_user_id ) ) { return absint( $current_user_id ); }
282 }
283 return 0;
284 }
285
286 /**
287 * Determine whether the current user may query Services from all owners.
288 *
289 * @return bool True for a Booking Calendar MultiUser super administrator.
290 */
291 function wpbc_appointment_services_can_view_all_owners() {
292 return class_exists( 'wpdev_bk_multiuser' ) && apply_bk_filter( 'is_user_super_admin', wpbc_get_current_user_id() );
293 }
294
295 /**
296 * Return existing Booking Calendar resources as Provider choices.
297 *
298 * @return array<int,string> Provider titles keyed by booking resource ID.
299 */
300 function wpbc_appointment_services_get_provider_options() {
301 $options = array();
302 $resources = apply_bk_filter( 'wpdebk_get_keyed_all_bk_resources', array() );
303 foreach ( (array) $resources as $resource_id => $resource ) {
304 $resource = is_object( $resource ) ? get_object_vars( $resource ) : (array) $resource;
305 $id = ! empty( $resource['id'] ) ? absint( $resource['id'] ) : absint( $resource_id );
306 if ( $id ) { $options[ $id ] = ! empty( $resource['title'] ) ? wpbc_lang( $resource['title'] ) : sprintf( __( 'Provider #%d', 'booking' ), $id ); }
307 }
308 if ( empty( $options ) && ! class_exists( 'wpdev_bk_personal' ) ) {
309 $title = function_exists( 'wpbc_get_resource_title' ) ? wpbc_get_resource_title( 1 ) : '';
310 $options[1] = $title ? $title : __( 'Default Provider', 'booking' );
311 }
312 return apply_filters( 'wpbc_appointment_service_provider_options', $options );
313 }
314
315 /**
316 * Build the Provider-specific Working Time administration URL.
317 *
318 * The Services catalog exposes only a recurring weekday summary. This URL
319 * opens the native General Availability page at the Provider-aware Working
320 * Time section where that summary can be edited.
321 *
322 * @param int $resource_id Provider booking resource ID.
323 *
324 * @return string Provider availability URL, or an empty string when the
325 * current user cannot manage availability.
326 */
327 function wpbc_appointment_services_get_provider_availability_url( $resource_id ) {
328 $resource_id = absint( $resource_id );
329
330 if ( ! $resource_id || ! wpbc_appointment_services_can_manage_availability() ) {
331 return '';
332 }
333
334 $availability_url = function_exists( 'wpbc_get_general_availability_url' )
335 ? wpbc_get_general_availability_url()
336 : admin_url( 'admin.php?page=wpbc-availability&tab=general_availability' );
337
338 return esc_url_raw(
339 add_query_arg(
340 array(
341 'resource_id' => $resource_id,
342 'wpbc_ag_open' => 'working_time',
343 ),
344 $availability_url
345 )
346 );
347 }
348
349 /**
350 * Resolve the recurring weekday availability known for one Provider.
351 *
352 * General unavailable weekdays are authoritative. When Working Time is
353 * enabled, weekdays without any effective interval are also unavailable.
354 * Date-specific and seasonal exceptions remain in the native calendar engine
355 * and are intentionally not guessed by this lightweight administration state.
356 *
357 * @param int $resource_id Provider booking resource ID.
358 * @param array<string,mixed> $resource Optional source resource values.
359 *
360 * @return array<string,bool> Availability keyed by mon through sun.
361 */
362 function wpbc_appointment_services_get_provider_weekday_availability( $resource_id, $resource = array() ) {
363 $weekdays = array(
364 'mon' => 'On' !== get_bk_option( 'booking_unavailable_day1' ),
365 'tue' => 'On' !== get_bk_option( 'booking_unavailable_day2' ),
366 'wed' => 'On' !== get_bk_option( 'booking_unavailable_day3' ),
367 'thu' => 'On' !== get_bk_option( 'booking_unavailable_day4' ),
368 'fri' => 'On' !== get_bk_option( 'booking_unavailable_day5' ),
369 'sat' => 'On' !== get_bk_option( 'booking_unavailable_day6' ),
370 'sun' => 'On' !== get_bk_option( 'booking_unavailable_day0' ),
371 );
372
373 if ( function_exists( 'wpbc_working_time__get_effective_rule' ) ) {
374 $working_time_rule = wpbc_working_time__get_effective_rule( absint( $resource_id ) );
375 if ( is_array( $working_time_rule ) && isset( $working_time_rule['weekdays'] ) ) {
376 $day_numbers = array(
377 'mon' => 1,
378 'tue' => 2,
379 'wed' => 3,
380 'thu' => 4,
381 'fri' => 5,
382 'sat' => 6,
383 'sun' => 0,
384 );
385 foreach ( $day_numbers as $day_key => $day_number ) {
386 $weekdays[ $day_key ] = $weekdays[ $day_key ] && ! empty( $working_time_rule['weekdays'][ $day_number ] );
387 }
388 }
389 }
390
391 return (array) apply_filters( 'wpbc_appointment_service_provider_weekday_availability', $weekdays, absint( $resource_id ), $resource );
392 }
393
394 /**
395 * Determine whether a Provider has at least one recurring bookable weekday.
396 *
397 * @param int $resource_id Provider booking resource ID.
398 * @param array<string,mixed> $resource Optional source resource values.
399 *
400 * @return bool True when one or more weekdays are available.
401 */
402 function wpbc_appointment_services_provider_has_weekly_availability( $resource_id, $resource = array() ) {
403 return in_array( true, wpbc_appointment_services_get_provider_weekday_availability( $resource_id, $resource ), true );
404 }
405
406 /**
407 * Resolve the public image configured for one Provider Booking Resource.
408 *
409 * Provider images are stored by the Business Large Searchable Resources
410 * module. Callers pass that module's option collection so it is loaded only
411 * once when several Providers are rendered. Other editions pass an empty
412 * collection and retain their existing avatar or initials fallback.
413 *
414 * @param int $provider_id Provider Booking Resource ID.
415 * @param array<int,array<string,mixed>> $search_options Searchable Resource options keyed by resource ID.
416 *
417 * @return string Sanitized Provider image URL or an empty string.
418 */
419 function wpbc_appointment_services_get_provider_image_url( $provider_id, $search_options ) {
420 $provider_id = absint( $provider_id );
421 if ( ! $provider_id || empty( $search_options[ $provider_id ]['picture'] ) ) {
422 return '';
423 }
424
425 $image_value = $search_options[ $provider_id ]['picture'];
426 if ( is_array( $image_value ) ) {
427 $image_value = reset( $image_value );
428 }
429 if ( ! is_scalar( $image_value ) ) {
430 return '';
431 }
432
433 return esc_url_raw( wpbc_lang( (string) $image_value ) );
434 }
435
436 /**
437 * Provider presentation data for the Services management table.
438 *
439 * Booking resources remain the availability authority. The default weekday
440 * map combines General Availability with each resource's effective Working
441 * Time rule. Business Large Searchable Resource pictures are reused before a
442 * WordPress user avatar, keeping the administration catalog consistent with
443 * the public Appointment flow. Extensions may replace the presentation or
444 * recurring-weekday summary through the filters below without changing the
445 * Services page contract.
446 *
447 * @return array<int,array<string,mixed>>
448 */
449 function wpbc_appointment_services_get_provider_directory() {
450 $directory = array();
451 $options = wpbc_appointment_services_get_provider_options();
452 $resources = apply_bk_filter( 'wpdebk_get_keyed_all_bk_resources', array() );
453 $resources_by_id = array();
454 $search_options = function_exists( 'wpbc_searchable_resources__get_all_options' )
455 ? (array) wpbc_searchable_resources__get_all_options()
456 : array();
457
458 foreach ( (array) $resources as $resource_id => $resource ) {
459 $resource = is_object( $resource ) ? get_object_vars( $resource ) : (array) $resource;
460 $id = ! empty( $resource['id'] ) ? absint( $resource['id'] ) : absint( $resource_id );
461 if ( $id ) { $resources_by_id[ $id ] = $resource; }
462 }
463
464 foreach ( $options as $resource_id => $title ) {
465 $resource = isset( $resources_by_id[ $resource_id ] ) ? $resources_by_id[ $resource_id ] : array();
466 $user_id = ! empty( $resource['users'] ) ? absint( $resource['users'] ) : 0;
467 $resource_image_url = wpbc_appointment_services_get_provider_image_url( $resource_id, $search_options );
468 $wp_user_avatar_url = $user_id ? get_avatar_url( $user_id, array( 'size' => 64 ) ) : '';
469 $words = preg_split( '/\s+/u', trim( wp_strip_all_tags( $title ) ) );
470 $initials = '';
471 foreach ( array_slice( array_filter( (array) $words ), 0, 2 ) as $word ) {
472 $initials .= function_exists( 'mb_substr' ) ? mb_substr( $word, 0, 1 ) : substr( $word, 0, 1 );
473 }
474 $provider_weekdays = wpbc_appointment_services_get_provider_weekday_availability( $resource_id, $resource );
475 $entry = array(
476 'id' => absint( $resource_id ),
477 'title' => $title,
478 'initials' => strtoupper( $initials ? $initials : 'P' ),
479 'avatar_url' => $resource_image_url ? $resource_image_url : $wp_user_avatar_url,
480 'availability_url' => wpbc_appointment_services_get_provider_availability_url( $resource_id ),
481 'weekdays' => $provider_weekdays,
482 'has_weekly_availability' => in_array( true, $provider_weekdays, true ),
483 );
484 $directory[ $resource_id ] = apply_filters( 'wpbc_appointment_service_provider_presentation', $entry, absint( $resource_id ), $resource );
485 }
486
487 return $directory;
488 }
489
490 /**
491 * Return published Booking Forms that can be assigned to a Service.
492 *
493 * @return array<int,string> Form titles keyed by form ID; zero means the default form.
494 */
495 function wpbc_appointment_services_get_form_options() {
496 $options = array( 0 => __( 'Default Booking Form', 'booking' ) );
497 if ( class_exists( 'WPBC_FE_Custom_Form_Helper' ) ) {
498 $forms = WPBC_FE_Custom_Form_Helper::get_custom_booking_forms_list(
499 array( 'include_standard' => false, 'owner_user_id' => wpbc_appointment_services_get_owner_user_id(), 'statuses' => array( 'published' ) )
500 );
501 foreach ( $forms as $form ) {
502 if ( ! empty( $form['id'] ) ) { $options[ absint( $form['id'] ) ] = ! empty( $form['title'] ) ? $form['title'] : $form['name']; }
503 }
504 }
505 return apply_filters( 'wpbc_appointment_service_form_options', $options );
506 }
507
508 /**
509 * Return the configured Appointment Services data provider.
510 *
511 * The native repository supplies list_items(), count_items(), find(), save(),
512 * duplicate(), and archive(). Extensions may replace it through the provider
513 * filter; count_items() enables efficient server-side catalog pagination.
514 *
515 * @return object|null
516 */
517 function wpbc_appointment_services_get_data_provider() {
518 return apply_filters( 'wpbc_appointment_services_data_provider', null );
519 }
520
521 /**
522 * Determine whether the configured Service storage is ready for requests.
523 *
524 * @return bool True when a provider exists and reports usable storage.
525 */
526 function wpbc_appointment_services_storage_is_ready() {
527 $provider = wpbc_appointment_services_get_data_provider();
528 if ( ! is_object( $provider ) || ! method_exists( $provider, 'list_items' ) ) { return false; }
529 return method_exists( $provider, 'is_ready' ) ? (bool) $provider->is_ready() : true;
530 }
531
532 /**
533 * Build the standard error returned when Service storage is unavailable.
534 *
535 * @return WP_Error Storage-not-ready error with administrator guidance.
536 */
537 function wpbc_appointment_services_storage_error() {
538 return new WP_Error( 'appointment_services_storage_unavailable', __( 'The Services database is not ready. Reload the page as an administrator or reactivate Booking Calendar to finish the database upgrade.', 'booking' ) );
539 }
540
541 /**
542 * Sanitize and normalize a Service payload at the repository boundary.
543 *
544 * @param mixed $payload Raw Service values from AJAX or another provider.
545 *
546 * @return array<string,mixed> Safe values with defaults and bounded numeric fields.
547 */
548 function wpbc_appointment_services_sanitize_payload( $payload ) {
549 $payload = is_array( $payload ) ? $payload : array();
550 $title = isset( $payload['title'] ) ? sanitize_text_field( $payload['title'] ) : '';
551 $title = wp_html_excerpt( $title, 200, '' );
552 $metadata = wpbc_appointment_services_decode_metadata( isset( $payload['metadata'] ) ? $payload['metadata'] : array() );
553 $picture_url = array_key_exists( 'picture_url', $payload ) ? $payload['picture_url'] : ( isset( $metadata['picture_url'] ) ? $metadata['picture_url'] : '' );
554 $picture_url = is_scalar( $picture_url ) ? esc_url_raw( trim( (string) $picture_url ) ) : '';
555 return array(
556 'service_id' => isset( $payload['service_id'] ) ? absint( $payload['service_id'] ) : 0,
557 'title' => $title,
558 'description' => isset( $payload['description'] ) ? sanitize_textarea_field( $payload['description'] ) : '',
559 'picture_url' => $picture_url,
560 'status' => isset( $payload['status'] ) && in_array( $payload['status'], array( 'active', 'inactive', 'archived' ), true ) ? $payload['status'] : 'active',
561 'duration_minutes' => isset( $payload['duration_minutes'] ) ? min( 65535, max( 1, absint( $payload['duration_minutes'] ) ) ) : 30,
562 'buffer_before_minutes' => isset( $payload['buffer_before_minutes'] ) ? min( 65535, absint( $payload['buffer_before_minutes'] ) ) : 0,
563 'buffer_after_minutes' => isset( $payload['buffer_after_minutes'] ) ? min( 65535, absint( $payload['buffer_after_minutes'] ) ) : 0,
564 'base_cost' => isset( $payload['base_cost'] ) && is_numeric( $payload['base_cost'] ) ? number_format( min( 9999999999.99, max( 0, (float) $payload['base_cost'] ) ), 2, '.', '' ) : '0.00',
565 'booking_form_id' => isset( $payload['booking_form_id'] ) ? absint( $payload['booking_form_id'] ) : 0,
566 'resource_ids' => isset( $payload['resource_ids'] ) ? array_values( array_filter( array_map( 'absint', (array) $payload['resource_ids'] ) ) ) : array(),
567 );
568 }
569
570 /**
571 * Apply nullable Provider-assignment overrides to one Service row.
572 *
573 * The returned `duration_minutes` and `base_cost` are effective values used by
574 * the Appointment flow. Original Service values and the applied override are
575 * retained separately so snapshots and extensions can explain their source.
576 *
577 * @param mixed $service Service row optionally containing assignment overrides.
578 *
579 * @return array<string,mixed> Service row with effective scheduling values.
580 */
581 function wpbc_appointment_services_apply_assignment_overrides( $service ) {
582 $service = is_object( $service ) ? get_object_vars( $service ) : (array) $service;
583
584 $base_duration_minutes = isset( $service['duration_minutes'] ) ? min( 65535, absint( $service['duration_minutes'] ) ) : 0;
585 $has_duration_override = array_key_exists( 'duration_override', $service ) && null !== $service['duration_override'] && absint( $service['duration_override'] ) > 0;
586 $duration_override_minutes = $has_duration_override ? min( 65535, absint( $service['duration_override'] ) ) : 0;
587 $base_service_cost = isset( $service['base_cost'] ) && is_numeric( $service['base_cost'] ) ? min( 9999999999.99, max( 0, (float) $service['base_cost'] ) ) : 0.0;
588 $has_cost_override = array_key_exists( 'cost_override', $service ) && null !== $service['cost_override'] && is_numeric( $service['cost_override'] );
589 $cost_override = $has_cost_override ? min( 9999999999.99, max( 0, (float) $service['cost_override'] ) ) : null;
590
591 $service['base_duration_minutes'] = $base_duration_minutes;
592 $service['duration_override_minutes'] = $duration_override_minutes;
593 $service['duration_minutes'] = $has_duration_override ? $duration_override_minutes : $base_duration_minutes;
594 $service['base_service_cost'] = number_format( $base_service_cost, 2, '.', '' );
595 $service['cost_override'] = null === $cost_override ? null : number_format( $cost_override, 2, '.', '' );
596 $service['base_cost'] = number_format( null === $cost_override ? $base_service_cost : $cost_override, 2, '.', '' );
597
598 return (array) apply_filters( 'wpbc_appointment_service_effective_assignment', $service );
599 }
600
601 /**
602 * Return a stable public title for a Provider resource.
603 *
604 * @param int $resource_id Booking resource acting as the Provider.
605 *
606 * @return string Localized plain-text Provider title.
607 */
608 function wpbc_appointment_services_get_provider_title( $resource_id ) {
609 $resource_id = absint( $resource_id );
610 $title = function_exists( 'wpbc_get_resource_title' ) ? wpbc_get_resource_title( $resource_id ) : '';
611 if ( '' === trim( wp_strip_all_tags( (string) $title ) ) ) {
612 $options = wpbc_appointment_services_get_provider_options();
613 $title = isset( $options[ $resource_id ] ) ? $options[ $resource_id ] : sprintf( __( 'Provider #%d', 'booking' ), $resource_id );
614 }
615
616 return sanitize_text_field( wp_strip_all_tags( wpbc_lang( (string) $title ) ) );
617 }
618
619 /**
620 * Decode an Appointment Service metadata value into an associative array.
621 *
622 * @param mixed $metadata JSON string or already-decoded metadata.
623 *
624 * @return array<string,mixed> Decoded metadata, or an empty array for invalid input.
625 */
626 function wpbc_appointment_services_decode_metadata( $metadata ) {
627 if ( is_array( $metadata ) ) {
628 return $metadata;
629 }
630
631 $decoded = json_decode( (string) $metadata, true );
632
633 return is_array( $decoded ) ? $decoded : array();
634 }
635
636 /**
637 * Decode Appointment snapshot metadata using the shared metadata parser.
638 *
639 * This compatibility wrapper preserves the existing snapshot helper while
640 * allowing Service records and snapshots to share one defensive JSON parser.
641 *
642 * @param mixed $metadata JSON string or already-decoded metadata.
643 *
644 * @return array<string,mixed> Decoded metadata, or an empty array for invalid input.
645 */
646 function wpbc_appointment_services_decode_snapshot_metadata( $metadata ) {
647 return wpbc_appointment_services_decode_metadata( $metadata );
648 }
649
650 /**
651 * Normalize a provider result into the stable Service response contract.
652 *
653 * @param mixed $service Service row returned by a data provider.
654 *
655 * @return array<string,mixed> Normalized Service item.
656 */
657 function wpbc_appointment_services_normalize_item( $service ) {
658 $service = is_object( $service ) ? get_object_vars( $service ) : (array) $service;
659 return wp_parse_args( wpbc_appointment_services_sanitize_payload( $service ), array( 'service_id' => 0, 'title' => '' ) );
660 }
661
662 /**
663 * Normalize Service status counts returned by a repository.
664 *
665 * @param mixed $raw_counts Repository count response.
666 *
667 * @return array<string,int> Counts keyed by all, active, inactive, and archived.
668 */
669 function wpbc_appointment_services_normalize_status_counts( $raw_counts ) {
670 $raw_counts = is_array( $raw_counts ) ? $raw_counts : array();
671 $counts = array(
672 'all' => 0,
673 'active' => isset( $raw_counts['active'] ) ? absint( $raw_counts['active'] ) : 0,
674 'inactive' => isset( $raw_counts['inactive'] ) ? absint( $raw_counts['inactive'] ) : 0,
675 'archived' => isset( $raw_counts['archived'] ) ? absint( $raw_counts['archived'] ) : 0,
676 );
677 $counts['all'] = $counts['active'] + $counts['inactive'] + $counts['archived'];
678
679 return $counts;
680 }
681
682 /**
683 * Load one exact page of Services through the shared listing contract.
684 *
685 * Providers implementing count_items() receive the efficient contract: one
686 * grouped count followed by one LIMIT/OFFSET page query. Existing replacement
687 * providers remain compatible through a bounded in-memory pagination fallback,
688 * but should implement count_items() and honor list_items() limit/offset values
689 * before serving large catalogs.
690 *
691 * @param object $provider Service data provider.
692 * @param array $query Search, status, Provider, page, and page-size values.
693 * @param WPBC_UI_Listing $listing Shared listing instance.
694 *
695 * @return array<string,mixed>|WP_Error Normalized Services, counts, and pagination metadata.
696 */
697 function wpbc_appointment_services_get_catalog_page( $provider, $query, $listing ) {
698 $query = wp_parse_args(
699 is_array( $query ) ? $query : array(),
700 array(
701 'search' => '',
702 'status' => 'all',
703 'resource_id' => 0,
704 'page_number' => 1,
705 'items_per_page' => $listing->get_items_per_page(),
706 'sort_by' => null,
707 'sort_order' => null,
708 )
709 );
710 $status = in_array( $query['status'], array( 'all', 'active', 'inactive', 'archived' ), true ) ? $query['status'] : 'all';
711 $sorting = $listing->get_sorting_request( $query['sort_by'], $query['sort_order'] );
712 $query['sort_by'] = $sorting['sort_by'];
713 $query['sort_order'] = $sorting['sort_order'];
714
715 if ( method_exists( $provider, 'count_items' ) ) {
716 $raw_counts = $provider->count_items( $query );
717 if ( is_wp_error( $raw_counts ) ) {
718 return $raw_counts;
719 }
720
721 $counts = wpbc_appointment_services_normalize_status_counts( $raw_counts );
722 $total_items = 'all' === $status ? $counts['all'] : $counts[ $status ];
723 $pagination = $listing->get_pagination_data( $total_items, $query['page_number'], $query['items_per_page'] );
724 $page_query = array_merge(
725 $query,
726 array(
727 'status' => $status,
728 'limit' => $pagination['limit'],
729 'offset' => $pagination['offset'],
730 )
731 );
732 $services = $provider->list_items( $page_query );
733 if ( is_wp_error( $services ) ) {
734 return $services;
735 }
736
737 $services = array_slice( (array) $services, 0, $pagination['limit'] );
738 } else {
739 $services = $provider->list_items(
740 array_merge(
741 $query,
742 array( 'status' => 'all' )
743 )
744 );
745 if ( is_wp_error( $services ) ) {
746 return $services;
747 }
748
749 $all_services = array_map( 'wpbc_appointment_services_normalize_item', (array) $services );
750 $counts = array(
751 'all' => count( $all_services ),
752 'active' => 0,
753 'inactive' => 0,
754 'archived' => 0,
755 );
756 foreach ( $all_services as $service ) {
757 if ( isset( $counts[ $service['status'] ] ) ) {
758 ++$counts[ $service['status'] ];
759 }
760 }
761 $services = 'all' === $status
762 ? $all_services
763 : array_values(
764 array_filter(
765 $all_services,
766 static function ( $service ) use ( $status ) {
767 return $status === $service['status'];
768 }
769 )
770 );
771 $pagination = $listing->get_pagination_data( count( $services ), $query['page_number'], $query['items_per_page'] );
772 $services = array_slice( $services, $pagination['offset'], $pagination['limit'] );
773 }
774
775 return array(
776 'services' => array_map( 'wpbc_appointment_services_normalize_item', (array) $services ),
777 'counts' => $counts,
778 'pagination' => $pagination,
779 'sorting' => $sorting,
780 );
781 }
782
783 /**
784 * Authorize an Appointment Services AJAX request.
785 *
786 * @return void Terminates with JSON when the nonce or capability check fails.
787 */
788 function wpbc_appointment_services_ajax_authorize() {
789 if ( false === check_ajax_referer( 'wpbc_appointment_services_ajax_nonce', 'nonce', false ) ) {
790 wp_send_json_error( array( 'message' => __( 'Security check failed.', 'booking' ) ), 403 );
791 }
792 if ( ! current_user_can( wpbc_appointment_services_get_manage_capability() ) ) {
793 wp_send_json_error( array( 'message' => __( 'You do not have permission to manage services.', 'booking' ) ), 403 );
794 }
795 }
796
797 /**
798 * Send a consistent JSON error for a failed data-provider operation.
799 *
800 * @param mixed $result Provider result, optionally a WP_Error instance.
801 * @param string $fallback Fallback message when no provider error is available.
802 *
803 * @return void Terminates with a JSON error response.
804 */
805 function wpbc_appointment_services_send_provider_error( $result, $fallback ) {
806 $message = is_wp_error( $result ) ? $result->get_error_message() : $fallback;
807 wp_send_json_error( array( 'message' => $message ), 500 );
808 }
809