PluginProbe
Booking Calendar / 11.6
Booking Calendar v11.6
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
booking / includes / booking-appointment / _src / booking-appointment.js

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

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