PluginProbe
Booking Calendar / 11.8.4
Booking Calendar v11.8.4
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 / _src / booking-appointment.js

booking-appointment.js in Booking Calendar 11.8.4, at includes/booking-appointment/_src/booking-appointment.js

991 lines 37.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 ( function ( window, $ ) {
2 'use strict';
3
4 var config = window.wpbc_booking_appointment_config || {};
5 var active_native_contexts = {};
6 var loaded_script_urls = {};
7
8 $( 'script[src]' ).each( function () {
9 loaded_script_urls[ String( this.src || '' ) ] = true;
10 } );
11
12 /** Return a normalized integer from a selector field. */
13 function get_selected_id( $form, name ) {
14 return Number( $form.find( '[name="' + name + '"]:checked, [name="' + name + '"][type="hidden"]' ).first().val() || 0 );
15 }
16
17 /**
18 * Apply public text search to one server-authorized Appointment catalog.
19 *
20 * @param {jQuery} $catalog Appointment catalog root.
21 * @return {void}
22 */
23 function filter_appointment_catalog( $catalog ) {
24 var search_term = String( $catalog.find( '[data-wpbc-appointment-catalog-search]' ).val() || '' ).toLocaleLowerCase().trim();
25 var catalog_type = String( $catalog.attr( 'data-catalog-type' ) || 'services' );
26 var visible_count = 0;
27
28 $catalog.find( '[data-wpbc-appointment-catalog-card]' ).each( function () {
29 var $card = $( this );
30 var searchable_text = String( $card.attr( 'data-appointment-catalog-search' ) || '' ).toLocaleLowerCase();
31 var is_visible = ! search_term || searchable_text.indexOf( search_term ) !== -1;
32 var $choice_input = $card.find( 'input[type="radio"]' ).first();
33
34 $card.prop( 'hidden', ! is_visible );
35 $choice_input.prop( 'disabled', ! is_visible );
36 if ( is_visible ) {
37 visible_count += 1;
38 } else if ( $choice_input.prop( 'checked' ) ) {
39 $choice_input.prop( 'checked', false );
40 $card.removeClass( 'is-selected' );
41 }
42 } );
43
44 $catalog.find( '[data-wpbc-appointment-catalog-empty]' ).prop( 'hidden', 0 !== visible_count );
45 $catalog.find( '[data-wpbc-appointment-catalog-status]' ).text(
46 String( visible_count ) + ' ' + (
47 'providers' === catalog_type
48 ? ( 1 === visible_count ? ( config.provider_found || 'Provider found.' ) : ( config.providers_found || 'Providers found.' ) )
49 : ( 1 === visible_count ? ( config.service_found || 'Service found.' ) : ( config.services_found || 'Services found.' ) )
50 )
51 );
52 }
53
54 /** Toggle one component loading state without clearing its current stage. */
55 function set_loading( $root, is_loading ) {
56 $root.toggleClass( 'is-loading', is_loading ).attr( 'aria-busy', is_loading ? 'true' : 'false' );
57 $root.find( '> .wpbc_booking_appointment__stage' ).attr( 'aria-busy', is_loading ? 'true' : 'false' );
58 $root.find( '> .wpbc_booking_appointment__loading' ).prop( 'hidden', ! is_loading ).attr( 'aria-hidden', is_loading ? 'false' : 'true' );
59 $root.find( '.wpbc_booking_appointment__selection_form :input' ).prop( 'disabled', is_loading );
60 if ( ! is_loading ) {
61 $root.find( '[data-wpbc-appointment-catalog]' ).each( function () {
62 filter_appointment_catalog( $( this ) );
63 } );
64 }
65 }
66
67 /**
68 * Display and focus an AJAX or initialization error in one component.
69 *
70 * @param {jQuery} $root Appointment component root.
71 * @param {string} message Error message.
72 * @param {string} action_url Optional trusted administration action URL.
73 * @param {string} action_label Optional action link label.
74 * @return {void}
75 */
76 function show_error( $root, message, action_url, action_label ) {
77 var $notice = $root.find( '> .wpbc_booking_appointment__ajax_notice' );
78 $notice.empty().append( $( '<span>' ).text( message || config.error || 'Unable to load the Appointment form.' ) );
79 if ( action_url && action_label ) {
80 $notice.append( ' ', $( '<a>', { 'class': 'wpbc_booking_appointment__notice_action', href: action_url, text: action_label } ) );
81 }
82 $notice.prop( 'hidden', false );
83 if ( $notice.get( 0 ) && typeof $notice.get( 0 ).focus === 'function' ) {
84 $notice.trigger( 'focus' );
85 }
86 }
87
88 /** Clear the component AJAX error. */
89 function clear_error( $root ) {
90 $root.find( '> .wpbc_booking_appointment__ajax_notice' ).empty().prop( 'hidden', true );
91 }
92
93 /** Return a registered native context only while its DOM element is live. */
94 function get_native_context( provider_id ) {
95 provider_id = Number( provider_id || 0 );
96 var context = active_native_contexts[ provider_id ];
97 if ( ! context || ! context.element || ! document.documentElement.contains( context.element ) ) {
98 delete active_native_contexts[ provider_id ];
99 return null;
100 }
101 return context;
102 }
103
104 /** Detect another live native Booking Calendar form for the same Provider. */
105 function has_duplicate_provider_form( $root, provider_id ) {
106 provider_id = Number( provider_id || 0 );
107 if ( ! provider_id ) {
108 return false;
109 }
110
111 var context = get_native_context( provider_id );
112 if ( context && ! $.contains( $root.get( 0 ), context.element ) ) {
113 return true;
114 }
115
116 return $( '[id="booking_form' + provider_id + '"]' ).filter( function () {
117 return ! $.contains( $root.get( 0 ), this );
118 } ).length > 0;
119 }
120
121 /** Register the exact signed native form context used by booking submission. */
122 function register_native_form( $native ) {
123 var provider_id = Number( $native.data( 'provider-id' ) || 0 );
124 var service_id = Number( $native.data( 'service-id' ) || 0 );
125 var context_token = String( $native.attr( 'data-appointment-context-token' ) || '' );
126 var allow_past = ( '1' === String( $native.attr( 'data-allow-past' ) || '0' ) ) ? 1 : 0;
127 var existing = get_native_context( provider_id );
128
129 if ( ! provider_id || ! service_id || ! context_token ) {
130 return false;
131 }
132 if ( existing && existing.element !== $native.get( 0 ) ) {
133 return false;
134 }
135
136 active_native_contexts[ provider_id ] = {
137 element: $native.get( 0 ),
138 service_id: service_id,
139 provider_id: provider_id,
140 context_token: context_token,
141 allow_past: allow_past
142 };
143 return true;
144 }
145
146 /** Remove a native form from the local submission-context registry. */
147 function unregister_native_form( $native ) {
148 var provider_id = Number( $native.data( 'provider-id' ) || 0 );
149 var context = get_native_context( provider_id );
150 if ( context && context.element === $native.get( 0 ) ) {
151 delete active_native_contexts[ provider_id ];
152 }
153 }
154
155 /** Return the native Start Time field used by the fixed Service duration. */
156 function get_start_time_field( $native ) {
157 var provider_id = Number( $native.data( 'provider-id' ) || 0 );
158 return $native.find( '[name="starttime' + provider_id + '"], [name="starttime' + provider_id + '[]"]' ).not( '[data-wpbc-booking-submit-ignore="1"]' ).first();
159 }
160
161 /** Return selected calendar dates in the server's strict SQL-date format. */
162 function get_selected_dates( $native ) {
163 var provider_id = Number( $native.data( 'provider-id' ) || 0 );
164 if ( provider_id && typeof window.wpbc_get__selected_dates_sql__as_arr === 'function' ) {
165 return window.wpbc_get__selected_dates_sql__as_arr( provider_id );
166 }
167
168 var value = String( $native.find( '#date_booking' + provider_id ).val() || '' );
169 return value.split( ',' ).map( function ( date_value ) {
170 var parts = $.trim( date_value ).split( '.' );
171 return 3 === parts.length ? parts[ 2 ] + '-' + parts[ 1 ] + '-' + parts[ 0 ] : '';
172 } ).filter( function ( date_value ) {
173 return /^\d{4}-\d{2}-\d{2}$/.test( date_value );
174 } );
175 }
176
177 /** Read the current date/start selection and its stable request signature. */
178 function get_time_selection( $native ) {
179 var $start = get_start_time_field( $native );
180 var dates = get_selected_dates( $native );
181 var start_time = $start.length ? String( $start.val() || '' ) : '';
182 return {
183 $start: $start,
184 dates: dates,
185 start_time: start_time,
186 complete: !! ( dates.length && /^\d{1,2}:\d{2}(?::\d{2})?$/.test( start_time ) ),
187 signature: dates.join( ',' ) + '|' + start_time
188 };
189 }
190
191 /** Determine whether the Start Time field belongs to the visible wizard step. */
192 function is_time_selection_stage_active( selection ) {
193 var $step = selection.$start.closest( '.wpbc_wizard_step' );
194 return ! $step.length || ( $step.is( ':visible' ) && ! $step.hasClass( 'wpbc_wizard_step_hidden' ) );
195 }
196
197 /** Find the visible Start Time UI beneath which validation is explained. */
198 function get_time_notice_anchor( selection ) {
199 var $picker = selection.$start.nextAll( '.wpbc_times_selector' ).first();
200 return $picker.length ? $picker : selection.$start;
201 }
202
203 /** Remove the Appointment buffer warning and invalid field semantics. */
204 function clear_time_notice( $native ) {
205 $native.find( '.wpbc_booking_appointment__time_notice' ).remove();
206 get_start_time_field( $native ).removeAttr( 'aria-invalid' ).nextAll( '.wpbc_times_selector' ).first().removeAttr( 'aria-invalid' );
207 }
208
209 /** Show a persistent warning using Booking Calendar's frontend message UI. */
210 function show_time_notice( $native, selection, message ) {
211 clear_time_notice( $native );
212 var $anchor = get_time_notice_anchor( selection );
213 if ( ! $anchor.length ) {
214 $anchor = $native.find( '.bk_calendar_frame' ).first();
215 }
216 if ( ! $anchor.length ) {
217 return;
218 }
219
220 selection.$start.attr( 'aria-invalid', 'true' ).nextAll( '.wpbc_times_selector' ).first().attr( 'aria-invalid', 'true' );
221 $( '<div>', {
222 'class': 'wpbc_booking_appointment__time_notice wpbc_front_end__message wpbc_fe_message wpbc_fe_message_warning',
223 role: 'alert'
224 } ).append( $( '<i>', { 'class': 'menu_icon icon-1x wpbc_icn_warning', 'aria-hidden': 'true' } ) )
225 .append( $( '<span>' ).text( message || config.validation_error || config.error ) )
226 .insertAfter( $anchor );
227 }
228
229 /** Return or initialize the validation state owned by one native form. */
230 function get_time_validation_state( $native ) {
231 var state = $native.data( 'wpbc-appointment-time-validation' );
232 if ( ! state ) {
233 state = {
234 sequence: 0,
235 signature: '',
236 status: 'incomplete',
237 request: null,
238 promise: null,
239 timer: null,
240 availability_sequence: 0,
241 availability_request: null,
242 availability_timer: null,
243 available_slots: {}
244 };
245 $native.data( 'wpbc-appointment-time-validation', state );
246 }
247 return state;
248 }
249
250 /** Restore only option state previously owned by the Appointment filter. */
251 function clear_appointment_disabled_times( $start ) {
252 $start.find( 'option[data-wpbc-appointment-unavailable="1"]' ).each( function () {
253 var $option = $( this );
254 if ( ! $option.hasClass( 'booked' ) ) {
255 $option.prop( 'disabled', false );
256 }
257 $option.removeAttr( 'data-wpbc-appointment-unavailable' );
258 } );
259 }
260
261 /** Rebuild the optional plate-style picker from the filtered native select. */
262 function refresh_start_time_picker( $start ) {
263 if ( $start.length && typeof $start.wpbc_timeselector === 'function' && $start.nextAll( '.wpbc_times_selector' ).length ) {
264 $start.wpbc_timeselector();
265 }
266 }
267
268 /** Read all options still allowed by the ordinary Booking Calendar engine. */
269 function get_core_available_start_times( $start ) {
270 var values = [];
271 $start.find( 'option' ).each( function () {
272 var $option = $( this );
273 var value = String( $option.val() || '' );
274 var appointment_disabled = '1' === String( $option.attr( 'data-wpbc-appointment-unavailable' ) || '' );
275 if ( /^\d{1,2}:\d{2}(?::\d{2})?$/.test( value ) && ( ! $option.prop( 'disabled' ) || ( appointment_disabled && ! $option.hasClass( 'booked' ) ) ) ) {
276 values.push( value );
277 }
278 } );
279 return values;
280 }
281
282 /** Convert a browser time string to minutes from the start of its day. */
283 function debug_time_to_minutes( time_value ) {
284 var parts = String( time_value || '' ).split( ':' );
285 if ( parts.length < 2 ) {
286 return null;
287 }
288 return ( Number( parts[ 0 ] ) * 60 ) + Number( parts[ 1 ] );
289 }
290
291 /** Format debug time while retaining a previous/next-day boundary. */
292 function debug_format_minutes( total_minutes ) {
293 var day_offset = 0;
294 while ( total_minutes < 0 ) {
295 total_minutes += 1440;
296 day_offset--;
297 }
298 while ( total_minutes >= 1440 ) {
299 total_minutes -= 1440;
300 day_offset++;
301 }
302 var hours = ( '0' + Math.floor( total_minutes / 60 ) ).slice( -2 );
303 var minutes = ( '0' + ( total_minutes % 60 ) ).slice( -2 );
304 return hours + ':' + minutes + ( day_offset ? ' (' + ( day_offset > 0 ? '+' : '' ) + day_offset + ' day)' : '' );
305 }
306
307 /** Explain the asynchronous Service-aware availability pass in the console. */
308 function log_start_time_filter_request( $native, dates, start_times ) {
309 if ( ! window.console || typeof window.console.info !== 'function' ) {
310 return;
311 }
312 window.console.info(
313 '[Booking Calendar Appointment] Rechecking Start Times with Service duration and buffers. The initial list comes from Provider calendar availability and may now be reduced.',
314 {
315 service_id: Number( $native.data( 'service-id' ) || 0 ),
316 provider_id: Number( $native.data( 'provider-id' ) || 0 ),
317 dates: dates.slice( 0 ),
318 initial_start_times: start_times.slice( 0 )
319 }
320 );
321 }
322
323 /** Log only scheduling data needed to understand removed Start Times. */
324 function log_start_time_filter_result( $native, dates, data ) {
325 if ( ! window.console || typeof window.console.info !== 'function' ) {
326 return;
327 }
328 var buffer_before = Number( data.buffer_before || 0 );
329 var buffer_after = Number( data.buffer_after || 0 );
330 var blocked = [];
331 Object.keys( data.slots || {} ).forEach( function ( start_time ) {
332 var result = data.slots[ start_time ];
333 if ( ! result || false !== result.valid ) {
334 return;
335 }
336 var end_time = String( result.end_time || '' );
337 var start_minutes = debug_time_to_minutes( start_time );
338 var end_minutes = debug_time_to_minutes( end_time );
339 blocked.push( {
340 start_time: start_time,
341 appointment_time: end_time ? start_time + ' - ' + end_time : start_time,
342 provider_reserved: null !== start_minutes && null !== end_minutes
343 ? debug_format_minutes( start_minutes - buffer_before ) + ' - ' + debug_format_minutes( end_minutes + buffer_after )
344 : '',
345 reason: result.message || result.code || config.validation_error
346 } );
347 } );
348
349 window.console.info(
350 '[Booking Calendar Appointment] Service-aware Start Time check completed.',
351 {
352 service_id: Number( $native.data( 'service-id' ) || 0 ),
353 provider_id: Number( $native.data( 'provider-id' ) || 0 ),
354 dates: dates.slice( 0 ),
355 duration_minutes: Number( data.duration || 0 ),
356 buffer_before_minutes: buffer_before,
357 buffer_after_minutes: buffer_after,
358 blocked_start_times: blocked.map( function ( item ) { return item.start_time; } )
359 }
360 );
361 if ( blocked.length && typeof window.console.table === 'function' ) {
362 window.console.table( blocked );
363 }
364 }
365
366 /** Apply one server response without exposing another customer's booking. */
367 function apply_available_start_times( $native, slots ) {
368 var selection = get_time_selection( $native );
369 var $start = selection.$start;
370 var selected_value = selection.start_time;
371 var selected_message = '';
372 clear_appointment_disabled_times( $start );
373
374 $start.find( 'option' ).each( function () {
375 var $option = $( this );
376 var value = String( $option.val() || '' );
377 var result = slots && slots[ value ] ? slots[ value ] : null;
378 if ( result && false === result.valid && ! $option.hasClass( 'booked' ) ) {
379 $option.prop( 'disabled', true ).attr( 'data-wpbc-appointment-unavailable', '1' );
380 if ( selected_value === value ) {
381 selected_message = result.message || config.validation_error;
382 }
383 }
384 } );
385
386 if ( selected_message ) {
387 $start.val( '' );
388 show_time_notice( $native, selection, selected_message );
389 }
390 refresh_start_time_picker( $start );
391 }
392
393 /** Filter the complete Start Time list with one bounded server request. */
394 function load_available_start_times( $native ) {
395 var state = get_time_validation_state( $native );
396 var $start = get_start_time_field( $native );
397 var dates = get_selected_dates( $native );
398 var start_times = get_core_available_start_times( $start );
399 var sequence = ++state.availability_sequence;
400
401 if ( state.availability_request && 4 !== state.availability_request.readyState ) {
402 state.availability_request.abort();
403 }
404 if ( ! $start.length || ! dates.length || ! start_times.length ) {
405 clear_appointment_disabled_times( $start );
406 refresh_start_time_picker( $start );
407 state.available_slots = {};
408 return;
409 }
410 log_start_time_filter_request( $native, dates, start_times );
411
412 state.availability_request = $.post( config.ajax_url, {
413 action: config.validate_action,
414 nonce: config.nonce,
415 service_id: Number( $native.data( 'service-id' ) || 0 ),
416 provider_id: Number( $native.data( 'provider-id' ) || 0 ),
417 context_token: String( $native.attr( 'data-appointment-context-token' ) || '' ),
418 dates: dates,
419 start_times: start_times
420 } );
421 state.availability_request.done( function ( response ) {
422 if ( sequence !== state.availability_sequence ) {
423 return;
424 }
425 var data = response && response.data ? response.data : {};
426 if ( ! response || ! response.success || ! data.slots ) {
427 return;
428 }
429 state.available_slots = data.slots;
430 log_start_time_filter_result( $native, dates, data );
431 apply_available_start_times( $native, data.slots );
432 } ).fail( function ( xhr, status ) {
433 if ( 'abort' === status || sequence !== state.availability_sequence ) {
434 return;
435 }
436 clear_appointment_disabled_times( $start );
437 refresh_start_time_picker( $start );
438 state.available_slots = {};
439 } );
440 }
441
442 /** Debounce the whole-list filter after core availability has refreshed. */
443 function schedule_available_start_times( $native ) {
444 var state = get_time_validation_state( $native );
445 window.clearTimeout( state.availability_timer );
446 state.availability_timer = window.setTimeout( function () {
447 load_available_start_times( $native );
448 }, 30 );
449 }
450
451 /** Validate current Service duration/buffers through the authoritative server. */
452 function validate_time_selection( $native ) {
453 var selection = get_time_selection( $native );
454 var state = get_time_validation_state( $native );
455 if ( ! selection.complete ) {
456 if ( state.request && 4 !== state.request.readyState ) {
457 state.request.abort();
458 }
459 state.signature = selection.signature;
460 state.status = 'incomplete';
461 state.promise = null;
462 clear_time_notice( $native );
463 return $.Deferred().resolve( false, 'incomplete' ).promise();
464 }
465 if ( selection.signature === state.signature && 'valid' === state.status ) {
466 return $.Deferred().resolve( true, 'valid' ).promise();
467 }
468 if ( selection.signature === state.signature && 'invalid' === state.status ) {
469 return $.Deferred().resolve( false, 'invalid' ).promise();
470 }
471 if ( selection.signature === state.signature && 'pending' === state.status && state.promise ) {
472 return state.promise;
473 }
474 if ( state.request && 4 !== state.request.readyState ) {
475 state.request.abort();
476 }
477
478 var deferred = $.Deferred();
479 var sequence = ++state.sequence;
480 state.signature = selection.signature;
481 state.status = 'pending';
482 state.promise = deferred.promise();
483 clear_time_notice( $native );
484 state.request = $.post( config.ajax_url, {
485 action: config.validate_action,
486 nonce: config.nonce,
487 service_id: Number( $native.data( 'service-id' ) || 0 ),
488 provider_id: Number( $native.data( 'provider-id' ) || 0 ),
489 context_token: String( $native.attr( 'data-appointment-context-token' ) || '' ),
490 dates: selection.dates,
491 start_time: selection.start_time
492 } );
493 state.request.done( function ( response ) {
494 if ( sequence !== state.sequence || selection.signature !== get_time_selection( $native ).signature ) {
495 deferred.reject( 'stale' );
496 return;
497 }
498 var data = response && response.data ? response.data : {};
499 if ( response && response.success && true === data.valid ) {
500 state.status = 'valid';
501 clear_time_notice( $native );
502 deferred.resolve( true, 'valid' );
503 return;
504 }
505 state.status = 'invalid';
506 show_time_notice( $native, selection, data.message || config.validation_error );
507 deferred.resolve( false, 'invalid' );
508 } ).fail( function ( xhr, status ) {
509 if ( 'abort' === status || sequence !== state.sequence ) {
510 deferred.reject( 'stale' );
511 return;
512 }
513 var response = xhr.responseJSON;
514 var message = response && response.data && response.data.message ? response.data.message : config.validation_error;
515 state.status = 'invalid';
516 show_time_notice( $native, selection, message );
517 deferred.resolve( false, 'invalid' );
518 } );
519
520 return state.promise;
521 }
522
523 /** Debounce validation after calendar or Start Time changes. */
524 function schedule_time_validation( $native ) {
525 var state = get_time_validation_state( $native );
526 window.clearTimeout( state.timer );
527 state.status = 'changed';
528 clear_time_notice( $native );
529 if ( ! is_time_selection_stage_active( get_time_selection( $native ) ) ) {
530 return;
531 }
532 state.timer = window.setTimeout( function () {
533 validate_time_selection( $native );
534 }, 120 );
535 }
536
537 /** Block forward wizard navigation until the current time preflight passes. */
538 function capture_wizard_navigation( event, $native ) {
539 var $button = $( event.target ).closest( '.wpbc_wizard_step_button' );
540 if ( ! $button.length || ! $.contains( $native.get( 0 ), $button.get( 0 ) ) ) {
541 return;
542 }
543 if ( $button.get( 0 ).wpbc_appointment_preflight_bypass ) {
544 $button.get( 0 ).wpbc_appointment_preflight_bypass = false;
545 return;
546 }
547
548 var target_match = String( $button.attr( 'class' ) || '' ).match( /wpbc_wizard_step_(\d+)/ );
549 var current_match = String( $button.closest( '.wpbc_wizard_step' ).attr( 'class' ) || '' ).match( /wpbc_wizard_step(\d+)/ );
550 if ( ! target_match || ( current_match && Number( target_match[1] ) <= Number( current_match[1] ) ) ) {
551 return;
552 }
553
554 var selection = get_time_selection( $native );
555 var $current_step = $button.closest( '.wpbc_wizard_step' );
556 var $time_step = selection.$start.closest( '.wpbc_wizard_step' );
557 if ( $time_step.length && $current_step.length && $time_step.get( 0 ) !== $current_step.get( 0 ) ) {
558 return;
559 }
560 if ( ! selection.complete ) {
561 return;
562 }
563 var state = get_time_validation_state( $native );
564 if ( selection.signature === state.signature && 'valid' === state.status ) {
565 return;
566 }
567
568 event.preventDefault();
569 event.stopImmediatePropagation();
570 validate_time_selection( $native ).done( function ( valid ) {
571 if ( valid && document.documentElement.contains( $button.get( 0 ) ) ) {
572 $button.get( 0 ).wpbc_appointment_preflight_bypass = true;
573 $button.get( 0 ).click();
574 }
575 } );
576 }
577
578 /** Attach isolated buffer preflight behavior to one native Appointment form. */
579 function initialize_time_preflight( $native ) {
580 if ( ! config.ajax_url || ! config.validate_action || ! $native.length ) {
581 return;
582 }
583 var native_element = $native.get( 0 );
584 if ( native_element.wpbc_appointment_time_preflight_ready ) {
585 return;
586 }
587 native_element.wpbc_appointment_time_preflight_ready = true;
588 native_element.addEventListener( 'click', function ( event ) {
589 capture_wizard_navigation( event, $native );
590 }, true );
591 $native.on( 'change.wpbc_appointment_time_preflight', '[name^="starttime"]', function () {
592 schedule_time_validation( $native );
593 } );
594 $native.on( 'date_selected.wpbc_appointment_time_preflight wpbc_hook_timeslots_disabled.wpbc_appointment_time_preflight', function ( event, provider_id ) {
595 if ( Number( provider_id || 0 ) === Number( $native.data( 'provider-id' ) || 0 ) ) {
596 schedule_available_start_times( $native );
597 schedule_time_validation( $native );
598 }
599 } );
600 schedule_available_start_times( $native );
601 }
602
603 /**
604 * Lock a native duration field to the selected Service duration.
605 *
606 * The core save handler independently derives duration from the Service row;
607 * this client step only keeps the visible form and serialized data aligned.
608 */
609 function prepare_native_form( $scope ) {
610 var $native = $scope.find( '.wpbc_booking_appointment__native_form' ).first();
611 if ( ! $native.length ) {
612 return true;
613 }
614 if ( ! register_native_form( $native ) ) {
615 return false;
616 }
617
618 var resource_id = Number( $native.data( 'provider-id' ) || 0 );
619 var duration = String( $native.data( 'duration' ) || '' );
620 var field_name = 'durationtime' + resource_id;
621 var $form = $native.find( '#booking_form' + resource_id );
622 $form.find( '.wpbc_booking_appointment__duration_proxy' ).remove();
623 var $duration_fields = $form.find( '[name="' + field_name + '"], [name="' + field_name + '[]"]' );
624 var $derived_time_fields = $form.find( '[name="endtime' + resource_id + '"], [name="endtime' + resource_id + '[]"], [name="rangetime' + resource_id + '"], [name="rangetime' + resource_id + '[]"]' );
625
626 $duration_fields.each( function () {
627 var $field = $( this );
628 if ( $field.is( 'select' ) && ! $field.find( 'option[value="' + duration.replace( /"/g, '\\"' ) + '"]' ).length ) {
629 $field.append( $( '<option>', { value: duration, text: duration } ) );
630 }
631 $field.val( duration ).prop( 'disabled', true ).attr( 'aria-disabled', 'true' );
632 $field.closest( '.wpdev-form-control-wrap' ).addClass( 'wpbc_booking_appointment__fixed_duration_field' );
633 } );
634 $derived_time_fields.each( function () {
635 var $field = $( this );
636 $field.prop( 'disabled', true ).attr( 'aria-disabled', 'true' );
637 $field.closest( '.wpdev-form-control-wrap' ).addClass( 'wpbc_booking_appointment__fixed_duration_field' );
638 } );
639
640 if ( ! $duration_fields.length ) {
641 var $duration_proxy = $( '<select>', {
642 name: field_name,
643 'class': 'wpbc_booking_appointment__duration_value',
644 'aria-hidden': 'true',
645 tabindex: '-1',
646 'data-wpbc-appointment-generated': '1'
647 } ).append( $( '<option>', { value: duration, text: duration, selected: true } ) );
648 $form.append(
649 $( '<span>', {
650 'class': 'wpdev-form-control-wrap wpbc_booking_appointment__fixed_duration_field wpbc_booking_appointment__duration_proxy',
651 'aria-hidden': 'true'
652 } ).append( $duration_proxy )
653 );
654 }
655
656 initialize_time_preflight( $native );
657
658 return true;
659 }
660
661 /** Convert a script URL to the same absolute representation as DOM script.src. */
662 function get_absolute_script_url( url ) {
663 var anchor = document.createElement( 'a' );
664 anchor.href = String( url || '' );
665 return anchor.href;
666 }
667
668 /** Execute renderer scripts sequentially while the request still owns the stage. */
669 function execute_scripts( scripts, owns_stage ) {
670 var sequence = $.Deferred().resolve().promise();
671
672 $.each( scripts, function ( index, script ) {
673 sequence = sequence.then( function () {
674 if ( ! owns_stage() ) {
675 return rejected_stage( '' );
676 }
677 if ( script.src ) {
678 var absolute_url = get_absolute_script_url( script.src );
679 if ( loaded_script_urls[ absolute_url ] ) {
680 return undefined;
681 }
682 return $.ajax( { url: absolute_url, dataType: 'script', cache: true } ).then( function () {
683 loaded_script_urls[ absolute_url ] = true;
684 } );
685 }
686 if ( script.code ) {
687 $.globalEval( script.code );
688 }
689 return undefined;
690 } );
691 } );
692
693 return sequence;
694 }
695
696 /** Initialize controls whose core handlers normally bind on document ready. */
697 function initialize_ajax_form_controls() {
698 if ( typeof window.wpbc_hook__init_booking_form_wizard_buttons === 'function' ) {
699 window.wpbc_hook__init_booking_form_wizard_buttons();
700 }
701 }
702
703 /** Destroy native calendar instances and unregister context before removal. */
704 function cleanup_native_form( $root ) {
705 $root.find( '.wpbc_booking_appointment__native_form' ).each( function () {
706 var $native = $( this );
707 var resource_id = Number( $native.data( 'provider-id' ) || 0 );
708 var $calendar = $native.find( '#calendar_booking' + resource_id );
709
710 unregister_native_form( $native );
711 if ( ! resource_id || ! $calendar.length || ! $.datepick || typeof $calendar.datepick !== 'function' ) {
712 return;
713 }
714
715 try {
716 var instance = typeof $.datepick._getInst === 'function' ? $.datepick._getInst( $calendar.get( 0 ) ) : null;
717 if ( instance ) {
718 $calendar.datepick( 'destroy' );
719 }
720 } catch ( error ) {
721 $calendar.removeClass( 'hasDatepick' );
722 }
723 } );
724 }
725
726 /** Restore the previously selected Service when navigating back one stage. */
727 function restore_service_selection( $root ) {
728 var service_id = Number( $root.attr( 'data-selected-service-id' ) || 0 );
729 if ( ! service_id ) {
730 return;
731 }
732 var $input = $root.find( '.wpbc_booking_appointment__selection_form [name="wpbc_appointment_service"][value="' + service_id + '"]' ).first();
733 if ( $input.length ) {
734 $input.prop( 'checked', true ).closest( '.wpbc_booking_appointment__choice' ).addClass( 'is-selected' );
735 }
736 }
737
738 /** Focus the new stage heading without forcing motion for reduced-motion users. */
739 function focus_stage( $root ) {
740 var $target = $root.find( '> .wpbc_booking_appointment__stage .wpbc_booking_appointment__heading h3, > .wpbc_booking_appointment__stage .wpbc_booking_appointment__notice' ).first();
741 if ( $target.length ) {
742 $target.attr( 'tabindex', '-1' );
743 try {
744 $target.get( 0 ).focus( { preventScroll: true } );
745 } catch ( error ) {
746 $target.trigger( 'focus' );
747 }
748 }
749
750 if ( $root.get( 0 ) && typeof $root.get( 0 ).scrollIntoView === 'function' ) {
751 var reduce_motion = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
752 $root.get( 0 ).scrollIntoView( { behavior: reduce_motion ? 'auto' : 'smooth', block: 'nearest' } );
753 }
754 }
755
756 /** Return a rejected promise carrying one controlled initialization message. */
757 function rejected_stage( message ) {
758 var deferred = $.Deferred();
759 deferred.reject( { wpbc_message: message } );
760 return deferred.promise();
761 }
762
763 /** Replace a stage while guaranteeing DOM-before-script initialization order. */
764 function replace_stage( $root, html, stage, provider_id, request_id ) {
765 if ( ! is_current_request( $root, request_id ) ) {
766 return rejected_stage( '' );
767 }
768 if ( 'booking' === stage && has_duplicate_provider_form( $root, provider_id ) ) {
769 return rejected_stage( config.duplicate_provider );
770 }
771
772 var parsed = $.parseHTML( String( html || '' ), document, true ) || [];
773 var scripts = [];
774 var $container = $( '<div>' ).append( parsed );
775
776 $container.find( 'script' ).addBack( 'script' ).each( function () {
777 scripts.push( { src: this.src || '', code: this.src ? '' : ( this.text || this.textContent || '' ) } );
778 $( this ).remove();
779 } );
780
781 cleanup_native_form( $root );
782 $root.attr( 'data-appointment-stage', stage );
783 $root.find( '> .wpbc_booking_appointment__stage' ).empty().append( $container.contents() );
784
785 if ( ! prepare_native_form( $root ) ) {
786 cleanup_native_form( $root );
787 $root.find( '.wpbc_booking_appointment__native_form :input' ).prop( 'disabled', true );
788 return rejected_stage( config.initialization_error || config.error );
789 }
790
791 return execute_scripts( scripts, function () {
792 return is_current_request( $root, request_id );
793 } ).then( function () {
794 if ( ! is_current_request( $root, request_id ) ) {
795 return rejected_stage( '' );
796 }
797 initialize_ajax_form_controls();
798 if ( 'service' === stage ) {
799 restore_service_selection( $root );
800 }
801 } );
802 }
803
804 /** Determine whether an AJAX callback still owns the component state. */
805 function is_current_request( $root, request_id ) {
806 return Number( $root.data( 'wpbc-appointment-request-id' ) || 0 ) === Number( request_id );
807 }
808
809 /** Finish only the current request so stale callbacks cannot alter the UI. */
810 function finish_request( $root, request_id ) {
811 if ( ! is_current_request( $root, request_id ) ) {
812 return;
813 }
814 $root.removeData( 'wpbc-appointment-request' );
815 set_loading( $root, false );
816 }
817
818 /** Request and render the next Appointment workflow stage. */
819 function resolve_stage( $root, service_id, provider_id ) {
820 if ( ! $root || ! $root.length ) {
821 return;
822 }
823
824 service_id = Number( service_id || 0 );
825 provider_id = Number( provider_id || 0 );
826 if ( service_id ) {
827 $root.attr( 'data-selected-service-id', service_id );
828 }
829 if ( provider_id ) {
830 $root.attr( 'data-selected-provider-id', provider_id );
831 }
832
833 var previous_request = $root.data( 'wpbc-appointment-request' );
834 var request_id = Number( $root.data( 'wpbc-appointment-request-id' ) || 0 ) + 1;
835 $root.data( 'wpbc-appointment-request-id', request_id );
836 if ( previous_request && previous_request.readyState !== 4 ) {
837 previous_request.abort();
838 }
839
840 clear_error( $root );
841 set_loading( $root, true );
842 var request = $.post( config.ajax_url, {
843 action: config.action,
844 nonce: config.nonce,
845 config_token: $root.attr( 'data-config-token' ) || '',
846 service_id: service_id,
847 provider_id: provider_id
848 } );
849 $root.data( 'wpbc-appointment-request', request );
850
851 request.done( function ( response ) {
852 if ( ! is_current_request( $root, request_id ) ) {
853 return;
854 }
855 if ( ! response || ! response.success || ! response.data ) {
856 show_error(
857 $root,
858 response && response.data && response.data.message ? response.data.message : config.error,
859 response && response.data ? response.data.action_url : '',
860 response && response.data ? response.data.action_label : ''
861 );
862 finish_request( $root, request_id );
863 return;
864 }
865
866 var stage = response.data.stage || '';
867 var replacement = replace_stage( $root, response.data.html, stage, response.data.provider_id, request_id );
868 replacement.done( function () {
869 if ( ! is_current_request( $root, request_id ) ) {
870 return;
871 }
872 if ( Number( response.data.service_id || 0 ) ) {
873 $root.attr( 'data-selected-service-id', Number( response.data.service_id ) );
874 }
875 if ( Number( response.data.provider_id || 0 ) ) {
876 $root.attr( 'data-selected-provider-id', Number( response.data.provider_id ) );
877 }
878 finish_request( $root, request_id );
879 focus_stage( $root );
880 } ).fail( function ( error ) {
881 if ( ! is_current_request( $root, request_id ) ) {
882 return;
883 }
884 var message = error && error.wpbc_message ? error.wpbc_message : ( config.initialization_error || config.error );
885 show_error( $root, message );
886 finish_request( $root, request_id );
887 } );
888 } ).fail( function ( xhr, status ) {
889 if ( 'abort' === status || ! is_current_request( $root, request_id ) ) {
890 return;
891 }
892 var response = xhr.responseJSON;
893 show_error(
894 $root,
895 response && response.data && response.data.message ? response.data.message : config.error,
896 response && response.data ? response.data.action_url : '',
897 response && response.data ? response.data.action_label : ''
898 );
899 finish_request( $root, request_id );
900 } );
901 }
902
903 /** Handle Service and Provider fallback forms through AJAX. */
904 $( document ).on( 'submit', '.wpbc_booking_appointment__selection_form', function ( event ) {
905 if ( ! config.ajax_url || ! config.action ) {
906 return;
907 }
908 event.preventDefault();
909 var $form = $( this );
910 var $root = $form.closest( '.wpbc_booking_appointment' );
911 resolve_stage( $root, get_selected_id( $form, 'wpbc_appointment_service' ), get_selected_id( $form, 'wpbc_appointment_provider' ) );
912 } );
913
914 /** Keep plate selection styling independent from CSS :has() support. */
915 $( document ).on( 'change', '.wpbc_booking_appointment__choice > input', function () {
916 var $input = $( this );
917 $input.closest( '.wpbc_booking_appointment__choices' ).find( '.wpbc_booking_appointment__choice' ).removeClass( 'is-selected' );
918 $input.closest( '.wpbc_booking_appointment__choice' ).addClass( 'is-selected' );
919 } );
920
921 /** Filter Service and Provider cards without changing the signed catalog. */
922 $( document ).on( 'input search', '[data-wpbc-appointment-catalog-search]', function () {
923 filter_appointment_catalog( $( this ).closest( '[data-wpbc-appointment-catalog]' ) );
924 } );
925
926 /** Return to Service selection while preserving the last valid Service. */
927 $( document ).on( 'click', '.wpbc_booking_appointment [data-appointment-back="service"]', function () {
928 var $root = $( this ).closest( '.wpbc_booking_appointment' );
929 if ( $root.hasClass( 'is-loading' ) ) {
930 return;
931 }
932 resolve_stage( $root, 0, 0 );
933 } );
934
935 /** Return to the first selectable Appointment stage without reloading the page. */
936 $( document ).on( 'click', '.wpbc_booking_appointment [data-wpbc-appointment-action="start-over"], .wpbc_booking_appointment .wpbc_booking_appointment__change', function ( event ) {
937 if ( ! config.ajax_url || ! config.action ) {
938 return;
939 }
940 event.preventDefault();
941 var $root = $( this ).closest( '.wpbc_booking_appointment' );
942 if ( $root.hasClass( 'is-loading' ) ) {
943 return;
944 }
945 $root.removeAttr( 'data-selected-service-id data-selected-provider-id' );
946 resolve_stage( $root, 0, 0 );
947 } );
948
949 /** Add the registered signed and server-authorized context to the core booking request. */
950 $( 'body' ).on( 'wpbc_before_booking_create.wpbc_booking_appointment', function ( event, resource_id, params ) {
951 var context = get_native_context( resource_id );
952 if ( ! context ) {
953 return;
954 }
955 params.service_id = context.service_id;
956 params.appointment_service_required = 1;
957 params.appointment_context_token = context.context_token;
958 params.allow_past = context.allow_past;
959 } );
960
961 /** Add the signed Appointment pair to the existing live-cost request. */
962 $( document ).on( 'wpbc_before_cost_request.wpbc_booking_appointment', function ( event, resource_id, params ) {
963 var context = get_native_context( resource_id );
964 if ( ! context || ! params ) {
965 return;
966 }
967 params.appointment_service_id = context.service_id;
968 params.appointment_context_token = context.context_token;
969 } );
970
971 $( function () {
972 $( '.wpbc_booking_appointment' ).each( function () {
973 var $root = $( this );
974 $root.find( '[data-wpbc-appointment-catalog]' ).each( function () {
975 filter_appointment_catalog( $( this ) );
976 } );
977 var $native = $root.find( '.wpbc_booking_appointment__native_form' ).first();
978 if ( $native.length ) {
979 $root.attr( 'data-selected-service-id', Number( $native.data( 'service-id' ) || 0 ) );
980 $root.attr( 'data-selected-provider-id', Number( $native.data( 'provider-id' ) || 0 ) );
981 }
982 if ( ! prepare_native_form( $root ) ) {
983 var duplicate = $native.length && has_duplicate_provider_form( $root, Number( $native.data( 'provider-id' ) || 0 ) );
984 cleanup_native_form( $root );
985 $root.find( '.wpbc_booking_appointment__native_form :input' ).prop( 'disabled', true );
986 show_error( $root, duplicate ? config.duplicate_provider : config.initialization_error );
987 }
988 } );
989 } );
990 } )( window, jQuery );
991