PluginProbe
Booking Calendar / 11.6.1
Booking Calendar v11.6.1
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 10.11 All 204 releases
booking / includes / booking-appointment / booking-appointment__render.php

booking-appointment__render.php in Booking Calendar 11.6.1, at includes/booking-appointment/booking-appointment__render.php

862 lines 43.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Server-side Appointment selector and native booking-form rendering.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Return a compact human-readable duration.
14 *
15 * @param int $minutes Duration in minutes.
16 *
17 * @return string Localized duration.
18 */
19 function wpbc_booking_appointment_format_duration( $minutes ) {
20 $minutes = absint( $minutes );
21 $hours = (int) floor( $minutes / 60 );
22 $rest = $minutes % 60;
23 $parts = array();
24 if ( $hours ) {
25 $parts[] = sprintf( _n( '%d hour', '%d hours', $hours, 'booking' ), $hours );
26 }
27 if ( $rest || ! $hours ) {
28 $parts[] = sprintf( _n( '%d minute', '%d minutes', $rest, 'booking' ), $rest );
29 }
30
31 return implode( ' ', $parts );
32 }
33
34 /**
35 * Return a clear duration summary before a Provider is selected.
36 *
37 * @param array<string,mixed> $service Public Service definition.
38 *
39 * @return string One duration or a Provider-dependent explanation.
40 */
41 function wpbc_booking_appointment_get_duration_summary( $service ) {
42 $durations = array();
43 foreach ( (array) ( isset( $service['provider_rules'] ) ? $service['provider_rules'] : array() ) as $rules ) {
44 if ( ! empty( $rules['duration_minutes'] ) ) {
45 $durations[] = absint( $rules['duration_minutes'] );
46 }
47 }
48 $durations = array_values( array_unique( $durations ) );
49 if ( count( $durations ) > 1 ) {
50 return __( 'Duration varies by Provider', 'booking' );
51 }
52
53 $duration_minutes = $durations ? reset( $durations ) : absint( $service['duration_minutes'] );
54
55 return wpbc_booking_appointment_format_duration( $duration_minutes );
56 }
57
58 /**
59 * Return a clear price summary before a Provider is selected.
60 *
61 * @param array<string,mixed> $service Public Service definition.
62 *
63 * @return string One price, a Provider-dependent explanation, or empty text.
64 */
65 function wpbc_booking_appointment_get_cost_summary( $service ) {
66 $costs = array();
67 foreach ( (array) ( isset( $service['provider_rules'] ) ? $service['provider_rules'] : array() ) as $rules ) {
68 if ( isset( $rules['base_cost'] ) && is_numeric( $rules['base_cost'] ) ) {
69 $costs[] = number_format( (float) $rules['base_cost'], 2, '.', '' );
70 }
71 }
72 $costs = array_values( array_unique( $costs ) );
73 if ( count( $costs ) > 1 ) {
74 return __( 'Price varies by Provider', 'booking' );
75 }
76
77 $cost = $costs ? reset( $costs ) : ( isset( $service['base_cost'] ) ? $service['base_cost'] : '' );
78 $resource_id = ! empty( $service['resource_ids'] ) ? absint( reset( $service['resource_ids'] ) ) : 0;
79
80 return function_exists( 'wpbc_booking_appointment_format_service_cost_text' )
81 ? wpbc_booking_appointment_format_service_cost_text( $cost, $resource_id )
82 : '';
83 }
84
85 /**
86 * Join non-empty Appointment summary fragments with a stable separator.
87 *
88 * @param array<int,string> $parts Summary fragments.
89 *
90 * @return string Joined summary.
91 */
92 function wpbc_booking_appointment_join_summary_parts( $parts ) {
93 $parts = array_values( array_filter( array_map( 'trim', $parts ) ) );
94
95 return implode( ' · ', $parts );
96 }
97
98 /**
99 * Build the form action used only by the non-JavaScript fallback.
100 *
101 * @param array<string,mixed> $config Appointment configuration containing the
102 * original public page URL during AJAX.
103 *
104 * @return string Public URL without Appointment selector query parameters.
105 */
106 function wpbc_booking_appointment_get_fallback_url( $config = array() ) {
107 $url = ! empty( $config['return_url'] ) ? esc_url_raw( $config['return_url'] ) : '';
108 if ( ! $url ) {
109 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( (string) $_SERVER['REQUEST_URI'] ) : '/'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
110 $url = home_url( $request_uri );
111 }
112 $url = remove_query_arg( array( 'wpbc_appointment_service', 'wpbc_appointment_provider' ), $url );
113
114 return wp_validate_redirect( $url, home_url( '/' ) );
115 }
116
117 /**
118 * Render the three-step progress indicator.
119 *
120 * @param int $active_step Current step from 1 to 3.
121 * @param array<string,mixed> $config Normalized Appointment configuration.
122 *
123 * @return string Progress markup, or an empty string when disabled.
124 */
125 function wpbc_booking_appointment_render_progress( $active_step, $config = array() ) {
126 if ( isset( $config['show_progress'] ) && ! $config['show_progress'] ) {
127 return '';
128 }
129
130 $steps = array(
131 1 => array(
132 'number' => isset( $config['progress_item_1_number'] ) ? $config['progress_item_1_number'] : '1',
133 'title' => isset( $config['progress_item_1_title'] ) ? $config['progress_item_1_title'] : __( 'Service', 'booking' ),
134 ),
135 2 => array(
136 'number' => isset( $config['progress_item_2_number'] ) ? $config['progress_item_2_number'] : '2',
137 'title' => isset( $config['progress_item_2_title'] ) ? $config['progress_item_2_title'] : __( 'Provider', 'booking' ),
138 ),
139 3 => array(
140 'number' => isset( $config['progress_item_3_number'] ) ? $config['progress_item_3_number'] : '3',
141 'title' => isset( $config['progress_item_3_title'] ) ? $config['progress_item_3_title'] : __( 'Date & Details', 'booking' ),
142 ),
143 );
144 $html = '<ol class="wpbc_booking_appointment__progress" aria-label="' . esc_attr__( 'Appointment booking progress', 'booking' ) . '">';
145 foreach ( $steps as $step_number => $step ) {
146 $class = $step_number < $active_step ? ' is-complete' : ( $step_number === $active_step ? ' is-active' : '' );
147 $html .= '<li class="wpbc_booking_appointment__progress_step' . esc_attr( $class ) . '"' . ( $step_number === $active_step ? ' aria-current="step"' : '' ) . '>';
148 $html .= '<span class="wpbc_booking_appointment__progress_number">' . esc_html( $step['number'] ) . '</span><span class="wpbc_booking_appointment__progress_label">' . esc_html( $step['title'] ) . '</span></li>';
149 }
150 $html .= '</ol>';
151
152 return $html;
153 }
154
155 /**
156 * Render a configurable Appointment screen heading.
157 *
158 * Omitted title or description parameters use translated defaults. Explicitly
159 * empty values hide only that element, while two empty values remove the
160 * heading wrapper completely.
161 *
162 * @param array<string,mixed> $config Normalized Appointment configuration.
163 * @param int $screen_number One-based Appointment screen number.
164 * @param string $default_title Translated default screen title.
165 * @param string $default_description Translated default screen description.
166 *
167 * @return string Escaped heading markup, or an empty string when both values are empty.
168 */
169 function wpbc_booking_appointment_render_screen_heading( $config, $screen_number, $default_title, $default_description ) {
170 $screen_number = absint( $screen_number );
171 if ( ! $screen_number ) {
172 return '';
173 }
174
175 $title_key = 'screen_' . $screen_number . '_title';
176 $description_key = 'screen_' . $screen_number . '_description';
177 $title = isset( $config[ $title_key ] ) ? $config[ $title_key ] : $default_title;
178 $description = isset( $config[ $description_key ] ) ? $config[ $description_key ] : $default_description;
179
180 if ( '' === $title && '' === $description ) {
181 return '';
182 }
183
184 $html = '<div class="wpbc_booking_appointment__heading">';
185 if ( '' !== $title ) {
186 $html .= '<h3>' . esc_html( $title ) . '</h3>';
187 }
188 if ( '' !== $description ) {
189 $html .= '<p>' . esc_html( $description ) . '</p>';
190 }
191 $html .= '</div>';
192
193 return $html;
194 }
195
196 /**
197 * Check whether the released frontend catalog presentation is available.
198 *
199 * The Appointment workflow remains fully usable with its legacy card grid when
200 * the 11.6 feature set is disabled. This keeps the release gate aligned with
201 * the Resource Selector catalog and its shortcode popup controls.
202 *
203 * @return bool True when Appointment catalog presentation may be used.
204 */
205 function wpbc_booking_appointment_is_catalog_enabled() {
206 return function_exists( 'wpbc_is_11_6_features_enabled' ) && wpbc_is_11_6_features_enabled();
207 }
208
209 /**
210 * Prepare Appointment presentation values for the active feature state.
211 *
212 * Stored or manually authored 11.6 catalog values remain harmless when the
213 * feature is disabled: the compatibility renderer uses its established visible
214 * card content and ignores all new presentation toggles.
215 *
216 * @param array<string,mixed> $config Raw or normalized Appointment configuration.
217 *
218 * @return array<string,mixed> Normalized render configuration.
219 */
220 function wpbc_booking_appointment_get_catalog_render_config( $config ) {
221 $config = wpbc_booking_appointment_normalize_config( $config );
222 if ( wpbc_booking_appointment_is_catalog_enabled() ) {
223 return $config;
224 }
225
226 foreach ( array( 'show_resource_image', 'show_resource_title', 'show_resource_description', 'show_resource_hierarchy', 'show_availability', 'show_starting_price' ) as $visible_config_key ) {
227 $config[ $visible_config_key ] = true;
228 }
229
230 return $config;
231 }
232
233 /**
234 * Return a grid track width that accounts for catalog gaps.
235 *
236 * @param string $item_width Normalized catalog item width.
237 *
238 * @return string Safe CSS grid track width.
239 */
240 function wpbc_booking_appointment_get_catalog_track_width( $item_width ) {
241 if ( ! preg_match( '/^(\d+(?:\.\d+)?)%$/', $item_width, $matches ) ) {
242 return $item_width;
243 }
244
245 $percentage_width = (float) $matches[1];
246 $estimated_columns = max( 1, (int) floor( ( 100 + 0.0001 ) / $percentage_width ) );
247 if ( $estimated_columns <= 1 ) {
248 return $item_width;
249 }
250
251 $gap_adjustment = 10 * ( $estimated_columns - 1 ) / $estimated_columns;
252 $gap_adjustment = rtrim( rtrim( number_format( $gap_adjustment, 4, '.', '' ), '0' ), '.' );
253
254 return 'calc(' . $item_width . ' - ' . $gap_adjustment . 'px)';
255 }
256
257 /**
258 * Render the opening markup for a selectable Service or Provider catalog.
259 *
260 * @param array<string,mixed> $config Normalized Appointment configuration.
261 * @param string $catalog_type Either services or providers.
262 * @param int $item_count Number of authorized catalog items.
263 *
264 * @return string Escaped catalog opening markup, including an optional search field.
265 */
266 function wpbc_booking_appointment_render_catalog_open( $config, $catalog_type, $item_count ) {
267 $catalog_type = 'providers' === $catalog_type ? 'providers' : 'services';
268 $is_provider = 'providers' === $catalog_type;
269 $layout = 'list' === $config['catalog_layout'] ? 'list' : 'grid';
270 $column_count = 'list' === $layout ? absint( $config['catalog_list_items_per_row'] ) : absint( $config['catalog_grid_items_per_row'] );
271 $column_count = min( 12, $column_count );
272 $item_width = wpbc_booking_appointment_normalize_css_width( $config['catalog_item_width'] );
273 $catalog_class = 'wpbc_booking_appointment__catalog wpbc_booking_appointment__catalog--' . $layout;
274 $catalog_styles = array();
275
276 if ( empty( $config['show_resource_image'] ) ) {
277 $catalog_class .= ' wpbc_booking_appointment__catalog--without-images';
278 }
279 if ( absint( $config['catalog_item_max_width'] ) > 0 ) {
280 $catalog_class .= ' wpbc_booking_appointment__catalog--bounded';
281 $catalog_styles[] = '--wpbc-appointment-catalog-item-max-width:' . min( 1200, max( 280, absint( $config['catalog_item_max_width'] ) ) ) . 'px';
282 }
283 if ( $column_count > 0 ) {
284 $catalog_class .= ' wpbc_booking_appointment__catalog--explicit-columns wpbc_booking_appointment__catalog--columns-' . $column_count;
285 $catalog_styles[] = '--wpbc-appointment-catalog-items-per-row:' . $column_count;
286 } elseif ( '' !== $item_width ) {
287 $catalog_class .= ' wpbc_booking_appointment__catalog--explicit-item-width';
288 $catalog_styles[] = '--wpbc-appointment-catalog-item-width:' . $item_width;
289 $catalog_styles[] = '--wpbc-appointment-catalog-item-track-width:' . wpbc_booking_appointment_get_catalog_track_width( $item_width );
290 }
291
292 $catalog_style = empty( $catalog_styles ) ? '' : ' style="' . esc_attr( implode( ';', $catalog_styles ) . ';' ) . '"';
293 $catalog_label = $is_provider ? __( 'Providers', 'booking' ) : __( 'Services', 'booking' );
294 $filter_label = $is_provider ? __( 'Filter Providers', 'booking' ) : __( 'Filter Services', 'booking' );
295 $search_label = $is_provider ? __( 'Search Providers', 'booking' ) : __( 'Search Services', 'booking' );
296 $html = '<div class="' . esc_attr( $catalog_class ) . '" data-wpbc-appointment-catalog="" data-catalog-type="' . esc_attr( $catalog_type ) . '" data-layout="' . esc_attr( $layout ) . '"' . $catalog_style . '>';
297
298 if ( ! empty( $config['show_resource_filters'] ) && absint( $item_count ) > 1 ) {
299 $filter_id = wp_unique_id( 'wpbc_appointment_catalog_search_' );
300 $html .= '<div class="wpbc_booking_appointment__catalog_filters" role="search" aria-label="' . esc_attr( $filter_label ) . '">';
301 $html .= '<label class="wpbc_booking_appointment__catalog_search" for="' . esc_attr( $filter_id ) . '"><span class="screen-reader-text">' . esc_html( $search_label ) . '</span><input id="' . esc_attr( $filter_id ) . '" type="search" placeholder="' . esc_attr( $search_label ) . '" data-wpbc-appointment-catalog-search="" autocomplete="off"></label>';
302 $html .= '</div>';
303 }
304
305 $html .= '<div class="wpbc_booking_appointment__choices wpbc_booking_appointment__catalog_items" role="radiogroup" aria-label="' . esc_attr( $catalog_label ) . '" data-wpbc-appointment-catalog-items="">';
306
307 return $html;
308 }
309
310 /**
311 * Render the closing markup and accessible empty state for one catalog.
312 *
313 * @param string $catalog_type Either services or providers.
314 *
315 * @return string Escaped catalog closing markup.
316 */
317 function wpbc_booking_appointment_render_catalog_close( $catalog_type ) {
318 $is_provider = 'providers' === $catalog_type;
319 $empty_label = $is_provider ? __( 'No Providers match your search.', 'booking' ) : __( 'No Services match your search.', 'booking' );
320
321 return '</div><p class="wpbc_booking_appointment__catalog_empty" data-wpbc-appointment-catalog-empty="" role="status" hidden>' . esc_html( $empty_label ) . '</p><p class="screen-reader-text" data-wpbc-appointment-catalog-status="" aria-live="polite"></p></div>';
322 }
323
324 /**
325 * Render the Service choice stage.
326 *
327 * @param array<string,mixed> $catalog Public catalogue.
328 * @param string $config_token Signed AJAX configuration.
329 * @param array<string,mixed> $config Appointment configuration.
330 *
331 * @return string Service picker markup.
332 */
333 function wpbc_booking_appointment_render_services( $catalog, $config_token, $config = array() ) {
334 if ( empty( $catalog['services'] ) ) {
335 $provider_count = isset( $catalog['diagnostics']['provider_count'] ) ? absint( $catalog['diagnostics']['provider_count'] ) : 0;
336 $message = $provider_count
337 ? __( 'No Services are currently available.', 'booking' )
338 : __( 'No Providers are currently available.', 'booking' );
339 if ( current_user_can( wpbc_appointment_services_get_manage_capability() ) ) {
340 $message .= $provider_count
341 ? ' ' . __( 'Activate a Service and assign at least one Provider to it.', 'booking' )
342 : ' ' . __( 'Create a Provider, then assign it to an active Service.', 'booking' );
343 }
344
345 return wpbc_booking_appointment_render_notice( $message, 'empty' );
346 }
347
348 $config = wpbc_booking_appointment_get_catalog_render_config( $config );
349 $catalog_enabled = wpbc_booking_appointment_is_catalog_enabled();
350 $html = wpbc_booking_appointment_render_progress( 1, $config );
351 $html .= wpbc_booking_appointment_render_screen_heading( $config, 1, __( 'Choose a Service', 'booking' ), __( 'Select what you would like to book.', 'booking' ) );
352 $html .= '<form class="wpbc_booking_appointment__selection_form" method="get" action="' . esc_url( wpbc_booking_appointment_get_fallback_url( $config ) ) . '" data-appointment-stage="service">';
353 $html .= $catalog_enabled
354 ? wpbc_booking_appointment_render_catalog_open( $config, 'services', count( $catalog['services'] ) )
355 : '<div class="wpbc_booking_appointment__choices" role="radiogroup" aria-label="' . esc_attr__( 'Services', 'booking' ) . '">';
356 foreach ( $catalog['services'] as $service ) {
357 $service_id = absint( $service['service_id'] );
358 $input_id = 'wpbc_appointment_service_' . $service_id . '_' . wp_rand( 1000, 9999 );
359 $search_text = wp_strip_all_tags( (string) $service['title'] . ' ' . (string) $service['description'] );
360 $choice_class = 'wpbc_booking_appointment__choice wpbc_booking_appointment__choice--service';
361 $choice_attrs = '';
362 if ( $catalog_enabled ) {
363 $choice_class .= ' wpbc_booking_appointment__catalog_card';
364 if ( ! empty( $config['show_resource_image'] ) && empty( $service['picture_url'] ) ) {
365 $choice_class .= ' wpbc_booking_appointment__catalog_card--without-image';
366 }
367 $choice_attrs = ' data-wpbc-appointment-catalog-card="" data-appointment-catalog-search="' . esc_attr( $search_text ) . '"';
368 }
369 $html .= '<label class="' . esc_attr( $choice_class ) . '" for="' . esc_attr( $input_id ) . '"' . $choice_attrs . ' data-summary-service="' . esc_attr( $service['title'] ) . '" data-summary-duration="' . esc_attr( wpbc_booking_appointment_get_duration_summary( $service ) ) . '" data-summary-price="' . esc_attr( wpbc_booking_appointment_get_cost_summary( $service ) ) . '" data-summary-buffer-before="' . absint( isset( $service['buffer_before_minutes'] ) ? $service['buffer_before_minutes'] : 0 ) . '" data-summary-buffer-after="' . absint( isset( $service['buffer_after_minutes'] ) ? $service['buffer_after_minutes'] : 0 ) . '">';
370 $html .= '<input id="' . esc_attr( $input_id ) . '" type="radio" name="wpbc_appointment_service" value="' . $service_id . '" required>';
371 if ( ! empty( $config['show_resource_image'] ) && ! empty( $service['picture_url'] ) ) {
372 $html .= '<span class="wpbc_booking_appointment__service_image" aria-hidden="true"><img src="' . esc_url( $service['picture_url'] ) . '" alt="" loading="lazy" decoding="async"></span>';
373 }
374 $html .= '<span class="wpbc_booking_appointment__choice_body">';
375 $html .= ! empty( $config['show_resource_title'] )
376 ? '<strong>' . esc_html( $service['title'] ) . '</strong>'
377 : '<span class="screen-reader-text">' . esc_html( $service['title'] ) . '</span>';
378 $service_summary = wpbc_booking_appointment_join_summary_parts(
379 array(
380 wpbc_booking_appointment_get_duration_summary( $service ),
381 ! empty( $config['show_starting_price'] ) ? wpbc_booking_appointment_get_cost_summary( $service ) : '',
382 )
383 );
384 $html .= '<span class="wpbc_booking_appointment__choice_meta">' . esc_html( $service_summary ) . '</span>';
385 if ( ! empty( $config['show_resource_description'] ) && ! empty( $service['description'] ) ) {
386 $html .= '<span class="wpbc_booking_appointment__choice_description">' . esc_html( $service['description'] ) . '</span>';
387 }
388 $html .= '</span><span class="wpbc_booking_appointment__choice_mark" aria-hidden="true"></span></label>';
389 }
390 $html .= ( $catalog_enabled ? wpbc_booking_appointment_render_catalog_close( 'services' ) : '</div>' );
391 $html .= '<input type="hidden" name="wpbc_appointment_config" value="' . esc_attr( $config_token ) . '">';
392 $html .= '<div class="wpbc_booking_appointment__actions"><button type="submit" class="wpbc_button wpbc_button_primary wpbc_booking_appointment__continue">' . esc_html__( 'Continue', 'booking' ) . '</button></div></form>';
393
394 return $html;
395 }
396
397 /**
398 * Render the Provider choice stage for a selected Service.
399 *
400 * @param array<string,mixed> $catalog Public catalogue.
401 * @param array<string,mixed> $service Selected Service.
402 * @param string $config_token Signed AJAX configuration.
403 * @param array<string,mixed> $config Appointment configuration.
404 *
405 * @return string Provider picker markup.
406 */
407 function wpbc_booking_appointment_render_providers( $catalog, $service, $config_token, $config = array() ) {
408 $providers = wpbc_booking_appointment_get_service_providers( $catalog, $service );
409 if ( empty( $providers ) ) {
410 return wpbc_booking_appointment_render_notice( __( 'No Providers are currently available for this Service.', 'booking' ), 'empty' );
411 }
412
413 $config = wpbc_booking_appointment_get_catalog_render_config( $config );
414 $catalog_enabled = wpbc_booking_appointment_is_catalog_enabled();
415 $html = wpbc_booking_appointment_render_progress( 2, $config );
416 $html .= wpbc_booking_appointment_render_selected_service( $service );
417 $html .= wpbc_booking_appointment_render_screen_heading( $config, 2, __( 'Choose a Provider', 'booking' ), __( 'Select who will provide this Service.', 'booking' ) );
418 $html .= '<form class="wpbc_booking_appointment__selection_form" method="get" action="' . esc_url( wpbc_booking_appointment_get_fallback_url( $config ) ) . '" data-appointment-stage="provider">';
419 $html .= '<input type="hidden" name="wpbc_appointment_service" value="' . absint( $service['service_id'] ) . '">';
420 $html .= $catalog_enabled
421 ? wpbc_booking_appointment_render_catalog_open( $config, 'providers', count( $providers ) )
422 : '<div class="wpbc_booking_appointment__choices wpbc_booking_appointment__choices--providers" role="radiogroup" aria-label="' . esc_attr__( 'Providers', 'booking' ) . '">';
423 foreach ( $providers as $provider ) {
424 $provider_id = absint( $provider['provider_id'] );
425 $provider_service = wpbc_booking_appointment_get_effective_service( $service, $provider_id );
426 $input_id = 'wpbc_appointment_provider_' . $provider_id . '_' . wp_rand( 1000, 9999 );
427 $initials = '';
428 foreach ( array_slice( array_filter( preg_split( '/\s+/u', trim( $provider['title'] ) ) ), 0, 2 ) as $word ) {
429 $initials .= function_exists( 'mb_substr' ) ? mb_substr( $word, 0, 1 ) : substr( $word, 0, 1 );
430 }
431 $provider_details = wpbc_booking_appointment_join_summary_parts(
432 array(
433 wpbc_booking_appointment_format_duration( $provider_service['duration_minutes'] ),
434 ! empty( $config['show_starting_price'] ) ? wpbc_booking_appointment_format_service_cost_text( $provider_service['base_cost'], $provider_id ) : '',
435 )
436 );
437 if ( ! empty( $config['show_availability'] ) && isset( $provider['has_weekly_availability'] ) && ! $provider['has_weekly_availability'] ) {
438 $provider_details = wpbc_booking_appointment_join_summary_parts(
439 array(
440 $provider_details,
441 __( 'No weekly availability configured', 'booking' ),
442 )
443 );
444 }
445 if ( ! empty( $config['show_resource_hierarchy'] ) ) {
446 /* translators: %s: effective Service duration, optional price, and availability for this Provider. */
447 $provider_meta = sprintf( __( 'Offers this Service · %s', 'booking' ), $provider_details );
448 } else {
449 $provider_meta = $provider_details;
450 }
451 $search_text = wp_strip_all_tags( (string) $provider['title'] . ' ' . (string) $service['title'] . ' ' . $provider_meta );
452 $choice_class = 'wpbc_booking_appointment__choice wpbc_booking_appointment__choice--provider';
453 $choice_attrs = '';
454 if ( $catalog_enabled ) {
455 $choice_class .= ' wpbc_booking_appointment__catalog_card';
456 $choice_attrs = ' data-wpbc-appointment-catalog-card="" data-appointment-catalog-search="' . esc_attr( $search_text ) . '"';
457 }
458 $html .= '<label class="' . esc_attr( $choice_class ) . '" for="' . esc_attr( $input_id ) . '"' . $choice_attrs . ' data-summary-provider="' . esc_attr( $provider['title'] ) . '" data-summary-duration="' . esc_attr( wpbc_booking_appointment_format_duration( $provider_service['duration_minutes'] ) ) . '" data-summary-price="' . esc_attr( wpbc_booking_appointment_format_service_cost_text( $provider_service['base_cost'], $provider_id ) ) . '" data-summary-buffer-before="' . absint( isset( $provider_service['buffer_before_minutes'] ) ? $provider_service['buffer_before_minutes'] : 0 ) . '" data-summary-buffer-after="' . absint( isset( $provider_service['buffer_after_minutes'] ) ? $provider_service['buffer_after_minutes'] : 0 ) . '">';
459 $html .= '<input id="' . esc_attr( $input_id ) . '" type="radio" name="wpbc_appointment_provider" value="' . $provider_id . '" required>';
460 if ( ! empty( $config['show_resource_image'] ) ) {
461 $html .= '<span class="wpbc_booking_appointment__provider_avatar" aria-hidden="true">';
462 if ( ! empty( $provider['image_url'] ) ) {
463 $html .= '<img src="' . esc_url( $provider['image_url'] ) . '" alt="" loading="lazy" decoding="async">';
464 } else {
465 $initials = $initials ? $initials : 'P';
466 $initials = function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $initials ) : strtoupper( $initials );
467 $html .= esc_html( $initials );
468 }
469 $html .= '</span>';
470 }
471 $html .= '<span class="wpbc_booking_appointment__choice_body">';
472 $html .= ! empty( $config['show_resource_title'] )
473 ? '<strong>' . esc_html( $provider['title'] ) . '</strong>'
474 : '<span class="screen-reader-text">' . esc_html( $provider['title'] ) . '</span>';
475 $html .= '<span class="wpbc_booking_appointment__choice_meta">' . esc_html( $provider_meta ) . '</span></span>';
476 $html .= '<span class="wpbc_booking_appointment__choice_mark" aria-hidden="true"></span></label>';
477 }
478 $html .= ( $catalog_enabled ? wpbc_booking_appointment_render_catalog_close( 'providers' ) : '</div>' );
479 $html .= '<input type="hidden" name="wpbc_appointment_config" value="' . esc_attr( $config_token ) . '">';
480 $html .= '<div class="wpbc_booking_appointment__actions">';
481 if ( empty( $config['service_id'] ) ) {
482 $html .= '<button type="button" class="wpbc_button wpbc_button_secondary wpbc_booking_appointment__back" data-appointment-back="service">' . esc_html__( 'Back', 'booking' ) . '</button>';
483 }
484 $html .= '<button type="submit" class="wpbc_button wpbc_button_primary wpbc_booking_appointment__continue">' . esc_html__( 'Continue', 'booking' ) . '</button></div></form>';
485
486 return $html;
487 }
488
489 /**
490 * Render a compact selected-Service summary.
491 *
492 * @param array<string,mixed> $service Selected Service.
493 *
494 * @return string Summary markup.
495 */
496 function wpbc_booking_appointment_render_selected_service( $service ) {
497 $summary = wpbc_booking_appointment_join_summary_parts( array( wpbc_booking_appointment_get_duration_summary( $service ), wpbc_booking_appointment_get_cost_summary( $service ) ) );
498
499 return '<div class="wpbc_booking_appointment__selected" data-summary-service="' . esc_attr( $service['title'] ) . '" data-summary-duration="' . esc_attr( wpbc_booking_appointment_get_duration_summary( $service ) ) . '" data-summary-price="' . esc_attr( wpbc_booking_appointment_get_cost_summary( $service ) ) . '" data-summary-buffer-before="' . absint( isset( $service['buffer_before_minutes'] ) ? $service['buffer_before_minutes'] : 0 ) . '" data-summary-buffer-after="' . absint( isset( $service['buffer_after_minutes'] ) ? $service['buffer_after_minutes'] : 0 ) . '"><span>' . esc_html__( 'Service', 'booking' ) . '</span><strong>' . esc_html( $service['title'] ) . '</strong><small>' . esc_html( $summary ) . '</small></div>';
500 }
501
502 /**
503 * Resolve the published Booking Form slug for the Appointment shortcode.
504 *
505 * An explicit shortcode form_type wins. Otherwise the selected Service's
506 * published form is used when it is valid for the Provider owner; stale,
507 * unpublished, or inaccessible form IDs fall back to `standard`.
508 *
509 * @param array<string,mixed> $service Selected Service.
510 * @param int $provider_id Provider resource ID.
511 * @param array<string,mixed> $config Shortcode configuration.
512 *
513 * @return string Safe published form slug.
514 */
515 function wpbc_booking_appointment_resolve_form_slug( $service, $provider_id, $config ) {
516 $form_slug = ! empty( $config['form_type'] ) ? sanitize_text_field( $config['form_type'] ) : '';
517 $form_id = ! empty( $service['booking_form_id'] ) ? absint( $service['booking_form_id'] ) : 0;
518 if ( '' === $form_slug && $form_id && class_exists( 'WPBC_FE_Custom_Form_Helper' ) ) {
519 $owner_user_id = class_exists( 'WPBC_FE_MU' )
520 ? WPBC_FE_MU::get_owner_user_id_for_resource( absint( $provider_id ) )
521 : 0;
522 if ( class_exists( 'WPBC_FE_MU' ) && WPBC_FE_MU::is_user_super_booking_admin( $owner_user_id ) ) {
523 $owner_user_id = 0;
524 }
525 $forms = WPBC_FE_Custom_Form_Helper::get_custom_booking_forms_list(
526 array(
527 'include_standard' => false,
528 'owner_user_id' => absint( $owner_user_id ),
529 'statuses' => array( 'published' ),
530 )
531 );
532 foreach ( $forms as $candidate_slug => $candidate ) {
533 if ( $form_id === absint( isset( $candidate['id'] ) ? $candidate['id'] : 0 ) ) {
534 $form_slug = sanitize_text_field( $candidate_slug );
535 break;
536 }
537 }
538 }
539 if ( '' === $form_slug ) {
540 $form_slug = 'standard';
541 }
542
543 return (string) apply_filters( 'wpbc_booking_appointment_form_slug', $form_slug, $service, absint( $provider_id ), $config );
544 }
545
546 /**
547 * Determine whether the resolved published Booking Form contains Start Time.
548 *
549 * The check runs before native rendering so a misconfigured form cannot enqueue
550 * calendar initialization for markup that will subsequently be discarded.
551 *
552 * @param string $form_slug Resolved form slug.
553 * @param int $provider_id Provider resource ID.
554 *
555 * @return bool True when Start Time is present or an extension allows the form.
556 */
557 function wpbc_booking_appointment_form_has_start_time( $form_slug, $provider_id ) {
558 global $wpdb;
559
560 $has_start_time = false;
561 if ( class_exists( 'WPBC_FE_Form_Source_Resolver' ) ) {
562 $resolution = WPBC_FE_Form_Source_Resolver::resolve(
563 array(
564 'resource_id' => absint( $provider_id ),
565 'form_slug' => sanitize_text_field( $form_slug ),
566 'form_status' => 'published',
567 )
568 );
569 $form_id = ! empty( $resolution['bfb_loader_args']['form_id'] ) ? absint( $resolution['bfb_loader_args']['form_id'] ) : 0;
570 if ( $form_id ) {
571 $form_source = $wpdb->get_var( $wpdb->prepare( "SELECT advanced_form FROM {$wpdb->prefix}booking_form_structures WHERE booking_form_id = %d AND status = %s LIMIT 1", $form_id, 'published' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
572 $has_start_time = is_string( $form_source ) && false !== stripos( $form_source, 'starttime' );
573 }
574 }
575
576 return (bool) apply_filters( 'wpbc_booking_appointment_form_has_start_time', $has_start_time, $form_slug, absint( $provider_id ) );
577 }
578
579 /**
580 * Temporarily suppress the older Provider-first Service selector.
581 *
582 * @param bool $enabled Existing selector state.
583 *
584 * @return bool Always false inside the Appointment renderer scope.
585 */
586 function wpbc_booking_appointment_disable_resource_service_selector( $enabled ) {
587 return false;
588 }
589
590 /**
591 * Build the Service editor URL for an incompatible Appointment form.
592 *
593 * The public Appointment workflow may be rendered for signed-out customers, so
594 * the administration action is exposed only during an authenticated session.
595 * The target Services page remains authoritative for capability and ownership
596 * checks. The focus argument is allow-listed again by that page before it is
597 * passed to JavaScript, which also supports custom Booking Calendar roles that
598 * may add Appointments without sharing the global Settings capability.
599 *
600 * @param int $service_id Service that needs a compatible Booking Form.
601 *
602 * @return string Service editor URL, or an empty string when unavailable.
603 */
604 function wpbc_booking_appointment_get_service_form_settings_url( $service_id ) {
605 $service_id = absint( $service_id );
606 if ( ! $service_id || ! is_user_logged_in() ) {
607 return '';
608 }
609
610 return add_query_arg(
611 array(
612 'page' => 'wpbc-services',
613 'service_id' => $service_id,
614 'wpbc_service_focus' => 'booking_form',
615 ),
616 admin_url( 'admin.php' )
617 );
618 }
619
620 /**
621 * Convert an Appointment error into a safe public response payload.
622 *
623 * Only the explicitly supported administration action keys are copied from
624 * WP_Error data. This keeps arbitrary validation data out of public JSON and
625 * prevents general error messages from becoming an HTML rendering channel.
626 *
627 * @param WP_Error $error Appointment workflow error.
628 *
629 * @return array<string,mixed> Sanitized message, code, and optional action.
630 */
631 function wpbc_booking_appointment_get_error_response_data( $error ) {
632 $response_data = array(
633 'message' => $error->get_error_message(),
634 'code' => $error->get_error_code(),
635 );
636 $error_data = $error->get_error_data();
637
638 if ( is_array( $error_data ) && ! empty( $error_data['action_url'] ) && ! empty( $error_data['action_label'] ) ) {
639 $response_data['action_url'] = esc_url_raw( $error_data['action_url'] );
640 $response_data['action_label'] = sanitize_text_field( $error_data['action_label'] );
641 }
642
643 return $response_data;
644 }
645
646 /**
647 * Render one complete existing booking form for a fixed Provider.
648 *
649 * @param array<string,mixed> $service Selected Service.
650 * @param array<string,mixed> $provider Selected Provider.
651 * @param array<string,mixed> $config Shortcode configuration.
652 * @param string $config_token Signed AJAX configuration.
653 *
654 * @return string|WP_Error Appointment form markup or configuration error.
655 */
656 function wpbc_booking_appointment_render_booking_form( $service, $provider, $config, $config_token ) {
657 $provider_id = absint( $provider['provider_id'] );
658 $form_slug = wpbc_booking_appointment_resolve_form_slug( $service, $provider_id, $config );
659 $allow_past = wpbc_booking_appointment_is_past_booking_enabled( $config );
660 $require_start_time = (bool) apply_filters( 'wpbc_booking_appointment_require_start_time', true, $service, $provider, $form_slug );
661 if ( $require_start_time && ! wpbc_booking_appointment_form_has_start_time( $form_slug, $provider_id ) ) {
662 $error_data = array();
663 $service_settings_url = wpbc_booking_appointment_get_service_form_settings_url( $service['service_id'] );
664 if ( $service_settings_url ) {
665 $error_data = array(
666 'action_url' => $service_settings_url,
667 'action_label' => __( 'Select Booking Form', 'booking' ),
668 );
669 }
670
671 return new WP_Error(
672 'appointment_start_time_missing',
673 __( 'This Appointment form needs a Start Time field. Select a Booking Form containing Start Time in the Service settings.', 'booking' ),
674 $error_data
675 );
676 }
677
678 static $rendered_provider_ids = array();
679 if ( isset( $rendered_provider_ids[ $provider_id ] ) ) {
680 return new WP_Error( 'appointment_duplicate_provider_form', __( 'This Provider already has an open Appointment form on this page. Complete or restart that Appointment before opening another form for the same Provider.', 'booking' ) );
681 }
682 $rendered_provider_ids[ $provider_id ] = true;
683
684 $render_params = array(
685 'is_echo' => 0,
686 'resource_id' => $provider_id,
687 'cal_count' => absint( $config['cal_count'] ),
688 'custom_booking_form' => $form_slug,
689 'shortcode_param__options' => $config['options'],
690 'calendar_dates_start' => $config['calendar_dates_start'],
691 'calendar_dates_end' => $config['calendar_dates_end'],
692 'calendar_request_overrides' => array(
693 'allow_past' => $allow_past ? 1 : 0,
694 ),
695 );
696 if ( ! empty( $config['start_month_calendar'] ) && is_array( $config['start_month_calendar'] ) ) {
697 $render_params['start_month_calendar'] = array(
698 absint( $config['start_month_calendar'][0] ),
699 absint( $config['start_month_calendar'][1] ),
700 );
701 }
702
703 if ( function_exists( 'wpbc_appointment_services_form_hint_context' ) ) {
704 wpbc_appointment_services_form_hint_context(
705 'set',
706 array(
707 'service_id' => $service['service_id'],
708 'resource_id' => $provider_id,
709 'title' => $service['title'],
710 )
711 );
712 }
713
714 add_filter( 'wpbc_appointment_services_frontend_is_enabled', 'wpbc_booking_appointment_disable_resource_service_selector', PHP_INT_MAX );
715 try {
716 $form_html = WPBC_FE_Render::render_booking_form( $render_params );
717 } finally {
718 remove_filter( 'wpbc_appointment_services_frontend_is_enabled', 'wpbc_booking_appointment_disable_resource_service_selector', PHP_INT_MAX );
719 if ( function_exists( 'wpbc_appointment_services_form_hint_context' ) ) {
720 wpbc_appointment_services_form_hint_context( 'clear' );
721 }
722 }
723
724 $duration_minutes = absint( $service['duration_minutes'] );
725 $duration_value = sprintf( '%02d:%02d', (int) floor( $duration_minutes / 60 ), $duration_minutes % 60 );
726 $service_cost = wpbc_appointment_services_is_pricing_available() && isset( $service['base_cost'] ) && is_numeric( $service['base_cost'] )
727 ? number_format( (float) $service['base_cost'], 2, '.', '' )
728 : '';
729 $submission_context = wpbc_booking_appointment_encode_submission_context( $config, $service['service_id'], $provider_id );
730 $html = wpbc_booking_appointment_render_progress( 3, $config );
731 $html .= wpbc_booking_appointment_render_booking_header( $service, $provider, $config );
732 $html .= '<div class="wpbc_booking_appointment__native_form" data-service-id="' . absint( $service['service_id'] ) . '" data-provider-id="' . $provider_id . '" data-service-title="' . esc_attr( $service['title'] ) . '" data-provider-title="' . esc_attr( $provider['title'] ) . '" data-duration="' . esc_attr( $duration_value ) . '" data-duration-label="' . esc_attr( wpbc_booking_appointment_format_duration( $duration_minutes ) ) . '" data-buffer-before="' . absint( $service['buffer_before_minutes'] ) . '" data-buffer-after="' . absint( $service['buffer_after_minutes'] ) . '" data-service-cost="' . esc_attr( $service_cost ) . '" data-service-cost-label="' . esc_attr( wpbc_booking_appointment_format_service_cost_text( $service_cost, $provider_id ) ) . '" data-form-slug="' . esc_attr( $form_slug ) . '" data-config-token="' . esc_attr( $config_token ) . '" data-appointment-context-token="' . esc_attr( $submission_context ) . '" data-allow-past="' . ( $allow_past ? '1' : '0' ) . '">';
733 $html .= $form_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
734 $html .= '</div>';
735
736 return $html;
737 }
738
739 /**
740 * Render the selected Appointment summary above the native Booking Form.
741 *
742 * The Service picture is decorative because the adjacent text identifies the
743 * selected Service. Invalid or unsupported picture URLs are omitted even when
744 * filtered Service data bypasses repository normalization.
745 *
746 * @param array<string,mixed> $service Effective Service presentation data.
747 * @param array<string,mixed> $provider Selected Provider presentation data.
748 * @param array<string,mixed> $config Normalized shortcode configuration.
749 *
750 * @return string Escaped Appointment header markup.
751 */
752 function wpbc_booking_appointment_render_booking_header( $service, $provider, $config ) {
753 $provider_id = absint( isset( $provider['provider_id'] ) ? $provider['provider_id'] : 0 );
754 $duration_minutes = absint( isset( $service['duration_minutes'] ) ? $service['duration_minutes'] : 0 );
755 $service_cost = isset( $service['base_cost'] ) ? $service['base_cost'] : '';
756 $service_title = isset( $service['title'] ) ? (string) $service['title'] : '';
757 $provider_title = isset( $provider['title'] ) ? (string) $provider['title'] : '';
758 $service_picture_url = isset( $service['picture_url'] ) ? (string) $service['picture_url'] : '';
759 $safe_picture_url = esc_url( $service_picture_url );
760 $show_service_image = ! isset( $config['show_resource_image'] ) || (bool) $config['show_resource_image'];
761 $appointment_summary = wpbc_booking_appointment_join_summary_parts(
762 array(
763 wpbc_booking_appointment_format_duration( $duration_minutes ),
764 $provider_title,
765 wpbc_booking_appointment_format_service_cost_text( $service_cost, $provider_id ),
766 )
767 );
768 $summary_label = wpbc_frontend_messages__get( 'message_appointment_summary_label', array(), $provider_id );
769 $start_over_label = wpbc_frontend_messages__get( 'message_appointment_start_over', array(), $provider_id );
770
771 $html = '<div class="wpbc_booking_appointment__booking_header">';
772 if ( $show_service_image && '' !== $safe_picture_url ) {
773 $html .= '<span class="wpbc_booking_appointment__service_image wpbc_booking_appointment__booking_header_image" aria-hidden="true"><img src="' . esc_url( $service_picture_url ) . '" alt="" loading="lazy" decoding="async"></span>';
774 }
775 $html .= '<div class="wpbc_booking_appointment__booking_header_content"><span>' . esc_html( $summary_label ) . '</span><strong>' . esc_html( $service_title ) . '</strong><small>' . esc_html( $appointment_summary ) . '</small></div>';
776 $html .= '<a class="wpbc_button wpbc_button_secondary wpbc_booking_appointment__change" data-wpbc-appointment-action="start-over" href="' . esc_url( wpbc_booking_appointment_get_fallback_url( $config ) ) . '">' . esc_html( $start_over_label ) . '</a>';
777 $html .= '</div>';
778
779 return $html;
780 }
781
782 /**
783 * Render a consistent public notice.
784 *
785 * @param string $message Notice text.
786 * @param string $type Notice type: error or empty.
787 * @param array<string,mixed> $action Optional action_url and action_label.
788 *
789 * @return string Notice markup.
790 */
791 function wpbc_booking_appointment_render_notice( $message, $type = 'error', $action = array() ) {
792 $html = '<div class="wpbc_booking_appointment__notice wpbc_booking_appointment__notice--' . esc_attr( sanitize_html_class( $type ) ) . '" role="status" tabindex="-1">';
793 $html .= '<span>' . esc_html( $message ) . '</span>';
794 if ( ! empty( $action['action_url'] ) && ! empty( $action['action_label'] ) ) {
795 $html .= ' <a class="wpbc_booking_appointment__notice_action" href="' . esc_url( $action['action_url'] ) . '">' . esc_html( $action['action_label'] ) . '</a>';
796 }
797 $html .= '</div>';
798
799 return $html;
800 }
801
802 /**
803 * Render a structured Appointment error without allowing arbitrary HTML.
804 *
805 * @param WP_Error $error Appointment workflow error.
806 *
807 * @return string Escaped notice markup with an optional authorized action.
808 */
809 function wpbc_booking_appointment_render_error_notice( $error ) {
810 $error_data = wpbc_booking_appointment_get_error_response_data( $error );
811
812 return wpbc_booking_appointment_render_notice( $error_data['message'], 'error', $error_data );
813 }
814
815 /**
816 * Resolve and render the correct Appointment stage.
817 *
818 * @param array<string,mixed> $config Normalized configuration.
819 * @param int $service_id Selected Service ID.
820 * @param int $provider_id Selected Provider ID.
821 *
822 * @return array{stage:string,html:string,service_id:int,provider_id:int}|WP_Error Stage response or validation error.
823 */
824 function wpbc_booking_appointment_resolve_stage( $config, $service_id = 0, $provider_id = 0 ) {
825 $config = wpbc_booking_appointment_normalize_config( $config );
826 $config_token = wpbc_booking_appointment_encode_config( $config );
827 $catalog = wpbc_booking_appointment_get_catalog( $config );
828 if ( is_wp_error( $catalog ) ) {
829 return $catalog;
830 }
831
832 $service_id = $config['service_id'] ? $config['service_id'] : absint( $service_id );
833 $provider_id = $config['provider_id'] ? $config['provider_id'] : absint( $provider_id );
834 if ( ! $service_id ) {
835 return array( 'stage' => 'service', 'html' => wpbc_booking_appointment_render_services( $catalog, $config_token, $config ), 'service_id' => 0, 'provider_id' => 0 );
836 }
837
838 $service = wpbc_booking_appointment_get_service( $catalog, $service_id );
839 if ( is_wp_error( $service ) ) {
840 return $service;
841 }
842 $providers = wpbc_booking_appointment_get_service_providers( $catalog, $service );
843 if ( ! $provider_id && $config['auto_select_provider'] && 1 === count( $providers ) ) {
844 $provider_id = absint( key( $providers ) );
845 }
846 if ( ! $provider_id ) {
847 return array( 'stage' => 'provider', 'html' => wpbc_booking_appointment_render_providers( $catalog, $service, $config_token, $config ), 'service_id' => absint( $service['service_id'] ), 'provider_id' => 0 );
848 }
849
850 $provider = wpbc_booking_appointment_get_provider( $catalog, $service, $provider_id );
851 if ( is_wp_error( $provider ) ) {
852 return $provider;
853 }
854 $effective_service = wpbc_booking_appointment_get_effective_service( $service, $provider_id );
855 $html = wpbc_booking_appointment_render_booking_form( $effective_service, $provider, $config, $config_token );
856 if ( is_wp_error( $html ) ) {
857 return $html;
858 }
859
860 return array( 'stage' => 'booking', 'html' => $html, 'service_id' => absint( $service['service_id'] ), 'provider_id' => absint( $provider['provider_id'] ) );
861 }
862