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

656 lines 32.2 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 * Render the Service choice stage.
198 *
199 * @param array<string,mixed> $catalog Public catalogue.
200 * @param string $config_token Signed AJAX configuration.
201 * @param array<string,mixed> $config Appointment configuration.
202 *
203 * @return string Service picker markup.
204 */
205 function wpbc_booking_appointment_render_services( $catalog, $config_token, $config = array() ) {
206 if ( empty( $catalog['services'] ) ) {
207 $provider_count = isset( $catalog['diagnostics']['provider_count'] ) ? absint( $catalog['diagnostics']['provider_count'] ) : 0;
208 $message = $provider_count
209 ? __( 'No Services are currently available.', 'booking' )
210 : __( 'No Providers are currently available.', 'booking' );
211 if ( current_user_can( wpbc_appointment_services_get_manage_capability() ) ) {
212 $message .= $provider_count
213 ? ' ' . __( 'Activate a Service and assign at least one Provider to it.', 'booking' )
214 : ' ' . __( 'Create a Provider, then assign it to an active Service.', 'booking' );
215 }
216
217 return wpbc_booking_appointment_render_notice( $message, 'empty' );
218 }
219
220 $html = wpbc_booking_appointment_render_progress( 1, $config );
221 $html .= wpbc_booking_appointment_render_screen_heading( $config, 1, __( 'Choose a Service', 'booking' ), __( 'Select what you would like to book.', 'booking' ) );
222 $html .= '<form class="wpbc_booking_appointment__selection_form" method="get" action="' . esc_url( wpbc_booking_appointment_get_fallback_url( $config ) ) . '" data-appointment-stage="service">';
223 $html .= '<div class="wpbc_booking_appointment__choices" role="radiogroup" aria-label="' . esc_attr__( 'Services', 'booking' ) . '">';
224 foreach ( $catalog['services'] as $service ) {
225 $service_id = absint( $service['service_id'] );
226 $input_id = 'wpbc_appointment_service_' . $service_id . '_' . wp_rand( 1000, 9999 );
227 $html .= '<label class="wpbc_booking_appointment__choice wpbc_booking_appointment__choice--service" for="' . esc_attr( $input_id ) . '" 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 ) . '">';
228 $html .= '<input id="' . esc_attr( $input_id ) . '" type="radio" name="wpbc_appointment_service" value="' . $service_id . '" required>';
229 if ( ! empty( $service['picture_url'] ) ) {
230 $html .= '<span class="wpbc_booking_appointment__service_image" aria-hidden="true"><img src="' . esc_url( $service['picture_url'] ) . '" alt="" loading="lazy" decoding="async"></span>';
231 }
232 $html .= '<span class="wpbc_booking_appointment__choice_body"><strong>' . esc_html( $service['title'] ) . '</strong>';
233 $service_summary = wpbc_booking_appointment_join_summary_parts( array( wpbc_booking_appointment_get_duration_summary( $service ), wpbc_booking_appointment_get_cost_summary( $service ) ) );
234 $html .= '<span class="wpbc_booking_appointment__choice_meta">' . esc_html( $service_summary ) . '</span>';
235 if ( ! empty( $service['description'] ) ) {
236 $html .= '<span class="wpbc_booking_appointment__choice_description">' . esc_html( $service['description'] ) . '</span>';
237 }
238 $html .= '</span><span class="wpbc_booking_appointment__choice_mark" aria-hidden="true"></span></label>';
239 }
240 $html .= '</div><input type="hidden" name="wpbc_appointment_config" value="' . esc_attr( $config_token ) . '">';
241 $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>';
242
243 return $html;
244 }
245
246 /**
247 * Render the Provider choice stage for a selected Service.
248 *
249 * @param array<string,mixed> $catalog Public catalogue.
250 * @param array<string,mixed> $service Selected Service.
251 * @param string $config_token Signed AJAX configuration.
252 *
253 * @return string Provider picker markup.
254 */
255 function wpbc_booking_appointment_render_providers( $catalog, $service, $config_token, $config = array() ) {
256 $providers = wpbc_booking_appointment_get_service_providers( $catalog, $service );
257 if ( empty( $providers ) ) {
258 return wpbc_booking_appointment_render_notice( __( 'No Providers are currently available for this Service.', 'booking' ), 'empty' );
259 }
260
261 $html = wpbc_booking_appointment_render_progress( 2, $config );
262 $html .= wpbc_booking_appointment_render_selected_service( $service );
263 $html .= wpbc_booking_appointment_render_screen_heading( $config, 2, __( 'Choose a Provider', 'booking' ), __( 'Select who will provide this Service.', 'booking' ) );
264 $html .= '<form class="wpbc_booking_appointment__selection_form" method="get" action="' . esc_url( wpbc_booking_appointment_get_fallback_url( $config ) ) . '" data-appointment-stage="provider">';
265 $html .= '<input type="hidden" name="wpbc_appointment_service" value="' . absint( $service['service_id'] ) . '">';
266 $html .= '<div class="wpbc_booking_appointment__choices wpbc_booking_appointment__choices--providers" role="radiogroup" aria-label="' . esc_attr__( 'Providers', 'booking' ) . '">';
267 foreach ( $providers as $provider ) {
268 $provider_id = absint( $provider['provider_id'] );
269 $provider_service = wpbc_booking_appointment_get_effective_service( $service, $provider_id );
270 $input_id = 'wpbc_appointment_provider_' . $provider_id . '_' . wp_rand( 1000, 9999 );
271 $initials = '';
272 foreach ( array_slice( array_filter( preg_split( '/\s+/u', trim( $provider['title'] ) ) ), 0, 2 ) as $word ) {
273 $initials .= function_exists( 'mb_substr' ) ? mb_substr( $word, 0, 1 ) : substr( $word, 0, 1 );
274 }
275 $provider_details = wpbc_booking_appointment_join_summary_parts(
276 array(
277 wpbc_booking_appointment_format_duration( $provider_service['duration_minutes'] ),
278 wpbc_booking_appointment_format_service_cost_text( $provider_service['base_cost'], $provider_id ),
279 )
280 );
281 if ( isset( $provider['has_weekly_availability'] ) && ! $provider['has_weekly_availability'] ) {
282 $provider_details = wpbc_booking_appointment_join_summary_parts(
283 array(
284 $provider_details,
285 __( 'No weekly availability configured', 'booking' ),
286 )
287 );
288 }
289 /* translators: %s: effective Service duration and optional price for this Provider. */
290 $provider_meta = sprintf( __( 'Offers this Service · %s', 'booking' ), $provider_details );
291 $html .= '<label class="wpbc_booking_appointment__choice wpbc_booking_appointment__choice--provider" for="' . esc_attr( $input_id ) . '" 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 ) . '">';
292 $html .= '<input id="' . esc_attr( $input_id ) . '" type="radio" name="wpbc_appointment_provider" value="' . $provider_id . '" required>';
293 $html .= '<span class="wpbc_booking_appointment__provider_avatar" aria-hidden="true">';
294 if ( ! empty( $provider['image_url'] ) ) {
295 $html .= '<img src="' . esc_url( $provider['image_url'] ) . '" alt="" loading="lazy" decoding="async">';
296 } else {
297 $initials = $initials ? $initials : 'P';
298 $initials = function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $initials ) : strtoupper( $initials );
299 $html .= esc_html( $initials );
300 }
301 $html .= '</span>';
302 $html .= '<span class="wpbc_booking_appointment__choice_body"><strong>' . esc_html( $provider['title'] ) . '</strong><span class="wpbc_booking_appointment__choice_meta">' . esc_html( $provider_meta ) . '</span></span>';
303 $html .= '<span class="wpbc_booking_appointment__choice_mark" aria-hidden="true"></span></label>';
304 }
305 $html .= '</div><input type="hidden" name="wpbc_appointment_config" value="' . esc_attr( $config_token ) . '">';
306 $html .= '<div class="wpbc_booking_appointment__actions">';
307 if ( empty( $config['service_id'] ) ) {
308 $html .= '<button type="button" class="wpbc_button wpbc_button_secondary wpbc_booking_appointment__back" data-appointment-back="service">' . esc_html__( 'Back', 'booking' ) . '</button>';
309 }
310 $html .= '<button type="submit" class="wpbc_button wpbc_button_primary wpbc_booking_appointment__continue">' . esc_html__( 'Continue', 'booking' ) . '</button></div></form>';
311
312 return $html;
313 }
314
315 /**
316 * Render a compact selected-Service summary.
317 *
318 * @param array<string,mixed> $service Selected Service.
319 *
320 * @return string Summary markup.
321 */
322 function wpbc_booking_appointment_render_selected_service( $service ) {
323 $summary = wpbc_booking_appointment_join_summary_parts( array( wpbc_booking_appointment_get_duration_summary( $service ), wpbc_booking_appointment_get_cost_summary( $service ) ) );
324
325 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>';
326 }
327
328 /**
329 * Resolve the published Booking Form slug for the Appointment shortcode.
330 *
331 * An explicit shortcode form_type wins. Otherwise the selected Service's
332 * published form is used when it is valid for the Provider owner; stale,
333 * unpublished, or inaccessible form IDs fall back to `standard`.
334 *
335 * @param array<string,mixed> $service Selected Service.
336 * @param int $provider_id Provider resource ID.
337 * @param array<string,mixed> $config Shortcode configuration.
338 *
339 * @return string Safe published form slug.
340 */
341 function wpbc_booking_appointment_resolve_form_slug( $service, $provider_id, $config ) {
342 $form_slug = ! empty( $config['form_type'] ) ? sanitize_text_field( $config['form_type'] ) : '';
343 $form_id = ! empty( $service['booking_form_id'] ) ? absint( $service['booking_form_id'] ) : 0;
344 if ( '' === $form_slug && $form_id && class_exists( 'WPBC_FE_Custom_Form_Helper' ) ) {
345 $owner_user_id = class_exists( 'WPBC_FE_MU' )
346 ? WPBC_FE_MU::get_owner_user_id_for_resource( absint( $provider_id ) )
347 : 0;
348 if ( class_exists( 'WPBC_FE_MU' ) && WPBC_FE_MU::is_user_super_booking_admin( $owner_user_id ) ) {
349 $owner_user_id = 0;
350 }
351 $forms = WPBC_FE_Custom_Form_Helper::get_custom_booking_forms_list(
352 array(
353 'include_standard' => false,
354 'owner_user_id' => absint( $owner_user_id ),
355 'statuses' => array( 'published' ),
356 )
357 );
358 foreach ( $forms as $candidate_slug => $candidate ) {
359 if ( $form_id === absint( isset( $candidate['id'] ) ? $candidate['id'] : 0 ) ) {
360 $form_slug = sanitize_text_field( $candidate_slug );
361 break;
362 }
363 }
364 }
365 if ( '' === $form_slug ) {
366 $form_slug = 'standard';
367 }
368
369 return (string) apply_filters( 'wpbc_booking_appointment_form_slug', $form_slug, $service, absint( $provider_id ), $config );
370 }
371
372 /**
373 * Determine whether the resolved published Booking Form contains Start Time.
374 *
375 * The check runs before native rendering so a misconfigured form cannot enqueue
376 * calendar initialization for markup that will subsequently be discarded.
377 *
378 * @param string $form_slug Resolved form slug.
379 * @param int $provider_id Provider resource ID.
380 *
381 * @return bool True when Start Time is present or an extension allows the form.
382 */
383 function wpbc_booking_appointment_form_has_start_time( $form_slug, $provider_id ) {
384 global $wpdb;
385
386 $has_start_time = false;
387 if ( class_exists( 'WPBC_FE_Form_Source_Resolver' ) ) {
388 $resolution = WPBC_FE_Form_Source_Resolver::resolve(
389 array(
390 'resource_id' => absint( $provider_id ),
391 'form_slug' => sanitize_text_field( $form_slug ),
392 'form_status' => 'published',
393 )
394 );
395 $form_id = ! empty( $resolution['bfb_loader_args']['form_id'] ) ? absint( $resolution['bfb_loader_args']['form_id'] ) : 0;
396 if ( $form_id ) {
397 $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
398 $has_start_time = is_string( $form_source ) && false !== stripos( $form_source, 'starttime' );
399 }
400 }
401
402 return (bool) apply_filters( 'wpbc_booking_appointment_form_has_start_time', $has_start_time, $form_slug, absint( $provider_id ) );
403 }
404
405 /**
406 * Temporarily suppress the older Provider-first Service selector.
407 *
408 * @param bool $enabled Existing selector state.
409 *
410 * @return bool Always false inside the Appointment renderer scope.
411 */
412 function wpbc_booking_appointment_disable_resource_service_selector( $enabled ) {
413 return false;
414 }
415
416 /**
417 * Build the Service editor URL for an incompatible Appointment form.
418 *
419 * The public Appointment workflow may be rendered for signed-out customers, so
420 * the administration action is exposed only during an authenticated session.
421 * The target Services page remains authoritative for capability and ownership
422 * checks. The focus argument is allow-listed again by that page before it is
423 * passed to JavaScript, which also supports custom Booking Calendar roles that
424 * may add Appointments without sharing the global Settings capability.
425 *
426 * @param int $service_id Service that needs a compatible Booking Form.
427 *
428 * @return string Service editor URL, or an empty string when unavailable.
429 */
430 function wpbc_booking_appointment_get_service_form_settings_url( $service_id ) {
431 $service_id = absint( $service_id );
432 if ( ! $service_id || ! is_user_logged_in() ) {
433 return '';
434 }
435
436 return add_query_arg(
437 array(
438 'page' => 'wpbc-services',
439 'service_id' => $service_id,
440 'wpbc_service_focus' => 'booking_form',
441 ),
442 admin_url( 'admin.php' )
443 );
444 }
445
446 /**
447 * Convert an Appointment error into a safe public response payload.
448 *
449 * Only the explicitly supported administration action keys are copied from
450 * WP_Error data. This keeps arbitrary validation data out of public JSON and
451 * prevents general error messages from becoming an HTML rendering channel.
452 *
453 * @param WP_Error $error Appointment workflow error.
454 *
455 * @return array<string,mixed> Sanitized message, code, and optional action.
456 */
457 function wpbc_booking_appointment_get_error_response_data( $error ) {
458 $response_data = array(
459 'message' => $error->get_error_message(),
460 'code' => $error->get_error_code(),
461 );
462 $error_data = $error->get_error_data();
463
464 if ( is_array( $error_data ) && ! empty( $error_data['action_url'] ) && ! empty( $error_data['action_label'] ) ) {
465 $response_data['action_url'] = esc_url_raw( $error_data['action_url'] );
466 $response_data['action_label'] = sanitize_text_field( $error_data['action_label'] );
467 }
468
469 return $response_data;
470 }
471
472 /**
473 * Render one complete existing booking form for a fixed Provider.
474 *
475 * @param array<string,mixed> $service Selected Service.
476 * @param array<string,mixed> $provider Selected Provider.
477 * @param array<string,mixed> $config Shortcode configuration.
478 * @param string $config_token Signed AJAX configuration.
479 *
480 * @return string|WP_Error Appointment form markup or configuration error.
481 */
482 function wpbc_booking_appointment_render_booking_form( $service, $provider, $config, $config_token ) {
483 $provider_id = absint( $provider['provider_id'] );
484 $form_slug = wpbc_booking_appointment_resolve_form_slug( $service, $provider_id, $config );
485 $allow_past = wpbc_booking_appointment_is_past_booking_enabled( $config );
486 $require_start_time = (bool) apply_filters( 'wpbc_booking_appointment_require_start_time', true, $service, $provider, $form_slug );
487 if ( $require_start_time && ! wpbc_booking_appointment_form_has_start_time( $form_slug, $provider_id ) ) {
488 $error_data = array();
489 $service_settings_url = wpbc_booking_appointment_get_service_form_settings_url( $service['service_id'] );
490 if ( $service_settings_url ) {
491 $error_data = array(
492 'action_url' => $service_settings_url,
493 'action_label' => __( 'Select Booking Form', 'booking' ),
494 );
495 }
496
497 return new WP_Error(
498 'appointment_start_time_missing',
499 __( 'This Appointment form needs a Start Time field. Select a Booking Form containing Start Time in the Service settings.', 'booking' ),
500 $error_data
501 );
502 }
503
504 static $rendered_provider_ids = array();
505 if ( isset( $rendered_provider_ids[ $provider_id ] ) ) {
506 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' ) );
507 }
508 $rendered_provider_ids[ $provider_id ] = true;
509
510 $render_params = array(
511 'is_echo' => 0,
512 'resource_id' => $provider_id,
513 'cal_count' => absint( $config['cal_count'] ),
514 'custom_booking_form' => $form_slug,
515 'shortcode_param__options' => $config['options'],
516 'calendar_dates_start' => $config['calendar_dates_start'],
517 'calendar_dates_end' => $config['calendar_dates_end'],
518 'calendar_request_overrides' => array(
519 'allow_past' => $allow_past ? 1 : 0,
520 ),
521 );
522 if ( ! empty( $config['start_month_calendar'] ) && is_array( $config['start_month_calendar'] ) ) {
523 $render_params['start_month_calendar'] = array(
524 absint( $config['start_month_calendar'][0] ),
525 absint( $config['start_month_calendar'][1] ),
526 );
527 }
528
529 if ( function_exists( 'wpbc_appointment_services_form_hint_context' ) ) {
530 wpbc_appointment_services_form_hint_context(
531 'set',
532 array(
533 'service_id' => $service['service_id'],
534 'resource_id' => $provider_id,
535 'title' => $service['title'],
536 )
537 );
538 }
539
540 add_filter( 'wpbc_appointment_services_frontend_is_enabled', 'wpbc_booking_appointment_disable_resource_service_selector', PHP_INT_MAX );
541 try {
542 $form_html = WPBC_FE_Render::render_booking_form( $render_params );
543 } finally {
544 remove_filter( 'wpbc_appointment_services_frontend_is_enabled', 'wpbc_booking_appointment_disable_resource_service_selector', PHP_INT_MAX );
545 if ( function_exists( 'wpbc_appointment_services_form_hint_context' ) ) {
546 wpbc_appointment_services_form_hint_context( 'clear' );
547 }
548 }
549
550 $duration_minutes = absint( $service['duration_minutes'] );
551 $duration_value = sprintf( '%02d:%02d', (int) floor( $duration_minutes / 60 ), $duration_minutes % 60 );
552 $service_cost = wpbc_appointment_services_is_pricing_available() && isset( $service['base_cost'] ) && is_numeric( $service['base_cost'] )
553 ? number_format( (float) $service['base_cost'], 2, '.', '' )
554 : '';
555 $submission_context = wpbc_booking_appointment_encode_submission_context( $config, $service['service_id'], $provider_id );
556 $html = wpbc_booking_appointment_render_progress( 3, $config );
557 $html .= '<div class="wpbc_booking_appointment__booking_header">';
558 $appointment_summary = wpbc_booking_appointment_join_summary_parts(
559 array(
560 wpbc_booking_appointment_format_duration( $duration_minutes ),
561 $provider['title'],
562 wpbc_booking_appointment_format_service_cost_text( $service['base_cost'], $provider_id ),
563 )
564 );
565 $summary_label = wpbc_frontend_messages__get( 'message_appointment_summary_label', array(), $provider_id );
566 $start_over_label = wpbc_frontend_messages__get( 'message_appointment_start_over', array(), $provider_id );
567 $html .= '<div><span>' . esc_html( $summary_label ) . '</span><strong>' . esc_html( $service['title'] ) . '</strong><small>' . esc_html( $appointment_summary ) . '</small></div>';
568 $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></div>';
569 $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' ) . '">';
570 $html .= $form_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
571 $html .= '</div>';
572
573 return $html;
574 }
575
576 /**
577 * Render a consistent public notice.
578 *
579 * @param string $message Notice text.
580 * @param string $type Notice type: error or empty.
581 * @param array<string,mixed> $action Optional action_url and action_label.
582 *
583 * @return string Notice markup.
584 */
585 function wpbc_booking_appointment_render_notice( $message, $type = 'error', $action = array() ) {
586 $html = '<div class="wpbc_booking_appointment__notice wpbc_booking_appointment__notice--' . esc_attr( sanitize_html_class( $type ) ) . '" role="status" tabindex="-1">';
587 $html .= '<span>' . esc_html( $message ) . '</span>';
588 if ( ! empty( $action['action_url'] ) && ! empty( $action['action_label'] ) ) {
589 $html .= ' <a class="wpbc_booking_appointment__notice_action" href="' . esc_url( $action['action_url'] ) . '">' . esc_html( $action['action_label'] ) . '</a>';
590 }
591 $html .= '</div>';
592
593 return $html;
594 }
595
596 /**
597 * Render a structured Appointment error without allowing arbitrary HTML.
598 *
599 * @param WP_Error $error Appointment workflow error.
600 *
601 * @return string Escaped notice markup with an optional authorized action.
602 */
603 function wpbc_booking_appointment_render_error_notice( $error ) {
604 $error_data = wpbc_booking_appointment_get_error_response_data( $error );
605
606 return wpbc_booking_appointment_render_notice( $error_data['message'], 'error', $error_data );
607 }
608
609 /**
610 * Resolve and render the correct Appointment stage.
611 *
612 * @param array<string,mixed> $config Normalized configuration.
613 * @param int $service_id Selected Service ID.
614 * @param int $provider_id Selected Provider ID.
615 *
616 * @return array{stage:string,html:string,service_id:int,provider_id:int}|WP_Error Stage response or validation error.
617 */
618 function wpbc_booking_appointment_resolve_stage( $config, $service_id = 0, $provider_id = 0 ) {
619 $config = wpbc_booking_appointment_normalize_config( $config );
620 $config_token = wpbc_booking_appointment_encode_config( $config );
621 $catalog = wpbc_booking_appointment_get_catalog( $config );
622 if ( is_wp_error( $catalog ) ) {
623 return $catalog;
624 }
625
626 $service_id = $config['service_id'] ? $config['service_id'] : absint( $service_id );
627 $provider_id = $config['provider_id'] ? $config['provider_id'] : absint( $provider_id );
628 if ( ! $service_id ) {
629 return array( 'stage' => 'service', 'html' => wpbc_booking_appointment_render_services( $catalog, $config_token, $config ), 'service_id' => 0, 'provider_id' => 0 );
630 }
631
632 $service = wpbc_booking_appointment_get_service( $catalog, $service_id );
633 if ( is_wp_error( $service ) ) {
634 return $service;
635 }
636 $providers = wpbc_booking_appointment_get_service_providers( $catalog, $service );
637 if ( ! $provider_id && $config['auto_select_provider'] && 1 === count( $providers ) ) {
638 $provider_id = absint( key( $providers ) );
639 }
640 if ( ! $provider_id ) {
641 return array( 'stage' => 'provider', 'html' => wpbc_booking_appointment_render_providers( $catalog, $service, $config_token, $config ), 'service_id' => absint( $service['service_id'] ), 'provider_id' => 0 );
642 }
643
644 $provider = wpbc_booking_appointment_get_provider( $catalog, $service, $provider_id );
645 if ( is_wp_error( $provider ) ) {
646 return $provider;
647 }
648 $effective_service = wpbc_booking_appointment_get_effective_service( $service, $provider_id );
649 $html = wpbc_booking_appointment_render_booking_form( $effective_service, $provider, $config, $config_token );
650 if ( is_wp_error( $html ) ) {
651 return $html;
652 }
653
654 return array( 'stage' => 'booking', 'html' => $html, 'service_id' => absint( $service['service_id'] ), 'provider_id' => absint( $provider['provider_id'] ) );
655 }
656