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

setup-wizard.js in Booking Calendar 11.9, at includes/page-setup-wizard/_src/setup-wizard.js

1,152 lines 32.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Coordinate Setup Wizard checkpoint saves, navigation, and dialogs.
3 *
4 * The server remains authoritative for field allow-lists, step order,
5 * revisions, access, and persistence. This adapter performs accessible early
6 * validation, ignores stale responses, and never applies canonical settings.
7 *
8 * @package Booking Calendar
9 */
10 ( function ( $, window, document ) {
11 'use strict';
12
13 var config = window.wpbc_setup_wizard;
14 var root = document.querySelector( '[data-wpbc-setup-wizard]' );
15 var statusNode;
16 var transition_message_node;
17 var request_error_node;
18 var request_error_message_node;
19 var request_error_reference_node;
20 var request_error_request_id_node;
21 var activeDialog = null;
22 var dialogTrigger = null;
23 var activeRequest = null;
24 var requestSequence = 0;
25 var mode_toolbar_show_timer = null;
26 var mode_toolbar_hide_timer = null;
27 var save_operation_state = null;
28 var request_phases = {
29 IDLE: 'idle',
30 REQUESTING: 'requesting',
31 REDIRECTING: 'redirecting'
32 };
33 var request_phase = request_phases.IDLE;
34 var browser_ready = false;
35 var step_adapters = [];
36
37 if ( ! config || ! root ) {
38 return;
39 }
40
41 statusNode = root.querySelector( '[data-wpbc-setup-wizard-status]' );
42 transition_message_node = root.querySelector( '[data-wpbc-setup-wizard-transition-message]' );
43 request_error_node = root.querySelector( '[data-wpbc-setup-wizard-request-error]' );
44 request_error_message_node = root.querySelector( '[data-wpbc-setup-wizard-request-error-message]' );
45 request_error_reference_node = root.querySelector( '[data-wpbc-setup-wizard-request-error-reference]' );
46 request_error_request_id_node = root.querySelector( '[data-wpbc-setup-wizard-request-error-request-id]' );
47
48 /**
49 * Set the live status message exposed to assistive technology.
50 *
51 * @param {string} message Status text.
52 * @return {void}
53 */
54 function setStatus( message ) {
55 if ( statusNode ) {
56 statusNode.textContent = message || '';
57 }
58 }
59
60 /**
61 * Return the normalized data object from a direct or jQuery AJAX response.
62 *
63 * @param {Object} response_or_xhr WordPress response or jQuery request object.
64 * @return {Object} Normalized response data.
65 */
66 function get_response_data( response_or_xhr ) {
67 var response = response_or_xhr && response_or_xhr.responseJSON
68 ? response_or_xhr.responseJSON
69 : response_or_xhr;
70
71 return response && response.data && 'object' === typeof response.data
72 ? response.data
73 : {};
74 }
75
76 /**
77 * Hide and reset the shared request error notice.
78 *
79 * @return {void}
80 */
81 function clear_request_error() {
82 if ( ! request_error_node ) {
83 return;
84 }
85
86 request_error_node.hidden = true;
87 if ( request_error_message_node ) {
88 request_error_message_node.textContent = '';
89 }
90 if ( request_error_reference_node ) {
91 request_error_reference_node.hidden = true;
92 }
93 if ( request_error_request_id_node ) {
94 request_error_request_id_node.textContent = '';
95 }
96 }
97
98 /**
99 * Show one server or transport failure in the common wizard alert region.
100 *
101 * Text is assigned through textContent so an error returned by any step cannot
102 * inject markup. The request reference helps support staff correlate a visible error
103 * with its server response without exposing internal stack details.
104 *
105 * @param {Object} response_or_xhr WordPress response or jQuery request object.
106 * @return {void}
107 */
108 function show_request_error( response_or_xhr ) {
109 var response_data = get_response_data( response_or_xhr );
110 var request_id = response_data.request_id ? String( response_data.request_id ) : '';
111
112 if ( ! request_error_node || ! request_error_message_node ) {
113 return;
114 }
115
116 request_error_message_node.textContent = getErrorMessage( response_or_xhr );
117 if ( request_error_reference_node && request_error_request_id_node ) {
118 request_error_request_id_node.textContent = request_id;
119 request_error_reference_node.hidden = ! request_id;
120 }
121
122 request_error_node.hidden = false;
123 try {
124 request_error_node.focus( { preventScroll: true } );
125 } catch ( focus_error ) {
126 request_error_node.focus();
127 }
128 try {
129 request_error_node.scrollIntoView( {
130 behavior: window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ? 'auto' : 'smooth',
131 block: 'start'
132 } );
133 } catch ( scroll_error ) {
134 request_error_node.scrollIntoView();
135 }
136 }
137
138 /**
139 * Release the server-locked forward action after browser initialization.
140 *
141 * The primary action is rendered as a disabled non-submit button so a click
142 * cannot fall through to the browser's native GET form submission before the
143 * shared handlers exist. Form-backed steps recover submit behavior only after
144 * the shared controller and dependent step scripts have finished loading.
145 *
146 * @return {void}
147 */
148 function enable_continue_when_ready() {
149 var continue_button = root.querySelector( '[data-wpbc-setup-wizard-enable-when-ready]' );
150
151 if ( ! browser_ready || request_phases.IDLE !== request_phase || ! continue_button ) {
152 return;
153 }
154
155 if ( continue_button.hasAttribute( 'data-wpbc-setup-wizard-submit-when-ready' ) ) {
156 continue_button.type = 'submit';
157 }
158 continue_button.disabled = false;
159 }
160
161 /**
162 * Mark the shared shell ready after dependent footer scripts have executed.
163 *
164 * @return {void}
165 */
166 function mark_browser_ready() {
167 browser_ready = true;
168 root.dataset.wpbcSetupWizardBrowserReady = 'true';
169 enable_continue_when_ready();
170 }
171
172 /**
173 * Defer readiness until parsing and all DOMContentLoaded listeners complete.
174 *
175 * The zero-delay task also covers optimized installations that execute the
176 * footer bundle after DOMContentLoaded while preserving dependency order.
177 *
178 * @return {void}
179 */
180 function schedule_browser_ready() {
181 var defer_ready = function () {
182 window.setTimeout( mark_browser_ready, 0 );
183 };
184
185 if ( 'loading' === document.readyState ) {
186 document.addEventListener( 'DOMContentLoaded', defer_ready, { once: true } );
187 return;
188 }
189
190 defer_ready();
191 }
192
193 /**
194 * Lock or unlock interactive wizard controls around one active request.
195 *
196 * Repeated calls with the current state are ignored so the original disabled
197 * state of every control remains available for restoration.
198 *
199 * @param {boolean} isBusy Whether a request is active.
200 * @return {void}
201 */
202 function setBusy( isBusy ) {
203 var current_busy_state = 'true' === root.getAttribute( 'aria-busy' );
204
205 if ( current_busy_state === isBusy ) {
206 return;
207 }
208
209 root.classList.toggle( 'wpbc_setup_wizard--busy', isBusy );
210 root.setAttribute( 'aria-busy', isBusy ? 'true' : 'false' );
211
212 root.querySelectorAll( 'button, input, select, textarea' ).forEach( function ( control ) {
213 if ( isBusy ) {
214 control.dataset.wpbcWasDisabled = control.disabled ? 'true' : 'false';
215 control.disabled = true;
216 } else {
217 control.disabled = 'true' === control.dataset.wpbcWasDisabled;
218 delete control.dataset.wpbcWasDisabled;
219 }
220 } );
221
222 if ( ! isBusy ) {
223 enable_continue_when_ready();
224 }
225 }
226
227 /**
228 * Update the visible message inside the blocking transition overlay.
229 *
230 * The separate live status node remains responsible for announcements to
231 * assistive technology.
232 *
233 * @param {string} message Visible transition text.
234 * @return {void}
235 */
236 function set_transition_message( message ) {
237 if ( transition_message_node ) {
238 transition_message_node.textContent = message || '';
239 }
240 }
241
242 /**
243 * Move the request controller between idle, requesting, and redirecting.
244 *
245 * Redirecting deliberately remains busy after the AJAX promise settles. The
246 * old document must stay locked until the browser replaces it.
247 *
248 * @param {string} phase One value from request_phases.
249 * @param {string} message Visible and announced progress text.
250 * @return {void}
251 */
252 function set_request_phase( phase, message ) {
253 request_phase = phase;
254 root.dataset.requestPhase = phase;
255
256 if ( request_phases.IDLE === phase ) {
257 set_transition_message( '' );
258 setBusy( false );
259 return;
260 }
261
262 set_transition_message( message );
263 setBusy( true );
264 setStatus( message );
265 }
266
267 /**
268 * Prevent links or delegated controls from acting outside the idle phase.
269 *
270 * Native form controls are disabled by setBusy(). This capture-phase guard
271 * also blocks the header links and shared product menu before their own click
272 * handlers run while the old document is waiting to be replaced.
273 *
274 * @param {MouseEvent} event Captured click event inside the wizard shell.
275 * @return {void}
276 */
277 function prevent_interaction_while_busy( event ) {
278 if ( request_phases.IDLE === request_phase ) {
279 return;
280 }
281
282 event.preventDefault();
283 event.stopImmediatePropagation();
284 }
285
286 /**
287 * Return every draft field control rendered by the current step.
288 *
289 * @return {HTMLElement[]} Ordered controls.
290 */
291 function getFieldControls() {
292 return Array.prototype.slice.call( root.querySelectorAll( '[data-wpbc-setup-wizard-field]' ) );
293 }
294
295 /**
296 * Find all controls sharing one stable field name.
297 *
298 * @param {string} fieldName Draft field name.
299 * @return {HTMLElement[]} Matching controls.
300 */
301 function findFieldControls( fieldName ) {
302 return getFieldControls().filter( function ( control ) {
303 return control.name === fieldName;
304 } );
305 }
306
307 /**
308 * Find a field-level error container without interpolating a CSS selector.
309 *
310 * @param {string} fieldName Draft field name.
311 * @return {HTMLElement|null} Matching error node.
312 */
313 function findFieldError( fieldName ) {
314 var errorNodes = root.querySelectorAll( '[data-wpbc-setup-wizard-error-for]' );
315 var matchingNode = null;
316
317 errorNodes.forEach( function ( errorNode ) {
318 if ( errorNode.dataset.wpbcSetupWizardErrorFor === fieldName ) {
319 matchingNode = errorNode;
320 }
321 } );
322
323 return matchingNode;
324 }
325
326 /**
327 * Clear an existing field error and invalid state.
328 *
329 * @param {string} fieldName Draft field name.
330 * @return {void}
331 */
332 function clearFieldError( fieldName ) {
333 var errorNode = findFieldError( fieldName );
334 var alternateControl = root.querySelector( '[data-wpbc-setup-wizard-error-control-for="' + fieldName + '"]' );
335
336 findFieldControls( fieldName ).forEach( function ( control ) {
337 control.removeAttribute( 'aria-invalid' );
338 } );
339 if ( alternateControl ) {
340 alternateControl.removeAttribute( 'aria-invalid' );
341 }
342
343 if ( errorNode ) {
344 errorNode.textContent = '';
345 }
346 }
347
348 /**
349 * Render one client- or server-provided field error.
350 *
351 * @param {string} fieldName Draft field name.
352 * @param {string} message Human-readable validation message.
353 * @return {HTMLElement|null} First invalid control.
354 */
355 function setFieldError( fieldName, message ) {
356 var controls = findFieldControls( fieldName );
357 var errorNode = findFieldError( fieldName );
358 var alternateControl = root.querySelector( '[data-wpbc-setup-wizard-error-control-for="' + fieldName + '"]' );
359
360 controls.forEach( function ( control ) {
361 control.setAttribute( 'aria-invalid', 'true' );
362 } );
363
364 if ( errorNode ) {
365 errorNode.textContent = message;
366 }
367
368 if ( alternateControl ) {
369 alternateControl.setAttribute( 'aria-invalid', 'true' );
370 }
371
372 return alternateControl || ( controls.length ? controls[ 0 ] : null );
373 }
374
375
376 /**
377 * Lock or unlock the consumer shell for an explicit module operation.
378 *
379 * @param {boolean} is_busy Whether the module operation is active.
380 * @param {string} message Accessible operation status.
381 * @return {void}
382 */
383 function set_step_adapter_busy( is_busy, message ) {
384 set_request_phase( is_busy ? request_phases.REQUESTING : request_phases.IDLE, message || '' );
385 }
386
387 /**
388 * Register one current-page editor adapter with the shared wizard shell.
389 *
390 * A step module owns its rendering, draft synchronization, and client-side
391 * validation. The shell exposes only navigation-safe services and never
392 * assumes which domain fields are present.
393 *
394 * @param {Object} step_adapter Step-owned adapter contract.
395 * @return {void}
396 */
397 function register_step_adapter( step_adapter ) {
398 if ( ! step_adapter || 'function' !== typeof step_adapter.initialize ) {
399 return;
400 }
401
402 step_adapters.push( step_adapter );
403 step_adapter.initialize( {
404 root: root,
405 config: config,
406 set_status: setStatus,
407 show_error: show_request_error,
408 clear_error: clear_request_error,
409 set_busy: set_step_adapter_busy,
410 set_field_error: setFieldError,
411 clear_field_error: clearFieldError,
412 open_dialog: openDialog
413 } );
414 }
415
416 /**
417 * Synchronize every registered step adapter before draft collection.
418 *
419 * @return {void}
420 */
421 function sync_step_adapters() {
422 step_adapters.forEach( function ( step_adapter ) {
423 if ( 'function' === typeof step_adapter.sync ) {
424 step_adapter.sync();
425 }
426 } );
427 }
428
429 /**
430 * Validate registered step modules in registration order.
431 *
432 * @return {HTMLElement|null} First invalid control, or null.
433 */
434 function validate_step_adapters() {
435 var first_invalid_control = null;
436
437 step_adapters.some( function ( step_adapter ) {
438 if ( 'function' !== typeof step_adapter.validate ) {
439 return false;
440 }
441
442 first_invalid_control = step_adapter.validate();
443 return Boolean( first_invalid_control );
444 } );
445
446 return first_invalid_control;
447 }
448
449 window.wpbc_setup_wizard_api = {
450 register_step_adapter: register_step_adapter
451 };
452
453 /**
454 * Remove every visible validation error from the current step.
455 *
456 * @return {void}
457 */
458 function clearFieldErrors() {
459 getFieldControls().forEach( function ( control ) {
460 clearFieldError( control.name );
461 } );
462
463 root.querySelectorAll( '[data-wpbc-review-step].has-error' ).forEach( function ( review_row ) {
464 review_row.classList.remove( 'has-error' );
465 } );
466 }
467
468 /**
469 * Validate required and email fields before forward navigation.
470 *
471 * Server validation remains authoritative. This check provides immediate
472 * keyboard focus and avoids a needless request for obvious omissions.
473 *
474 * @return {boolean} True when client-visible constraints pass.
475 */
476 function validateCurrentStep() {
477 var controls = getFieldControls();
478 var handledFields = {};
479 var firstInvalidControl = null;
480
481 clearFieldErrors();
482
483 firstInvalidControl = validate_step_adapters();
484 if ( firstInvalidControl ) {
485 setStatus( config.i18n.validation_error );
486 firstInvalidControl.focus();
487 return false;
488 }
489
490 controls.forEach( function ( control ) {
491 var fieldControls;
492 var hasValue;
493 var invalidMessage = '';
494
495 if ( handledFields[ control.name ] ) {
496 return;
497 }
498 handledFields[ control.name ] = true;
499 fieldControls = findFieldControls( control.name );
500
501 if ( 'radio' === control.type ) {
502 hasValue = fieldControls.some( function ( radioControl ) {
503 return radioControl.checked;
504 } );
505 } else if ( 'checkbox' === control.type ) {
506 hasValue = control.checked;
507 } else {
508 hasValue = '' !== String( control.value ).trim();
509 }
510
511 if ( control.required && ! hasValue ) {
512 invalidMessage = config.i18n.required;
513 } else if ( 'email' === control.type && hasValue && control.validity && control.validity.typeMismatch ) {
514 invalidMessage = config.i18n.invalid_email;
515 }
516
517 if ( invalidMessage ) {
518 firstInvalidControl = firstInvalidControl || setFieldError( control.name, invalidMessage );
519 }
520 } );
521
522 if ( firstInvalidControl ) {
523 setStatus( config.i18n.validation_error );
524 firstInvalidControl.focus();
525 return false;
526 }
527
528 return true;
529 }
530
531 /**
532 * Collect scalar values from the current allow-listed form controls.
533 *
534 * @return {Object} Draft field values keyed by stable field name.
535 */
536 function collectFields() {
537 var fieldValues = {};
538
539 sync_step_adapters();
540
541 getFieldControls().forEach( function ( control ) {
542 if ( 'radio' === control.type ) {
543 if ( control.checked ) {
544 fieldValues[ control.name ] = control.value;
545 }
546 return;
547 }
548
549 if ( 'checkbox' === control.type ) {
550 fieldValues[ control.name ] = control.checked ? '1' : '0';
551 return;
552 }
553
554 fieldValues[ control.name ] = control.value;
555 } );
556
557 return fieldValues;
558 }
559
560 /**
561 * Extract a safe message from a failed WordPress AJAX response.
562 *
563 * @param {Object} response_or_xhr WordPress response or jQuery request object.
564 * @return {string} Human-readable error text.
565 */
566 function getErrorMessage( response_or_xhr ) {
567 var response_data = get_response_data( response_or_xhr );
568
569 if ( response_data.message ) {
570 return String( response_data.message );
571 }
572
573 return config.i18n.error;
574 }
575
576 /**
577 * Render server-side field errors and focus the first rejected control.
578 *
579 * @param {Object} xhr jQuery request object.
580 * @return {void}
581 */
582 function renderServerErrors( xhr ) {
583 var responseData = xhr && xhr.responseJSON ? xhr.responseJSON.data : null;
584 var firstInvalidControl = null;
585 var invalid_step_row = null;
586
587 if ( ! responseData || ( ! responseData.field_errors && ! responseData.invalid_step_id ) ) {
588 return;
589 }
590
591 Object.keys( responseData.field_errors || {} ).forEach( function ( fieldName ) {
592 firstInvalidControl = firstInvalidControl || setFieldError( fieldName, String( responseData.field_errors[ fieldName ] ) );
593 } );
594
595 if ( responseData.invalid_step_id ) {
596 root.querySelectorAll( '[data-wpbc-review-step]' ).forEach( function ( review_row ) {
597 if ( review_row.dataset.wpbcReviewStep === responseData.invalid_step_id ) {
598 invalid_step_row = review_row;
599 review_row.classList.add( 'has-error' );
600 }
601 } );
602 }
603
604 if ( firstInvalidControl ) {
605 firstInvalidControl.focus();
606 } else if ( invalid_step_row ) {
607 firstInvalidControl = invalid_step_row.querySelector( '[data-wpbc-setup-wizard-edit-step]' );
608 if ( firstInvalidControl ) {
609 firstInvalidControl.focus();
610 }
611 }
612 }
613
614 /**
615 * Build a stable idempotency key for one page, revision, and field snapshot.
616 *
617 * Two browser tabs receive different entropy, while an unchanged retry after
618 * an ambiguous response reuses the same identifier. Editing a field creates
619 * a new logical operation rather than replaying the previous result.
620 *
621 * @param {Object} field_values Current scalar field snapshot.
622 * @return {string} Stable operation identifier.
623 */
624 function get_save_operation_id( field_values ) {
625 var normalized_fields = {};
626 var field_signature;
627 var entropy_values;
628 var entropy;
629 var operation_id;
630
631 Object.keys( field_values || {} ).sort().forEach( function ( field_name ) {
632 normalized_fields[ field_name ] = field_values[ field_name ];
633 } );
634 field_signature = JSON.stringify( normalized_fields );
635
636 if (
637 save_operation_state
638 && save_operation_state.step_id === config.current_step
639 && save_operation_state.revision === config.revision
640 && save_operation_state.field_signature === field_signature
641 ) {
642 return save_operation_state.operation_id;
643 }
644
645 if ( window.crypto && 'function' === typeof window.crypto.getRandomValues ) {
646 entropy_values = new Uint32Array( 2 );
647 window.crypto.getRandomValues( entropy_values );
648 entropy = entropy_values[ 0 ].toString( 36 ) + entropy_values[ 1 ].toString( 36 );
649 } else {
650 entropy = Date.now().toString( 36 ) + Math.random().toString( 36 ).slice( 2, 12 );
651 }
652
653 operation_id = (
654 'save_' +
655 String( config.current_step || '' ).replace( /[^a-z0-9_-]/gi, '' ) + '_' +
656 String( config.revision ) + '_' +
657 entropy
658 ).slice( 0, 100 );
659 save_operation_state = {
660 step_id: config.current_step,
661 revision: config.revision,
662 field_signature: field_signature,
663 operation_id: operation_id
664 };
665
666 return operation_id;
667 }
668
669 /**
670 * Send one revision-bound action and ignore aborted or stale responses.
671 *
672 * @param {string} action WordPress AJAX action.
673 * @param {Object} payload Action-specific scalar or field values.
674 * @return {void}
675 */
676 function sendRequest( action, payload ) {
677 var sequence;
678 var requestId;
679
680 requestSequence += 1;
681 sequence = requestSequence;
682 requestId = 'request_' + Date.now() + '_' + sequence;
683
684 if ( activeRequest ) {
685 activeRequest.abort();
686 }
687
688 clear_request_error();
689 set_request_phase( request_phases.REQUESTING, config.i18n.working );
690
691 activeRequest = $.ajax( {
692 url: config.ajax_url,
693 method: 'POST',
694 dataType: 'json',
695 data: $.extend( {}, payload, {
696 action: action,
697 nonce: config.nonce,
698 current_step: config.current_step,
699 expected_revision: config.revision,
700 request_id: requestId
701 } )
702 } );
703
704 activeRequest.done( function ( response ) {
705 if ( sequence !== requestSequence ) {
706 return;
707 }
708
709 if (
710 ! response ||
711 ! response.success ||
712 ! response.data ||
713 response.data.request_id !== requestId
714 ) {
715 set_request_phase( request_phases.IDLE );
716 show_request_error( response );
717 setStatus( getErrorMessage( response ) );
718 return;
719 }
720
721 if ( response.data.checkpoint ) {
722 config.current_step = response.data.checkpoint.current_step;
723 config.revision = response.data.checkpoint.revision;
724 root.dataset.revision = String( response.data.checkpoint.revision );
725 }
726
727 if ( 'string' !== typeof response.data.redirect_url || '' === response.data.redirect_url.trim() ) {
728 set_request_phase( request_phases.IDLE );
729 show_request_error( response );
730 setStatus( getErrorMessage( response ) );
731 return;
732 }
733
734 set_request_phase( request_phases.REDIRECTING, config.i18n.loading );
735
736 try {
737 window.location.assign( response.data.redirect_url );
738 } catch ( redirectError ) {
739 set_request_phase( request_phases.IDLE );
740 show_request_error( {} );
741 setStatus( config.i18n.error );
742 }
743 } );
744
745 activeRequest.fail( function ( xhr, textStatus ) {
746 if ( 'abort' === textStatus || sequence !== requestSequence ) {
747 return;
748 }
749
750 set_request_phase( request_phases.IDLE );
751 show_request_error( xhr );
752 renderServerErrors( xhr );
753 setStatus( getErrorMessage( xhr ) );
754 } );
755
756 activeRequest.always( function () {
757 if ( sequence === requestSequence ) {
758 activeRequest = null;
759 if ( request_phases.REDIRECTING !== request_phase ) {
760 set_request_phase( request_phases.IDLE );
761 }
762 }
763 } );
764 }
765
766 /**
767 * Return focusable controls currently available in a dialog.
768 *
769 * @param {HTMLElement} dialog Dialog element.
770 * @return {HTMLElement[]} Ordered focusable elements.
771 */
772 function getFocusableElements( dialog ) {
773 return Array.prototype.slice.call(
774 dialog.querySelectorAll( 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])' )
775 );
776 }
777
778 /**
779 * Open a registered confirmation dialog and transfer focus inside it.
780 *
781 * @param {string} action Dialog action identifier.
782 * @param {HTMLElement} trigger Control that opened the dialog.
783 * @return {boolean} Whether the requested dialog was opened.
784 */
785 function openDialog( action, trigger ) {
786 var backdrop = root.querySelector( '[data-wpbc-setup-wizard-dialog="' + action + '"]' );
787
788 if ( ! backdrop ) {
789 return false;
790 }
791
792 dialogTrigger = trigger;
793 activeDialog = backdrop;
794 backdrop.hidden = false;
795 document.body.classList.add( 'wpbc_setup_wizard_dialog_open' );
796
797 window.setTimeout( function () {
798 var cancelButton = backdrop.querySelector( '[data-wpbc-setup-wizard-dialog-cancel]' );
799 if ( cancelButton ) {
800 cancelButton.focus();
801 }
802 }, 0 );
803
804 return true;
805 }
806
807 /**
808 * Close the active dialog, announce its outcome, and restore trigger focus.
809 *
810 * The lifecycle event lets a domain adapter commit or roll back provisional
811 * browser state without adding domain branches to the shared shell.
812 *
813 * @param {string} outcome Either confirm or cancel.
814 * @return {void}
815 */
816 function closeDialog( outcome ) {
817 var closed_dialog;
818 var closed_action;
819 var closed_trigger;
820
821 if ( ! activeDialog ) {
822 return;
823 }
824
825 closed_dialog = activeDialog;
826 closed_action = String( closed_dialog.dataset.wpbcSetupWizardDialog || '' );
827 closed_trigger = dialogTrigger;
828 closed_dialog.hidden = true;
829 activeDialog = null;
830 document.body.classList.remove( 'wpbc_setup_wizard_dialog_open' );
831
832 if ( closed_trigger ) {
833 closed_trigger.focus();
834 }
835 dialogTrigger = null;
836
837 root.dispatchEvent( new window.CustomEvent( 'wpbc:setup-wizard-dialog-closed', {
838 detail: {
839 action: closed_action,
840 outcome: 'confirm' === outcome ? 'confirm' : 'cancel',
841 trigger: closed_trigger
842 }
843 } ) );
844 }
845
846 /**
847 * Synchronize visual selection state with native radio controls.
848 *
849 * Native radio behavior supplies Tab, Space, and arrow-key operation. The
850 * card styling is presentation only and does not determine authorization.
851 *
852 * @return {void}
853 */
854 function updateExperienceCards() {
855 root.querySelectorAll( '.wpbc_setup_wizard__experience-card' ).forEach( function ( card ) {
856 var radioControl = card.querySelector( 'input[type="radio"]' );
857 var selectedBadge = card.querySelector( '.wpbc_setup_wizard__selected-badge' );
858 var isSelected = radioControl && radioControl.checked;
859
860 card.classList.toggle( 'is-selected', Boolean( isSelected ) );
861 if ( selectedBadge ) {
862 selectedBadge.hidden = ! isSelected;
863 }
864 } );
865 }
866
867 /**
868 * Hide the Step 4 Booking Mode learning tooltip and cancel pending timers.
869 *
870 * @return {void}
871 */
872 function hide_mode_toolbar_tooltip() {
873 var toolbar = root.querySelector( '[data-wpbc-setup-wizard-mode-toolbar]' );
874 var tooltip = root.querySelector( '[data-wpbc-setup-wizard-mode-toolbar-tooltip]' );
875
876 window.clearTimeout( mode_toolbar_show_timer );
877 window.clearTimeout( mode_toolbar_hide_timer );
878 mode_toolbar_show_timer = null;
879 mode_toolbar_hide_timer = null;
880
881 if ( toolbar ) {
882 toolbar.classList.remove( 'is-previewing' );
883 }
884
885 if ( tooltip ) {
886 tooltip.classList.remove( 'is-visible' );
887 tooltip.setAttribute( 'aria-hidden', 'true' );
888 }
889 }
890
891 /**
892 * Synchronize the preview-only toolbar selector with the Step 4 radio draft.
893 *
894 * The preview deliberately avoids the released `.wpbc_booking_mode_option`
895 * hook because that control performs the canonical Booking Mode mutation.
896 *
897 * @param {string} mode_id Validated mode identifier from a radio.
898 * @param {boolean} should_animate Whether to teach the toolbar location.
899 * @return {void}
900 */
901 function update_mode_toolbar_preview( mode_id, should_animate ) {
902 var toolbar = root.querySelector( '[data-wpbc-setup-wizard-mode-toolbar]' );
903 var toolbar_label;
904 var tooltip;
905 var selected_option = null;
906 var reduce_motion;
907
908 if ( ! toolbar ) {
909 return;
910 }
911
912 toolbar_label = toolbar.querySelector( '[data-wpbc-setup-wizard-mode-toolbar-label]' );
913 tooltip = toolbar.querySelector( '[data-wpbc-setup-wizard-mode-toolbar-tooltip]' );
914 toolbar.querySelectorAll( '[data-wpbc-setup-wizard-mode-toolbar-option]' ).forEach( function ( option ) {
915 var is_selected = option.dataset.wpbcSetupWizardModeToolbarOption === mode_id;
916
917 option.classList.toggle( 'is-current', is_selected );
918 option.setAttribute( 'aria-checked', is_selected ? 'true' : 'false' );
919 if ( is_selected ) {
920 selected_option = option;
921 }
922 } );
923
924 if ( ! selected_option || ! toolbar_label ) {
925 return;
926 }
927
928 toolbar_label.textContent = selected_option.dataset.wpbcSetupWizardModeToolbarTitle || selected_option.textContent.trim();
929 if ( ! should_animate ) {
930 return;
931 }
932
933 hide_mode_toolbar_tooltip();
934 toolbar.classList.remove( 'is-previewing' );
935 void toolbar.offsetWidth;
936 toolbar.classList.add( 'is-previewing' );
937
938 reduce_motion = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;
939 mode_toolbar_show_timer = window.setTimeout( function () {
940 toolbar.classList.remove( 'is-previewing' );
941 if ( ! tooltip ) {
942 return;
943 }
944
945 tooltip.classList.add( 'is-visible' );
946 tooltip.setAttribute( 'aria-hidden', 'false' );
947 setStatus( tooltip.textContent.trim() );
948 mode_toolbar_hide_timer = window.setTimeout( hide_mode_toolbar_tooltip, 6500 );
949 }, reduce_motion ? 0 : 1900 );
950 }
951
952 root.addEventListener( 'click', prevent_interaction_while_busy, true );
953
954 root.addEventListener( 'click', function ( event ) {
955 var directionButton = event.target.closest( '[data-wpbc-setup-wizard-direction]' );
956 var edit_step_button = event.target.closest( '[data-wpbc-setup-wizard-edit-step]' );
957 var dialogButton = event.target.closest( '[data-wpbc-setup-wizard-open-dialog]' );
958 var cancelButton = event.target.closest( '[data-wpbc-setup-wizard-dialog-cancel]' );
959 var confirmButton = event.target.closest( '[data-wpbc-setup-wizard-confirm]' );
960 var mode_toolbar_option = event.target.closest( '[data-wpbc-setup-wizard-mode-toolbar-option]' );
961 var dismiss_error_button = event.target.closest( '[data-wpbc-setup-wizard-dismiss-error]' );
962 var mode_radio = null;
963 var direction;
964 var action;
965 var submitted_fields;
966
967 if ( dismiss_error_button ) {
968 event.preventDefault();
969 clear_request_error();
970 return;
971 }
972
973 if ( edit_step_button ) {
974 event.preventDefault();
975 sendRequest( config.action_edit, {
976 target_step: edit_step_button.dataset.wpbcSetupWizardEditStep
977 } );
978 return;
979 }
980
981 if ( mode_toolbar_option ) {
982 event.preventDefault();
983 root.querySelectorAll( 'input[name="booking_experience"]' ).forEach( function ( radio_control ) {
984 if ( radio_control.value === mode_toolbar_option.dataset.wpbcSetupWizardModeToolbarOption ) {
985 mode_radio = radio_control;
986 }
987 } );
988
989 if ( mode_radio && ! mode_radio.checked ) {
990 $( mode_radio ).prop( 'checked', true ).trigger( 'change' );
991 }
992 return;
993 }
994
995 if ( directionButton ) {
996 event.preventDefault();
997 direction = directionButton.dataset.wpbcSetupWizardDirection;
998 if ( 'back' === direction ) {
999 clearFieldErrors();
1000 sendRequest( config.action_back, {} );
1001 return;
1002 }
1003
1004 if ( 'next' !== direction || ! validateCurrentStep() ) {
1005 return;
1006 }
1007 clearFieldErrors();
1008 submitted_fields = collectFields();
1009 sendRequest( config.action_save_continue, {
1010 fields: submitted_fields,
1011 operation_id: get_save_operation_id( submitted_fields )
1012 } );
1013 return;
1014 }
1015
1016 if ( dialogButton ) {
1017 event.preventDefault();
1018 openDialog( dialogButton.dataset.wpbcSetupWizardOpenDialog, dialogButton );
1019 return;
1020 }
1021
1022 if ( cancelButton ) {
1023 event.preventDefault();
1024 closeDialog( 'cancel' );
1025 return;
1026 }
1027
1028 if ( confirmButton ) {
1029 event.preventDefault();
1030 action = confirmButton.dataset.wpbcSetupWizardConfirm;
1031 closeDialog( 'confirm' );
1032 if ( 'restart' === action || 'skip' === action ) {
1033 sendRequest( 'restart' === action ? config.action_restart : config.action_skip, {
1034 confirmation: action
1035 } );
1036 }
1037 return;
1038 }
1039
1040 if ( activeDialog && event.target === activeDialog ) {
1041 closeDialog( 'cancel' );
1042 }
1043 } );
1044
1045 root.addEventListener( 'submit', function ( event ) {
1046 var continueButton;
1047
1048 if ( 'wpbc-setup-wizard-step-form' !== event.target.id ) {
1049 return;
1050 }
1051
1052 event.preventDefault();
1053 continueButton = root.querySelector( '[data-wpbc-setup-wizard-direction="next"]' );
1054 if ( continueButton && ! continueButton.disabled ) {
1055 continueButton.click();
1056 }
1057 } );
1058
1059 root.addEventListener( 'change', function ( event ) {
1060 if ( event.target.matches( '[data-wpbc-setup-wizard-field]' ) ) {
1061 clearFieldError( event.target.name );
1062 }
1063
1064 if ( event.target.matches( 'input[name="booking_experience"]' ) ) {
1065 updateExperienceCards();
1066 update_mode_toolbar_preview( event.target.value, true );
1067 }
1068
1069 if ( event.target.matches( 'input[name="customer_journey"]' ) ) {
1070 updateExperienceCards();
1071 }
1072
1073 } );
1074
1075 root.addEventListener( 'input', function ( event ) {
1076 if ( event.target.matches( '[data-wpbc-setup-wizard-field]' ) ) {
1077 clearFieldError( event.target.name );
1078 }
1079 } );
1080
1081 document.addEventListener( 'keydown', function ( event ) {
1082 var dialog;
1083 var focusable;
1084 var first;
1085 var last;
1086
1087 if ( ! activeDialog ) {
1088 return;
1089 }
1090
1091 if ( 'Escape' === event.key ) {
1092 event.preventDefault();
1093 closeDialog( 'cancel' );
1094 return;
1095 }
1096
1097 if ( 'Tab' !== event.key ) {
1098 return;
1099 }
1100
1101 dialog = activeDialog.querySelector( '[role="dialog"]' );
1102 focusable = getFocusableElements( dialog );
1103 if ( ! focusable.length ) {
1104 event.preventDefault();
1105 dialog.focus();
1106 return;
1107 }
1108
1109 first = focusable[ 0 ];
1110 last = focusable[ focusable.length - 1 ];
1111 if ( event.shiftKey && document.activeElement === first ) {
1112 event.preventDefault();
1113 last.focus();
1114 } else if ( ! event.shiftKey && document.activeElement === last ) {
1115 event.preventDefault();
1116 first.focus();
1117 }
1118 } );
1119
1120 /**
1121 * Restore transient request state when the browser revives this document.
1122 *
1123 * A page restored from the back-forward cache must not retain the disabled
1124 * controls or transition overlay from a navigation that already completed.
1125 *
1126 * @param {PageTransitionEvent} event Browser page-show event.
1127 * @return {void}
1128 */
1129 function restore_transient_request_state( event ) {
1130 if ( ! event.persisted ) {
1131 return;
1132 }
1133
1134 requestSequence += 1;
1135 if ( activeRequest ) {
1136 activeRequest.abort();
1137 activeRequest = null;
1138 }
1139
1140 set_request_phase( request_phases.IDLE );
1141 setStatus( '' );
1142 }
1143
1144 window.addEventListener( 'pageshow', restore_transient_request_state );
1145
1146 updateExperienceCards();
1147 if ( root.querySelector( 'input[name="booking_experience"]:checked' ) ) {
1148 update_mode_toolbar_preview( root.querySelector( 'input[name="booking_experience"]:checked' ).value, false );
1149 }
1150 schedule_browser_ready();
1151 }( jQuery, window, document ) );
1152