# booking/11.9/includes/page-setup-wizard/step-services/_src/step-services.js

Booking Calendar, version 11.9. 636 lines.

- Page: https://pluginprobe.com/plugins/booking/11.9/code/includes/page-setup-wizard/step-services/_src/step-services.js
- Raw: https://pluginprobe.com/plugins/booking/11.9/raw/includes/page-setup-wizard/step-services/_src/step-services.js
- Modified: 2026-10-03T20:32:36+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/booking/11.9/code/includes/page-setup-wizard/step-services/_src/step-services.js#L10-L20`.

```javascript
/**
 * Provide the reusable progressively saved Service editor used by Setup Wizard.
 *
 * The module owns Service presentation, local draft synchronization, media
 * preview behavior, and early validation. Persistence and authorization remain
 * server-owned. The number input remains authoritative when paired with a
 * range input so keyboard users can enter exact minute values.
 *
 * @package Booking Calendar
 */
( function ( $, window, document ) {
	'use strict';

	var module_config = window.wpbc_setup_wizard_services || { i18n: {} };

	/**
	 * Create one isolated Service editor adapter.
	 *
	 * @param {Object} config Module translations and presentation settings.
	 * @return {Object} Step adapter accepted by the shared wizard shell.
	 */
	function create_services_adapter( config ) {
		var shell_api = null;
		var root = null;
		var editor = null;

		/**
		 * Return one translated string with a safe fallback.
		 *
		 * @param {string} key      Translation key.
		 * @param {string} fallback Fallback copy.
		 * @return {string} Translation or fallback.
		 */
		function get_message( key, fallback ) {
			return config.i18n && config.i18n[ key ] ? config.i18n[ key ] : fallback;
		}

		/**
		 * Replace one string placeholder without interpreting replacement tokens.
		 *
		 * @param {string} message     Translated message containing `%s`.
		 * @param {string} replacement Plain-text replacement.
		 * @return {string} Formatted message.
		 */
		function format_message( message, replacement ) {
			return String( message ).replace( '%s', function () {
				return String( replacement );
			} );
		}

		/**
		 * Create one unsaved Service proposal using safe presentation defaults.
		 *
		 * @return {Object} JSON-safe Service proposal.
		 */
		function create_service_draft() {
			return {
				draft_id: 'draft-' + Date.now() + '-' + Math.floor( Math.random() * 100000 ),
				source_service_id: 0,
				title: get_message( 'new_service', 'New Service' ),
				description: '',
				picture_url: '',
				duration_minutes: 30,
				buffer_before_minutes: 0,
				buffer_after_minutes: 0,
				base_cost: '0.00',
				status: 'active'
			};
		}

		/**
		 * Return one editable Service field control.
		 *
		 * @param {string} field_name Stable Service DTO field.
		 * @return {HTMLElement|null} Matching field control.
		 */
		function get_service_field( field_name ) {
			return editor ? editor.node.querySelector( '[data-wpbc-service-field="' + field_name + '"]' ) : null;
		}

		/**
		 * Synchronize both explicit Service transport fields.
		 *
		 * Canonical lifecycle intent is kept separate from the visible proposal
		 * collection so omission alone can never move a Service to Draft.
		 *
		 * @return {void}
		 */
		function sync_transports() {
			if ( ! editor ) {
				return;
			}

			editor.transport.value = JSON.stringify( editor.services );
			editor.inactive_transport.value = JSON.stringify( editor.inactive_service_ids );
		}

		/**
		 * Synchronize a range input from its authoritative number control.
		 *
		 * Invalid number input is intentionally left visible for validation and is
		 * not allowed to coerce the stored draft silently.
		 *
		 * @param {string} field_name Stable numeric Service field.
		 * @return {void}
		 */
		function sync_range_from_number( field_name ) {
			var number_control = get_service_field( field_name );
			var range_control = editor && editor.node.querySelector( '[data-wpbc-service-range="' + field_name + '"]' );
			var number_value;

			if ( ! number_control || ! range_control ) {
				return;
			}

			number_value = Number( number_control.value );
			if ( isFinite( number_value ) && number_value >= Number( range_control.min ) && number_value <= Number( range_control.max ) ) {
				range_control.value = String( number_value );
			}
		}

		/**
		 * Persist the current detail controls into the in-memory draft collection.
		 *
		 * @return {void}
		 */
		function sync_selected_service() {
			var service;

			if ( ! editor || ! editor.services[ editor.selected_index ] ) {
				return;
			}

			service = editor.services[ editor.selected_index ];
			[ 'title', 'description', 'picture_url', 'duration_minutes', 'buffer_before_minutes', 'buffer_after_minutes', 'base_cost' ].forEach( function ( field_name ) {
				var field = get_service_field( field_name );
				if ( field && ! field.disabled ) {
					service[ field_name ] = field.value;
				}
			} );
			service.status = 'active';
			sync_transports();
		}

		/**
		 * Update the Service picture preview from the URL control.
		 *
		 * @return {void}
		 */
		function update_service_picture() {
			var url_field = get_service_field( 'picture_url' );
			var image = editor && editor.node.querySelector( '[data-wpbc-service-picture-image]' );
			var placeholder = editor && editor.node.querySelector( '[data-wpbc-service-picture-placeholder]' );
			var remove_button = editor && editor.node.querySelector( '[data-wpbc-remove-service-picture]' );
			var picture_url = url_field ? String( url_field.value || '' ).trim() : '';

			if ( image ) {
				image.hidden = ! picture_url;
				if ( picture_url ) {
					image.src = picture_url;
				} else {
					image.removeAttribute( 'src' );
				}
			}
			if ( placeholder ) {
				placeholder.hidden = Boolean( picture_url );
			}
			if ( remove_button ) {
				remove_button.disabled = ! picture_url;
			}
		}

		/**
		 * Format a Service duration as localized hours and minutes.
		 *
		 * The formatter is presentation-only. Canonical Service draft values remain
		 * integer minutes for validation and persistence.
		 *
		 * @param {number|string} duration_minutes Canonical duration in minutes.
		 * @return {string} Human-readable duration such as "2 hours and 3 minutes".
		 */
		function format_service_duration( duration_minutes ) {
			var parsed_minutes = Number( duration_minutes );
			var total_minutes = isFinite( parsed_minutes ) && parsed_minutes >= 0 ? Math.floor( parsed_minutes ) : 0;
			var hours = Math.floor( total_minutes / 60 );
			var minutes = total_minutes % 60;
			var duration_parts = [];
			var locale = document.documentElement.getAttribute( 'lang' ) || undefined;

			if ( window.Intl && 'function' === typeof window.Intl.NumberFormat ) {
				try {
					if ( hours ) {
						duration_parts.push( new window.Intl.NumberFormat( locale, { style: 'unit', unit: 'hour', unitDisplay: 'long' } ).format( hours ) );
					}
					if ( minutes || ! hours ) {
						duration_parts.push( new window.Intl.NumberFormat( locale, { style: 'unit', unit: 'minute', unitDisplay: 'long' } ).format( minutes ) );
					}
				} catch ( formatter_error ) {
					duration_parts = [];
				}
			}

			if ( ! duration_parts.length ) {
				if ( hours ) {
					duration_parts.push( String( hours ) + ' ' + get_message( 1 === hours ? 'hour_singular' : 'hour_plural', 1 === hours ? 'hour' : 'hours' ) );
				}
				if ( minutes || ! hours ) {
					duration_parts.push( String( minutes ) + ' ' + get_message( 1 === minutes ? 'minute_singular' : 'minute_plural', 1 === minutes ? 'minute' : 'minutes' ) );
				}
			}

			if ( 1 === duration_parts.length ) {
				return duration_parts[0];
			}

			if ( window.Intl && 'function' === typeof window.Intl.ListFormat ) {
				try {
					return new window.Intl.ListFormat( locale, { style: 'long', type: 'conjunction' } ).format( duration_parts );
				} catch ( list_formatter_error ) {
					// Fall through to the translated conjunction for older browsers.
				}
			}

			return duration_parts.join( ' ' + get_message( 'duration_joiner', 'and' ) + ' ' );
		}

		/**
		 * Update the customer-facing title and duration preview.
		 *
		 * @return {void}
		 */
		function update_service_summary() {
			var summary = editor && editor.node.querySelector( '[data-wpbc-service-summary]' );
			var title = get_service_field( 'title' );
			var duration = get_service_field( 'duration_minutes' );

			if ( summary ) {
				summary.textContent = ( title && title.value.trim() ? title.value.trim() : get_message( 'new_service', 'New Service' ) ) + ' · ' + format_service_duration( duration ? duration.value : 30 );
			}
		}

		/**
		 * Load the selected proposal into the detail controls and preview.
		 *
		 * @return {void}
		 */
		function load_selected_service() {
			var service;

			if ( ! editor ) {
				return;
			}

			service = editor.services[ editor.selected_index ];
			if ( ! service ) {
				return;
			}

			[ 'title', 'description', 'picture_url', 'duration_minutes', 'buffer_before_minutes', 'buffer_after_minutes', 'base_cost' ].forEach( function ( field_name ) {
				var field = get_service_field( field_name );
				if ( field ) {
					field.value = null === service[ field_name ] || undefined === service[ field_name ] ? '' : service[ field_name ];
				}
			} );

			[ 'duration_minutes', 'buffer_before_minutes', 'buffer_after_minutes' ].forEach( sync_range_from_number );
			update_service_picture();
			update_service_summary();
		}

		/**
		 * Render the proposal chooser from the data-only draft collection.
		 *
		 * @return {void}
		 */
		function render_service_cards() {
			if ( ! editor || ! editor.list || ! editor.template ) {
				return;
			}

			editor.list.textContent = '';
			editor.services.forEach( function ( service, service_index ) {
				var card = editor.template.content.firstElementChild.cloneNode( true );
				var radio = card.querySelector( 'input[type="radio"]' );
				var image = card.querySelector( 'img' );
				var placeholder = card.querySelector( 'i' );
				var title = card.querySelector( 'strong' );
				var duration = card.querySelector( '[data-wpbc-service-card-duration]' );
				var remove_button = card.querySelector( '[data-wpbc-remove-service]' );
				var picture_url = String( service.picture_url || '' ).trim();
				var service_title = service.title || get_message( 'new_service', 'New Service' );

				radio.value = String( service_index );
				radio.checked = service_index === editor.selected_index;
				radio.dataset.wpbcServiceSelection = String( service_index );
				card.classList.toggle( 'is-selected', radio.checked );
				title.textContent = service_title;
				duration.textContent = format_service_duration( null === service.duration_minutes || undefined === service.duration_minutes || '' === service.duration_minutes ? 30 : service.duration_minutes );
				if ( remove_button ) {
					remove_button.dataset.wpbcRemoveService = String( service_index );
					remove_button.disabled = 1 === editor.services.length;
					if ( Number( service.source_service_id ) > 0 ) {
						remove_button.setAttribute( 'aria-label', format_message( get_message( 'move_service_to_draft', 'Move %s to Draft' ), service_title ) );
						remove_button.title = format_message( get_message( 'move_service_to_draft', 'Move %s to Draft' ), service_title );
					} else {
						remove_button.setAttribute( 'aria-label', format_message( get_message( 'remove_service', 'Remove %s' ), service_title ) );
						remove_button.title = format_message( get_message( 'remove_service', 'Remove %s' ), service_title );
					}
				}
				if ( picture_url ) {
					image.src = picture_url;
					image.hidden = false;
					placeholder.hidden = true;
				} else {
					image.hidden = true;
					image.removeAttribute( 'src' );
					placeholder.hidden = false;
				}
				editor.list.appendChild( card );
			} );
		}

		/**
		 * Remove one proposal and explicitly stage a canonical Service for Draft.
		 *
		 * The required final Service cannot be removed. Canonical storage is not
		 * changed until Save; unsaved proposals have no canonical lifecycle action.
		 *
		 * @param {number} service_index Zero-based Service draft index.
		 * @return {void}
		 */
		function remove_service( service_index ) {
			var focus_target;
			var removed_service;
			var source_service_id;
			var status_message;

			if ( ! editor || ! isFinite( service_index ) || service_index !== Math.floor( service_index ) || editor.services.length <= 1 || service_index < 0 || service_index >= editor.services.length ) {
				return;
			}

			sync_selected_service();
			removed_service = editor.services[ service_index ];
			source_service_id = Number( removed_service.source_service_id || 0 );
			if ( source_service_id > 0 && -1 === editor.inactive_service_ids.indexOf( source_service_id ) ) {
				editor.inactive_service_ids.push( source_service_id );
			}
			editor.services.splice( service_index, 1 );

			if ( service_index < editor.selected_index ) {
				editor.selected_index -= 1;
			} else if ( service_index === editor.selected_index ) {
				editor.selected_index = Math.min( service_index, editor.services.length - 1 );
			}

			render_service_editor();
			shell_api.clear_field_error( 'services' );
			status_message = source_service_id > 0
				? format_message( get_message( 'service_draft_pending', '%s will move to Draft when you save.' ), removed_service.title || get_message( 'new_service', 'New Service' ) )
				: get_message( 'service_removed', 'Unsaved Service removed from this setup.' );
			shell_api.set_status( status_message );

			focus_target = editor.list.querySelector( '[data-wpbc-remove-service="' + editor.selected_index + '"]' );
			if ( focus_target && focus_target.disabled ) {
				focus_target = editor.node.querySelector( '[data-wpbc-add-service]' );
			}
			if ( focus_target ) {
				focus_target.focus();
			}
		}

		/**
		 * Refresh all Service editor presentation and its JSON transport field.
		 *
		 * @return {void}
		 */
		function render_service_editor() {
			var add_button;

			if ( ! editor ) {
				return;
			}

			editor.selected_index = Math.min( editor.selected_index, editor.services.length - 1 );
			sync_transports();
			render_service_cards();
			load_selected_service();
			add_button = editor.node.querySelector( '[data-wpbc-add-service]' );
			if ( add_button ) {
				add_button.disabled = editor.services.length >= editor.max_services;
			}
		}

		/**
		 * Handle Service editor button actions.
		 *
		 * @param {MouseEvent} event Delegated click event.
		 * @return {void}
		 */
		function handle_click( event ) {
			var add_button = event.target.closest( '[data-wpbc-add-service]' );
			var remove_service_button = event.target.closest( '[data-wpbc-remove-service]' );
			var remove_picture_button = event.target.closest( '[data-wpbc-remove-service-picture]' );

			if ( remove_service_button ) {
				event.preventDefault();
				event.stopPropagation();
				remove_service( parseInt( remove_service_button.dataset.wpbcRemoveService, 10 ) );
				return;
			}

			if ( add_button ) {
				event.preventDefault();
				if ( editor.services.length >= editor.max_services ) {
					shell_api.set_status( get_message( 'service_limit', 'The maximum number of Service drafts has been reached.' ) );
					return;
				}

				sync_selected_service();
				editor.services.push( create_service_draft() );
				editor.selected_index = editor.services.length - 1;
				render_service_editor();
				get_service_field( 'title' ).select();
				return;
			}

			if ( remove_picture_button ) {
				event.preventDefault();
				get_service_field( 'picture_url' ).value = '';
				sync_selected_service();
				update_service_picture();
				render_service_cards();
			}
		}

		/**
		 * Handle selection and committed field changes.
		 *
		 * @param {Event} event Delegated change event.
		 * @return {void}
		 */
		function handle_change( event ) {
			if ( event.target.matches( '[data-wpbc-service-selection]' ) ) {
				sync_selected_service();
				editor.selected_index = parseInt( event.target.dataset.wpbcServiceSelection, 10 );
				render_service_editor();
				return;
			}

			if ( event.target.matches( '[data-wpbc-service-field]' ) ) {
				sync_range_from_number( event.target.dataset.wpbcServiceField );
				sync_selected_service();
				update_service_picture();
				update_service_summary();
				render_service_cards();
				shell_api.clear_field_error( 'services' );
			}
		}

		/**
		 * Handle live number, range, and text edits.
		 *
		 * @param {InputEvent} event Delegated input event.
		 * @return {void}
		 */
		function handle_input( event ) {
			var field_name;
			var number_control;

			if ( event.target.matches( '[data-wpbc-service-range]' ) ) {
				field_name = event.target.dataset.wpbcServiceRange;
				number_control = get_service_field( field_name );
				if ( number_control ) {
					number_control.value = event.target.value;
				}
			} else if ( event.target.matches( '[data-wpbc-service-field]' ) ) {
				field_name = event.target.dataset.wpbcServiceField;
				sync_range_from_number( field_name );
			} else {
				return;
			}

			sync_selected_service();
			update_service_picture();
			update_service_summary();
			render_service_cards();
			shell_api.clear_field_error( 'services' );
		}

		/**
		 * Refresh the media preview after the shared WordPress picker writes a URL.
		 *
		 * @return {void}
		 */
		function handle_media_url_set() {
			if ( ! editor ) {
				return;
			}

			sync_selected_service();
			update_service_picture();
			render_service_cards();
		}

		/**
		 * Initialize this module against shell-provided services.
		 *
		 * @param {Object} registered_shell_api Shared wizard adapter services.
		 * @return {void}
		 */
		function initialize( registered_shell_api ) {
			var parsed_services;
			var parsed_inactive_service_ids;
			var editor_node;
			var transport;
			var inactive_transport;

			shell_api = registered_shell_api;
			root = shell_api.root;
			editor_node = root.querySelector( '[data-wpbc-services-editor]' );
			transport = root.querySelector( '[data-wpbc-service-draft]' );
			inactive_transport = root.querySelector( '[data-wpbc-inactive-service-ids]' );
			if ( ! editor_node || ! transport || ! inactive_transport ) {
				return;
			}

			try {
				parsed_services = JSON.parse( transport.value || '[]' );
			} catch ( parse_error ) {
				parsed_services = [];
			}
			if ( ! Array.isArray( parsed_services ) ) {
				parsed_services = [];
			}
			try {
				parsed_inactive_service_ids = JSON.parse( inactive_transport.value || '[]' );
			} catch ( inactive_parse_error ) {
				parsed_inactive_service_ids = [];
			}
			if ( ! Array.isArray( parsed_inactive_service_ids ) ) {
				parsed_inactive_service_ids = [];
			}
			parsed_inactive_service_ids = parsed_inactive_service_ids.map( Number ).filter( function ( service_id, service_index, service_ids ) {
				return isFinite( service_id ) && service_id === Math.floor( service_id ) && service_id > 0 && service_ids.indexOf( service_id ) === service_index;
			} );

			editor = {
				node: editor_node,
				transport: transport,
				inactive_transport: inactive_transport,
				list: editor_node.querySelector( '[data-wpbc-services-list]' ),
				template: root.querySelector( '[data-wpbc-service-card-template]' ),
				services: parsed_services,
				inactive_service_ids: parsed_inactive_service_ids,
				selected_index: 0,
				max_services: parseInt( transport.dataset.maxServices || '20', 10 ),
				pricing_available: 'true' === editor_node.dataset.pricingAvailable
			};

			if ( ! editor.services.length ) {
				editor.services.push( create_service_draft() );
			}

			editor.node.addEventListener( 'click', handle_click );
			editor.node.addEventListener( 'change', handle_change );
			editor.node.addEventListener( 'input', handle_input );
			$( document ).on( 'wpbc_media_upload_url_set.wpbcSetupWizardServices', '#wpbc-setup-wizard-service-picture-url', handle_media_url_set );
			render_service_editor();
		}

		/**
		 * Validate all proposed Services before forward navigation.
		 *
		 * @return {HTMLElement|null} First invalid detail control, or null.
		 */
		function validate() {
			var first_invalid = null;

			if ( ! editor || ! editor.services.length ) {
				return shell_api.set_field_error( 'services', get_message( 'service_title_required', 'Enter a title for every Service.' ) );
			}

			sync_selected_service();
			editor.services.some( function ( service, service_index ) {
				var invalid_field_name = '';
				var numeric_fields = [
					{ key: 'duration_minutes', min: 1, max: 1440 },
					{ key: 'buffer_before_minutes', min: 0, max: 1440 },
					{ key: 'buffer_after_minutes', min: 0, max: 1440 }
				];
				var invalid_number = numeric_fields.some( function ( rule ) {
					var number_value = Number( service[ rule.key ] );
					var is_invalid = ! /^\d+$/.test( String( service[ rule.key ] ) ) || number_value < rule.min || number_value > rule.max;

					if ( is_invalid ) {
						invalid_field_name = rule.key;
					}
					return is_invalid;
				} );
				var base_cost = Number( service.base_cost );

				if ( ! invalid_number && editor.pricing_available && ( ! isFinite( base_cost ) || base_cost < 0 || base_cost > 1000 ) ) {
					invalid_number = true;
					invalid_field_name = 'base_cost';
				}

				if ( ! String( service.title || '' ).trim() || invalid_number ) {
					editor.selected_index = service_index;
					render_service_editor();
					first_invalid = get_service_field( ! String( service.title || '' ).trim() ? 'title' : invalid_field_name );
					shell_api.set_field_error( 'services', ! String( service.title || '' ).trim() ? get_message( 'service_title_required', 'Enter a title for every Service.' ) : get_message( 'service_number_invalid', 'Enter a valid number within the available range.' ) );
					return true;
				}
				return false;
			} );

			return first_invalid;
		}

		return {
			initialize: initialize,
			sync: sync_selected_service,
			validate: validate
		};
	}

	window.wpbc_setup_editor_modules = window.wpbc_setup_editor_modules || {};
	window.wpbc_setup_editor_modules.services = {
		create: create_services_adapter
	};

	if ( window.wpbc_setup_wizard_api && 'function' === typeof window.wpbc_setup_wizard_api.register_step_adapter ) {
		window.wpbc_setup_wizard_api.register_step_adapter( create_services_adapter( module_config ) );
	}
}( jQuery, window, document ) );

```
